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
@@ -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!);
});
}