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
- 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
- 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
- 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
- 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
- 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.
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/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/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/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 00000000..c6e175ae
Binary files /dev/null and b/test/goldens/screens/support/goldens/macos/support_create_ticket_page_attached.png differ
diff --git a/test/goldens/screens/support/support_create_ticket_golden_test.dart b/test/goldens/screens/support/support_create_ticket_golden_test.dart
index 953958d9..0a1f7f55 100644
--- a/test/goldens/screens/support/support_create_ticket_golden_test.dart
+++ b/test/goldens/screens/support/support_create_ticket_golden_test.dart
@@ -1,9 +1,12 @@
+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:go_router/go_router.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/exceptions/api_exception.dart';
@@ -13,14 +16,34 @@ import 'package:realunit_wallet/screens/support/cubits/support_create_ticket/sup
import 'package:realunit_wallet/screens/support/cubits/support_create_ticket/support_create_ticket_state.dart';
import 'package:realunit_wallet/screens/support/subpages/support_create_ticket_page.dart';
import 'package:realunit_wallet/styles/themes.dart';
+import 'package:realunit_wallet/widgets/form/file_preview_field.dart';
import '../../../helper/helper.dart';
class _MockSupportCreateTicketCubit extends MockCubit
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',
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());
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'),
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(
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);
+ },
+ );
+ });
+}