From 80d7ffb9ade195a0d7286f458148c1554cb9cdc4 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Tue, 4 Aug 2026 16:41:57 +0200 Subject: [PATCH 1/6] refactor(form): share the file preview body and harden Image.file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SupportAttachmentField and FilePickerField carried a near line-identical body (48 px thumbnail, file name, edit icon, placeholder row). Both are now thin wrappers around FilePreviewField and keep their own label, so the KYC and support surfaces render exactly as before — proven by the unchanged goldens. With one call site left, Image.file gains the two things it lacked in both: an errorBuilder, so an unreadable temp file (iOS reclaims those) shows a placeholder instead of a grey box plus a logged exception, and cacheWidth, so a 48 px thumbnail no longer decodes at full resolution. --- .../widgets/support_attachment_field.dart | 92 ++-------- lib/widgets/form/file_picker_field.dart | 73 ++------ lib/widgets/form/file_preview_field.dart | 135 +++++++++++++++ .../widgets/form/file_preview_field_test.dart | 160 ++++++++++++++++++ 4 files changed, 324 insertions(+), 136 deletions(-) create mode 100644 lib/widgets/form/file_preview_field.dart create mode 100644 test/widgets/form/file_preview_field_test.dart diff --git a/lib/screens/support/widgets/support_attachment_field.dart b/lib/screens/support/widgets/support_attachment_field.dart index 77137c4b..c13d8963 100644 --- a/lib/screens/support/widgets/support_attachment_field.dart +++ b/lib/screens/support/widgets/support_attachment_field.dart @@ -1,9 +1,8 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/form/file_preview_field.dart'; /// Attachment picker styled like the surrounding ticket-form fields /// (`support_create_ticket_page`: bodyLarge section labels, radius 12, @@ -12,6 +11,7 @@ class SupportAttachmentField extends StatelessWidget { final XFile? selectedFile; final VoidCallback onTap; final VoidCallback onRemove; + /// When false, taps (pick + remove) are ignored — same pattern as the chat /// input's `onPressed: isSending ? null : …` (no opacity/color change). final bool enabled; @@ -43,80 +43,20 @@ class SupportAttachmentField extends StatelessWidget { fontWeight: .w600, ), ), - GestureDetector( - onTap: enabled ? onTap : null, - child: Container( - width: .infinity, - padding: const .symmetric(horizontal: 12, vertical: 12), - decoration: BoxDecoration( - borderRadius: .circular(12), - border: .all( - color: selectedFile != null - ? RealUnitColors.realUnitBlue - : RealUnitColors.neutral200, - ), - ), - child: selectedFile != null - ? Row( - spacing: 12.0, - children: [ - ClipRRect( - borderRadius: .circular(4), - child: Image.file( - File(selectedFile!.path), - width: 48, - height: 48, - fit: .cover, - ), - ), - Expanded( - child: Text( - selectedFile!.name, - maxLines: 1, - overflow: .ellipsis, - style: theme.textTheme.bodyMedium, - ), - ), - const Icon( - Icons.edit_rounded, - color: RealUnitColors.realUnitBlue, - ), - GestureDetector( - onTap: enabled ? onRemove : null, - behavior: .opaque, - // 48dp minimum tap target (Material accessibility). - child: const SizedBox( - width: 48, - height: 48, - child: Center( - child: Icon( - Icons.close_rounded, - color: RealUnitColors.neutral500, - ), - ), - ), - ), - ], - ) - : Row( - spacing: 8.0, - mainAxisAlignment: .center, - children: [ - Icon( - Icons.add_a_photo_rounded, - color: placeholderColor, - ), - Flexible( - child: Text( - S.of(context).supportChooseAttachment, - maxLines: 1, - overflow: .ellipsis, - style: placeholderStyle, - ), - ), - ], - ), - ), + FilePreviewField( + selectedFile: selectedFile, + onTap: onTap, + onRemove: onRemove, + enabled: enabled, + placeholderText: S.of(context).supportChooseAttachment, + placeholderStyle: placeholderStyle, + placeholderIconColor: placeholderColor, + borderColor: selectedFile != null + ? RealUnitColors.realUnitBlue + : RealUnitColors.neutral200, + borderRadius: 12, + height: null, + padding: const .symmetric(horizontal: 12, vertical: 12), ), ], ); diff --git a/lib/widgets/form/file_picker_field.dart b/lib/widgets/form/file_picker_field.dart index 60d1337d..1da7f0d1 100644 --- a/lib/widgets/form/file_picker_field.dart +++ b/lib/widgets/form/file_picker_field.dart @@ -1,8 +1,7 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/form/file_preview_field.dart'; class FilePickerField extends StatelessWidget { final String label; @@ -36,66 +35,20 @@ class FilePickerField extends StatelessWidget { ), ), ), - GestureDetector( + FilePreviewField( + selectedFile: selectedFile, onTap: onTap, - child: Container( - width: .infinity, - height: 76, - padding: const .symmetric(horizontal: 10), - decoration: BoxDecoration( - borderRadius: .circular(8), - border: .all( - color: error != null ? RealUnitColors.status.red600 : RealUnitColors.neutral300, - ), - ), - child: selectedFile != null - ? Row( - spacing: 12.0, - children: [ - ClipRRect( - borderRadius: .circular(4), - child: Image.file( - File(selectedFile!.path), - width: 48, - height: 48, - fit: BoxFit.cover, - ), - ), - Expanded( - child: Text( - selectedFile!.name, - maxLines: 1, - overflow: .ellipsis, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - const Icon( - Icons.edit_rounded, - color: RealUnitColors.realUnitBlue, - ), - ], - ) - : Row( - spacing: 8.0, - mainAxisAlignment: .center, - children: [ - const Icon( - Icons.add_a_photo_rounded, - color: RealUnitColors.neutral400, - ), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: .ellipsis, - style: const TextStyle( - color: RealUnitColors.neutral400, - ), - ), - ), - ], - ), + onRemove: null, + enabled: true, + placeholderText: label, + placeholderStyle: const TextStyle( + color: RealUnitColors.neutral400, ), + placeholderIconColor: RealUnitColors.neutral400, + borderColor: error != null ? RealUnitColors.status.red600 : RealUnitColors.neutral300, + borderRadius: 8, + height: 76, + padding: const .symmetric(horizontal: 10), ), ], ); diff --git a/lib/widgets/form/file_preview_field.dart b/lib/widgets/form/file_preview_field.dart new file mode 100644 index 00000000..1bcf194f --- /dev/null +++ b/lib/widgets/form/file_preview_field.dart @@ -0,0 +1,135 @@ +import 'dart:developer' as developer; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:realunit_wallet/styles/colors.dart'; + +/// Bordered file-preview body: either a 48×48 thumbnail with file name and +/// action icons, or a centered placeholder row. +/// +/// Does not render a section/field label — callers supply that themselves. +class FilePreviewField extends StatelessWidget { + final XFile? selectedFile; + final VoidCallback onTap; + final VoidCallback? onRemove; + final bool enabled; + final String placeholderText; + final TextStyle? placeholderStyle; + final Color placeholderIconColor; + final Color borderColor; + final double borderRadius; + final double? height; + final EdgeInsetsGeometry padding; + + const FilePreviewField({ + super.key, + required this.selectedFile, + required this.onTap, + this.onRemove, + this.enabled = true, + required this.placeholderText, + this.placeholderStyle, + required this.placeholderIconColor, + required this.borderColor, + required this.borderRadius, + this.height, + required this.padding, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: enabled ? onTap : null, + child: Container( + width: .infinity, + height: height, + padding: padding, + decoration: BoxDecoration( + borderRadius: .circular(borderRadius), + border: .all(color: borderColor), + ), + child: selectedFile != null + ? Row( + spacing: 12.0, + children: [ + ClipRRect( + borderRadius: .circular(4), + child: Image.file( + File(selectedFile!.path), + width: 48, + height: 48, + fit: .cover, + cacheWidth: (48 * MediaQuery.devicePixelRatioOf(context)).round(), + errorBuilder: (context, error, stackTrace) { + // Visible fallback replaces Flutter's grey box; log keeps + // the decode failure visible. May fire again on rebuilds + // that stay in the error state. + developer.log( + 'Could not decode file preview: $error', + name: '$FilePreviewField', + ); + return Container( + width: 48, + height: 48, + color: RealUnitColors.neutral200, + child: const Icon( + Icons.broken_image_rounded, + color: RealUnitColors.neutral500, + ), + ); + }, + ), + ), + Expanded( + child: Text( + selectedFile!.name, + maxLines: 1, + overflow: .ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + const Icon( + Icons.edit_rounded, + color: RealUnitColors.realUnitBlue, + ), + if (onRemove != null) + GestureDetector( + onTap: enabled ? onRemove : null, + behavior: .opaque, + // 48dp minimum tap target (Material accessibility). + child: const SizedBox( + width: 48, + height: 48, + child: Center( + child: Icon( + Icons.close_rounded, + color: RealUnitColors.neutral500, + ), + ), + ), + ), + ], + ) + : Row( + spacing: 8.0, + mainAxisAlignment: .center, + children: [ + Icon( + Icons.add_a_photo_rounded, + color: placeholderIconColor, + ), + Flexible( + child: Text( + placeholderText, + maxLines: 1, + overflow: .ellipsis, + style: placeholderStyle, + ), + ), + ], + ), + ), + ); + } +} diff --git a/test/widgets/form/file_preview_field_test.dart b/test/widgets/form/file_preview_field_test.dart new file mode 100644 index 00000000..7bb9646d --- /dev/null +++ b/test/widgets/form/file_preview_field_test.dart @@ -0,0 +1,160 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/form/file_preview_field.dart'; + +Widget _host(Widget child) => MaterialApp( + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), +); + +void main() { + late Directory tempDir; + late File imageFile; + late File corruptFile; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('file_preview_field_'); + imageFile = File('${tempDir.path}${Platform.pathSeparator}shot.png'); + final bytes = await File('assets/icons/realunit_wallet_logo_full.png').readAsBytes(); + await imageFile.writeAsBytes(bytes); + + // Unreadable image: empty .png. (Plain text bytes hang the native codec; + // empty/truncated PNG headers resolve to an error that errorBuilder handles.) + corruptFile = File('${tempDir.path}${Platform.pathSeparator}corrupt.png'); + await corruptFile.writeAsBytes(const []); + }); + + tearDownAll(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + group('$FilePreviewField', () { + testWidgets( + 'corrupt image path: shows broken_image fallback without throwing', + (tester) async { + await tester.pumpWidget( + _host( + FilePreviewField( + selectedFile: XFile(corruptFile.path), + onTap: () {}, + onRemove: () {}, + placeholderText: 'pick', + placeholderIconColor: RealUnitColors.neutral400, + borderColor: RealUnitColors.neutral200, + borderRadius: 12, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + ), + ); + // FileImage load/error runs on the real async timeline; pump alone + // cannot await that I/O. + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 100)), + ); + await tester.pump(); + + expect(find.byIcon(Icons.broken_image_rounded), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'Image.file uses ResizeImage cacheWidth = 48 * devicePixelRatio', + (tester) async { + const dpr = 3.0; + tester.view.devicePixelRatio = dpr; + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + _host( + FilePreviewField( + selectedFile: XFile(imageFile.path), + onTap: () {}, + onRemove: () {}, + placeholderText: 'pick', + placeholderIconColor: RealUnitColors.neutral400, + borderColor: RealUnitColors.neutral200, + borderRadius: 12, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + ), + ); + await tester.pump(); + + final image = tester.widget(find.byType(Image)); + expect(image.image, isA()); + final resize = image.image as ResizeImage; + expect(resize.width, (48 * dpr).round()); // 144 at dpr 3 + expect(resize.height, isNull); + }, + ); + + testWidgets( + 'onRemove: null — no close icon', + (tester) async { + await tester.pumpWidget( + _host( + FilePreviewField( + selectedFile: XFile(imageFile.path), + onTap: () {}, + onRemove: null, + placeholderText: 'pick', + placeholderIconColor: RealUnitColors.neutral400, + borderColor: RealUnitColors.neutral200, + borderRadius: 8, + height: 76, + padding: const EdgeInsets.symmetric(horizontal: 10), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.close_rounded), findsNothing); + expect(find.byIcon(Icons.edit_rounded), findsOneWidget); + expect(find.byType(Image), findsOneWidget); + }, + ); + + testWidgets( + 'onRemove set + enabled: false — neither onTap nor onRemove fires', + (tester) async { + var taps = 0; + var removes = 0; + await tester.pumpWidget( + _host( + FilePreviewField( + selectedFile: XFile(imageFile.path), + onTap: () => taps++, + onRemove: () => removes++, + enabled: false, + placeholderText: 'pick', + placeholderIconColor: RealUnitColors.neutral400, + borderColor: RealUnitColors.neutral200, + borderRadius: 12, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.edit_rounded)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.close_rounded)); + await tester.pumpAndSettle(); + + expect(taps, 0); + expect(removes, 0); + }, + ); + }); +} From 85c3190deb92fd7e34615d896b450725a3db55af Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Tue, 4 Aug 2026 16:42:14 +0200 Subject: [PATCH 2/6] test(support): cover the filled attachment row in the responsive matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The matrix only ever pumped a state without an attachment, so the placeholder row was gated and the filled one — 48 px thumbnail, an Expanded file name and two icons, one of them a 48x48 tap target — was not. The second group runs the same device x text-scale sweep with a long file name and additionally asserts the close target stays tappable. Counter-probe: dropping the Expanded around the file name turns 35 of the 35 new cells red while the code still analyzes clean. --- ..._create_ticket_responsive_matrix_test.dart | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/test/screens/support/support_create_ticket_responsive_matrix_test.dart b/test/screens/support/support_create_ticket_responsive_matrix_test.dart index dde02ef6..28be6b30 100644 --- a/test/screens/support/support_create_ticket_responsive_matrix_test.dart +++ b/test/screens/support/support_create_ticket_responsive_matrix_test.dart @@ -4,11 +4,19 @@ // text-scale matrix when a long German message expands the multiline field. // Regression lock for the iPhone SE + textScale 2.0 bug where the CTA was // scrolled below the fold inside the legacy Spacer layout. +// +// A second group covers the filled-attachment row (48 px thumbnail + long +// ellipsized file name + 48×48 close target) under the same matrix pressure. +// Close is scrolled into view first: the field lives in the scrollable body, +// while the sticky CTA sits outside it. +import 'dart:io'; + import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/service/dfx/models/support/support_issue_reason.dart'; @@ -33,6 +41,10 @@ const _longGermanMessage = 'Können Sie bitte prüfen, was mit der Zahlung passiert ist und mir den ' 'aktuellen Stand mitteilen? Vielen Dank im Voraus.'; +/// Long file name so Expanded + ellipsis is under real pressure at large scales. +const _longAttachmentName = + 'screenshot_fehler_transaktion_maerz_2026_bankkonto_abgebucht_wallet_status_in_bearbeitung.png'; + const _submittableState = SupportCreateTicketState( selectedType: SupportIssueType.genericIssue, selectedReason: SupportIssueReason.other, @@ -69,6 +81,29 @@ Future _pumpScreen( void main() { late _MockSupportCreateTicketCubit cubit; + late Directory tempDir; + late File imageFile; + late SupportCreateTicketState stateWithAttachment; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('support_create_ticket_matrix_'); + imageFile = File('${tempDir.path}${Platform.pathSeparator}$_longAttachmentName'); + final bytes = + await File('assets/icons/realunit_wallet_logo_full.png').readAsBytes(); + await imageFile.writeAsBytes(bytes); + stateWithAttachment = SupportCreateTicketState( + selectedType: SupportIssueType.genericIssue, + selectedReason: SupportIssueReason.other, + message: 'Ich habe ein Problem mit meiner Transaktion.', + attachment: XFile(imageFile.path), + ); + }); + + tearDownAll(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); setUp(() { cubit = _MockSupportCreateTicketCubit(); @@ -80,6 +115,7 @@ void main() { ); when(() => cubit.submit()).thenAnswer((_) async {}); when(() => cubit.updateMessage(any())).thenReturn(null); + when(() => cubit.clearAttachment()).thenReturn(null); }); Widget buildSubject() => BlocProvider.value( @@ -112,6 +148,55 @@ void main() { } }); + group( + 'SupportCreateTicketView responsive matrix with filled attachment ' + '(CTA + close target tappable after being scrolled into view, no overflow)', + () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + when(() => cubit.state).thenReturn(stateWithAttachment); + whenListen( + cubit, + const Stream.empty(), + initialState: stateWithAttachment, + ); + + await withTargetPlatform(cell.device.platform, () async { + await expectNoLayoutOverflow( + tester, + () async { + await _pumpScreen(tester, buildSubject(), cell.mediaQuery); + await tester.enterText(find.byType(TextField), _longGermanMessage); + await tester.pump(); + }, + reason: 'overflow with attachment on ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton), + within: find.byType(SupportCreateTicketView), + reason: '${cell.label}: CTA not tappable with attachment', + ); + + // Attachment is in the scrollable body (sticky CTA is outside it); + // at large text scales the close control may start off-screen. + final closeTarget = find.byIcon(Icons.close_rounded); + await tester.ensureVisible(closeTarget); + await tester.pumpAndSettle(); + + await expectFullyTappable( + tester, + closeTarget, + within: find.byType(SupportCreateTicketView), + reason: '${cell.label}: close target not tappable', + ); + }); + }); + } + }, + ); + // Focused regression: iPhone SE 375×667 DE + textScale 2.0 — proven fail // pre-fix when a long message expands the multiline field. testWidgets( From a8988c4f3479ea115dd6581c4a262316fb631c24 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Tue, 4 Aug 2026 16:42:14 +0200 Subject: [PATCH 3/6] fix(support): send the trimmed chat message body sendMessage decided on trimmed but posted the raw message, so leading and trailing whitespace reached support. Counter-probe: restoring the old expression turns 1 of the 20 cubit tests red. --- .../support_chat/support_chat_cubit.dart | 2 +- .../cubits/support_chat_cubit_test.dart | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/screens/support/cubits/support_chat/support_chat_cubit.dart b/lib/screens/support/cubits/support_chat/support_chat_cubit.dart index 258590c9..6342aa87 100644 --- a/lib/screens/support/cubits/support_chat/support_chat_cubit.dart +++ b/lib/screens/support/cubits/support_chat/support_chat_cubit.dart @@ -64,7 +64,7 @@ class SupportChatCubit extends Cubit { final base64 = await attachment?.toBase64DataUri(); await _supportService.sendMessage( _ticketUid, - message: trimmed.isEmpty ? null : message, + message: trimmed.isEmpty ? null : trimmed, file: base64, fileName: attachment?.name, ); diff --git a/test/screens/support/cubits/support_chat_cubit_test.dart b/test/screens/support/cubits/support_chat_cubit_test.dart index 112e8914..f40b4d7e 100644 --- a/test/screens/support/cubits/support_chat_cubit_test.dart +++ b/test/screens/support/cubits/support_chat_cubit_test.dart @@ -163,6 +163,29 @@ void main() { expect((cubit.state as SupportChatLoaded).isSending, isFalse); }); + test('sendMessage trims whitespace before posting the message body', () async { + when(() => service.getTicket(_ticketUid)) + .thenAnswer((_) async => _ticket()); + when(() => service.sendMessage( + any(), + message: any(named: 'message'), + file: any(named: 'file'), + fileName: any(named: 'fileName'), + )).thenAnswer((_) async {}); + final cubit = SupportChatCubit(service, _ticketUid); + await cubit.stream.firstWhere((s) => s is SupportChatLoaded); + + final ok = await cubit.sendMessage(' hallo '); + + expect(ok, isTrue); + verify(() => service.sendMessage( + _ticketUid, + message: 'hallo', + file: null, + fileName: null, + )).called(1); + }); + test('sendMessage clears isSending=true when the service fails', () async { when(() => service.getTicket(_ticketUid)) .thenAnswer((_) async => _ticket()); From f79f2c37502f798e04e71c8aa26e449b3db0766d Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Tue, 4 Aug 2026 16:42:14 +0200 Subject: [PATCH 4/6] docs(handbook): describe the ticket attachment field and the chat paperclip Five of the goldens regenerated in #890 are mapped into the handbook and now show the attachment field and the paperclip button, while the prose still enumerated the old form fields. These descriptions are the alt text for readers who cannot see the screenshots, so the gap hits exactly the people who depend on them. The submitting state was checked against the golden rather than assumed: the attachment region is pixel-identical to the default state, so only the tags, the message field and the button change. --- docs/handbook/de/index.html | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html index 4ef0d2dd..e8f6da43 100644 --- a/docs/handbook/de/index.html +++ b/docs/handbook/de/index.html @@ -7496,7 +7496,7 @@

73Support — Ticket erstellen

- Das Formular zum Erfassen eines neuen Support-Tickets: Anliegen-Typ per Tag auswählen, Anliegen im Textfeld beschreiben und absenden. + Das Formular zum Erfassen eines neuen Support-Tickets: Anliegen-Typ per Tag auswählen, Anliegen im Textfeld beschreiben, optional ein Bild als Anhang wählen und absenden.

@@ -7509,11 +7509,11 @@

73Support — Ticket erstellen

Leeres Formular für neues Ticket
- Das leere Formular Neues Ticket erstellen mit der Auswahl Anliegen auswählen (Tags Allgemeines Anliegen, Fehlerbericht, KYC-Problem), dem Nachrichtenfeld und der noch deaktivierten Schaltfläche Senden. So sieht der Nutzer das Formular direkt beim Öffnen, bevor er einen Typ wählt oder etwas eingibt. + Das leere Formular Neues Ticket erstellen mit der Auswahl Anliegen auswählen (Tags Allgemeines Anliegen, Fehlerbericht, KYC-Problem), dem Nachrichtenfeld, der Sektion Anhang mit dem Rahmenfeld und Platzhalter Anhang wählen sowie der noch deaktivierten Schaltfläche Senden. So sieht der Nutzer das Formular direkt beim Öffnen, bevor er einen Typ wählt oder etwas eingibt.
@@ -7525,11 +7525,11 @@

73Support — Ticket erstellen

Ticket-Formular ausgefüllt, Senden aktiv
- Das ausgefüllte Formular mit ausgewähltem Anliegen-Typ und eingegebener Nachricht: Die Schaltfläche Senden ist nun aktiv. Der Nutzer sieht diesen Zustand, sobald er einen Typ gewählt und eine Nachricht erfasst hat und das Ticket abschicken kann. + Das ausgefüllte Formular mit ausgewähltem Anliegen-Typ und eingegebener Nachricht: Die Schaltfläche Senden ist nun aktiv. Das Anhang-Feld darunter bleibt leer — der Anhang ist optional und muss fürs Absenden nicht gesetzt sein. Der Nutzer sieht diesen Zustand, sobald er einen Typ gewählt und eine Nachricht erfasst hat und das Ticket abschicken kann.
@@ -7541,11 +7541,11 @@

73Support — Ticket erstellen

Ticket wird abgesendet
- Das Ticket-Formular während des Absendens: Die Schaltfläche Senden zeigt eine Ladeanimation. Dieser Zustand erscheint, nachdem der Nutzer auf Senden getippt hat, solange das Ticket an den Server übermittelt wird. + Das Ticket-Formular während des Absendens: die Anliegen-Tags und das Nachrichtenfeld sind abgeblendet, die Schaltfläche Senden zeigt eine Ladeanimation. Das Anhang-Feld darunter bleibt unverändert sichtbar. Dieser Zustand erscheint, nachdem der Nutzer auf Senden getippt hat, solange das Ticket an den Server übermittelt wird.
@@ -7664,7 +7664,7 @@

75Support — Chat

- Der Chat-Verlauf zu einem einzelnen Ticket: Nachrichten von Kunde und Support als Sprechblasen, mit Eingabefeld bei offenen Tickets und seinen Lade-, Sende-, Fehler- und Geschlossen-Zuständen. + Der Chat-Verlauf zu einem einzelnen Ticket: Nachrichten von Kunde und Support als Sprechblasen, mit Eingabefeld und Büroklammer zum Anhängen eines Bildes bei offenen Tickets sowie Lade-, Sende-, Fehler- und Geschlossen-Zuständen.

@@ -7693,11 +7693,11 @@

75Support — Chat

Geladener Chat mit Nachrichten und Eingabefeld
- Der geladene Chat eines offenen Tickets: die Kunden-Nachricht als blaue, rechtsbündige Sprechblase und die Antwort des Supports als graue, linksbündige Sprechblase mit Label Support und Uhrzeit. Unten steht das Eingabefeld Nachricht eingeben mit Sende-Schaltfläche bereit. + Der geladene Chat eines offenen Tickets: die Kunden-Nachricht als blaue, rechtsbündige Sprechblase und die Antwort des Supports als graue, linksbündige Sprechblase mit Label Support und Uhrzeit. Unten steht links neben dem Eingabefeld Nachricht eingeben eine Büroklammer-Schaltfläche zum Anhängen eines Bildes, rechts die Sende-Schaltfläche.
@@ -7709,11 +7709,11 @@

75Support — Chat

Chat-Nachricht wird gesendet
- Der offene Chat, während eine Nachricht gesendet wird: Das Eingabefeld ist deaktiviert und die Sende-Schaltfläche zeigt statt des Pfeils eine Ladeanimation. Dieser Zustand erscheint, nachdem der Nutzer auf Senden getippt hat, solange die Nachricht übermittelt wird. + Der offene Chat, während eine Nachricht gesendet wird: dieselbe Büroklammer-Schaltfläche bleibt sichtbar, das Eingabefeld ist deaktiviert und die Sende-Schaltfläche zeigt statt des Pfeils eine Ladeanimation. Dieser Zustand erscheint, nachdem der Nutzer auf Senden getippt hat, solange die Nachricht übermittelt wird.
From 3fd3f6e3481bcacceb8daf380e59091050f1fade Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Tue, 4 Aug 2026 17:24:58 +0200 Subject: [PATCH 5/6] test(support): golden for the ticket form with an attachment selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No golden rendered a selected file, so the thumbnail path this PR touches had no baseline at all — the one state where cacheWidth changes the decode resolution. Image.file decodes on the real async timeline and the shared precacheImages helper ends in pumpAndSettle, which does not return here. The test therefore polls for the decoded RawImage and asserts it, so a slower runner fails loudly instead of capturing an empty thumbnail as the new baseline. With the poll budget set to zero the test goes red. The baseline PNG was rendered by golden-regenerate.yaml on the self-hosted runner, under the same toolchain that validates it; it is carried here byte for byte so the branch keeps a fully signed history. --- .../support_create_ticket_page_attached.png | Bin 0 -> 32816 bytes .../support_create_ticket_golden_test.dart | 74 ++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 test/goldens/screens/support/goldens/macos/support_create_ticket_page_attached.png diff --git a/test/goldens/screens/support/goldens/macos/support_create_ticket_page_attached.png b/test/goldens/screens/support/goldens/macos/support_create_ticket_page_attached.png new file mode 100644 index 0000000000000000000000000000000000000000..c6e175ae376f575c96faaf3b18ec1703d4ee1440 GIT binary patch literal 32816 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 implements SupportCreateTicketCubit {} +/// Stable name shown in the filled-attachment golden (no random/timestamp). +const _attachedFileName = 'beleg.png'; + void main() { late _MockSupportCreateTicketCubit cubit; + late Directory tempDir; + late File imageFile; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('support_create_ticket_golden_'); + imageFile = File('${tempDir.path}${Platform.pathSeparator}$_attachedFileName'); + final bytes = + await File('assets/icons/realunit_wallet_logo_full.png').readAsBytes(); + await imageFile.writeAsBytes(bytes); + }); + + tearDownAll(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); setUp(() { cubit = _MockSupportCreateTicketCubit(); @@ -105,6 +128,57 @@ void main() { }, ); + // Filled form plus attachment — covers FilePreviewField with a selected + // file (cacheWidth / thumbnail path). Image.file decodes on the real async + // timeline; the default precacheImages ends in pumpAndSettle, which does + // not return here. Poll until RawImage.image is set (decode done), fail + // loudly if the budget elapses — never snapshot a blank thumbnail. + goldenTest( + 'filled form with attachment selected', + fileName: 'support_create_ticket_page_attached', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + final rawImage = find.descendant( + of: find.byType(FilePreviewField), + matching: find.byType(RawImage), + ); + // Bounded: 60 × 50 ms ≈ 3 s. Breaks early when decode completes. + const maxAttempts = 60; + for (var i = 0; i < maxAttempts; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + await tester.pump(); + if (rawImage.evaluate().isNotEmpty && + tester.widget(rawImage).image != null) { + break; + } + } + expect( + rawImage, + findsOneWidget, + reason: 'FilePreviewField should host a RawImage for the attachment', + ); + expect( + tester.widget(rawImage).image, + isNotNull, + reason: + 'Attachment thumbnail did not decode within ${maxAttempts * 50} ms', + ); + }, + builder: () { + when(() => cubit.state).thenReturn( + SupportCreateTicketState( + selectedType: SupportIssueType.genericIssue, + selectedReason: SupportIssueReason.other, + message: 'Ich habe eine Frage zu meinem Konto.', + attachment: XFile(imageFile.path), + ), + ); + return wrapForGolden(buildSubject()); + }, + ); + goldenTest( 'submitting — send button shows the loading spinner', fileName: 'support_create_ticket_page_submitting', From fa81b1f5db31f764405c5a233bc08758eb915371 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Tue, 4 Aug 2026 20:06:27 +0200 Subject: [PATCH 6/6] fix(support): trim the ticket message body too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit made sendMessage post the trimmed body, but submit() still hoisted the raw one — so the chat trimmed and the ticket did not. That split is worse than the state before: the two paths behaving alike was the reason the chat side was a readability note rather than a defect. canSubmit already gates on message.trim().isNotEmpty, so trimming what is sent changes no acceptance decision, only what support receives. Counter-probe: dropping .trim() again turns 1 of the 80 support cubit tests red. --- .../support_create_ticket_cubit.dart | 2 +- .../support_create_ticket_cubit_test.dart | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/lib/screens/support/cubits/support_create_ticket/support_create_ticket_cubit.dart b/lib/screens/support/cubits/support_create_ticket/support_create_ticket_cubit.dart index e429fc3a..d9f0c650 100644 --- a/lib/screens/support/cubits/support_create_ticket/support_create_ticket_cubit.dart +++ b/lib/screens/support/cubits/support_create_ticket/support_create_ticket_cubit.dart @@ -50,7 +50,7 @@ class SupportCreateTicketCubit extends Cubit { final attachment = state.attachment; final selectedType = state.selectedType!; final selectedReason = state.selectedReason!; - final message = state.message; + final message = state.message.trim(); if (isClosed) return; emit(state.copyWith(isSubmitting: true, error: null)); diff --git a/test/screens/support/cubits/support_create_ticket_cubit_test.dart b/test/screens/support/cubits/support_create_ticket_cubit_test.dart index 4d55ff4b..0856a45b 100644 --- a/test/screens/support/cubits/support_create_ticket_cubit_test.dart +++ b/test/screens/support/cubits/support_create_ticket_cubit_test.dart @@ -228,6 +228,32 @@ void main() { )).called(1); }); + test('submit() trims whitespace before posting the message body', () async { + when(() => service.createTicket( + type: any(named: 'type'), + reason: any(named: 'reason'), + name: any(named: 'name'), + message: any(named: 'message'), + file: any(named: 'file'), + fileName: any(named: 'fileName'), + )).thenAnswer((_) async => _ticket()); + final cubit = SupportCreateTicketCubit(service); + cubit.selectType(SupportIssueType.bugReport); + cubit.selectReason(SupportIssueReason.other); + cubit.updateMessage(' hallo '); + + await cubit.submit(); + + verify(() => service.createTicket( + type: SupportIssueType.bugReport, + reason: SupportIssueReason.other, + name: 'Bug Report', + message: 'hallo', + file: null, + fileName: null, + )).called(1); + }); + test('submit() with attachment forwards data-URI file and fileName', () async { when(() => service.createTicket( type: any(named: 'type'),