added guardian letters with chat and multiple answer functionalities
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# MarianumMobile Client
|
# MarianumMobile Client
|
||||||
|
|
||||||
Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. über das MarianumConnect-Backend, Nextcloud Talk + Files. Zwei Kontoarten: Schul-Konto (Schüler/Lehrer, Benutzername + Passwort) und Eltern-Konto (passwortlos per E-Mail-Code/App-Link, sieht die Stundenpläne der zugeordneten Kinder, kein Talk/Files).
|
Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. über das MarianumConnect-Backend, Nextcloud Talk + Files. Zwei Kontoarten: Schul-Konto (Schüler/Lehrer, Benutzername + Passwort) und Eltern-Konto (passwortlos per E-Mail-Code/App-Link, sieht die Stundenpläne der zugeordneten Kinder und erhält Elternbriefe, kein Talk/Files).
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -56,10 +56,12 @@ lib/
|
|||||||
|
|
||||||
**Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()` – Eltern-Sessions haben keine Nextcloud-Identität. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE).
|
**Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()` – Eltern-Sessions haben keine Nextcloud-Identität. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE).
|
||||||
|
|
||||||
**Rollen & Zuschnitt:** Views verzweigen nie auf Rollen. Module und Settings-Sections deklarieren `AccessRequirement`s (`AppModule.requirements`, `Settings._sections`); Views bekommen ein Subjekt + eine Policy, die an genau einer Stelle als pure Funktion aufgelöst wird (Vorbild: `TimetableSubject` + `TimetablePolicy.resolve`, `AbsenceFormPolicy`). `UserRole` nur für Anzeige/Policy-Ableitung.
|
**Rollen & Zuschnitt:** Views verzweigen nie auf Rollen. Module und Settings-Sections deklarieren `AccessRequirement`s (`nextcloud`, `guardian`; `AppModule.requirements`, `Settings._sections`); Views bekommen ein Subjekt + eine Policy, die an genau einer Stelle als pure Funktion aufgelöst wird (Vorbild: `TimetableSubject` + `TimetablePolicy.resolve`, `AbsenceFormPolicy`). `UserRole` nur für Anzeige/Policy-Ableitung.
|
||||||
|
|
||||||
**Stundenplan:** Ein `TimetableBloc(subject: …)` für alle Fälle (eigener Plan, Fremdplan, Kind). Den globalen Bloc stellt `PrimaryTimetableScope` bereit und tauscht ihn beim Kindwechsel aus; page-scoped Fremdpläne nutzen `ScopedTimetableBloc`. Die Kinderauswahl (`ChildSelectionCubit`) ist modulübergreifend.
|
**Stundenplan:** Ein `TimetableBloc(subject: …)` für alle Fälle (eigener Plan, Fremdplan, Kind). Den globalen Bloc stellt `PrimaryTimetableScope` bereit und tauscht ihn beim Kindwechsel aus; page-scoped Fremdpläne nutzen `ScopedTimetableBloc`. Die Kinderauswahl (`ChildSelectionCubit`) ist modulübergreifend.
|
||||||
|
|
||||||
|
**Elternbriefe:** Modul `parentLetters` (nur Eltern-Sessions, `AccessRequirement.guardian`): Lehrer-Mitteilungen aus MarianumConnect mit Gelesen-Status, Kenntnisnahme/Auswahl/Unterschrift pro Kind, Thread und Anhängen. Der Posteingang (`ParentLettersBloc`) ist global (Modul-Badge, Push-Refresh), ein Brief ist page-scoped (`ParentLetterBloc`). Ob und wie geantwortet werden darf, entscheidet der Server (`editable`); `ParentLetterFormPolicy.resolve` macht daraus das Formular. Push: Connect-Direct-Push `type: parent-letter`, Tap-Routing allein über `parentLetterId`. Alle Benachrichtigungs-Taps (lokal gerendert wie FCM) werden von `resolvePushTarget` (`lib/push/push_target.dart`) aufgelöst und in `NotificationTasks.openPushTarget` navigiert – neue Push-Ziele nur dort ergänzen.
|
||||||
|
|
||||||
## Build / Run
|
## Build / Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -74,7 +76,7 @@ flutter test # Tests (siehe test
|
|||||||
|
|
||||||
| Backend | Pfad | Zweck |
|
| Backend | Pfad | Zweck |
|
||||||
|---------------------------|-----------------------|----------------------------------------|
|
|---------------------------|-----------------------|----------------------------------------|
|
||||||
| MarianumConnect (Bearer) | `lib/api/marianumconnect/` | Auth, Stundenplan (Webuntis-Proxy), Ticker, Newsletter, Ferien, Abwesenheit, Capabilities, Push |
|
| MarianumConnect (Bearer) | `lib/api/marianumconnect/` | Auth, Stundenplan (Webuntis-Proxy), Ticker, Newsletter, Ferien, Abwesenheit, Elternbriefe, Capabilities, Push |
|
||||||
| Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung |
|
| Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung |
|
||||||
| MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events |
|
| MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events |
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import '../session/session.dart';
|
|||||||
/// Backend identity a feature needs. Modules and settings sections declare
|
/// Backend identity a feature needs. Modules and settings sections declare
|
||||||
/// these; anything whose requirements the session does not meet is hidden.
|
/// these; anything whose requirements the session does not meet is hidden.
|
||||||
enum AccessRequirement {
|
enum AccessRequirement {
|
||||||
nextcloud;
|
nextcloud,
|
||||||
|
guardian;
|
||||||
|
|
||||||
bool isMetBy(Session? session) => switch (this) {
|
bool isMetBy(Session? session) => switch (this) {
|
||||||
AccessRequirement.nextcloud => session?.nextcloud != null,
|
AccessRequirement.nextcloud => session?.nextcloud != null,
|
||||||
|
AccessRequirement.guardian => session is GuardianSession,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import '../../marianumconnect/queries/parent_letters/parent_letter_models.dart';
|
||||||
|
import '../demo_persona.dart';
|
||||||
|
|
||||||
|
/// Demo inbox of the guardian persona: one letter per variant (information,
|
||||||
|
/// acknowledgement, choice, signature, answered, expired, thread, attachment).
|
||||||
|
/// Child ids match [DemoCapabilities.guardianState].
|
||||||
|
class DemoParentLetters {
|
||||||
|
const DemoParentLetters._();
|
||||||
|
|
||||||
|
static const _lena = 'demo-child-1';
|
||||||
|
static const _jonas = 'demo-child-2';
|
||||||
|
|
||||||
|
static final Set<String> _readInSession = {};
|
||||||
|
|
||||||
|
static void markRead(String letterId) => _readInSession.add(letterId);
|
||||||
|
|
||||||
|
static ParentLetterListResponse list() {
|
||||||
|
final items = [for (final letter in _letters()) letter.summary];
|
||||||
|
return ParentLetterListResponse(
|
||||||
|
items: items,
|
||||||
|
unreadCount: items.where((letter) => !letter.read).length,
|
||||||
|
openCount: items
|
||||||
|
.where((letter) => letter.status == ParentLetterStatus.open)
|
||||||
|
.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ParentLetterDetail detail(String letterId) {
|
||||||
|
final letters = _letters();
|
||||||
|
return letters.firstWhere(
|
||||||
|
(letter) => letter.summary.id == letterId,
|
||||||
|
orElse: () => letters.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ParentLetterPerson _teacher(int index) =>
|
||||||
|
ParentLetterPerson(displayName: DemoPersona.teachers[index].name);
|
||||||
|
|
||||||
|
static const _participation = ParentLetterField(
|
||||||
|
id: 'participation',
|
||||||
|
type: ParentLetterFieldType.singleChoice,
|
||||||
|
label: 'Nimmt Ihr Kind teil?',
|
||||||
|
isRequired: true,
|
||||||
|
options: [
|
||||||
|
ParentLetterOption(id: 'yes', label: 'Ja'),
|
||||||
|
ParentLetterOption(id: 'no', label: 'Nein'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
static List<ParentLetterDetail> _letters() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
ParentLetterDetail letter({
|
||||||
|
required String id,
|
||||||
|
required String subject,
|
||||||
|
required ParentLetterPerson sender,
|
||||||
|
required Duration age,
|
||||||
|
required String body,
|
||||||
|
required List<String> childIds,
|
||||||
|
bool read = true,
|
||||||
|
ParentLetterStatus status = ParentLetterStatus.info,
|
||||||
|
ParentLetterRequest? request,
|
||||||
|
List<ParentLetterChildState> children = const [],
|
||||||
|
List<ParentLetterAttachment> attachments = const [],
|
||||||
|
ParentLetterThread thread = const ParentLetterThread(enabled: true),
|
||||||
|
}) => ParentLetterDetail(
|
||||||
|
summary: ParentLetterSummary(
|
||||||
|
id: id,
|
||||||
|
subject: subject,
|
||||||
|
preview: body.replaceAll('\n', ' ').trim(),
|
||||||
|
sender: sender,
|
||||||
|
sentAt: now.subtract(age),
|
||||||
|
read: read || _readInSession.contains(id),
|
||||||
|
childIds: childIds,
|
||||||
|
attachmentCount: attachments.length,
|
||||||
|
status: status,
|
||||||
|
deadline: request?.deadline,
|
||||||
|
),
|
||||||
|
content: ParentLetterContent(
|
||||||
|
body: body,
|
||||||
|
attachments: attachments,
|
||||||
|
request: request,
|
||||||
|
children: children,
|
||||||
|
thread: thread,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
letter(
|
||||||
|
id: 'demo-letter-hike',
|
||||||
|
subject: 'Wandertag der ${DemoPersona.className}',
|
||||||
|
sender: _teacher(0),
|
||||||
|
age: const Duration(hours: 2),
|
||||||
|
read: false,
|
||||||
|
childIds: const [_lena],
|
||||||
|
status: ParentLetterStatus.open,
|
||||||
|
body:
|
||||||
|
'Liebe Eltern,\n\nam Freitag in zwei Wochen findet unser Wandertag '
|
||||||
|
'statt. Wir fahren mit dem Bus in die Rhön und sind gegen 16 Uhr '
|
||||||
|
'zurück. Alle Einzelheiten finden Sie im angehängten Schreiben.\n\n'
|
||||||
|
'Bitte geben Sie uns eine verbindliche Rückmeldung.\n\n'
|
||||||
|
'Viele Grüße\n${DemoPersona.teachers[0].name}',
|
||||||
|
attachments: const [
|
||||||
|
ParentLetterAttachment(
|
||||||
|
id: 'demo-attachment-hike',
|
||||||
|
fileName: 'Wandertag.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
size: 48211,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
request: ParentLetterRequest(
|
||||||
|
fields: const [_participation],
|
||||||
|
signatureRequired: true,
|
||||||
|
deadline: now.add(const Duration(days: 10)),
|
||||||
|
),
|
||||||
|
children: const [
|
||||||
|
ParentLetterChildState(
|
||||||
|
childId: _lena,
|
||||||
|
status: ParentLetterStatus.open,
|
||||||
|
editable: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
letter(
|
||||||
|
id: 'demo-letter-parents-evening',
|
||||||
|
subject: 'Einladung zum Elternabend der 6a',
|
||||||
|
sender: _teacher(1),
|
||||||
|
age: const Duration(days: 1),
|
||||||
|
read: false,
|
||||||
|
childIds: const [_jonas],
|
||||||
|
status: ParentLetterStatus.open,
|
||||||
|
body:
|
||||||
|
'Liebe Eltern,\n\nhiermit lade ich Sie herzlich zum ersten '
|
||||||
|
'Elternabend des Schuljahres ein. Wir treffen uns am kommenden '
|
||||||
|
'Dienstag um 19 Uhr in Raum ${DemoPersona.rooms[0]}.\n\n'
|
||||||
|
'Bitte bestätigen Sie kurz den Erhalt dieser Einladung.',
|
||||||
|
request: const ParentLetterRequest(),
|
||||||
|
children: const [
|
||||||
|
ParentLetterChildState(
|
||||||
|
childId: _jonas,
|
||||||
|
status: ParentLetterStatus.open,
|
||||||
|
editable: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
letter(
|
||||||
|
id: 'demo-letter-school-festival',
|
||||||
|
subject: 'Schulfest: Wer kommt mit?',
|
||||||
|
sender: _teacher(5),
|
||||||
|
age: const Duration(days: 3),
|
||||||
|
childIds: const [_lena, _jonas],
|
||||||
|
status: ParentLetterStatus.open,
|
||||||
|
body:
|
||||||
|
'Liebe Eltern,\n\nfür die Planung unseres Schulfestes möchten wir '
|
||||||
|
'wissen, welche Kinder teilnehmen. Die Angabe können Sie bis zum '
|
||||||
|
'Ablauf der Frist noch ändern.\n\nWeitere Informationen: '
|
||||||
|
'https://www.marianum-fulda.de',
|
||||||
|
request: ParentLetterRequest(
|
||||||
|
fields: const [_participation],
|
||||||
|
deadline: now.add(const Duration(days: 5)),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
ParentLetterChildState(
|
||||||
|
childId: _lena,
|
||||||
|
status: ParentLetterStatus.done,
|
||||||
|
editable: true,
|
||||||
|
response: ParentLetterResponse(
|
||||||
|
respondedAt: now.subtract(const Duration(days: 2)),
|
||||||
|
respondedBy: const ParentLetterPerson(self: true),
|
||||||
|
answers: const [
|
||||||
|
ParentLetterAnswer(
|
||||||
|
fieldId: 'participation',
|
||||||
|
optionIds: ['yes'],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const ParentLetterChildState(
|
||||||
|
childId: _jonas,
|
||||||
|
status: ParentLetterStatus.open,
|
||||||
|
editable: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
letter(
|
||||||
|
id: 'demo-letter-reports',
|
||||||
|
subject: 'Zeugnisausgabe und Unterrichtsschluss',
|
||||||
|
sender: _teacher(3),
|
||||||
|
age: const Duration(days: 7),
|
||||||
|
childIds: const [_lena, _jonas],
|
||||||
|
body:
|
||||||
|
'Liebe Eltern,\n\nam letzten Schultag vor den Ferien endet der '
|
||||||
|
'Unterricht nach der dritten Stunde mit der Zeugnisausgabe. Die '
|
||||||
|
'Busse fahren entsprechend früher.',
|
||||||
|
thread: ParentLetterThread(
|
||||||
|
enabled: true,
|
||||||
|
messages: [
|
||||||
|
ParentLetterThreadMessage(
|
||||||
|
id: 'demo-thread-1',
|
||||||
|
author: const ParentLetterPerson(self: true),
|
||||||
|
body: 'Gilt das auch für die Nachmittagsbetreuung?',
|
||||||
|
sentAt: now.subtract(const Duration(days: 6, hours: 20)),
|
||||||
|
),
|
||||||
|
ParentLetterThreadMessage(
|
||||||
|
id: 'demo-thread-2',
|
||||||
|
author: _teacher(3),
|
||||||
|
body:
|
||||||
|
'Ja, die Betreuung entfällt an diesem Tag ebenfalls. Bei '
|
||||||
|
'Bedarf melden Sie sich bitte im Sekretariat.',
|
||||||
|
sentAt: now.subtract(const Duration(days: 6, hours: 2)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
letter(
|
||||||
|
id: 'demo-letter-swimming',
|
||||||
|
subject: 'Einverständnis Schwimmunterricht',
|
||||||
|
sender: _teacher(6),
|
||||||
|
age: const Duration(days: 21),
|
||||||
|
childIds: const [_jonas],
|
||||||
|
status: ParentLetterStatus.done,
|
||||||
|
body:
|
||||||
|
'Liebe Eltern,\n\nim zweiten Halbjahr findet der Sportunterricht '
|
||||||
|
'der 6a im Hallenbad statt. Dafür benötigen wir Ihr '
|
||||||
|
'Einverständnis.',
|
||||||
|
request: const ParentLetterRequest(
|
||||||
|
fields: [_participation],
|
||||||
|
signatureRequired: true,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
ParentLetterChildState(
|
||||||
|
childId: _jonas,
|
||||||
|
status: ParentLetterStatus.done,
|
||||||
|
response: ParentLetterResponse(
|
||||||
|
respondedAt: now.subtract(const Duration(days: 20)),
|
||||||
|
respondedBy: const ParentLetterPerson(displayName: 'M. Hoffmann'),
|
||||||
|
answers: const [
|
||||||
|
ParentLetterAnswer(
|
||||||
|
fieldId: 'participation',
|
||||||
|
optionIds: ['yes'],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
signed: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
thread: const ParentLetterThread(),
|
||||||
|
),
|
||||||
|
letter(
|
||||||
|
id: 'demo-letter-ski-trip',
|
||||||
|
subject: 'Anmeldung zur Skifreizeit',
|
||||||
|
sender: _teacher(6),
|
||||||
|
age: const Duration(days: 35),
|
||||||
|
childIds: const [_lena],
|
||||||
|
status: ParentLetterStatus.expired,
|
||||||
|
body:
|
||||||
|
'Liebe Eltern,\n\ndie Anmeldung zur Skifreizeit der Jahrgangsstufe '
|
||||||
|
'10 ist ab sofort möglich. Die Plätze sind begrenzt.',
|
||||||
|
request: ParentLetterRequest(
|
||||||
|
fields: const [_participation],
|
||||||
|
signatureRequired: true,
|
||||||
|
deadline: now.subtract(const Duration(days: 14)),
|
||||||
|
),
|
||||||
|
children: const [
|
||||||
|
ParentLetterChildState(
|
||||||
|
childId: _lena,
|
||||||
|
status: ParentLetterStatus.expired,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
thread: const ParentLetterThread(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,22 @@ import '../../errors/network_exception.dart';
|
|||||||
import '../../errors/parse_exception.dart';
|
import '../../errors/parse_exception.dart';
|
||||||
import '../../errors/server_exception.dart';
|
import '../../errors/server_exception.dart';
|
||||||
|
|
||||||
|
/// The error code of a rejected call: the server answers either with JSON
|
||||||
|
/// `{"error": "<code>"}` or with the plain text `Fehler: <code>` of its
|
||||||
|
/// generic error handler.
|
||||||
|
String? marianumConnectErrorCode(Object? body) {
|
||||||
|
if (body is Map) {
|
||||||
|
final code = body['error'];
|
||||||
|
return code is String ? code : null;
|
||||||
|
}
|
||||||
|
if (body is String) {
|
||||||
|
const prefix = 'Fehler: ';
|
||||||
|
return (body.startsWith(prefix) ? body.substring(prefix.length) : body)
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Converts a DioException raised against the Marianum-Connect API into one of
|
/// Converts a DioException raised against the Marianum-Connect API into one of
|
||||||
/// the app's typed AppExceptions. Keeps the dio dependency out of call sites
|
/// the app's typed AppExceptions. Keeps the dio dependency out of call sites
|
||||||
/// that just want to render an error message.
|
/// that just want to render an error message.
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../errors/app_exception.dart';
|
||||||
import 'errors/marianumconnect_error.dart';
|
import 'errors/marianumconnect_error.dart';
|
||||||
import 'marianumconnect_api.dart';
|
import 'marianumconnect_api.dart';
|
||||||
import 'marianumconnect_endpoint.dart';
|
import 'marianumconnect_endpoint.dart';
|
||||||
@@ -23,10 +26,14 @@ abstract class MarianumConnectQuery {
|
|||||||
try {
|
try {
|
||||||
return await body();
|
return await body();
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
throw mapMarianumConnectError(e);
|
throw mapError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The AppException a failed call surfaces as. Query families with domain
|
||||||
|
/// error codes override this instead of re-implementing [guard].
|
||||||
|
AppException mapError(DioException error) => mapMarianumConnectError(error);
|
||||||
|
|
||||||
/// GETs [path] and parses the JSON object body with [fromJson].
|
/// GETs [path] and parses the JSON object body with [fromJson].
|
||||||
Future<T> getObject<T>(
|
Future<T> getObject<T>(
|
||||||
String path,
|
String path,
|
||||||
@@ -55,6 +62,16 @@ abstract class MarianumConnectQuery {
|
|||||||
.toList();
|
.toList();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// GETs the raw bytes of [path] (files that need the bearer token).
|
||||||
|
Future<Uint8List> getBytes(String path) => guard(() async {
|
||||||
|
final response = await dio.get<List<int>>(
|
||||||
|
endpoint(path),
|
||||||
|
options: Options(responseType: ResponseType.bytes),
|
||||||
|
);
|
||||||
|
final bytes = response.data!;
|
||||||
|
return bytes is Uint8List ? bytes : Uint8List.fromList(bytes);
|
||||||
|
});
|
||||||
|
|
||||||
/// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params.
|
/// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params.
|
||||||
String isoDate(DateTime d) =>
|
String isoDate(DateTime d) =>
|
||||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||||
|
|||||||
@@ -84,21 +84,9 @@ class GuardianLoginException extends AppException {
|
|||||||
'Bitte versuche es später erneut.',
|
'Bitte versuche es später erneut.',
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Accepts the documented JSON body (`{"error": …, "attemptsLeft": …}`) as
|
|
||||||
/// well as the plain-text `Fehler: <code>` the server's generic error
|
|
||||||
/// handler produces.
|
|
||||||
static (String?, int?) _parseBody(Object? data) {
|
static (String?, int?) _parseBody(Object? data) {
|
||||||
if (data is Map) {
|
final attempts = data is Map ? data['attemptsLeft'] : null;
|
||||||
final attempts = data['attemptsLeft'];
|
return (marianumConnectErrorCode(data), attempts is int ? attempts : null);
|
||||||
return (data['error'] as String?, attempts is int ? attempts : null);
|
|
||||||
}
|
|
||||||
if (data is String) {
|
|
||||||
final code = data.startsWith('Fehler: ')
|
|
||||||
? data.substring('Fehler: '.length)
|
|
||||||
: data;
|
|
||||||
return (code.trim(), null);
|
|
||||||
}
|
|
||||||
return (null, null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static GuardianLoginError? _errorFor(String? code, int status) =>
|
static GuardianLoginError? _errorFor(String? code, int status) =>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
|
|
||||||
import '../../marianumconnect_query.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
/// Downloads the raw PDF bytes of a Marianum Message from
|
/// Downloads the raw PDF bytes of a Marianum Message from
|
||||||
@@ -15,11 +13,6 @@ class GetNewsletterFile extends MarianumConnectQuery {
|
|||||||
|
|
||||||
GetNewsletterFile(this.id, {super.dio});
|
GetNewsletterFile(this.id, {super.dio});
|
||||||
|
|
||||||
Future<Uint8List> run() => guard(() async {
|
Future<Uint8List> run() =>
|
||||||
final response = await dio.get<List<int>>(
|
getBytes('newsletter/${Uri.encodeComponent(id)}/file');
|
||||||
endpoint('newsletter/${Uri.encodeComponent(id)}/file'),
|
|
||||||
options: Options(responseType: ResponseType.bytes),
|
|
||||||
);
|
|
||||||
return Uint8List.fromList(response.data!);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
|
|
||||||
import '../../marianumconnect_query.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
/// Downloads the raw bytes of a PROXIED_FILE ticker page from
|
/// Downloads the raw bytes of a PROXIED_FILE ticker page from
|
||||||
@@ -15,11 +13,6 @@ class GetTickerPageFile extends MarianumConnectQuery {
|
|||||||
|
|
||||||
GetTickerPageFile(this.slug, {super.dio});
|
GetTickerPageFile(this.slug, {super.dio});
|
||||||
|
|
||||||
Future<Uint8List> run() => guard(() async {
|
Future<Uint8List> run() =>
|
||||||
final response = await dio.get<List<int>>(
|
getBytes('ticker/pages/${Uri.encodeComponent(slug)}/file');
|
||||||
endpoint('ticker/pages/${Uri.encodeComponent(slug)}/file'),
|
|
||||||
options: Options(responseType: ResponseType.bytes),
|
|
||||||
);
|
|
||||||
return Uint8List.fromList(response.data!);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import 'parent_letter_models.dart';
|
||||||
|
import 'parent_letter_query.dart';
|
||||||
|
|
||||||
|
class GetParentLetter extends ParentLetterQuery {
|
||||||
|
GetParentLetter({super.dio});
|
||||||
|
|
||||||
|
Future<ParentLetterDetail> run(String letterId) =>
|
||||||
|
getObject(letterPath(letterId), ParentLetterDetail.fromJson);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'parent_letter_query.dart';
|
||||||
|
|
||||||
|
class GetParentLetterAttachment extends ParentLetterQuery {
|
||||||
|
GetParentLetterAttachment({super.dio});
|
||||||
|
|
||||||
|
Future<Uint8List> run({
|
||||||
|
required String letterId,
|
||||||
|
required String attachmentId,
|
||||||
|
}) => getBytes(
|
||||||
|
letterPath(letterId, '/attachments/${Uri.encodeComponent(attachmentId)}'),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import 'parent_letter_models.dart';
|
||||||
|
import 'parent_letter_query.dart';
|
||||||
|
|
||||||
|
/// `GET parent-letters`: the guardian's inbox across all children, newest
|
||||||
|
/// first. [before] continues after the given letter (keyset paging).
|
||||||
|
class GetParentLetters extends ParentLetterQuery {
|
||||||
|
static const int pageSize = 50;
|
||||||
|
|
||||||
|
GetParentLetters({super.dio});
|
||||||
|
|
||||||
|
Future<ParentLetterListResponse> run({String? before}) => getObject(
|
||||||
|
'parent-letters',
|
||||||
|
ParentLetterListResponse.fromJson,
|
||||||
|
queryParameters: {'limit': pageSize, 'before': ?before},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import 'parent_letter_query.dart';
|
||||||
|
|
||||||
|
/// `POST parent-letters/{id}/read`: marks the letter and its thread as read
|
||||||
|
/// for this guardian. Idempotent.
|
||||||
|
class MarkParentLetterRead extends ParentLetterQuery {
|
||||||
|
MarkParentLetterRead({super.dio});
|
||||||
|
|
||||||
|
Future<void> run(String letterId) => guard(() async {
|
||||||
|
await dio.post<void>(endpoint(letterPath(letterId, '/read')));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../../errors/app_exception.dart';
|
||||||
|
import '../../errors/marianumconnect_error.dart';
|
||||||
|
|
||||||
|
enum ParentLetterError {
|
||||||
|
guardianRequired,
|
||||||
|
letterNotFound,
|
||||||
|
childNotFound,
|
||||||
|
attachmentNotFound,
|
||||||
|
invalidAnswers,
|
||||||
|
signatureRequired,
|
||||||
|
responseFinal,
|
||||||
|
deadlinePassed,
|
||||||
|
threadDisabled,
|
||||||
|
invalidRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parent-letter call the server rejected with a domain reason.
|
||||||
|
class ParentLetterException extends AppException {
|
||||||
|
final ParentLetterError error;
|
||||||
|
|
||||||
|
const ParentLetterException(
|
||||||
|
this.error, {
|
||||||
|
required super.userMessage,
|
||||||
|
super.technicalDetails,
|
||||||
|
}) : super(allowRetry: false);
|
||||||
|
|
||||||
|
/// Only 4xx answers with a parent-letter reason become a
|
||||||
|
/// [ParentLetterException]; everything else (network, 401, 5xx) keeps the
|
||||||
|
/// generic MarianumConnect mapping.
|
||||||
|
static AppException fromDio(DioException e) {
|
||||||
|
final response = e.response;
|
||||||
|
final status = response?.statusCode;
|
||||||
|
if (status == null || status < 400 || status >= 500 || status == 401) {
|
||||||
|
return mapMarianumConnectError(e);
|
||||||
|
}
|
||||||
|
final error = _errorFor(marianumConnectErrorCode(response!.data), status);
|
||||||
|
if (error == null) return mapMarianumConnectError(e);
|
||||||
|
return ParentLetterException(
|
||||||
|
error,
|
||||||
|
userMessage: messageFor(error),
|
||||||
|
technicalDetails: 'MC $status: ${response.data}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String messageFor(ParentLetterError error) => switch (error) {
|
||||||
|
ParentLetterError.guardianRequired =>
|
||||||
|
'Elternbriefe sind nur mit einem Eltern-Konto verfügbar.',
|
||||||
|
ParentLetterError.letterNotFound =>
|
||||||
|
'Dieser Elternbrief ist nicht mehr verfügbar.',
|
||||||
|
ParentLetterError.childNotFound =>
|
||||||
|
'Dieser Elternbrief betrifft das ausgewählte Kind nicht.',
|
||||||
|
ParentLetterError.attachmentNotFound =>
|
||||||
|
'Der Anhang ist nicht mehr verfügbar.',
|
||||||
|
ParentLetterError.invalidAnswers =>
|
||||||
|
'Die Rückmeldung ist unvollständig. Bitte prüfe deine Angaben.',
|
||||||
|
ParentLetterError.signatureRequired =>
|
||||||
|
'Für diese Rückmeldung wird eine Unterschrift benötigt.',
|
||||||
|
ParentLetterError.responseFinal =>
|
||||||
|
'Die Rückmeldung wurde bereits endgültig abgegeben und kann nicht mehr '
|
||||||
|
'geändert werden.',
|
||||||
|
ParentLetterError.deadlinePassed =>
|
||||||
|
'Die Frist für diese Rückmeldung ist abgelaufen.',
|
||||||
|
ParentLetterError.threadDisabled =>
|
||||||
|
'Auf diesen Elternbrief kann nicht geantwortet werden.',
|
||||||
|
ParentLetterError.invalidRequest =>
|
||||||
|
'Die Anfrage konnte nicht verarbeitet werden.',
|
||||||
|
};
|
||||||
|
|
||||||
|
static ParentLetterError? _errorFor(String? code, int status) =>
|
||||||
|
switch (code) {
|
||||||
|
'guardian_required' => ParentLetterError.guardianRequired,
|
||||||
|
'letter_not_found' => ParentLetterError.letterNotFound,
|
||||||
|
'child_not_found' => ParentLetterError.childNotFound,
|
||||||
|
'attachment_not_found' => ParentLetterError.attachmentNotFound,
|
||||||
|
'invalid_answers' => ParentLetterError.invalidAnswers,
|
||||||
|
'signature_required' => ParentLetterError.signatureRequired,
|
||||||
|
'response_final' => ParentLetterError.responseFinal,
|
||||||
|
'deadline_passed' => ParentLetterError.deadlinePassed,
|
||||||
|
'thread_disabled' => ParentLetterError.threadDisabled,
|
||||||
|
'invalid_request' => ParentLetterError.invalidRequest,
|
||||||
|
_ => switch (status) {
|
||||||
|
404 => ParentLetterError.letterNotFound,
|
||||||
|
409 => ParentLetterError.responseFinal,
|
||||||
|
410 => ParentLetterError.deadlinePassed,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'parent_letter_models.freezed.dart';
|
||||||
|
part 'parent_letter_models.g.dart';
|
||||||
|
|
||||||
|
enum ParentLetterStatus { info, open, done, expired }
|
||||||
|
|
||||||
|
enum ParentLetterFieldType {
|
||||||
|
@JsonValue('single_choice')
|
||||||
|
singleChoice,
|
||||||
|
unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sender, thread author or responding guardian. [self] marks the signed-in
|
||||||
|
/// guardian.
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterPerson with _$ParentLetterPerson {
|
||||||
|
const factory ParentLetterPerson({
|
||||||
|
@Default('') String displayName,
|
||||||
|
@Default(false) bool self,
|
||||||
|
}) = _ParentLetterPerson;
|
||||||
|
|
||||||
|
factory ParentLetterPerson.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterPersonFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inbox entry from `GET parent-letters`. [childIds] are the opaque guardian
|
||||||
|
/// child ids from `me/capabilities`.
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterSummary with _$ParentLetterSummary {
|
||||||
|
const factory ParentLetterSummary({
|
||||||
|
required String id,
|
||||||
|
@Default('') String subject,
|
||||||
|
@Default('') String preview,
|
||||||
|
@Default(ParentLetterPerson()) ParentLetterPerson sender,
|
||||||
|
required DateTime sentAt,
|
||||||
|
DateTime? editedAt,
|
||||||
|
@Default(true) bool read,
|
||||||
|
@Default([]) List<String> childIds,
|
||||||
|
@Default(0) int attachmentCount,
|
||||||
|
@JsonKey(unknownEnumValue: ParentLetterStatus.info)
|
||||||
|
@Default(ParentLetterStatus.info)
|
||||||
|
ParentLetterStatus status,
|
||||||
|
DateTime? deadline,
|
||||||
|
}) = _ParentLetterSummary;
|
||||||
|
|
||||||
|
factory ParentLetterSummary.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterSummaryFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterListResponse with _$ParentLetterListResponse {
|
||||||
|
const factory ParentLetterListResponse({
|
||||||
|
@Default([]) List<ParentLetterSummary> items,
|
||||||
|
@Default(false) bool hasMore,
|
||||||
|
@Default(0) int unreadCount,
|
||||||
|
@Default(0) int openCount,
|
||||||
|
}) = _ParentLetterListResponse;
|
||||||
|
|
||||||
|
factory ParentLetterListResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterListResponseFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterAttachment with _$ParentLetterAttachment {
|
||||||
|
const factory ParentLetterAttachment({
|
||||||
|
required String id,
|
||||||
|
@Default('') String fileName,
|
||||||
|
@Default('') String mimeType,
|
||||||
|
@Default(0) int size,
|
||||||
|
}) = _ParentLetterAttachment;
|
||||||
|
|
||||||
|
factory ParentLetterAttachment.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterAttachmentFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterOption with _$ParentLetterOption {
|
||||||
|
const factory ParentLetterOption({
|
||||||
|
required String id,
|
||||||
|
@Default('') String label,
|
||||||
|
}) = _ParentLetterOption;
|
||||||
|
|
||||||
|
factory ParentLetterOption.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterOptionFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterField with _$ParentLetterField {
|
||||||
|
const factory ParentLetterField({
|
||||||
|
required String id,
|
||||||
|
@JsonKey(unknownEnumValue: ParentLetterFieldType.unknown)
|
||||||
|
@Default(ParentLetterFieldType.unknown)
|
||||||
|
ParentLetterFieldType type,
|
||||||
|
@Default('') String label,
|
||||||
|
@JsonKey(name: 'required') @Default(false) bool isRequired,
|
||||||
|
@Default([]) List<ParentLetterOption> options,
|
||||||
|
}) = _ParentLetterField;
|
||||||
|
|
||||||
|
factory ParentLetterField.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterFieldFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the sender asks for. No fields and no signature means a plain
|
||||||
|
/// acknowledgement.
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterRequest with _$ParentLetterRequest {
|
||||||
|
const factory ParentLetterRequest({
|
||||||
|
@Default([]) List<ParentLetterField> fields,
|
||||||
|
@Default(false) bool signatureRequired,
|
||||||
|
DateTime? deadline,
|
||||||
|
}) = _ParentLetterRequest;
|
||||||
|
|
||||||
|
factory ParentLetterRequest.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterRequestFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterAnswer with _$ParentLetterAnswer {
|
||||||
|
const factory ParentLetterAnswer({
|
||||||
|
required String fieldId,
|
||||||
|
@Default([]) List<String> optionIds,
|
||||||
|
String? text,
|
||||||
|
}) = _ParentLetterAnswer;
|
||||||
|
|
||||||
|
factory ParentLetterAnswer.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterAnswerFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterResponse with _$ParentLetterResponse {
|
||||||
|
const factory ParentLetterResponse({
|
||||||
|
DateTime? respondedAt,
|
||||||
|
@Default(ParentLetterPerson()) ParentLetterPerson respondedBy,
|
||||||
|
@Default([]) List<ParentLetterAnswer> answers,
|
||||||
|
@Default(false) bool signed,
|
||||||
|
}) = _ParentLetterResponse;
|
||||||
|
|
||||||
|
factory ParentLetterResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterResponseFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-child state of a letter. [editable] is the server's verdict on whether
|
||||||
|
/// this guardian may (re)submit right now.
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterChildState with _$ParentLetterChildState {
|
||||||
|
const factory ParentLetterChildState({
|
||||||
|
required String childId,
|
||||||
|
@JsonKey(unknownEnumValue: ParentLetterStatus.info)
|
||||||
|
@Default(ParentLetterStatus.info)
|
||||||
|
ParentLetterStatus status,
|
||||||
|
@Default(false) bool editable,
|
||||||
|
ParentLetterResponse? response,
|
||||||
|
}) = _ParentLetterChildState;
|
||||||
|
|
||||||
|
factory ParentLetterChildState.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterChildStateFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterThreadMessage with _$ParentLetterThreadMessage {
|
||||||
|
const factory ParentLetterThreadMessage({
|
||||||
|
required String id,
|
||||||
|
@Default(ParentLetterPerson()) ParentLetterPerson author,
|
||||||
|
@Default('') String body,
|
||||||
|
required DateTime sentAt,
|
||||||
|
}) = _ParentLetterThreadMessage;
|
||||||
|
|
||||||
|
factory ParentLetterThreadMessage.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterThreadMessageFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterThread with _$ParentLetterThread {
|
||||||
|
const factory ParentLetterThread({
|
||||||
|
@Default(false) bool enabled,
|
||||||
|
@Default([]) List<ParentLetterThreadMessage> messages,
|
||||||
|
}) = _ParentLetterThread;
|
||||||
|
|
||||||
|
factory ParentLetterThread.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterThreadFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The detail-only part of `GET parent-letters/{id}`.
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterContent with _$ParentLetterContent {
|
||||||
|
const factory ParentLetterContent({
|
||||||
|
@Default('') String body,
|
||||||
|
@Default([]) List<ParentLetterAttachment> attachments,
|
||||||
|
ParentLetterRequest? request,
|
||||||
|
@Default([]) List<ParentLetterChildState> children,
|
||||||
|
@Default(ParentLetterThread()) ParentLetterThread thread,
|
||||||
|
}) = _ParentLetterContent;
|
||||||
|
|
||||||
|
factory ParentLetterContent.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterContentFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A full letter. The server sends the inbox fields and the detail fields in
|
||||||
|
/// one flat object; they are parsed into [summary] and [content] so the inbox
|
||||||
|
/// entry can be replaced from a detail answer without copying fields.
|
||||||
|
@immutable
|
||||||
|
class ParentLetterDetail {
|
||||||
|
final ParentLetterSummary summary;
|
||||||
|
final ParentLetterContent content;
|
||||||
|
|
||||||
|
const ParentLetterDetail({required this.summary, required this.content});
|
||||||
|
|
||||||
|
factory ParentLetterDetail.fromJson(Map<String, dynamic> json) =>
|
||||||
|
ParentLetterDetail(
|
||||||
|
summary: ParentLetterSummary.fromJson(json),
|
||||||
|
content: ParentLetterContent.fromJson(json),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {...summary.toJson(), ...content.toJson()};
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is ParentLetterDetail &&
|
||||||
|
other.summary == summary &&
|
||||||
|
other.content == content;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(summary, content);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,331 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'parent_letter_models.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
_ParentLetterPerson _$ParentLetterPersonFromJson(Map<String, dynamic> json) =>
|
||||||
|
_ParentLetterPerson(
|
||||||
|
displayName: json['displayName'] as String? ?? '',
|
||||||
|
self: json['self'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterPersonToJson(_ParentLetterPerson instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'displayName': instance.displayName,
|
||||||
|
'self': instance.self,
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterSummary _$ParentLetterSummaryFromJson(Map<String, dynamic> json) =>
|
||||||
|
_ParentLetterSummary(
|
||||||
|
id: json['id'] as String,
|
||||||
|
subject: json['subject'] as String? ?? '',
|
||||||
|
preview: json['preview'] as String? ?? '',
|
||||||
|
sender: json['sender'] == null
|
||||||
|
? const ParentLetterPerson()
|
||||||
|
: ParentLetterPerson.fromJson(json['sender'] as Map<String, dynamic>),
|
||||||
|
sentAt: DateTime.parse(json['sentAt'] as String),
|
||||||
|
editedAt: json['editedAt'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['editedAt'] as String),
|
||||||
|
read: json['read'] as bool? ?? true,
|
||||||
|
childIds:
|
||||||
|
(json['childIds'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as String)
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
attachmentCount: (json['attachmentCount'] as num?)?.toInt() ?? 0,
|
||||||
|
status:
|
||||||
|
$enumDecodeNullable(
|
||||||
|
_$ParentLetterStatusEnumMap,
|
||||||
|
json['status'],
|
||||||
|
unknownValue: ParentLetterStatus.info,
|
||||||
|
) ??
|
||||||
|
ParentLetterStatus.info,
|
||||||
|
deadline: json['deadline'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['deadline'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterSummaryToJson(
|
||||||
|
_ParentLetterSummary instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'subject': instance.subject,
|
||||||
|
'preview': instance.preview,
|
||||||
|
'sender': instance.sender,
|
||||||
|
'sentAt': instance.sentAt.toIso8601String(),
|
||||||
|
'editedAt': instance.editedAt?.toIso8601String(),
|
||||||
|
'read': instance.read,
|
||||||
|
'childIds': instance.childIds,
|
||||||
|
'attachmentCount': instance.attachmentCount,
|
||||||
|
'status': _$ParentLetterStatusEnumMap[instance.status]!,
|
||||||
|
'deadline': instance.deadline?.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const _$ParentLetterStatusEnumMap = {
|
||||||
|
ParentLetterStatus.info: 'info',
|
||||||
|
ParentLetterStatus.open: 'open',
|
||||||
|
ParentLetterStatus.done: 'done',
|
||||||
|
ParentLetterStatus.expired: 'expired',
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterListResponse _$ParentLetterListResponseFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => _ParentLetterListResponse(
|
||||||
|
items:
|
||||||
|
(json['items'] as List<dynamic>?)
|
||||||
|
?.map((e) => ParentLetterSummary.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
hasMore: json['hasMore'] as bool? ?? false,
|
||||||
|
unreadCount: (json['unreadCount'] as num?)?.toInt() ?? 0,
|
||||||
|
openCount: (json['openCount'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterListResponseToJson(
|
||||||
|
_ParentLetterListResponse instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'items': instance.items,
|
||||||
|
'hasMore': instance.hasMore,
|
||||||
|
'unreadCount': instance.unreadCount,
|
||||||
|
'openCount': instance.openCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterAttachment _$ParentLetterAttachmentFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => _ParentLetterAttachment(
|
||||||
|
id: json['id'] as String,
|
||||||
|
fileName: json['fileName'] as String? ?? '',
|
||||||
|
mimeType: json['mimeType'] as String? ?? '',
|
||||||
|
size: (json['size'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterAttachmentToJson(
|
||||||
|
_ParentLetterAttachment instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'fileName': instance.fileName,
|
||||||
|
'mimeType': instance.mimeType,
|
||||||
|
'size': instance.size,
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterOption _$ParentLetterOptionFromJson(Map<String, dynamic> json) =>
|
||||||
|
_ParentLetterOption(
|
||||||
|
id: json['id'] as String,
|
||||||
|
label: json['label'] as String? ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterOptionToJson(_ParentLetterOption instance) =>
|
||||||
|
<String, dynamic>{'id': instance.id, 'label': instance.label};
|
||||||
|
|
||||||
|
_ParentLetterField _$ParentLetterFieldFromJson(Map<String, dynamic> json) =>
|
||||||
|
_ParentLetterField(
|
||||||
|
id: json['id'] as String,
|
||||||
|
type:
|
||||||
|
$enumDecodeNullable(
|
||||||
|
_$ParentLetterFieldTypeEnumMap,
|
||||||
|
json['type'],
|
||||||
|
unknownValue: ParentLetterFieldType.unknown,
|
||||||
|
) ??
|
||||||
|
ParentLetterFieldType.unknown,
|
||||||
|
label: json['label'] as String? ?? '',
|
||||||
|
isRequired: json['required'] as bool? ?? false,
|
||||||
|
options:
|
||||||
|
(json['options'] as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(e) => ParentLetterOption.fromJson(e as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterFieldToJson(_ParentLetterField instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'type': _$ParentLetterFieldTypeEnumMap[instance.type]!,
|
||||||
|
'label': instance.label,
|
||||||
|
'required': instance.isRequired,
|
||||||
|
'options': instance.options,
|
||||||
|
};
|
||||||
|
|
||||||
|
const _$ParentLetterFieldTypeEnumMap = {
|
||||||
|
ParentLetterFieldType.singleChoice: 'single_choice',
|
||||||
|
ParentLetterFieldType.unknown: 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterRequest _$ParentLetterRequestFromJson(Map<String, dynamic> json) =>
|
||||||
|
_ParentLetterRequest(
|
||||||
|
fields:
|
||||||
|
(json['fields'] as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(e) => ParentLetterField.fromJson(e as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
signatureRequired: json['signatureRequired'] as bool? ?? false,
|
||||||
|
deadline: json['deadline'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['deadline'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterRequestToJson(
|
||||||
|
_ParentLetterRequest instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'fields': instance.fields,
|
||||||
|
'signatureRequired': instance.signatureRequired,
|
||||||
|
'deadline': instance.deadline?.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterAnswer _$ParentLetterAnswerFromJson(Map<String, dynamic> json) =>
|
||||||
|
_ParentLetterAnswer(
|
||||||
|
fieldId: json['fieldId'] as String,
|
||||||
|
optionIds:
|
||||||
|
(json['optionIds'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as String)
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
text: json['text'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterAnswerToJson(_ParentLetterAnswer instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'fieldId': instance.fieldId,
|
||||||
|
'optionIds': instance.optionIds,
|
||||||
|
'text': instance.text,
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterResponse _$ParentLetterResponseFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => _ParentLetterResponse(
|
||||||
|
respondedAt: json['respondedAt'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['respondedAt'] as String),
|
||||||
|
respondedBy: json['respondedBy'] == null
|
||||||
|
? const ParentLetterPerson()
|
||||||
|
: ParentLetterPerson.fromJson(
|
||||||
|
json['respondedBy'] as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
answers:
|
||||||
|
(json['answers'] as List<dynamic>?)
|
||||||
|
?.map((e) => ParentLetterAnswer.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
signed: json['signed'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterResponseToJson(
|
||||||
|
_ParentLetterResponse instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'respondedAt': instance.respondedAt?.toIso8601String(),
|
||||||
|
'respondedBy': instance.respondedBy,
|
||||||
|
'answers': instance.answers,
|
||||||
|
'signed': instance.signed,
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterChildState _$ParentLetterChildStateFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => _ParentLetterChildState(
|
||||||
|
childId: json['childId'] as String,
|
||||||
|
status:
|
||||||
|
$enumDecodeNullable(
|
||||||
|
_$ParentLetterStatusEnumMap,
|
||||||
|
json['status'],
|
||||||
|
unknownValue: ParentLetterStatus.info,
|
||||||
|
) ??
|
||||||
|
ParentLetterStatus.info,
|
||||||
|
editable: json['editable'] as bool? ?? false,
|
||||||
|
response: json['response'] == null
|
||||||
|
? null
|
||||||
|
: ParentLetterResponse.fromJson(json['response'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterChildStateToJson(
|
||||||
|
_ParentLetterChildState instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'childId': instance.childId,
|
||||||
|
'status': _$ParentLetterStatusEnumMap[instance.status]!,
|
||||||
|
'editable': instance.editable,
|
||||||
|
'response': instance.response,
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterThreadMessage _$ParentLetterThreadMessageFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => _ParentLetterThreadMessage(
|
||||||
|
id: json['id'] as String,
|
||||||
|
author: json['author'] == null
|
||||||
|
? const ParentLetterPerson()
|
||||||
|
: ParentLetterPerson.fromJson(json['author'] as Map<String, dynamic>),
|
||||||
|
body: json['body'] as String? ?? '',
|
||||||
|
sentAt: DateTime.parse(json['sentAt'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterThreadMessageToJson(
|
||||||
|
_ParentLetterThreadMessage instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'author': instance.author,
|
||||||
|
'body': instance.body,
|
||||||
|
'sentAt': instance.sentAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterThread _$ParentLetterThreadFromJson(Map<String, dynamic> json) =>
|
||||||
|
_ParentLetterThread(
|
||||||
|
enabled: json['enabled'] as bool? ?? false,
|
||||||
|
messages:
|
||||||
|
(json['messages'] as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(e) => ParentLetterThreadMessage.fromJson(
|
||||||
|
e as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterThreadToJson(_ParentLetterThread instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'enabled': instance.enabled,
|
||||||
|
'messages': instance.messages,
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterContent _$ParentLetterContentFromJson(Map<String, dynamic> json) =>
|
||||||
|
_ParentLetterContent(
|
||||||
|
body: json['body'] as String? ?? '',
|
||||||
|
attachments:
|
||||||
|
(json['attachments'] as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(e) =>
|
||||||
|
ParentLetterAttachment.fromJson(e as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
request: json['request'] == null
|
||||||
|
? null
|
||||||
|
: ParentLetterRequest.fromJson(
|
||||||
|
json['request'] as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
children:
|
||||||
|
(json['children'] as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(e) =>
|
||||||
|
ParentLetterChildState.fromJson(e as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
thread: json['thread'] == null
|
||||||
|
? const ParentLetterThread()
|
||||||
|
: ParentLetterThread.fromJson(json['thread'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterContentToJson(
|
||||||
|
_ParentLetterContent instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'body': instance.body,
|
||||||
|
'attachments': instance.attachments,
|
||||||
|
'request': instance.request,
|
||||||
|
'children': instance.children,
|
||||||
|
'thread': instance.thread,
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../../errors/app_exception.dart';
|
||||||
|
import '../../marianumconnect_query.dart';
|
||||||
|
import 'parent_letter_exception.dart';
|
||||||
|
|
||||||
|
/// Base for the parent-letter calls: domain rejections surface as
|
||||||
|
/// [ParentLetterException].
|
||||||
|
abstract class ParentLetterQuery extends MarianumConnectQuery {
|
||||||
|
ParentLetterQuery({super.dio});
|
||||||
|
|
||||||
|
String letterPath(String letterId, [String suffix = '']) =>
|
||||||
|
'parent-letters/${Uri.encodeComponent(letterId)}$suffix';
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppException mapError(DioException error) =>
|
||||||
|
ParentLetterException.fromDio(error);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import 'parent_letter_models.dart';
|
||||||
|
import 'parent_letter_query.dart';
|
||||||
|
|
||||||
|
/// `POST parent-letters/{id}/thread`. [clientMessageId] makes a retry
|
||||||
|
/// idempotent on the server.
|
||||||
|
class PostParentLetterThreadMessage extends ParentLetterQuery {
|
||||||
|
PostParentLetterThreadMessage({super.dio});
|
||||||
|
|
||||||
|
Future<ParentLetterThreadMessage> run({
|
||||||
|
required String letterId,
|
||||||
|
required String body,
|
||||||
|
required String clientMessageId,
|
||||||
|
}) => guard(() async {
|
||||||
|
final response = await dio.post<Map<String, dynamic>>(
|
||||||
|
endpoint(letterPath(letterId, '/thread')),
|
||||||
|
data: {'body': body, 'clientMessageId': clientMessageId},
|
||||||
|
);
|
||||||
|
return ParentLetterThreadMessage.fromJson(response.data!);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'parent_letter_models.dart';
|
||||||
|
import 'parent_letter_query.dart';
|
||||||
|
|
||||||
|
/// `PUT parent-letters/{id}/responses/{childId}`: creates or (while editable)
|
||||||
|
/// replaces the response for one child. Answers with the updated letter.
|
||||||
|
class SubmitParentLetterResponse extends ParentLetterQuery {
|
||||||
|
SubmitParentLetterResponse({super.dio});
|
||||||
|
|
||||||
|
Future<ParentLetterDetail> run({
|
||||||
|
required String letterId,
|
||||||
|
required String childId,
|
||||||
|
required List<ParentLetterAnswer> answers,
|
||||||
|
Uint8List? signaturePng,
|
||||||
|
}) => guard(() async {
|
||||||
|
final response = await dio.put<Map<String, dynamic>>(
|
||||||
|
endpoint(
|
||||||
|
letterPath(letterId, '/responses/${Uri.encodeComponent(childId)}'),
|
||||||
|
),
|
||||||
|
data: {
|
||||||
|
'answers': [
|
||||||
|
for (final answer in answers)
|
||||||
|
{'fieldId': answer.fieldId, 'optionIds': answer.optionIds},
|
||||||
|
],
|
||||||
|
'signaturePng': signaturePng == null
|
||||||
|
? null
|
||||||
|
: base64Encode(signaturePng),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return ParentLetterDetail.fromJson(response.data!);
|
||||||
|
});
|
||||||
|
}
|
||||||
+13
-16
@@ -11,6 +11,7 @@ import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart
|
|||||||
import 'main.dart';
|
import 'main.dart';
|
||||||
import 'model/data_cleaner.dart';
|
import 'model/data_cleaner.dart';
|
||||||
import 'notification/notification_controller.dart';
|
import 'notification/notification_controller.dart';
|
||||||
|
import 'notification/notification_service.dart';
|
||||||
import 'notification/notification_tasks.dart';
|
import 'notification/notification_tasks.dart';
|
||||||
import 'push/push_registration.dart';
|
import 'push/push_registration.dart';
|
||||||
import 'push/push_tap_router.dart';
|
import 'push/push_tap_router.dart';
|
||||||
@@ -119,18 +120,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPushTapPending() {
|
void _onPushTargetPending() {
|
||||||
final token = PushTapRouter.pendingChatToken.value;
|
final target = PushTapRouter.pendingTarget.value;
|
||||||
if (token == null || !mounted) return;
|
if (target == null || !mounted) return;
|
||||||
PushTapRouter.pendingChatToken.value = null;
|
PushTapRouter.pendingTarget.value = null;
|
||||||
NotificationTasks.navigateToTalk(context, chatToken: token);
|
NotificationTasks.openPushTarget(context, target);
|
||||||
}
|
|
||||||
|
|
||||||
void _onNewsletterTapPending() {
|
|
||||||
final id = PushTapRouter.pendingNewsletterId.value;
|
|
||||||
if (id == null || !mounted) return;
|
|
||||||
PushTapRouter.pendingNewsletterId.value = null;
|
|
||||||
AppRoutes.openNewsletterById(context, id: id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handlePendingWidgetNavigation() async {
|
Future<void> _handlePendingWidgetNavigation() async {
|
||||||
@@ -234,8 +228,12 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
// Android renders pushes locally, so a tap arrives via the local
|
// Android renders pushes locally, so a tap arrives via the local
|
||||||
// notifications callback (PushTapRouter) rather than onMessageOpenedApp.
|
// notifications callback (PushTapRouter) rather than onMessageOpenedApp.
|
||||||
PushTapRouter.pendingChatToken.addListener(_onPushTapPending);
|
PushTapRouter.pendingTarget.addListener(_onPushTargetPending);
|
||||||
PushTapRouter.pendingNewsletterId.addListener(_onNewsletterTapPending);
|
unawaited(
|
||||||
|
PushTapRouter.handleAppLaunch(
|
||||||
|
NotificationService().flutterLocalNotificationsPlugin,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
_onMessageSub = FirebaseMessaging.onMessage.listen((message) {
|
_onMessageSub = FirebaseMessaging.onMessage.listen((message) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -264,8 +262,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
_onMessageSub?.cancel();
|
_onMessageSub?.cancel();
|
||||||
_onMessageOpenedAppSub?.cancel();
|
_onMessageOpenedAppSub?.cancel();
|
||||||
_fcmTokenRefreshSub?.cancel();
|
_fcmTokenRefreshSub?.cancel();
|
||||||
PushTapRouter.pendingChatToken.removeListener(_onPushTapPending);
|
PushTapRouter.pendingTarget.removeListener(_onPushTargetPending);
|
||||||
PushTapRouter.pendingNewsletterId.removeListener(_onNewsletterTapPending);
|
|
||||||
ShareIntentListener.pending.removeListener(_handlePendingShare);
|
ShareIntentListener.pending.removeListener(_handlePendingShare);
|
||||||
ShareIntentListener.instance.detach();
|
ShareIntentListener.instance.detach();
|
||||||
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
||||||
|
|||||||
+22
-3
@@ -28,6 +28,7 @@ import 'auth_link/guardian_link_listener.dart';
|
|||||||
import 'background/widget_background_task.dart';
|
import 'background/widget_background_task.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
import 'notification/notification_service.dart';
|
import 'notification/notification_service.dart';
|
||||||
|
import 'push/notification_permission_prompt.dart';
|
||||||
import 'push/push_message_handler.dart';
|
import 'push/push_message_handler.dart';
|
||||||
import 'push/push_registration.dart';
|
import 'push/push_registration.dart';
|
||||||
import 'push/push_registration_store.dart';
|
import 'push/push_registration_store.dart';
|
||||||
@@ -43,6 +44,7 @@ import 'state/app/modules/chat/bloc/chat_bloc.dart';
|
|||||||
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||||
import 'state/app/modules/children/child_selection_cubit.dart';
|
import 'state/app/modules/children/child_selection_cubit.dart';
|
||||||
import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
|
import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
|
||||||
|
import 'state/app/modules/parent_letters/bloc/parent_letters_bloc.dart';
|
||||||
import 'state/app/modules/settings/bloc/settings_cubit.dart';
|
import 'state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import 'state/app/modules/timetable/primary/primary_timetable_scope.dart';
|
import 'state/app/modules/timetable/primary/primary_timetable_scope.dart';
|
||||||
import 'storage/hydrated_storage_bootstrap.dart';
|
import 'storage/hydrated_storage_bootstrap.dart';
|
||||||
@@ -256,6 +258,7 @@ Future<void> main() async {
|
|||||||
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
|
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
|
||||||
),
|
),
|
||||||
BlocProvider<ChildSelectionCubit>(create: (_) => ChildSelectionCubit()),
|
BlocProvider<ChildSelectionCubit>(create: (_) => ChildSelectionCubit()),
|
||||||
|
BlocProvider<ParentLettersBloc>(create: (_) => ParentLettersBloc()),
|
||||||
],
|
],
|
||||||
child: const PrimaryTimetableScope(child: Main()),
|
child: const PrimaryTimetableScope(child: Main()),
|
||||||
),
|
),
|
||||||
@@ -327,16 +330,27 @@ class _MainState extends State<Main> {
|
|||||||
capabilitiesCubit.load().then((_) {
|
capabilitiesCubit.load().then((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_syncPush(settingsCubit, capabilitiesCubit);
|
_syncPush(settingsCubit, capabilitiesCubit);
|
||||||
|
_promptGuardianNotifications();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
unawaited(context.read<NextcloudCapabilitiesCubit>().load());
|
unawaited(context.read<NextcloudCapabilitiesCubit>().load());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Waits for the post-login splash so the dialog never covers it; the
|
||||||
|
/// splash's completion calls this again.
|
||||||
|
void _promptGuardianNotifications() {
|
||||||
|
if (_showPostLoginSplash) return;
|
||||||
|
final overlayContext = AppRoutes.overlayContext;
|
||||||
|
if (overlayContext == null) return;
|
||||||
|
unawaited(maybePromptGuardianLoginNotifications(overlayContext));
|
||||||
|
}
|
||||||
|
|
||||||
/// Warms the chat list and files root in the background so the first screen
|
/// Warms the chat list and files root in the background so the first screen
|
||||||
/// render hits populated data. The timetable needs no warm-up:
|
/// render hits populated data. The timetable needs no warm-up:
|
||||||
/// PrimaryTimetableScope creates a freshly loading bloc per account.
|
/// PrimaryTimetableScope creates a freshly loading bloc per account.
|
||||||
void _prefetchBaseData(BuildContext context) {
|
void _prefetchBaseData(BuildContext context) {
|
||||||
unawaited(context.read<ChatListBloc>().refresh(silent: true));
|
unawaited(context.read<ChatListBloc>().refresh(silent: true));
|
||||||
|
unawaited(context.read<ParentLettersBloc>().refresh(silent: true));
|
||||||
if (SessionManager().hasNextcloud) {
|
if (SessionManager().hasNextcloud) {
|
||||||
unawaited(ListFilesCache.prefetchRootListing());
|
unawaited(ListFilesCache.prefetchRootListing());
|
||||||
}
|
}
|
||||||
@@ -482,6 +496,7 @@ class _MainState extends State<Main> {
|
|||||||
final childSelectionCubit = context
|
final childSelectionCubit = context
|
||||||
.read<ChildSelectionCubit>();
|
.read<ChildSelectionCubit>();
|
||||||
final chatListBloc = context.read<ChatListBloc>();
|
final chatListBloc = context.read<ChatListBloc>();
|
||||||
|
final parentLettersBloc = context.read<ParentLettersBloc>();
|
||||||
final chatBloc = context.read<ChatBloc>();
|
final chatBloc = context.read<ChatBloc>();
|
||||||
final nextcloudCapabilitiesCubit = context
|
final nextcloudCapabilitiesCubit = context
|
||||||
.read<NextcloudCapabilitiesCubit>();
|
.read<NextcloudCapabilitiesCubit>();
|
||||||
@@ -495,6 +510,7 @@ class _MainState extends State<Main> {
|
|||||||
settingsCubit: settingsCubit,
|
settingsCubit: settingsCubit,
|
||||||
childSelectionCubit: childSelectionCubit,
|
childSelectionCubit: childSelectionCubit,
|
||||||
chatListBloc: chatListBloc,
|
chatListBloc: chatListBloc,
|
||||||
|
parentLettersBloc: parentLettersBloc,
|
||||||
chatBloc: chatBloc,
|
chatBloc: chatBloc,
|
||||||
breakerBloc: breakerBloc,
|
breakerBloc: breakerBloc,
|
||||||
capabilitiesCubit: capabilitiesCubit,
|
capabilitiesCubit: capabilitiesCubit,
|
||||||
@@ -515,9 +531,10 @@ class _MainState extends State<Main> {
|
|||||||
if (_showPostLoginSplash)
|
if (_showPostLoginSplash)
|
||||||
PostLoginSplash(
|
PostLoginSplash(
|
||||||
key: const ValueKey('post-login-splash'),
|
key: const ValueKey('post-login-splash'),
|
||||||
onComplete: () => setState(
|
onComplete: () {
|
||||||
() => _showPostLoginSplash = false,
|
setState(() => _showPostLoginSplash = false);
|
||||||
),
|
_promptGuardianNotifications();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -541,6 +558,7 @@ Future<void> _wipeUserState({
|
|||||||
required SettingsCubit settingsCubit,
|
required SettingsCubit settingsCubit,
|
||||||
required ChildSelectionCubit childSelectionCubit,
|
required ChildSelectionCubit childSelectionCubit,
|
||||||
required ChatListBloc chatListBloc,
|
required ChatListBloc chatListBloc,
|
||||||
|
required ParentLettersBloc parentLettersBloc,
|
||||||
required ChatBloc chatBloc,
|
required ChatBloc chatBloc,
|
||||||
required BreakerBloc breakerBloc,
|
required BreakerBloc breakerBloc,
|
||||||
required CapabilitiesCubit capabilitiesCubit,
|
required CapabilitiesCubit capabilitiesCubit,
|
||||||
@@ -558,6 +576,7 @@ Future<void> _wipeUserState({
|
|||||||
await Future.wait([
|
await Future.wait([
|
||||||
childSelectionCubit.reset(),
|
childSelectionCubit.reset(),
|
||||||
chatListBloc.reset(),
|
chatListBloc.reset(),
|
||||||
|
parentLettersBloc.reset(),
|
||||||
chatBloc.reset(),
|
chatBloc.reset(),
|
||||||
breakerBloc.reset(),
|
breakerBloc.reset(),
|
||||||
capabilitiesCubit.reset(),
|
capabilitiesCubit.reset(),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../push/push_message_handler.dart';
|
import '../push/push_message_handler.dart';
|
||||||
import '../routing/app_routes.dart';
|
import '../push/push_target.dart';
|
||||||
import '../state/app/modules/chat/bloc/chat_bloc.dart';
|
import '../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
import '../widget/debug/debug_tile.dart';
|
import '../widget/debug/debug_tile.dart';
|
||||||
import '../widget/debug/json_viewer.dart';
|
import '../widget/debug/json_viewer.dart';
|
||||||
@@ -42,18 +42,15 @@ class NotificationController {
|
|||||||
RemoteMessage message,
|
RemoteMessage message,
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
) async {
|
) async {
|
||||||
final newsletterId = _extractNewsletterId(message);
|
final target = resolvePushTarget(message.data);
|
||||||
if (newsletterId != null) {
|
if (target != null) {
|
||||||
AppRoutes.openNewsletterById(
|
NotificationTasks.openPushTarget(
|
||||||
context,
|
context,
|
||||||
id: newsletterId,
|
target,
|
||||||
title: message.notification?.title,
|
title: message.notification?.title,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
NotificationTasks.navigateToTalk(
|
NotificationTasks.navigateToTalk(context);
|
||||||
context,
|
|
||||||
chatToken: _extractChatToken(message),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
NotificationTasks.updateProviders(context);
|
NotificationTasks.updateProviders(context);
|
||||||
unawaited(NotificationTasks.refreshBadge());
|
unawaited(NotificationTasks.refreshBadge());
|
||||||
@@ -68,17 +65,4 @@ class NotificationController {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static String? _extractChatToken(RemoteMessage message) {
|
|
||||||
for (final key in const ['chatToken', 'token', 'roomToken']) {
|
|
||||||
final value = message.data[key];
|
|
||||||
if (value is String && value.isNotEmpty) return value;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static String? _extractNewsletterId(RemoteMessage message) {
|
|
||||||
final value = message.data['newsletterId'];
|
|
||||||
return value is String && value.isNotEmpty ? value : null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import 'package:flutter_app_badge/flutter_app_badge.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../push/chat_thread_store.dart';
|
import '../push/chat_thread_store.dart';
|
||||||
|
import '../push/push_renderer.dart';
|
||||||
|
import '../push/push_target.dart';
|
||||||
import '../routing/app_routes.dart';
|
import '../routing/app_routes.dart';
|
||||||
import '../session/session_manager.dart';
|
import '../session/session_manager.dart';
|
||||||
import '../state/app/modules/app_modules.dart';
|
import '../state/app/modules/app_modules.dart';
|
||||||
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||||
|
import '../state/app/modules/parent_letters/bloc/parent_letters_bloc.dart';
|
||||||
import 'notification_service.dart';
|
import 'notification_service.dart';
|
||||||
|
|
||||||
class NotificationTasks {
|
class NotificationTasks {
|
||||||
@@ -72,6 +75,41 @@ class NotificationTasks {
|
|||||||
/// even if the user has already left.
|
/// even if the user has already left.
|
||||||
static void updateProviders(BuildContext context) {
|
static void updateProviders(BuildContext context) {
|
||||||
context.read<ChatListBloc>().refresh();
|
context.read<ChatListBloc>().refresh();
|
||||||
|
context.read<ParentLettersBloc>().refresh(silent: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [title] is the notification title, when the tap came with one.
|
||||||
|
static void openPushTarget(
|
||||||
|
BuildContext context,
|
||||||
|
PushTarget target, {
|
||||||
|
String? title,
|
||||||
|
}) {
|
||||||
|
switch (target) {
|
||||||
|
case ParentLetterTarget(:final letterId):
|
||||||
|
if (AppModule.isAvailableFor(
|
||||||
|
Modules.parentLetters,
|
||||||
|
SessionManager().current,
|
||||||
|
)) {
|
||||||
|
AppRoutes.openParentLetter(context, id: letterId);
|
||||||
|
}
|
||||||
|
case NewsletterTarget(:final newsletterId):
|
||||||
|
AppRoutes.openNewsletterById(context, id: newsletterId, title: title);
|
||||||
|
case ChatTarget(:final chatToken):
|
||||||
|
navigateToTalk(context, chatToken: chatToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the letter's tray notification (Android; iOS alerts are posted by
|
||||||
|
/// the system and cannot be addressed by id).
|
||||||
|
static Future<void> clearParentLetterNotification(String letterId) async {
|
||||||
|
try {
|
||||||
|
await NotificationService().flutterLocalNotificationsPlugin.cancel(
|
||||||
|
id: PushRenderer.parentLetterNotificationId(letterId),
|
||||||
|
);
|
||||||
|
await refreshBadge();
|
||||||
|
} on Object catch (e) {
|
||||||
|
log('Parent letter notification cleanup failed: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switches to the Talk tab. If [chatToken] is provided, also schedules
|
/// Switches to the Talk tab. If [chatToken] is provided, also schedules
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:app_settings/app_settings.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
import '../session/session_manager.dart';
|
||||||
|
import '../state/app/modules/app_modules.dart';
|
||||||
|
import '../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||||
|
import '../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
|
import '../storage/notification_settings.dart';
|
||||||
|
import '../widget/confirm_dialog.dart';
|
||||||
|
import 'push_registration.dart';
|
||||||
|
|
||||||
|
const _talkExplanation =
|
||||||
|
'Damit du keine neuen Nachrichten im Talk verpasst, fragen wir dich '
|
||||||
|
'gleich nach der Erlaubnis für Benachrichtigungen. Bitte akzeptiere '
|
||||||
|
'sie, um Push-Nachrichten zu erhalten.';
|
||||||
|
const _talkDeclinedNote =
|
||||||
|
'Ohne die Berechtigung erhältst du keine Benachrichtigungen bei neuen '
|
||||||
|
'Talk-Nachrichten. Du kannst sie jederzeit in den Systemeinstellungen '
|
||||||
|
'deines Geräts nachträglich aktivieren.';
|
||||||
|
const _parentLetterExplanation =
|
||||||
|
'Damit du keine Elternbriefe der Schule verpasst, fragen wir dich gleich '
|
||||||
|
'nach der Erlaubnis für Benachrichtigungen. Bitte akzeptiere sie, um '
|
||||||
|
'Push-Nachrichten zu erhalten.';
|
||||||
|
const _parentLetterDeclinedNote =
|
||||||
|
'Ohne die Berechtigung erhältst du keine Benachrichtigungen bei neuen '
|
||||||
|
'Elternbriefen. Du kannst sie jederzeit in den Systemeinstellungen deines '
|
||||||
|
'Geräts nachträglich aktivieren.';
|
||||||
|
|
||||||
|
/// The occasions with a guided notification-permission flow. Each runs at
|
||||||
|
/// most once per install, guarded by its own flag in [NotificationSettings].
|
||||||
|
enum _PermissionPrompt {
|
||||||
|
talkVisit(_talkExplanation, _talkDeclinedNote),
|
||||||
|
guardianLogin(
|
||||||
|
_parentLetterExplanation,
|
||||||
|
_parentLetterDeclinedNote,
|
||||||
|
module: Modules.parentLetters,
|
||||||
|
spentOnDisplay: true,
|
||||||
|
),
|
||||||
|
parentLettersVisit(
|
||||||
|
_parentLetterExplanation,
|
||||||
|
_parentLetterDeclinedNote,
|
||||||
|
module: Modules.parentLetters,
|
||||||
|
);
|
||||||
|
|
||||||
|
const _PermissionPrompt(
|
||||||
|
this.explanation,
|
||||||
|
this.declinedNote, {
|
||||||
|
this.module,
|
||||||
|
this.spentOnDisplay = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String explanation;
|
||||||
|
final String declinedNote;
|
||||||
|
|
||||||
|
/// Only sessions that have this module are asked.
|
||||||
|
final Modules? module;
|
||||||
|
|
||||||
|
/// Spends the one-shot as soon as the explanation is displayed, so a
|
||||||
|
/// dismissed dialog does not come back on every app start.
|
||||||
|
final bool spentOnDisplay;
|
||||||
|
|
||||||
|
bool wasShown(NotificationSettings settings) => switch (this) {
|
||||||
|
talkVisit => settings.talkPermissionPromptShown,
|
||||||
|
guardianLogin => settings.guardianLoginPromptShown,
|
||||||
|
parentLettersVisit => settings.parentLettersPromptShown,
|
||||||
|
};
|
||||||
|
|
||||||
|
void markShown(NotificationSettings settings) {
|
||||||
|
switch (this) {
|
||||||
|
case talkVisit:
|
||||||
|
settings.talkPermissionPromptShown = true;
|
||||||
|
case guardianLogin:
|
||||||
|
settings.guardianLoginPromptShown = true;
|
||||||
|
case parentLettersVisit:
|
||||||
|
settings.parentLettersPromptShown = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the one-time notification-permission flow on the first Talk visit.
|
||||||
|
///
|
||||||
|
/// The OS prompt is deliberately kept out of the cold-start path (younger users
|
||||||
|
/// decline it reflexively before ever seeing why they'd want it). Instead, the
|
||||||
|
/// first time Talk is opened we explain the request, then trigger the OS prompt,
|
||||||
|
/// and — if declined — offer a shortcut to the system settings.
|
||||||
|
///
|
||||||
|
/// Runs at most once per install (guarded by `talkPermissionPromptShown`).
|
||||||
|
Future<void> maybePromptTalkNotifications(BuildContext context) =>
|
||||||
|
_maybePrompt(context, _PermissionPrompt.talkVisit);
|
||||||
|
|
||||||
|
/// Accounts that receive parent letters never reach the Talk flow, so they
|
||||||
|
/// are asked once right after signing in.
|
||||||
|
Future<void> maybePromptGuardianLoginNotifications(BuildContext context) =>
|
||||||
|
_maybePrompt(context, _PermissionPrompt.guardianLogin);
|
||||||
|
|
||||||
|
/// Second chance with context: explains the request once more on the first
|
||||||
|
/// visit of the parent letters when the permission is still missing.
|
||||||
|
Future<void> maybePromptParentLetterNotifications(BuildContext context) =>
|
||||||
|
_maybePrompt(context, _PermissionPrompt.parentLettersVisit);
|
||||||
|
|
||||||
|
bool _promptInFlight = false;
|
||||||
|
|
||||||
|
Future<void> _maybePrompt(
|
||||||
|
BuildContext context,
|
||||||
|
_PermissionPrompt prompt,
|
||||||
|
) async {
|
||||||
|
final module = prompt.module;
|
||||||
|
if (module != null &&
|
||||||
|
!AppModule.isAvailableFor(module, SessionManager().current)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final settings = context.read<SettingsCubit>();
|
||||||
|
final notificationSettings = settings.val().notificationSettings;
|
||||||
|
|
||||||
|
// Already handled once, or the user opted out of push entirely.
|
||||||
|
if (prompt.wasShown(notificationSettings)) return;
|
||||||
|
if (!notificationSettings.enabled) return;
|
||||||
|
|
||||||
|
// Capabilities may still be loading on a fresh cold start; retry on the next
|
||||||
|
// occasion instead of burning the one-shot flag.
|
||||||
|
if (!context.read<CapabilitiesCubit>().canReceivePushNotifications) return;
|
||||||
|
|
||||||
|
if (_promptInFlight) return;
|
||||||
|
_promptInFlight = true;
|
||||||
|
try {
|
||||||
|
// Users who already granted the permission: register silently and mark the
|
||||||
|
// prompt as handled without showing any dialog.
|
||||||
|
if (await PushRegistration.isOsPermissionGranted()) {
|
||||||
|
prompt.markShown(settings.val(write: true).notificationSettings);
|
||||||
|
unawaited(PushRegistration().register());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
if (prompt.spentOnDisplay) {
|
||||||
|
prompt.markShown(settings.val(write: true).notificationSettings);
|
||||||
|
}
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: ConfirmDialog(
|
||||||
|
icon: Icons.notifications_active_outlined,
|
||||||
|
title: 'Benachrichtigungen aktivieren',
|
||||||
|
content: prompt.explanation,
|
||||||
|
confirmButton: 'Weiter',
|
||||||
|
cancelButton: null,
|
||||||
|
onConfirm: () =>
|
||||||
|
unawaited(_requestPermission(context, settings, prompt)),
|
||||||
|
).build,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
_promptInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _requestPermission(
|
||||||
|
BuildContext context,
|
||||||
|
SettingsCubit settings,
|
||||||
|
_PermissionPrompt prompt,
|
||||||
|
) async {
|
||||||
|
final granted = await PushRegistration.requestOsPermission();
|
||||||
|
|
||||||
|
// Mark handled regardless of the outcome — the user can re-enable later via
|
||||||
|
// the system settings; we don't want to prompt again on the next occasion.
|
||||||
|
prompt.markShown(settings.val(write: true).notificationSettings);
|
||||||
|
|
||||||
|
if (granted) {
|
||||||
|
unawaited(PushRegistration().register());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Push: notification permission declined on ${prompt.name}');
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
ConfirmDialog(
|
||||||
|
icon: Icons.notifications_off_outlined,
|
||||||
|
title: 'Benachrichtigungen deaktiviert',
|
||||||
|
content: prompt.declinedNote,
|
||||||
|
confirmButton: 'Einstellungen öffnen',
|
||||||
|
cancelButton: 'Später',
|
||||||
|
onConfirm: () =>
|
||||||
|
AppSettings.openAppSettings(type: AppSettingsType.notification),
|
||||||
|
).asDialog(context);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import 'nid_store.dart';
|
|||||||
import 'push_actions.dart';
|
import 'push_actions.dart';
|
||||||
import 'push_avatar.dart';
|
import 'push_avatar.dart';
|
||||||
import 'push_subject.dart';
|
import 'push_subject.dart';
|
||||||
|
import 'push_target.dart';
|
||||||
|
|
||||||
/// Renders decrypted push subjects (and plaintext Connect pushes) as local
|
/// Renders decrypted push subjects (and plaintext Connect pushes) as local
|
||||||
/// notifications. Talk messages of one chat stack into a SINGLE
|
/// notifications. Talk messages of one chat stack into a SINGLE
|
||||||
@@ -23,6 +24,8 @@ class PushRenderer {
|
|||||||
static const talkChannelName = 'Talk-Nachrichten';
|
static const talkChannelName = 'Talk-Nachrichten';
|
||||||
static const generalChannelId = 'nextcloud_general';
|
static const generalChannelId = 'nextcloud_general';
|
||||||
static const generalChannelName = 'Benachrichtigungen';
|
static const generalChannelName = 'Benachrichtigungen';
|
||||||
|
static const parentLettersChannelId = 'parent_letters';
|
||||||
|
static const parentLettersChannelName = 'Elternbriefe';
|
||||||
|
|
||||||
static const String iosTalkCategory = 'TALK_MESSAGE';
|
static const String iosTalkCategory = 'TALK_MESSAGE';
|
||||||
|
|
||||||
@@ -67,6 +70,14 @@ class PushRenderer {
|
|||||||
description: 'Allgemeine Benachrichtigungen',
|
description: 'Allgemeine Benachrichtigungen',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
await android.createNotificationChannel(
|
||||||
|
const AndroidNotificationChannel(
|
||||||
|
parentLettersChannelId,
|
||||||
|
parentLettersChannelName,
|
||||||
|
description: 'Neue Elternbriefe und Antworten der Schule',
|
||||||
|
importance: Importance.high,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders a decrypted Nextcloud push subject.
|
/// Renders a decrypted Nextcloud push subject.
|
||||||
@@ -350,23 +361,35 @@ class PushRenderer {
|
|||||||
required String body,
|
required String body,
|
||||||
Map<String, String>? data,
|
Map<String, String>? data,
|
||||||
}) async {
|
}) async {
|
||||||
final id = _fallbackId('$title$body');
|
final parentLetterId = data?[parentLetterIdKey];
|
||||||
const androidDetails = AndroidNotificationDetails(
|
final isParentLetter =
|
||||||
generalChannelId,
|
data?['type'] == parentLetterPushType &&
|
||||||
generalChannelName,
|
parentLetterId != null &&
|
||||||
|
parentLetterId.isNotEmpty;
|
||||||
|
// One notification per letter: a reply replaces the letter's earlier one.
|
||||||
|
final id = isParentLetter
|
||||||
|
? parentLetterNotificationId(parentLetterId)
|
||||||
|
: _fallbackId('$title$body');
|
||||||
|
final androidDetails = AndroidNotificationDetails(
|
||||||
|
isParentLetter ? parentLettersChannelId : generalChannelId,
|
||||||
|
isParentLetter ? parentLettersChannelName : generalChannelName,
|
||||||
importance: Importance.high,
|
importance: Importance.high,
|
||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
color: _accentColor,
|
color: _accentColor,
|
||||||
|
styleInformation: isParentLetter ? BigTextStyleInformation(body) : null,
|
||||||
);
|
);
|
||||||
await _plugin.show(
|
await _plugin.show(
|
||||||
id: id,
|
id: id,
|
||||||
title: title,
|
title: title,
|
||||||
body: body,
|
body: body,
|
||||||
notificationDetails: const NotificationDetails(android: androidDetails),
|
notificationDetails: NotificationDetails(android: androidDetails),
|
||||||
payload: data == null ? null : jsonEncode(data),
|
payload: data == null ? null : jsonEncode(data),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int parentLetterNotificationId(String letterId) =>
|
||||||
|
stableChatNotificationId('parent-letter:$letterId');
|
||||||
|
|
||||||
String _payload({required String? chatToken, required int nid}) =>
|
String _payload({required String? chatToken, required int nid}) =>
|
||||||
jsonEncode({'chatToken': ?chatToken, 'nid': nid});
|
jsonEncode({'chatToken': ?chatToken, 'nid': nid});
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
||||||
import 'push_actions.dart';
|
import 'push_actions.dart';
|
||||||
|
import 'push_target.dart';
|
||||||
|
|
||||||
/// Routes foreground notification interactions from the single
|
/// Routes foreground notification interactions from the single
|
||||||
/// flutter_local_notifications response callback. Action responses (reply /
|
/// flutter_local_notifications response callback. Action responses (reply /
|
||||||
/// mark-read) are dispatched straight to [PushActions]; a plain tap publishes
|
/// mark-read) are dispatched straight to [PushActions]; a plain tap publishes
|
||||||
/// the target chat token via [pendingChatToken] for [App] to navigate to.
|
/// its target via [pendingTarget] for [App] to navigate to.
|
||||||
class PushTapRouter {
|
class PushTapRouter {
|
||||||
PushTapRouter._();
|
PushTapRouter._();
|
||||||
|
|
||||||
/// Chat token of the most recently tapped Talk notification, or null. [App]
|
/// Target of the most recently tapped notification, or null. [App] listens
|
||||||
/// listens to this and opens the chat, then resets it to null.
|
/// to this and navigates, then resets it to null.
|
||||||
static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null);
|
static final ValueNotifier<PushTarget?> pendingTarget = ValueNotifier(null);
|
||||||
|
|
||||||
/// Newsletter id of the most recently tapped Marianum-Message notification,
|
|
||||||
/// or null. [App] listens to this and opens the message, then resets it.
|
|
||||||
static final ValueNotifier<String?> pendingNewsletterId = ValueNotifier(null);
|
|
||||||
|
|
||||||
static void handleResponse(NotificationResponse response) {
|
static void handleResponse(NotificationResponse response) {
|
||||||
final actionId = response.actionId;
|
final actionId = response.actionId;
|
||||||
@@ -29,13 +27,28 @@ class PushTapRouter {
|
|||||||
}
|
}
|
||||||
final map = _payloadMap(response.payload);
|
final map = _payloadMap(response.payload);
|
||||||
if (map == null) return;
|
if (map == null) return;
|
||||||
final newsletterId = _stringValue(map, 'newsletterId');
|
final target = resolvePushTarget(map);
|
||||||
if (newsletterId != null) {
|
if (target != null) pendingTarget.value = target;
|
||||||
pendingNewsletterId.value = newsletterId;
|
}
|
||||||
return;
|
|
||||||
|
static bool _launchHandled = false;
|
||||||
|
|
||||||
|
/// Routes the tap that cold-started the app. The plugin reports such a tap
|
||||||
|
/// only through its launch details, never through the response callback;
|
||||||
|
/// the details stay set for the whole process, hence the one-shot guard.
|
||||||
|
static Future<void> handleAppLaunch(
|
||||||
|
FlutterLocalNotificationsPlugin plugin,
|
||||||
|
) async {
|
||||||
|
if (_launchHandled) return;
|
||||||
|
_launchHandled = true;
|
||||||
|
try {
|
||||||
|
final details = await plugin.getNotificationAppLaunchDetails();
|
||||||
|
final response = details?.notificationResponse;
|
||||||
|
if (details?.didNotificationLaunchApp != true || response == null) return;
|
||||||
|
handleResponse(response);
|
||||||
|
} on Object catch (e) {
|
||||||
|
log('Reading the notification launch details failed: $e');
|
||||||
}
|
}
|
||||||
final token = _stringValue(map, 'chatToken');
|
|
||||||
if (token != null) pendingChatToken.value = token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Map<String, dynamic>? _payloadMap(String? payload) {
|
static Map<String, dynamic>? _payloadMap(String? payload) {
|
||||||
@@ -46,9 +59,4 @@ class PushTapRouter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static String? _stringValue(Map<String, dynamic> map, String key) {
|
|
||||||
final value = map[key];
|
|
||||||
return value is String && value.isNotEmpty ? value : null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/feedback/feedback_dialog.dart';
|
||||||
import '../view/pages/more/roomplan/roomplan.dart';
|
import '../view/pages/more/roomplan/roomplan.dart';
|
||||||
import '../view/pages/more/share/qr_share_view.dart';
|
import '../view/pages/more/share/qr_share_view.dart';
|
||||||
|
import '../view/pages/parent_letters/parent_letter_view.dart';
|
||||||
import '../view/pages/settings/chat_background_settings_page.dart';
|
import '../view/pages/settings/chat_background_settings_page.dart';
|
||||||
import '../view/pages/settings/modules_settings_page.dart';
|
import '../view/pages/settings/modules_settings_page.dart';
|
||||||
import '../view/pages/settings/settings.dart';
|
import '../view/pages/settings/settings.dart';
|
||||||
@@ -203,6 +204,12 @@ class AppRoutes {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens a parent letter by id — from the inbox as well as from push deep
|
||||||
|
/// links, where only the id is known.
|
||||||
|
static void openParentLetter(BuildContext context, {required String id}) {
|
||||||
|
pushScreen(context, withNavBar: false, screen: ParentLetterView(id: id));
|
||||||
|
}
|
||||||
|
|
||||||
/// Opens a ticker page (CONTENT or PROXIED_FILE) as a standalone detail
|
/// Opens a ticker page (CONTENT or PROXIED_FILE) as a standalone detail
|
||||||
/// screen. Used for deep links from outside the ticker module — inside the
|
/// screen. Used for deep links from outside the ticker module — inside the
|
||||||
/// module pages open in-place instead.
|
/// module pages open in-place instead.
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:badges/badges.dart' as badges;
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
||||||
@@ -16,14 +15,18 @@ import '../../../view/pages/holidays/holidays_view.dart';
|
|||||||
import '../../../view/pages/marianum_dates/marianum_dates_view.dart';
|
import '../../../view/pages/marianum_dates/marianum_dates_view.dart';
|
||||||
import '../../../view/pages/marianum_message/marianum_message_list_view.dart';
|
import '../../../view/pages/marianum_message/marianum_message_list_view.dart';
|
||||||
import '../../../view/pages/more/roomplan/roomplan.dart';
|
import '../../../view/pages/more/roomplan/roomplan.dart';
|
||||||
|
import '../../../view/pages/parent_letters/parent_letters_view.dart';
|
||||||
import '../../../view/pages/talk/chat_list.dart';
|
import '../../../view/pages/talk/chat_list.dart';
|
||||||
import '../../../view/pages/ticker/ticker_view.dart';
|
import '../../../view/pages/ticker/ticker_view.dart';
|
||||||
import '../../../view/pages/timetable/timetable.dart';
|
import '../../../view/pages/timetable/timetable.dart';
|
||||||
import '../../../widget/breaker/breaker.dart';
|
import '../../../widget/breaker/breaker.dart';
|
||||||
import '../../../widget/centered_leading.dart';
|
import '../../../widget/centered_leading.dart';
|
||||||
|
import '../../../widget/module_badge_icon.dart';
|
||||||
import '../infrastructure/loadable_state/loadable_state.dart';
|
import '../infrastructure/loadable_state/loadable_state.dart';
|
||||||
import 'chat_list/bloc/chat_list_bloc.dart';
|
import 'chat_list/bloc/chat_list_bloc.dart';
|
||||||
import 'chat_list/bloc/chat_list_state.dart';
|
import 'chat_list/bloc/chat_list_state.dart';
|
||||||
|
import 'parent_letters/bloc/parent_letters_bloc.dart';
|
||||||
|
import 'parent_letters/bloc/parent_letters_state.dart';
|
||||||
import 'settings/bloc/settings_cubit.dart';
|
import 'settings/bloc/settings_cubit.dart';
|
||||||
|
|
||||||
class AppModule {
|
class AppModule {
|
||||||
@@ -46,6 +49,7 @@ class AppModule {
|
|||||||
static const Map<Modules, Set<AccessRequirement>> requirements = {
|
static const Map<Modules, Set<AccessRequirement>> requirements = {
|
||||||
Modules.talk: {AccessRequirement.nextcloud},
|
Modules.talk: {AccessRequirement.nextcloud},
|
||||||
Modules.files: {AccessRequirement.nextcloud},
|
Modules.files: {AccessRequirement.nextcloud},
|
||||||
|
Modules.parentLetters: {AccessRequirement.guardian},
|
||||||
};
|
};
|
||||||
|
|
||||||
static bool isAvailableFor(Modules module, Session? session) =>
|
static bool isAvailableFor(Modules module, Session? session) =>
|
||||||
@@ -64,6 +68,18 @@ class AppModule {
|
|||||||
breakerArea: BreakerArea.timetable,
|
breakerArea: BreakerArea.timetable,
|
||||||
create: Timetable.new,
|
create: Timetable.new,
|
||||||
),
|
),
|
||||||
|
Modules.parentLetters: AppModule(
|
||||||
|
Modules.parentLetters,
|
||||||
|
name: 'Elternbriefe',
|
||||||
|
icon: () =>
|
||||||
|
BlocBuilder<ParentLettersBloc, LoadableState<ParentLettersState>>(
|
||||||
|
builder: (context, state) => ModuleBadgeIcon(
|
||||||
|
icon: Icons.mail_outline,
|
||||||
|
count: state.data?.unreadCount ?? 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
create: ParentLettersView.new,
|
||||||
|
),
|
||||||
Modules.ticker: AppModule(
|
Modules.ticker: AppModule(
|
||||||
Modules.ticker,
|
Modules.ticker,
|
||||||
name: 'Ticker',
|
name: 'Ticker',
|
||||||
@@ -77,34 +93,15 @@ class AppModule {
|
|||||||
Modules.talk,
|
Modules.talk,
|
||||||
name: 'Talk',
|
name: 'Talk',
|
||||||
icon: () => BlocBuilder<ChatListBloc, LoadableState<ChatListState>>(
|
icon: () => BlocBuilder<ChatListBloc, LoadableState<ChatListState>>(
|
||||||
builder: (context, state) {
|
builder: (context, state) => ModuleBadgeIcon(
|
||||||
final rooms = state.data?.rooms;
|
icon: Icons.chat,
|
||||||
if (rooms == null || rooms.data.isEmpty) {
|
count:
|
||||||
return const Icon(Icons.chat);
|
state.data?.rooms?.data.fold<int>(
|
||||||
}
|
0,
|
||||||
final messages = rooms.data
|
(sum, room) => sum + room.unreadMessages,
|
||||||
.map((e) => e.unreadMessages)
|
) ??
|
||||||
.reduce((a, b) => a + b);
|
0,
|
||||||
return badges.Badge(
|
),
|
||||||
showBadge: messages > 0,
|
|
||||||
position: badges.BadgePosition.topEnd(top: -3, end: -3),
|
|
||||||
stackFit: StackFit.loose,
|
|
||||||
badgeStyle: badges.BadgeStyle(
|
|
||||||
padding: const EdgeInsets.all(3),
|
|
||||||
badgeColor: Theme.of(context).primaryColor,
|
|
||||||
elevation: 1,
|
|
||||||
),
|
|
||||||
badgeContent: Text(
|
|
||||||
'$messages',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Icon(Icons.chat),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
breakerArea: BreakerArea.talk,
|
breakerArea: BreakerArea.talk,
|
||||||
create: ChatList.new,
|
create: ChatList.new,
|
||||||
@@ -299,6 +296,7 @@ class AppModule {
|
|||||||
|
|
||||||
enum Modules {
|
enum Modules {
|
||||||
timetable,
|
timetable,
|
||||||
|
parentLetters,
|
||||||
ticker,
|
ticker,
|
||||||
talk,
|
talk,
|
||||||
files,
|
files,
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_exception.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
|
||||||
|
import '../../../../../utils/random_id.dart';
|
||||||
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
|
||||||
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
||||||
|
import '../repository/parent_letter_repository.dart';
|
||||||
|
import 'parent_letter_event.dart';
|
||||||
|
import 'parent_letter_state.dart';
|
||||||
|
import 'parent_letters_bloc.dart';
|
||||||
|
|
||||||
|
/// One letter; [id] is the letter id so each letter keeps its own hydrated
|
||||||
|
/// cache entry. Changes are mirrored into the app-wide [inbox].
|
||||||
|
class ParentLetterBloc
|
||||||
|
extends
|
||||||
|
LoadableHydratedBloc<
|
||||||
|
ParentLetterEvent,
|
||||||
|
ParentLetterState,
|
||||||
|
ParentLetterRepository
|
||||||
|
> {
|
||||||
|
final String letterId;
|
||||||
|
final ParentLettersBloc inbox;
|
||||||
|
|
||||||
|
ParentLetterBloc(this.letterId, {required this.inbox});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get id => letterId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> gatherData() async {
|
||||||
|
final ParentLetterDetail letter;
|
||||||
|
try {
|
||||||
|
letter = await repo.getLetter(letterId);
|
||||||
|
} on ParentLetterException catch (e) {
|
||||||
|
if (e.error == ParentLetterError.letterNotFound) {
|
||||||
|
// Withdrawn: a hydrated copy must not stay readable.
|
||||||
|
add(Emit((state) => state.copyWith(letter: null)));
|
||||||
|
unawaited(inbox.refresh(silent: true));
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
add(DataGathered((state) => state.copyWith(letter: letter)));
|
||||||
|
if (!letter.summary.read) inbox.markRead(letterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> submitResponse({
|
||||||
|
required String childId,
|
||||||
|
required List<ParentLetterAnswer> answers,
|
||||||
|
Uint8List? signaturePng,
|
||||||
|
}) async {
|
||||||
|
final letter = await repo.submitResponse(
|
||||||
|
letterId: letterId,
|
||||||
|
childId: childId,
|
||||||
|
answers: answers,
|
||||||
|
signaturePng: signaturePng,
|
||||||
|
);
|
||||||
|
if (isClosed) return;
|
||||||
|
add(Emit((state) => state.copyWith(letter: letter)));
|
||||||
|
inbox.applyDetail(letter);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> sendThreadMessage(String body) async {
|
||||||
|
final message = await repo.sendThreadMessage(
|
||||||
|
letterId: letterId,
|
||||||
|
body: body,
|
||||||
|
clientMessageId: randomUuidV4(),
|
||||||
|
);
|
||||||
|
if (isClosed) return;
|
||||||
|
add(
|
||||||
|
Emit((state) {
|
||||||
|
final letter = state.letter;
|
||||||
|
if (letter == null) return state;
|
||||||
|
final thread = letter.content.thread;
|
||||||
|
return state.copyWith(
|
||||||
|
letter: ParentLetterDetail(
|
||||||
|
summary: letter.summary,
|
||||||
|
content: letter.content.copyWith(
|
||||||
|
thread: thread.copyWith(messages: [...thread.messages, message]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Uint8List> loadAttachment(String attachmentId) =>
|
||||||
|
repo.getAttachment(letterId: letterId, attachmentId: attachmentId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ParentLetterRepository repository() => ParentLetterRepository();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ParentLetterState fromNothing() => const ParentLetterState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ParentLetterState fromStorage(Map<String, dynamic> json) =>
|
||||||
|
ParentLetterState.fromJson(json);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic>? toStorage(ParentLetterState state) => state.toJson();
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
||||||
|
import 'parent_letter_state.dart';
|
||||||
|
|
||||||
|
sealed class ParentLetterEvent
|
||||||
|
extends LoadableHydratedBlocEvent<ParentLetterState> {}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
|
||||||
|
|
||||||
|
part 'parent_letter_state.freezed.dart';
|
||||||
|
part 'parent_letter_state.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterState with _$ParentLetterState {
|
||||||
|
const factory ParentLetterState({ParentLetterDetail? letter}) =
|
||||||
|
_ParentLetterState;
|
||||||
|
|
||||||
|
factory ParentLetterState.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterStateFromJson(json);
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// coverage:ignore-file
|
||||||
|
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
|
||||||
|
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||||
|
|
||||||
|
part of 'parent_letter_state.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// FreezedGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// dart format off
|
||||||
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$ParentLetterState {
|
||||||
|
|
||||||
|
ParentLetterDetail? get letter;
|
||||||
|
/// Create a copy of ParentLetterState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$ParentLetterStateCopyWith<ParentLetterState> get copyWith => _$ParentLetterStateCopyWithImpl<ParentLetterState>(this as ParentLetterState, _$identity);
|
||||||
|
|
||||||
|
/// Serializes this ParentLetterState to a JSON map.
|
||||||
|
Map<String, dynamic> toJson();
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
final _this = this as ParentLetterState;
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is ParentLetterState&&(identical(other.letter, _this.letter) || other.letter == _this.letter));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
final _this = this as ParentLetterState;
|
||||||
|
return Object.hash(runtimeType,_this.letter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
final _this = this as ParentLetterState;
|
||||||
|
return 'ParentLetterState(letter: ${_this.letter})';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class $ParentLetterStateCopyWith<$Res> {
|
||||||
|
factory $ParentLetterStateCopyWith(ParentLetterState value, $Res Function(ParentLetterState) _then) = _$ParentLetterStateCopyWithImpl;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
ParentLetterDetail? letter
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class _$ParentLetterStateCopyWithImpl<$Res>
|
||||||
|
implements $ParentLetterStateCopyWith<$Res> {
|
||||||
|
_$ParentLetterStateCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final ParentLetterState _self;
|
||||||
|
final $Res Function(ParentLetterState) _then;
|
||||||
|
|
||||||
|
/// Create a copy of ParentLetterState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline') @override $Res call({Object? letter = freezed,}) {
|
||||||
|
return _then(ParentLetterState(
|
||||||
|
letter: freezed == letter ? _self.letter : letter // ignore: cast_nullable_to_non_nullable
|
||||||
|
as ParentLetterDetail?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Adds pattern-matching-related methods to [ParentLetterState].
|
||||||
|
extension ParentLetterStatePatterns on ParentLetterState {
|
||||||
|
/// A variant of `map` that fallback to returning `orElse`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ParentLetterState value)? $default,{required TResult orElse(),}){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _ParentLetterState() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// Callbacks receives the raw object, upcasted.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case final Subclass2 value:
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ParentLetterState value) $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _ParentLetterState():
|
||||||
|
return $default(_that);case _:
|
||||||
|
throw StateError('Unexpected subclass');
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `map` that fallback to returning `null`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ParentLetterState value)? $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _ParentLetterState() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to an `orElse` callback.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( ParentLetterDetail? letter)? $default,{required TResult orElse(),}) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _ParentLetterState() when $default != null:
|
||||||
|
return $default(_that.letter);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// As opposed to `map`, this offers destructuring.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case Subclass2(:final field2):
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( ParentLetterDetail? letter) $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _ParentLetterState():
|
||||||
|
return $default(_that.letter);case _:
|
||||||
|
throw StateError('Unexpected subclass');
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to returning `null`
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( ParentLetterDetail? letter)? $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _ParentLetterState() when $default != null:
|
||||||
|
return $default(_that.letter);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
|
||||||
|
class _ParentLetterState implements ParentLetterState {
|
||||||
|
const _ParentLetterState({this.letter});
|
||||||
|
factory _ParentLetterState.fromJson(Map<String, dynamic> json) => _$ParentLetterStateFromJson(json);
|
||||||
|
|
||||||
|
@override final ParentLetterDetail? letter;
|
||||||
|
|
||||||
|
/// Create a copy of ParentLetterState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$ParentLetterStateCopyWith<_ParentLetterState> get copyWith => __$ParentLetterStateCopyWithImpl<_ParentLetterState>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$ParentLetterStateToJson(this, );
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ParentLetterState&&(identical(other.letter, letter) || other.letter == letter));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(runtimeType,letter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'ParentLetterState(letter: $letter)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class _$ParentLetterStateCopyWith<$Res> implements $ParentLetterStateCopyWith<$Res> {
|
||||||
|
factory _$ParentLetterStateCopyWith(_ParentLetterState value, $Res Function(_ParentLetterState) _then) = __$ParentLetterStateCopyWithImpl;
|
||||||
|
@override @useResult
|
||||||
|
$Res call({
|
||||||
|
ParentLetterDetail? letter
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class __$ParentLetterStateCopyWithImpl<$Res>
|
||||||
|
implements _$ParentLetterStateCopyWith<$Res> {
|
||||||
|
__$ParentLetterStateCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final _ParentLetterState _self;
|
||||||
|
final $Res Function(_ParentLetterState) _then;
|
||||||
|
|
||||||
|
/// Create a copy of ParentLetterState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @pragma('vm:prefer-inline') $Res call({Object? letter = freezed,}) {
|
||||||
|
return _then(_ParentLetterState(
|
||||||
|
letter: freezed == letter ? _self.letter : letter // ignore: cast_nullable_to_non_nullable
|
||||||
|
as ParentLetterDetail?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// dart format on
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'parent_letter_state.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
_ParentLetterState _$ParentLetterStateFromJson(Map<String, dynamic> json) =>
|
||||||
|
_ParentLetterState(
|
||||||
|
letter: json['letter'] == null
|
||||||
|
? null
|
||||||
|
: ParentLetterDetail.fromJson(json['letter'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterStateToJson(_ParentLetterState instance) =>
|
||||||
|
<String, dynamic>{'letter': instance.letter};
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import '../../../../../access/access_requirement.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
|
||||||
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
|
||||||
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
||||||
|
import '../parent_letters_logic.dart';
|
||||||
|
import '../repository/parent_letters_repository.dart';
|
||||||
|
import 'parent_letters_event.dart';
|
||||||
|
import 'parent_letters_state.dart';
|
||||||
|
|
||||||
|
/// The guardian's letter inbox. Lives app-wide because the module badge and
|
||||||
|
/// push handling need it outside the page.
|
||||||
|
class ParentLettersBloc
|
||||||
|
extends
|
||||||
|
LoadableHydratedBloc<
|
||||||
|
ParentLettersEvent,
|
||||||
|
ParentLettersState,
|
||||||
|
ParentLettersRepository
|
||||||
|
> {
|
||||||
|
@override
|
||||||
|
Set<AccessRequirement> get requirements => const {AccessRequirement.guardian};
|
||||||
|
|
||||||
|
Future<void>? _loading;
|
||||||
|
|
||||||
|
/// App start, prefetch, page visit and push handling all ask for the first
|
||||||
|
/// page within moments of each other; they share one request.
|
||||||
|
@override
|
||||||
|
Future<void> gatherData() =>
|
||||||
|
_loading ??= _loadFirstPage().whenComplete(() => _loading = null);
|
||||||
|
|
||||||
|
Future<void> _loadFirstPage() async {
|
||||||
|
final page = await repo.getLetters();
|
||||||
|
if (isClosed) return;
|
||||||
|
add(DataGathered((_) => firstPageState(page)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reloads the first page; older pages loaded via [loadOlder] are dropped.
|
||||||
|
Future<void> refresh({bool silent = false}) async {
|
||||||
|
if (!requirementsMet) return;
|
||||||
|
if (!silent) add(RefetchStarted<ParentLettersState>());
|
||||||
|
try {
|
||||||
|
await gatherData();
|
||||||
|
} catch (e) {
|
||||||
|
if (isClosed) return;
|
||||||
|
if (silent) {
|
||||||
|
log('Silent parent letters refresh failed: $e');
|
||||||
|
} else {
|
||||||
|
addLoadingError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadOlder() async {
|
||||||
|
final letters = innerState?.letters;
|
||||||
|
if (letters == null || letters.isEmpty) return;
|
||||||
|
final page = await repo.getLetters(before: letters.last.id);
|
||||||
|
if (isClosed) return;
|
||||||
|
add(Emit((state) => appendOlderPage(state, page)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optimistic locally. When nothing could be applied locally (a letter
|
||||||
|
/// opened from a push is not in the inbox yet), the follow-up refresh picks
|
||||||
|
/// it up.
|
||||||
|
void markRead(String letterId) {
|
||||||
|
final current = innerState;
|
||||||
|
final updated = current == null ? null : withLetterRead(current, letterId);
|
||||||
|
final appliedLocally = updated != null && !identical(updated, current);
|
||||||
|
if (appliedLocally) add(Emit((_) => updated));
|
||||||
|
repo
|
||||||
|
.markRead(letterId)
|
||||||
|
.then((_) {
|
||||||
|
if (!appliedLocally) _reloadAfterWrite();
|
||||||
|
})
|
||||||
|
.catchError((Object e) {
|
||||||
|
log('Marking parent letter $letterId as read failed: $e');
|
||||||
|
_reloadAfterWrite();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A request already in flight may predate the write, so it is not joined.
|
||||||
|
Future<void> _reloadAfterWrite() async {
|
||||||
|
try {
|
||||||
|
await _loading;
|
||||||
|
} on Object {
|
||||||
|
// Its own caller reports the failure.
|
||||||
|
}
|
||||||
|
await refresh(silent: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes over a freshly loaded letter.
|
||||||
|
void applyDetail(ParentLetterDetail detail) =>
|
||||||
|
add(Emit((state) => withSummary(state, detail.summary)));
|
||||||
|
|
||||||
|
@override
|
||||||
|
ParentLettersRepository repository() => ParentLettersRepository();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ParentLettersState fromNothing() => const ParentLettersState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ParentLettersState fromStorage(Map<String, dynamic> json) =>
|
||||||
|
ParentLettersState.fromJson(json);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic>? toStorage(ParentLettersState state) => state.toJson();
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
||||||
|
import 'parent_letters_state.dart';
|
||||||
|
|
||||||
|
sealed class ParentLettersEvent
|
||||||
|
extends LoadableHydratedBlocEvent<ParentLettersState> {}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
|
||||||
|
|
||||||
|
part 'parent_letters_state.freezed.dart';
|
||||||
|
part 'parent_letters_state.g.dart';
|
||||||
|
|
||||||
|
/// Hydrated inbox. [unreadCount] and [openCount] cover all letters on the
|
||||||
|
/// server, not just the loaded [letters].
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLettersState with _$ParentLettersState {
|
||||||
|
const factory ParentLettersState({
|
||||||
|
@Default([]) List<ParentLetterSummary> letters,
|
||||||
|
@Default(false) bool hasMore,
|
||||||
|
@Default(0) int unreadCount,
|
||||||
|
@Default(0) int openCount,
|
||||||
|
}) = _ParentLettersState;
|
||||||
|
|
||||||
|
factory ParentLettersState.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLettersStateFromJson(json);
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// coverage:ignore-file
|
||||||
|
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
|
||||||
|
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||||
|
|
||||||
|
part of 'parent_letters_state.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// FreezedGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// dart format off
|
||||||
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$ParentLettersState {
|
||||||
|
|
||||||
|
List<ParentLetterSummary> get letters; bool get hasMore; int get unreadCount; int get openCount;
|
||||||
|
/// Create a copy of ParentLettersState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$ParentLettersStateCopyWith<ParentLettersState> get copyWith => _$ParentLettersStateCopyWithImpl<ParentLettersState>(this as ParentLettersState, _$identity);
|
||||||
|
|
||||||
|
/// Serializes this ParentLettersState to a JSON map.
|
||||||
|
Map<String, dynamic> toJson();
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
final _this = this as ParentLettersState;
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is ParentLettersState&&const DeepCollectionEquality().equals(other.letters, _this.letters)&&(identical(other.hasMore, _this.hasMore) || other.hasMore == _this.hasMore)&&(identical(other.unreadCount, _this.unreadCount) || other.unreadCount == _this.unreadCount)&&(identical(other.openCount, _this.openCount) || other.openCount == _this.openCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
final _this = this as ParentLettersState;
|
||||||
|
return Object.hash(runtimeType,const DeepCollectionEquality().hash(_this.letters),_this.hasMore,_this.unreadCount,_this.openCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
final _this = this as ParentLettersState;
|
||||||
|
return 'ParentLettersState(letters: ${_this.letters}, hasMore: ${_this.hasMore}, unreadCount: ${_this.unreadCount}, openCount: ${_this.openCount})';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class $ParentLettersStateCopyWith<$Res> {
|
||||||
|
factory $ParentLettersStateCopyWith(ParentLettersState value, $Res Function(ParentLettersState) _then) = _$ParentLettersStateCopyWithImpl;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
List<ParentLetterSummary> letters, bool hasMore, int unreadCount, int openCount
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class _$ParentLettersStateCopyWithImpl<$Res>
|
||||||
|
implements $ParentLettersStateCopyWith<$Res> {
|
||||||
|
_$ParentLettersStateCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final ParentLettersState _self;
|
||||||
|
final $Res Function(ParentLettersState) _then;
|
||||||
|
|
||||||
|
/// Create a copy of ParentLettersState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline') @override $Res call({Object? letters = null,Object? hasMore = null,Object? unreadCount = null,Object? openCount = null,}) {
|
||||||
|
return _then(ParentLettersState(
|
||||||
|
letters: null == letters ? _self.letters : letters // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<ParentLetterSummary>,hasMore: null == hasMore ? _self.hasMore : hasMore // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,unreadCount: null == unreadCount ? _self.unreadCount : unreadCount // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,openCount: null == openCount ? _self.openCount : openCount // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Adds pattern-matching-related methods to [ParentLettersState].
|
||||||
|
extension ParentLettersStatePatterns on ParentLettersState {
|
||||||
|
/// A variant of `map` that fallback to returning `orElse`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ParentLettersState value)? $default,{required TResult orElse(),}){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _ParentLettersState() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// Callbacks receives the raw object, upcasted.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case final Subclass2 value:
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ParentLettersState value) $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _ParentLettersState():
|
||||||
|
return $default(_that);case _:
|
||||||
|
throw StateError('Unexpected subclass');
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `map` that fallback to returning `null`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ParentLettersState value)? $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _ParentLettersState() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to an `orElse` callback.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( List<ParentLetterSummary> letters, bool hasMore, int unreadCount, int openCount)? $default,{required TResult orElse(),}) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _ParentLettersState() when $default != null:
|
||||||
|
return $default(_that.letters,_that.hasMore,_that.unreadCount,_that.openCount);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// As opposed to `map`, this offers destructuring.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case Subclass2(:final field2):
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<ParentLetterSummary> letters, bool hasMore, int unreadCount, int openCount) $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _ParentLettersState():
|
||||||
|
return $default(_that.letters,_that.hasMore,_that.unreadCount,_that.openCount);case _:
|
||||||
|
throw StateError('Unexpected subclass');
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to returning `null`
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<ParentLetterSummary> letters, bool hasMore, int unreadCount, int openCount)? $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _ParentLettersState() when $default != null:
|
||||||
|
return $default(_that.letters,_that.hasMore,_that.unreadCount,_that.openCount);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
|
||||||
|
class _ParentLettersState implements ParentLettersState {
|
||||||
|
const _ParentLettersState({ List<ParentLetterSummary> letters = const [], this.hasMore = false, this.unreadCount = 0, this.openCount = 0}): _letters = letters;
|
||||||
|
factory _ParentLettersState.fromJson(Map<String, dynamic> json) => _$ParentLettersStateFromJson(json);
|
||||||
|
|
||||||
|
final List<ParentLetterSummary> _letters;
|
||||||
|
@override@JsonKey() List<ParentLetterSummary> get letters {
|
||||||
|
if (_letters is EqualUnmodifiableListView) return _letters;
|
||||||
|
// ignore: implicit_dynamic_type
|
||||||
|
return EqualUnmodifiableListView(_letters);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override@JsonKey() final bool hasMore;
|
||||||
|
@override@JsonKey() final int unreadCount;
|
||||||
|
@override@JsonKey() final int openCount;
|
||||||
|
|
||||||
|
/// Create a copy of ParentLettersState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$ParentLettersStateCopyWith<_ParentLettersState> get copyWith => __$ParentLettersStateCopyWithImpl<_ParentLettersState>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$ParentLettersStateToJson(this, );
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ParentLettersState&&const DeepCollectionEquality().equals(other.letters, _letters)&&(identical(other.hasMore, hasMore) || other.hasMore == hasMore)&&(identical(other.unreadCount, unreadCount) || other.unreadCount == unreadCount)&&(identical(other.openCount, openCount) || other.openCount == openCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(runtimeType,const DeepCollectionEquality().hash(_letters),hasMore,unreadCount,openCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'ParentLettersState(letters: $letters, hasMore: $hasMore, unreadCount: $unreadCount, openCount: $openCount)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class _$ParentLettersStateCopyWith<$Res> implements $ParentLettersStateCopyWith<$Res> {
|
||||||
|
factory _$ParentLettersStateCopyWith(_ParentLettersState value, $Res Function(_ParentLettersState) _then) = __$ParentLettersStateCopyWithImpl;
|
||||||
|
@override @useResult
|
||||||
|
$Res call({
|
||||||
|
List<ParentLetterSummary> letters, bool hasMore, int unreadCount, int openCount
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class __$ParentLettersStateCopyWithImpl<$Res>
|
||||||
|
implements _$ParentLettersStateCopyWith<$Res> {
|
||||||
|
__$ParentLettersStateCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final _ParentLettersState _self;
|
||||||
|
final $Res Function(_ParentLettersState) _then;
|
||||||
|
|
||||||
|
/// Create a copy of ParentLettersState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @pragma('vm:prefer-inline') $Res call({Object? letters = null,Object? hasMore = null,Object? unreadCount = null,Object? openCount = null,}) {
|
||||||
|
return _then(_ParentLettersState(
|
||||||
|
letters: null == letters ? _self._letters : letters // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<ParentLetterSummary>,hasMore: null == hasMore ? _self.hasMore : hasMore // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,unreadCount: null == unreadCount ? _self.unreadCount : unreadCount // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,openCount: null == openCount ? _self.openCount : openCount // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// dart format on
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'parent_letters_state.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
_ParentLettersState _$ParentLettersStateFromJson(Map<String, dynamic> json) =>
|
||||||
|
_ParentLettersState(
|
||||||
|
letters:
|
||||||
|
(json['letters'] as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(e) => ParentLetterSummary.fromJson(e as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
hasMore: json['hasMore'] as bool? ?? false,
|
||||||
|
unreadCount: (json['unreadCount'] as num?)?.toInt() ?? 0,
|
||||||
|
openCount: (json['openCount'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLettersStateToJson(_ParentLettersState instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'letters': instance.letters,
|
||||||
|
'hasMore': instance.hasMore,
|
||||||
|
'unreadCount': instance.unreadCount,
|
||||||
|
'openCount': instance.openCount,
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import '../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
|
||||||
|
import 'bloc/parent_letters_state.dart';
|
||||||
|
|
||||||
|
ParentLettersState firstPageState(ParentLetterListResponse page) =>
|
||||||
|
ParentLettersState(
|
||||||
|
letters: page.items,
|
||||||
|
hasMore: page.hasMore,
|
||||||
|
unreadCount: page.unreadCount,
|
||||||
|
openCount: page.openCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
ParentLettersState appendOlderPage(
|
||||||
|
ParentLettersState state,
|
||||||
|
ParentLetterListResponse page,
|
||||||
|
) {
|
||||||
|
final known = {for (final letter in state.letters) letter.id};
|
||||||
|
return state.copyWith(
|
||||||
|
letters: [
|
||||||
|
...state.letters,
|
||||||
|
...page.items.where((letter) => !known.contains(letter.id)),
|
||||||
|
],
|
||||||
|
hasMore: page.hasMore,
|
||||||
|
unreadCount: page.unreadCount,
|
||||||
|
openCount: page.openCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks [letterId] as read and lowers the unread counter. Returns [state]
|
||||||
|
/// itself when the letter is unknown or already read.
|
||||||
|
ParentLettersState withLetterRead(ParentLettersState state, String letterId) {
|
||||||
|
final index = state.letters.indexWhere((letter) => letter.id == letterId);
|
||||||
|
if (index < 0 || state.letters[index].read) return state;
|
||||||
|
final letters = [...state.letters];
|
||||||
|
letters[index] = letters[index].copyWith(read: true);
|
||||||
|
return state.copyWith(
|
||||||
|
letters: letters,
|
||||||
|
unreadCount: state.unreadCount > 0 ? state.unreadCount - 1 : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the letter and moves [ParentLettersState.openCount] along with
|
||||||
|
/// its status, so answering does not need a reload to fix the counter.
|
||||||
|
ParentLettersState withSummary(
|
||||||
|
ParentLettersState state,
|
||||||
|
ParentLetterSummary summary,
|
||||||
|
) {
|
||||||
|
final index = state.letters.indexWhere((letter) => letter.id == summary.id);
|
||||||
|
if (index < 0) return state;
|
||||||
|
int open(ParentLetterSummary letter) =>
|
||||||
|
letter.status == ParentLetterStatus.open ? 1 : 0;
|
||||||
|
final openCount =
|
||||||
|
state.openCount - open(state.letters[index]) + open(summary);
|
||||||
|
final letters = [...state.letters];
|
||||||
|
letters[index] = summary;
|
||||||
|
return state.copyWith(
|
||||||
|
letters: letters,
|
||||||
|
openCount: openCount < 0 ? 0 : openCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [childId] null means all children.
|
||||||
|
List<ParentLetterSummary> filterLettersByChild(
|
||||||
|
List<ParentLetterSummary> letters,
|
||||||
|
String? childId,
|
||||||
|
) => childId == null
|
||||||
|
? letters
|
||||||
|
: letters.where((letter) => letter.childIds.contains(childId)).toList();
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import '../../../../../api/demo/data/demo_parent_letters.dart';
|
||||||
|
import '../../../../../api/demo/demo_mode.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/parent_letters/get_parent_letter.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/parent_letters/get_parent_letter_attachment.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/parent_letters/post_parent_letter_thread_message.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/parent_letters/submit_parent_letter_response.dart';
|
||||||
|
import '../../../infrastructure/repository/repository.dart';
|
||||||
|
import '../bloc/parent_letter_state.dart';
|
||||||
|
|
||||||
|
/// Split from [ParentLettersRepository] because the loadable base binds a
|
||||||
|
/// repository to its state type. Writes never reach this class in demo mode
|
||||||
|
/// (the views guard them).
|
||||||
|
class ParentLetterRepository extends Repository<ParentLetterState> {
|
||||||
|
Future<ParentLetterDetail> getLetter(String letterId) {
|
||||||
|
if (DemoMode.active) {
|
||||||
|
return Future.value(DemoParentLetters.detail(letterId));
|
||||||
|
}
|
||||||
|
return GetParentLetter().run(letterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ParentLetterDetail> submitResponse({
|
||||||
|
required String letterId,
|
||||||
|
required String childId,
|
||||||
|
required List<ParentLetterAnswer> answers,
|
||||||
|
Uint8List? signaturePng,
|
||||||
|
}) => SubmitParentLetterResponse().run(
|
||||||
|
letterId: letterId,
|
||||||
|
childId: childId,
|
||||||
|
answers: answers,
|
||||||
|
signaturePng: signaturePng,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<ParentLetterThreadMessage> sendThreadMessage({
|
||||||
|
required String letterId,
|
||||||
|
required String body,
|
||||||
|
required String clientMessageId,
|
||||||
|
}) => PostParentLetterThreadMessage().run(
|
||||||
|
letterId: letterId,
|
||||||
|
body: body,
|
||||||
|
clientMessageId: clientMessageId,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<Uint8List> getAttachment({
|
||||||
|
required String letterId,
|
||||||
|
required String attachmentId,
|
||||||
|
}) => GetParentLetterAttachment().run(
|
||||||
|
letterId: letterId,
|
||||||
|
attachmentId: attachmentId,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import '../../../../../api/demo/data/demo_parent_letters.dart';
|
||||||
|
import '../../../../../api/demo/demo_mode.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/parent_letters/get_parent_letters.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/parent_letters/mark_parent_letter_read.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
|
||||||
|
import '../../../infrastructure/repository/repository.dart';
|
||||||
|
import '../bloc/parent_letters_state.dart';
|
||||||
|
|
||||||
|
class ParentLettersRepository extends Repository<ParentLettersState> {
|
||||||
|
Future<ParentLetterListResponse> getLetters({String? before}) {
|
||||||
|
if (DemoMode.active) return Future.value(DemoParentLetters.list());
|
||||||
|
return GetParentLetters().run(before: before);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markRead(String letterId) {
|
||||||
|
if (DemoMode.active) {
|
||||||
|
DemoParentLetters.markRead(letterId);
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
return MarkParentLetterRead().run(letterId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ Map<String, dynamic> _$ModulesSettingsToJson(
|
|||||||
|
|
||||||
const _$ModulesEnumMap = {
|
const _$ModulesEnumMap = {
|
||||||
Modules.timetable: 'timetable',
|
Modules.timetable: 'timetable',
|
||||||
|
Modules.parentLetters: 'parentLetters',
|
||||||
Modules.ticker: 'ticker',
|
Modules.ticker: 'ticker',
|
||||||
Modules.talk: 'talk',
|
Modules.talk: 'talk',
|
||||||
Modules.files: 'files',
|
Modules.files: 'files',
|
||||||
|
|||||||
@@ -16,9 +16,20 @@ class NotificationSettings {
|
|||||||
@JsonKey(defaultValue: false)
|
@JsonKey(defaultValue: false)
|
||||||
bool talkPermissionPromptShown;
|
bool talkPermissionPromptShown;
|
||||||
|
|
||||||
|
/// Same one-shot guards for the two occasions on which accounts with
|
||||||
|
/// parent letters are asked: right after signing in and, if the permission
|
||||||
|
/// is still missing, on the first visit of the parent letters.
|
||||||
|
@JsonKey(defaultValue: false)
|
||||||
|
bool guardianLoginPromptShown;
|
||||||
|
|
||||||
|
@JsonKey(defaultValue: false)
|
||||||
|
bool parentLettersPromptShown;
|
||||||
|
|
||||||
NotificationSettings({
|
NotificationSettings({
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
this.talkPermissionPromptShown = false,
|
this.talkPermissionPromptShown = false,
|
||||||
|
this.guardianLoginPromptShown = false,
|
||||||
|
this.parentLettersPromptShown = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory NotificationSettings.fromJson(Map<String, dynamic> json) =>
|
factory NotificationSettings.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ NotificationSettings _$NotificationSettingsFromJson(
|
|||||||
enabled: json['enabled'] as bool? ?? true,
|
enabled: json['enabled'] as bool? ?? true,
|
||||||
talkPermissionPromptShown:
|
talkPermissionPromptShown:
|
||||||
json['talkPermissionPromptShown'] as bool? ?? false,
|
json['talkPermissionPromptShown'] as bool? ?? false,
|
||||||
|
guardianLoginPromptShown: json['guardianLoginPromptShown'] as bool? ?? false,
|
||||||
|
parentLettersPromptShown: json['parentLettersPromptShown'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$NotificationSettingsToJson(
|
Map<String, dynamic> _$NotificationSettingsToJson(
|
||||||
@@ -19,4 +21,6 @@ Map<String, dynamic> _$NotificationSettingsToJson(
|
|||||||
) => <String, dynamic>{
|
) => <String, dynamic>{
|
||||||
'enabled': instance.enabled,
|
'enabled': instance.enabled,
|
||||||
'talkPermissionPromptShown': instance.talkPermissionPromptShown,
|
'talkPermissionPromptShown': instance.talkPermissionPromptShown,
|
||||||
|
'guardianLoginPromptShown': instance.guardianLoginPromptShown,
|
||||||
|
'parentLettersPromptShown': instance.parentLettersPromptShown,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,3 +8,12 @@ String randomHexId({int bytes = 16}) {
|
|||||||
(_) => random.nextInt(256),
|
(_) => random.nextInt(256),
|
||||||
).map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
).map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Random RFC 4122 version-4 UUID, for ids a server expects in UUID form.
|
||||||
|
String randomUuidV4() {
|
||||||
|
final hex = randomHexId();
|
||||||
|
final variant = (int.parse(hex[16], radix: 16) & 0x3 | 0x8).toRadixString(16);
|
||||||
|
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
|
||||||
|
'4${hex.substring(13, 16)}-$variant${hex.substring(17, 20)}-'
|
||||||
|
'${hex.substring(20)}';
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import '../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
|
||||||
|
|
||||||
|
enum ParentLetterFormMode {
|
||||||
|
/// Nothing answered yet and the server accepts a response.
|
||||||
|
open,
|
||||||
|
|
||||||
|
/// Answered, but the server still accepts a change.
|
||||||
|
change,
|
||||||
|
|
||||||
|
/// Answered and locked.
|
||||||
|
done,
|
||||||
|
|
||||||
|
/// Unanswered and locked (deadline passed).
|
||||||
|
closed,
|
||||||
|
|
||||||
|
/// The letter asks for an input this app version cannot render.
|
||||||
|
unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the response form of one child shows and allows. Whether a response
|
||||||
|
/// may be sent at all is the server's call ([ParentLetterChildState.editable]);
|
||||||
|
/// this only turns that verdict into a form.
|
||||||
|
class ParentLetterFormPolicy {
|
||||||
|
final ParentLetterFormMode mode;
|
||||||
|
final List<ParentLetterField> fields;
|
||||||
|
final bool signatureRequired;
|
||||||
|
|
||||||
|
const ParentLetterFormPolicy._(
|
||||||
|
this.mode,
|
||||||
|
this.fields,
|
||||||
|
this.signatureRequired,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get canSubmit =>
|
||||||
|
mode == ParentLetterFormMode.open || mode == ParentLetterFormMode.change;
|
||||||
|
|
||||||
|
bool get isAcknowledgement => fields.isEmpty && !signatureRequired;
|
||||||
|
|
||||||
|
/// Signed responses and acknowledgements cannot be changed afterwards, so
|
||||||
|
/// the form asks for confirmation first.
|
||||||
|
bool get submitIsFinal => isAcknowledgement || signatureRequired;
|
||||||
|
|
||||||
|
String get submitLabel {
|
||||||
|
if (isAcknowledgement) return 'Zur Kenntnis genommen';
|
||||||
|
if (signatureRequired) return 'Unterschreiben und absenden';
|
||||||
|
return mode == ParentLetterFormMode.change
|
||||||
|
? 'Rückmeldung ändern'
|
||||||
|
: 'Rückmeldung absenden';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [selection] maps field id to the chosen option id.
|
||||||
|
bool isComplete(Map<String, String> selection) => fields
|
||||||
|
.where((field) => field.isRequired)
|
||||||
|
.every((field) => selection.containsKey(field.id));
|
||||||
|
|
||||||
|
List<ParentLetterAnswer> answersFor(Map<String, String> selection) => [
|
||||||
|
for (final field in fields)
|
||||||
|
if (selection[field.id] case final optionId?)
|
||||||
|
ParentLetterAnswer(fieldId: field.id, optionIds: [optionId]),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The selection a previous [response] stands for.
|
||||||
|
static Map<String, String> selectionOf(ParentLetterResponse? response) => {
|
||||||
|
for (final answer in response?.answers ?? const <ParentLetterAnswer>[])
|
||||||
|
if (answer.optionIds.isNotEmpty) answer.fieldId: answer.optionIds.first,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Null when the letter asks for nothing.
|
||||||
|
static ParentLetterFormPolicy? resolve({
|
||||||
|
required ParentLetterRequest? request,
|
||||||
|
required ParentLetterChildState child,
|
||||||
|
}) {
|
||||||
|
if (request == null) return null;
|
||||||
|
final supported = request.fields
|
||||||
|
.where((field) => field.type != ParentLetterFieldType.unknown)
|
||||||
|
.toList();
|
||||||
|
final ParentLetterFormMode mode;
|
||||||
|
if (!child.editable) {
|
||||||
|
mode = child.response != null
|
||||||
|
? ParentLetterFormMode.done
|
||||||
|
: ParentLetterFormMode.closed;
|
||||||
|
} else if (request.fields.any(
|
||||||
|
(field) =>
|
||||||
|
field.isRequired && field.type == ParentLetterFieldType.unknown,
|
||||||
|
)) {
|
||||||
|
mode = ParentLetterFormMode.unsupported;
|
||||||
|
} else {
|
||||||
|
mode = child.response != null
|
||||||
|
? ParentLetterFormMode.change
|
||||||
|
: ParentLetterFormMode.open;
|
||||||
|
}
|
||||||
|
return ParentLetterFormPolicy._(mode, supported, request.signatureRequired);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||||
|
|
||||||
|
import '../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart';
|
||||||
|
import '../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
|
||||||
|
import '../../../extensions/date_time.dart';
|
||||||
|
import '../../../notification/notification_tasks.dart';
|
||||||
|
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
|
||||||
|
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
||||||
|
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
|
||||||
|
import '../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||||
|
import '../../../state/app/modules/parent_letters/bloc/parent_letter_bloc.dart';
|
||||||
|
import '../../../state/app/modules/parent_letters/bloc/parent_letter_state.dart';
|
||||||
|
import '../../../state/app/modules/parent_letters/bloc/parent_letters_bloc.dart';
|
||||||
|
import '../../../utils/url_opener.dart';
|
||||||
|
import 'parent_letter_form_policy.dart';
|
||||||
|
import 'widgets/parent_letter_attachments.dart';
|
||||||
|
import 'widgets/parent_letter_response_card.dart';
|
||||||
|
import 'widgets/parent_letter_thread.dart';
|
||||||
|
|
||||||
|
class ParentLetterView extends StatelessWidget {
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
const ParentLetterView({required this.id, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(
|
||||||
|
BuildContext context,
|
||||||
|
) => BlocModule<ParentLetterBloc, LoadableState<ParentLetterState>>(
|
||||||
|
create: (context) =>
|
||||||
|
ParentLetterBloc(id, inbox: context.read<ParentLettersBloc>()),
|
||||||
|
autoRebuild: true,
|
||||||
|
onInitialisation: (_, _) =>
|
||||||
|
NotificationTasks.clearParentLetterNotification(id),
|
||||||
|
child: (context, bloc, state) {
|
||||||
|
final letter = state.data?.letter;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Elternbrief')),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: LoadableStateConsumer<ParentLetterBloc, ParentLetterState>(
|
||||||
|
isReady: (state) => state.letter != null,
|
||||||
|
child: (state, loading) => _LetterBody(
|
||||||
|
letter: state.letter!,
|
||||||
|
children: context.watch<CapabilitiesCubit>().state.children,
|
||||||
|
bloc: bloc,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (letter != null && letter.content.thread.enabled)
|
||||||
|
ParentLetterThreadInput(
|
||||||
|
recipientName: letter.summary.sender.displayName,
|
||||||
|
onSend: bloc.sendThreadMessage,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LetterBody extends StatelessWidget {
|
||||||
|
final ParentLetterDetail letter;
|
||||||
|
final List<GuardianChild> children;
|
||||||
|
final ParentLetterBloc bloc;
|
||||||
|
|
||||||
|
const _LetterBody({
|
||||||
|
required this.letter,
|
||||||
|
required this.children,
|
||||||
|
required this.bloc,
|
||||||
|
});
|
||||||
|
|
||||||
|
String _childName(String childId) =>
|
||||||
|
children.firstWhereOrNull((child) => child.id == childId)?.displayName ??
|
||||||
|
'dein Kind';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final summary = letter.summary;
|
||||||
|
final content = letter.content;
|
||||||
|
final editedAt = summary.editedAt;
|
||||||
|
return ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(summary.subject, style: theme.textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
[
|
||||||
|
summary.sender.displayName,
|
||||||
|
summary.sentAt.formatDateTime(),
|
||||||
|
if (editedAt != null)
|
||||||
|
'bearbeitet am ${editedAt.formatDateTime()}',
|
||||||
|
].join(' · '),
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if (children.length > 1)
|
||||||
|
Text(
|
||||||
|
'Betrifft: ${summary.childIds.map(_childName).join(', ')}',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SelectableLinkify(
|
||||||
|
text: content.body,
|
||||||
|
onOpen: UrlOpener.onOpen,
|
||||||
|
options: const LinkifyOptions(humanize: false),
|
||||||
|
style: theme.textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (content.attachments.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ParentLetterAttachments(
|
||||||
|
letterId: summary.id,
|
||||||
|
attachments: content.attachments,
|
||||||
|
load: bloc.loadAttachment,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
for (final child in content.children)
|
||||||
|
if (ParentLetterFormPolicy.resolve(
|
||||||
|
request: content.request,
|
||||||
|
child: child,
|
||||||
|
)
|
||||||
|
case final policy?)
|
||||||
|
ParentLetterResponseCard(
|
||||||
|
key: ValueKey((child.childId, child.response)),
|
||||||
|
childName: _childName(child.childId),
|
||||||
|
child: child,
|
||||||
|
policy: policy,
|
||||||
|
deadline: content.request?.deadline,
|
||||||
|
onSubmit: (answers, signaturePng) => bloc.submitResponse(
|
||||||
|
childId: child.childId,
|
||||||
|
answers: answers,
|
||||||
|
signaturePng: signaturePng,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (content.thread.messages.isNotEmpty) ...[
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
|
child: Text(
|
||||||
|
'Nachrichten',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ParentLetterThreadMessages(content.thread.messages),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
import '../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart';
|
||||||
|
import '../../../push/notification_permission_prompt.dart';
|
||||||
|
import '../../../routing/app_routes.dart';
|
||||||
|
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
||||||
|
import '../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||||
|
import '../../../state/app/modules/parent_letters/bloc/parent_letters_bloc.dart';
|
||||||
|
import '../../../state/app/modules/parent_letters/bloc/parent_letters_state.dart';
|
||||||
|
import '../../../state/app/modules/parent_letters/parent_letters_logic.dart';
|
||||||
|
import '../../../widget/async_action_button.dart';
|
||||||
|
import '../../../widget/child_switcher.dart';
|
||||||
|
import '../../../widget/placeholder_view.dart';
|
||||||
|
import 'widgets/parent_letter_tile.dart';
|
||||||
|
|
||||||
|
class ParentLettersView extends StatefulWidget {
|
||||||
|
const ParentLettersView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ParentLettersView> createState() => _ParentLettersViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ParentLettersViewState extends State<ParentLettersView> {
|
||||||
|
/// Null shows the letters of all children. Deliberately independent of the
|
||||||
|
/// app-wide child selection: the inbox must not hide a sibling's letters.
|
||||||
|
String? _childFilter;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.read<ParentLettersBloc>().refresh(silent: true);
|
||||||
|
maybePromptParentLetterNotifications(context);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final children = context.watch<CapabilitiesCubit>().state.children;
|
||||||
|
final filter = children.any((child) => child.id == _childFilter)
|
||||||
|
? _childFilter
|
||||||
|
: null;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Elternbriefe')),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
if (children.length > 1)
|
||||||
|
_ChildFilterBar(
|
||||||
|
children: children,
|
||||||
|
selected: filter,
|
||||||
|
onSelected: (id) => setState(() => _childFilter = id),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: LoadableStateConsumer<ParentLettersBloc, ParentLettersState>(
|
||||||
|
child: (state, loading) {
|
||||||
|
if (children.isEmpty) return const NoChildrenPlaceholder();
|
||||||
|
final letters = filterLettersByChild(state.letters, filter);
|
||||||
|
if (letters.isEmpty && !state.hasMore) {
|
||||||
|
return const PlaceholderView(
|
||||||
|
icon: Icons.mark_email_read_outlined,
|
||||||
|
text: 'Keine Elternbriefe vorhanden.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.separated(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
itemCount: letters.length + (state.hasMore ? 1 : 0),
|
||||||
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index == letters.length) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: AsyncTextButton(
|
||||||
|
onPressed: context
|
||||||
|
.read<ParentLettersBloc>()
|
||||||
|
.loadOlder,
|
||||||
|
child: const Text('Ältere Elternbriefe laden'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final letter = letters[index];
|
||||||
|
return ParentLetterTile(
|
||||||
|
letter: letter,
|
||||||
|
children: children,
|
||||||
|
onTap: () =>
|
||||||
|
AppRoutes.openParentLetter(context, id: letter.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChildFilterBar extends StatelessWidget {
|
||||||
|
final List<GuardianChild> children;
|
||||||
|
final String? selected;
|
||||||
|
final ValueChanged<String?> onSelected;
|
||||||
|
|
||||||
|
const _ChildFilterBar({
|
||||||
|
required this.children,
|
||||||
|
required this.selected,
|
||||||
|
required this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
ChoiceChip(
|
||||||
|
label: const Text('Alle'),
|
||||||
|
selected: selected == null,
|
||||||
|
onSelected: (_) => onSelected(null),
|
||||||
|
),
|
||||||
|
for (final child in children)
|
||||||
|
ChoiceChip(
|
||||||
|
label: Text(child.firstName),
|
||||||
|
selected: selected == child.id,
|
||||||
|
onSelected: (_) => onSelected(child.id),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:filesize/filesize.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
|
||||||
|
import '../../../../routing/app_routes.dart';
|
||||||
|
import '../../../../widget/async_action_button.dart';
|
||||||
|
import '../../../../widget/centered_leading.dart';
|
||||||
|
import '../../../../widget/demo_restricted.dart';
|
||||||
|
|
||||||
|
/// File name safe to use inside the cache directory: no path parts, never
|
||||||
|
/// empty.
|
||||||
|
String safeAttachmentFileName(ParentLetterAttachment attachment) {
|
||||||
|
final name = attachment.fileName
|
||||||
|
.split(RegExp(r'[/\\]'))
|
||||||
|
.last
|
||||||
|
.replaceAll(RegExp(r'[\x00-\x1f:*?"<>|]'), '_')
|
||||||
|
.trim();
|
||||||
|
return name.isEmpty || name == '.' || name == '..' ? 'Anhang' : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ParentLetterAttachments extends StatelessWidget {
|
||||||
|
final String letterId;
|
||||||
|
final List<ParentLetterAttachment> attachments;
|
||||||
|
final Future<Uint8List> Function(String attachmentId) load;
|
||||||
|
|
||||||
|
const ParentLetterAttachments({
|
||||||
|
required this.letterId,
|
||||||
|
required this.attachments,
|
||||||
|
required this.load,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> _open(
|
||||||
|
BuildContext context,
|
||||||
|
ParentLetterAttachment attachment,
|
||||||
|
) async {
|
||||||
|
if (guardDemoAction(context)) return;
|
||||||
|
final cache = await getTemporaryDirectory();
|
||||||
|
final directory = Directory(
|
||||||
|
[
|
||||||
|
cache.path,
|
||||||
|
'parent_letters',
|
||||||
|
Uri.encodeComponent(letterId),
|
||||||
|
Uri.encodeComponent(attachment.id),
|
||||||
|
].join(Platform.pathSeparator),
|
||||||
|
);
|
||||||
|
await directory.create(recursive: true);
|
||||||
|
final file = File(
|
||||||
|
'${directory.path}${Platform.pathSeparator}'
|
||||||
|
'${safeAttachmentFileName(attachment)}',
|
||||||
|
);
|
||||||
|
// An attachment never changes under its id, so a complete earlier download
|
||||||
|
// is reused.
|
||||||
|
final cached =
|
||||||
|
file.existsSync() &&
|
||||||
|
(attachment.size <= 0 || file.lengthSync() == attachment.size);
|
||||||
|
if (!cached) {
|
||||||
|
await file.writeAsBytes(await load(attachment.id), flush: true);
|
||||||
|
}
|
||||||
|
if (!context.mounted) return;
|
||||||
|
AppRoutes.openFileViewer(context, file.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Column(
|
||||||
|
children: [
|
||||||
|
for (final attachment in attachments)
|
||||||
|
AsyncListTile(
|
||||||
|
closeOnSuccess: false,
|
||||||
|
leading: const CenteredLeading(Icon(Icons.attach_file)),
|
||||||
|
title: Text(
|
||||||
|
safeAttachmentFileName(attachment),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: attachment.size > 0
|
||||||
|
? Text(filesize(attachment.size))
|
||||||
|
: null,
|
||||||
|
onPressed: () => _open(context, attachment),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
|
||||||
|
import '../../../../extensions/date_time.dart';
|
||||||
|
import '../../../../widget/async_action_button.dart';
|
||||||
|
import '../../../../widget/confirm_dialog.dart';
|
||||||
|
import '../../../../widget/demo_restricted.dart';
|
||||||
|
import '../parent_letter_form_policy.dart';
|
||||||
|
import 'signature_sheet.dart';
|
||||||
|
|
||||||
|
typedef ParentLetterSubmit =
|
||||||
|
Future<void> Function(
|
||||||
|
List<ParentLetterAnswer> answers,
|
||||||
|
Uint8List? signaturePng,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Response form (or its result) of one child. The form state starts from
|
||||||
|
/// the child's response; key the card by it to restart on a new one.
|
||||||
|
class ParentLetterResponseCard extends StatefulWidget {
|
||||||
|
final String childName;
|
||||||
|
final ParentLetterChildState child;
|
||||||
|
final ParentLetterFormPolicy policy;
|
||||||
|
final DateTime? deadline;
|
||||||
|
final ParentLetterSubmit onSubmit;
|
||||||
|
|
||||||
|
const ParentLetterResponseCard({
|
||||||
|
required this.childName,
|
||||||
|
required this.child,
|
||||||
|
required this.policy,
|
||||||
|
required this.deadline,
|
||||||
|
required this.onSubmit,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ParentLetterResponseCard> createState() =>
|
||||||
|
_ParentLetterResponseCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ParentLetterResponseCardState extends State<ParentLetterResponseCard> {
|
||||||
|
late Map<String, String> _selection = ParentLetterFormPolicy.selectionOf(
|
||||||
|
widget.child.response,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (guardDemoAction(context)) return;
|
||||||
|
final policy = widget.policy;
|
||||||
|
Uint8List? signature;
|
||||||
|
if (policy.signatureRequired) {
|
||||||
|
signature = await showSignatureSheet(
|
||||||
|
context,
|
||||||
|
signerHint: 'Rückmeldung für ${widget.childName}',
|
||||||
|
);
|
||||||
|
if (signature == null || !mounted) return;
|
||||||
|
}
|
||||||
|
final answers = policy.answersFor(_selection);
|
||||||
|
if (!policy.submitIsFinal) return widget.onSubmit(answers, signature);
|
||||||
|
ConfirmDialog(
|
||||||
|
icon: Icons.task_alt,
|
||||||
|
title: 'Verbindlich absenden?',
|
||||||
|
content:
|
||||||
|
'Die Rückmeldung für ${widget.childName} kann danach nicht mehr '
|
||||||
|
'geändert werden.',
|
||||||
|
confirmButton: 'Absenden',
|
||||||
|
onConfirmAsync: () => widget.onSubmit(answers, signature),
|
||||||
|
).asDialog(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final policy = widget.policy;
|
||||||
|
final deadline = widget.deadline;
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(
|
||||||
|
'Rückmeldung für ${widget.childName}',
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (deadline != null && policy.canSubmit)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 2, 16, 0),
|
||||||
|
child: Text(
|
||||||
|
'Frist: ${deadline.formatDateTime()}',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
...switch (policy.mode) {
|
||||||
|
ParentLetterFormMode.open ||
|
||||||
|
ParentLetterFormMode.change => _form(policy),
|
||||||
|
ParentLetterFormMode.done => _result(theme, policy),
|
||||||
|
ParentLetterFormMode.closed => [
|
||||||
|
_note(
|
||||||
|
'Die Frist ist abgelaufen. Eine Rückmeldung ist nicht mehr '
|
||||||
|
'möglich.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ParentLetterFormMode.unsupported => [
|
||||||
|
_note(
|
||||||
|
'Für diese Rückmeldung wird eine neuere Version der App '
|
||||||
|
'benötigt. Bitte aktualisiere die App.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _note(String text) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(text),
|
||||||
|
);
|
||||||
|
|
||||||
|
List<Widget> _form(ParentLetterFormPolicy policy) => [
|
||||||
|
if (policy.mode == ParentLetterFormMode.change)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: _note(_respondedLine(widget.child.response!)),
|
||||||
|
),
|
||||||
|
for (final field in policy.fields) ...[
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(
|
||||||
|
field.isRequired ? '${field.label} *' : field.label,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
RadioGroup<String>(
|
||||||
|
groupValue: _selection[field.id],
|
||||||
|
onChanged: (optionId) {
|
||||||
|
if (optionId == null) return;
|
||||||
|
setState(() => _selection = {..._selection, field.id: optionId});
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (final option in field.options)
|
||||||
|
RadioListTile<String>(
|
||||||
|
dense: true,
|
||||||
|
title: Text(option.label),
|
||||||
|
value: option.id,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||||
|
child: AsyncActionButton(
|
||||||
|
icon: policy.signatureRequired ? Icons.draw_outlined : Icons.check,
|
||||||
|
onPressed: policy.isComplete(_selection) ? _submit : null,
|
||||||
|
child: Text(policy.submitLabel),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
List<Widget> _result(ThemeData theme, ParentLetterFormPolicy policy) {
|
||||||
|
final response = widget.child.response!;
|
||||||
|
final selection = ParentLetterFormPolicy.selectionOf(response);
|
||||||
|
return [
|
||||||
|
for (final field in policy.fields)
|
||||||
|
if (selection[field.id] case final optionId?)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 6),
|
||||||
|
child: Text(
|
||||||
|
'${field.label}\n${_optionLabel(field, optionId)}',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
response.signed ? Icons.draw_outlined : Icons.task_alt,
|
||||||
|
size: 18,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(_respondedLine(response))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
String _optionLabel(ParentLetterField field, String optionId) =>
|
||||||
|
field.options
|
||||||
|
.firstWhereOrNull((option) => option.id == optionId)
|
||||||
|
?.label ??
|
||||||
|
'–';
|
||||||
|
|
||||||
|
String _respondedLine(ParentLetterResponse response) {
|
||||||
|
final verb = response.signed
|
||||||
|
? 'Unterschrieben'
|
||||||
|
: widget.policy.isAcknowledgement
|
||||||
|
? 'Zur Kenntnis genommen'
|
||||||
|
: 'Beantwortet';
|
||||||
|
final by = response.respondedBy.self
|
||||||
|
? 'von dir'
|
||||||
|
: response.respondedBy.displayName.isEmpty
|
||||||
|
? ''
|
||||||
|
: 'von ${response.respondedBy.displayName}';
|
||||||
|
final at = response.respondedAt;
|
||||||
|
return [
|
||||||
|
verb,
|
||||||
|
if (by.isNotEmpty) by,
|
||||||
|
if (at != null) 'am ${at.formatDateTime()}',
|
||||||
|
].join(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
|
||||||
|
|
||||||
|
/// Small pill for the response state of a letter; nothing for plain
|
||||||
|
/// information letters.
|
||||||
|
class ParentLetterStatusChip extends StatelessWidget {
|
||||||
|
final ParentLetterStatus status;
|
||||||
|
|
||||||
|
const ParentLetterStatusChip(this.status, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final (label, background, foreground) = switch (status) {
|
||||||
|
ParentLetterStatus.info => (null, null, null),
|
||||||
|
ParentLetterStatus.open => (
|
||||||
|
'Rückmeldung offen',
|
||||||
|
scheme.primary,
|
||||||
|
scheme.onPrimary,
|
||||||
|
),
|
||||||
|
ParentLetterStatus.done => (
|
||||||
|
'Erledigt',
|
||||||
|
scheme.surfaceContainerHighest,
|
||||||
|
scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
ParentLetterStatus.expired => (
|
||||||
|
'Frist abgelaufen',
|
||||||
|
scheme.errorContainer,
|
||||||
|
scheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if (label == null) return const SizedBox.shrink();
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: background,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall?.copyWith(color: foreground),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||||
|
|
||||||
|
import '../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
|
||||||
|
import '../../../../extensions/date_time.dart';
|
||||||
|
import '../../../../utils/url_opener.dart';
|
||||||
|
import '../../../../widget/async_action_button.dart';
|
||||||
|
import '../../../../widget/demo_restricted.dart';
|
||||||
|
|
||||||
|
/// The private conversation between this guardian and the sender.
|
||||||
|
class ParentLetterThreadMessages extends StatelessWidget {
|
||||||
|
final List<ParentLetterThreadMessage> messages;
|
||||||
|
|
||||||
|
const ParentLetterThreadMessages(this.messages, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
for (final message in messages)
|
||||||
|
Align(
|
||||||
|
alignment: message.author.self
|
||||||
|
? Alignment.centerRight
|
||||||
|
: Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: MediaQuery.sizeOf(context).width * 0.8,
|
||||||
|
),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: message.author.self
|
||||||
|
? theme.colorScheme.primaryContainer
|
||||||
|
: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
[
|
||||||
|
if (message.author.self)
|
||||||
|
'Du'
|
||||||
|
else
|
||||||
|
message.author.displayName,
|
||||||
|
message.sentAt.formatDateShortHm(),
|
||||||
|
].join(' · '),
|
||||||
|
style: theme.textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
SelectableLinkify(
|
||||||
|
text: message.body,
|
||||||
|
onOpen: UrlOpener.onOpen,
|
||||||
|
options: const LinkifyOptions(humanize: false),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ParentLetterThreadInput extends StatefulWidget {
|
||||||
|
static const int maxLength = 4000;
|
||||||
|
|
||||||
|
final String recipientName;
|
||||||
|
final Future<void> Function(String body) onSend;
|
||||||
|
|
||||||
|
const ParentLetterThreadInput({
|
||||||
|
required this.recipientName,
|
||||||
|
required this.onSend,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ParentLetterThreadInput> createState() =>
|
||||||
|
_ParentLetterThreadInputState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ParentLetterThreadInputState extends State<ParentLetterThreadInput> {
|
||||||
|
final TextEditingController _text = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_text.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _send() async {
|
||||||
|
if (guardDemoAction(context)) return;
|
||||||
|
await widget.onSend(_text.text.trim());
|
||||||
|
_text.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Material(
|
||||||
|
elevation: 8,
|
||||||
|
child: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 6, 4, 6),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _text,
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: 5,
|
||||||
|
maxLength: ParentLetterThreadInput.maxLength,
|
||||||
|
textCapitalization: TextCapitalization.sentences,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: widget.recipientName.isEmpty
|
||||||
|
? 'Antworten'
|
||||||
|
: 'Antwort an ${widget.recipientName}',
|
||||||
|
border: InputBorder.none,
|
||||||
|
counterText: '',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ValueListenableBuilder(
|
||||||
|
valueListenable: _text,
|
||||||
|
builder: (context, value, _) => AsyncIconButton(
|
||||||
|
icon: Icons.send,
|
||||||
|
tooltip: 'Senden',
|
||||||
|
onPressed: value.text.trim().isEmpty ? null : _send,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart';
|
||||||
|
import '../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
|
||||||
|
import '../../../../extensions/date_time.dart';
|
||||||
|
import '../../../../widget/a11y/a11y_labels.dart';
|
||||||
|
import 'parent_letter_status_chip.dart';
|
||||||
|
|
||||||
|
class ParentLetterTile extends StatelessWidget {
|
||||||
|
final ParentLetterSummary letter;
|
||||||
|
|
||||||
|
/// The guardian's children; names are only shown when there is more than
|
||||||
|
/// one to tell apart.
|
||||||
|
final List<GuardianChild> children;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const ParentLetterTile({
|
||||||
|
required this.letter,
|
||||||
|
required this.children,
|
||||||
|
required this.onTap,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final unread = !letter.read;
|
||||||
|
final childNames = children.length < 2
|
||||||
|
? ''
|
||||||
|
: children
|
||||||
|
.where((child) => letter.childIds.contains(child.id))
|
||||||
|
.map((child) => child.firstName)
|
||||||
|
.join(', ');
|
||||||
|
final deadline = letter.deadline;
|
||||||
|
return ListTile(
|
||||||
|
leading: Semantics(
|
||||||
|
label: unread ? A11yLabels.unread : null,
|
||||||
|
child: Badge(
|
||||||
|
isLabelVisible: unread,
|
||||||
|
smallSize: 10,
|
||||||
|
backgroundColor: theme.primaryColor,
|
||||||
|
child: Icon(unread ? Icons.mail : Icons.drafts_outlined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
letter.subject,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: unread ? const TextStyle(fontWeight: FontWeight.bold) : null,
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
[
|
||||||
|
letter.sender.displayName,
|
||||||
|
letter.sentAt.formatDateRelativeShort(),
|
||||||
|
if (childNames.isNotEmpty) childNames,
|
||||||
|
].join(' · '),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (letter.preview.isNotEmpty)
|
||||||
|
Text(letter.preview, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
|
if (letter.status != ParentLetterStatus.info)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ParentLetterStatusChip(letter.status),
|
||||||
|
if (deadline != null &&
|
||||||
|
letter.status == ParentLetterStatus.open)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8),
|
||||||
|
child: Text(
|
||||||
|
'bis ${deadline.formatDate()}',
|
||||||
|
style: theme.textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: letter.attachmentCount > 0
|
||||||
|
? const Icon(Icons.attach_file, size: 18)
|
||||||
|
: null,
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signature/signature.dart';
|
||||||
|
|
||||||
|
import '../../../../widget/info_dialog.dart';
|
||||||
|
|
||||||
|
/// Upper bound the server accepts for a signature image.
|
||||||
|
const int maxSignatureBytes = 256 * 1024;
|
||||||
|
|
||||||
|
/// Lets the guardian draw a signature. Resolves to the PNG (transparent
|
||||||
|
/// background, cropped to the strokes) or null when dismissed.
|
||||||
|
Future<Uint8List?> showSignatureSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
required String signerHint,
|
||||||
|
}) => showModalBottomSheet<Uint8List>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
enableDrag: false,
|
||||||
|
showDragHandle: false,
|
||||||
|
builder: (_) => _SignatureSheet(signerHint: signerHint),
|
||||||
|
);
|
||||||
|
|
||||||
|
class _SignatureSheet extends StatefulWidget {
|
||||||
|
final String signerHint;
|
||||||
|
|
||||||
|
const _SignatureSheet({required this.signerHint});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SignatureSheet> createState() => _SignatureSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SignatureSheetState extends State<_SignatureSheet> {
|
||||||
|
// Paper-like pad in both themes: what is drawn is what gets exported.
|
||||||
|
final SignatureController _controller = SignatureController(
|
||||||
|
penStrokeWidth: 2.5,
|
||||||
|
penColor: Colors.black,
|
||||||
|
strokeCap: StrokeCap.round,
|
||||||
|
strokeJoin: StrokeJoin.round,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller.addListener(_onChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isEmpty = true;
|
||||||
|
|
||||||
|
// The controller notifies per drawn point; only the buttons depend on it.
|
||||||
|
void _onChanged() {
|
||||||
|
if (_controller.isEmpty == _isEmpty) return;
|
||||||
|
setState(() => _isEmpty = _controller.isEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.removeListener(_onChanged);
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _accept() async {
|
||||||
|
var png = await _controller.toPngBytes();
|
||||||
|
if (png != null && png.lengthInBytes > maxSignatureBytes) {
|
||||||
|
png = await _controller.toPngBytes(width: 600);
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
if (png == null || png.lengthInBytes > maxSignatureBytes) {
|
||||||
|
InfoDialog.show(
|
||||||
|
context,
|
||||||
|
'Die Unterschrift konnte nicht übernommen werden. Bitte versuche es '
|
||||||
|
'erneut.',
|
||||||
|
title: 'Unterschrift',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.pop(context, png);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text('Unterschrift', style: Theme.of(context).textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(widget.signerHint),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Signature(
|
||||||
|
controller: _controller,
|
||||||
|
height: 220,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _isEmpty ? null : _controller.clear,
|
||||||
|
icon: const Icon(Icons.undo),
|
||||||
|
label: const Text('Löschen'),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Abbrechen'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _isEmpty ? null : _accept,
|
||||||
|
child: const Text('Übernehmen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ class DefaultSettings {
|
|||||||
modulesSettings: ModulesSettings(
|
modulesSettings: ModulesSettings(
|
||||||
moduleOrder: [
|
moduleOrder: [
|
||||||
Modules.timetable,
|
Modules.timetable,
|
||||||
|
Modules.parentLetters,
|
||||||
Modules.ticker,
|
Modules.ticker,
|
||||||
Modules.talk,
|
Modules.talk,
|
||||||
Modules.files,
|
Modules.files,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_split_view/flutter_split_view.dart';
|
import 'package:flutter_split_view/flutter_split_view.dart';
|
||||||
|
|
||||||
|
import '../../../push/notification_permission_prompt.dart';
|
||||||
import '../../../routing/app_routes.dart';
|
import '../../../routing/app_routes.dart';
|
||||||
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
|
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
|
||||||
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
||||||
@@ -14,7 +15,6 @@ import '../../../widget/demo_restricted.dart';
|
|||||||
import '../../../widget/placeholder_view.dart';
|
import '../../../widget/placeholder_view.dart';
|
||||||
import 'data/open_direct_chat.dart';
|
import 'data/open_direct_chat.dart';
|
||||||
import 'join_chat.dart';
|
import 'join_chat.dart';
|
||||||
import 'notification_permission_prompt.dart';
|
|
||||||
import 'search_chat.dart';
|
import 'search_chat.dart';
|
||||||
import 'widgets/chat_tile.dart';
|
import 'widgets/chat_tile.dart';
|
||||||
import 'widgets/split_view_placeholder.dart';
|
import 'widgets/split_view_placeholder.dart';
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:developer';
|
|
||||||
|
|
||||||
import 'package:app_settings/app_settings.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
|
|
||||||
import '../../../push/push_registration.dart';
|
|
||||||
import '../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
|
||||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
|
||||||
import '../../../widget/confirm_dialog.dart';
|
|
||||||
|
|
||||||
/// Shows the one-time notification-permission flow on the first Talk visit.
|
|
||||||
///
|
|
||||||
/// The OS prompt is deliberately kept out of the cold-start path (younger users
|
|
||||||
/// decline it reflexively before ever seeing why they'd want it). Instead, the
|
|
||||||
/// first time Talk is opened we explain the request, then trigger the OS prompt,
|
|
||||||
/// and — if declined — offer a shortcut to the system settings.
|
|
||||||
///
|
|
||||||
/// Runs at most once per install (guarded by `talkPermissionPromptShown`).
|
|
||||||
Future<void> maybePromptTalkNotifications(BuildContext context) async {
|
|
||||||
final settings = context.read<SettingsCubit>();
|
|
||||||
final notificationSettings = settings.val().notificationSettings;
|
|
||||||
|
|
||||||
// Already handled once, or the user opted out of push entirely.
|
|
||||||
if (notificationSettings.talkPermissionPromptShown) return;
|
|
||||||
if (!notificationSettings.enabled) return;
|
|
||||||
|
|
||||||
// Capabilities may still be loading on a fresh cold start; retry on the next
|
|
||||||
// Talk visit instead of burning the one-shot flag.
|
|
||||||
if (!context.read<CapabilitiesCubit>().canReceivePushNotifications) return;
|
|
||||||
|
|
||||||
// Existing users who already granted the permission: register silently and
|
|
||||||
// mark the prompt as handled without showing any dialog.
|
|
||||||
if (await PushRegistration.isOsPermissionGranted()) {
|
|
||||||
settings.val(write: true).notificationSettings.talkPermissionPromptShown =
|
|
||||||
true;
|
|
||||||
unawaited(PushRegistration().register());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
ConfirmDialog(
|
|
||||||
icon: Icons.notifications_active_outlined,
|
|
||||||
title: 'Benachrichtigungen aktivieren',
|
|
||||||
content:
|
|
||||||
'Damit du keine neuen Nachrichten im Talk verpasst, fragen wir dich '
|
|
||||||
'gleich nach der Erlaubnis für Benachrichtigungen. Bitte akzeptiere '
|
|
||||||
'sie, um Push-Nachrichten zu erhalten.',
|
|
||||||
confirmButton: 'Weiter',
|
|
||||||
cancelButton: null,
|
|
||||||
onConfirm: () => unawaited(_requestPermission(context, settings)),
|
|
||||||
).asDialog(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _requestPermission(
|
|
||||||
BuildContext context,
|
|
||||||
SettingsCubit settings,
|
|
||||||
) async {
|
|
||||||
final granted = await PushRegistration.requestOsPermission();
|
|
||||||
|
|
||||||
// Mark handled regardless of the outcome — the user can re-enable later via
|
|
||||||
// the system settings; we don't want to prompt again on the next Talk visit.
|
|
||||||
settings.val(write: true).notificationSettings.talkPermissionPromptShown =
|
|
||||||
true;
|
|
||||||
|
|
||||||
if (granted) {
|
|
||||||
unawaited(PushRegistration().register());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log('Push: notification permission declined on first Talk visit');
|
|
||||||
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
ConfirmDialog(
|
|
||||||
icon: Icons.notifications_off_outlined,
|
|
||||||
title: 'Benachrichtigungen deaktiviert',
|
|
||||||
content:
|
|
||||||
'Ohne die Berechtigung erhältst du keine Benachrichtigungen bei neuen '
|
|
||||||
'Talk-Nachrichten. Du kannst sie jederzeit in den Systemeinstellungen '
|
|
||||||
'deines Geräts nachträglich aktivieren.',
|
|
||||||
confirmButton: 'Einstellungen öffnen',
|
|
||||||
cancelButton: 'Später',
|
|
||||||
onConfirm: () =>
|
|
||||||
AppSettings.openAppSettings(type: AppSettingsType.notification),
|
|
||||||
).asDialog(context);
|
|
||||||
}
|
|
||||||
@@ -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
|
app_links: ^7.2.1
|
||||||
integration_test:
|
integration_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
signature: ^6.4.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -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));
|
final effective = AppModule.effectiveModuleOrder(settingsWith(custom));
|
||||||
|
|
||||||
|
// parentLetters and ticker are both missing and follow timetable in the
|
||||||
|
// declared order, so they line up behind it.
|
||||||
|
expect(
|
||||||
|
effective.indexOf(Modules.parentLetters),
|
||||||
|
effective.indexOf(Modules.timetable) + 1,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
effective.indexOf(Modules.ticker),
|
effective.indexOf(Modules.ticker),
|
||||||
effective.indexOf(Modules.timetable) + 1,
|
effective.indexOf(Modules.parentLetters) + 1,
|
||||||
);
|
);
|
||||||
expect(effective.toSet(), Modules.values.toSet());
|
expect(effective.toSet(), Modules.values.toSet());
|
||||||
});
|
});
|
||||||
@@ -94,10 +100,11 @@ void main() {
|
|||||||
final student = CredentialSession(username: 'max', password: 'pw');
|
final student = CredentialSession(username: 'max', password: 'pw');
|
||||||
const guardian = GuardianSession(email: 'e@x.de');
|
const guardian = GuardianSession(email: 'e@x.de');
|
||||||
|
|
||||||
test('password accounts see every module', () {
|
test('password accounts see every module but the guardian ones', () {
|
||||||
for (final m in Modules.values) {
|
final hidden = Modules.values
|
||||||
expect(AppModule.isAvailableFor(m, student), isTrue, reason: m.name);
|
.where((m) => !AppModule.isAvailableFor(m, student))
|
||||||
}
|
.toSet();
|
||||||
|
expect(hidden, {Modules.parentLetters});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('guardians lose exactly the Nextcloud modules', () {
|
test('guardians lose exactly the Nextcloud modules', () {
|
||||||
|
|||||||
@@ -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