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