added guardian letters with chat and multiple answer functionalities
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ class DefaultSettings {
|
||||
modulesSettings: ModulesSettings(
|
||||
moduleOrder: [
|
||||
Modules.timetable,
|
||||
Modules.parentLetters,
|
||||
Modules.ticker,
|
||||
Modules.talk,
|
||||
Modules.files,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_split_view/flutter_split_view.dart';
|
||||
|
||||
import '../../../push/notification_permission_prompt.dart';
|
||||
import '../../../routing/app_routes.dart';
|
||||
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
|
||||
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
||||
@@ -14,7 +15,6 @@ import '../../../widget/demo_restricted.dart';
|
||||
import '../../../widget/placeholder_view.dart';
|
||||
import 'data/open_direct_chat.dart';
|
||||
import 'join_chat.dart';
|
||||
import 'notification_permission_prompt.dart';
|
||||
import 'search_chat.dart';
|
||||
import 'widgets/chat_tile.dart';
|
||||
import 'widgets/split_view_placeholder.dart';
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:app_settings/app_settings.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../push/push_registration.dart';
|
||||
import '../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../widget/confirm_dialog.dart';
|
||||
|
||||
/// Shows the one-time notification-permission flow on the first Talk visit.
|
||||
///
|
||||
/// The OS prompt is deliberately kept out of the cold-start path (younger users
|
||||
/// decline it reflexively before ever seeing why they'd want it). Instead, the
|
||||
/// first time Talk is opened we explain the request, then trigger the OS prompt,
|
||||
/// and — if declined — offer a shortcut to the system settings.
|
||||
///
|
||||
/// Runs at most once per install (guarded by `talkPermissionPromptShown`).
|
||||
Future<void> maybePromptTalkNotifications(BuildContext context) async {
|
||||
final settings = context.read<SettingsCubit>();
|
||||
final notificationSettings = settings.val().notificationSettings;
|
||||
|
||||
// Already handled once, or the user opted out of push entirely.
|
||||
if (notificationSettings.talkPermissionPromptShown) return;
|
||||
if (!notificationSettings.enabled) return;
|
||||
|
||||
// Capabilities may still be loading on a fresh cold start; retry on the next
|
||||
// Talk visit instead of burning the one-shot flag.
|
||||
if (!context.read<CapabilitiesCubit>().canReceivePushNotifications) return;
|
||||
|
||||
// Existing users who already granted the permission: register silently and
|
||||
// mark the prompt as handled without showing any dialog.
|
||||
if (await PushRegistration.isOsPermissionGranted()) {
|
||||
settings.val(write: true).notificationSettings.talkPermissionPromptShown =
|
||||
true;
|
||||
unawaited(PushRegistration().register());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
ConfirmDialog(
|
||||
icon: Icons.notifications_active_outlined,
|
||||
title: 'Benachrichtigungen aktivieren',
|
||||
content:
|
||||
'Damit du keine neuen Nachrichten im Talk verpasst, fragen wir dich '
|
||||
'gleich nach der Erlaubnis für Benachrichtigungen. Bitte akzeptiere '
|
||||
'sie, um Push-Nachrichten zu erhalten.',
|
||||
confirmButton: 'Weiter',
|
||||
cancelButton: null,
|
||||
onConfirm: () => unawaited(_requestPermission(context, settings)),
|
||||
).asDialog(context);
|
||||
}
|
||||
|
||||
Future<void> _requestPermission(
|
||||
BuildContext context,
|
||||
SettingsCubit settings,
|
||||
) async {
|
||||
final granted = await PushRegistration.requestOsPermission();
|
||||
|
||||
// Mark handled regardless of the outcome — the user can re-enable later via
|
||||
// the system settings; we don't want to prompt again on the next Talk visit.
|
||||
settings.val(write: true).notificationSettings.talkPermissionPromptShown =
|
||||
true;
|
||||
|
||||
if (granted) {
|
||||
unawaited(PushRegistration().register());
|
||||
return;
|
||||
}
|
||||
|
||||
log('Push: notification permission declined on first Talk visit');
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
ConfirmDialog(
|
||||
icon: Icons.notifications_off_outlined,
|
||||
title: 'Benachrichtigungen deaktiviert',
|
||||
content:
|
||||
'Ohne die Berechtigung erhältst du keine Benachrichtigungen bei neuen '
|
||||
'Talk-Nachrichten. Du kannst sie jederzeit in den Systemeinstellungen '
|
||||
'deines Geräts nachträglich aktivieren.',
|
||||
confirmButton: 'Einstellungen öffnen',
|
||||
cancelButton: 'Später',
|
||||
onConfirm: () =>
|
||||
AppSettings.openAppSettings(type: AppSettingsType.notification),
|
||||
).asDialog(context);
|
||||
}
|
||||
Reference in New Issue
Block a user