From 13464e1bb9f87dcbd12a29c7ec8c1df4602d7f85 Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Sat, 24 Aug 2024 12:33:47 -0400 Subject: [PATCH] Add support for image alt text (#1497) * Display image alt text * FIx localization style in image viewer --- lib/core/models/media.dart | 3 + lib/post/utils/post.dart | 2 + lib/shared/advanced_share_sheet.dart | 1 + lib/shared/common_markdown_body.dart | 1 + lib/shared/image_preview.dart | 3 + lib/shared/image_viewer.dart | 139 ++++++++++++++++++++++++++- lib/shared/media_view.dart | 1 + lib/utils/media/image.dart | 3 +- 8 files changed, 151 insertions(+), 2 deletions(-) diff --git a/lib/core/models/media.dart b/lib/core/models/media.dart index ac1a4bfd3..edbdd3951 100644 --- a/lib/core/models/media.dart +++ b/lib/core/models/media.dart @@ -30,6 +30,9 @@ class Media { /// Indicates the type of media it holds MediaType mediaType; + /// Includes an alternative text-based description of the image + String? altText; + /// Gets the full-size image URL, if any String? get imageUrl => isImageUrl(mediaUrl ?? '') ? mediaUrl : thumbnailUrl; diff --git a/lib/post/utils/post.dart b/lib/post/utils/post.dart index 642ac8078..e4b51672f 100644 --- a/lib/post/utils/post.dart +++ b/lib/post/utils/post.dart @@ -385,6 +385,8 @@ Future parsePostView(PostView postView, bool fetchImageDimensions media.height = size.height; } + media.altText = postView.post.altText; + mediaList.add(media); return PostViewMedia(postView: postView, media: mediaList); diff --git a/lib/shared/advanced_share_sheet.dart b/lib/shared/advanced_share_sheet.dart index ac9698b28..6f5cafa65 100644 --- a/lib/shared/advanced_share_sheet.dart +++ b/lib/shared/advanced_share_sheet.dart @@ -197,6 +197,7 @@ void showAdvancedShareSheet(BuildContext context, PostViewMedia postViewMedia) a isExpandable: true, isComment: true, showFullHeightImages: true, + altText: postViewMedia.media.first.altText, ), if (_isImageCustomized(options, postViewMedia)) snapshot.hasData && !isGeneratingImage diff --git a/lib/shared/common_markdown_body.dart b/lib/shared/common_markdown_body.dart index 1adb25144..25e0ee6ca 100644 --- a/lib/shared/common_markdown_body.dart +++ b/lib/shared/common_markdown_body.dart @@ -135,6 +135,7 @@ class CommonMarkdownBody extends StatelessWidget { isComment: isComment, showFullHeightImages: true, maxWidth: imageMaxWidth, + altText: alt, ) : Container( constraints: isComment == true diff --git a/lib/shared/image_preview.dart b/lib/shared/image_preview.dart index 2df28a925..aa71ce5ae 100644 --- a/lib/shared/image_preview.dart +++ b/lib/shared/image_preview.dart @@ -26,6 +26,7 @@ class ImagePreview extends StatefulWidget { final void Function()? navigateToPost; final bool? isComment; final bool? read; + final String? altText; const ImagePreview({ super.key, @@ -42,6 +43,7 @@ class ImagePreview extends StatefulWidget { this.navigateToPost, this.isComment, this.read, + this.altText, }) : assert(url != null || bytes != null); @override @@ -75,6 +77,7 @@ class _ImagePreviewState extends State { bytes: widget.bytes, postId: widget.postId, navigateToPost: widget.navigateToPost, + altText: widget.altText, ); } }, diff --git a/lib/shared/image_viewer.dart b/lib/shared/image_viewer.dart index 0b86f891b..af62e65c3 100644 --- a/lib/shared/image_viewer.dart +++ b/lib/shared/image_viewer.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:math'; +import 'package:expandable/expandable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -28,6 +29,7 @@ class ImageViewer extends StatefulWidget { final Uint8List? bytes; final int? postId; final void Function()? navigateToPost; + final String? altText; const ImageViewer({ super.key, @@ -35,6 +37,7 @@ class ImageViewer extends StatefulWidget { this.bytes, this.postId, this.navigateToPost, + this.altText, }) : assert(url != null || bytes != null); get postViewMedia => null; @@ -146,6 +149,7 @@ class _ImageViewerState extends State with TickerProviderStateMixin @override Widget build(BuildContext context) { final ThunderState thunderState = context.read().state; + final AppLocalizations l10n = AppLocalizations.of(context)!; AnimationController animationController = AnimationController(duration: const Duration(milliseconds: 140), vsync: this); Function() animationListener = () {}; @@ -414,7 +418,7 @@ class _ImageViewerState extends State with TickerProviderStateMixin await Share.shareXFiles([XFile(mediaFile!.path)]); } catch (e) { // Tell the user that the download failed - showSnackbar(AppLocalizations.of(context)!.errorDownloadingMedia(e)); + showSnackbar(l10n.errorDownloadingMedia(e)); } finally { setState(() => isDownloadingMedia = false); } @@ -522,7 +526,140 @@ class _ImageViewerState extends State with TickerProviderStateMixin ], ), ), + if (widget.altText?.isNotEmpty == true) + Positioned( + bottom: kBottomNavigationBarHeight + 25, + width: MediaQuery.sizeOf(context).width, + child: AnimatedOpacity( + opacity: fullscreen ? 0.0 : 1.0, + duration: const Duration(milliseconds: 200), + child: Padding( + padding: const EdgeInsets.all(16), + child: ImageAltTextWrapper(altText: widget.altText!), + ), + ), + ), ], ); } } + +class ImageAltTextWrapper extends StatefulWidget { + final String altText; + + const ImageAltTextWrapper({super.key, required this.altText}); + + @override + State createState() => _ImageAltTextWrapperState(); +} + +class _ImageAltTextWrapperState extends State { + final GlobalKey textKey = GlobalKey(); + bool altTextIsLong = false; + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + altTextIsLong = (textKey.currentContext?.size?.height ?? 0) > 40; + }); + }); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppLocalizations l10n = AppLocalizations.of(context)!; + + return AnimatedCrossFade( + crossFadeState: altTextIsLong ? CrossFadeState.showSecond : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 250), + firstChild: ImageAltText(key: textKey, altText: widget.altText), + secondChild: ExpandableNotifier( + child: Expandable( + expanded: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ImageAltText(altText: widget.altText), + ExpandableButton( + theme: const ExpandableThemeData(useInkWell: false), + child: Text( + l10n.showLess, + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.white.withOpacity(0.5), + ), + ), + ), + ], + ), + collapsed: Stack( + children: [ + LimitedBox( + maxHeight: 60, + child: ShaderMask( + shaderCallback: (bounds) { + return const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black, + Colors.transparent, + Colors.transparent, + ], + stops: [0.0, 0.8, 1.0], + ).createShader(bounds); + }, + blendMode: BlendMode.dstIn, + child: ImageAltText(altText: widget.altText), + ), + ), + Positioned( + bottom: 0, + child: ExpandableButton( + theme: const ExpandableThemeData(useInkWell: false), + child: Text( + l10n.showMore, + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.white.withOpacity(0.5), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class ImageAltText extends StatelessWidget { + final String altText; + + const ImageAltText({ + super.key, + required this.altText, + }); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + + return Text( + key: key, + altText, + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.white.withOpacity(0.90), + shadows: [ + Shadow( + offset: const Offset(1, 1), + color: Colors.black.withOpacity(1), + blurRadius: 5.0, + ) + ], + ), + ); + } +} diff --git a/lib/shared/media_view.dart b/lib/shared/media_view.dart index ce6e59354..3ac714f04 100644 --- a/lib/shared/media_view.dart +++ b/lib/shared/media_view.dart @@ -166,6 +166,7 @@ class _MediaViewState extends State with SingleTickerProviderStateMix url: widget.postViewMedia.media.first.imageUrl, postId: widget.postViewMedia.postView.post.id, navigateToPost: widget.navigateToPost, + altText: widget.postViewMedia.media.first.altText, ); }, ), diff --git a/lib/utils/media/image.dart b/lib/utils/media/image.dart index fdbd6ee52..2cc31bde7 100644 --- a/lib/utils/media/image.dart +++ b/lib/utils/media/image.dart @@ -128,7 +128,7 @@ Future> selectImagesToUpload({bool allowMultiple = false}) async { return [file!.path]; } -void showImageViewer(BuildContext context, {String? url, Uint8List? bytes, int? postId, void Function()? navigateToPost}) { +void showImageViewer(BuildContext context, {String? url, Uint8List? bytes, int? postId, void Function()? navigateToPost, String? altText}) { Navigator.of(context).push( PageRouteBuilder( opaque: false, @@ -140,6 +140,7 @@ void showImageViewer(BuildContext context, {String? url, Uint8List? bytes, int? bytes: bytes, postId: postId, navigateToPost: navigateToPost, + altText: altText, ); }, transitionsBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) {