6 Commits
144 changed files with 12266 additions and 966 deletions

No files matched your search

+13 -5
View File
@@ -1,6 +1,6 @@
# MarianumMobile Client
Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. über das MarianumConnect-Backend, Nextcloud Talk + Files.
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
@@ -17,7 +17,9 @@ Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. übe
```
lib/
├── api/ HTTP-Layer pro Backend (marianumconnect/, marianumcloud/, mhsl/ Legacy, demo/)
├── session/ Session-Modell, SessionManager, SessionLifecycle, Konten-Index
├── session/ Session-Modell (CredentialSession / GuardianSession), SessionManager, SessionLifecycle
├── access/ UserRole, AccessRequirement (Gating von Modulen/Settings)
├── auth_link/ Eltern-Login: App-Link-Listener, Link-Parser, Geräte-Bindung
├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...)
├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine
├── view/ Screens
@@ -52,10 +54,16 @@ lib/
**Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc.
**Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()`. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE).
**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).
**Mehrere Konten:** Die `SessionKeys`-Slots, der MC-Token und die Group-Keychain spiegeln immer das *aktive* Konto; inaktive Konten liegen im Tresor (`accounts_index` + `account_vault_<id>`, `lib/session/account_codec.dart`). Wechseln/Hinzufügen/Entfernen nur über `SessionLifecycle` (`switchTo`, `beginAddAccount`/`finishLogin`/`cancelAddAccount`, `removeInactive`), danach `AccountBloc.activated(id)`. Lokale Daten sind pro Konto getrennt (`AccountStorage`: eigene HydratedBloc-Box; der Request-Cache `CacheStore` folgt dem aktiven Konto mit eigenem Verzeichnis); nur die Settings liegen in der globalen Box. `SessionManager().sessionEpoch` steigt bei jedem Kontowechsel – asynchrone Arbeit prüft damit, ob ihr Ergebnis noch zum aktiven Konto gehört. Was ein Konto außerhalb seines Speichers hinterlässt (Tray, Push-Schlüssel, Downloads), räumt `SessionWipe` auf. Alle Pro-Konto-Blocs gehören in `AccountScope` – er wird per Konto-ID neu aufgebaut (samt `MaterialApp`/Navigator). Push ist nur für das aktive Konto registriert.
**Rollen & Zuschnitt:** Views verzweigen nie auf Rollen. Module und Settings-Sections deklarieren `AccessRequirement`s (`nextcloud`, `guardian`; `AppModule.requirements`, `Settings._sections`); Views bekommen ein Subjekt + eine Policy, die an genau einer Stelle als pure Funktion aufgelöst wird (Vorbild: `TimetableSubject` + `TimetablePolicy.resolve`, `AbsenceFormPolicy`). `UserRole` nur für Anzeige/Policy-Ableitung.
**Stundenplan:** Ein `TimetableBloc(subject: …)` für alle Fälle (eigener Plan, Fremdplan, Kind). Den globalen Bloc stellt `PrimaryTimetableScope` bereit und tauscht ihn beim Kindwechsel aus; page-scoped Fremdpläne nutzen `ScopedTimetableBloc`. Die Kinderauswahl (`ChildSelectionCubit`) ist modulübergreifend.
**Elternbriefe:** Modul `parentLetters` (nur Eltern-Sessions, `AccessRequirement.guardian`): Lehrer-Mitteilungen aus MarianumConnect mit Gelesen-Status, Kenntnisnahme/Auswahl/Unterschrift pro Kind, Thread und Anhängen. Der Posteingang (`ParentLettersBloc`) ist global (Modul-Badge, Push-Refresh), ein Brief ist page-scoped (`ParentLetterBloc`). Ob und wie geantwortet werden darf, entscheidet der Server (`editable`); `ParentLetterFormPolicy.resolve` macht daraus das Formular. Push: Connect-Direct-Push `type: parent-letter`, Tap-Routing allein über `parentLetterId`. Alle Benachrichtigungs-Taps (lokal gerendert wie FCM) werden von `resolvePushTarget` (`lib/push/push_target.dart`) aufgelöst und in `NotificationTasks.openPushTarget` navigiert – neue Push-Ziele nur dort ergänzen.
## Build / Run
```bash
@@ -70,7 +78,7 @@ flutter test # Tests (siehe test
| Backend | Pfad | Zweck |
|---------------------------|-----------------------|----------------------------------------|
| MarianumConnect (Bearer) | `lib/api/marianumconnect/` | Auth, Stundenplan (Webuntis-Proxy), Ticker, Newsletter, Ferien, Abwesenheit, Capabilities, Push |
| 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 |
| MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events |
@@ -78,4 +86,4 @@ flutter test # Tests (siehe test
## Tests
`test/` deckt vor allem pure Funktionen ab (DateTime-Extensions, Stundenplan-Logik, Session-Codec). 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="application/*" />
</intent-filter>
<!-- Guardian login mail link (App Link). Verified via
/.well-known/assetlinks.json on both hosts. -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="connect.marianum-fulda.de" />
<data android:host="connect-beta.marianum-fulda.de" />
<data android:path="/app/guardian-login" />
</intent-filter>
<!-- The app routes links itself (GuardianLinkListener); Flutter's
built-in handling would push the path as a named route onto a
MaterialApp that only has `home`. -->
<meta-data
android:name="flutter_deeplinking_enabled"
android:value="false" />
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
+3 -1
View File
@@ -106,12 +106,14 @@ void _log(String message) => debugPrint('SHOTS: $message');
Future<void> _login(WidgetTester tester) async {
final loginVisible = await _pumpUntil(
tester,
find.byKey(const Key('login-username-field')),
find.byKey(const Key('login-audience-school')),
);
if (!loginVisible) {
_log('kein Login-Screen sichtbar – bereits angemeldet, überspringe Login');
return;
}
await tester.tap(find.byKey(const Key('login-audience-school')));
await _pumpUntil(tester, find.byKey(const Key('login-username-field')));
await tester.enterText(
find.byKey(const Key('login-username-field')),
'demo@screenshots',
+2 -8
View File
@@ -12,8 +12,6 @@ PODS:
- PhoneNumberKit/PhoneNumberKitCore (3.7.11)
- PhoneNumberKit/UIKit (3.7.11):
- PhoneNumberKit/PhoneNumberKitCore
- workmanager_apple (0.0.1):
- Flutter
DEPENDENCIES:
- eraser (from `.symlinks/plugins/eraser/ios`)
@@ -21,7 +19,6 @@ DEPENDENCIES:
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
- open_filex (from `.symlinks/plugins/open_filex/ios`)
- PhoneNumberKit (~> 3.7.6)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS:
trunk:
@@ -36,17 +33,14 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_app_badge/ios"
open_filex:
:path: ".symlinks/plugins/open_filex/ios"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"
SPEC CHECKSUMS:
eraser: 83a4b06985f3702aa3d8dec816f9693266012937
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
COCOAPODS: 1.16.2
COCOAPODS: 1.17.0
@@ -50,8 +50,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270",
"version" : "12.15.0"
"revision" : "27eaab3918e0bf78711cf1abf240577176326432",
"version" : "12.19.0"
}
},
{
@@ -59,8 +59,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64",
"version" : "3.6.0"
"revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
"version" : "3.7.0"
}
},
{
@@ -68,8 +68,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b",
"version" : "12.15.0"
"revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
"version" : "12.19.2"
}
},
{
@@ -86,8 +86,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822",
"version" : "8.1.1"
"revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
"version" : "8.1.3"
}
},
{
+2
View File
@@ -37,6 +37,8 @@
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FlutterDeepLinkingEnabled</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
+5
View File
@@ -4,6 +4,11 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:connect.marianum-fulda.de</string>
<string>applinks:connect-beta.marianum-fulda.de</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
+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/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
import '../../marianumconnect/queries/get_capabilities/guardian_child.dart';
import '../demo_persona.dart';
/// Demo fixtures for the mobile capability flags — everything granted so the
/// demo persona sees every feature (incl. push) as available and the timetable
@@ -17,6 +19,27 @@ class DemoCapabilities {
userType: 'STUDENT',
loaded: true,
);
/// Guardian persona with two children, so the child switcher is visible.
static CapabilitiesState guardianState() => const CapabilitiesState(
pushNotifications: true,
userType: 'PARENT',
children: [
GuardianChild(
id: 'demo-child-1',
firstName: DemoPersona.studentFirstName,
lastName: 'Hoffmann',
className: DemoPersona.className,
),
GuardianChild(
id: 'demo-child-2',
firstName: 'Jonas',
lastName: 'Hoffmann',
className: '6a',
),
],
loaded: true,
);
}
/// 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(),
),
];
}
}
+8
View File
@@ -22,6 +22,14 @@ class DemoMode {
static bool matches(String username) =>
username.trim().toLowerCase().startsWith(usernamePrefix);
/// Guardian logins are passwordless, so reviewers need a fixed address that
/// skips the mail round-trip. Deliberately not the `demo@` prefix, which a
/// real e-mail address could start with.
static const String guardianEmail = 'demo-eltern@marianum-fulda.de';
static bool matchesGuardian(String email) =>
email.trim().toLowerCase() == guardianEmail;
/// True while the active session is a demo session.
static bool get active => SessionManager().isDemo;
}
@@ -7,7 +7,8 @@ import 'device_token_name.dart';
import 'token_storage.dart';
/// Adds the bearer token to outgoing Marianum-Connect requests and, on 401,
/// renews the token once before retrying.
/// renews the token once before retrying. Only password accounts can renew
/// silently; passwordless accounts surface the 401.
class MarianumConnectAuthInterceptor extends Interceptor {
static const _retriedKey = 'mc_auth_retried';
@@ -74,6 +75,9 @@ class MarianumConnectAuthInterceptor extends Interceptor {
}
final refreshed = await _attemptReLogin();
if (!refreshed) {
if (SessionManager().current is GuardianSession) {
SessionManager().reportUnauthorized();
}
handler.next(err);
return;
}
@@ -4,11 +4,13 @@ import '../../../session/session.dart';
import '../../../session/session_lifecycle.dart';
import '../../../session/session_manager.dart';
import '../../errors/auth_exception.dart';
import '../queries/auth_me/auth_me.dart';
import '../queries/auth_verify/auth_verify.dart';
/// Credential probe. A server-side password rotation forces a re-login on the
/// next cold start even when the bearer token would still be accepted.
/// Another stored account then takes over.
/// Credential probe. For password accounts a server-side password rotation
/// forces a re-login on the next cold start even when the bearer token would
/// still be accepted; for guardians it confirms a rejected token before the
/// session is dropped. Another stored account then takes over.
class SessionValidator {
static Future<void> probeStored({
required Future<void> Function(String? nextAccountId) onInvalidated,
@@ -22,6 +24,8 @@ class SessionValidator {
switch (session) {
case CredentialSession(:final username, :final password):
await AuthVerify().run(username: username, password: password);
case GuardianSession():
await AuthMe().run();
}
} on AuthException catch (e) {
if (e.statusCode != 401) return;
@@ -34,6 +38,8 @@ class SessionValidator {
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(next);
@@ -6,6 +6,22 @@ import '../../errors/network_exception.dart';
import '../../errors/parse_exception.dart';
import '../../errors/server_exception.dart';
/// The error code of a rejected call: the server answers either with JSON
/// `{"error": "<code>"}` or with the plain text `Fehler: <code>` of its
/// generic error handler.
String? marianumConnectErrorCode(Object? body) {
if (body is Map) {
final code = body['error'];
return code is String ? code : null;
}
if (body is String) {
const prefix = 'Fehler: ';
return (body.startsWith(prefix) ? body.substring(prefix.length) : body)
.trim();
}
return null;
}
/// Converts a DioException raised against the Marianum-Connect API into one of
/// the app's typed AppExceptions. Keeps the dio dependency out of call sites
/// that just want to render an error message.
@@ -1,10 +1,30 @@
import '../../../errors/app_exception.dart';
import '../../marianumconnect_query.dart';
import 'absence_prefill_response.dart';
/// GETs the absence-report form prefill (`absence/prefill`, bearer-auth).
/// Guardians pass the [childId] the report is for; the identity then comes
/// from that child.
class AbsencePrefill extends MarianumConnectQuery {
AbsencePrefill({super.dio});
Future<AbsencePrefillResponse> run() =>
getObject('absence/prefill', AbsencePrefillResponse.fromJson);
Future<AbsencePrefillResponse> run({String? childId}) => getObject(
'absence/prefill',
AbsencePrefillResponse.fromJson,
queryParameters: {'childId': ?childId},
);
}
/// The prefill of a guardian's report is missing a field the form cannot ask
/// for: name and class come from the child and are not editable there, so an
/// incomplete prefill would leave a locked, unsubmittable form behind.
class AbsencePrefillIncompleteException extends AppException {
const AbsencePrefillIncompleteException({super.technicalDetails})
: super(
userMessage:
'Für dieses Kind sind in der Schulverwaltung noch nicht alle '
'Angaben hinterlegt, die für eine Abwesenheitsmeldung nötig sind. '
'Bitte wende dich an das Sekretariat.',
allowRetry: false,
);
}
@@ -3,7 +3,8 @@ import '../../marianumconnect_query.dart';
/// Submits an absence report (`POST absence`, bearer-auth, `source=mobile`).
/// Empty identity fields are backfilled from LDAP server-side; validation
/// (all fields required, class must exist, no past start date, end >= start)
/// also runs server-side and mirrors the client checks.
/// also runs server-side and mirrors the client checks. For guardians the
/// server takes name and class from the child given by [childId].
class AbsenceSubmit extends MarianumConnectQuery {
AbsenceSubmit({super.dio});
@@ -15,6 +16,7 @@ class AbsenceSubmit extends MarianumConnectQuery {
required DateTime absentUntil,
required String phone,
required String note,
String? childId,
}) => guard(() async {
await dio.post<void>(
endpoint('absence'),
@@ -26,6 +28,7 @@ class AbsenceSubmit extends MarianumConnectQuery {
'absentUntil': isoDate(absentUntil),
'phone': phone,
'note': note,
'childId': ?childId,
},
);
});
@@ -0,0 +1,60 @@
import 'package:dio/dio.dart';
import '../../../../auth_link/pending_guardian_request.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
import 'guardian_login_exception.dart';
class AuthGuardianRequestResponse {
final String requestId;
final DateTime expiresAt;
final DateTime resendAvailableAt;
final int codeLength;
const AuthGuardianRequestResponse({
required this.requestId,
required this.expiresAt,
required this.resendAvailableAt,
required this.codeLength,
});
factory AuthGuardianRequestResponse.fromJson(Map<String, dynamic> json) =>
AuthGuardianRequestResponse(
requestId: json['requestId'] as String,
expiresAt: DateTime.parse(json['expiresAt'] as String),
resendAvailableAt: DateTime.parse(json['resendAvailableAt'] as String),
codeLength:
json['codeLength'] as int? ??
PendingGuardianRequest.defaultCodeLength,
);
}
/// Starts a passwordless guardian login: the server mails a code and a link.
/// An unknown address is reported as such (`email_not_registered`) instead of
/// being answered like a known one — a deliberate trade: without it guardians
/// wait for a mail that never arrives. Registered addresses are therefore
/// enumerable; keep that in mind when changing the server contract.
class AuthGuardianRequest extends MarianumConnectQuery {
AuthGuardianRequest({Dio? dio})
: super(dio: dio ?? MarianumConnectApi.plainDio());
Future<AuthGuardianRequestResponse> run({
required String email,
required String deviceChallenge,
required String tokenName,
}) async {
try {
final response = await dio.post<Map<String, dynamic>>(
endpoint('auth/guardian/request'),
data: {
'email': email,
'deviceChallenge': deviceChallenge,
'tokenName': tokenName,
},
);
return AuthGuardianRequestResponse.fromJson(response.data!);
} on DioException catch (e) {
throw GuardianLoginException.fromDio(e, verifying: false);
}
}
}
@@ -0,0 +1,51 @@
import 'package:dio/dio.dart';
import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
import '../auth_login/auth_login_response.dart';
import 'guardian_login_exception.dart';
/// Completes a guardian login with the mailed code or link token and stores
/// the issued bearer token.
class AuthGuardianVerify extends MarianumConnectQuery {
final MarianumConnectTokenStorage _tokenStorage;
AuthGuardianVerify({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
super(dio: dio ?? MarianumConnectApi.plainDio());
Future<AuthLoginResponse> run({
required String requestId,
required String deviceVerifier,
required String tokenName,
String? code,
String? linkToken,
}) async {
assert((code == null) != (linkToken == null));
try {
final response = await dio.post<Map<String, dynamic>>(
endpoint('auth/guardian/verify'),
data: {
'requestId': requestId,
'deviceVerifier': deviceVerifier,
'tokenName': tokenName,
'code': ?code,
'linkToken': ?linkToken,
},
);
final payload = AuthLoginResponse.fromJson(response.data!);
await _tokenStorage.write(
token: payload.token,
tokenId: payload.tokenId,
expiresAt: payload.expiresAt,
);
return payload;
} on DioException catch (e) {
throw GuardianLoginException.fromDio(e);
}
}
}
@@ -0,0 +1,120 @@
import 'package:dio/dio.dart';
import '../../../errors/app_exception.dart';
import '../../errors/marianumconnect_error.dart';
enum GuardianLoginError {
invalidRequest,
emailNotRegistered,
accountDisabled,
invalidCode,
deviceMismatch,
requestConsumed,
requestExpired,
tooManyAttempts,
rateLimited,
unsupportedServer,
}
/// A rejected guardian login step with the reason the server reported.
class GuardianLoginException extends AppException {
final GuardianLoginError error;
final int? attemptsLeft;
const GuardianLoginException(
this.error, {
required super.userMessage,
this.attemptsLeft,
super.technicalDetails,
}) : super(allowRetry: false);
/// Maps a failed guardian auth call. Only 4xx answers that carry a guardian
/// login reason become [GuardianLoginException]; everything else keeps the
/// generic MarianumConnect mapping (network, 5xx, …).
///
/// [verifying] false marks the e-mail step, where no code exists yet: a bare
/// 401 (reverse proxy, gateway) must not be reported as a wrong code there.
static AppException fromDio(DioException e, {bool verifying = true}) {
final response = e.response;
final status = response?.statusCode;
if (status == null || status < 400 || status >= 500) {
return mapMarianumConnectError(e);
}
final (code, attemptsLeft) = _parseBody(response!.data);
final error = _errorFor(code, status, verifying: verifying);
if (error == null) return mapMarianumConnectError(e);
return GuardianLoginException(
error,
attemptsLeft: attemptsLeft,
userMessage: messageFor(error, attemptsLeft: attemptsLeft),
technicalDetails: 'MC $status: ${response.data}',
);
}
static String messageFor(
GuardianLoginError error, {
int? attemptsLeft,
}) => switch (error) {
GuardianLoginError.invalidRequest =>
'Bitte gib eine gültige E-Mail-Adresse ein.',
GuardianLoginError.emailNotRegistered =>
'Unter dieser E-Mail-Adresse ist kein Eltern-Zugang hinterlegt. '
'Bitte prüfe, ob die korrekte Adresse verwendet wurde. Bitte wende dich an das Sekretariat, '
'wenn du nicht weißt, welche Adresse für dich registriert ist.',
GuardianLoginError.accountDisabled =>
'Dieser Eltern-Zugang ist derzeit deaktiviert. Bitte wende dich an '
'das Sekretariat.',
GuardianLoginError.invalidCode =>
attemptsLeft == null
? 'Der Code ist falsch.'
: 'Der Code ist falsch. Noch $attemptsLeft '
'${attemptsLeft == 1 ? 'Versuch' : 'Versuche'}.',
GuardianLoginError.deviceMismatch =>
'Dieser Anmeldelink wurde auf einem anderen Gerät angefordert. '
'Bitte gib stattdessen den Code aus der E-Mail ein.',
GuardianLoginError.requestConsumed =>
'Diese Anmeldung wurde bereits verwendet. Bitte fordere einen neuen '
'Code an.',
GuardianLoginError.requestExpired =>
'Der Code ist abgelaufen. Bitte fordere einen neuen an.',
GuardianLoginError.tooManyAttempts =>
'Zu viele Fehlversuche. Bitte fordere einen neuen Code an.',
GuardianLoginError.rateLimited =>
'Zu viele Anfragen. Bitte warte einige Stunden und versuche es '
'erneut.',
GuardianLoginError.unsupportedServer =>
'Die Eltern-Anmeldung ist auf diesem Server noch nicht verfügbar. '
'Bitte versuche es später erneut.',
};
static (String?, int?) _parseBody(Object? data) {
final attempts = data is Map ? data['attemptsLeft'] : null;
return (marianumConnectErrorCode(data), attempts is int ? attempts : null);
}
static GuardianLoginError? _errorFor(
String? code,
int status, {
required bool verifying,
}) => switch (code) {
'invalid_request' => GuardianLoginError.invalidRequest,
'email_not_registered' => GuardianLoginError.emailNotRegistered,
'account_disabled' => GuardianLoginError.accountDisabled,
'invalid_code' => GuardianLoginError.invalidCode,
'device_mismatch' => GuardianLoginError.deviceMismatch,
'request_consumed' => GuardianLoginError.requestConsumed,
'request_expired' => GuardianLoginError.requestExpired,
'too_many_attempts' => GuardianLoginError.tooManyAttempts,
_ => switch (status) {
401 when verifying => GuardianLoginError.invalidCode,
// The server names an unknown address explicitly
// (`email_not_registered`); a bare 404/405 means the endpoint itself
// is missing, i.e. a server version without guardian login.
404 || 405 => GuardianLoginError.unsupportedServer,
409 => GuardianLoginError.requestConsumed,
410 => GuardianLoginError.requestExpired,
429 => GuardianLoginError.rateLimited,
_ => null,
},
};
}
@@ -6,8 +6,8 @@ import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
import '../auth_login/auth_login_response.dart';
/// Reads the user behind the stored bearer token, which also probes that the
/// token is still accepted.
/// 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.
@@ -1,5 +1,7 @@
import 'package:json_annotation/json_annotation.dart';
import 'guardian_child.dart';
part 'get_capabilities_response.g.dart';
/// Slimmed-down capability flags the mobile UI gates features on. The backend
@@ -23,16 +25,21 @@ class CapabilitiesResponse {
final int? timetableFutureDays;
/// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'). Null when the backend
/// predates the field or has no LDAP record for the user.
/// User type ('TEACHER' | 'STUDENT' | 'STAFF' | 'PARENT'). Null when the
/// backend predates the field or has no record for the user.
final String? userType;
/// Students linked to a guardian account; empty for everyone else.
@JsonKey(defaultValue: <GuardianChild>[])
final List<GuardianChild> children;
CapabilitiesResponse({
required this.viewForeignTimetables,
required this.pushNotifications,
this.timetablePastDays,
this.timetableFutureDays,
this.userType,
this.children = const [],
});
factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>
@@ -14,6 +14,11 @@ CapabilitiesResponse _$CapabilitiesResponseFromJson(
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
userType: json['userType'] as String?,
children:
(json['children'] as List<dynamic>?)
?.map((e) => GuardianChild.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
);
Map<String, dynamic> _$CapabilitiesResponseToJson(
@@ -24,4 +29,5 @@ Map<String, dynamic> _$CapabilitiesResponseToJson(
'timetablePastDays': instance.timetablePastDays,
'timetableFutureDays': instance.timetableFutureDays,
'userType': instance.userType,
'children': instance.children,
};
@@ -0,0 +1,27 @@
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,
/// Login of the child, also its Nextcloud id (avatar). Null on servers
/// that do not send it yet.
String? username,
}) = _GuardianChild;
factory GuardianChild.fromJson(Map<String, Object?> json) =>
_$GuardianChildFromJson(json);
String get displayName => '$firstName $lastName'.trim();
}
@@ -0,0 +1,301 @@
// 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;/// Login of the child, also its Nextcloud id (avatar). Null on servers
/// that do not send it yet.
String? get username;
/// 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)&&(identical(other.username, _this.username) || other.username == _this.username));
}
@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,_this.username);
}
@override
String toString() {
final _this = this as GuardianChild;
return 'GuardianChild(id: ${_this.id}, firstName: ${_this.firstName}, lastName: ${_this.lastName}, className: ${_this.className}, username: ${_this.username})';
}
}
/// @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, String? username
});
}
/// @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,Object? username = freezed,}) {
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,username: freezed == username ? _self.username : username // 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, String? username)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _GuardianChild() when $default != null:
return $default(_that.id,_that.firstName,_that.lastName,_that.className,_that.username);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, String? username) $default,) {final _that = this;
switch (_that) {
case _GuardianChild():
return $default(_that.id,_that.firstName,_that.lastName,_that.className,_that.username);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, String? username)? $default,) {final _that = this;
switch (_that) {
case _GuardianChild() when $default != null:
return $default(_that.id,_that.firstName,_that.lastName,_that.className,_that.username);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _GuardianChild extends GuardianChild {
const _GuardianChild({required this.id, required this.firstName, required this.lastName, this.className = '', this.username}): 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;
/// Login of the child, also its Nextcloud id (avatar). Null on servers
/// that do not send it yet.
@override final String? username;
/// 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)&&(identical(other.username, username) || other.username == username));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
return Object.hash(runtimeType,id,firstName,lastName,className,username);
}
@override
String toString() {
return 'GuardianChild(id: $id, firstName: $firstName, lastName: $lastName, className: $className, username: $username)';
}
}
/// @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, String? username
});
}
/// @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,Object? username = freezed,}) {
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,username: freezed == username ? _self.username : username // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
// dart format on
@@ -0,0 +1,25 @@
// 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? ?? '',
username: json['username'] as String?,
);
Map<String, dynamic> _$GuardianChildToJson(_GuardianChild instance) =>
<String, dynamic>{
'id': instance.id,
'firstName': instance.firstName,
'lastName': instance.lastName,
'className': instance.className,
'username': instance.username,
};
@@ -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';
/// Registers (upserts) this device's push subscription with MarianumConnect via
/// `PUT /api/mobile/v1/me/push-device`. The backend verifies the Nextcloud
/// device-identifier signature, stores the routing metadata and starts
/// forwarding Nextcloud pushes to this device's FCM token. Responds 204.
/// `PUT /api/mobile/v1/me/push-device`. For Nextcloud registrations the backend
/// verifies the device-identifier signature and forwards Nextcloud pushes to
/// this device's FCM token; `direct` registrations (accounts without
/// Nextcloud) carry no signature and only receive MarianumConnect pushes.
/// Responds 204.
class PushDeviceRegister extends MarianumConnectQuery {
PushDeviceRegister({super.dio});
Future<void> run({
required String deviceIdentifier,
required String deviceIdentifierSignature,
required String userPublicKey,
String? deviceIdentifierSignature,
String? userPublicKey,
required String pushToken,
required String platform,
required String registrationType,
@@ -20,12 +22,13 @@ class PushDeviceRegister extends MarianumConnectQuery {
endpoint('me/push-device'),
data: {
'deviceIdentifier': deviceIdentifier,
'deviceIdentifierSignature': deviceIdentifierSignature,
'userPublicKey': userPublicKey,
'deviceIdentifierSignature': ?deviceIdentifierSignature,
'userPublicKey': ?userPublicKey,
'pushToken': pushToken,
'platform': platform,
// 'general' | 'talk' — the backend derives the NC hash comparison
// value from it (general = sha512(token), talk = sha512(token+'#talk')).
// 'direct' — no Nextcloud subscription behind it.
'registrationType': registrationType,
'appVersion': ?appVersion,
},
@@ -26,7 +26,8 @@ class CustomEventsMigration {
const CustomEventsMigration._();
static Future<void> runOnce() async {
// Only password accounts can derive the legacy identity.
// 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;
@@ -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)},
);
}
+59 -49
View File
@@ -19,6 +19,7 @@ import 'push/push_tap_router.dart';
import 'routing/app_routes.dart';
import 'session/session_manager.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/breaker/bloc/breaker_bloc.dart';
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
@@ -26,13 +27,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/timetable/bloc/timetable_bloc.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 'utils/debouncer.dart';
import 'utils/haptics.dart';
import 'view/pages/overhang.dart';
import 'widget/breaker/breaker.dart';
import 'widget/info_dialog.dart';
import 'widget_data/widget_navigation.dart';
import 'widget_data/widget_publisher.dart';
import 'widget_data/widget_sync.dart';
class App extends StatefulWidget {
const App({super.key});
@@ -42,7 +46,6 @@ class App extends StatefulWidget {
}
class _AppState extends State<App> with WidgetsBindingObserver {
StreamSubscription<dynamic>? _timetableWidgetSync;
StreamSubscription<RemoteMessage>? _onMessageSub;
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
StreamSubscription<String>? _fcmTokenRefreshSub;
@@ -134,12 +137,43 @@ class _AppState extends State<App> with WidgetsBindingObserver {
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() {
if (!mounted) return;
final share = ShareIntentListener.pending.value;
if (share == null || ShareIntentListener.instance.isShown(share)) return;
// A second share would otherwise leave the previous share-flow page
// 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);
if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst || route is PopupRoute);
@@ -161,53 +195,13 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (!mounted) return;
context.read<BreakerBloc>().refresh();
context.read<ChatListBloc>().refresh();
// Re-mounts on every login, so this also covers post-logout state reset.
final timetable = context.read<TimetableBloc>();
timetable.refresh();
// 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();
// Debounced: a refresh or week swipe emits several times in a row, and
// only the settled state is worth mirroring.
_timetableWidgetSync = timetable.stream.listen((state) {
final data = state.data;
if (data is TimetableState && !state.isLoading) {
final epoch = SessionManager().sessionEpoch;
Debouncer.debounce(
'widgetPublish',
const Duration(seconds: 1),
() => unawaited(
WidgetPublisher.publishFromBlocState(
data,
settings: settingsCubit.val(),
isTeacher: capabilitiesCubit.isTeacher,
epoch: epoch,
),
),
);
}
// Initial publish in case hydrated storage already has data. No refresh
// needed: PrimaryTimetableScope hands out a freshly loading bloc. The
// widget still shows its last snapshot, so this waits until the
// cold-start frames are done (a bloc emit in the meantime supersedes it).
Debouncer.debounce('widgetPublish', const Duration(seconds: 3), () {
if (mounted) _publishWidget(context.read<TimetableBloc>());
});
// Initial publish in case hydrated storage already has data. The widget
// still shows its last snapshot, so this waits until the cold-start
// frames are done (a fresh bloc emit in the meantime supersedes it).
final initialData = timetable.state.data;
if (initialData is TimetableState) {
final epoch = SessionManager().sessionEpoch;
Debouncer.debounce(
'widgetPublish',
const Duration(seconds: 3),
() => unawaited(
WidgetPublisher.publishFromBlocState(
initialData,
settings: settingsCubit.val(),
isTeacher: capabilitiesCubit.isTeacher,
epoch: epoch,
),
),
);
}
unawaited(_handlePendingWidgetNavigation());
ShareIntentListener.pending.addListener(_handlePendingShare);
_handlePendingShare();
@@ -265,7 +259,6 @@ class _AppState extends State<App> with WidgetsBindingObserver {
@override
void dispose() {
_timetableWidgetSync?.cancel();
_onMessageSub?.cancel();
_onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel();
@@ -277,7 +270,24 @@ class _AppState extends State<App> with WidgetsBindingObserver {
}
@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(seconds: 1),
() {
if (mounted) _publishWidget(context.read<TimetableBloc>());
},
),
child: _buildShell(context),
);
Widget _buildShell(
BuildContext context,
) => BlocSelector<SettingsCubit, model.Settings, Object>(
// Only the module layout shapes the shell; copied values because the
+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.
}
}
}
+46 -13
View File
@@ -15,10 +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_timegrid/timetable_get_timegrid.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_response.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_publisher.dart';
import '../widget_data/widget_sync.dart';
@@ -101,11 +102,23 @@ class WidgetBackgroundTask {
return;
}
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');
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 {
@@ -126,6 +139,16 @@ bool shouldSkipRefresh({
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')
void _callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
@@ -142,7 +165,7 @@ void _callbackDispatcher() {
});
}
Future<void> _refresh() async {
Future<void> _refresh(TimetableSubject subject) async {
await WidgetSync.ensureInitialized();
// The background isolate doesn't go through main.dart's BlocBuilder, so we
// 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
// hard time budget). Reference-data failures fall through to null in the
// 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,
until: weekEndExclusive.subtract(const Duration(days: 1)),
until: until,
);
final subjectsFuture = _runOrNull<TimetableGetSubjectsResponse>(
() => TimetableGetSubjects().run(),
@@ -181,9 +206,11 @@ Future<void> _refresh() async {
final timegridFuture = _runOrNull<TimetableGetTimegridResponse>(
() => TimetableGetTimegrid().run(),
);
final customEventsFuture = _runOrNull<GetCustomTimetableEventResponse>(
() => TimetableCustomEventsGet().run(),
);
final customEventsFuture = subject.supportsCustomEvents
? _runOrNull<GetCustomTimetableEventResponse>(
() => TimetableCustomEventsGet().run(),
)
: Future<GetCustomTimetableEventResponse?>.value();
final timetable = await timetableFuture;
final subjects = await subjectsFuture;
final rooms = await roomsFuture;
@@ -193,9 +220,9 @@ Future<void> _refresh() async {
final lessons = timetable.entries;
final [connectDouble, isTeacher] = await Future.wait([
final [connectDouble, showClassInsteadOfTeacher] = await Future.wait([
WidgetSync.getConnectDoubleLessons(),
WidgetSync.getIsTeacher(),
WidgetSync.getShowClassInsteadOfTeacher(),
]);
final dayData = WidgetDataMapper.buildDayData(
now: now,
@@ -206,7 +233,7 @@ Future<void> _refresh() async {
timegrid: timegrid,
customEvents: customEvents,
connectDoubleLessons: connectDouble,
showClassInsteadOfTeacher: isTeacher,
showClassInsteadOfTeacher: showClassInsteadOfTeacher,
);
final weekData = WidgetDataMapper.buildWeekData(
now: now,
@@ -217,9 +244,15 @@ Future<void> _refresh() async {
timegrid: timegrid,
customEvents: customEvents,
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.writeWeekData(weekData);
await WidgetSync.setLoggedIn(true);
+57 -8
View File
@@ -16,6 +16,7 @@ import 'package:loader_overlay/loader_overlay.dart';
import 'package:path_provider/path_provider.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import 'access/user_role.dart';
import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart';
import 'api/marianumconnect/auth/session_validator.dart';
import 'api/marianumconnect/marianumconnect_endpoint.dart';
@@ -23,10 +24,12 @@ 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/telemetry_heartbeat/telemetry_heartbeat.dart';
import 'app.dart';
import 'auth_link/guardian_link_listener.dart';
import 'background/widget_background_task.dart';
import 'firebase_options.dart';
import 'model/session_wipe.dart';
import 'notification/notification_service.dart';
import 'push/notification_permission_prompt.dart';
import 'push/push_message_handler.dart';
import 'push/push_registration.dart';
import 'push/push_registration_store.dart';
@@ -43,13 +46,14 @@ import 'state/app/modules/app_modules.dart';
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import 'state/app/modules/chat_list/bloc/chat_list_bloc.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/timetable/bloc/timetable_bloc.dart';
import 'storage/account_storage.dart';
import 'storage/settings.dart';
import 'theming/dark_app_theme.dart';
import 'theming/light_app_theme.dart';
import 'utils/app_paths.dart';
import 'utils/debouncer.dart';
import 'utils/downloads/download_manager.dart';
import 'view/login/account_loading_screen.dart';
import 'view/login/login.dart';
@@ -163,6 +167,7 @@ Future<void> main() async {
report: false,
),
_startupStep('share intent', ShareIntentListener.instance.initialize),
_startupStep('guardian link', GuardianLinkListener.instance.initialize),
];
log('starting app initialisation...');
@@ -248,7 +253,7 @@ Future<void> main() async {
AvatarDiskCache.instance.warmUp();
// Created eagerly so the endpoint is configured before anything below can
// issue a request (the timetable bloc loads on creation).
// issue a request (the primary timetable bloc loads on creation).
final settingsCubit = SettingsCubit(storage: AccountStorage.global);
_syncMarianumConnectEndpoint(settingsCubit.state);
settingsCubit.stream.listen(_syncMarianumConnectEndpoint);
@@ -317,6 +322,8 @@ class _MainState extends State<Main> {
Jiffy.setLocale('de');
_lastStatus = context.read<AccountBloc>().state.status;
SessionManager().unauthorizedSignal.addListener(_onUnauthorized);
GuardianLinkListener.pending.addListener(_onGuardianLink);
SessionManager().waitForLoad().then((session) async {
if (!mounted) return;
final accountBloc = context.read<AccountBloc>();
@@ -331,6 +338,7 @@ class _MainState extends State<Main> {
if (!mounted) return;
accountBloc.activated(account?.id);
_scheduleSessionValidation(accountBloc);
unawaited(_onGuardianLink());
});
}
@@ -345,6 +353,7 @@ class _MainState extends State<Main> {
if (!mounted || capabilitiesCubit.isClosed) return;
_syncPush(settingsCubit, capabilitiesCubit);
_applyRoleDefaults(settingsCubit, capabilitiesCubit);
_promptGuardianNotifications();
}),
);
unawaited(scopeContext.read<NextcloudCapabilitiesCubit>().load());
@@ -363,11 +372,21 @@ class _MainState extends State<Main> {
InfoDialog.show(overlayContext, notice, title: 'Abgemeldet');
}
/// Warms the core caches (timetable, chat list, files root) in the
/// background so the first screen render hits populated data.
/// 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) {
context.read<TimetableBloc>().refresh();
unawaited(context.read<ChatListBloc>().refresh(silent: true));
unawaited(context.read<ParentLettersBloc>().refresh(silent: true));
if (SessionManager().hasNextcloud) {
unawaited(ListFilesCache.prefetchRootListing());
}
@@ -400,12 +419,40 @@ class _MainState extends State<Main> {
) {
if (AppModule.applyTeacherDefaults(
settings.val().modulesSettings,
isTeacher: capabilities.isTeacher,
isTeacher: capabilities.state.role == UserRole.teacher,
)) {
settings.val(write: true);
}
}
@override
void dispose() {
SessionManager().unauthorizedSignal.removeListener(_onUnauthorized);
GuardianLinkListener.pending.removeListener(_onGuardianLink);
super.dispose();
}
/// A guardian login link tapped while signed in belongs to an "add account"
/// flow (the app may have been killed meanwhile): reopen the login for it.
Future<void> _onGuardianLink() async {
if (GuardianLinkListener.pending.value == null || !mounted) return;
final accountBloc = context.read<AccountBloc>();
if (accountBloc.state.status != AccountStatus.loggedIn) return;
await SessionLifecycle.beginAddAccount();
accountBloc.setStatus(AccountStatus.addingAccount);
}
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
/// server-side, so the validator signs the account out and the app moves
/// on to another stored account or the login screen.
@@ -564,8 +611,10 @@ class _MainState extends State<Main> {
if (_showPostLoginSplash)
PostLoginSplash(
key: const ValueKey('post-login-splash'),
onComplete: () =>
setState(() => _showPostLoginSplash = false),
onComplete: () {
setState(() => _showPostLoginSplash = false);
_promptGuardianNotifications();
},
),
],
);
+28
View File
@@ -7,9 +7,13 @@ import 'package:flutter_app_badge/flutter_app_badge.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../push/chat_thread_store.dart';
import '../push/push_renderer.dart';
import '../push/push_target.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/parent_letters/bloc/parent_letters_bloc.dart';
import 'notification_service.dart';
class NotificationTasks {
@@ -71,6 +75,7 @@ class NotificationTasks {
/// even if the user has already left.
static void updateProviders(BuildContext context) {
context.read<ChatListBloc>().refresh();
context.read<ParentLettersBloc>().refresh(silent: true);
}
/// [title] is the notification title, when the tap came with one.
@@ -80,6 +85,13 @@ class NotificationTasks {
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):
@@ -87,10 +99,26 @@ class NotificationTasks {
}
}
/// 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
/// the matching chat to be opened automatically once the chat list view
/// resolves the token (handled inside [ChatList]).
static void navigateToTalk(BuildContext context, {String? chatToken}) {
if (!AppModule.isAvailableFor(Modules.talk, SessionManager().current)) {
return;
}
if (chatToken != null && chatToken.isNotEmpty) {
AppRoutes.openChatByToken(context, chatToken);
} 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);
}
+9 -3
View File
@@ -13,6 +13,7 @@ import '../api/marianumconnect/queries/push_device_unregister/push_device_unregi
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 'push_device_info.dart';
import 'push_keypair.dart';
@@ -65,6 +66,9 @@ class PushRegistration {
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
/// registration binds to it, so it must be obtained before registering.
Future<void> ensureAppPassword() async {
@@ -113,7 +117,7 @@ class PushRegistration {
/// fire-and-forget (and simply ignore the result).
Future<bool> register() async {
if (DemoMode.active) return false;
if (_nextcloudOrNull == null) return false;
if (_nextcloudOrNull == null) return _direct.register();
final epoch = SessionManager().sessionEpoch;
final String? fcmToken;
try {
@@ -430,7 +434,8 @@ class PushRegistration {
/// Stops pushes for the active account before another one takes over. The
/// app passwords stay valid so switching back can re-register silently.
Future<void> deactivate() async {
if (DemoMode.active || _nextcloudOrNull == null) return;
if (DemoMode.active) return;
if (_nextcloudOrNull == null) return _direct.unregister();
await unregister();
}
@@ -439,7 +444,8 @@ class PushRegistration {
/// is called with), then clear them locally. Ordered so the proxy stops
/// pushing before credentials are gone.
Future<void> logoutCleanup() async {
if (DemoMode.active || _nextcloudOrNull == null) return;
if (DemoMode.active) return;
if (_nextcloudOrNull == null) return _direct.unregister();
await unregister();
try {
await DeleteAppPassword().run();
+28 -5
View File
@@ -12,6 +12,7 @@ import 'nid_store.dart';
import 'push_actions.dart';
import 'push_avatar.dart';
import 'push_subject.dart';
import 'push_target.dart';
/// Renders decrypted push subjects (and plaintext Connect pushes) as local
/// notifications. Talk messages of one chat stack into a SINGLE
@@ -23,6 +24,8 @@ class PushRenderer {
static const talkChannelName = 'Talk-Nachrichten';
static const generalChannelId = 'nextcloud_general';
static const generalChannelName = 'Benachrichtigungen';
static const parentLettersChannelId = 'parent_letters';
static const parentLettersChannelName = 'Elternbriefe';
static const String iosTalkCategory = 'TALK_MESSAGE';
@@ -67,6 +70,14 @@ class PushRenderer {
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.
@@ -350,23 +361,35 @@ class PushRenderer {
required String body,
Map<String, String>? data,
}) async {
final id = _fallbackId('$title$body');
const androidDetails = AndroidNotificationDetails(
generalChannelId,
generalChannelName,
final parentLetterId = data?[parentLetterIdKey];
final isParentLetter =
data?['type'] == parentLetterPushType &&
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,
priority: Priority.high,
color: _accentColor,
styleInformation: isParentLetter ? BigTextStyleInformation(body) : null,
);
await _plugin.show(
id: id,
title: title,
body: body,
notificationDetails: const NotificationDetails(android: androidDetails),
notificationDetails: NotificationDetails(android: androidDetails),
payload: data == null ? null : jsonEncode(data),
);
}
static int parentLetterNotificationId(String letterId) =>
stableChatNotificationId('parent-letter:$letterId');
String _payload({required String? chatToken, required int nid}) =>
jsonEncode({'chatToken': ?chatToken, 'nid': nid});
+13
View File
@@ -1,8 +1,18 @@
/// `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);
@@ -22,6 +32,9 @@ PushTarget? resolvePushTarget(Map<String, dynamic> data) {
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);
}
+7
View File
@@ -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/roomplan/roomplan.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/diagnostics_page.dart';
import '../view/pages/settings/modules_settings_page.dart';
@@ -208,6 +209,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
/// screen. Used for deep links from outside the ticker module — inside the
/// module pages open in-place instead.
+4 -1
View File
@@ -9,7 +9,7 @@ class AccountEntry {
final String id;
final String kind;
/// Login name — the identity of the account.
/// Username or e-mail — the identity of the account.
final String label;
/// Real name as known to the server; null until it was loaded once.
@@ -31,6 +31,8 @@ class AccountEntry {
this.lastUsed = 0,
});
bool get isGuardian => kind == SessionKeys.kindGuardian;
AccountEntry copyWith({int? lastUsed, String? displayName}) => AccountEntry(
id: id,
kind: kind,
@@ -190,6 +192,7 @@ class AccountIndex {
(String kind, String label) identityOf(Session session) => switch (session) {
CredentialSession(:final username) => (SessionKeys.kindCredential, username),
GuardianSession(:final email) => (SessionKeys.kindGuardian, email),
};
/// Keychain fields of [session] including the app passwords, as stored in an
+11 -1
View File
@@ -13,7 +13,7 @@ sealed class Session {
const Session({this.isDemo = false});
/// Nextcloud identity, or null for accounts without one.
/// Nextcloud identity, or null for accounts without one (guardians).
NextcloudCredentials? get nextcloud;
}
@@ -53,6 +53,16 @@ final class CredentialSession extends Session {
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 {
+19 -3
View File
@@ -10,18 +10,25 @@ abstract final class SessionKeys {
static const loginFlow = 'nextcloud_login_flow';
static const demo = 'is_demo';
// Absent on installs from before account kinds — see [decodeSession].
// 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
/// account kinds carry no [SessionKeys.kind]; a stored username and password
/// then mean a credential session, so existing users stay signed in.
/// 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];
@@ -50,5 +57,14 @@ Map<String, String?> encodeSessionFields(Session session) => switch (session) {
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,
},
};
+4
View File
@@ -8,6 +8,7 @@ import '../api/marianumconnect/auth/auth_interceptor.dart';
import '../api/marianumconnect/auth/token_storage.dart';
import '../api/marianumconnect/queries/auth_logout/auth_logout.dart';
import '../api/marianumconnect/queries/auth_me/auth_me.dart';
import '../auth_link/guardian_link_listener.dart';
import '../background/widget_background_task.dart';
import '../model/session_wipe.dart';
import '../push/push_registration.dart';
@@ -40,6 +41,9 @@ abstract final class SessionLifecycle {
await AuthLogout().run();
final removed = await SessionManager().signOut();
SessionWipe.clearImmediate();
// A login link that arrived while signed in must not be replayed on the
// login screen that follows.
GuardianLinkListener.clear();
await SessionWipe.accountLeft();
if (removed != null) await AccountStorage.delete(removed.namespace);
+8
View File
@@ -37,6 +37,7 @@ class SessionManager {
SessionKeys.kind,
SessionKeys.username,
SessionKeys.password,
SessionKeys.guardianEmail,
SessionKeys.demo,
SessionKeys.loginFlow,
];
@@ -103,6 +104,13 @@ class SessionManager {
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());
+12 -1
View File
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import '../auth_link/guardian_login_link.dart';
import 'pending_share.dart';
/// Bridges native share intents (Android ACTION_SEND, iOS Share Extension)
@@ -136,6 +137,16 @@ class ShareIntentListener {
if (previous != null) _deleteFiles(previous);
}
/// Android hands an opened App Link (guardian login mail) to the share
/// plugin as a shared URL too; it belongs to [GuardianLinkListener] only.
@visibleForTesting
static bool isAppLink(String value) {
final uri = Uri.tryParse(value.trim());
return uri != null &&
uri.hasScheme &&
uri.path.endsWith(GuardianLoginLink.path);
}
PendingShare? _toPendingShare(List<SharedMediaFile> items) {
if (items.isEmpty) return null;
final files = <String>[];
@@ -150,7 +161,7 @@ class ShareIntentListener {
if (File(item.path).existsSync()) files.add(item.path);
case SharedMediaType.text:
case SharedMediaType.url:
texts.add(item.path);
if (!isAppLink(item.path)) texts.add(item.path);
}
}
if (files.isEmpty && texts.isEmpty) return null;
@@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../access/access_requirement.dart';
import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/errors/stale_session_exception.dart';
import '../../../../../session/session_manager.dart';
@@ -126,6 +127,13 @@ abstract class LoadableHydratedBloc<
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);
// Blocs are rebuilt per account (see AccountScope). One that briefly
// outlives its account would otherwise load with the next account's
// credentials and persist the result into its own storage.
@@ -182,7 +190,7 @@ abstract class LoadableHydratedBloc<
final SessionSingleFlight _fetchFlight = SessionSingleFlight();
void fetch() {
if (!ownsSession) return;
if (!requirementsMet || !ownsSession) return;
unawaited(
_fetchFlight.run(() {
log('Fetching data for ${TState.toString()}');
@@ -5,8 +5,10 @@ import '../breaker/bloc/breaker_bloc.dart';
import '../capabilities/bloc/capabilities_cubit.dart';
import '../chat/bloc/chat_bloc.dart';
import '../chat_list/bloc/chat_list_bloc.dart';
import '../children/child_selection_cubit.dart';
import '../nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
import '../timetable/bloc/timetable_bloc.dart';
import '../parent_letters/bloc/parent_letters_bloc.dart';
import '../timetable/primary/primary_timetable_scope.dart';
import 'bloc/account_bloc.dart';
import 'bloc/account_state.dart';
@@ -33,9 +35,12 @@ class AccountScope extends StatelessWidget {
BlocProvider<ChatBloc>(
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
),
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()),
BlocProvider<ChildSelectionCubit>(create: (_) => ChildSelectionCubit()),
BlocProvider<ParentLettersBloc>(create: (_) => ParentLettersBloc()),
],
child: _Activation(onActive: onActive, child: child),
child: PrimaryTimetableScope(
child: _Activation(onActive: onActive, child: child),
),
);
}
+44 -32
View File
@@ -1,10 +1,12 @@
import 'package:badges/badges.dart' as badges;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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 '../../../routing/app_routes.dart';
import '../../../session/session.dart';
import '../../../session/session_manager.dart';
import '../../../storage/modules_settings.dart';
import '../../../view/pages/absence_report/absence_report_view.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_message/marianum_message_list_view.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/ticker/ticker_view.dart';
import '../../../view/pages/timetable/timetable.dart';
import '../../../widget/breaker/breaker.dart';
import '../../../widget/centered_leading.dart';
import '../../../widget/module_badge_icon.dart';
import '../infrastructure/loadable_state/loadable_state.dart';
import 'chat_list/bloc/chat_list_bloc.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';
class AppModule {
@@ -38,6 +44,17 @@ class AppModule {
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(
BuildContext context, {
bool showFiltered = false,
@@ -51,6 +68,18 @@ class AppModule {
breakerArea: BreakerArea.timetable,
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,
name: 'Ticker',
@@ -64,34 +93,15 @@ class AppModule {
Modules.talk,
name: 'Talk',
icon: () => BlocBuilder<ChatListBloc, LoadableState<ChatListState>>(
builder: (context, state) {
final rooms = state.data?.rooms;
if (rooms == null || rooms.data.isEmpty) {
return const Icon(Icons.chat);
}
final messages = rooms.data
.map((e) => e.unreadMessages)
.reduce((a, b) => a + b);
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),
);
},
builder: (context, state) => ModuleBadgeIcon(
icon: Icons.chat,
count:
state.data?.rooms?.data.fold<int>(
0,
(sum, room) => sum + room.unreadMessages,
) ??
0,
),
),
breakerArea: BreakerArea.talk,
create: ChatList.new,
@@ -146,6 +156,9 @@ class AppModule {
),
};
final session = SessionManager().current;
available.removeWhere((key, _) => !isAvailableFor(key, session));
if (!showFiltered) {
available.removeWhere(
(key, value) =>
@@ -177,9 +190,7 @@ class AppModule {
for (final missing in Modules.values) {
if (!seen.add(missing)) continue;
var insertAt = 0;
for (final predecessor in Modules.values.takeWhile(
(m) => m != missing,
)) {
for (final predecessor in Modules.values.takeWhile((m) => m != missing)) {
final pos = order.indexOf(predecessor);
if (pos >= insertAt) insertAt = pos + 1;
}
@@ -306,6 +317,7 @@ class AppModule {
enum Modules {
timetable,
parentLetters,
ticker,
talk,
files,
@@ -5,6 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../api/demo/data/demo_capabilities.dart';
import '../../../../../api/demo/demo_mode.dart';
import '../../../../../api/marianumconnect/queries/get_capabilities/get_capabilities.dart';
import '../../../../../session/session.dart';
import '../../../../../session/session_manager.dart';
import 'capabilities_state.dart';
@@ -22,17 +23,18 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
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
/// live, network error, 4xx) the previously hydrated flags are kept but the
/// state is marked `loaded` — a failed fetch never silently grants a
/// capability, and an offline launch keeps whatever was cached.
/// state is marked `loaded` and `loadFailed` — a failed fetch never silently
/// 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 {
if (DemoMode.active) {
emit(DemoCapabilities.state());
emit(
SessionManager().current is GuardianSession
? DemoCapabilities.guardianState()
: DemoCapabilities.state(),
);
return;
}
final epoch = SessionManager().sessionEpoch;
@@ -48,12 +50,13 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
timetablePastDays: response.timetablePastDays,
timetableFutureDays: response.timetableFutureDays,
userType: response.userType,
children: response.children,
loaded: true,
),
);
} catch (e) {
log('Failed to load capabilities: $e');
emit(state.copyWith(loaded: true));
emit(state.copyWith(loaded: true, loadFailed: true));
}
}
@@ -1,10 +1,15 @@
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.g.dart';
@freezed
abstract class CapabilitiesState with _$CapabilitiesState {
const CapabilitiesState._();
const factory CapabilitiesState({
@Default(false) bool viewForeignTimetables,
@Default(false) bool pushNotifications,
@@ -12,14 +17,24 @@ abstract class CapabilitiesState with _$CapabilitiesState {
// client-side clamp; the (server-narrowed) school year alone governs.
int? timetablePastDays,
int? timetableFutureDays,
// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown.
// Wire value of the user type; read it through [role].
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
// observed at least once this session. Lets the UI distinguish "still
// unknown" from "confirmed not allowed".
@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;
factory CapabilitiesState.fromJson(Map<String, Object?> json) =>
_$CapabilitiesStateFromJson(json);
UserRole get role => UserRole.parse(userType);
}
@@ -1,6 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// 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
part of 'capabilities_state.dart';
@@ -9,19 +9,14 @@ part of 'capabilities_state.dart';
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$CapabilitiesState {
bool get viewForeignTimetables; bool get pushNotifications;// Days into the past/future the timetable may be scrolled. Null = no
// 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;
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;
/// Create a copy of CapabilitiesState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -34,16 +29,21 @@ $CapabilitiesStateCopyWith<CapabilitiesState> get copyWith => _$CapabilitiesStat
@override
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)
@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
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;
@useResult
$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
/// 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,}) {
return _then(_self.copyWith(
@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(CapabilitiesState(
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,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?,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,
));
}
@@ -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) {
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();
}
@@ -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) {
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');
}
@@ -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) {
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;
}
@@ -219,22 +221,24 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timeta
/// @nodoc
@JsonSerializable()
class _CapabilitiesState implements CapabilitiesState {
const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.timetablePastDays, this.timetableFutureDays, this.userType, this.loaded = false});
class _CapabilitiesState extends CapabilitiesState {
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);
@override@JsonKey() final bool viewForeignTimetables;
@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? timetableFutureDays;
// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown.
@override final String? 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".
final List<GuardianChild> _children;
@override@JsonKey() List<GuardianChild> get children {
if (_children is EqualUnmodifiableListView) return _children;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_children);
}
@override@JsonKey() final bool loaded;
@override@JsonKey(includeToJson: false, includeFromJson: false) final bool loadFailed;
/// Create a copy of CapabilitiesState
/// with the given fields replaced by the non-null parameter values.
@@ -249,16 +253,18 @@ Map<String, dynamic> toJson() {
@override
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)
@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
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;
@override @useResult
$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
/// 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(
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,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?,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,
));
}
@@ -13,6 +13,11 @@ _CapabilitiesState _$CapabilitiesStateFromJson(Map<String, dynamic> json) =>
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
userType: json['userType'] as String?,
children:
(json['children'] as List<dynamic>?)
?.map((e) => GuardianChild.fromJson(e as Map<String, dynamic>))
.toList() ??
const <GuardianChild>[],
loaded: json['loaded'] as bool? ?? false,
);
@@ -23,5 +28,6 @@ Map<String, dynamic> _$CapabilitiesStateToJson(_CapabilitiesState instance) =>
'timetablePastDays': instance.timetablePastDays,
'timetableFutureDays': instance.timetableFutureDays,
'userType': instance.userType,
'children': instance.children,
'loaded': instance.loaded,
};
@@ -4,6 +4,7 @@ import 'dart:developer';
import 'package:flutter_app_badge/flutter_app_badge.dart';
import '../../../../../access/access_requirement.dart';
import '../../../../../api/marianumcloud/talk/actions/talk_actions.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
@@ -53,6 +54,11 @@ class ChatListBloc
});
}
@override
Set<AccessRequirement> get requirements => const {
AccessRequirement.nextcloud,
};
@override
ChatListRepository repository() => ChatListRepository();
@@ -88,7 +94,7 @@ class ChatListBloc
/// Concurrent callers (tab switch, poll, push, resume) join the running
/// refresh instead of each fetching and re-emitting the full room list.
Future<void> refresh({bool renew = true, bool silent = false}) async {
if (!ownsSession) return;
if (!requirementsMet || !ownsSession) return;
await _refreshFlight.run(() => _refresh(renew: renew, silent: silent));
}
@@ -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,187 +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: reference.mondayOfWeek,
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(reference.mondayOfWeek, _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.withReferenceData(
rooms: rooms,
subjects: subjects,
schoolHolidays: schoolHolidays,
schoolyear: schoolyear,
),
),
);
} catch (e) {
onError?.call(e);
}
try {
final timegrid = await repo.data.getTimegrid();
add(Emit((s) => s.withReferenceData(timegrid: timegrid)));
} 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) {
add(Emit((s) => s.withFetchedWeek(weekStart, week)));
}
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;
}
@@ -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();
@@ -0,0 +1,53 @@
import 'dart:typed_data';
import '../../../../../api/demo/data/demo_parent_letters.dart';
import '../../../../../api/demo/demo_mode.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/get_parent_letter.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/get_parent_letter_attachment.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/post_parent_letter_thread_message.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/submit_parent_letter_response.dart';
import '../../../infrastructure/repository/repository.dart';
import '../bloc/parent_letter_state.dart';
/// Split from [ParentLettersRepository] because the loadable base binds a
/// repository to its state type. Writes never reach this class in demo mode
/// (the views guard them).
class ParentLetterRepository extends Repository<ParentLetterState> {
Future<ParentLetterDetail> getLetter(String letterId) {
if (DemoMode.active) {
return Future.value(DemoParentLetters.detail(letterId));
}
return GetParentLetter().run(letterId);
}
Future<ParentLetterDetail> submitResponse({
required String letterId,
required String childId,
required List<ParentLetterAnswer> answers,
Uint8List? signaturePng,
}) => SubmitParentLetterResponse().run(
letterId: letterId,
childId: childId,
answers: answers,
signaturePng: signaturePng,
);
Future<ParentLetterThreadMessage> sendThreadMessage({
required String letterId,
required String body,
required String clientMessageId,
}) => PostParentLetterThreadMessage().run(
letterId: letterId,
body: body,
clientMessageId: clientMessageId,
);
Future<Uint8List> getAttachment({
required String letterId,
required String attachmentId,
}) => GetParentLetterAttachment().run(
letterId: letterId,
attachmentId: attachmentId,
);
}
@@ -0,0 +1,22 @@
import '../../../../../api/demo/data/demo_parent_letters.dart';
import '../../../../../api/demo/demo_mode.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/get_parent_letters.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/mark_parent_letter_read.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
import '../../../infrastructure/repository/repository.dart';
import '../bloc/parent_letters_state.dart';
class ParentLettersRepository extends Repository<ParentLettersState> {
Future<ParentLetterListResponse> getLetters({String? before}) {
if (DemoMode.active) return Future.value(DemoParentLetters.list());
return GetParentLetters().run(before: before);
}
Future<void> markRead(String letterId) {
if (DemoMode.active) {
DemoParentLetters.markRead(letterId);
return Future.value();
}
return MarkParentLetterRead().run(letterId);
}
}
@@ -4,12 +4,17 @@ import '../../../../../api/marianumconnect/queries/timetable_custom_events/custo
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.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 '../repository/timetable_repository.dart';
import '../subject/timetable_subject.dart';
import 'timetable_event.dart';
import 'timetable_state.dart';
/// Drives one [TimetableSubject]'s plan. The same class serves the own plan
/// and foreign element plans; everything subject-specific (endpoint,
/// persistence, custom events) is derived from [subject].
class TimetableBloc
extends
LoadableHydratedBloc<
@@ -17,6 +22,13 @@ class TimetableBloc
TimetableState,
TimetableRepository
> {
final TimetableSubject subject;
TimetableBloc({required this.subject});
@override
String get id => subject.storageId;
DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0);
/// Set by [retry] to force the next [gatherData] to bypass cache freshness
@@ -59,8 +71,25 @@ class TimetableBloc
@override
Map<String, dynamic>? toStorage(TimetableState state) => state.toJson();
@override
Map<String, dynamic>? toJson(LoadableState<TimetableState> state) =>
subject.persistent ? super.toJson(state) : null;
@override
LoadableState<TimetableState> fromJson(Map<String, dynamic> json) =>
subject.persistent
? super.fromJson(json)
: const LoadableState(
isLoading: true,
data: null,
lastFetch: null,
reFetch: null,
error: null,
);
@override
Future<void> gatherData() async {
if (subject is NoTimetable) return;
final initial = innerState ?? fromNothing();
final renew = _forceRenew;
_forceRenew = false;
@@ -75,10 +104,10 @@ class TimetableBloc
initial.startDate,
initial.endDate,
onError: recordError,
renew: renew,
),
_loadStaticReferenceData(onError: recordError, renew: renew),
_loadCustomEvents(onError: recordError, renew: renew),
if (subject.supportsCustomEvents)
_loadCustomEvents(onError: recordError, renew: renew),
]);
if (firstError != null) throw firstError!;
@@ -104,17 +133,28 @@ class TimetableBloc
void refresh() => fetch();
/// Custom events belong to the signed-in user's own plan only — never to a
/// foreign plan or a guardian's view of a child.
void _requireCustomEvents() {
if (!subject.supportsCustomEvents) {
throw StateError('Custom events are not available for $subject');
}
}
Future<void> addCustomEvent(CustomTimetableEvent event) async {
_requireCustomEvents();
await repo.data.addCustomEvent(event);
await _refreshCustomEvents();
}
Future<void> updateCustomEvent(String id, CustomTimetableEvent event) async {
_requireCustomEvents();
await repo.data.updateCustomEvent(id, event);
await _refreshCustomEvents();
}
Future<void> removeCustomEvent(String id) async {
_requireCustomEvents();
await repo.data.removeCustomEvent(id);
await _refreshCustomEvents();
}
@@ -140,16 +180,15 @@ class TimetableBloc
DateTime startDate,
DateTime endDate, {
void Function(Object)? onError,
bool renew = false,
}) async {
final requestStart = DateTime.now();
_lastWeekRequestStart = requestStart;
try {
final week = await repo.data.getWeek(
subject,
startDate,
endDate,
onError: onError,
renew: renew,
);
if (_lastWeekRequestStart.isAfter(requestStart)) return;
_writeWeekToCache(startDate, week);
@@ -221,7 +260,7 @@ class TimetableBloc
void _prefetchWeek(DateTime start, DateTime end) {
repo.data
.getWeek(start, end)
.getWeek(subject, start, end)
.then((week) => _writeWeekToCache(start, week))
.catchError((_) {});
}
@@ -237,3 +276,11 @@ class TimetableBloc
return DateTime(friday.year, friday.month, friday.day);
}
}
/// Type token for a page-scoped plan (e.g. a foreign element). Carries no
/// logic of its own; the distinct type keeps a page-local provider from
/// shadowing the app-wide [TimetableBloc] that sheets and root-navigator pages
/// (subject colours, custom events) read.
final class ScopedTimetableBloc extends TimetableBloc {
ScopedTimetableBloc({required super.subject});
}
@@ -98,9 +98,12 @@ abstract class TimetableState with _$TimetableState {
);
}
bool get hasReferenceData =>
/// Whether the calendar has everything it needs to render. Custom events
/// only exist for subjects that support them; requiring them elsewhere would
/// keep foreign plans loading forever.
bool isReady({required bool needsCustomEvents}) =>
rooms != null &&
subjects != null &&
schoolHolidays != null &&
customEvents != null;
(!needsCustomEvents || customEvents != null);
}
@@ -4,6 +4,8 @@ import '../../../../../api/marianumconnect/queries/timetable_custom_events/timet
import '../../../../../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_cache.dart';
import '../../../../../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_remove.dart';
import '../../../../../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_update.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_child_week/timetable_get_child_week.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.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_rooms/timetable_get_rooms.dart';
@@ -21,19 +23,43 @@ import '../../../../../api/marianumconnect/queries/timetable_subject_colors/time
import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../../../../../api/request_cache.dart';
import '../subject/timetable_subject.dart';
/// Pulls the timetable from the Marianum-Connect mobile API. Each endpoint is
/// its own HTTP call; this provider exposes the lazy futures so the bloc can
/// chain them without seeing the dio layer.
/// chain them without seeing the dio layer. Only the week depends on the
/// [TimetableSubject]; the reference data is school-wide.
class TimetableDataProvider {
/// The endpoint serving [subject]'s week. Shared with the widget background
/// isolate, which has no bloc.
static Future<TimetableGetWeekResponse> fetchWeek(
TimetableSubject subject, {
required DateTime from,
required DateTime until,
}) => switch (subject) {
OwnTimetable() => TimetableGetWeek().run(from: from, until: until),
ElementTimetable(:final element) => TimetableGetElementWeek().run(
type: element.type,
id: element.id,
from: from,
until: until,
),
ChildTimetable(:final childId) => TimetableGetChildWeek().run(
childId: childId,
from: from,
until: until,
),
NoTimetable() => throw StateError('No timetable subject'),
};
Future<TimetableGetWeekResponse> getWeek(
TimetableSubject subject,
DateTime startDate,
DateTime endDate, {
void Function(Object)? onError,
bool renew = false,
}) async {
try {
return await TimetableGetWeek().run(from: startDate, until: endDate);
return await fetchWeek(subject, from: startDate, until: endDate);
} catch (e) {
onError?.call(e);
rethrow;
@@ -0,0 +1,54 @@
import '../../../../../access/user_role.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
import '../../capabilities/bloc/capabilities_state.dart';
import '../subject/timetable_subject.dart';
/// What the timetable view offers for a given subject. Resolved in one place
/// so the view never branches on roles or subject types itself.
class TimetablePolicy {
final bool canManageCustomEvents;
final bool canEditSubjectColors;
final bool showClassInsteadOfTeacher;
final bool canOpenForeign;
const TimetablePolicy({
required this.canManageCustomEvents,
required this.canEditSubjectColors,
required this.showClassInsteadOfTeacher,
required this.canOpenForeign,
});
static TimetablePolicy resolve({
required TimetableSubject subject,
required CapabilitiesState capabilities,
}) => switch (subject) {
OwnTimetable() => TimetablePolicy(
canManageCustomEvents: true,
canEditSubjectColors: true,
showClassInsteadOfTeacher: capabilities.role == UserRole.teacher,
canOpenForeign: capabilities.viewForeignTimetables,
),
// Subject colours are the viewer's own, global setting; editing them from
// a foreign plan would not refresh that plan, so it is not offered there.
ElementTimetable(:final element) => TimetablePolicy(
canManageCustomEvents: false,
canEditSubjectColors: false,
showClassInsteadOfTeacher: element.type == TimetableElementType.teacher,
canOpenForeign: capabilities.viewForeignTimetables,
),
// Custom events are the child's private data; subject colours are the
// guardian's own and apply to this (primary) plan directly.
ChildTimetable() => TimetablePolicy(
canManageCustomEvents: false,
canEditSubjectColors: true,
showClassInsteadOfTeacher: false,
canOpenForeign: capabilities.viewForeignTimetables,
),
NoTimetable() => const TimetablePolicy(
canManageCustomEvents: false,
canEditSubjectColors: false,
showClassInsteadOfTeacher: false,
canOpenForeign: false,
),
};
}
@@ -0,0 +1,18 @@
import '../../../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart';
import '../../../../../session/session.dart';
import '../../children/child_selection_cubit.dart';
import '../subject/timetable_subject.dart';
/// Whose plan the timetable tab shows for the active session.
TimetableSubject resolvePrimarySubject({
required Session? session,
required List<GuardianChild> children,
required String? selectedChildId,
}) => switch (session) {
null => const NoTimetable(),
CredentialSession() => const OwnTimetable(),
GuardianSession() => switch (effectiveChild(children, selectedChildId)) {
null => const NoTimetable(),
final child => ChildTimetable(child.id),
},
};
@@ -0,0 +1,81 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../session/session_manager.dart';
import '../../account/bloc/account_bloc.dart';
import '../../account/bloc/account_state.dart';
import '../../capabilities/bloc/capabilities_cubit.dart';
import '../../capabilities/bloc/capabilities_state.dart';
import '../../children/child_selection_cubit.dart';
import '../bloc/timetable_bloc.dart';
import '../subject/timetable_subject.dart';
import 'primary_subject_resolver.dart';
/// Provides the app-wide [TimetableBloc] for the session's primary subject
/// (own plan, or the selected child for guardians) and swaps it for a fresh
/// instance when that subject changes.
///
/// Sits above MaterialApp so root-navigator pages (subject colours, custom
/// events) reach it. The widget subtree is kept on a swap — only the provided
/// instance changes, which BlocBuilder/BlocListener pick up — so switching
/// the child does not reset the navigation. A new instance per subject (rather
/// than retargeting one bloc) keeps late responses for the previous child out
/// of the new child's week cache.
class PrimaryTimetableScope extends StatefulWidget {
final Widget child;
const PrimaryTimetableScope({required this.child, super.key});
@override
State<PrimaryTimetableScope> createState() => _PrimaryTimetableScopeState();
}
class _PrimaryTimetableScopeState extends State<PrimaryTimetableScope> {
late TimetableBloc _bloc;
@override
void initState() {
super.initState();
_bloc = TimetableBloc(subject: _resolve());
}
@override
void dispose() {
_bloc.close();
super.dispose();
}
TimetableSubject _resolve() => resolvePrimarySubject(
session: SessionManager().current,
children: context.read<CapabilitiesCubit>().state.children,
selectedChildId: context.read<ChildSelectionCubit>().state,
);
void _sync() {
final subject = _resolve();
if (subject == _bloc.subject) return;
final previous = _bloc;
setState(() => _bloc = TimetableBloc(subject: subject));
// Dependents re-subscribe during the next build; close afterwards.
WidgetsBinding.instance.addPostFrameCallback((_) => previous.close());
}
@override
Widget build(BuildContext context) => MultiBlocListener(
listeners: [
BlocListener<AccountBloc, AccountState>(
// While another account is being added, the session may already
// belong to it; this scope is replaced once that account takes over.
listenWhen: (a, b) =>
a.status != b.status && b.status != AccountStatus.addingAccount,
listener: (_, _) => _sync(),
),
BlocListener<CapabilitiesCubit, CapabilitiesState>(
listenWhen: (a, b) => a.children != b.children,
listener: (_, _) => _sync(),
),
BlocListener<ChildSelectionCubit, String?>(listener: (_, _) => _sync()),
],
child: BlocProvider<TimetableBloc>.value(value: _bloc, child: widget.child),
);
}
@@ -0,0 +1,110 @@
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
/// Whose timetable a [TimetableBloc] shows. The render pipeline is identical
/// for every subject; only the week endpoint, persistence and the
/// user-private extras (custom events) differ.
sealed class TimetableSubject {
const TimetableSubject();
/// Suffix of the hydrated storage slot. Must be unique per subject so
/// subjects never overwrite each other's cached weeks.
String get storageId;
/// Whether the bloc keeps its state across app restarts.
bool get persistent;
/// Custom events are user-private and only exist for the own plan.
bool get supportsCustomEvents;
}
/// The signed-in user's own plan (`timetable/me`).
final class OwnTimetable extends TimetableSubject {
const OwnTimetable();
// Empty on purpose: keeps the pre-existing storage slot "TimetableBloc", so
// updating the app does not drop the cached weeks.
@override
String get storageId => '';
@override
bool get persistent => true;
@override
bool get supportsCustomEvents => true;
@override
bool operator ==(Object other) => other is OwnTimetable;
@override
int get hashCode => (OwnTimetable).hashCode;
}
/// A foreign element picked by the user (teacher, room, class, student).
final class ElementTimetable extends TimetableSubject {
final TimetableElementRef element;
const ElementTimetable(this.element);
@override
String get storageId => 'element-${element.type.name}-${element.id}';
@override
bool get persistent => false;
@override
bool get supportsCustomEvents => false;
@override
bool operator ==(Object other) =>
other is ElementTimetable &&
other.element.type == element.type &&
other.element.id == element.id;
@override
int get hashCode => Object.hash(element.type, element.id);
}
/// A guardian's child (`timetable/child/{id}`). Kept across restarts per
/// child, so switching between siblings shows the cached plan immediately.
final class ChildTimetable extends TimetableSubject {
final String childId;
const ChildTimetable(this.childId);
@override
String get storageId => 'child-$childId';
@override
bool get persistent => true;
@override
bool get supportsCustomEvents => false;
@override
bool operator ==(Object other) =>
other is ChildTimetable && other.childId == childId;
@override
int get hashCode => childId.hashCode;
}
/// No plan to show: signed out, or a guardian without (known) children. The
/// bloc loads nothing; the view explains why.
final class NoTimetable extends TimetableSubject {
const NoTimetable();
@override
String get storageId => 'none';
@override
bool get persistent => false;
@override
bool get supportsCustomEvents => false;
@override
bool operator ==(Object other) => other is NoTimetable;
@override
int get hashCode => (NoTimetable).hashCode;
}
+1
View File
@@ -33,6 +33,7 @@ Map<String, dynamic> _$ModulesSettingsToJson(
const _$ModulesEnumMap = {
Modules.timetable: 'timetable',
Modules.parentLetters: 'parentLetters',
Modules.ticker: 'ticker',
Modules.talk: 'talk',
Modules.files: 'files',
+11
View File
@@ -16,9 +16,20 @@ class NotificationSettings {
@JsonKey(defaultValue: false)
bool talkPermissionPromptShown;
/// Same one-shot guards for the two occasions on which accounts with
/// parent letters are asked: right after signing in and, if the permission
/// is still missing, on the first visit of the parent letters.
@JsonKey(defaultValue: false)
bool guardianLoginPromptShown;
@JsonKey(defaultValue: false)
bool parentLettersPromptShown;
NotificationSettings({
this.enabled = true,
this.talkPermissionPromptShown = false,
this.guardianLoginPromptShown = false,
this.parentLettersPromptShown = false,
});
factory NotificationSettings.fromJson(Map<String, dynamic> json) =>
+4
View File
@@ -12,6 +12,8 @@ NotificationSettings _$NotificationSettingsFromJson(
enabled: json['enabled'] as bool? ?? true,
talkPermissionPromptShown:
json['talkPermissionPromptShown'] as bool? ?? false,
guardianLoginPromptShown: json['guardianLoginPromptShown'] as bool? ?? false,
parentLettersPromptShown: json['parentLettersPromptShown'] as bool? ?? false,
);
Map<String, dynamic> _$NotificationSettingsToJson(
@@ -19,4 +21,6 @@ Map<String, dynamic> _$NotificationSettingsToJson(
) => <String, dynamic>{
'enabled': instance.enabled,
'talkPermissionPromptShown': instance.talkPermissionPromptShown,
'guardianLoginPromptShown': instance.guardianLoginPromptShown,
'parentLettersPromptShown': instance.parentLettersPromptShown,
};
@@ -0,0 +1,235 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import '../../api/demo/demo_mode.dart';
import '../../api/errors/error_mapper.dart';
import '../../api/marianumconnect/auth/device_token_name.dart';
import '../../api/marianumconnect/queries/auth_guardian/auth_guardian_request.dart';
import '../../api/marianumconnect/queries/auth_guardian/auth_guardian_verify.dart';
import '../../api/marianumconnect/queries/auth_guardian/guardian_login_exception.dart';
import '../../auth_link/device_binding.dart';
import '../../auth_link/guardian_login_link.dart';
import '../../auth_link/pending_guardian_request.dart';
import '../../model/session_wipe.dart';
import '../../session/session.dart';
import '../../session/session_manager.dart';
import '../../widget_data/widget_sync.dart';
enum GuardianLoginStep { enterEmail, enterCode }
/// Drives the passwordless guardian login: request a mail, then finish with
/// the mailed code or link. The running request is persisted so the flow
/// survives the app being killed while the user reads the mail.
class GuardianLoginController extends ChangeNotifier {
final AuthGuardianRequest _request;
final AuthGuardianVerify _verify;
final PendingGuardianRequestStore _store;
final Future<void> Function(Session session) _signIn;
final Future<String> Function() _tokenName;
final DateTime Function() _now;
GuardianLoginController({
AuthGuardianRequest? request,
AuthGuardianVerify? verify,
PendingGuardianRequestStore store = const PendingGuardianRequestStore(),
Future<void> Function(Session session)? signIn,
Future<String> Function()? tokenName,
DateTime Function()? now,
}) : _request = request ?? AuthGuardianRequest(),
_verify = verify ?? AuthGuardianVerify(),
_store = store,
_signIn = signIn ?? _defaultSignIn,
_tokenName = tokenName ?? DeviceTokenName.resolve,
_now = now ?? DateTime.now;
GuardianLoginStep _step = GuardianLoginStep.enterEmail;
PendingGuardianRequest? _pending;
bool _loading = false;
String? _errorMessage;
String? _errorDetails;
GuardianLoginStep get step => _step;
PendingGuardianRequest? get pending => _pending;
bool get loading => _loading;
String? get errorMessage => _errorMessage;
String? get errorDetails => _errorDetails;
bool canResend() => resendCooldown() == Duration.zero;
/// Restzeit, bis [resend] wieder erlaubt ist.
Duration resendCooldown() {
final pending = _pending;
if (pending == null) return Duration.zero;
final remaining = pending.resendAvailableAt.difference(_now());
return remaining.isNegative ? Duration.zero : remaining;
}
/// Picks up a request started before the app was closed.
Future<void> restore() async {
final stored = await _store.read();
if (stored == null) return;
if (stored.isExpired(_now())) {
await _store.clear();
return;
}
_pending = stored;
_step = GuardianLoginStep.enterCode;
notifyListeners();
}
/// Sends the login mail. Returns true when the user is already signed in
/// (demo address), false when the code step follows or the request failed.
Future<bool> requestCode(String email) async {
final normalized = email.trim().toLowerCase();
if (DemoMode.matchesGuardian(normalized)) {
var signedIn = false;
await _run(() async {
await _signIn(GuardianSession(email: normalized, isDemo: true));
signedIn = true;
});
return signedIn;
}
await _run(() async {
final secret = DeviceBinding.generateSecret();
final response = await _request.run(
email: normalized,
deviceChallenge: DeviceBinding.challengeFor(secret),
tokenName: await _tokenName(),
);
final pending = PendingGuardianRequest(
requestId: response.requestId,
email: normalized,
deviceSecret: secret,
expiresAt: response.expiresAt,
resendAvailableAt: response.resendAvailableAt,
codeLength: response.codeLength,
);
await _store.write(pending);
_pending = pending;
_step = GuardianLoginStep.enterCode;
});
return false;
}
Future<void> resend() async {
final pending = _pending;
if (pending == null || !canResend()) return;
await requestCode(pending.email);
}
/// Mail clients and autofill may insert spaces into the code.
static String normalizeCode(String code) =>
code.replaceAll(RegExp(r'\s'), '');
Future<bool> submitCode(String code) => _complete(code: normalizeCode(code));
/// Completes the login from a mail link. A link belonging to another
/// request (other device, or an older mail) cannot be verified here.
Future<bool> submitLink(GuardianLoginLink link) {
if (_pending?.requestId != link.requestId) {
_errorMessage = GuardianLoginException.messageFor(
GuardianLoginError.deviceMismatch,
);
_errorDetails = null;
notifyListeners();
return Future.value(false);
}
return _complete(linkToken: link.linkToken);
}
/// A mail link that does not belong to the server the app talks to (a live
/// link while the app points at beta). Without this the tap would do nothing
/// at all.
void rejectForeignLink() {
_errorMessage =
'Dieser Anmeldelink gehört zu einem anderen Server als dem, mit dem '
'die App gerade verbunden ist. Bitte gib den Code aus der E-Mail ein.';
_errorDetails = null;
notifyListeners();
}
/// Abandons the running request, e.g. to correct a mistyped address.
Future<void> changeEmail() async {
await _store.clear();
_pending = null;
_step = GuardianLoginStep.enterEmail;
_errorMessage = null;
_errorDetails = null;
notifyListeners();
}
Future<bool> _complete({String? code, String? linkToken}) async {
final pending = _pending;
if (pending == null) {
_errorMessage = GuardianLoginException.messageFor(
GuardianLoginError.requestExpired,
);
_errorDetails = null;
_step = GuardianLoginStep.enterEmail;
notifyListeners();
return false;
}
var signedIn = false;
await _run(() async {
await _verify.run(
requestId: pending.requestId,
deviceVerifier: pending.deviceSecret,
tokenName: await _tokenName(),
code: code,
linkToken: linkToken,
);
await _store.clear();
await _signIn(GuardianSession(email: pending.email));
signedIn = true;
});
return signedIn;
}
Future<void> _run(Future<void> Function() body) async {
if (_loading) return;
_loading = true;
_errorMessage = null;
_errorDetails = null;
notifyListeners();
try {
await body();
} on GuardianLoginException catch (e) {
_errorMessage = e.userMessage;
_errorDetails = e.technicalDetails;
// These end the request for good; only a new mail helps.
if (e.error
case GuardianLoginError.requestExpired ||
GuardianLoginError.requestConsumed ||
GuardianLoginError.tooManyAttempts) {
await _store.clear();
_pending = null;
_step = GuardianLoginStep.enterEmail;
}
} catch (e) {
log('Guardian login failed: $e');
_errorMessage = errorToUserMessage(e);
_errorDetails = errorToTechnicalDetails(e);
} finally {
_loading = false;
notifyListeners();
}
}
static Future<void> _defaultSignIn(Session session) async {
// The previous account's wipe runs deferred; racing it would let it
// clear what this login stores.
await SessionWipe.done;
// Sign in first: the one-time code is already spent at this point, so a
// failing widget reset must not cost the session (the user would have to
// request a fresh mail for a login that actually succeeded).
await SessionManager().signIn(session);
// Drop any widget snapshot of a previous account.
try {
await WidgetSync.clear();
await WidgetSync.triggerUpdate();
} on Object catch (e) {
log('Guardian login: widget reset failed: $e');
}
}
}
+88 -8
View File
@@ -4,6 +4,9 @@ import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../api/marianumconnect/marianumconnect_endpoint.dart' as mc;
import '../../auth_link/guardian_link_listener.dart';
import '../../auth_link/guardian_login_link.dart';
import '../../background/widget_background_task.dart';
import '../../session/session_lifecycle.dart';
import '../../session/session_manager.dart';
@@ -15,8 +18,11 @@ import '../../theming/light_app_theme.dart';
import '../../utils/haptics.dart';
import '../../widget/info_dialog.dart';
import '../pages/settings/widgets/endpoint_picker.dart';
import 'guardian_login_controller.dart';
import 'login_controller.dart';
import 'post_login_splash.dart';
import 'widgets/guardian_login_card.dart';
import 'widgets/login_audience_card.dart';
import 'widgets/login_branding.dart';
import 'widgets/login_card.dart';
@@ -34,6 +40,11 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
static const _marianumRed = LightAppTheme.marianumRed;
final LoginController _controller = LoginController();
final GuardianLoginController _guardianController = GuardianLoginController();
late final Future<void> _guardianRestored;
/// Null while the user has not picked who is signing in.
LoginAudience? _audience;
late final AnimationController _fade = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 450),
@@ -43,6 +54,12 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
@override
void initState() {
super.initState();
_guardianRestored = _guardianController.restore().then((_) {
if (!mounted || _guardianController.pending == null) return;
setState(() => _audience = LoginAudience.guardian);
});
GuardianLinkListener.pending.addListener(_consumeGuardianLink);
_consumeGuardianLink();
WidgetsBinding.instance.addPostFrameCallback((_) => _showSignOutNotice());
}
@@ -55,6 +72,26 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
InfoDialog.show(context, notice, title: 'Erneut anmelden');
}
/// A tapped mail link finishes the guardian login without typing the code.
Future<void> _consumeGuardianLink() async {
final uri = GuardianLinkListener.pending.value;
if (uri == null) return;
GuardianLinkListener.clear();
final link = GuardianLoginLink.parse(
uri,
apiBase: Uri.parse(mc.MarianumConnectEndpoint.current()),
);
await _guardianRestored;
if (!mounted) return;
setState(() => _audience = LoginAudience.guardian);
if (link == null) {
_guardianController.rejectForeignLink();
return;
}
final signedIn = await _guardianController.submitLink(link);
if (signedIn && mounted) await _onLoginSuccess();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -64,8 +101,10 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
@override
void dispose() {
GuardianLinkListener.pending.removeListener(_consumeGuardianLink);
_fade.dispose();
_controller.dispose();
_guardianController.dispose();
super.dispose();
}
@@ -98,6 +137,23 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
accountBloc.activated(SessionManager().activeAccount?.id);
}
Widget _buildCard() => switch (_audience) {
null => LoginAudienceCard(
addingAccount: widget.addingAccount,
onSelected: (choice) => setState(() => _audience = choice),
),
LoginAudience.school => LoginCard(
controller: _controller,
onSuccess: _onLoginSuccess,
addingAccount: widget.addingAccount,
),
LoginAudience.guardian => GuardianLoginCard(
controller: _guardianController,
onSuccess: _onLoginSuccess,
addingAccount: widget.addingAccount,
),
};
@override
Widget build(BuildContext context) => PopScope(
canPop: !widget.addingAccount,
@@ -112,7 +168,10 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
foregroundColor: Colors.white,
elevation: 0,
leading: ListenableBuilder(
listenable: _controller,
listenable: Listenable.merge([
_controller,
_guardianController,
]),
builder: (context, _) => IconButton(
icon: const Icon(Icons.close),
tooltip: 'Abbrechen',
@@ -144,12 +203,33 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
children: [
LoginHeader(addingAccount: widget.addingAccount),
const SizedBox(height: 28),
LoginCard(
controller: _controller,
onSuccess: _onLoginSuccess,
addingAccount: widget.addingAccount,
),
const SizedBox(height: 12),
_buildCard(),
if (_audience != null)
Padding(
padding: const EdgeInsets.only(top: 8),
// Leaving mid-request would drop the form that
// receives the result, so it is disabled then.
child: ListenableBuilder(
listenable: Listenable.merge([
_controller,
_guardianController,
]),
builder: (context, _) => TextButton.icon(
style: TextButton.styleFrom(
foregroundColor: Colors.white,
),
icon: const Icon(Icons.arrow_back, size: 18),
label: const Text('Zurück zur Auswahl'),
onPressed:
_controller.loading ||
_guardianController.loading
? null
: () => setState(() => _audience = null),
),
),
)
else
const SizedBox(height: 12),
],
),
Column(
@@ -172,7 +252,7 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
),
);
bool get _busy => _controller.loading;
bool get _busy => _controller.loading || _guardianController.loading;
}
/// Subtle text link above the footer that surfaces the currently selected
@@ -0,0 +1,231 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../guardian_login_controller.dart';
import 'login_error_banner.dart';
import 'login_form_parts.dart';
/// Passwordless guardian login: e-mail step, then the mailed six-digit code.
/// A tapped mail link completes the second step without typing (handled by
/// the login screen).
class GuardianLoginCard extends StatefulWidget {
final GuardianLoginController controller;
final VoidCallback onSuccess;
/// Signs in an additional account instead of the first one.
final bool addingAccount;
const GuardianLoginCard({
required this.controller,
required this.onSuccess,
this.addingAccount = false,
super.key,
});
@override
State<GuardianLoginCard> createState() => _GuardianLoginCardState();
}
class _GuardianLoginCardState extends State<GuardianLoginCard> {
final _emailFormKey = GlobalKey<FormState>();
final _codeFormKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _codeController = TextEditingController();
Timer? _resendTicker;
GuardianLoginController get _controller => widget.controller;
@override
void initState() {
super.initState();
_controller.addListener(_onControllerChange);
_syncResendTicker();
}
@override
void dispose() {
_controller.removeListener(_onControllerChange);
_resendTicker?.cancel();
_emailController.dispose();
_codeController.dispose();
super.dispose();
}
void _onControllerChange() {
if (!mounted) return;
_syncResendTicker();
setState(() {});
}
// Rebuilds once per second while the resend cooldown runs so the countdown
// stays current.
void _syncResendTicker() {
final waiting =
_controller.step == GuardianLoginStep.enterCode &&
!_controller.canResend();
if (waiting && _resendTicker == null) {
_resendTicker = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() {});
if (_controller.canResend()) {
_resendTicker?.cancel();
_resendTicker = null;
}
});
} else if (!waiting) {
_resendTicker?.cancel();
_resendTicker = null;
}
}
String? _validateEmail(String? value) {
final email = (value ?? '').trim();
if (email.isEmpty) return 'Eingabe erforderlich';
if (!RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email)) {
return 'Bitte eine gültige E-Mail-Adresse eingeben';
}
return null;
}
String? _validateCode(String? value) {
final length = _controller.pending!.codeLength;
final code = GuardianLoginController.normalizeCode(value ?? '');
return code.length == length
? null
: 'Bitte den $length-stelligen Code eingeben';
}
Future<void> _requestCode() async {
if (_controller.loading) return;
if (!(_emailFormKey.currentState?.validate() ?? false)) return;
final signedIn = await _controller.requestCode(_emailController.text);
if (signedIn && mounted) widget.onSuccess();
}
Future<void> _submitCode() async {
if (_controller.loading) return;
if (!(_codeFormKey.currentState?.validate() ?? false)) return;
final signedIn = await _controller.submitCode(_codeController.text);
if (signedIn && mounted) widget.onSuccess();
}
@override
Widget build(BuildContext context) => switch (_controller.step) {
GuardianLoginStep.enterEmail => _buildEmailStep(context),
GuardianLoginStep.enterCode => _buildCodeStep(context),
};
Widget _buildEmailStep(BuildContext context) {
final theme = Theme.of(context);
return Form(
key: _emailFormKey,
child: LoginCardFrame(
title: widget.addingAccount
? 'Elternkonto hinzufügen'
: 'Anmeldung für Eltern',
hint:
'Gib die E-Mail-Adresse ein, die bei der Schule hinterlegt ist. '
'Du erhältst einen Anmeldecode per E-Mail.',
children: [
TextFormField(
key: const Key('guardian-email-field'),
controller: _emailController,
enabled: !_controller.loading,
validator: _validateEmail,
autocorrect: false,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _requestCode(),
decoration: loginInputDecoration(
theme,
'E-Mail-Adresse',
Icons.alternate_email,
),
),
LoginErrorBanner(
message: _controller.errorMessage,
details: _controller.errorDetails,
),
const SizedBox(height: 20),
LoginSubmitButton(
key: const Key('guardian-request-button'),
label: 'Code anfordern',
loading: _controller.loading,
onPressed: _requestCode,
),
],
),
);
}
Widget _buildCodeStep(BuildContext context) {
final theme = Theme.of(context);
final pending = _controller.pending!;
final remaining = _controller.resendCooldown();
return Form(
key: _codeFormKey,
child: LoginCardFrame(
title: 'Code eingeben',
hint:
'Wir haben eine E-Mail an ${pending.email} gesendet. Gib den Code '
'ein oder tippe auf den Link in der E-Mail.',
children: [
TextFormField(
key: const Key('guardian-code-field'),
controller: _codeController,
enabled: !_controller.loading,
validator: _validateCode,
autofocus: true,
keyboardType: TextInputType.number,
autofillHints: const [AutofillHints.oneTimeCode],
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(pending.codeLength),
],
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submitCode(),
decoration: loginInputDecoration(
theme,
'Anmeldecode',
Icons.pin_outlined,
),
),
LoginErrorBanner(
message: _controller.errorMessage,
details: _controller.errorDetails,
),
const SizedBox(height: 20),
LoginSubmitButton(
key: const Key('guardian-verify-button'),
label: widget.addingAccount ? 'Hinzufügen' : 'Anmelden',
loading: _controller.loading,
onPressed: _submitCode,
),
const SizedBox(height: 8),
Row(
children: [
TextButton(
onPressed: _controller.loading ? null : _controller.changeEmail,
child: const Text('E-Mail ändern'),
),
const Spacer(),
TextButton(
onPressed: _controller.loading || !_controller.canResend()
? null
: _controller.resend,
child: Text(
_controller.canResend()
? 'Erneut senden'
: 'Erneut senden (${remaining.inSeconds + 1} s)',
),
),
],
),
],
),
);
}
}
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'login_form_parts.dart';
enum LoginAudience { school, guardian }
/// First login step: who is signing in. School accounts and guardians use
/// entirely different forms, so the choice comes before any input.
class LoginAudienceCard extends StatelessWidget {
final ValueChanged<LoginAudience> onSelected;
/// Signs in an additional account instead of the first one.
final bool addingAccount;
const LoginAudienceCard({
required this.onSelected,
this.addingAccount = false,
super.key,
});
@override
Widget build(BuildContext context) => LoginCardFrame(
title: addingAccount ? 'Konto hinzufügen' : 'Anmelden',
hint: addingAccount
? 'Welches Konto möchtest du hinzufügen?'
: 'Bitte wähle deine Anmeldemethode',
children: [
_AudienceButton(
key: const Key('login-audience-school'),
icon: Icons.school_outlined,
label: addingAccount
? 'Schulkonto (Schülerschaft & Lehrkräfte)'
: 'Login für Schülerschaft & Lehrkräfte',
onPressed: () => onSelected(LoginAudience.school),
),
const SizedBox(height: 12),
_AudienceButton(
key: const Key('login-audience-guardian'),
icon: Icons.family_restroom_outlined,
label: addingAccount ? 'Elternkonto' : 'Login für Eltern',
onPressed: () => onSelected(LoginAudience.guardian),
),
],
);
}
class _AudienceButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onPressed;
const _AudienceButton({
required this.icon,
required this.label,
required this.onPressed,
super.key,
});
@override
Widget build(BuildContext context) => SizedBox(
height: 64,
child: FilledButton.tonalIcon(
onPressed: onPressed,
icon: Icon(icon, size: 26),
label: Text(label),
style: FilledButton.styleFrom(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 20),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
);
}
+1 -1
View File
@@ -87,7 +87,7 @@ class _LoginCardState extends State<LoginCard> {
return Form(
key: _formKey,
child: LoginCardFrame(
title: widget.addingAccount ? 'Konto hinzufügen' : 'Anmelden',
title: widget.addingAccount ? 'Schulkonto hinzufügen' : 'Anmelden',
hint: widget.addingAccount
? 'Melde dich mit den Marianum-Zugangsdaten des Kontos an, das '
'du hinzufügen möchtest.'
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more