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
@@ -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'),
),
],
),
],
),
),
);
}