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
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
@@ -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).
**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.
**Elternbriefe:** Modul `parentLetters` (nur Eltern-Sessions, `AccessRequirement.guardian`): Lehrer-Mitteilungen aus MarianumConnect mit Gelesen-Status, Kenntnisnahme/Auswahl/Unterschrift pro Kind, Thread und Anhängen. Der Posteingang (`ParentLettersBloc`) ist global (Modul-Badge, Push-Refresh), ein Brief ist page-scoped (`ParentLetterBloc`). Ob und wie geantwortet werden darf, entscheidet der Server (`editable`); `ParentLetterFormPolicy.resolve` macht daraus das Formular. Push: Connect-Direct-Push `type: parent-letter`, Tap-Routing allein über `parentLetterId`. Alle Benachrichtigungs-Taps (lokal gerendert wie FCM) werden von `resolvePushTarget` (`lib/push/push_target.dart`) aufgelöst und in `NotificationTasks.openPushTarget` navigiert neue Push-Ziele nur dort ergänzen.
## Build / Run
```bash
@@ -74,7 +76,7 @@ flutter test # Tests (siehe test
| Backend | Pfad | Zweck |
|---------------------------|-----------------------|----------------------------------------|
| MarianumConnect (Bearer) | `lib/api/marianumconnect/` | Auth, Stundenplan (Webuntis-Proxy), Ticker, Newsletter, Ferien, Abwesenheit, Capabilities, Push |
| MarianumConnect (Bearer) | `lib/api/marianumconnect/` | Auth, Stundenplan (Webuntis-Proxy), Ticker, Newsletter, Ferien, Abwesenheit, Elternbriefe, Capabilities, Push |
| Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung |
| MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events |
+3 -1
View File
@@ -3,10 +3,12 @@ import '../session/session.dart';
/// Backend identity a feature needs. Modules and settings sections declare
/// these; anything whose requirements the session does not meet is hidden.
enum AccessRequirement {
nextcloud;
nextcloud,
guardian;
bool isMetBy(Session? session) => switch (this) {
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/server_exception.dart';
/// The error code of a rejected call: the server answers either with JSON
/// `{"error": "<code>"}` or with the plain text `Fehler: <code>` of its
/// generic error handler.
String? marianumConnectErrorCode(Object? body) {
if (body is Map) {
final code = body['error'];
return code is String ? code : null;
}
if (body is String) {
const prefix = 'Fehler: ';
return (body.startsWith(prefix) ? body.substring(prefix.length) : body)
.trim();
}
return null;
}
/// Converts a DioException raised against the Marianum-Connect API into one of
/// the app's typed AppExceptions. Keeps the dio dependency out of call sites
/// that just want to render an error message.
@@ -1,5 +1,8 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../errors/app_exception.dart';
import 'errors/marianumconnect_error.dart';
import 'marianumconnect_api.dart';
import 'marianumconnect_endpoint.dart';
@@ -23,10 +26,14 @@ abstract class MarianumConnectQuery {
try {
return await body();
} 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].
Future<T> getObject<T>(
String path,
@@ -55,6 +62,16 @@ abstract class MarianumConnectQuery {
.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.
String isoDate(DateTime d) =>
'${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.',
};
/// 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) {
if (data is Map) {
final attempts = data['attemptsLeft'];
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);
final attempts = data is Map ? data['attemptsLeft'] : null;
return (marianumConnectErrorCode(data), attempts is int ? attempts : null);
}
static GuardianLoginError? _errorFor(String? code, int status) =>
@@ -1,7 +1,5 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../marianumconnect_query.dart';
/// Downloads the raw PDF bytes of a Marianum Message from
@@ -15,11 +13,6 @@ class GetNewsletterFile extends MarianumConnectQuery {
GetNewsletterFile(this.id, {super.dio});
Future<Uint8List> run() => guard(() async {
final response = await dio.get<List<int>>(
endpoint('newsletter/${Uri.encodeComponent(id)}/file'),
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
});
Future<Uint8List> run() =>
getBytes('newsletter/${Uri.encodeComponent(id)}/file');
}
@@ -1,7 +1,5 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../marianumconnect_query.dart';
/// Downloads the raw bytes of a PROXIED_FILE ticker page from
@@ -15,11 +13,6 @@ class GetTickerPageFile extends MarianumConnectQuery {
GetTickerPageFile(this.slug, {super.dio});
Future<Uint8List> run() => guard(() async {
final response = await dio.get<List<int>>(
endpoint('ticker/pages/${Uri.encodeComponent(slug)}/file'),
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
});
Future<Uint8List> run() =>
getBytes('ticker/pages/${Uri.encodeComponent(slug)}/file');
}
@@ -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 'model/data_cleaner.dart';
import 'notification/notification_controller.dart';
import 'notification/notification_service.dart';
import 'notification/notification_tasks.dart';
import 'push/push_registration.dart';
import 'push/push_tap_router.dart';
@@ -119,18 +120,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
}
}
void _onPushTapPending() {
final token = PushTapRouter.pendingChatToken.value;
if (token == null || !mounted) return;
PushTapRouter.pendingChatToken.value = null;
NotificationTasks.navigateToTalk(context, chatToken: token);
}
void _onNewsletterTapPending() {
final id = PushTapRouter.pendingNewsletterId.value;
if (id == null || !mounted) return;
PushTapRouter.pendingNewsletterId.value = null;
AppRoutes.openNewsletterById(context, id: id);
void _onPushTargetPending() {
final target = PushTapRouter.pendingTarget.value;
if (target == null || !mounted) return;
PushTapRouter.pendingTarget.value = null;
NotificationTasks.openPushTarget(context, target);
}
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
// notifications callback (PushTapRouter) rather than onMessageOpenedApp.
PushTapRouter.pendingChatToken.addListener(_onPushTapPending);
PushTapRouter.pendingNewsletterId.addListener(_onNewsletterTapPending);
PushTapRouter.pendingTarget.addListener(_onPushTargetPending);
unawaited(
PushTapRouter.handleAppLaunch(
NotificationService().flutterLocalNotificationsPlugin,
),
);
_onMessageSub = FirebaseMessaging.onMessage.listen((message) {
if (!mounted) return;
@@ -264,8 +262,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
_onMessageSub?.cancel();
_onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel();
PushTapRouter.pendingChatToken.removeListener(_onPushTapPending);
PushTapRouter.pendingNewsletterId.removeListener(_onNewsletterTapPending);
PushTapRouter.pendingTarget.removeListener(_onPushTargetPending);
ShareIntentListener.pending.removeListener(_handlePendingShare);
ShareIntentListener.instance.detach();
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 'firebase_options.dart';
import 'notification/notification_service.dart';
import 'push/notification_permission_prompt.dart';
import 'push/push_message_handler.dart';
import 'push/push_registration.dart';
import 'push/push_registration_store.dart';
@@ -43,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/children/child_selection_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/timetable/primary/primary_timetable_scope.dart';
import 'storage/hydrated_storage_bootstrap.dart';
@@ -256,6 +258,7 @@ Future<void> main() async {
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
),
BlocProvider<ChildSelectionCubit>(create: (_) => ChildSelectionCubit()),
BlocProvider<ParentLettersBloc>(create: (_) => ParentLettersBloc()),
],
child: const PrimaryTimetableScope(child: Main()),
),
@@ -327,16 +330,27 @@ class _MainState extends State<Main> {
capabilitiesCubit.load().then((_) {
if (!mounted) return;
_syncPush(settingsCubit, capabilitiesCubit);
_promptGuardianNotifications();
}),
);
unawaited(context.read<NextcloudCapabilitiesCubit>().load());
}
/// Waits for the post-login splash so the dialog never covers it; the
/// splash's completion calls this again.
void _promptGuardianNotifications() {
if (_showPostLoginSplash) return;
final overlayContext = AppRoutes.overlayContext;
if (overlayContext == null) return;
unawaited(maybePromptGuardianLoginNotifications(overlayContext));
}
/// Warms the chat list and files root in the background so the first screen
/// render hits populated data. The timetable needs no warm-up:
/// PrimaryTimetableScope creates a freshly loading bloc per account.
void _prefetchBaseData(BuildContext context) {
unawaited(context.read<ChatListBloc>().refresh(silent: true));
unawaited(context.read<ParentLettersBloc>().refresh(silent: true));
if (SessionManager().hasNextcloud) {
unawaited(ListFilesCache.prefetchRootListing());
}
@@ -482,6 +496,7 @@ class _MainState extends State<Main> {
final childSelectionCubit = context
.read<ChildSelectionCubit>();
final chatListBloc = context.read<ChatListBloc>();
final parentLettersBloc = context.read<ParentLettersBloc>();
final chatBloc = context.read<ChatBloc>();
final nextcloudCapabilitiesCubit = context
.read<NextcloudCapabilitiesCubit>();
@@ -495,6 +510,7 @@ class _MainState extends State<Main> {
settingsCubit: settingsCubit,
childSelectionCubit: childSelectionCubit,
chatListBloc: chatListBloc,
parentLettersBloc: parentLettersBloc,
chatBloc: chatBloc,
breakerBloc: breakerBloc,
capabilitiesCubit: capabilitiesCubit,
@@ -515,9 +531,10 @@ class _MainState extends State<Main> {
if (_showPostLoginSplash)
PostLoginSplash(
key: const ValueKey('post-login-splash'),
onComplete: () => setState(
() => _showPostLoginSplash = false,
),
onComplete: () {
setState(() => _showPostLoginSplash = false);
_promptGuardianNotifications();
},
),
],
);
@@ -541,6 +558,7 @@ Future<void> _wipeUserState({
required SettingsCubit settingsCubit,
required ChildSelectionCubit childSelectionCubit,
required ChatListBloc chatListBloc,
required ParentLettersBloc parentLettersBloc,
required ChatBloc chatBloc,
required BreakerBloc breakerBloc,
required CapabilitiesCubit capabilitiesCubit,
@@ -558,6 +576,7 @@ Future<void> _wipeUserState({
await Future.wait([
childSelectionCubit.reset(),
chatListBloc.reset(),
parentLettersBloc.reset(),
chatBloc.reset(),
breakerBloc.reset(),
capabilitiesCubit.reset(),
+6 -22
View File
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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 '../widget/debug/debug_tile.dart';
import '../widget/debug/json_viewer.dart';
@@ -42,18 +42,15 @@ class NotificationController {
RemoteMessage message,
BuildContext context,
) async {
final newsletterId = _extractNewsletterId(message);
if (newsletterId != null) {
AppRoutes.openNewsletterById(
final target = resolvePushTarget(message.data);
if (target != null) {
NotificationTasks.openPushTarget(
context,
id: newsletterId,
target,
title: message.notification?.title,
);
} else {
NotificationTasks.navigateToTalk(
context,
chatToken: _extractChatToken(message),
);
NotificationTasks.navigateToTalk(context);
}
NotificationTasks.updateProviders(context);
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 '../push/chat_thread_store.dart';
import '../push/push_renderer.dart';
import '../push/push_target.dart';
import '../routing/app_routes.dart';
import '../session/session_manager.dart';
import '../state/app/modules/app_modules.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../state/app/modules/parent_letters/bloc/parent_letters_bloc.dart';
import 'notification_service.dart';
class NotificationTasks {
@@ -72,6 +75,41 @@ class NotificationTasks {
/// even if the user has already left.
static void updateProviders(BuildContext context) {
context.read<ChatListBloc>().refresh();
context.read<ParentLettersBloc>().refresh(silent: true);
}
/// [title] is the notification title, when the tap came with one.
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
@@ -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_avatar.dart';
import 'push_subject.dart';
import 'push_target.dart';
/// Renders decrypted push subjects (and plaintext Connect pushes) as local
/// notifications. Talk messages of one chat stack into a SINGLE
@@ -23,6 +24,8 @@ class PushRenderer {
static const talkChannelName = 'Talk-Nachrichten';
static const generalChannelId = 'nextcloud_general';
static const generalChannelName = 'Benachrichtigungen';
static const parentLettersChannelId = 'parent_letters';
static const parentLettersChannelName = 'Elternbriefe';
static const String iosTalkCategory = 'TALK_MESSAGE';
@@ -67,6 +70,14 @@ class PushRenderer {
description: 'Allgemeine Benachrichtigungen',
),
);
await android.createNotificationChannel(
const AndroidNotificationChannel(
parentLettersChannelId,
parentLettersChannelName,
description: 'Neue Elternbriefe und Antworten der Schule',
importance: Importance.high,
),
);
}
/// Renders a decrypted Nextcloud push subject.
@@ -350,23 +361,35 @@ class PushRenderer {
required String body,
Map<String, String>? data,
}) async {
final id = _fallbackId('$title$body');
const androidDetails = AndroidNotificationDetails(
generalChannelId,
generalChannelName,
final parentLetterId = data?[parentLetterIdKey];
final isParentLetter =
data?['type'] == parentLetterPushType &&
parentLetterId != null &&
parentLetterId.isNotEmpty;
// One notification per letter: a reply replaces the letter's earlier one.
final id = isParentLetter
? parentLetterNotificationId(parentLetterId)
: _fallbackId('$title$body');
final androidDetails = AndroidNotificationDetails(
isParentLetter ? parentLettersChannelId : generalChannelId,
isParentLetter ? parentLettersChannelName : generalChannelName,
importance: Importance.high,
priority: Priority.high,
color: _accentColor,
styleInformation: isParentLetter ? BigTextStyleInformation(body) : null,
);
await _plugin.show(
id: id,
title: title,
body: body,
notificationDetails: const NotificationDetails(android: androidDetails),
notificationDetails: NotificationDetails(android: androidDetails),
payload: data == null ? null : jsonEncode(data),
);
}
static int parentLetterNotificationId(String letterId) =>
stableChatNotificationId('parent-letter:$letterId');
String _payload({required String? chatToken, required int nid}) =>
jsonEncode({'chatToken': ?chatToken, 'nid': nid});
+27 -19
View File
@@ -1,24 +1,22 @@
import 'dart:convert';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'push_actions.dart';
import 'push_target.dart';
/// Routes foreground notification interactions from the single
/// flutter_local_notifications response callback. Action responses (reply /
/// 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 {
PushTapRouter._();
/// Chat token of the most recently tapped Talk notification, or null. [App]
/// listens to this and opens the chat, then resets it to null.
static final ValueNotifier<String?> pendingChatToken = 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);
/// Target of the most recently tapped notification, or null. [App] listens
/// to this and navigates, then resets it to null.
static final ValueNotifier<PushTarget?> pendingTarget = ValueNotifier(null);
static void handleResponse(NotificationResponse response) {
final actionId = response.actionId;
@@ -29,13 +27,28 @@ class PushTapRouter {
}
final map = _payloadMap(response.payload);
if (map == null) return;
final newsletterId = _stringValue(map, 'newsletterId');
if (newsletterId != null) {
pendingNewsletterId.value = newsletterId;
return;
final target = resolvePushTarget(map);
if (target != null) pendingTarget.value = target;
}
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) {
@@ -46,9 +59,4 @@ class PushTapRouter {
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/roomplan/roomplan.dart';
import '../view/pages/more/share/qr_share_view.dart';
import '../view/pages/parent_letters/parent_letter_view.dart';
import '../view/pages/settings/chat_background_settings_page.dart';
import '../view/pages/settings/modules_settings_page.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
/// screen. Used for deep links from outside the ticker module — inside the
/// module pages open in-place instead.
+26 -28
View File
@@ -1,4 +1,3 @@
import 'package:badges/badges.dart' as badges;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
@@ -16,14 +15,18 @@ import '../../../view/pages/holidays/holidays_view.dart';
import '../../../view/pages/marianum_dates/marianum_dates_view.dart';
import '../../../view/pages/marianum_message/marianum_message_list_view.dart';
import '../../../view/pages/more/roomplan/roomplan.dart';
import '../../../view/pages/parent_letters/parent_letters_view.dart';
import '../../../view/pages/talk/chat_list.dart';
import '../../../view/pages/ticker/ticker_view.dart';
import '../../../view/pages/timetable/timetable.dart';
import '../../../widget/breaker/breaker.dart';
import '../../../widget/centered_leading.dart';
import '../../../widget/module_badge_icon.dart';
import '../infrastructure/loadable_state/loadable_state.dart';
import 'chat_list/bloc/chat_list_bloc.dart';
import 'chat_list/bloc/chat_list_state.dart';
import 'parent_letters/bloc/parent_letters_bloc.dart';
import 'parent_letters/bloc/parent_letters_state.dart';
import 'settings/bloc/settings_cubit.dart';
class AppModule {
@@ -46,6 +49,7 @@ class AppModule {
static const Map<Modules, Set<AccessRequirement>> requirements = {
Modules.talk: {AccessRequirement.nextcloud},
Modules.files: {AccessRequirement.nextcloud},
Modules.parentLetters: {AccessRequirement.guardian},
};
static bool isAvailableFor(Modules module, Session? session) =>
@@ -64,6 +68,18 @@ class AppModule {
breakerArea: BreakerArea.timetable,
create: Timetable.new,
),
Modules.parentLetters: AppModule(
Modules.parentLetters,
name: 'Elternbriefe',
icon: () =>
BlocBuilder<ParentLettersBloc, LoadableState<ParentLettersState>>(
builder: (context, state) => ModuleBadgeIcon(
icon: Icons.mail_outline,
count: state.data?.unreadCount ?? 0,
),
),
create: ParentLettersView.new,
),
Modules.ticker: AppModule(
Modules.ticker,
name: 'Ticker',
@@ -77,34 +93,15 @@ class AppModule {
Modules.talk,
name: 'Talk',
icon: () => BlocBuilder<ChatListBloc, LoadableState<ChatListState>>(
builder: (context, state) {
final rooms = state.data?.rooms;
if (rooms == null || rooms.data.isEmpty) {
return const Icon(Icons.chat);
}
final messages = rooms.data
.map((e) => e.unreadMessages)
.reduce((a, b) => a + b);
return badges.Badge(
showBadge: messages > 0,
position: badges.BadgePosition.topEnd(top: -3, end: -3),
stackFit: StackFit.loose,
badgeStyle: badges.BadgeStyle(
padding: const EdgeInsets.all(3),
badgeColor: Theme.of(context).primaryColor,
elevation: 1,
builder: (context, state) => ModuleBadgeIcon(
icon: Icons.chat,
count:
state.data?.rooms?.data.fold<int>(
0,
(sum, room) => sum + room.unreadMessages,
) ??
0,
),
badgeContent: Text(
'$messages',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
child: const Icon(Icons.chat),
);
},
),
breakerArea: BreakerArea.talk,
create: ChatList.new,
@@ -299,6 +296,7 @@ class AppModule {
enum Modules {
timetable,
parentLetters,
ticker,
talk,
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 = {
Modules.timetable: 'timetable',
Modules.parentLetters: 'parentLetters',
Modules.ticker: 'ticker',
Modules.talk: 'talk',
Modules.files: 'files',
+11
View File
@@ -16,9 +16,20 @@ class NotificationSettings {
@JsonKey(defaultValue: false)
bool talkPermissionPromptShown;
/// Same one-shot guards for the two occasions on which accounts with
/// parent letters are asked: right after signing in and, if the permission
/// is still missing, on the first visit of the parent letters.
@JsonKey(defaultValue: false)
bool guardianLoginPromptShown;
@JsonKey(defaultValue: false)
bool parentLettersPromptShown;
NotificationSettings({
this.enabled = true,
this.talkPermissionPromptShown = false,
this.guardianLoginPromptShown = false,
this.parentLettersPromptShown = false,
});
factory NotificationSettings.fromJson(Map<String, dynamic> json) =>
+4
View File
@@ -12,6 +12,8 @@ NotificationSettings _$NotificationSettingsFromJson(
enabled: json['enabled'] as bool? ?? true,
talkPermissionPromptShown:
json['talkPermissionPromptShown'] as bool? ?? false,
guardianLoginPromptShown: json['guardianLoginPromptShown'] as bool? ?? false,
parentLettersPromptShown: json['parentLettersPromptShown'] as bool? ?? false,
);
Map<String, dynamic> _$NotificationSettingsToJson(
@@ -19,4 +21,6 @@ Map<String, dynamic> _$NotificationSettingsToJson(
) => <String, dynamic>{
'enabled': instance.enabled,
'talkPermissionPromptShown': instance.talkPermissionPromptShown,
'guardianLoginPromptShown': instance.guardianLoginPromptShown,
'parentLettersPromptShown': instance.parentLettersPromptShown,
};
+9
View File
@@ -8,3 +8,12 @@ String randomHexId({int bytes = 16}) {
(_) => random.nextInt(256),
).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(
moduleOrder: [
Modules.timetable,
Modules.parentLetters,
Modules.ticker,
Modules.talk,
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_split_view/flutter_split_view.dart';
import '../../../push/notification_permission_prompt.dart';
import '../../../routing/app_routes.dart';
import '../../../state/app/infrastructure/loadable_state/loadable_state.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 'data/open_direct_chat.dart';
import 'join_chat.dart';
import 'notification_permission_prompt.dart';
import 'search_chat.dart';
import 'widgets/chat_tile.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
integration_test:
sdk: flutter
signature: ^6.4.0
dev_dependencies:
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));
// 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(
effective.indexOf(Modules.ticker),
effective.indexOf(Modules.timetable) + 1,
effective.indexOf(Modules.parentLetters) + 1,
);
expect(effective.toSet(), Modules.values.toSet());
});
@@ -94,10 +100,11 @@ void main() {
final student = CredentialSession(username: 'max', password: 'pw');
const guardian = GuardianSession(email: 'e@x.de');
test('password accounts see every module', () {
for (final m in Modules.values) {
expect(AppModule.isAvailableFor(m, student), isTrue, reason: m.name);
}
test('password accounts see every module but the guardian ones', () {
final hidden = Modules.values
.where((m) => !AppModule.isAvailableFor(m, student))
.toSet();
expect(hidden, {Modules.parentLetters});
});
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');
});
});
}