added guardian letters with chat and multiple answer functionalities

This commit is contained in:
2026-09-20 16:12:45 +02:00
parent 67c935c05b
commit 2423c1a75e
68 changed files with 8348 additions and 226 deletions
+273
View File
@@ -0,0 +1,273 @@
import '../../marianumconnect/queries/parent_letters/parent_letter_models.dart';
import '../demo_persona.dart';
/// Demo inbox of the guardian persona: one letter per variant (information,
/// acknowledgement, choice, signature, answered, expired, thread, attachment).
/// Child ids match [DemoCapabilities.guardianState].
class DemoParentLetters {
const DemoParentLetters._();
static const _lena = 'demo-child-1';
static const _jonas = 'demo-child-2';
static final Set<String> _readInSession = {};
static void markRead(String letterId) => _readInSession.add(letterId);
static ParentLetterListResponse list() {
final items = [for (final letter in _letters()) letter.summary];
return ParentLetterListResponse(
items: items,
unreadCount: items.where((letter) => !letter.read).length,
openCount: items
.where((letter) => letter.status == ParentLetterStatus.open)
.length,
);
}
static ParentLetterDetail detail(String letterId) {
final letters = _letters();
return letters.firstWhere(
(letter) => letter.summary.id == letterId,
orElse: () => letters.first,
);
}
static ParentLetterPerson _teacher(int index) =>
ParentLetterPerson(displayName: DemoPersona.teachers[index].name);
static const _participation = ParentLetterField(
id: 'participation',
type: ParentLetterFieldType.singleChoice,
label: 'Nimmt Ihr Kind teil?',
isRequired: true,
options: [
ParentLetterOption(id: 'yes', label: 'Ja'),
ParentLetterOption(id: 'no', label: 'Nein'),
],
);
static List<ParentLetterDetail> _letters() {
final now = DateTime.now();
ParentLetterDetail letter({
required String id,
required String subject,
required ParentLetterPerson sender,
required Duration age,
required String body,
required List<String> childIds,
bool read = true,
ParentLetterStatus status = ParentLetterStatus.info,
ParentLetterRequest? request,
List<ParentLetterChildState> children = const [],
List<ParentLetterAttachment> attachments = const [],
ParentLetterThread thread = const ParentLetterThread(enabled: true),
}) => ParentLetterDetail(
summary: ParentLetterSummary(
id: id,
subject: subject,
preview: body.replaceAll('\n', ' ').trim(),
sender: sender,
sentAt: now.subtract(age),
read: read || _readInSession.contains(id),
childIds: childIds,
attachmentCount: attachments.length,
status: status,
deadline: request?.deadline,
),
content: ParentLetterContent(
body: body,
attachments: attachments,
request: request,
children: children,
thread: thread,
),
);
return [
letter(
id: 'demo-letter-hike',
subject: 'Wandertag der ${DemoPersona.className}',
sender: _teacher(0),
age: const Duration(hours: 2),
read: false,
childIds: const [_lena],
status: ParentLetterStatus.open,
body:
'Liebe Eltern,\n\nam Freitag in zwei Wochen findet unser Wandertag '
'statt. Wir fahren mit dem Bus in die Rhön und sind gegen 16 Uhr '
'zurück. Alle Einzelheiten finden Sie im angehängten Schreiben.\n\n'
'Bitte geben Sie uns eine verbindliche Rückmeldung.\n\n'
'Viele Grüße\n${DemoPersona.teachers[0].name}',
attachments: const [
ParentLetterAttachment(
id: 'demo-attachment-hike',
fileName: 'Wandertag.pdf',
mimeType: 'application/pdf',
size: 48211,
),
],
request: ParentLetterRequest(
fields: const [_participation],
signatureRequired: true,
deadline: now.add(const Duration(days: 10)),
),
children: const [
ParentLetterChildState(
childId: _lena,
status: ParentLetterStatus.open,
editable: true,
),
],
),
letter(
id: 'demo-letter-parents-evening',
subject: 'Einladung zum Elternabend der 6a',
sender: _teacher(1),
age: const Duration(days: 1),
read: false,
childIds: const [_jonas],
status: ParentLetterStatus.open,
body:
'Liebe Eltern,\n\nhiermit lade ich Sie herzlich zum ersten '
'Elternabend des Schuljahres ein. Wir treffen uns am kommenden '
'Dienstag um 19 Uhr in Raum ${DemoPersona.rooms[0]}.\n\n'
'Bitte bestätigen Sie kurz den Erhalt dieser Einladung.',
request: const ParentLetterRequest(),
children: const [
ParentLetterChildState(
childId: _jonas,
status: ParentLetterStatus.open,
editable: true,
),
],
),
letter(
id: 'demo-letter-school-festival',
subject: 'Schulfest: Wer kommt mit?',
sender: _teacher(5),
age: const Duration(days: 3),
childIds: const [_lena, _jonas],
status: ParentLetterStatus.open,
body:
'Liebe Eltern,\n\nfür die Planung unseres Schulfestes möchten wir '
'wissen, welche Kinder teilnehmen. Die Angabe können Sie bis zum '
'Ablauf der Frist noch ändern.\n\nWeitere Informationen: '
'https://www.marianum-fulda.de',
request: ParentLetterRequest(
fields: const [_participation],
deadline: now.add(const Duration(days: 5)),
),
children: [
ParentLetterChildState(
childId: _lena,
status: ParentLetterStatus.done,
editable: true,
response: ParentLetterResponse(
respondedAt: now.subtract(const Duration(days: 2)),
respondedBy: const ParentLetterPerson(self: true),
answers: const [
ParentLetterAnswer(
fieldId: 'participation',
optionIds: ['yes'],
),
],
),
),
const ParentLetterChildState(
childId: _jonas,
status: ParentLetterStatus.open,
editable: true,
),
],
),
letter(
id: 'demo-letter-reports',
subject: 'Zeugnisausgabe und Unterrichtsschluss',
sender: _teacher(3),
age: const Duration(days: 7),
childIds: const [_lena, _jonas],
body:
'Liebe Eltern,\n\nam letzten Schultag vor den Ferien endet der '
'Unterricht nach der dritten Stunde mit der Zeugnisausgabe. Die '
'Busse fahren entsprechend früher.',
thread: ParentLetterThread(
enabled: true,
messages: [
ParentLetterThreadMessage(
id: 'demo-thread-1',
author: const ParentLetterPerson(self: true),
body: 'Gilt das auch für die Nachmittagsbetreuung?',
sentAt: now.subtract(const Duration(days: 6, hours: 20)),
),
ParentLetterThreadMessage(
id: 'demo-thread-2',
author: _teacher(3),
body:
'Ja, die Betreuung entfällt an diesem Tag ebenfalls. Bei '
'Bedarf melden Sie sich bitte im Sekretariat.',
sentAt: now.subtract(const Duration(days: 6, hours: 2)),
),
],
),
),
letter(
id: 'demo-letter-swimming',
subject: 'Einverständnis Schwimmunterricht',
sender: _teacher(6),
age: const Duration(days: 21),
childIds: const [_jonas],
status: ParentLetterStatus.done,
body:
'Liebe Eltern,\n\nim zweiten Halbjahr findet der Sportunterricht '
'der 6a im Hallenbad statt. Dafür benötigen wir Ihr '
'Einverständnis.',
request: const ParentLetterRequest(
fields: [_participation],
signatureRequired: true,
),
children: [
ParentLetterChildState(
childId: _jonas,
status: ParentLetterStatus.done,
response: ParentLetterResponse(
respondedAt: now.subtract(const Duration(days: 20)),
respondedBy: const ParentLetterPerson(displayName: 'M. Hoffmann'),
answers: const [
ParentLetterAnswer(
fieldId: 'participation',
optionIds: ['yes'],
),
],
signed: true,
),
),
],
thread: const ParentLetterThread(),
),
letter(
id: 'demo-letter-ski-trip',
subject: 'Anmeldung zur Skifreizeit',
sender: _teacher(6),
age: const Duration(days: 35),
childIds: const [_lena],
status: ParentLetterStatus.expired,
body:
'Liebe Eltern,\n\ndie Anmeldung zur Skifreizeit der Jahrgangsstufe '
'10 ist ab sofort möglich. Die Plätze sind begrenzt.',
request: ParentLetterRequest(
fields: const [_participation],
signatureRequired: true,
deadline: now.subtract(const Duration(days: 14)),
),
children: const [
ParentLetterChildState(
childId: _lena,
status: ParentLetterStatus.expired,
),
],
thread: const ParentLetterThread(),
),
];
}
}
@@ -6,6 +6,22 @@ import '../../errors/network_exception.dart';
import '../../errors/parse_exception.dart';
import '../../errors/server_exception.dart';
/// The error code of a rejected call: the server answers either with JSON
/// `{"error": "<code>"}` or with the plain text `Fehler: <code>` of its
/// generic error handler.
String? marianumConnectErrorCode(Object? body) {
if (body is Map) {
final code = body['error'];
return code is String ? code : null;
}
if (body is String) {
const prefix = 'Fehler: ';
return (body.startsWith(prefix) ? body.substring(prefix.length) : body)
.trim();
}
return null;
}
/// Converts a DioException raised against the Marianum-Connect API into one of
/// the app's typed AppExceptions. Keeps the dio dependency out of call sites
/// that just want to render an error message.
@@ -1,5 +1,8 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../errors/app_exception.dart';
import 'errors/marianumconnect_error.dart';
import 'marianumconnect_api.dart';
import 'marianumconnect_endpoint.dart';
@@ -23,10 +26,14 @@ abstract class MarianumConnectQuery {
try {
return await body();
} on DioException catch (e) {
throw mapMarianumConnectError(e);
throw mapError(e);
}
}
/// The AppException a failed call surfaces as. Query families with domain
/// error codes override this instead of re-implementing [guard].
AppException mapError(DioException error) => mapMarianumConnectError(error);
/// GETs [path] and parses the JSON object body with [fromJson].
Future<T> getObject<T>(
String path,
@@ -55,6 +62,16 @@ abstract class MarianumConnectQuery {
.toList();
});
/// GETs the raw bytes of [path] (files that need the bearer token).
Future<Uint8List> getBytes(String path) => guard(() async {
final response = await dio.get<List<int>>(
endpoint(path),
options: Options(responseType: ResponseType.bytes),
);
final bytes = response.data!;
return bytes is Uint8List ? bytes : Uint8List.fromList(bytes);
});
/// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params.
String isoDate(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
@@ -84,21 +84,9 @@ class GuardianLoginException extends AppException {
'Bitte versuche es später erneut.',
};
/// Accepts the documented JSON body (`{"error": …, "attemptsLeft": …}`) as
/// well as the plain-text `Fehler: <code>` the server's generic error
/// handler produces.
static (String?, int?) _parseBody(Object? data) {
if (data is Map) {
final attempts = data['attemptsLeft'];
return (data['error'] as String?, attempts is int ? attempts : null);
}
if (data is String) {
final code = data.startsWith('Fehler: ')
? data.substring('Fehler: '.length)
: data;
return (code.trim(), null);
}
return (null, null);
final attempts = data is Map ? data['attemptsLeft'] : null;
return (marianumConnectErrorCode(data), attempts is int ? attempts : null);
}
static GuardianLoginError? _errorFor(String? code, int status) =>
@@ -1,7 +1,5 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../marianumconnect_query.dart';
/// Downloads the raw PDF bytes of a Marianum Message from
@@ -15,11 +13,6 @@ class GetNewsletterFile extends MarianumConnectQuery {
GetNewsletterFile(this.id, {super.dio});
Future<Uint8List> run() => guard(() async {
final response = await dio.get<List<int>>(
endpoint('newsletter/${Uri.encodeComponent(id)}/file'),
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
});
Future<Uint8List> run() =>
getBytes('newsletter/${Uri.encodeComponent(id)}/file');
}
@@ -1,7 +1,5 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../marianumconnect_query.dart';
/// Downloads the raw bytes of a PROXIED_FILE ticker page from
@@ -15,11 +13,6 @@ class GetTickerPageFile extends MarianumConnectQuery {
GetTickerPageFile(this.slug, {super.dio});
Future<Uint8List> run() => guard(() async {
final response = await dio.get<List<int>>(
endpoint('ticker/pages/${Uri.encodeComponent(slug)}/file'),
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
});
Future<Uint8List> run() =>
getBytes('ticker/pages/${Uri.encodeComponent(slug)}/file');
}
@@ -0,0 +1,9 @@
import 'parent_letter_models.dart';
import 'parent_letter_query.dart';
class GetParentLetter extends ParentLetterQuery {
GetParentLetter({super.dio});
Future<ParentLetterDetail> run(String letterId) =>
getObject(letterPath(letterId), ParentLetterDetail.fromJson);
}
@@ -0,0 +1,14 @@
import 'dart:typed_data';
import 'parent_letter_query.dart';
class GetParentLetterAttachment extends ParentLetterQuery {
GetParentLetterAttachment({super.dio});
Future<Uint8List> run({
required String letterId,
required String attachmentId,
}) => getBytes(
letterPath(letterId, '/attachments/${Uri.encodeComponent(attachmentId)}'),
);
}
@@ -0,0 +1,16 @@
import 'parent_letter_models.dart';
import 'parent_letter_query.dart';
/// `GET parent-letters`: the guardian's inbox across all children, newest
/// first. [before] continues after the given letter (keyset paging).
class GetParentLetters extends ParentLetterQuery {
static const int pageSize = 50;
GetParentLetters({super.dio});
Future<ParentLetterListResponse> run({String? before}) => getObject(
'parent-letters',
ParentLetterListResponse.fromJson,
queryParameters: {'limit': pageSize, 'before': ?before},
);
}
@@ -0,0 +1,11 @@
import 'parent_letter_query.dart';
/// `POST parent-letters/{id}/read`: marks the letter and its thread as read
/// for this guardian. Idempotent.
class MarkParentLetterRead extends ParentLetterQuery {
MarkParentLetterRead({super.dio});
Future<void> run(String letterId) => guard(() async {
await dio.post<void>(endpoint(letterPath(letterId, '/read')));
});
}
@@ -0,0 +1,90 @@
import 'package:dio/dio.dart';
import '../../../errors/app_exception.dart';
import '../../errors/marianumconnect_error.dart';
enum ParentLetterError {
guardianRequired,
letterNotFound,
childNotFound,
attachmentNotFound,
invalidAnswers,
signatureRequired,
responseFinal,
deadlinePassed,
threadDisabled,
invalidRequest,
}
/// A parent-letter call the server rejected with a domain reason.
class ParentLetterException extends AppException {
final ParentLetterError error;
const ParentLetterException(
this.error, {
required super.userMessage,
super.technicalDetails,
}) : super(allowRetry: false);
/// Only 4xx answers with a parent-letter reason become a
/// [ParentLetterException]; everything else (network, 401, 5xx) keeps the
/// generic MarianumConnect mapping.
static AppException fromDio(DioException e) {
final response = e.response;
final status = response?.statusCode;
if (status == null || status < 400 || status >= 500 || status == 401) {
return mapMarianumConnectError(e);
}
final error = _errorFor(marianumConnectErrorCode(response!.data), status);
if (error == null) return mapMarianumConnectError(e);
return ParentLetterException(
error,
userMessage: messageFor(error),
technicalDetails: 'MC $status: ${response.data}',
);
}
static String messageFor(ParentLetterError error) => switch (error) {
ParentLetterError.guardianRequired =>
'Elternbriefe sind nur mit einem Eltern-Konto verfügbar.',
ParentLetterError.letterNotFound =>
'Dieser Elternbrief ist nicht mehr verfügbar.',
ParentLetterError.childNotFound =>
'Dieser Elternbrief betrifft das ausgewählte Kind nicht.',
ParentLetterError.attachmentNotFound =>
'Der Anhang ist nicht mehr verfügbar.',
ParentLetterError.invalidAnswers =>
'Die Rückmeldung ist unvollständig. Bitte prüfe deine Angaben.',
ParentLetterError.signatureRequired =>
'Für diese Rückmeldung wird eine Unterschrift benötigt.',
ParentLetterError.responseFinal =>
'Die Rückmeldung wurde bereits endgültig abgegeben und kann nicht mehr '
'geändert werden.',
ParentLetterError.deadlinePassed =>
'Die Frist für diese Rückmeldung ist abgelaufen.',
ParentLetterError.threadDisabled =>
'Auf diesen Elternbrief kann nicht geantwortet werden.',
ParentLetterError.invalidRequest =>
'Die Anfrage konnte nicht verarbeitet werden.',
};
static ParentLetterError? _errorFor(String? code, int status) =>
switch (code) {
'guardian_required' => ParentLetterError.guardianRequired,
'letter_not_found' => ParentLetterError.letterNotFound,
'child_not_found' => ParentLetterError.childNotFound,
'attachment_not_found' => ParentLetterError.attachmentNotFound,
'invalid_answers' => ParentLetterError.invalidAnswers,
'signature_required' => ParentLetterError.signatureRequired,
'response_final' => ParentLetterError.responseFinal,
'deadline_passed' => ParentLetterError.deadlinePassed,
'thread_disabled' => ParentLetterError.threadDisabled,
'invalid_request' => ParentLetterError.invalidRequest,
_ => switch (status) {
404 => ParentLetterError.letterNotFound,
409 => ParentLetterError.responseFinal,
410 => ParentLetterError.deadlinePassed,
_ => null,
},
};
}
@@ -0,0 +1,226 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'parent_letter_models.freezed.dart';
part 'parent_letter_models.g.dart';
enum ParentLetterStatus { info, open, done, expired }
enum ParentLetterFieldType {
@JsonValue('single_choice')
singleChoice,
unknown,
}
/// Sender, thread author or responding guardian. [self] marks the signed-in
/// guardian.
@freezed
abstract class ParentLetterPerson with _$ParentLetterPerson {
const factory ParentLetterPerson({
@Default('') String displayName,
@Default(false) bool self,
}) = _ParentLetterPerson;
factory ParentLetterPerson.fromJson(Map<String, dynamic> json) =>
_$ParentLetterPersonFromJson(json);
}
/// Inbox entry from `GET parent-letters`. [childIds] are the opaque guardian
/// child ids from `me/capabilities`.
@freezed
abstract class ParentLetterSummary with _$ParentLetterSummary {
const factory ParentLetterSummary({
required String id,
@Default('') String subject,
@Default('') String preview,
@Default(ParentLetterPerson()) ParentLetterPerson sender,
required DateTime sentAt,
DateTime? editedAt,
@Default(true) bool read,
@Default([]) List<String> childIds,
@Default(0) int attachmentCount,
@JsonKey(unknownEnumValue: ParentLetterStatus.info)
@Default(ParentLetterStatus.info)
ParentLetterStatus status,
DateTime? deadline,
}) = _ParentLetterSummary;
factory ParentLetterSummary.fromJson(Map<String, dynamic> json) =>
_$ParentLetterSummaryFromJson(json);
}
@freezed
abstract class ParentLetterListResponse with _$ParentLetterListResponse {
const factory ParentLetterListResponse({
@Default([]) List<ParentLetterSummary> items,
@Default(false) bool hasMore,
@Default(0) int unreadCount,
@Default(0) int openCount,
}) = _ParentLetterListResponse;
factory ParentLetterListResponse.fromJson(Map<String, dynamic> json) =>
_$ParentLetterListResponseFromJson(json);
}
@freezed
abstract class ParentLetterAttachment with _$ParentLetterAttachment {
const factory ParentLetterAttachment({
required String id,
@Default('') String fileName,
@Default('') String mimeType,
@Default(0) int size,
}) = _ParentLetterAttachment;
factory ParentLetterAttachment.fromJson(Map<String, dynamic> json) =>
_$ParentLetterAttachmentFromJson(json);
}
@freezed
abstract class ParentLetterOption with _$ParentLetterOption {
const factory ParentLetterOption({
required String id,
@Default('') String label,
}) = _ParentLetterOption;
factory ParentLetterOption.fromJson(Map<String, dynamic> json) =>
_$ParentLetterOptionFromJson(json);
}
@freezed
abstract class ParentLetterField with _$ParentLetterField {
const factory ParentLetterField({
required String id,
@JsonKey(unknownEnumValue: ParentLetterFieldType.unknown)
@Default(ParentLetterFieldType.unknown)
ParentLetterFieldType type,
@Default('') String label,
@JsonKey(name: 'required') @Default(false) bool isRequired,
@Default([]) List<ParentLetterOption> options,
}) = _ParentLetterField;
factory ParentLetterField.fromJson(Map<String, dynamic> json) =>
_$ParentLetterFieldFromJson(json);
}
/// What the sender asks for. No fields and no signature means a plain
/// acknowledgement.
@freezed
abstract class ParentLetterRequest with _$ParentLetterRequest {
const factory ParentLetterRequest({
@Default([]) List<ParentLetterField> fields,
@Default(false) bool signatureRequired,
DateTime? deadline,
}) = _ParentLetterRequest;
factory ParentLetterRequest.fromJson(Map<String, dynamic> json) =>
_$ParentLetterRequestFromJson(json);
}
@freezed
abstract class ParentLetterAnswer with _$ParentLetterAnswer {
const factory ParentLetterAnswer({
required String fieldId,
@Default([]) List<String> optionIds,
String? text,
}) = _ParentLetterAnswer;
factory ParentLetterAnswer.fromJson(Map<String, dynamic> json) =>
_$ParentLetterAnswerFromJson(json);
}
@freezed
abstract class ParentLetterResponse with _$ParentLetterResponse {
const factory ParentLetterResponse({
DateTime? respondedAt,
@Default(ParentLetterPerson()) ParentLetterPerson respondedBy,
@Default([]) List<ParentLetterAnswer> answers,
@Default(false) bool signed,
}) = _ParentLetterResponse;
factory ParentLetterResponse.fromJson(Map<String, dynamic> json) =>
_$ParentLetterResponseFromJson(json);
}
/// Per-child state of a letter. [editable] is the server's verdict on whether
/// this guardian may (re)submit right now.
@freezed
abstract class ParentLetterChildState with _$ParentLetterChildState {
const factory ParentLetterChildState({
required String childId,
@JsonKey(unknownEnumValue: ParentLetterStatus.info)
@Default(ParentLetterStatus.info)
ParentLetterStatus status,
@Default(false) bool editable,
ParentLetterResponse? response,
}) = _ParentLetterChildState;
factory ParentLetterChildState.fromJson(Map<String, dynamic> json) =>
_$ParentLetterChildStateFromJson(json);
}
@freezed
abstract class ParentLetterThreadMessage with _$ParentLetterThreadMessage {
const factory ParentLetterThreadMessage({
required String id,
@Default(ParentLetterPerson()) ParentLetterPerson author,
@Default('') String body,
required DateTime sentAt,
}) = _ParentLetterThreadMessage;
factory ParentLetterThreadMessage.fromJson(Map<String, dynamic> json) =>
_$ParentLetterThreadMessageFromJson(json);
}
@freezed
abstract class ParentLetterThread with _$ParentLetterThread {
const factory ParentLetterThread({
@Default(false) bool enabled,
@Default([]) List<ParentLetterThreadMessage> messages,
}) = _ParentLetterThread;
factory ParentLetterThread.fromJson(Map<String, dynamic> json) =>
_$ParentLetterThreadFromJson(json);
}
/// The detail-only part of `GET parent-letters/{id}`.
@freezed
abstract class ParentLetterContent with _$ParentLetterContent {
const factory ParentLetterContent({
@Default('') String body,
@Default([]) List<ParentLetterAttachment> attachments,
ParentLetterRequest? request,
@Default([]) List<ParentLetterChildState> children,
@Default(ParentLetterThread()) ParentLetterThread thread,
}) = _ParentLetterContent;
factory ParentLetterContent.fromJson(Map<String, dynamic> json) =>
_$ParentLetterContentFromJson(json);
}
/// A full letter. The server sends the inbox fields and the detail fields in
/// one flat object; they are parsed into [summary] and [content] so the inbox
/// entry can be replaced from a detail answer without copying fields.
@immutable
class ParentLetterDetail {
final ParentLetterSummary summary;
final ParentLetterContent content;
const ParentLetterDetail({required this.summary, required this.content});
factory ParentLetterDetail.fromJson(Map<String, dynamic> json) =>
ParentLetterDetail(
summary: ParentLetterSummary.fromJson(json),
content: ParentLetterContent.fromJson(json),
);
Map<String, dynamic> toJson() => {...summary.toJson(), ...content.toJson()};
@override
bool operator ==(Object other) =>
other is ParentLetterDetail &&
other.summary == summary &&
other.content == content;
@override
int get hashCode => Object.hash(summary, content);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,331 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'parent_letter_models.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_ParentLetterPerson _$ParentLetterPersonFromJson(Map<String, dynamic> json) =>
_ParentLetterPerson(
displayName: json['displayName'] as String? ?? '',
self: json['self'] as bool? ?? false,
);
Map<String, dynamic> _$ParentLetterPersonToJson(_ParentLetterPerson instance) =>
<String, dynamic>{
'displayName': instance.displayName,
'self': instance.self,
};
_ParentLetterSummary _$ParentLetterSummaryFromJson(Map<String, dynamic> json) =>
_ParentLetterSummary(
id: json['id'] as String,
subject: json['subject'] as String? ?? '',
preview: json['preview'] as String? ?? '',
sender: json['sender'] == null
? const ParentLetterPerson()
: ParentLetterPerson.fromJson(json['sender'] as Map<String, dynamic>),
sentAt: DateTime.parse(json['sentAt'] as String),
editedAt: json['editedAt'] == null
? null
: DateTime.parse(json['editedAt'] as String),
read: json['read'] as bool? ?? true,
childIds:
(json['childIds'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
attachmentCount: (json['attachmentCount'] as num?)?.toInt() ?? 0,
status:
$enumDecodeNullable(
_$ParentLetterStatusEnumMap,
json['status'],
unknownValue: ParentLetterStatus.info,
) ??
ParentLetterStatus.info,
deadline: json['deadline'] == null
? null
: DateTime.parse(json['deadline'] as String),
);
Map<String, dynamic> _$ParentLetterSummaryToJson(
_ParentLetterSummary instance,
) => <String, dynamic>{
'id': instance.id,
'subject': instance.subject,
'preview': instance.preview,
'sender': instance.sender,
'sentAt': instance.sentAt.toIso8601String(),
'editedAt': instance.editedAt?.toIso8601String(),
'read': instance.read,
'childIds': instance.childIds,
'attachmentCount': instance.attachmentCount,
'status': _$ParentLetterStatusEnumMap[instance.status]!,
'deadline': instance.deadline?.toIso8601String(),
};
const _$ParentLetterStatusEnumMap = {
ParentLetterStatus.info: 'info',
ParentLetterStatus.open: 'open',
ParentLetterStatus.done: 'done',
ParentLetterStatus.expired: 'expired',
};
_ParentLetterListResponse _$ParentLetterListResponseFromJson(
Map<String, dynamic> json,
) => _ParentLetterListResponse(
items:
(json['items'] as List<dynamic>?)
?.map((e) => ParentLetterSummary.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
hasMore: json['hasMore'] as bool? ?? false,
unreadCount: (json['unreadCount'] as num?)?.toInt() ?? 0,
openCount: (json['openCount'] as num?)?.toInt() ?? 0,
);
Map<String, dynamic> _$ParentLetterListResponseToJson(
_ParentLetterListResponse instance,
) => <String, dynamic>{
'items': instance.items,
'hasMore': instance.hasMore,
'unreadCount': instance.unreadCount,
'openCount': instance.openCount,
};
_ParentLetterAttachment _$ParentLetterAttachmentFromJson(
Map<String, dynamic> json,
) => _ParentLetterAttachment(
id: json['id'] as String,
fileName: json['fileName'] as String? ?? '',
mimeType: json['mimeType'] as String? ?? '',
size: (json['size'] as num?)?.toInt() ?? 0,
);
Map<String, dynamic> _$ParentLetterAttachmentToJson(
_ParentLetterAttachment instance,
) => <String, dynamic>{
'id': instance.id,
'fileName': instance.fileName,
'mimeType': instance.mimeType,
'size': instance.size,
};
_ParentLetterOption _$ParentLetterOptionFromJson(Map<String, dynamic> json) =>
_ParentLetterOption(
id: json['id'] as String,
label: json['label'] as String? ?? '',
);
Map<String, dynamic> _$ParentLetterOptionToJson(_ParentLetterOption instance) =>
<String, dynamic>{'id': instance.id, 'label': instance.label};
_ParentLetterField _$ParentLetterFieldFromJson(Map<String, dynamic> json) =>
_ParentLetterField(
id: json['id'] as String,
type:
$enumDecodeNullable(
_$ParentLetterFieldTypeEnumMap,
json['type'],
unknownValue: ParentLetterFieldType.unknown,
) ??
ParentLetterFieldType.unknown,
label: json['label'] as String? ?? '',
isRequired: json['required'] as bool? ?? false,
options:
(json['options'] as List<dynamic>?)
?.map(
(e) => ParentLetterOption.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
);
Map<String, dynamic> _$ParentLetterFieldToJson(_ParentLetterField instance) =>
<String, dynamic>{
'id': instance.id,
'type': _$ParentLetterFieldTypeEnumMap[instance.type]!,
'label': instance.label,
'required': instance.isRequired,
'options': instance.options,
};
const _$ParentLetterFieldTypeEnumMap = {
ParentLetterFieldType.singleChoice: 'single_choice',
ParentLetterFieldType.unknown: 'unknown',
};
_ParentLetterRequest _$ParentLetterRequestFromJson(Map<String, dynamic> json) =>
_ParentLetterRequest(
fields:
(json['fields'] as List<dynamic>?)
?.map(
(e) => ParentLetterField.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
signatureRequired: json['signatureRequired'] as bool? ?? false,
deadline: json['deadline'] == null
? null
: DateTime.parse(json['deadline'] as String),
);
Map<String, dynamic> _$ParentLetterRequestToJson(
_ParentLetterRequest instance,
) => <String, dynamic>{
'fields': instance.fields,
'signatureRequired': instance.signatureRequired,
'deadline': instance.deadline?.toIso8601String(),
};
_ParentLetterAnswer _$ParentLetterAnswerFromJson(Map<String, dynamic> json) =>
_ParentLetterAnswer(
fieldId: json['fieldId'] as String,
optionIds:
(json['optionIds'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
text: json['text'] as String?,
);
Map<String, dynamic> _$ParentLetterAnswerToJson(_ParentLetterAnswer instance) =>
<String, dynamic>{
'fieldId': instance.fieldId,
'optionIds': instance.optionIds,
'text': instance.text,
};
_ParentLetterResponse _$ParentLetterResponseFromJson(
Map<String, dynamic> json,
) => _ParentLetterResponse(
respondedAt: json['respondedAt'] == null
? null
: DateTime.parse(json['respondedAt'] as String),
respondedBy: json['respondedBy'] == null
? const ParentLetterPerson()
: ParentLetterPerson.fromJson(
json['respondedBy'] as Map<String, dynamic>,
),
answers:
(json['answers'] as List<dynamic>?)
?.map((e) => ParentLetterAnswer.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
signed: json['signed'] as bool? ?? false,
);
Map<String, dynamic> _$ParentLetterResponseToJson(
_ParentLetterResponse instance,
) => <String, dynamic>{
'respondedAt': instance.respondedAt?.toIso8601String(),
'respondedBy': instance.respondedBy,
'answers': instance.answers,
'signed': instance.signed,
};
_ParentLetterChildState _$ParentLetterChildStateFromJson(
Map<String, dynamic> json,
) => _ParentLetterChildState(
childId: json['childId'] as String,
status:
$enumDecodeNullable(
_$ParentLetterStatusEnumMap,
json['status'],
unknownValue: ParentLetterStatus.info,
) ??
ParentLetterStatus.info,
editable: json['editable'] as bool? ?? false,
response: json['response'] == null
? null
: ParentLetterResponse.fromJson(json['response'] as Map<String, dynamic>),
);
Map<String, dynamic> _$ParentLetterChildStateToJson(
_ParentLetterChildState instance,
) => <String, dynamic>{
'childId': instance.childId,
'status': _$ParentLetterStatusEnumMap[instance.status]!,
'editable': instance.editable,
'response': instance.response,
};
_ParentLetterThreadMessage _$ParentLetterThreadMessageFromJson(
Map<String, dynamic> json,
) => _ParentLetterThreadMessage(
id: json['id'] as String,
author: json['author'] == null
? const ParentLetterPerson()
: ParentLetterPerson.fromJson(json['author'] as Map<String, dynamic>),
body: json['body'] as String? ?? '',
sentAt: DateTime.parse(json['sentAt'] as String),
);
Map<String, dynamic> _$ParentLetterThreadMessageToJson(
_ParentLetterThreadMessage instance,
) => <String, dynamic>{
'id': instance.id,
'author': instance.author,
'body': instance.body,
'sentAt': instance.sentAt.toIso8601String(),
};
_ParentLetterThread _$ParentLetterThreadFromJson(Map<String, dynamic> json) =>
_ParentLetterThread(
enabled: json['enabled'] as bool? ?? false,
messages:
(json['messages'] as List<dynamic>?)
?.map(
(e) => ParentLetterThreadMessage.fromJson(
e as Map<String, dynamic>,
),
)
.toList() ??
const [],
);
Map<String, dynamic> _$ParentLetterThreadToJson(_ParentLetterThread instance) =>
<String, dynamic>{
'enabled': instance.enabled,
'messages': instance.messages,
};
_ParentLetterContent _$ParentLetterContentFromJson(Map<String, dynamic> json) =>
_ParentLetterContent(
body: json['body'] as String? ?? '',
attachments:
(json['attachments'] as List<dynamic>?)
?.map(
(e) =>
ParentLetterAttachment.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
request: json['request'] == null
? null
: ParentLetterRequest.fromJson(
json['request'] as Map<String, dynamic>,
),
children:
(json['children'] as List<dynamic>?)
?.map(
(e) =>
ParentLetterChildState.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
thread: json['thread'] == null
? const ParentLetterThread()
: ParentLetterThread.fromJson(json['thread'] as Map<String, dynamic>),
);
Map<String, dynamic> _$ParentLetterContentToJson(
_ParentLetterContent instance,
) => <String, dynamic>{
'body': instance.body,
'attachments': instance.attachments,
'request': instance.request,
'children': instance.children,
'thread': instance.thread,
};
@@ -0,0 +1,18 @@
import 'package:dio/dio.dart';
import '../../../errors/app_exception.dart';
import '../../marianumconnect_query.dart';
import 'parent_letter_exception.dart';
/// Base for the parent-letter calls: domain rejections surface as
/// [ParentLetterException].
abstract class ParentLetterQuery extends MarianumConnectQuery {
ParentLetterQuery({super.dio});
String letterPath(String letterId, [String suffix = '']) =>
'parent-letters/${Uri.encodeComponent(letterId)}$suffix';
@override
AppException mapError(DioException error) =>
ParentLetterException.fromDio(error);
}
@@ -0,0 +1,20 @@
import 'parent_letter_models.dart';
import 'parent_letter_query.dart';
/// `POST parent-letters/{id}/thread`. [clientMessageId] makes a retry
/// idempotent on the server.
class PostParentLetterThreadMessage extends ParentLetterQuery {
PostParentLetterThreadMessage({super.dio});
Future<ParentLetterThreadMessage> run({
required String letterId,
required String body,
required String clientMessageId,
}) => guard(() async {
final response = await dio.post<Map<String, dynamic>>(
endpoint(letterPath(letterId, '/thread')),
data: {'body': body, 'clientMessageId': clientMessageId},
);
return ParentLetterThreadMessage.fromJson(response.data!);
});
}
@@ -0,0 +1,34 @@
import 'dart:convert';
import 'dart:typed_data';
import 'parent_letter_models.dart';
import 'parent_letter_query.dart';
/// `PUT parent-letters/{id}/responses/{childId}`: creates or (while editable)
/// replaces the response for one child. Answers with the updated letter.
class SubmitParentLetterResponse extends ParentLetterQuery {
SubmitParentLetterResponse({super.dio});
Future<ParentLetterDetail> run({
required String letterId,
required String childId,
required List<ParentLetterAnswer> answers,
Uint8List? signaturePng,
}) => guard(() async {
final response = await dio.put<Map<String, dynamic>>(
endpoint(
letterPath(letterId, '/responses/${Uri.encodeComponent(childId)}'),
),
data: {
'answers': [
for (final answer in answers)
{'fieldId': answer.fieldId, 'optionIds': answer.optionIds},
],
'signaturePng': signaturePng == null
? null
: base64Encode(signaturePng),
},
);
return ParentLetterDetail.fromJson(response.data!);
});
}