From 843686358f0543c88fd0998c7f54e016182225f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 13 May 2026 19:07:06 +0200 Subject: [PATCH] overhauled feedback dialog UI, implemented async action buttons for submission and image picking, and added a custom image preview widget --- .../pages/more/feedback/feedback_dialog.dart | 313 +++++++++--------- lib/widget/file_pick.dart | 3 + 2 files changed, 158 insertions(+), 158 deletions(-) diff --git a/lib/view/pages/more/feedback/feedback_dialog.dart b/lib/view/pages/more/feedback/feedback_dialog.dart index 3fcd528..a504ec1 100644 --- a/lib/view/pages/more/feedback/feedback_dialog.dart +++ b/lib/view/pages/more/feedback/feedback_dialog.dart @@ -1,18 +1,13 @@ -import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; -import 'package:badges/badges.dart' as badges; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:loader_overlay/loader_overlay.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../../api/mhsl/server/feedback/add_feedback.dart'; import '../../../../api/mhsl/server/feedback/add_feedback_params.dart'; import '../../../../model/account_data.dart'; -import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../../widget/async_action_button.dart'; import '../../../../widget/file_pick.dart'; import '../../../../widget/focus_behaviour.dart'; import '../../../../widget/info_dialog.dart'; @@ -25,187 +20,189 @@ class FeedbackDialog extends StatefulWidget { } class _FeedbackDialogState extends State { - final ImagePicker picker = ImagePicker(); - final TextEditingController _feedbackInput = TextEditingController(); + final AsyncActionController _sendController = AsyncActionController(); + final AsyncActionController _pickController = AsyncActionController(); + Uint8List? _image; - String? _error; bool _textFieldEmpty = false; @override void initState() { super.initState(); _feedbackInput.addListener(() { - setState(() { - _textFieldEmpty = _feedbackInput.text.isEmpty; - _error = null; - }); + if (_textFieldEmpty && _feedbackInput.text.isNotEmpty) { + setState(() => _textFieldEmpty = false); + } }); } @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('Feedback')), - body: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.max, - children: [ - const SizedBox(height: 5), - const Text( - 'Feedback, Anregungen, Ideen, Fehler und Verbesserungen', - textAlign: TextAlign.center, - ), - const SizedBox(height: 15), - const Text( - 'Bitte gib keine geheimen Daten wie z.B. Passwörter weiter.', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 11), - ), - const SizedBox(height: 20), - Padding( - padding: const EdgeInsets.all(10), - child: TextField( + void dispose() { + _feedbackInput.dispose(); + _sendController.dispose(); + _pickController.dispose(); + super.dispose(); + } + + Future _pickImage() async { + final picked = await FilePick.singleGalleryPick(); + if (picked == null) return; + final data = await picked.readAsBytes(); + if (!mounted) return; + setState(() => _image = data); + } + + Future _submit() async { + if (_feedbackInput.text.trim().isEmpty) { + setState(() => _textFieldEmpty = true); + return; + } + final info = await PackageInfo.fromPlatform(); + await AddFeedback( + AddFeedbackParams( + user: AccountData().getUserSecret(), + feedback: _feedbackInput.text, + screenshot: _image != null ? base64Encode(_image!) : null, + appVersion: int.parse(info.buildNumber), + ), + ).run(); + if (!mounted) return; + Navigator.of(context).pop(); + InfoDialog.show(context, 'Danke für dein Feedback!'); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar(title: const Text('Feedback')), + body: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Feedback, Anregungen, Ideen, Fehler und Verbesserungen', + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + 'Bitte gib keine geheimen Daten wie z.B. Passwörter weiter.', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + TextField( + controller: _feedbackInput, + autofocus: true, + minLines: 5, + maxLines: 8, + textCapitalization: TextCapitalization.sentences, onChanged: (value) { if (value.trim().toLowerCase() == 'ranzig') { _feedbackInput.text = 'selber'; } }, - controller: _feedbackInput, - autofocus: true, decoration: InputDecoration( border: const OutlineInputBorder(), - label: const Text('Feedback und Verbesserungen'), + label: const Text('Deine Nachricht'), + hintText: 'Was möchtest du uns mitteilen?', errorText: _textFieldEmpty ? 'Bitte gib eine Beschreibung an!' : null, ), - minLines: 4, - maxLines: 7, - onTapOutside: (PointerDownEvent event) => + onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context), ), - ), - const SizedBox(height: 10), - if (_image != null) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - badges.Badge( - badgeContent: const Icon(Icons.close_outlined, size: 17), - badgeStyle: const badges.BadgeStyle( - padding: EdgeInsets.all(2), - ), - child: Container( - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(5)), - border: Border.all( - width: 3, - color: Theme.of(context).primaryColor, - ), - ), - height: 150, - child: Image( - image: Image.memory(_image!).image, - fit: BoxFit.contain, - ), - ), - onTap: () async { - setState(() { - _image = null; - }); - }, + const SizedBox(height: 16), + if (_image != null) + Center( + child: _ImagePreview( + bytes: _image!, + onRemove: () => setState(() => _image = null), ), - ], - ), - Padding( - padding: const EdgeInsets.all(5), - child: Visibility( - visible: _error != null, - child: Visibility( - visible: context.read().val().devToolsEnabled, - replacement: const Text( - 'Senden fehlgeschlagen, bitte überprüfe die Internetverbindung.', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.red), - ), - child: Text( - 'Senden fehlgeschlagen: \n $_error', - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.red), + ) + else + Align( + alignment: Alignment.centerLeft, + child: AsyncTextButton( + controller: _pickController, + onPressed: _pickImage, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.attach_file_outlined, size: 18), + SizedBox(width: 6), + Text('Screenshot anhängen'), + ], + ), ), ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: AsyncTextButton( + controller: _sendController, + onPressed: _submit, + child: const Text('Senden'), + ), ), - ), - Padding( - padding: const EdgeInsets.only(right: 20, left: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Visibility( - visible: _image == null, - child: IconButton( - onPressed: () async { - context.loaderOverlay.show(); - final picked = await FilePick.multipleGalleryPick(); - final imageData = await picked?.first.readAsBytes(); - if (context.mounted) context.loaderOverlay.hide(); - setState(() { - _image = imageData; - }); - }, - icon: const Icon(Icons.attach_file_outlined), - ), - ), - const Expanded(child: SizedBox.shrink()), - TextButton( - onPressed: () async { - if (_feedbackInput.text.isEmpty) { - setState(() { - _textFieldEmpty = true; - }); - return; - } - context.loaderOverlay.show(); - unawaited( - AddFeedback( - AddFeedbackParams( - user: AccountData().getUserSecret(), - feedback: _feedbackInput.text, - screenshot: _image != null - ? base64Encode(_image!) - : null, - appVersion: int.parse( - (await PackageInfo.fromPlatform()).buildNumber, - ), - ), - ) - .run() - .then((value) { - if (!context.mounted) return; - Navigator.of(context).pop(); - InfoDialog.show( - context, - 'Danke für dein Feedback!', - ); - context.loaderOverlay.hide(); - }) - .catchError((Object error, StackTrace trace) { - if (!mounted) return; - setState(() { - _error = error.toString(); - }); - if (!context.mounted) return; - context.loaderOverlay.hide(); - }), - ); - }, - child: const Text('Senden'), - ), - ], - ), - ), - ], + ], + ), ), - ), + ); + } +} + +class _ImagePreview extends StatelessWidget { + final Uint8List bytes; + final VoidCallback onRemove; + + const _ImagePreview({required this.bytes, required this.onRemove}); + + @override + Widget build(BuildContext context) => Stack( + children: [ + Padding( + // Reserve space inside the Stack bounds for the close button. + // Without it the button would sit on a negative Positioned and + // Flutter wouldn't hit-test taps outside the Stack rect. + padding: const EdgeInsets.only(top: 10, right: 10), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + borderRadius: BorderRadius.circular(8), + ), + constraints: const BoxConstraints(maxHeight: 200), + child: Image.memory(bytes, fit: BoxFit.contain), + ), + ), + ), + Positioned( + top: 0, + right: 0, + child: Material( + color: Theme.of(context).colorScheme.surface, + shape: const CircleBorder(), + elevation: 2, + child: InkWell( + customBorder: const CircleBorder(), + onTap: onRemove, + child: const Padding( + padding: EdgeInsets.all(4), + child: Icon(Icons.close, size: 18), + ), + ), + ), + ), + ], ); } diff --git a/lib/widget/file_pick.dart b/lib/widget/file_pick.dart index 130880e..a729999 100644 --- a/lib/widget/file_pick.dart +++ b/lib/widget/file_pick.dart @@ -9,6 +9,9 @@ class FilePick { return pickedImages.isNotEmpty ? pickedImages : null; } + static Future singleGalleryPick() => + _picker.pickImage(source: ImageSource.gallery); + static Future cameraPick() => _picker.pickImage(source: ImageSource.camera);