added guardian letters with chat and multiple answer functionalities

This commit is contained in:
2026-09-20 16:12:45 +02:00
parent 67c935c05b
commit 2423c1a75e
68 changed files with 8348 additions and 226 deletions
+5 -3
View File
@@ -1,6 +1,6 @@
# MarianumMobile Client # MarianumMobile Client
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, kein 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 ## Stack
@@ -56,10 +56,12 @@ lib/
**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). **Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()` Eltern-Sessions haben keine Nextcloud-Identität. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE).
**Rollen & Zuschnitt:** Views verzweigen nie auf Rollen. Module und Settings-Sections deklarieren `AccessRequirement`s (`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. **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. **Stundenplan:** Ein `TimetableBloc(subject: …)` für alle Fälle (eigener Plan, Fremdplan, Kind). Den globalen Bloc stellt `PrimaryTimetableScope` bereit und tauscht ihn beim Kindwechsel aus; page-scoped Fremdpläne nutzen `ScopedTimetableBloc`. Die Kinderauswahl (`ChildSelectionCubit`) ist modulübergreifend.
**Elternbriefe:** Modul `parentLetters` (nur Eltern-Sessions, `AccessRequirement.guardian`): Lehrer-Mitteilungen aus MarianumConnect mit Gelesen-Status, Kenntnisnahme/Auswahl/Unterschrift pro Kind, Thread und Anhängen. Der Posteingang (`ParentLettersBloc`) ist global (Modul-Badge, Push-Refresh), ein Brief ist page-scoped (`ParentLetterBloc`). Ob und wie geantwortet werden darf, entscheidet der Server (`editable`); `ParentLetterFormPolicy.resolve` macht daraus das Formular. Push: Connect-Direct-Push `type: parent-letter`, Tap-Routing allein über `parentLetterId`. Alle Benachrichtigungs-Taps (lokal gerendert wie FCM) werden von `resolvePushTarget` (`lib/push/push_target.dart`) aufgelöst und in `NotificationTasks.openPushTarget` navigiert neue Push-Ziele nur dort ergänzen.
## Build / Run ## Build / Run
```bash ```bash
@@ -74,7 +76,7 @@ flutter test # Tests (siehe test
| Backend | Pfad | Zweck | | 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 | | Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung |
| MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events | | MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events |
+3 -1
View File
@@ -3,10 +3,12 @@ import '../session/session.dart';
/// Backend identity a feature needs. Modules and settings sections declare /// Backend identity a feature needs. Modules and settings sections declare
/// these; anything whose requirements the session does not meet is hidden. /// these; anything whose requirements the session does not meet is hidden.
enum AccessRequirement { enum AccessRequirement {
nextcloud; nextcloud,
guardian;
bool isMetBy(Session? session) => switch (this) { bool isMetBy(Session? session) => switch (this) {
AccessRequirement.nextcloud => session?.nextcloud != null, AccessRequirement.nextcloud => session?.nextcloud != null,
AccessRequirement.guardian => session is GuardianSession,
}; };
} }
+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(),
),
];
}
}
@@ -6,6 +6,22 @@ import '../../errors/network_exception.dart';
import '../../errors/parse_exception.dart'; import '../../errors/parse_exception.dart';
import '../../errors/server_exception.dart'; import '../../errors/server_exception.dart';
/// The error code of a rejected call: the server answers either with JSON
/// `{"error": "<code>"}` or with the plain text `Fehler: <code>` of its
/// generic error handler.
String? marianumConnectErrorCode(Object? body) {
if (body is Map) {
final code = body['error'];
return code is String ? code : null;
}
if (body is String) {
const prefix = 'Fehler: ';
return (body.startsWith(prefix) ? body.substring(prefix.length) : body)
.trim();
}
return null;
}
/// Converts a DioException raised against the Marianum-Connect API into one of /// Converts a DioException raised against the Marianum-Connect API into one of
/// the app's typed AppExceptions. Keeps the dio dependency out of call sites /// the app's typed AppExceptions. Keeps the dio dependency out of call sites
/// that just want to render an error message. /// that just want to render an error message.
@@ -1,5 +1,8 @@
import 'dart:typed_data';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../errors/app_exception.dart';
import 'errors/marianumconnect_error.dart'; import 'errors/marianumconnect_error.dart';
import 'marianumconnect_api.dart'; import 'marianumconnect_api.dart';
import 'marianumconnect_endpoint.dart'; import 'marianumconnect_endpoint.dart';
@@ -23,10 +26,14 @@ abstract class MarianumConnectQuery {
try { try {
return await body(); return await body();
} on DioException catch (e) { } on DioException catch (e) {
throw mapMarianumConnectError(e); throw mapError(e);
} }
} }
/// The AppException a failed call surfaces as. Query families with domain
/// error codes override this instead of re-implementing [guard].
AppException mapError(DioException error) => mapMarianumConnectError(error);
/// GETs [path] and parses the JSON object body with [fromJson]. /// GETs [path] and parses the JSON object body with [fromJson].
Future<T> getObject<T>( Future<T> getObject<T>(
String path, String path,
@@ -55,6 +62,16 @@ abstract class MarianumConnectQuery {
.toList(); .toList();
}); });
/// GETs the raw bytes of [path] (files that need the bearer token).
Future<Uint8List> getBytes(String path) => guard(() async {
final response = await dio.get<List<int>>(
endpoint(path),
options: Options(responseType: ResponseType.bytes),
);
final bytes = response.data!;
return bytes is Uint8List ? bytes : Uint8List.fromList(bytes);
});
/// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params. /// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params.
String isoDate(DateTime d) => String isoDate(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
@@ -84,21 +84,9 @@ class GuardianLoginException extends AppException {
'Bitte versuche es später erneut.', 'Bitte versuche es später erneut.',
}; };
/// Accepts the documented JSON body (`{"error": …, "attemptsLeft": …}`) as
/// well as the plain-text `Fehler: <code>` the server's generic error
/// handler produces.
static (String?, int?) _parseBody(Object? data) { static (String?, int?) _parseBody(Object? data) {
if (data is Map) { final attempts = data is Map ? data['attemptsLeft'] : null;
final attempts = data['attemptsLeft']; return (marianumConnectErrorCode(data), attempts is int ? attempts : null);
return (data['error'] as String?, attempts is int ? attempts : null);
}
if (data is String) {
final code = data.startsWith('Fehler: ')
? data.substring('Fehler: '.length)
: data;
return (code.trim(), null);
}
return (null, null);
} }
static GuardianLoginError? _errorFor(String? code, int status) => static GuardianLoginError? _errorFor(String? code, int status) =>
@@ -1,7 +1,5 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
/// Downloads the raw PDF bytes of a Marianum Message from /// Downloads the raw PDF bytes of a Marianum Message from
@@ -15,11 +13,6 @@ class GetNewsletterFile extends MarianumConnectQuery {
GetNewsletterFile(this.id, {super.dio}); GetNewsletterFile(this.id, {super.dio});
Future<Uint8List> run() => guard(() async { Future<Uint8List> run() =>
final response = await dio.get<List<int>>( getBytes('newsletter/${Uri.encodeComponent(id)}/file');
endpoint('newsletter/${Uri.encodeComponent(id)}/file'),
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
});
} }
@@ -1,7 +1,5 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
/// Downloads the raw bytes of a PROXIED_FILE ticker page from /// Downloads the raw bytes of a PROXIED_FILE ticker page from
@@ -15,11 +13,6 @@ class GetTickerPageFile extends MarianumConnectQuery {
GetTickerPageFile(this.slug, {super.dio}); GetTickerPageFile(this.slug, {super.dio});
Future<Uint8List> run() => guard(() async { Future<Uint8List> run() =>
final response = await dio.get<List<int>>( getBytes('ticker/pages/${Uri.encodeComponent(slug)}/file');
endpoint('ticker/pages/${Uri.encodeComponent(slug)}/file'),
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
});
} }
@@ -0,0 +1,9 @@
import 'parent_letter_models.dart';
import 'parent_letter_query.dart';
class GetParentLetter extends ParentLetterQuery {
GetParentLetter({super.dio});
Future<ParentLetterDetail> run(String letterId) =>
getObject(letterPath(letterId), ParentLetterDetail.fromJson);
}
@@ -0,0 +1,14 @@
import 'dart:typed_data';
import 'parent_letter_query.dart';
class GetParentLetterAttachment extends ParentLetterQuery {
GetParentLetterAttachment({super.dio});
Future<Uint8List> run({
required String letterId,
required String attachmentId,
}) => getBytes(
letterPath(letterId, '/attachments/${Uri.encodeComponent(attachmentId)}'),
);
}
@@ -0,0 +1,16 @@
import 'parent_letter_models.dart';
import 'parent_letter_query.dart';
/// `GET parent-letters`: the guardian's inbox across all children, newest
/// first. [before] continues after the given letter (keyset paging).
class GetParentLetters extends ParentLetterQuery {
static const int pageSize = 50;
GetParentLetters({super.dio});
Future<ParentLetterListResponse> run({String? before}) => getObject(
'parent-letters',
ParentLetterListResponse.fromJson,
queryParameters: {'limit': pageSize, 'before': ?before},
);
}
@@ -0,0 +1,11 @@
import 'parent_letter_query.dart';
/// `POST parent-letters/{id}/read`: marks the letter and its thread as read
/// for this guardian. Idempotent.
class MarkParentLetterRead extends ParentLetterQuery {
MarkParentLetterRead({super.dio});
Future<void> run(String letterId) => guard(() async {
await dio.post<void>(endpoint(letterPath(letterId, '/read')));
});
}
@@ -0,0 +1,90 @@
import 'package:dio/dio.dart';
import '../../../errors/app_exception.dart';
import '../../errors/marianumconnect_error.dart';
enum ParentLetterError {
guardianRequired,
letterNotFound,
childNotFound,
attachmentNotFound,
invalidAnswers,
signatureRequired,
responseFinal,
deadlinePassed,
threadDisabled,
invalidRequest,
}
/// A parent-letter call the server rejected with a domain reason.
class ParentLetterException extends AppException {
final ParentLetterError error;
const ParentLetterException(
this.error, {
required super.userMessage,
super.technicalDetails,
}) : super(allowRetry: false);
/// Only 4xx answers with a parent-letter reason become a
/// [ParentLetterException]; everything else (network, 401, 5xx) keeps the
/// generic MarianumConnect mapping.
static AppException fromDio(DioException e) {
final response = e.response;
final status = response?.statusCode;
if (status == null || status < 400 || status >= 500 || status == 401) {
return mapMarianumConnectError(e);
}
final error = _errorFor(marianumConnectErrorCode(response!.data), status);
if (error == null) return mapMarianumConnectError(e);
return ParentLetterException(
error,
userMessage: messageFor(error),
technicalDetails: 'MC $status: ${response.data}',
);
}
static String messageFor(ParentLetterError error) => switch (error) {
ParentLetterError.guardianRequired =>
'Elternbriefe sind nur mit einem Eltern-Konto verfügbar.',
ParentLetterError.letterNotFound =>
'Dieser Elternbrief ist nicht mehr verfügbar.',
ParentLetterError.childNotFound =>
'Dieser Elternbrief betrifft das ausgewählte Kind nicht.',
ParentLetterError.attachmentNotFound =>
'Der Anhang ist nicht mehr verfügbar.',
ParentLetterError.invalidAnswers =>
'Die Rückmeldung ist unvollständig. Bitte prüfe deine Angaben.',
ParentLetterError.signatureRequired =>
'Für diese Rückmeldung wird eine Unterschrift benötigt.',
ParentLetterError.responseFinal =>
'Die Rückmeldung wurde bereits endgültig abgegeben und kann nicht mehr '
'geändert werden.',
ParentLetterError.deadlinePassed =>
'Die Frist für diese Rückmeldung ist abgelaufen.',
ParentLetterError.threadDisabled =>
'Auf diesen Elternbrief kann nicht geantwortet werden.',
ParentLetterError.invalidRequest =>
'Die Anfrage konnte nicht verarbeitet werden.',
};
static ParentLetterError? _errorFor(String? code, int status) =>
switch (code) {
'guardian_required' => ParentLetterError.guardianRequired,
'letter_not_found' => ParentLetterError.letterNotFound,
'child_not_found' => ParentLetterError.childNotFound,
'attachment_not_found' => ParentLetterError.attachmentNotFound,
'invalid_answers' => ParentLetterError.invalidAnswers,
'signature_required' => ParentLetterError.signatureRequired,
'response_final' => ParentLetterError.responseFinal,
'deadline_passed' => ParentLetterError.deadlinePassed,
'thread_disabled' => ParentLetterError.threadDisabled,
'invalid_request' => ParentLetterError.invalidRequest,
_ => switch (status) {
404 => ParentLetterError.letterNotFound,
409 => ParentLetterError.responseFinal,
410 => ParentLetterError.deadlinePassed,
_ => null,
},
};
}
@@ -0,0 +1,226 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'parent_letter_models.freezed.dart';
part 'parent_letter_models.g.dart';
enum ParentLetterStatus { info, open, done, expired }
enum ParentLetterFieldType {
@JsonValue('single_choice')
singleChoice,
unknown,
}
/// Sender, thread author or responding guardian. [self] marks the signed-in
/// guardian.
@freezed
abstract class ParentLetterPerson with _$ParentLetterPerson {
const factory ParentLetterPerson({
@Default('') String displayName,
@Default(false) bool self,
}) = _ParentLetterPerson;
factory ParentLetterPerson.fromJson(Map<String, dynamic> json) =>
_$ParentLetterPersonFromJson(json);
}
/// Inbox entry from `GET parent-letters`. [childIds] are the opaque guardian
/// child ids from `me/capabilities`.
@freezed
abstract class ParentLetterSummary with _$ParentLetterSummary {
const factory ParentLetterSummary({
required String id,
@Default('') String subject,
@Default('') String preview,
@Default(ParentLetterPerson()) ParentLetterPerson sender,
required DateTime sentAt,
DateTime? editedAt,
@Default(true) bool read,
@Default([]) List<String> childIds,
@Default(0) int attachmentCount,
@JsonKey(unknownEnumValue: ParentLetterStatus.info)
@Default(ParentLetterStatus.info)
ParentLetterStatus status,
DateTime? deadline,
}) = _ParentLetterSummary;
factory ParentLetterSummary.fromJson(Map<String, dynamic> json) =>
_$ParentLetterSummaryFromJson(json);
}
@freezed
abstract class ParentLetterListResponse with _$ParentLetterListResponse {
const factory ParentLetterListResponse({
@Default([]) List<ParentLetterSummary> items,
@Default(false) bool hasMore,
@Default(0) int unreadCount,
@Default(0) int openCount,
}) = _ParentLetterListResponse;
factory ParentLetterListResponse.fromJson(Map<String, dynamic> json) =>
_$ParentLetterListResponseFromJson(json);
}
@freezed
abstract class ParentLetterAttachment with _$ParentLetterAttachment {
const factory ParentLetterAttachment({
required String id,
@Default('') String fileName,
@Default('') String mimeType,
@Default(0) int size,
}) = _ParentLetterAttachment;
factory ParentLetterAttachment.fromJson(Map<String, dynamic> json) =>
_$ParentLetterAttachmentFromJson(json);
}
@freezed
abstract class ParentLetterOption with _$ParentLetterOption {
const factory ParentLetterOption({
required String id,
@Default('') String label,
}) = _ParentLetterOption;
factory ParentLetterOption.fromJson(Map<String, dynamic> json) =>
_$ParentLetterOptionFromJson(json);
}
@freezed
abstract class ParentLetterField with _$ParentLetterField {
const factory ParentLetterField({
required String id,
@JsonKey(unknownEnumValue: ParentLetterFieldType.unknown)
@Default(ParentLetterFieldType.unknown)
ParentLetterFieldType type,
@Default('') String label,
@JsonKey(name: 'required') @Default(false) bool isRequired,
@Default([]) List<ParentLetterOption> options,
}) = _ParentLetterField;
factory ParentLetterField.fromJson(Map<String, dynamic> json) =>
_$ParentLetterFieldFromJson(json);
}
/// What the sender asks for. No fields and no signature means a plain
/// acknowledgement.
@freezed
abstract class ParentLetterRequest with _$ParentLetterRequest {
const factory ParentLetterRequest({
@Default([]) List<ParentLetterField> fields,
@Default(false) bool signatureRequired,
DateTime? deadline,
}) = _ParentLetterRequest;
factory ParentLetterRequest.fromJson(Map<String, dynamic> json) =>
_$ParentLetterRequestFromJson(json);
}
@freezed
abstract class ParentLetterAnswer with _$ParentLetterAnswer {
const factory ParentLetterAnswer({
required String fieldId,
@Default([]) List<String> optionIds,
String? text,
}) = _ParentLetterAnswer;
factory ParentLetterAnswer.fromJson(Map<String, dynamic> json) =>
_$ParentLetterAnswerFromJson(json);
}
@freezed
abstract class ParentLetterResponse with _$ParentLetterResponse {
const factory ParentLetterResponse({
DateTime? respondedAt,
@Default(ParentLetterPerson()) ParentLetterPerson respondedBy,
@Default([]) List<ParentLetterAnswer> answers,
@Default(false) bool signed,
}) = _ParentLetterResponse;
factory ParentLetterResponse.fromJson(Map<String, dynamic> json) =>
_$ParentLetterResponseFromJson(json);
}
/// Per-child state of a letter. [editable] is the server's verdict on whether
/// this guardian may (re)submit right now.
@freezed
abstract class ParentLetterChildState with _$ParentLetterChildState {
const factory ParentLetterChildState({
required String childId,
@JsonKey(unknownEnumValue: ParentLetterStatus.info)
@Default(ParentLetterStatus.info)
ParentLetterStatus status,
@Default(false) bool editable,
ParentLetterResponse? response,
}) = _ParentLetterChildState;
factory ParentLetterChildState.fromJson(Map<String, dynamic> json) =>
_$ParentLetterChildStateFromJson(json);
}
@freezed
abstract class ParentLetterThreadMessage with _$ParentLetterThreadMessage {
const factory ParentLetterThreadMessage({
required String id,
@Default(ParentLetterPerson()) ParentLetterPerson author,
@Default('') String body,
required DateTime sentAt,
}) = _ParentLetterThreadMessage;
factory ParentLetterThreadMessage.fromJson(Map<String, dynamic> json) =>
_$ParentLetterThreadMessageFromJson(json);
}
@freezed
abstract class ParentLetterThread with _$ParentLetterThread {
const factory ParentLetterThread({
@Default(false) bool enabled,
@Default([]) List<ParentLetterThreadMessage> messages,
}) = _ParentLetterThread;
factory ParentLetterThread.fromJson(Map<String, dynamic> json) =>
_$ParentLetterThreadFromJson(json);
}
/// The detail-only part of `GET parent-letters/{id}`.
@freezed
abstract class ParentLetterContent with _$ParentLetterContent {
const factory ParentLetterContent({
@Default('') String body,
@Default([]) List<ParentLetterAttachment> attachments,
ParentLetterRequest? request,
@Default([]) List<ParentLetterChildState> children,
@Default(ParentLetterThread()) ParentLetterThread thread,
}) = _ParentLetterContent;
factory ParentLetterContent.fromJson(Map<String, dynamic> json) =>
_$ParentLetterContentFromJson(json);
}
/// A full letter. The server sends the inbox fields and the detail fields in
/// one flat object; they are parsed into [summary] and [content] so the inbox
/// entry can be replaced from a detail answer without copying fields.
@immutable
class ParentLetterDetail {
final ParentLetterSummary summary;
final ParentLetterContent content;
const ParentLetterDetail({required this.summary, required this.content});
factory ParentLetterDetail.fromJson(Map<String, dynamic> json) =>
ParentLetterDetail(
summary: ParentLetterSummary.fromJson(json),
content: ParentLetterContent.fromJson(json),
);
Map<String, dynamic> toJson() => {...summary.toJson(), ...content.toJson()};
@override
bool operator ==(Object other) =>
other is ParentLetterDetail &&
other.summary == summary &&
other.content == content;
@override
int get hashCode => Object.hash(summary, content);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,331 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'parent_letter_models.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_ParentLetterPerson _$ParentLetterPersonFromJson(Map<String, dynamic> json) =>
_ParentLetterPerson(
displayName: json['displayName'] as String? ?? '',
self: json['self'] as bool? ?? false,
);
Map<String, dynamic> _$ParentLetterPersonToJson(_ParentLetterPerson instance) =>
<String, dynamic>{
'displayName': instance.displayName,
'self': instance.self,
};
_ParentLetterSummary _$ParentLetterSummaryFromJson(Map<String, dynamic> json) =>
_ParentLetterSummary(
id: json['id'] as String,
subject: json['subject'] as String? ?? '',
preview: json['preview'] as String? ?? '',
sender: json['sender'] == null
? const ParentLetterPerson()
: ParentLetterPerson.fromJson(json['sender'] as Map<String, dynamic>),
sentAt: DateTime.parse(json['sentAt'] as String),
editedAt: json['editedAt'] == null
? null
: DateTime.parse(json['editedAt'] as String),
read: json['read'] as bool? ?? true,
childIds:
(json['childIds'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
attachmentCount: (json['attachmentCount'] as num?)?.toInt() ?? 0,
status:
$enumDecodeNullable(
_$ParentLetterStatusEnumMap,
json['status'],
unknownValue: ParentLetterStatus.info,
) ??
ParentLetterStatus.info,
deadline: json['deadline'] == null
? null
: DateTime.parse(json['deadline'] as String),
);
Map<String, dynamic> _$ParentLetterSummaryToJson(
_ParentLetterSummary instance,
) => <String, dynamic>{
'id': instance.id,
'subject': instance.subject,
'preview': instance.preview,
'sender': instance.sender,
'sentAt': instance.sentAt.toIso8601String(),
'editedAt': instance.editedAt?.toIso8601String(),
'read': instance.read,
'childIds': instance.childIds,
'attachmentCount': instance.attachmentCount,
'status': _$ParentLetterStatusEnumMap[instance.status]!,
'deadline': instance.deadline?.toIso8601String(),
};
const _$ParentLetterStatusEnumMap = {
ParentLetterStatus.info: 'info',
ParentLetterStatus.open: 'open',
ParentLetterStatus.done: 'done',
ParentLetterStatus.expired: 'expired',
};
_ParentLetterListResponse _$ParentLetterListResponseFromJson(
Map<String, dynamic> json,
) => _ParentLetterListResponse(
items:
(json['items'] as List<dynamic>?)
?.map((e) => ParentLetterSummary.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
hasMore: json['hasMore'] as bool? ?? false,
unreadCount: (json['unreadCount'] as num?)?.toInt() ?? 0,
openCount: (json['openCount'] as num?)?.toInt() ?? 0,
);
Map<String, dynamic> _$ParentLetterListResponseToJson(
_ParentLetterListResponse instance,
) => <String, dynamic>{
'items': instance.items,
'hasMore': instance.hasMore,
'unreadCount': instance.unreadCount,
'openCount': instance.openCount,
};
_ParentLetterAttachment _$ParentLetterAttachmentFromJson(
Map<String, dynamic> json,
) => _ParentLetterAttachment(
id: json['id'] as String,
fileName: json['fileName'] as String? ?? '',
mimeType: json['mimeType'] as String? ?? '',
size: (json['size'] as num?)?.toInt() ?? 0,
);
Map<String, dynamic> _$ParentLetterAttachmentToJson(
_ParentLetterAttachment instance,
) => <String, dynamic>{
'id': instance.id,
'fileName': instance.fileName,
'mimeType': instance.mimeType,
'size': instance.size,
};
_ParentLetterOption _$ParentLetterOptionFromJson(Map<String, dynamic> json) =>
_ParentLetterOption(
id: json['id'] as String,
label: json['label'] as String? ?? '',
);
Map<String, dynamic> _$ParentLetterOptionToJson(_ParentLetterOption instance) =>
<String, dynamic>{'id': instance.id, 'label': instance.label};
_ParentLetterField _$ParentLetterFieldFromJson(Map<String, dynamic> json) =>
_ParentLetterField(
id: json['id'] as String,
type:
$enumDecodeNullable(
_$ParentLetterFieldTypeEnumMap,
json['type'],
unknownValue: ParentLetterFieldType.unknown,
) ??
ParentLetterFieldType.unknown,
label: json['label'] as String? ?? '',
isRequired: json['required'] as bool? ?? false,
options:
(json['options'] as List<dynamic>?)
?.map(
(e) => ParentLetterOption.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
);
Map<String, dynamic> _$ParentLetterFieldToJson(_ParentLetterField instance) =>
<String, dynamic>{
'id': instance.id,
'type': _$ParentLetterFieldTypeEnumMap[instance.type]!,
'label': instance.label,
'required': instance.isRequired,
'options': instance.options,
};
const _$ParentLetterFieldTypeEnumMap = {
ParentLetterFieldType.singleChoice: 'single_choice',
ParentLetterFieldType.unknown: 'unknown',
};
_ParentLetterRequest _$ParentLetterRequestFromJson(Map<String, dynamic> json) =>
_ParentLetterRequest(
fields:
(json['fields'] as List<dynamic>?)
?.map(
(e) => ParentLetterField.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
signatureRequired: json['signatureRequired'] as bool? ?? false,
deadline: json['deadline'] == null
? null
: DateTime.parse(json['deadline'] as String),
);
Map<String, dynamic> _$ParentLetterRequestToJson(
_ParentLetterRequest instance,
) => <String, dynamic>{
'fields': instance.fields,
'signatureRequired': instance.signatureRequired,
'deadline': instance.deadline?.toIso8601String(),
};
_ParentLetterAnswer _$ParentLetterAnswerFromJson(Map<String, dynamic> json) =>
_ParentLetterAnswer(
fieldId: json['fieldId'] as String,
optionIds:
(json['optionIds'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
text: json['text'] as String?,
);
Map<String, dynamic> _$ParentLetterAnswerToJson(_ParentLetterAnswer instance) =>
<String, dynamic>{
'fieldId': instance.fieldId,
'optionIds': instance.optionIds,
'text': instance.text,
};
_ParentLetterResponse _$ParentLetterResponseFromJson(
Map<String, dynamic> json,
) => _ParentLetterResponse(
respondedAt: json['respondedAt'] == null
? null
: DateTime.parse(json['respondedAt'] as String),
respondedBy: json['respondedBy'] == null
? const ParentLetterPerson()
: ParentLetterPerson.fromJson(
json['respondedBy'] as Map<String, dynamic>,
),
answers:
(json['answers'] as List<dynamic>?)
?.map((e) => ParentLetterAnswer.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
signed: json['signed'] as bool? ?? false,
);
Map<String, dynamic> _$ParentLetterResponseToJson(
_ParentLetterResponse instance,
) => <String, dynamic>{
'respondedAt': instance.respondedAt?.toIso8601String(),
'respondedBy': instance.respondedBy,
'answers': instance.answers,
'signed': instance.signed,
};
_ParentLetterChildState _$ParentLetterChildStateFromJson(
Map<String, dynamic> json,
) => _ParentLetterChildState(
childId: json['childId'] as String,
status:
$enumDecodeNullable(
_$ParentLetterStatusEnumMap,
json['status'],
unknownValue: ParentLetterStatus.info,
) ??
ParentLetterStatus.info,
editable: json['editable'] as bool? ?? false,
response: json['response'] == null
? null
: ParentLetterResponse.fromJson(json['response'] as Map<String, dynamic>),
);
Map<String, dynamic> _$ParentLetterChildStateToJson(
_ParentLetterChildState instance,
) => <String, dynamic>{
'childId': instance.childId,
'status': _$ParentLetterStatusEnumMap[instance.status]!,
'editable': instance.editable,
'response': instance.response,
};
_ParentLetterThreadMessage _$ParentLetterThreadMessageFromJson(
Map<String, dynamic> json,
) => _ParentLetterThreadMessage(
id: json['id'] as String,
author: json['author'] == null
? const ParentLetterPerson()
: ParentLetterPerson.fromJson(json['author'] as Map<String, dynamic>),
body: json['body'] as String? ?? '',
sentAt: DateTime.parse(json['sentAt'] as String),
);
Map<String, dynamic> _$ParentLetterThreadMessageToJson(
_ParentLetterThreadMessage instance,
) => <String, dynamic>{
'id': instance.id,
'author': instance.author,
'body': instance.body,
'sentAt': instance.sentAt.toIso8601String(),
};
_ParentLetterThread _$ParentLetterThreadFromJson(Map<String, dynamic> json) =>
_ParentLetterThread(
enabled: json['enabled'] as bool? ?? false,
messages:
(json['messages'] as List<dynamic>?)
?.map(
(e) => ParentLetterThreadMessage.fromJson(
e as Map<String, dynamic>,
),
)
.toList() ??
const [],
);
Map<String, dynamic> _$ParentLetterThreadToJson(_ParentLetterThread instance) =>
<String, dynamic>{
'enabled': instance.enabled,
'messages': instance.messages,
};
_ParentLetterContent _$ParentLetterContentFromJson(Map<String, dynamic> json) =>
_ParentLetterContent(
body: json['body'] as String? ?? '',
attachments:
(json['attachments'] as List<dynamic>?)
?.map(
(e) =>
ParentLetterAttachment.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
request: json['request'] == null
? null
: ParentLetterRequest.fromJson(
json['request'] as Map<String, dynamic>,
),
children:
(json['children'] as List<dynamic>?)
?.map(
(e) =>
ParentLetterChildState.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
thread: json['thread'] == null
? const ParentLetterThread()
: ParentLetterThread.fromJson(json['thread'] as Map<String, dynamic>),
);
Map<String, dynamic> _$ParentLetterContentToJson(
_ParentLetterContent instance,
) => <String, dynamic>{
'body': instance.body,
'attachments': instance.attachments,
'request': instance.request,
'children': instance.children,
'thread': instance.thread,
};
@@ -0,0 +1,18 @@
import 'package:dio/dio.dart';
import '../../../errors/app_exception.dart';
import '../../marianumconnect_query.dart';
import 'parent_letter_exception.dart';
/// Base for the parent-letter calls: domain rejections surface as
/// [ParentLetterException].
abstract class ParentLetterQuery extends MarianumConnectQuery {
ParentLetterQuery({super.dio});
String letterPath(String letterId, [String suffix = '']) =>
'parent-letters/${Uri.encodeComponent(letterId)}$suffix';
@override
AppException mapError(DioException error) =>
ParentLetterException.fromDio(error);
}
@@ -0,0 +1,20 @@
import 'parent_letter_models.dart';
import 'parent_letter_query.dart';
/// `POST parent-letters/{id}/thread`. [clientMessageId] makes a retry
/// idempotent on the server.
class PostParentLetterThreadMessage extends ParentLetterQuery {
PostParentLetterThreadMessage({super.dio});
Future<ParentLetterThreadMessage> run({
required String letterId,
required String body,
required String clientMessageId,
}) => guard(() async {
final response = await dio.post<Map<String, dynamic>>(
endpoint(letterPath(letterId, '/thread')),
data: {'body': body, 'clientMessageId': clientMessageId},
);
return ParentLetterThreadMessage.fromJson(response.data!);
});
}
@@ -0,0 +1,34 @@
import 'dart:convert';
import 'dart:typed_data';
import 'parent_letter_models.dart';
import 'parent_letter_query.dart';
/// `PUT parent-letters/{id}/responses/{childId}`: creates or (while editable)
/// replaces the response for one child. Answers with the updated letter.
class SubmitParentLetterResponse extends ParentLetterQuery {
SubmitParentLetterResponse({super.dio});
Future<ParentLetterDetail> run({
required String letterId,
required String childId,
required List<ParentLetterAnswer> answers,
Uint8List? signaturePng,
}) => guard(() async {
final response = await dio.put<Map<String, dynamic>>(
endpoint(
letterPath(letterId, '/responses/${Uri.encodeComponent(childId)}'),
),
data: {
'answers': [
for (final answer in answers)
{'fieldId': answer.fieldId, 'optionIds': answer.optionIds},
],
'signaturePng': signaturePng == null
? null
: base64Encode(signaturePng),
},
);
return ParentLetterDetail.fromJson(response.data!);
});
}
+13 -16
View File
@@ -11,6 +11,7 @@ import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart
import 'main.dart'; import 'main.dart';
import 'model/data_cleaner.dart'; import 'model/data_cleaner.dart';
import 'notification/notification_controller.dart'; import 'notification/notification_controller.dart';
import 'notification/notification_service.dart';
import 'notification/notification_tasks.dart'; import 'notification/notification_tasks.dart';
import 'push/push_registration.dart'; import 'push/push_registration.dart';
import 'push/push_tap_router.dart'; import 'push/push_tap_router.dart';
@@ -119,18 +120,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
} }
} }
void _onPushTapPending() { void _onPushTargetPending() {
final token = PushTapRouter.pendingChatToken.value; final target = PushTapRouter.pendingTarget.value;
if (token == null || !mounted) return; if (target == null || !mounted) return;
PushTapRouter.pendingChatToken.value = null; PushTapRouter.pendingTarget.value = null;
NotificationTasks.navigateToTalk(context, chatToken: token); NotificationTasks.openPushTarget(context, target);
}
void _onNewsletterTapPending() {
final id = PushTapRouter.pendingNewsletterId.value;
if (id == null || !mounted) return;
PushTapRouter.pendingNewsletterId.value = null;
AppRoutes.openNewsletterById(context, id: id);
} }
Future<void> _handlePendingWidgetNavigation() async { Future<void> _handlePendingWidgetNavigation() async {
@@ -234,8 +228,12 @@ class _AppState extends State<App> with WidgetsBindingObserver {
// Android renders pushes locally, so a tap arrives via the local // Android renders pushes locally, so a tap arrives via the local
// notifications callback (PushTapRouter) rather than onMessageOpenedApp. // notifications callback (PushTapRouter) rather than onMessageOpenedApp.
PushTapRouter.pendingChatToken.addListener(_onPushTapPending); PushTapRouter.pendingTarget.addListener(_onPushTargetPending);
PushTapRouter.pendingNewsletterId.addListener(_onNewsletterTapPending); unawaited(
PushTapRouter.handleAppLaunch(
NotificationService().flutterLocalNotificationsPlugin,
),
);
_onMessageSub = FirebaseMessaging.onMessage.listen((message) { _onMessageSub = FirebaseMessaging.onMessage.listen((message) {
if (!mounted) return; if (!mounted) return;
@@ -264,8 +262,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
_onMessageSub?.cancel(); _onMessageSub?.cancel();
_onMessageOpenedAppSub?.cancel(); _onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel(); _fcmTokenRefreshSub?.cancel();
PushTapRouter.pendingChatToken.removeListener(_onPushTapPending); PushTapRouter.pendingTarget.removeListener(_onPushTargetPending);
PushTapRouter.pendingNewsletterId.removeListener(_onNewsletterTapPending);
ShareIntentListener.pending.removeListener(_handlePendingShare); ShareIntentListener.pending.removeListener(_handlePendingShare);
ShareIntentListener.instance.detach(); ShareIntentListener.instance.detach();
Main.bottomNavigator.removeListener(_onTabControllerChanged); Main.bottomNavigator.removeListener(_onTabControllerChanged);
+22 -3
View File
@@ -28,6 +28,7 @@ import 'auth_link/guardian_link_listener.dart';
import 'background/widget_background_task.dart'; import 'background/widget_background_task.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
import 'notification/notification_service.dart'; import 'notification/notification_service.dart';
import 'push/notification_permission_prompt.dart';
import 'push/push_message_handler.dart'; import 'push/push_message_handler.dart';
import 'push/push_registration.dart'; import 'push/push_registration.dart';
import 'push/push_registration_store.dart'; import 'push/push_registration_store.dart';
@@ -43,6 +44,7 @@ import 'state/app/modules/chat/bloc/chat_bloc.dart';
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import 'state/app/modules/children/child_selection_cubit.dart'; import 'state/app/modules/children/child_selection_cubit.dart';
import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
import 'state/app/modules/parent_letters/bloc/parent_letters_bloc.dart';
import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart';
import 'state/app/modules/timetable/primary/primary_timetable_scope.dart'; import 'state/app/modules/timetable/primary/primary_timetable_scope.dart';
import 'storage/hydrated_storage_bootstrap.dart'; import 'storage/hydrated_storage_bootstrap.dart';
@@ -256,6 +258,7 @@ Future<void> main() async {
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()), create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
), ),
BlocProvider<ChildSelectionCubit>(create: (_) => ChildSelectionCubit()), BlocProvider<ChildSelectionCubit>(create: (_) => ChildSelectionCubit()),
BlocProvider<ParentLettersBloc>(create: (_) => ParentLettersBloc()),
], ],
child: const PrimaryTimetableScope(child: Main()), child: const PrimaryTimetableScope(child: Main()),
), ),
@@ -327,16 +330,27 @@ class _MainState extends State<Main> {
capabilitiesCubit.load().then((_) { capabilitiesCubit.load().then((_) {
if (!mounted) return; if (!mounted) return;
_syncPush(settingsCubit, capabilitiesCubit); _syncPush(settingsCubit, capabilitiesCubit);
_promptGuardianNotifications();
}), }),
); );
unawaited(context.read<NextcloudCapabilitiesCubit>().load()); unawaited(context.read<NextcloudCapabilitiesCubit>().load());
} }
/// Waits for the post-login splash so the dialog never covers it; the
/// splash's completion calls this again.
void _promptGuardianNotifications() {
if (_showPostLoginSplash) return;
final overlayContext = AppRoutes.overlayContext;
if (overlayContext == null) return;
unawaited(maybePromptGuardianLoginNotifications(overlayContext));
}
/// Warms the chat list and files root in the background so the first screen /// Warms the chat list and files root in the background so the first screen
/// render hits populated data. The timetable needs no warm-up: /// render hits populated data. The timetable needs no warm-up:
/// PrimaryTimetableScope creates a freshly loading bloc per account. /// PrimaryTimetableScope creates a freshly loading bloc per account.
void _prefetchBaseData(BuildContext context) { void _prefetchBaseData(BuildContext context) {
unawaited(context.read<ChatListBloc>().refresh(silent: true)); unawaited(context.read<ChatListBloc>().refresh(silent: true));
unawaited(context.read<ParentLettersBloc>().refresh(silent: true));
if (SessionManager().hasNextcloud) { if (SessionManager().hasNextcloud) {
unawaited(ListFilesCache.prefetchRootListing()); unawaited(ListFilesCache.prefetchRootListing());
} }
@@ -482,6 +496,7 @@ class _MainState extends State<Main> {
final childSelectionCubit = context final childSelectionCubit = context
.read<ChildSelectionCubit>(); .read<ChildSelectionCubit>();
final chatListBloc = context.read<ChatListBloc>(); final chatListBloc = context.read<ChatListBloc>();
final parentLettersBloc = context.read<ParentLettersBloc>();
final chatBloc = context.read<ChatBloc>(); final chatBloc = context.read<ChatBloc>();
final nextcloudCapabilitiesCubit = context final nextcloudCapabilitiesCubit = context
.read<NextcloudCapabilitiesCubit>(); .read<NextcloudCapabilitiesCubit>();
@@ -495,6 +510,7 @@ class _MainState extends State<Main> {
settingsCubit: settingsCubit, settingsCubit: settingsCubit,
childSelectionCubit: childSelectionCubit, childSelectionCubit: childSelectionCubit,
chatListBloc: chatListBloc, chatListBloc: chatListBloc,
parentLettersBloc: parentLettersBloc,
chatBloc: chatBloc, chatBloc: chatBloc,
breakerBloc: breakerBloc, breakerBloc: breakerBloc,
capabilitiesCubit: capabilitiesCubit, capabilitiesCubit: capabilitiesCubit,
@@ -515,9 +531,10 @@ class _MainState extends State<Main> {
if (_showPostLoginSplash) if (_showPostLoginSplash)
PostLoginSplash( PostLoginSplash(
key: const ValueKey('post-login-splash'), key: const ValueKey('post-login-splash'),
onComplete: () => setState( onComplete: () {
() => _showPostLoginSplash = false, setState(() => _showPostLoginSplash = false);
), _promptGuardianNotifications();
},
), ),
], ],
); );
@@ -541,6 +558,7 @@ Future<void> _wipeUserState({
required SettingsCubit settingsCubit, required SettingsCubit settingsCubit,
required ChildSelectionCubit childSelectionCubit, required ChildSelectionCubit childSelectionCubit,
required ChatListBloc chatListBloc, required ChatListBloc chatListBloc,
required ParentLettersBloc parentLettersBloc,
required ChatBloc chatBloc, required ChatBloc chatBloc,
required BreakerBloc breakerBloc, required BreakerBloc breakerBloc,
required CapabilitiesCubit capabilitiesCubit, required CapabilitiesCubit capabilitiesCubit,
@@ -558,6 +576,7 @@ Future<void> _wipeUserState({
await Future.wait([ await Future.wait([
childSelectionCubit.reset(), childSelectionCubit.reset(),
chatListBloc.reset(), chatListBloc.reset(),
parentLettersBloc.reset(),
chatBloc.reset(), chatBloc.reset(),
breakerBloc.reset(), breakerBloc.reset(),
capabilitiesCubit.reset(), capabilitiesCubit.reset(),
+6 -22
View File
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../push/push_message_handler.dart'; import '../push/push_message_handler.dart';
import '../routing/app_routes.dart'; import '../push/push_target.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart'; import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../widget/debug/debug_tile.dart'; import '../widget/debug/debug_tile.dart';
import '../widget/debug/json_viewer.dart'; import '../widget/debug/json_viewer.dart';
@@ -42,18 +42,15 @@ class NotificationController {
RemoteMessage message, RemoteMessage message,
BuildContext context, BuildContext context,
) async { ) async {
final newsletterId = _extractNewsletterId(message); final target = resolvePushTarget(message.data);
if (newsletterId != null) { if (target != null) {
AppRoutes.openNewsletterById( NotificationTasks.openPushTarget(
context, context,
id: newsletterId, target,
title: message.notification?.title, title: message.notification?.title,
); );
} else { } else {
NotificationTasks.navigateToTalk( NotificationTasks.navigateToTalk(context);
context,
chatToken: _extractChatToken(message),
);
} }
NotificationTasks.updateProviders(context); NotificationTasks.updateProviders(context);
unawaited(NotificationTasks.refreshBadge()); unawaited(NotificationTasks.refreshBadge());
@@ -68,17 +65,4 @@ class NotificationController {
); );
}); });
} }
static String? _extractChatToken(RemoteMessage message) {
for (final key in const ['chatToken', 'token', 'roomToken']) {
final value = message.data[key];
if (value is String && value.isNotEmpty) return value;
}
return null;
}
static String? _extractNewsletterId(RemoteMessage message) {
final value = message.data['newsletterId'];
return value is String && value.isNotEmpty ? value : null;
}
} }
+38
View File
@@ -7,10 +7,13 @@ import 'package:flutter_app_badge/flutter_app_badge.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../push/chat_thread_store.dart'; import '../push/chat_thread_store.dart';
import '../push/push_renderer.dart';
import '../push/push_target.dart';
import '../routing/app_routes.dart'; import '../routing/app_routes.dart';
import '../session/session_manager.dart'; import '../session/session_manager.dart';
import '../state/app/modules/app_modules.dart'; import '../state/app/modules/app_modules.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../state/app/modules/parent_letters/bloc/parent_letters_bloc.dart';
import 'notification_service.dart'; import 'notification_service.dart';
class NotificationTasks { class NotificationTasks {
@@ -72,6 +75,41 @@ class NotificationTasks {
/// even if the user has already left. /// even if the user has already left.
static void updateProviders(BuildContext context) { static void updateProviders(BuildContext context) {
context.read<ChatListBloc>().refresh(); context.read<ChatListBloc>().refresh();
context.read<ParentLettersBloc>().refresh(silent: true);
}
/// [title] is the notification title, when the tap came with one.
static void openPushTarget(
BuildContext context,
PushTarget target, {
String? title,
}) {
switch (target) {
case ParentLetterTarget(:final letterId):
if (AppModule.isAvailableFor(
Modules.parentLetters,
SessionManager().current,
)) {
AppRoutes.openParentLetter(context, id: letterId);
}
case NewsletterTarget(:final newsletterId):
AppRoutes.openNewsletterById(context, id: newsletterId, title: title);
case ChatTarget(:final chatToken):
navigateToTalk(context, chatToken: chatToken);
}
}
/// Removes the letter's tray notification (Android; iOS alerts are posted by
/// the system and cannot be addressed by id).
static Future<void> clearParentLetterNotification(String letterId) async {
try {
await NotificationService().flutterLocalNotificationsPlugin.cancel(
id: PushRenderer.parentLetterNotificationId(letterId),
);
await refreshBadge();
} on Object catch (e) {
log('Parent letter notification cleanup failed: $e');
}
} }
/// Switches to the Talk tab. If [chatToken] is provided, also schedules /// Switches to the Talk tab. If [chatToken] is provided, also schedules
@@ -0,0 +1,190 @@
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);
}
await showDialog<void>(
context: context,
builder: ConfirmDialog(
icon: Icons.notifications_active_outlined,
title: 'Benachrichtigungen aktivieren',
content: prompt.explanation,
confirmButton: 'Weiter',
cancelButton: null,
onConfirm: () =>
unawaited(_requestPermission(context, settings, prompt)),
).build,
);
} 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);
}
+28 -5
View File
@@ -12,6 +12,7 @@ import 'nid_store.dart';
import 'push_actions.dart'; import 'push_actions.dart';
import 'push_avatar.dart'; import 'push_avatar.dart';
import 'push_subject.dart'; import 'push_subject.dart';
import 'push_target.dart';
/// Renders decrypted push subjects (and plaintext Connect pushes) as local /// Renders decrypted push subjects (and plaintext Connect pushes) as local
/// notifications. Talk messages of one chat stack into a SINGLE /// notifications. Talk messages of one chat stack into a SINGLE
@@ -23,6 +24,8 @@ class PushRenderer {
static const talkChannelName = 'Talk-Nachrichten'; static const talkChannelName = 'Talk-Nachrichten';
static const generalChannelId = 'nextcloud_general'; static const generalChannelId = 'nextcloud_general';
static const generalChannelName = 'Benachrichtigungen'; static const generalChannelName = 'Benachrichtigungen';
static const parentLettersChannelId = 'parent_letters';
static const parentLettersChannelName = 'Elternbriefe';
static const String iosTalkCategory = 'TALK_MESSAGE'; static const String iosTalkCategory = 'TALK_MESSAGE';
@@ -67,6 +70,14 @@ class PushRenderer {
description: 'Allgemeine Benachrichtigungen', description: 'Allgemeine Benachrichtigungen',
), ),
); );
await android.createNotificationChannel(
const AndroidNotificationChannel(
parentLettersChannelId,
parentLettersChannelName,
description: 'Neue Elternbriefe und Antworten der Schule',
importance: Importance.high,
),
);
} }
/// Renders a decrypted Nextcloud push subject. /// Renders a decrypted Nextcloud push subject.
@@ -350,23 +361,35 @@ class PushRenderer {
required String body, required String body,
Map<String, String>? data, Map<String, String>? data,
}) async { }) async {
final id = _fallbackId('$title$body'); final parentLetterId = data?[parentLetterIdKey];
const androidDetails = AndroidNotificationDetails( final isParentLetter =
generalChannelId, data?['type'] == parentLetterPushType &&
generalChannelName, parentLetterId != null &&
parentLetterId.isNotEmpty;
// One notification per letter: a reply replaces the letter's earlier one.
final id = isParentLetter
? parentLetterNotificationId(parentLetterId)
: _fallbackId('$title$body');
final androidDetails = AndroidNotificationDetails(
isParentLetter ? parentLettersChannelId : generalChannelId,
isParentLetter ? parentLettersChannelName : generalChannelName,
importance: Importance.high, importance: Importance.high,
priority: Priority.high, priority: Priority.high,
color: _accentColor, color: _accentColor,
styleInformation: isParentLetter ? BigTextStyleInformation(body) : null,
); );
await _plugin.show( await _plugin.show(
id: id, id: id,
title: title, title: title,
body: body, body: body,
notificationDetails: const NotificationDetails(android: androidDetails), notificationDetails: NotificationDetails(android: androidDetails),
payload: data == null ? null : jsonEncode(data), payload: data == null ? null : jsonEncode(data),
); );
} }
static int parentLetterNotificationId(String letterId) =>
stableChatNotificationId('parent-letter:$letterId');
String _payload({required String? chatToken, required int nid}) => String _payload({required String? chatToken, required int nid}) =>
jsonEncode({'chatToken': ?chatToken, 'nid': nid}); jsonEncode({'chatToken': ?chatToken, 'nid': nid});
+27 -19
View File
@@ -1,24 +1,22 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:developer';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'push_actions.dart'; import 'push_actions.dart';
import 'push_target.dart';
/// Routes foreground notification interactions from the single /// Routes foreground notification interactions from the single
/// flutter_local_notifications response callback. Action responses (reply / /// flutter_local_notifications response callback. Action responses (reply /
/// mark-read) are dispatched straight to [PushActions]; a plain tap publishes /// mark-read) are dispatched straight to [PushActions]; a plain tap publishes
/// the target chat token via [pendingChatToken] for [App] to navigate to. /// its target via [pendingTarget] for [App] to navigate to.
class PushTapRouter { class PushTapRouter {
PushTapRouter._(); PushTapRouter._();
/// Chat token of the most recently tapped Talk notification, or null. [App] /// Target of the most recently tapped notification, or null. [App] listens
/// listens to this and opens the chat, then resets it to null. /// to this and navigates, then resets it to null.
static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null); static final ValueNotifier<PushTarget?> pendingTarget = ValueNotifier(null);
/// Newsletter id of the most recently tapped Marianum-Message notification,
/// or null. [App] listens to this and opens the message, then resets it.
static final ValueNotifier<String?> pendingNewsletterId = ValueNotifier(null);
static void handleResponse(NotificationResponse response) { static void handleResponse(NotificationResponse response) {
final actionId = response.actionId; final actionId = response.actionId;
@@ -29,13 +27,28 @@ class PushTapRouter {
} }
final map = _payloadMap(response.payload); final map = _payloadMap(response.payload);
if (map == null) return; if (map == null) return;
final newsletterId = _stringValue(map, 'newsletterId'); final target = resolvePushTarget(map);
if (newsletterId != null) { if (target != null) pendingTarget.value = target;
pendingNewsletterId.value = newsletterId; }
return;
static bool _launchHandled = false;
/// Routes the tap that cold-started the app. The plugin reports such a tap
/// only through its launch details, never through the response callback;
/// the details stay set for the whole process, hence the one-shot guard.
static Future<void> handleAppLaunch(
FlutterLocalNotificationsPlugin plugin,
) async {
if (_launchHandled) return;
_launchHandled = true;
try {
final details = await plugin.getNotificationAppLaunchDetails();
final response = details?.notificationResponse;
if (details?.didNotificationLaunchApp != true || response == null) return;
handleResponse(response);
} on Object catch (e) {
log('Reading the notification launch details failed: $e');
} }
final token = _stringValue(map, 'chatToken');
if (token != null) pendingChatToken.value = token;
} }
static Map<String, dynamic>? _payloadMap(String? payload) { static Map<String, dynamic>? _payloadMap(String? payload) {
@@ -46,9 +59,4 @@ class PushTapRouter {
return null; return null;
} }
} }
static String? _stringValue(Map<String, dynamic> map, String key) {
final value = map[key];
return value is String && value.isNotEmpty ? value : null;
}
} }
+45
View File
@@ -0,0 +1,45 @@
/// `type` of the visible Connect push for a parent letter. Its data carries
/// the letter under [parentLetterIdKey].
const String parentLetterPushType = 'parent-letter';
const String parentLetterIdKey = 'parentLetterId';
/// Where a tapped notification leads.
sealed class PushTarget {
const PushTarget();
}
class ParentLetterTarget extends PushTarget {
final String letterId;
const ParentLetterTarget(this.letterId);
}
class NewsletterTarget extends PushTarget {
final String newsletterId;
const NewsletterTarget(this.newsletterId);
}
class ChatTarget extends PushTarget {
final String chatToken;
const ChatTarget(this.chatToken);
}
/// Resolves the data of a tapped notification — the payload of a locally
/// rendered one as well as the data of an FCM message. Null when it names no
/// known target.
PushTarget? resolvePushTarget(Map<String, dynamic> data) {
String? value(String key) {
final value = data[key];
return value is String && value.isNotEmpty ? value : null;
}
if (value(parentLetterIdKey) case final letterId?) {
return ParentLetterTarget(letterId);
}
if (value('newsletterId') case final newsletterId?) {
return NewsletterTarget(newsletterId);
}
for (final key in const ['chatToken', 'token', 'roomToken']) {
if (value(key) case final chatToken?) return ChatTarget(chatToken);
}
return null;
}
+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/feedback/feedback_dialog.dart';
import '../view/pages/more/roomplan/roomplan.dart'; import '../view/pages/more/roomplan/roomplan.dart';
import '../view/pages/more/share/qr_share_view.dart'; import '../view/pages/more/share/qr_share_view.dart';
import '../view/pages/parent_letters/parent_letter_view.dart';
import '../view/pages/settings/chat_background_settings_page.dart'; import '../view/pages/settings/chat_background_settings_page.dart';
import '../view/pages/settings/modules_settings_page.dart'; import '../view/pages/settings/modules_settings_page.dart';
import '../view/pages/settings/settings.dart'; import '../view/pages/settings/settings.dart';
@@ -203,6 +204,12 @@ class AppRoutes {
); );
} }
/// Opens a parent letter by id — from the inbox as well as from push deep
/// links, where only the id is known.
static void openParentLetter(BuildContext context, {required String id}) {
pushScreen(context, withNavBar: false, screen: ParentLetterView(id: id));
}
/// Opens a ticker page (CONTENT or PROXIED_FILE) as a standalone detail /// Opens a ticker page (CONTENT or PROXIED_FILE) as a standalone detail
/// screen. Used for deep links from outside the ticker module — inside the /// screen. Used for deep links from outside the ticker module — inside the
/// module pages open in-place instead. /// module pages open in-place instead.
+27 -29
View File
@@ -1,4 +1,3 @@
import 'package:badges/badges.dart' as badges;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
@@ -16,14 +15,18 @@ import '../../../view/pages/holidays/holidays_view.dart';
import '../../../view/pages/marianum_dates/marianum_dates_view.dart'; import '../../../view/pages/marianum_dates/marianum_dates_view.dart';
import '../../../view/pages/marianum_message/marianum_message_list_view.dart'; import '../../../view/pages/marianum_message/marianum_message_list_view.dart';
import '../../../view/pages/more/roomplan/roomplan.dart'; import '../../../view/pages/more/roomplan/roomplan.dart';
import '../../../view/pages/parent_letters/parent_letters_view.dart';
import '../../../view/pages/talk/chat_list.dart'; import '../../../view/pages/talk/chat_list.dart';
import '../../../view/pages/ticker/ticker_view.dart'; import '../../../view/pages/ticker/ticker_view.dart';
import '../../../view/pages/timetable/timetable.dart'; import '../../../view/pages/timetable/timetable.dart';
import '../../../widget/breaker/breaker.dart'; import '../../../widget/breaker/breaker.dart';
import '../../../widget/centered_leading.dart'; import '../../../widget/centered_leading.dart';
import '../../../widget/module_badge_icon.dart';
import '../infrastructure/loadable_state/loadable_state.dart'; import '../infrastructure/loadable_state/loadable_state.dart';
import 'chat_list/bloc/chat_list_bloc.dart'; import 'chat_list/bloc/chat_list_bloc.dart';
import 'chat_list/bloc/chat_list_state.dart'; import 'chat_list/bloc/chat_list_state.dart';
import 'parent_letters/bloc/parent_letters_bloc.dart';
import 'parent_letters/bloc/parent_letters_state.dart';
import 'settings/bloc/settings_cubit.dart'; import 'settings/bloc/settings_cubit.dart';
class AppModule { class AppModule {
@@ -46,6 +49,7 @@ class AppModule {
static const Map<Modules, Set<AccessRequirement>> requirements = { static const Map<Modules, Set<AccessRequirement>> requirements = {
Modules.talk: {AccessRequirement.nextcloud}, Modules.talk: {AccessRequirement.nextcloud},
Modules.files: {AccessRequirement.nextcloud}, Modules.files: {AccessRequirement.nextcloud},
Modules.parentLetters: {AccessRequirement.guardian},
}; };
static bool isAvailableFor(Modules module, Session? session) => static bool isAvailableFor(Modules module, Session? session) =>
@@ -64,6 +68,18 @@ class AppModule {
breakerArea: BreakerArea.timetable, breakerArea: BreakerArea.timetable,
create: Timetable.new, create: Timetable.new,
), ),
Modules.parentLetters: AppModule(
Modules.parentLetters,
name: 'Elternbriefe',
icon: () =>
BlocBuilder<ParentLettersBloc, LoadableState<ParentLettersState>>(
builder: (context, state) => ModuleBadgeIcon(
icon: Icons.mail_outline,
count: state.data?.unreadCount ?? 0,
),
),
create: ParentLettersView.new,
),
Modules.ticker: AppModule( Modules.ticker: AppModule(
Modules.ticker, Modules.ticker,
name: 'Ticker', name: 'Ticker',
@@ -77,34 +93,15 @@ class AppModule {
Modules.talk, Modules.talk,
name: 'Talk', name: 'Talk',
icon: () => BlocBuilder<ChatListBloc, LoadableState<ChatListState>>( icon: () => BlocBuilder<ChatListBloc, LoadableState<ChatListState>>(
builder: (context, state) { builder: (context, state) => ModuleBadgeIcon(
final rooms = state.data?.rooms; icon: Icons.chat,
if (rooms == null || rooms.data.isEmpty) { count:
return const Icon(Icons.chat); state.data?.rooms?.data.fold<int>(
} 0,
final messages = rooms.data (sum, room) => sum + room.unreadMessages,
.map((e) => e.unreadMessages) ) ??
.reduce((a, b) => a + b); 0,
return badges.Badge( ),
showBadge: messages > 0,
position: badges.BadgePosition.topEnd(top: -3, end: -3),
stackFit: StackFit.loose,
badgeStyle: badges.BadgeStyle(
padding: const EdgeInsets.all(3),
badgeColor: Theme.of(context).primaryColor,
elevation: 1,
),
badgeContent: Text(
'$messages',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
child: const Icon(Icons.chat),
);
},
), ),
breakerArea: BreakerArea.talk, breakerArea: BreakerArea.talk,
create: ChatList.new, create: ChatList.new,
@@ -299,6 +296,7 @@ class AppModule {
enum Modules { enum Modules {
timetable, timetable,
parentLetters,
ticker, ticker,
talk, talk,
files, files,
@@ -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);
}
}
+1
View File
@@ -31,6 +31,7 @@ Map<String, dynamic> _$ModulesSettingsToJson(
const _$ModulesEnumMap = { const _$ModulesEnumMap = {
Modules.timetable: 'timetable', Modules.timetable: 'timetable',
Modules.parentLetters: 'parentLetters',
Modules.ticker: 'ticker', Modules.ticker: 'ticker',
Modules.talk: 'talk', Modules.talk: 'talk',
Modules.files: 'files', Modules.files: 'files',
+11
View File
@@ -16,9 +16,20 @@ class NotificationSettings {
@JsonKey(defaultValue: false) @JsonKey(defaultValue: false)
bool talkPermissionPromptShown; 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({ NotificationSettings({
this.enabled = true, this.enabled = true,
this.talkPermissionPromptShown = false, this.talkPermissionPromptShown = false,
this.guardianLoginPromptShown = false,
this.parentLettersPromptShown = false,
}); });
factory NotificationSettings.fromJson(Map<String, dynamic> json) => factory NotificationSettings.fromJson(Map<String, dynamic> json) =>
+4
View File
@@ -12,6 +12,8 @@ NotificationSettings _$NotificationSettingsFromJson(
enabled: json['enabled'] as bool? ?? true, enabled: json['enabled'] as bool? ?? true,
talkPermissionPromptShown: talkPermissionPromptShown:
json['talkPermissionPromptShown'] as bool? ?? false, json['talkPermissionPromptShown'] as bool? ?? false,
guardianLoginPromptShown: json['guardianLoginPromptShown'] as bool? ?? false,
parentLettersPromptShown: json['parentLettersPromptShown'] as bool? ?? false,
); );
Map<String, dynamic> _$NotificationSettingsToJson( Map<String, dynamic> _$NotificationSettingsToJson(
@@ -19,4 +21,6 @@ Map<String, dynamic> _$NotificationSettingsToJson(
) => <String, dynamic>{ ) => <String, dynamic>{
'enabled': instance.enabled, 'enabled': instance.enabled,
'talkPermissionPromptShown': instance.talkPermissionPromptShown, 'talkPermissionPromptShown': instance.talkPermissionPromptShown,
'guardianLoginPromptShown': instance.guardianLoginPromptShown,
'parentLettersPromptShown': instance.parentLettersPromptShown,
}; };
+9
View File
@@ -8,3 +8,12 @@ String randomHexId({int bytes = 16}) {
(_) => random.nextInt(256), (_) => random.nextInt(256),
).map((b) => b.toRadixString(16).padLeft(2, '0')).join(); ).map((b) => b.toRadixString(16).padLeft(2, '0')).join();
} }
/// Random RFC 4122 version-4 UUID, for ids a server expects in UUID form.
String randomUuidV4() {
final hex = randomHexId();
final variant = (int.parse(hex[16], radix: 16) & 0x3 | 0x8).toRadixString(16);
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
'4${hex.substring(13, 16)}-$variant${hex.substring(17, 20)}-'
'${hex.substring(20)}';
}
@@ -0,0 +1,94 @@
import '../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
enum ParentLetterFormMode {
/// Nothing answered yet and the server accepts a response.
open,
/// Answered, but the server still accepts a change.
change,
/// Answered and locked.
done,
/// Unanswered and locked (deadline passed).
closed,
/// The letter asks for an input this app version cannot render.
unsupported,
}
/// What the response form of one child shows and allows. Whether a response
/// may be sent at all is the server's call ([ParentLetterChildState.editable]);
/// this only turns that verdict into a form.
class ParentLetterFormPolicy {
final ParentLetterFormMode mode;
final List<ParentLetterField> fields;
final bool signatureRequired;
const ParentLetterFormPolicy._(
this.mode,
this.fields,
this.signatureRequired,
);
bool get canSubmit =>
mode == ParentLetterFormMode.open || mode == ParentLetterFormMode.change;
bool get isAcknowledgement => fields.isEmpty && !signatureRequired;
/// Signed responses and acknowledgements cannot be changed afterwards, so
/// the form asks for confirmation first.
bool get submitIsFinal => isAcknowledgement || signatureRequired;
String get submitLabel {
if (isAcknowledgement) return 'Zur Kenntnis genommen';
if (signatureRequired) return 'Unterschreiben und absenden';
return mode == ParentLetterFormMode.change
? 'Rückmeldung ändern'
: 'Rückmeldung absenden';
}
/// [selection] maps field id to the chosen option id.
bool isComplete(Map<String, String> selection) => fields
.where((field) => field.isRequired)
.every((field) => selection.containsKey(field.id));
List<ParentLetterAnswer> answersFor(Map<String, String> selection) => [
for (final field in fields)
if (selection[field.id] case final optionId?)
ParentLetterAnswer(fieldId: field.id, optionIds: [optionId]),
];
/// The selection a previous [response] stands for.
static Map<String, String> selectionOf(ParentLetterResponse? response) => {
for (final answer in response?.answers ?? const <ParentLetterAnswer>[])
if (answer.optionIds.isNotEmpty) answer.fieldId: answer.optionIds.first,
};
/// Null when the letter asks for nothing.
static ParentLetterFormPolicy? resolve({
required ParentLetterRequest? request,
required ParentLetterChildState child,
}) {
if (request == null) return null;
final supported = request.fields
.where((field) => field.type != ParentLetterFieldType.unknown)
.toList();
final ParentLetterFormMode mode;
if (!child.editable) {
mode = child.response != null
? ParentLetterFormMode.done
: ParentLetterFormMode.closed;
} else if (request.fields.any(
(field) =>
field.isRequired && field.type == ParentLetterFieldType.unknown,
)) {
mode = ParentLetterFormMode.unsupported;
} else {
mode = child.response != null
? ParentLetterFormMode.change
: ParentLetterFormMode.open;
}
return ParentLetterFormPolicy._(mode, supported, request.signatureRequired);
}
}
@@ -0,0 +1,161 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import '../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart';
import '../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
import '../../../extensions/date_time.dart';
import '../../../notification/notification_tasks.dart';
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
import '../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import '../../../state/app/modules/parent_letters/bloc/parent_letter_bloc.dart';
import '../../../state/app/modules/parent_letters/bloc/parent_letter_state.dart';
import '../../../state/app/modules/parent_letters/bloc/parent_letters_bloc.dart';
import '../../../utils/url_opener.dart';
import 'parent_letter_form_policy.dart';
import 'widgets/parent_letter_attachments.dart';
import 'widgets/parent_letter_response_card.dart';
import 'widgets/parent_letter_thread.dart';
class ParentLetterView extends StatelessWidget {
final String id;
const ParentLetterView({required this.id, super.key});
@override
Widget build(
BuildContext context,
) => BlocModule<ParentLetterBloc, LoadableState<ParentLetterState>>(
create: (context) =>
ParentLetterBloc(id, inbox: context.read<ParentLettersBloc>()),
autoRebuild: true,
onInitialisation: (_, _) =>
NotificationTasks.clearParentLetterNotification(id),
child: (context, bloc, state) {
final letter = state.data?.letter;
return Scaffold(
appBar: AppBar(title: const Text('Elternbrief')),
body: Column(
children: [
Expanded(
child: LoadableStateConsumer<ParentLetterBloc, ParentLetterState>(
isReady: (state) => state.letter != null,
child: (state, loading) => _LetterBody(
letter: state.letter!,
children: context.watch<CapabilitiesCubit>().state.children,
bloc: bloc,
),
),
),
if (letter != null && letter.content.thread.enabled)
ParentLetterThreadInput(
recipientName: letter.summary.sender.displayName,
onSend: bloc.sendThreadMessage,
),
],
),
);
},
);
}
class _LetterBody extends StatelessWidget {
final ParentLetterDetail letter;
final List<GuardianChild> children;
final ParentLetterBloc bloc;
const _LetterBody({
required this.letter,
required this.children,
required this.bloc,
});
String _childName(String childId) =>
children.firstWhereOrNull((child) => child.id == childId)?.displayName ??
'dein Kind';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final summary = letter.summary;
final content = letter.content;
final editedAt = summary.editedAt;
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 16),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(summary.subject, style: theme.textTheme.titleLarge),
const SizedBox(height: 4),
Text(
[
summary.sender.displayName,
summary.sentAt.formatDateTime(),
if (editedAt != null)
'bearbeitet am ${editedAt.formatDateTime()}',
].join(' · '),
style: theme.textTheme.bodySmall,
),
if (children.length > 1)
Text(
'Betrifft: ${summary.childIds.map(_childName).join(', ')}',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 16),
SelectableLinkify(
text: content.body,
onOpen: UrlOpener.onOpen,
options: const LinkifyOptions(humanize: false),
style: theme.textTheme.bodyLarge,
),
],
),
),
if (content.attachments.isNotEmpty) ...[
const SizedBox(height: 8),
ParentLetterAttachments(
letterId: summary.id,
attachments: content.attachments,
load: bloc.loadAttachment,
),
],
const SizedBox(height: 8),
for (final child in content.children)
if (ParentLetterFormPolicy.resolve(
request: content.request,
child: child,
)
case final policy?)
ParentLetterResponseCard(
key: ValueKey((child.childId, child.response)),
childName: _childName(child.childId),
child: child,
policy: policy,
deadline: content.request?.deadline,
onSubmit: (answers, signaturePng) => bloc.submitResponse(
childId: child.childId,
answers: answers,
signaturePng: signaturePng,
),
),
if (content.thread.messages.isNotEmpty) ...[
const Padding(
padding: EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
'Nachrichten',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
ParentLetterThreadMessages(content.thread.messages),
],
],
);
}
}
@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart';
import '../../../push/notification_permission_prompt.dart';
import '../../../routing/app_routes.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import '../../../state/app/modules/parent_letters/bloc/parent_letters_bloc.dart';
import '../../../state/app/modules/parent_letters/bloc/parent_letters_state.dart';
import '../../../state/app/modules/parent_letters/parent_letters_logic.dart';
import '../../../widget/async_action_button.dart';
import '../../../widget/child_switcher.dart';
import '../../../widget/placeholder_view.dart';
import 'widgets/parent_letter_tile.dart';
class ParentLettersView extends StatefulWidget {
const ParentLettersView({super.key});
@override
State<ParentLettersView> createState() => _ParentLettersViewState();
}
class _ParentLettersViewState extends State<ParentLettersView> {
/// Null shows the letters of all children. Deliberately independent of the
/// app-wide child selection: the inbox must not hide a sibling's letters.
String? _childFilter;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.read<ParentLettersBloc>().refresh(silent: true);
maybePromptParentLetterNotifications(context);
});
}
@override
Widget build(BuildContext context) {
final children = context.watch<CapabilitiesCubit>().state.children;
final filter = children.any((child) => child.id == _childFilter)
? _childFilter
: null;
return Scaffold(
appBar: AppBar(title: const Text('Elternbriefe')),
body: Column(
children: [
if (children.length > 1)
_ChildFilterBar(
children: children,
selected: filter,
onSelected: (id) => setState(() => _childFilter = id),
),
Expanded(
child: LoadableStateConsumer<ParentLettersBloc, ParentLettersState>(
child: (state, loading) {
if (children.isEmpty) return const NoChildrenPlaceholder();
final letters = filterLettersByChild(state.letters, filter);
if (letters.isEmpty && !state.hasMore) {
return const PlaceholderView(
icon: Icons.mark_email_read_outlined,
text: 'Keine Elternbriefe vorhanden.',
);
}
return ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: letters.length + (state.hasMore ? 1 : 0),
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
if (index == letters.length) {
return Padding(
padding: const EdgeInsets.all(8),
child: AsyncTextButton(
onPressed: context
.read<ParentLettersBloc>()
.loadOlder,
child: const Text('Ältere Elternbriefe laden'),
),
);
}
final letter = letters[index];
return ParentLetterTile(
letter: letter,
children: children,
onTap: () =>
AppRoutes.openParentLetter(context, id: letter.id),
);
},
);
},
),
),
],
),
);
}
}
class _ChildFilterBar extends StatelessWidget {
final List<GuardianChild> children;
final String? selected;
final ValueChanged<String?> onSelected;
const _ChildFilterBar({
required this.children,
required this.selected,
required this.onSelected,
});
@override
Widget build(BuildContext context) => SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(
spacing: 8,
children: [
ChoiceChip(
label: const Text('Alle'),
selected: selected == null,
onSelected: (_) => onSelected(null),
),
for (final child in children)
ChoiceChip(
label: Text(child.firstName),
selected: selected == child.id,
onSelected: (_) => onSelected(child.id),
),
],
),
);
}
@@ -0,0 +1,87 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:filesize/filesize.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import '../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
import '../../../../routing/app_routes.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/demo_restricted.dart';
/// File name safe to use inside the cache directory: no path parts, never
/// empty.
String safeAttachmentFileName(ParentLetterAttachment attachment) {
final name = attachment.fileName
.split(RegExp(r'[/\\]'))
.last
.replaceAll(RegExp(r'[\x00-\x1f:*?"<>|]'), '_')
.trim();
return name.isEmpty || name == '.' || name == '..' ? 'Anhang' : name;
}
class ParentLetterAttachments extends StatelessWidget {
final String letterId;
final List<ParentLetterAttachment> attachments;
final Future<Uint8List> Function(String attachmentId) load;
const ParentLetterAttachments({
required this.letterId,
required this.attachments,
required this.load,
super.key,
});
Future<void> _open(
BuildContext context,
ParentLetterAttachment attachment,
) async {
if (guardDemoAction(context)) return;
final cache = await getTemporaryDirectory();
final directory = Directory(
[
cache.path,
'parent_letters',
Uri.encodeComponent(letterId),
Uri.encodeComponent(attachment.id),
].join(Platform.pathSeparator),
);
await directory.create(recursive: true);
final file = File(
'${directory.path}${Platform.pathSeparator}'
'${safeAttachmentFileName(attachment)}',
);
// An attachment never changes under its id, so a complete earlier download
// is reused.
final cached =
file.existsSync() &&
(attachment.size <= 0 || file.lengthSync() == attachment.size);
if (!cached) {
await file.writeAsBytes(await load(attachment.id), flush: true);
}
if (!context.mounted) return;
AppRoutes.openFileViewer(context, file.path);
}
@override
Widget build(BuildContext context) => Column(
children: [
for (final attachment in attachments)
AsyncListTile(
closeOnSuccess: false,
leading: const CenteredLeading(Icon(Icons.attach_file)),
title: Text(
safeAttachmentFileName(attachment),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: attachment.size > 0
? Text(filesize(attachment.size))
: null,
onPressed: () => _open(context, attachment),
),
],
);
}
@@ -0,0 +1,225 @@
import 'dart:typed_data';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import '../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
import '../../../../extensions/date_time.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/demo_restricted.dart';
import '../parent_letter_form_policy.dart';
import 'signature_sheet.dart';
typedef ParentLetterSubmit =
Future<void> Function(
List<ParentLetterAnswer> answers,
Uint8List? signaturePng,
);
/// Response form (or its result) of one child. The form state starts from
/// the child's response; key the card by it to restart on a new one.
class ParentLetterResponseCard extends StatefulWidget {
final String childName;
final ParentLetterChildState child;
final ParentLetterFormPolicy policy;
final DateTime? deadline;
final ParentLetterSubmit onSubmit;
const ParentLetterResponseCard({
required this.childName,
required this.child,
required this.policy,
required this.deadline,
required this.onSubmit,
super.key,
});
@override
State<ParentLetterResponseCard> createState() =>
_ParentLetterResponseCardState();
}
class _ParentLetterResponseCardState extends State<ParentLetterResponseCard> {
late Map<String, String> _selection = ParentLetterFormPolicy.selectionOf(
widget.child.response,
);
Future<void> _submit() async {
if (guardDemoAction(context)) return;
final policy = widget.policy;
Uint8List? signature;
if (policy.signatureRequired) {
signature = await showSignatureSheet(
context,
signerHint: 'Rückmeldung für ${widget.childName}',
);
if (signature == null || !mounted) return;
}
final answers = policy.answersFor(_selection);
if (!policy.submitIsFinal) return widget.onSubmit(answers, signature);
ConfirmDialog(
icon: Icons.task_alt,
title: 'Verbindlich absenden?',
content:
'Die Rückmeldung für ${widget.childName} kann danach nicht mehr '
'geändert werden.',
confirmButton: 'Absenden',
onConfirmAsync: () => widget.onSubmit(answers, signature),
).asDialog(context);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final policy = widget.policy;
final deadline = widget.deadline;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Rückmeldung für ${widget.childName}',
style: theme.textTheme.titleMedium,
),
),
if (deadline != null && policy.canSubmit)
Padding(
padding: const EdgeInsets.fromLTRB(16, 2, 16, 0),
child: Text(
'Frist: ${deadline.formatDateTime()}',
style: theme.textTheme.bodySmall,
),
),
const SizedBox(height: 8),
...switch (policy.mode) {
ParentLetterFormMode.open ||
ParentLetterFormMode.change => _form(policy),
ParentLetterFormMode.done => _result(theme, policy),
ParentLetterFormMode.closed => [
_note(
'Die Frist ist abgelaufen. Eine Rückmeldung ist nicht mehr '
'möglich.',
),
],
ParentLetterFormMode.unsupported => [
_note(
'Für diese Rückmeldung wird eine neuere Version der App '
'benötigt. Bitte aktualisiere die App.',
),
],
},
],
),
),
);
}
Widget _note(String text) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(text),
);
List<Widget> _form(ParentLetterFormPolicy policy) => [
if (policy.mode == ParentLetterFormMode.change)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _note(_respondedLine(widget.child.response!)),
),
for (final field in policy.fields) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
field.isRequired ? '${field.label} *' : field.label,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
RadioGroup<String>(
groupValue: _selection[field.id],
onChanged: (optionId) {
if (optionId == null) return;
setState(() => _selection = {..._selection, field.id: optionId});
},
child: Column(
children: [
for (final option in field.options)
RadioListTile<String>(
dense: true,
title: Text(option.label),
value: option.id,
),
],
),
),
],
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
child: AsyncActionButton(
icon: policy.signatureRequired ? Icons.draw_outlined : Icons.check,
onPressed: policy.isComplete(_selection) ? _submit : null,
child: Text(policy.submitLabel),
),
),
];
List<Widget> _result(ThemeData theme, ParentLetterFormPolicy policy) {
final response = widget.child.response!;
final selection = ParentLetterFormPolicy.selectionOf(response);
return [
for (final field in policy.fields)
if (selection[field.id] case final optionId?)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 6),
child: Text(
'${field.label}\n${_optionLabel(field, optionId)}',
style: theme.textTheme.bodyMedium,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
response.signed ? Icons.draw_outlined : Icons.task_alt,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(child: Text(_respondedLine(response))),
],
),
),
];
}
String _optionLabel(ParentLetterField field, String optionId) =>
field.options
.firstWhereOrNull((option) => option.id == optionId)
?.label ??
'';
String _respondedLine(ParentLetterResponse response) {
final verb = response.signed
? 'Unterschrieben'
: widget.policy.isAcknowledgement
? 'Zur Kenntnis genommen'
: 'Beantwortet';
final by = response.respondedBy.self
? 'von dir'
: response.respondedBy.displayName.isEmpty
? ''
: 'von ${response.respondedBy.displayName}';
final at = response.respondedAt;
return [
verb,
if (by.isNotEmpty) by,
if (at != null) 'am ${at.formatDateTime()}',
].join(' ');
}
}
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import '../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
/// Small pill for the response state of a letter; nothing for plain
/// information letters.
class ParentLetterStatusChip extends StatelessWidget {
final ParentLetterStatus status;
const ParentLetterStatusChip(this.status, {super.key});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final (label, background, foreground) = switch (status) {
ParentLetterStatus.info => (null, null, null),
ParentLetterStatus.open => (
'Rückmeldung offen',
scheme.primary,
scheme.onPrimary,
),
ParentLetterStatus.done => (
'Erledigt',
scheme.surfaceContainerHighest,
scheme.onSurfaceVariant,
),
ParentLetterStatus.expired => (
'Frist abgelaufen',
scheme.errorContainer,
scheme.onErrorContainer,
),
};
if (label == null) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(10),
),
child: Text(
label,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: foreground),
),
);
}
}
@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import '../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
import '../../../../extensions/date_time.dart';
import '../../../../utils/url_opener.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/demo_restricted.dart';
/// The private conversation between this guardian and the sender.
class ParentLetterThreadMessages extends StatelessWidget {
final List<ParentLetterThreadMessage> messages;
const ParentLetterThreadMessages(this.messages, {super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final message in messages)
Align(
alignment: message.author.self
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.sizeOf(context).width * 0.8,
),
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: message.author.self
? theme.colorScheme.primaryContainer
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
[
if (message.author.self)
'Du'
else
message.author.displayName,
message.sentAt.formatDateShortHm(),
].join(' · '),
style: theme.textTheme.labelSmall,
),
const SizedBox(height: 2),
SelectableLinkify(
text: message.body,
onOpen: UrlOpener.onOpen,
options: const LinkifyOptions(humanize: false),
),
],
),
),
),
],
);
}
}
class ParentLetterThreadInput extends StatefulWidget {
static const int maxLength = 4000;
final String recipientName;
final Future<void> Function(String body) onSend;
const ParentLetterThreadInput({
required this.recipientName,
required this.onSend,
super.key,
});
@override
State<ParentLetterThreadInput> createState() =>
_ParentLetterThreadInputState();
}
class _ParentLetterThreadInputState extends State<ParentLetterThreadInput> {
final TextEditingController _text = TextEditingController();
@override
void dispose() {
_text.dispose();
super.dispose();
}
Future<void> _send() async {
if (guardDemoAction(context)) return;
await widget.onSend(_text.text.trim());
_text.clear();
}
@override
Widget build(BuildContext context) => Material(
elevation: 8,
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 4, 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: _text,
minLines: 1,
maxLines: 5,
maxLength: ParentLetterThreadInput.maxLength,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
hintText: widget.recipientName.isEmpty
? 'Antworten'
: 'Antwort an ${widget.recipientName}',
border: InputBorder.none,
counterText: '',
),
),
),
ValueListenableBuilder(
valueListenable: _text,
builder: (context, value, _) => AsyncIconButton(
icon: Icons.send,
tooltip: 'Senden',
onPressed: value.text.trim().isEmpty ? null : _send,
),
),
],
),
),
),
);
}
@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import '../../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart';
import '../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
import '../../../../extensions/date_time.dart';
import '../../../../widget/a11y/a11y_labels.dart';
import 'parent_letter_status_chip.dart';
class ParentLetterTile extends StatelessWidget {
final ParentLetterSummary letter;
/// The guardian's children; names are only shown when there is more than
/// one to tell apart.
final List<GuardianChild> children;
final VoidCallback onTap;
const ParentLetterTile({
required this.letter,
required this.children,
required this.onTap,
super.key,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final unread = !letter.read;
final childNames = children.length < 2
? ''
: children
.where((child) => letter.childIds.contains(child.id))
.map((child) => child.firstName)
.join(', ');
final deadline = letter.deadline;
return ListTile(
leading: Semantics(
label: unread ? A11yLabels.unread : null,
child: Badge(
isLabelVisible: unread,
smallSize: 10,
backgroundColor: theme.primaryColor,
child: Icon(unread ? Icons.mail : Icons.drafts_outlined),
),
),
title: Text(
letter.subject,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: unread ? const TextStyle(fontWeight: FontWeight.bold) : null,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
[
letter.sender.displayName,
letter.sentAt.formatDateRelativeShort(),
if (childNames.isNotEmpty) childNames,
].join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (letter.preview.isNotEmpty)
Text(letter.preview, maxLines: 2, overflow: TextOverflow.ellipsis),
if (letter.status != ParentLetterStatus.info)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
ParentLetterStatusChip(letter.status),
if (deadline != null &&
letter.status == ParentLetterStatus.open)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
'bis ${deadline.formatDate()}',
style: theme.textTheme.labelSmall,
),
),
],
),
),
],
),
trailing: letter.attachmentCount > 0
? const Icon(Icons.attach_file, size: 18)
: null,
onTap: onTap,
);
}
}
@@ -0,0 +1,125 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:signature/signature.dart';
import '../../../../widget/info_dialog.dart';
/// Upper bound the server accepts for a signature image.
const int maxSignatureBytes = 256 * 1024;
/// Lets the guardian draw a signature. Resolves to the PNG (transparent
/// background, cropped to the strokes) or null when dismissed.
Future<Uint8List?> showSignatureSheet(
BuildContext context, {
required String signerHint,
}) => showModalBottomSheet<Uint8List>(
context: context,
isScrollControlled: true,
enableDrag: false,
showDragHandle: false,
builder: (_) => _SignatureSheet(signerHint: signerHint),
);
class _SignatureSheet extends StatefulWidget {
final String signerHint;
const _SignatureSheet({required this.signerHint});
@override
State<_SignatureSheet> createState() => _SignatureSheetState();
}
class _SignatureSheetState extends State<_SignatureSheet> {
// Paper-like pad in both themes: what is drawn is what gets exported.
final SignatureController _controller = SignatureController(
penStrokeWidth: 2.5,
penColor: Colors.black,
strokeCap: StrokeCap.round,
strokeJoin: StrokeJoin.round,
);
@override
void initState() {
super.initState();
_controller.addListener(_onChanged);
}
bool _isEmpty = true;
// The controller notifies per drawn point; only the buttons depend on it.
void _onChanged() {
if (_controller.isEmpty == _isEmpty) return;
setState(() => _isEmpty = _controller.isEmpty);
}
@override
void dispose() {
_controller.removeListener(_onChanged);
_controller.dispose();
super.dispose();
}
Future<void> _accept() async {
var png = await _controller.toPngBytes();
if (png != null && png.lengthInBytes > maxSignatureBytes) {
png = await _controller.toPngBytes(width: 600);
}
if (!mounted) return;
if (png == null || png.lengthInBytes > maxSignatureBytes) {
InfoDialog.show(
context,
'Die Unterschrift konnte nicht übernommen werden. Bitte versuche es '
'erneut.',
title: 'Unterschrift',
);
return;
}
Navigator.pop(context, png);
}
@override
Widget build(BuildContext context) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Unterschrift', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 4),
Text(widget.signerHint),
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Signature(
controller: _controller,
height: 220,
backgroundColor: Colors.white,
),
),
const SizedBox(height: 8),
Row(
children: [
TextButton.icon(
onPressed: _isEmpty ? null : _controller.clear,
icon: const Icon(Icons.undo),
label: const Text('Löschen'),
),
const Spacer(),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _isEmpty ? null : _accept,
child: const Text('Übernehmen'),
),
],
),
],
),
),
);
}
@@ -25,6 +25,7 @@ class DefaultSettings {
modulesSettings: ModulesSettings( modulesSettings: ModulesSettings(
moduleOrder: [ moduleOrder: [
Modules.timetable, Modules.timetable,
Modules.parentLetters,
Modules.ticker, Modules.ticker,
Modules.talk, Modules.talk,
Modules.files, Modules.files,
+1 -1
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_split_view/flutter_split_view.dart'; import 'package:flutter_split_view/flutter_split_view.dart';
import '../../../push/notification_permission_prompt.dart';
import '../../../routing/app_routes.dart'; import '../../../routing/app_routes.dart';
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
@@ -14,7 +15,6 @@ import '../../../widget/demo_restricted.dart';
import '../../../widget/placeholder_view.dart'; import '../../../widget/placeholder_view.dart';
import 'data/open_direct_chat.dart'; import 'data/open_direct_chat.dart';
import 'join_chat.dart'; import 'join_chat.dart';
import 'notification_permission_prompt.dart';
import 'search_chat.dart'; import 'search_chat.dart';
import 'widgets/chat_tile.dart'; import 'widgets/chat_tile.dart';
import 'widgets/split_view_placeholder.dart'; import 'widgets/split_view_placeholder.dart';
@@ -1,89 +0,0 @@
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 '../../../push/push_registration.dart';
import '../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../widget/confirm_dialog.dart';
/// 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) async {
final settings = context.read<SettingsCubit>();
final notificationSettings = settings.val().notificationSettings;
// Already handled once, or the user opted out of push entirely.
if (notificationSettings.talkPermissionPromptShown) return;
if (!notificationSettings.enabled) return;
// Capabilities may still be loading on a fresh cold start; retry on the next
// Talk visit instead of burning the one-shot flag.
if (!context.read<CapabilitiesCubit>().canReceivePushNotifications) return;
// Existing users who already granted the permission: register silently and
// mark the prompt as handled without showing any dialog.
if (await PushRegistration.isOsPermissionGranted()) {
settings.val(write: true).notificationSettings.talkPermissionPromptShown =
true;
unawaited(PushRegistration().register());
return;
}
if (!context.mounted) return;
ConfirmDialog(
icon: Icons.notifications_active_outlined,
title: 'Benachrichtigungen aktivieren',
content:
'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.',
confirmButton: 'Weiter',
cancelButton: null,
onConfirm: () => unawaited(_requestPermission(context, settings)),
).asDialog(context);
}
Future<void> _requestPermission(
BuildContext context,
SettingsCubit settings,
) 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 Talk visit.
settings.val(write: true).notificationSettings.talkPermissionPromptShown =
true;
if (granted) {
unawaited(PushRegistration().register());
return;
}
log('Push: notification permission declined on first Talk visit');
if (!context.mounted) return;
ConfirmDialog(
icon: Icons.notifications_off_outlined,
title: 'Benachrichtigungen deaktiviert',
content:
'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.',
confirmButton: 'Einstellungen öffnen',
cancelButton: 'Später',
onConfirm: () =>
AppSettings.openAppSettings(type: AppSettingsType.notification),
).asDialog(context);
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:badges/badges.dart' as badges;
import 'package:flutter/material.dart';
/// Module icon with an unread counter; the plain icon while [count] is zero.
class ModuleBadgeIcon extends StatelessWidget {
final IconData icon;
final int count;
const ModuleBadgeIcon({required this.icon, required this.count, super.key});
@override
Widget build(BuildContext context) => badges.Badge(
showBadge: count > 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(
'$count',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
child: Icon(icon),
);
}
+1
View File
@@ -107,6 +107,7 @@ dependencies:
app_links: ^7.2.1 app_links: ^7.2.1
integration_test: integration_test:
sdk: flutter sdk: flutter
signature: ^6.4.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+31
View File
@@ -0,0 +1,31 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/access/access_requirement.dart';
import 'package:marianum_mobile/session/session.dart';
void main() {
final student = CredentialSession(username: 'max', password: 'pw');
const guardian = GuardianSession(email: 'e@x.de');
test('nextcloud is only met by sessions with a Nextcloud identity', () {
expect(AccessRequirement.nextcloud.isMetBy(student), isTrue);
expect(AccessRequirement.nextcloud.isMetBy(guardian), isFalse);
expect(AccessRequirement.nextcloud.isMetBy(null), isFalse);
});
test('guardian is only met by guardian sessions', () {
expect(AccessRequirement.guardian.isMetBy(guardian), isTrue);
expect(AccessRequirement.guardian.isMetBy(student), isFalse);
expect(AccessRequirement.guardian.isMetBy(null), isFalse);
});
test('a set is met only when every requirement is met', () {
expect(<AccessRequirement>{}.areMetBy(null), isTrue);
expect(
{
AccessRequirement.guardian,
AccessRequirement.nextcloud,
}.areMetBy(guardian),
isFalse,
);
});
}
@@ -0,0 +1,241 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/errors/auth_exception.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/parent_letters/get_parent_letter.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/parent_letters/parent_letter_exception.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
const _detailJson = '''
{
"id": "l1", "subject": "Wandertag 10b", "preview": "Liebe Eltern",
"sender": {"displayName": "Hr. Müller"},
"sentAt": "2026-09-20T08:15:00", "editedAt": null, "read": false,
"childIds": ["c1"], "attachmentCount": 1, "status": "open",
"deadline": "2026-10-01T23:59:00",
"body": "Liebe Eltern,\\n…",
"attachments": [{"id": "a1", "fileName": "Brief.pdf", "mimeType": "application/pdf", "size": 48211}],
"request": {
"fields": [
{"id": "f1", "type": "single_choice", "label": "Teilnahme?", "required": true,
"options": [{"id": "o1", "label": "Ja"}, {"id": "o2", "label": "Nein"}]},
{"id": "f2", "type": "date_range", "label": "Zeitraum", "required": false, "options": null}
],
"signatureRequired": true, "deadline": "2026-10-01T23:59:00"
},
"children": [
{"childId": "c1", "status": "done", "editable": false,
"response": {"respondedAt": "2026-09-21T19:02:11",
"respondedBy": {"displayName": "A. Hoffmann", "self": true},
"answers": [{"fieldId": "f1", "optionIds": ["o1"], "text": null}], "signed": true}}
],
"thread": {"enabled": true, "messages": [
{"id": "m1", "author": {"displayName": "Hr. Müller", "self": false}, "body": "Danke", "sentAt": "2026-09-21T20:10:00"}
]}
}
''';
void main() {
group('ParentLetterSummary', () {
test('parses the documented inbox entry', () {
final response = ParentLetterListResponse.fromJson({
'items': [
{
'id': 'l1',
'subject': 'Wandertag',
'preview': 'Liebe Eltern',
'sender': {'displayName': 'Hr. Müller'},
'sentAt': '2026-09-20T08:15:00',
'editedAt': null,
'read': false,
'childIds': ['c1', 'c2'],
'attachmentCount': 2,
'status': 'expired',
'deadline': '2026-10-01T23:59:00',
},
],
'hasMore': true,
'unreadCount': 3,
'openCount': 1,
});
final letter = response.items.single;
expect(letter.sender.displayName, 'Hr. Müller');
expect(letter.sentAt, DateTime(2026, 9, 20, 8, 15));
expect(letter.read, isFalse);
expect(letter.childIds, ['c1', 'c2']);
expect(letter.status, ParentLetterStatus.expired);
expect(letter.deadline, DateTime(2026, 10, 1, 23, 59));
expect(response.hasMore, isTrue);
expect(response.unreadCount, 3);
expect(response.openCount, 1);
});
test('explicit nulls and unknown values fall back to safe defaults', () {
final letter = ParentLetterSummary.fromJson({
'id': 'l1',
'subject': null,
'sender': null,
'sentAt': '2026-09-20T08:15:00',
'read': null,
'childIds': null,
'status': 'archived',
'deadline': null,
});
expect(letter.subject, '');
expect(letter.sender.displayName, '');
expect(letter.read, isTrue);
expect(letter.childIds, isEmpty);
expect(letter.status, ParentLetterStatus.info);
expect(letter.deadline, isNull);
});
test('an empty list response is an empty inbox', () {
final response = ParentLetterListResponse.fromJson(const {});
expect(response.items, isEmpty);
expect(response.hasMore, isFalse);
expect(response.unreadCount, 0);
});
});
group('ParentLetterDetail', () {
final detail = ParentLetterDetail.fromJson(
jsonDecode(_detailJson) as Map<String, dynamic>,
);
test('splits the flat object into summary and content', () {
expect(detail.summary.id, 'l1');
expect(detail.summary.status, ParentLetterStatus.open);
expect(detail.content.body, startsWith('Liebe Eltern'));
expect(detail.content.attachments.single.size, 48211);
expect(detail.content.thread.messages.single.author.self, isFalse);
});
test('keeps unknown field types and tolerates null options', () {
final fields = detail.content.request!.fields;
expect(fields[0].type, ParentLetterFieldType.singleChoice);
expect(fields[0].isRequired, isTrue);
expect(fields[0].options.map((o) => o.id), ['o1', 'o2']);
expect(fields[1].type, ParentLetterFieldType.unknown);
expect(fields[1].options, isEmpty);
});
test('parses the per-child response', () {
final child = detail.content.children.single;
expect(child.editable, isFalse);
expect(child.response!.respondedBy.self, isTrue);
expect(child.response!.signed, isTrue);
expect(child.response!.answers.single.optionIds, ['o1']);
});
test('a letter without request, children and thread is pure information', () {
final info = ParentLetterDetail.fromJson({
'id': 'l2',
'sentAt': '2026-09-20T08:15:00',
'request': null,
'thread': null,
});
expect(info.content.request, isNull);
expect(info.content.children, isEmpty);
expect(info.content.thread.enabled, isFalse);
});
test('survives the storage round trip', () {
final restored = ParentLetterDetail.fromJson(
jsonDecode(jsonEncode(detail.toJson())) as Map<String, dynamic>,
);
expect(restored, detail);
});
});
group('ParentLetterException mapping', () {
Future<Object> errorOf(int status, Object? body) async {
final options = RequestOptions(path: 'parent-letters/l1');
final dio = _ThrowingDio(
DioException(
requestOptions: options,
type: DioExceptionType.badResponse,
response: Response<dynamic>(
requestOptions: options,
statusCode: status,
data: body,
),
),
);
try {
await GetParentLetter(dio: dio).run('l1');
} catch (e) {
return e;
}
fail('expected an error');
}
test('reads the JSON error code', () async {
final error = await errorOf(409, {'error': 'response_final'});
expect(
error,
isA<ParentLetterException>()
.having((e) => e.error, 'error', ParentLetterError.responseFinal)
.having((e) => e.allowRetry, 'allowRetry', isFalse),
);
});
test('reads the plain-text "Fehler: <code>" body', () async {
final error = await errorOf(410, 'Fehler: deadline_passed');
expect(
(error as ParentLetterException).error,
ParentLetterError.deadlinePassed,
);
});
test('the code wins over the status', () async {
final error = await errorOf(404, {'error': 'child_not_found'});
expect(
(error as ParentLetterException).error,
ParentLetterError.childNotFound,
);
});
test('falls back to the status without a known code', () async {
expect(
((await errorOf(404, null)) as ParentLetterException).error,
ParentLetterError.letterNotFound,
);
expect(
((await errorOf(409, 'Fehler: whatever')) as ParentLetterException)
.error,
ParentLetterError.responseFinal,
);
});
test('401 and 5xx keep the generic mapping', () async {
expect(await errorOf(401, 'Fehler: x'), isA<AuthException>());
expect(
await errorOf(500, {'error': 'letter_not_found'}),
isNot(isA<ParentLetterException>()),
);
});
});
}
/// Minimal fake Dio whose `get` always fails with the given exception; every
/// other member is unused.
class _ThrowingDio implements Dio {
final DioException error;
_ThrowingDio(this.error);
@override
Future<Response<T>> get<T>(
String path, {
Object? data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onReceiveProgress,
}) => Future<Response<T>>.error(error);
@override
dynamic noSuchMethod(Invocation invocation) =>
super.noSuchMethod(invocation);
}
+41
View File
@@ -0,0 +1,41 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/demo/data/demo_parent_letters.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
void main() {
test('every demo inbox letter resolves to a consistent detail', () {
final inbox = DemoParentLetters.list();
expect(inbox.items, isNotEmpty);
expect(
inbox.unreadCount,
inbox.items.where((letter) => !letter.read).length,
);
for (final letter in inbox.items) {
final detail = DemoParentLetters.detail(letter.id);
expect(detail.summary.id, letter.id);
expect(detail.summary.status, letter.status);
expect(
detail.content.children.map((child) => child.childId),
detail.content.request == null ? isEmpty : letter.childIds,
);
// The inbox is hydrated, so the fixtures must survive storage.
expect(
ParentLetterDetail.fromJson(
jsonDecode(jsonEncode(detail.toJson())) as Map<String, dynamic>,
).content,
detail.content,
);
}
});
test('a letter read in the session stays read', () {
final unread = DemoParentLetters.list().items.firstWhere((l) => !l.read);
DemoParentLetters.markRead(unread.id);
final after = DemoParentLetters.list().items.firstWhere(
(l) => l.id == unread.id,
);
expect(after.read, isTrue);
});
}
+66
View File
@@ -0,0 +1,66 @@
import 'dart:convert';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/push/push_tap_router.dart';
import 'package:marianum_mobile/push/push_target.dart';
void main() {
group('resolvePushTarget', () {
test('a parent letter push routes by its letter id', () {
final target = resolvePushTarget({
'source': 'connect',
'type': 'parent-letter',
'parentLetterId': 'l1',
'event': 'reply',
'title': 'Hr. Müller: Wandertag',
});
expect(
target,
isA<ParentLetterTarget>().having((t) => t.letterId, 'letterId', 'l1'),
);
});
test('newsletter pushes route by newsletterId', () {
expect(
resolvePushTarget({'source': 'connect', 'newsletterId': 'n1'}),
isA<NewsletterTarget>().having((t) => t.newsletterId, 'id', 'n1'),
);
});
test('chat pushes accept every token key in use', () {
for (final key in ['chatToken', 'token', 'roomToken']) {
expect(
resolvePushTarget({key: 'abc', 'nid': 5}),
isA<ChatTarget>().having((t) => t.chatToken, 'chatToken', 'abc'),
reason: key,
);
}
});
test('empty ids and unrelated data name no target', () {
expect(resolvePushTarget({'parentLetterId': ''}), isNull);
expect(resolvePushTarget({'type': 'widget-refresh'}), isNull);
expect(resolvePushTarget({'chatToken': 7}), isNull);
});
});
group('PushTapRouter.handleResponse', () {
setUp(() => PushTapRouter.pendingTarget.value = null);
NotificationResponse tap(String payload) => NotificationResponse(
notificationResponseType: NotificationResponseType.selectedNotification,
payload: payload,
);
test('publishes the target of a tapped notification', () {
PushTapRouter.handleResponse(tap(jsonEncode({'parentLetterId': 'l1'})));
expect(PushTapRouter.pendingTarget.value, isA<ParentLetterTarget>());
});
test('ignores broken payloads', () {
PushTapRouter.handleResponse(tap('not json'));
expect(PushTapRouter.pendingTarget.value, isNull);
});
});
}
+12 -5
View File
@@ -29,9 +29,15 @@ void main() {
final effective = AppModule.effectiveModuleOrder(settingsWith(custom)); final effective = AppModule.effectiveModuleOrder(settingsWith(custom));
// parentLetters and ticker are both missing and follow timetable in the
// declared order, so they line up behind it.
expect(
effective.indexOf(Modules.parentLetters),
effective.indexOf(Modules.timetable) + 1,
);
expect( expect(
effective.indexOf(Modules.ticker), effective.indexOf(Modules.ticker),
effective.indexOf(Modules.timetable) + 1, effective.indexOf(Modules.parentLetters) + 1,
); );
expect(effective.toSet(), Modules.values.toSet()); expect(effective.toSet(), Modules.values.toSet());
}); });
@@ -94,10 +100,11 @@ void main() {
final student = CredentialSession(username: 'max', password: 'pw'); final student = CredentialSession(username: 'max', password: 'pw');
const guardian = GuardianSession(email: 'e@x.de'); const guardian = GuardianSession(email: 'e@x.de');
test('password accounts see every module', () { test('password accounts see every module but the guardian ones', () {
for (final m in Modules.values) { final hidden = Modules.values
expect(AppModule.isAvailableFor(m, student), isTrue, reason: m.name); .where((m) => !AppModule.isAvailableFor(m, student))
} .toSet();
expect(hidden, {Modules.parentLetters});
}); });
test('guardians lose exactly the Nextcloud modules', () { test('guardians lose exactly the Nextcloud modules', () {
+104
View File
@@ -0,0 +1,104 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
import 'package:marianum_mobile/state/app/modules/parent_letters/bloc/parent_letters_state.dart';
import 'package:marianum_mobile/state/app/modules/parent_letters/parent_letters_logic.dart';
ParentLetterSummary _letter(
String id, {
bool read = true,
List<String> childIds = const ['c1'],
}) => ParentLetterSummary(
id: id,
sentAt: DateTime(2026, 9, 20),
read: read,
childIds: childIds,
);
void main() {
test('firstPageState takes over list and counters', () {
final state = firstPageState(
ParentLetterListResponse(
items: [_letter('a')],
hasMore: true,
unreadCount: 2,
openCount: 1,
),
);
expect(state.letters.single.id, 'a');
expect(state.hasMore, isTrue);
expect(state.unreadCount, 2);
expect(state.openCount, 1);
});
test('appendOlderPage appends without duplicating known letters', () {
final state = ParentLettersState(
letters: [_letter('a'), _letter('b')],
hasMore: true,
);
final next = appendOlderPage(
state,
ParentLetterListResponse(items: [_letter('b'), _letter('c')]),
);
expect(next.letters.map((l) => l.id), ['a', 'b', 'c']);
expect(next.hasMore, isFalse);
});
group('withLetterRead', () {
final state = ParentLettersState(
letters: [_letter('a', read: false), _letter('b')],
unreadCount: 1,
);
test('marks the letter and lowers the counter', () {
final next = withLetterRead(state, 'a');
expect(next.letters.first.read, isTrue);
expect(next.unreadCount, 0);
});
test('returns the same state for read or unknown letters', () {
expect(identical(withLetterRead(state, 'b'), state), isTrue);
expect(identical(withLetterRead(state, 'x'), state), isTrue);
});
test('never drops the counter below zero', () {
final drifted = state.copyWith(unreadCount: 0);
expect(withLetterRead(drifted, 'a').unreadCount, 0);
});
});
group('withSummary', () {
final open = _letter('b').copyWith(status: ParentLetterStatus.open);
final state = ParentLettersState(
letters: [_letter('a'), open],
openCount: 3,
);
test('replaces the letter in place and lowers the open counter', () {
final next = withSummary(
state,
open.copyWith(status: ParentLetterStatus.done),
);
expect(next.letters.map((l) => l.id), ['a', 'b']);
expect(next.letters.last.status, ParentLetterStatus.done);
expect(next.openCount, 2);
});
test('keeps the counter while the letter stays open', () {
expect(withSummary(state, open.copyWith(read: false)).openCount, 3);
});
test('ignores letters the inbox does not hold', () {
expect(identical(withSummary(state, _letter('x')), state), isTrue);
});
});
test('filterLettersByChild keeps letters that concern the child', () {
final letters = [
_letter('a', childIds: ['c1']),
_letter('b', childIds: ['c2']),
_letter('c', childIds: ['c1', 'c2']),
];
expect(filterLettersByChild(letters, null), letters);
expect(filterLettersByChild(letters, 'c2').map((l) => l.id), ['b', 'c']);
});
}
+15
View File
@@ -0,0 +1,15 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/utils/random_id.dart';
void main() {
test('randomUuidV4 is a well-formed version-4 UUID', () {
final pattern = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
);
final ids = {for (var i = 0; i < 200; i++) randomUuidV4()};
expect(ids, hasLength(200));
for (final id in ids) {
expect(id, matches(pattern));
}
});
}
@@ -0,0 +1,168 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
import 'package:marianum_mobile/view/pages/parent_letters/parent_letter_form_policy.dart';
import 'package:marianum_mobile/view/pages/parent_letters/widgets/parent_letter_attachments.dart';
const _choice = ParentLetterField(
id: 'f1',
type: ParentLetterFieldType.singleChoice,
label: 'Teilnahme?',
isRequired: true,
options: [
ParentLetterOption(id: 'yes', label: 'Ja'),
ParentLetterOption(id: 'no', label: 'Nein'),
],
);
const _optionalChoice = ParentLetterField(
id: 'f2',
type: ParentLetterFieldType.singleChoice,
);
const _unknownRequired = ParentLetterField(id: 'f3', isRequired: true);
const _unknownOptional = ParentLetterField(id: 'f4');
const _answered = ParentLetterResponse(
answers: [
ParentLetterAnswer(fieldId: 'f1', optionIds: ['no']),
],
);
ParentLetterChildState _child({
bool editable = true,
ParentLetterResponse? response,
}) => ParentLetterChildState(
childId: 'c1',
editable: editable,
response: response,
);
void main() {
group('ParentLetterFormPolicy.resolve', () {
test('letters without a request have no form', () {
expect(
ParentLetterFormPolicy.resolve(request: null, child: _child()),
isNull,
);
});
test('mode follows the server verdict and the existing response', () {
const request = ParentLetterRequest(fields: [_choice]);
ParentLetterFormMode mode(ParentLetterChildState child) =>
ParentLetterFormPolicy.resolve(request: request, child: child)!.mode;
expect(mode(_child()), ParentLetterFormMode.open);
expect(mode(_child(response: _answered)), ParentLetterFormMode.change);
expect(
mode(_child(editable: false, response: _answered)),
ParentLetterFormMode.done,
);
expect(mode(_child(editable: false)), ParentLetterFormMode.closed);
});
test('an unknown required field blocks the form', () {
final policy = ParentLetterFormPolicy.resolve(
request: const ParentLetterRequest(fields: [_choice, _unknownRequired]),
child: _child(),
)!;
expect(policy.mode, ParentLetterFormMode.unsupported);
expect(policy.canSubmit, isFalse);
});
test('an answered letter stays readable despite unknown fields', () {
final policy = ParentLetterFormPolicy.resolve(
request: const ParentLetterRequest(fields: [_choice, _unknownRequired]),
child: _child(editable: false, response: _answered),
)!;
expect(policy.mode, ParentLetterFormMode.done);
});
test('unknown optional fields are skipped', () {
final policy = ParentLetterFormPolicy.resolve(
request: const ParentLetterRequest(fields: [_choice, _unknownOptional]),
child: _child(),
)!;
expect(policy.mode, ParentLetterFormMode.open);
expect(policy.fields, [_choice]);
});
});
group('submit semantics', () {
ParentLetterFormPolicy policy(
ParentLetterRequest request, {
ParentLetterResponse? response,
}) => ParentLetterFormPolicy.resolve(
request: request,
child: _child(response: response),
)!;
test('acknowledgement', () {
final p = policy(const ParentLetterRequest());
expect(p.isAcknowledgement, isTrue);
expect(p.submitIsFinal, isTrue);
expect(p.submitLabel, 'Zur Kenntnis genommen');
expect(p.isComplete(const {}), isTrue);
expect(p.answersFor(const {}), isEmpty);
});
test('plain choice can be changed later', () {
const request = ParentLetterRequest(fields: [_choice]);
expect(policy(request).submitIsFinal, isFalse);
expect(policy(request).submitLabel, 'Rückmeldung absenden');
expect(
policy(request, response: _answered).submitLabel,
'Rückmeldung ändern',
);
});
test('signature makes the response final', () {
final p = policy(
const ParentLetterRequest(fields: [_choice], signatureRequired: true),
);
expect(p.submitIsFinal, isTrue);
expect(p.isAcknowledgement, isFalse);
expect(p.submitLabel, 'Unterschreiben und absenden');
});
test('only required fields must be answered', () {
final p = policy(
const ParentLetterRequest(fields: [_choice, _optionalChoice]),
);
expect(p.isComplete(const {}), isFalse);
expect(p.isComplete(const {'f2': 'x'}), isFalse);
expect(p.isComplete(const {'f1': 'yes'}), isTrue);
});
test('answers contain exactly the selected fields', () {
final p = policy(
const ParentLetterRequest(fields: [_choice, _optionalChoice]),
);
expect(p.answersFor(const {'f1': 'yes', 'stale': 'x'}), const [
ParentLetterAnswer(fieldId: 'f1', optionIds: ['yes']),
]);
});
test('selectionOf restores a previous response', () {
expect(ParentLetterFormPolicy.selectionOf(_answered), {'f1': 'no'});
expect(ParentLetterFormPolicy.selectionOf(null), isEmpty);
});
});
group('safeAttachmentFileName', () {
String name(String fileName) => safeAttachmentFileName(
ParentLetterAttachment(id: 'a', fileName: fileName),
);
test('keeps ordinary names', () {
expect(name('Elternbrief 10b.pdf'), 'Elternbrief 10b.pdf');
});
test('strips path parts and reserved characters', () {
expect(name('../../etc/passwd'), 'passwd');
expect(name(r'C:\Users\x\brief?.pdf'), 'brief_.pdf');
});
test('never returns an empty or relative name', () {
expect(name(''), 'Anhang');
expect(name('foo/..'), 'Anhang');
});
});
}