added guardian letters with chat and multiple answer functionalities
This commit is contained in:
@@ -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,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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
badgeContent: Text(
|
||||
'$messages',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.chat),
|
||||
);
|
||||
},
|
||||
builder: (context, state) => ModuleBadgeIcon(
|
||||
icon: Icons.chat,
|
||||
count:
|
||||
state.data?.rooms?.data.fold<int>(
|
||||
0,
|
||||
(sum, room) => sum + room.unreadMessages,
|
||||
) ??
|
||||
0,
|
||||
),
|
||||
),
|
||||
breakerArea: BreakerArea.talk,
|
||||
create: ChatList.new,
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -107,6 +107,7 @@ dependencies:
|
||||
app_links: ^7.2.1
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
signature: ^6.4.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user