From e970f9e5908ddf8ba35e5b1c75ea84c1b7efedde Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sun, 17 May 2026 06:57:49 -0500 Subject: [PATCH] feat: preserve alt text * resolves #42 --- .../compose/presentation/compose_screen.dart | 226 ++++++++++-------- .../presentation/compose_screen_test.dart | 138 +++++++++++ 2 files changed, 260 insertions(+), 104 deletions(-) diff --git a/lib/features/compose/presentation/compose_screen.dart b/lib/features/compose/presentation/compose_screen.dart index 3310f72..f8b2546 100644 --- a/lib/features/compose/presentation/compose_screen.dart +++ b/lib/features/compose/presentation/compose_screen.dart @@ -1686,69 +1686,78 @@ class _ImageAltTextDialogState extends State<_ImageAltTextDialog> { final size = MediaQuery.sizeOf(context); final imageHeight = (size.height * 0.32).clamp(140.0, 280.0).toDouble(); - return Dialog( - clipBehavior: Clip.antiAlias, - insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: 560, maxHeight: size.height * 0.9), - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text(context.l10n.messageComposeImageAltTextTitle, style: theme.textTheme.titleLarge), + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) widget.onSave(_controller.text); + }, + child: Dialog( + clipBehavior: Clip.antiAlias, + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 560, maxHeight: size.height * 0.9), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text(context.l10n.messageComposeImageAltTextTitle, style: theme.textTheme.titleLarge), + ), + IconButton( + tooltip: context.l10n.labelClose, + onPressed: () => widget.onSave(_controller.text), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 12), + Container( + height: imageHeight, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.6)), ), - IconButton( - tooltip: context.l10n.labelClose, - onPressed: widget.onCancel, - icon: const Icon(Icons.close), + child: Image.file( + key: const ValueKey('alt-text-image-preview'), + File(widget.imagePath), + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) => + Center(child: Icon(Icons.broken_image_outlined, size: 40, color: colorScheme.onSurfaceVariant)), ), - ], - ), - const SizedBox(height: 12), - Container( - height: imageHeight, - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.6)), ), - child: Image.file( - key: const ValueKey('alt-text-image-preview'), - File(widget.imagePath), - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) => - Center(child: Icon(Icons.broken_image_outlined, size: 40, color: colorScheme.onSurfaceVariant)), + const SizedBox(height: 16), + TextField( + key: const ValueKey('alt-text-field'), + controller: _controller, + minLines: 3, + maxLines: 5, + maxLength: 1000, + textInputAction: TextInputAction.newline, + decoration: InputDecoration( + hintText: context.l10n.messageComposeDescribeImage, + border: const OutlineInputBorder(), + ), ), - ), - const SizedBox(height: 16), - TextField( - key: const ValueKey('alt-text-field'), - controller: _controller, - minLines: 3, - maxLines: 5, - maxLength: 1000, - textInputAction: TextInputAction.newline, - decoration: InputDecoration( - hintText: context.l10n.messageComposeDescribeImage, - border: const OutlineInputBorder(), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton(onPressed: widget.onCancel, child: Text(context.l10n.buttonCancel)), + const SizedBox(width: 8), + FilledButton( + onPressed: () => widget.onSave(_controller.text), + child: Text(context.l10n.buttonSave), + ), + ], ), - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton(onPressed: widget.onCancel, child: Text(context.l10n.buttonCancel)), - const SizedBox(width: 8), - FilledButton(onPressed: () => widget.onSave(_controller.text), child: Text(context.l10n.buttonSave)), - ], - ), - ], + ], + ), ), ), ), @@ -1782,54 +1791,63 @@ class _VideoAltTextDialogState extends State<_VideoAltTextDialog> { final size = MediaQuery.sizeOf(context); final previewHeight = (size.height * 0.32).clamp(140.0, 280.0).toDouble(); - return Dialog( - clipBehavior: Clip.antiAlias, - insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: 560, maxHeight: size.height * 0.9), - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text(context.l10n.messageComposeVideoAltTextTitle, style: theme.textTheme.titleLarge), - ), - IconButton( - tooltip: context.l10n.labelClose, - onPressed: widget.onCancel, - icon: const Icon(Icons.close), + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) widget.onSave(_controller.text); + }, + child: Dialog( + clipBehavior: Clip.antiAlias, + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 560, maxHeight: size.height * 0.9), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text(context.l10n.messageComposeVideoAltTextTitle, style: theme.textTheme.titleLarge), + ), + IconButton( + tooltip: context.l10n.labelClose, + onPressed: () => widget.onSave(_controller.text), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 12), + _LocalVideoPreview(videoPath: widget.video.localPath, height: previewHeight), + const SizedBox(height: 16), + TextField( + key: const ValueKey('video-alt-text-field'), + controller: _controller, + minLines: 3, + maxLines: 5, + maxLength: 1000, + textInputAction: TextInputAction.newline, + decoration: InputDecoration( + hintText: context.l10n.messageComposeDescribeVideo, + border: const OutlineInputBorder(), ), - ], - ), - const SizedBox(height: 12), - _LocalVideoPreview(videoPath: widget.video.localPath, height: previewHeight), - const SizedBox(height: 16), - TextField( - key: const ValueKey('video-alt-text-field'), - controller: _controller, - minLines: 3, - maxLines: 5, - maxLength: 1000, - textInputAction: TextInputAction.newline, - decoration: InputDecoration( - hintText: context.l10n.messageComposeDescribeVideo, - border: const OutlineInputBorder(), ), - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton(onPressed: widget.onCancel, child: Text(context.l10n.buttonCancel)), - const SizedBox(width: 8), - FilledButton(onPressed: () => widget.onSave(_controller.text), child: Text(context.l10n.buttonSave)), - ], - ), - ], + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton(onPressed: widget.onCancel, child: Text(context.l10n.buttonCancel)), + const SizedBox(width: 8), + FilledButton( + onPressed: () => widget.onSave(_controller.text), + child: Text(context.l10n.buttonSave), + ), + ], + ), + ], + ), ), ), ), diff --git a/test/features/compose/presentation/compose_screen_test.dart b/test/features/compose/presentation/compose_screen_test.dart index 43fba0e..b629bab 100644 --- a/test/features/compose/presentation/compose_screen_test.dart +++ b/test/features/compose/presentation/compose_screen_test.dart @@ -390,6 +390,94 @@ void main() { verify(() => mockBloc.add(const AltTextUpdated(index: 0, altText: 'A clearer image description'))).called(1); }); + + testWidgets('close button preserves typed image alt text', (tester) async { + final image = _writeTempImage(); + seedState( + ComposeState.ready( + isEmpty: false, + mediaAttachments: [MediaAttachment(localPath: image.path, altText: 'Existing description')], + ), + ); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + await tester.tap(find.text('ALT')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const ValueKey('alt-text-field')), 'Text kept from close'); + await tester.tap(find.descendant(of: find.byType(Dialog), matching: find.byTooltip('Close'))); + await tester.pumpAndSettle(); + + verify(() => mockBloc.add(const AltTextUpdated(index: 0, altText: 'Text kept from close'))).called(1); + }); + + testWidgets('Android back preserves typed image alt text', (tester) async { + final image = _writeTempImage(); + seedState( + ComposeState.ready( + isEmpty: false, + mediaAttachments: [MediaAttachment(localPath: image.path, altText: 'Existing description')], + ), + ); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + await tester.tap(find.text('ALT')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const ValueKey('alt-text-field')), 'Text kept from back'); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + verify(() => mockBloc.add(const AltTextUpdated(index: 0, altText: 'Text kept from back'))).called(1); + }); + + testWidgets('Android back preserves cleared image alt text', (tester) async { + final image = _writeTempImage(); + seedState( + ComposeState.ready( + isEmpty: false, + mediaAttachments: [MediaAttachment(localPath: image.path, altText: 'Existing description')], + ), + ); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + await tester.tap(find.text('ALT')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const ValueKey('alt-text-field')), ''); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + verify(() => mockBloc.add(const AltTextUpdated(index: 0, altText: ''))).called(1); + }); + + testWidgets('cancel discards typed image alt text changes', (tester) async { + final image = _writeTempImage(); + seedState( + ComposeState.ready( + isEmpty: false, + mediaAttachments: [MediaAttachment(localPath: image.path, altText: 'Existing description')], + ), + ); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + await tester.tap(find.text('ALT')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const ValueKey('alt-text-field')), 'Discard this text'); + await tester.tap(find.descendant(of: find.byType(Dialog), matching: find.widgetWithText(TextButton, 'Cancel'))); + await tester.pumpAndSettle(); + + verifyNever(() => mockBloc.add(const AltTextUpdated(index: 0, altText: 'Discard this text'))); + }); }); group('video alt text', () { @@ -422,6 +510,56 @@ void main() { verify(() => mockBloc.add(const VideoAltTextUpdated('A clearer video description'))).called(1); }); + + testWidgets('close button preserves typed video alt text', (tester) async { + seedState( + const ComposeState.ready( + isEmpty: false, + videoAttachment: VideoAttachment( + localPath: '/tmp/composer-video.mp4', + status: VideoUploadStatus.ready, + altText: 'Existing video description', + ), + ), + ); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.subtitles_outlined)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const ValueKey('video-alt-text-field')), 'Video text kept from close'); + await tester.tap(find.descendant(of: find.byType(Dialog), matching: find.byTooltip('Close'))); + await tester.pumpAndSettle(); + + verify(() => mockBloc.add(const VideoAltTextUpdated('Video text kept from close'))).called(1); + }); + + testWidgets('Android back preserves typed video alt text', (tester) async { + seedState( + const ComposeState.ready( + isEmpty: false, + videoAttachment: VideoAttachment( + localPath: '/tmp/composer-video.mp4', + status: VideoUploadStatus.ready, + altText: 'Existing video description', + ), + ), + ); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.subtitles_outlined)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const ValueKey('video-alt-text-field')), 'Video text kept from back'); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + verify(() => mockBloc.add(const VideoAltTextUpdated('Video text kept from back'))).called(1); + }); }); group('inline drafts panel (Bug #3)', () { -- 2.51.2