added guardian letters with chat and multiple answer functionalities
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user