From bc29cb44ae1e07388f298c06d32065072632e978 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Mon, 25 May 2026 09:11:59 -0500 Subject: [PATCH] feat: add scrollable alt text panel for media viewers --- .../media/image_viewer_screen.dart | 28 +++--- .../media/media_alt_text_panel.dart | 61 ++++++++++++ .../media/video_player_screen.dart | 96 +++++++++---------- .../media/media_alt_text_panel_test.dart | 55 +++++++++++ 4 files changed, 174 insertions(+), 66 deletions(-) create mode 100644 lib/features/feed/presentation/media/media_alt_text_panel.dart create mode 100644 test/features/feed/presentation/media/media_alt_text_panel_test.dart diff --git a/lib/features/feed/presentation/media/image_viewer_screen.dart b/lib/features/feed/presentation/media/image_viewer_screen.dart index d58469d..c546425 100644 --- a/lib/features/feed/presentation/media/image_viewer_screen.dart +++ b/lib/features/feed/presentation/media/image_viewer_screen.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:lazurite/core/cache/lazurite_image_cache.dart'; -import 'package:photo_view/photo_view.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/feed/presentation/media/image_viewer_route_args.dart'; import 'package:lazurite/features/feed/presentation/media/media_actions.dart'; +import 'package:lazurite/features/feed/presentation/media/media_alt_text_panel.dart'; +import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view_gallery.dart'; /// Full-screen, zoomable gallery for one or more images in a post embed. @@ -46,7 +48,8 @@ class _ImageViewerScreenState extends State { @override Widget build(BuildContext context) { final image = widget.args.images[_currentIndex]; - final theme = Theme.of(context); + final altText = image.altText?.trim(); + final hasAltText = altText?.isNotEmpty ?? false; final progressValue = _downloadProgress > 0 && _downloadProgress < 1 ? _downloadProgress : null; final backgroundOpacity = (1 - (_dragOffset.abs() / 240)).clamp(0.45, 1.0); @@ -129,8 +132,9 @@ class _ImageViewerScreenState extends State { heroAttributes: PhotoViewHeroAttributes(tag: item.heroTag), minScale: PhotoViewComputedScale.contained, maxScale: PhotoViewComputedScale.covered * 2.6, - errorBuilder: (context, error, stackTrace) => - Center(child: Icon(Icons.broken_image_outlined, color: theme.colorScheme.onSurface, size: 40)), + errorBuilder: (context, error, stackTrace) => Center( + child: Icon(Icons.broken_image_outlined, color: context.colorScheme.onSurface, size: 40), + ), ); }, ), @@ -151,24 +155,20 @@ class _ImageViewerScreenState extends State { ), child: Text( '${_currentIndex + 1} / ${widget.args.images.length}', - style: theme.textTheme.labelLarge?.copyWith(color: Colors.white), + style: context.textTheme.labelLarge?.copyWith(color: Colors.white), ), ), - if ((image.altText?.trim().isNotEmpty ?? false)) ...[ + if (hasAltText) ...[ const SizedBox(height: 12), - Container( - width: double.infinity, + MediaAltTextPanel( + text: altText!, + maxHeightFraction: 0.28, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(16), ), - child: Text( - image.altText!, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white), - ), + textStyle: context.textTheme.bodyMedium?.copyWith(color: Colors.white), ), ], ], diff --git a/lib/features/feed/presentation/media/media_alt_text_panel.dart b/lib/features/feed/presentation/media/media_alt_text_panel.dart new file mode 100644 index 0000000..0ccf2d9 --- /dev/null +++ b/lib/features/feed/presentation/media/media_alt_text_panel.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +/// Scrollable panel used for media alt text in full-screen viewers. +/// +/// Long alt text should remain readable without covering the whole media route, +/// so the panel grows naturally up to [maxHeightFraction] of the viewport and +/// then scrolls within that bounded area. +class MediaAltTextPanel extends StatefulWidget { + const MediaAltTextPanel({ + super.key, + required this.text, + required this.decoration, + this.textStyle, + this.maxHeightFraction = 0.32, + this.padding = const EdgeInsets.all(12), + }) : assert(maxHeightFraction > 0 && maxHeightFraction <= 1); + + final String text; + final Decoration decoration; + final TextStyle? textStyle; + final double maxHeightFraction; + final EdgeInsetsGeometry padding; + + @override + State createState() => _MediaAltTextPanelState(); +} + +class _MediaAltTextPanelState extends State { + late final ScrollController _scrollController; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ConstrainedBox( + constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * widget.maxHeightFraction), + child: Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + decoration: widget.decoration, + child: Scrollbar( + controller: _scrollController, + thumbVisibility: true, + child: SingleChildScrollView( + controller: _scrollController, + padding: widget.padding, + child: Text(widget.text.trim(), style: widget.textStyle), + ), + ), + ), + ); +} diff --git a/lib/features/feed/presentation/media/video_player_screen.dart b/lib/features/feed/presentation/media/video_player_screen.dart index 5dcc45e..a17afbc 100644 --- a/lib/features/feed/presentation/media/video_player_screen.dart +++ b/lib/features/feed/presentation/media/video_player_screen.dart @@ -1,10 +1,12 @@ import 'dart:io'; -import 'package:chewie/chewie.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:chewie/chewie.dart'; import 'package:flutter/material.dart'; import 'package:lazurite/core/cache/lazurite_image_cache.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/feed/presentation/media/media_actions.dart'; +import 'package:lazurite/features/feed/presentation/media/media_alt_text_panel.dart'; import 'package:lazurite/features/feed/presentation/media/video_layout.dart'; import 'package:lazurite/features/feed/presentation/media/video_player_route_args.dart'; import 'package:video_player/video_player.dart'; @@ -52,7 +54,6 @@ class _VideoPlayerScreenState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); final progressValue = _downloadProgress > 0 && _downloadProgress < 1 ? _downloadProgress : null; final altText = widget.args.altText?.trim(); final hasAltText = altText?.isNotEmpty ?? false; @@ -94,7 +95,7 @@ class _VideoPlayerScreenState extends State { height: videoSize.height, child: switch ((_isInitializing, _initializationError, _chewieController)) { (true, _, _) => _buildPlaceholder(showSpinner: true), - (_, final Object error, _) => _buildErrorState(theme, error), + (_, final Object error, _) => _buildErrorState(context, error), (_, _, final ChewieController controller) => Chewie(controller: controller), _ => _buildPlaceholder(), }, @@ -104,20 +105,15 @@ class _VideoPlayerScreenState extends State { ), ), if (hasAltText) - Flexible( - fit: FlexFit.loose, - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(16), - ), - child: Text(altText!, style: theme.textTheme.bodyMedium), - ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: MediaAltTextPanel( + text: altText!, + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), ), + textStyle: context.textTheme.bodyMedium, ), ), ], @@ -127,45 +123,41 @@ class _VideoPlayerScreenState extends State { ); } - Widget _buildPlaceholder({bool showSpinner = false}) { - return Stack( - fit: StackFit.expand, - alignment: Alignment.center, - children: [ - if (widget.args.thumbnailUrl != null) - CachedNetworkImage( - imageUrl: widget.args.thumbnailUrl!, - cacheManager: LazuriteImageCacheManager.instance, - fit: BoxFit.cover, - errorWidget: (_, _, _) => const ColoredBox(color: Colors.black26), - ) - else - const ColoredBox(color: Colors.black26), - if (showSpinner) const Center(child: CircularProgressIndicator()), - if (!showSpinner) - Center( - child: Container( - width: 56, - height: 56, - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.65), shape: BoxShape.circle), - child: const Icon(Icons.play_arrow, color: Colors.white, size: 28), - ), + Widget _buildPlaceholder({bool showSpinner = false}) => Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + if (widget.args.thumbnailUrl != null) + CachedNetworkImage( + imageUrl: widget.args.thumbnailUrl!, + cacheManager: LazuriteImageCacheManager.instance, + fit: BoxFit.cover, + errorWidget: (_, _, _) => const ColoredBox(color: Colors.black26), + ) + else + const ColoredBox(color: Colors.black26), + if (showSpinner) const Center(child: CircularProgressIndicator()), + if (!showSpinner) + Center( + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.65), shape: BoxShape.circle), + child: const Icon(Icons.play_arrow, color: Colors.white, size: 28), ), - ], - ); - } - - Widget _buildErrorState(ThemeData theme, Object error) { - return ColoredBox( - color: theme.colorScheme.surfaceContainerHighest, - child: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text('Failed to load video.\n$error', textAlign: TextAlign.center, style: theme.textTheme.bodyMedium), ), + ], + ); + + Widget _buildErrorState(BuildContext context, Object error) => ColoredBox( + color: context.colorScheme.surfaceContainerHighest, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text('Failed to load video.\n$error', textAlign: TextAlign.center, style: context.textTheme.bodyMedium), ), - ); - } + ), + ); Future _initializePlayer() async { try { diff --git a/test/features/feed/presentation/media/media_alt_text_panel_test.dart b/test/features/feed/presentation/media/media_alt_text_panel_test.dart new file mode 100644 index 0000000..c9d39ec --- /dev/null +++ b/test/features/feed/presentation/media/media_alt_text_panel_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/feed/presentation/media/media_alt_text_panel.dart'; + +void main() { + testWidgets('scrolls long alt text within its bounded height', (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final longAltText = List.generate(40, (index) => 'Alt text line $index').join('\n'); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: Size(320, 400)), + child: Scaffold( + body: Center( + child: MediaAltTextPanel( + text: longAltText, + maxHeightFraction: 0.2, + decoration: BoxDecoration(color: Colors.black, borderRadius: BorderRadius.circular(16)), + textStyle: const TextStyle(color: Colors.white), + ), + ), + ), + ), + ), + ); + + final scrollable = tester.state(find.byType(Scrollable)); + expect(scrollable.position.maxScrollExtent, greaterThan(0)); + expect(tester.getSize(find.byType(MediaAltTextPanel)).height, lessThanOrEqualTo(80)); + + await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -80)); + await tester.pump(); + + expect(scrollable.position.pixels, greaterThan(0)); + }); + + testWidgets('trims surrounding whitespace before rendering alt text', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MediaAltTextPanel( + text: ' A concise description ', + decoration: BoxDecoration(color: Colors.black), + ), + ), + ), + ); + + expect(find.text('A concise description'), findsOneWidget); + expect(find.text(' A concise description '), findsNothing); + }); +} -- 2.51.2