Skip to content

Commit

Permalink
Chore: Refactor image preview for MediaView (#1715)
Browse files Browse the repository at this point in the history
chore: extracted image preview from media view into its own widget
  • Loading branch information
hjiangsu authored Feb 27, 2025
1 parent 87a2ac3 commit 1d42304
Show file tree
Hide file tree
Showing 2 changed files with 181 additions and 75 deletions.
135 changes: 135 additions & 0 deletions lib/shared/image/image_preview.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import 'dart:ui';

import 'package:flutter/material.dart';

import 'package:extended_image/extended_image.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import 'package:thunder/core/enums/image_caching_mode.dart';
import 'package:thunder/core/enums/media_type.dart';
import 'package:thunder/thunder/bloc/thunder_bloc.dart';

/// Displays a preview of an image.
class ImagePreview extends StatefulWidget {
/// The URL of the image to display.
final String url;

/// The width of the image.
final double? width;

/// The height of the image.
final double? height;

/// The box fit of the image.
final BoxFit? fit;

/// The media type that the underlying image represents.
///
/// This value dictates the icon that will be displayed if the image fails to load.
/// If none is provided, a generic error icon will be displayed.
final MediaType? mediaType;

/// Whether the image has been viewed. This will affect the opacity of the image.
final bool? viewed;

/// Whether the image should be blurred.
final bool? blur;

const ImagePreview({
super.key,
required this.url,
this.width,
this.height,
this.fit,
this.mediaType,
this.viewed,
this.blur,
});

@override
State<ImagePreview> createState() => _ImagePreviewState();
}

class _ImagePreviewState extends State<ImagePreview> with SingleTickerProviderStateMixin {
late AnimationController _controller;

@override
void initState() {
super.initState();

_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 130),
lowerBound: 0.0,
upperBound: 1.0,
);
}

@override
void dispose() {
_controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final state = context.read<ThunderBloc>().state;

final devicePixelRatio = View.of(context).devicePixelRatio.ceil();

Widget image = ExtendedImage.network(
widget.url,
height: widget.height,
width: widget.width,
fit: widget.fit,
cache: true,
clearMemoryCacheWhenDispose: state.imageCachingMode == ImageCachingMode.relaxed,
cacheWidth: widget.width != null ? (widget.width! * devicePixelRatio).toInt() : null,
cacheHeight: widget.height != null ? (widget.height! * devicePixelRatio).toInt() : null,
loadStateChanged: (ExtendedImageState state) {
switch (state.extendedImageLoadState) {
case LoadState.loading:
_controller.reset();
return Container();
case LoadState.completed:
if (state.wasSynchronouslyLoaded) return state.completedWidget;

_controller.forward();
return FadeTransition(opacity: _controller, child: state.completedWidget);
case LoadState.failed:
_controller.reset();
state.imageProvider.evict();

return Center(
child: Icon(
_getErrorIcon(widget.mediaType),
color: theme.colorScheme.onSecondaryContainer.withValues(alpha: widget.viewed == true ? 0.55 : 1.0),
),
);
}
},
);

return ImageFiltered(
enabled: widget.blur == true,
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: image,
);
}

IconData _getErrorIcon(MediaType? mediaType) {
switch (mediaType) {
case MediaType.image:
return Icons.image_not_supported_outlined;
case MediaType.video:
return Icons.video_camera_back_outlined;
case MediaType.link:
return Icons.language_rounded;
case MediaType.text:
return Icons.text_fields_rounded;
default:
return Icons.error_outline_rounded;
}
}
}
121 changes: 46 additions & 75 deletions lib/shared/media/media_view.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import 'dart:ui';

import 'package:flutter/material.dart';

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:extended_image/extended_image.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

import 'package:thunder/shared/image/image_preview.dart';
import 'package:thunder/shared/link_information.dart';
import 'package:thunder/shared/media/media_view_text.dart';
import 'package:thunder/utils/colors.dart';
Expand All @@ -17,7 +15,6 @@ import 'package:thunder/post/enums/post_action.dart';
import 'package:thunder/shared/link_preview_card.dart';
import 'package:thunder/thunder/bloc/thunder_bloc.dart';
import 'package:thunder/core/models/post_view_media.dart';
import 'package:thunder/core/enums/image_caching_mode.dart';
import 'package:thunder/utils/media/video.dart';

class MediaView extends StatefulWidget {
Expand Down Expand Up @@ -78,22 +75,18 @@ class MediaView extends StatefulWidget {
}

class _MediaViewState extends State<MediaView> with TickerProviderStateMixin {
late AnimationController _controller;

// Overlay used for image peeking
OverlayEntry? _overlayEntry;
late final AnimationController _overlayAnimationController;

@override
void initState() {
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 130), lowerBound: 0.0, upperBound: 1.0);
_overlayAnimationController = AnimationController(duration: const Duration(milliseconds: 100), vsync: this);
super.initState();
}

@override
void dispose() {
_controller.dispose();
_overlayAnimationController.dispose();
super.dispose();
}
Expand Down Expand Up @@ -158,10 +151,25 @@ class _MediaViewState extends State<MediaView> with TickerProviderStateMixin {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;

final state = context.read<ThunderBloc>().state;

// TODO: If this site has a content warning, we don't need to blur previews.
// (This can be implemented once the web UI does the same.)
final blurNSFWPreviews = widget.hideNsfwPreviews && widget.postViewMedia.postView.post.nsfw;

double? width;
double? height;

switch (widget.viewMode) {
case ViewMode.compact:
width = null; // Setting this to null will use the image's width. This will allow the image to not be stretched or squished.
height = ViewMode.compact.height;
break;
case ViewMode.comfortable:
width = (state.tabletMode ? (MediaQuery.of(context).size.width / 2) - 24.0 : MediaQuery.of(context).size.width) - (widget.edgeToEdgeImages ? 0 : 24);
height = widget.showFullHeightImages ? widget.postViewMedia.media.first.height : null;
}

return Stack(
children: [
Container(
Expand Down Expand Up @@ -191,10 +199,14 @@ class _MediaViewState extends State<MediaView> with TickerProviderStateMixin {
fit: widget.allowUnconstrainedImageHeight ? StackFit.loose : StackFit.expand,
alignment: Alignment.center,
children: [
ImageFiltered(
enabled: blurNSFWPreviews,
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: previewImage(context),
ImagePreview(
url: widget.postViewMedia.media.first.thumbnailUrl ?? widget.postViewMedia.media.first.originalUrl!,
width: width,
height: height,
fit: widget.viewMode == ViewMode.compact ? BoxFit.cover : BoxFit.fitWidth,
mediaType: widget.postViewMedia.media.first.mediaType,
viewed: widget.read,
blur: blurNSFWPreviews,
),
if (blurNSFWPreviews)
Column(
Expand Down Expand Up @@ -250,11 +262,25 @@ class _MediaViewState extends State<MediaView> with TickerProviderStateMixin {
Widget buildMediaVideo() {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
final state = context.read<ThunderBloc>().state;

// TODO: If this site has a content warning, we don't need to blur previews.
// (This can be implemented once the web UI does the same.)
final blurNSFWPreviews = widget.hideNsfwPreviews && widget.postViewMedia.postView.post.nsfw;

double? width;
double? height;

switch (widget.viewMode) {
case ViewMode.compact:
width = null; // Setting this to null will use the image's width. This will allow the image to not be stretched or squished.
height = ViewMode.compact.height;
break;
case ViewMode.comfortable:
width = (state.tabletMode ? (MediaQuery.of(context).size.width / 2) - 24.0 : MediaQuery.of(context).size.width) - (widget.edgeToEdgeImages ? 0 : 24);
height = widget.showFullHeightImages ? widget.postViewMedia.media.first.height : null;
}

return InkWell(
splashColor: theme.colorScheme.primary.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular((widget.edgeToEdgeImages ? 0 : 12)),
Expand Down Expand Up @@ -299,10 +325,14 @@ class _MediaViewState extends State<MediaView> with TickerProviderStateMixin {
color: theme.colorScheme.onSecondaryContainer.withValues(alpha: widget.read == true ? 0.55 : 1.0),
),
if (widget.postViewMedia.media.first.thumbnailUrl != null)
ImageFiltered(
enabled: blurNSFWPreviews,
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: previewImage(context),
ImagePreview(
url: widget.postViewMedia.media.first.thumbnailUrl ?? widget.postViewMedia.media.first.originalUrl!,
width: width,
height: height,
fit: widget.viewMode == ViewMode.compact ? BoxFit.cover : BoxFit.fitWidth,
mediaType: widget.postViewMedia.media.first.mediaType,
viewed: widget.read,
blur: blurNSFWPreviews,
),
if (widget.postViewMedia.postView.post.nsfw)
Column(
Expand Down Expand Up @@ -384,63 +414,4 @@ class _MediaViewState extends State<MediaView> with TickerProviderStateMixin {
return Container();
}
}

Widget previewImage(BuildContext context) {
final theme = Theme.of(context);
final state = context.read<ThunderBloc>().state;

double? width;
double? height;

switch (widget.viewMode) {
case ViewMode.compact:
width = null; // Setting this to null will use the image's width. This will allow the image to not be stretched or squished.
height = ViewMode.compact.height;
break;
case ViewMode.comfortable:
width = (state.tabletMode ? (MediaQuery.of(context).size.width / 2) - 24.0 : MediaQuery.of(context).size.width) - (widget.edgeToEdgeImages ? 0 : 24);
height = widget.showFullHeightImages ? widget.postViewMedia.media.first.height : null;
}

return ExtendedImage.network(
color: widget.read == true ? const Color.fromRGBO(255, 255, 255, 0.5) : null,
colorBlendMode: widget.read == true ? BlendMode.modulate : null,
widget.postViewMedia.media.first.thumbnailUrl ?? widget.postViewMedia.media.first.originalUrl!,
height: height,
width: width,
fit: widget.viewMode == ViewMode.compact ? BoxFit.cover : BoxFit.fitWidth,
cache: true,
clearMemoryCacheWhenDispose: state.imageCachingMode == ImageCachingMode.relaxed,
cacheWidth: width != null ? (width * View.of(context).devicePixelRatio.ceil()).toInt() : null,
cacheHeight: height != null ? (height * View.of(context).devicePixelRatio.ceil()).toInt() : null,
loadStateChanged: (ExtendedImageState state) {
switch (state.extendedImageLoadState) {
case LoadState.loading:
_controller.reset();
return Container();
case LoadState.completed:
if (state.wasSynchronouslyLoaded) return state.completedWidget;

_controller.forward();
return FadeTransition(opacity: _controller, child: state.completedWidget);
case LoadState.failed:
_controller.reset();
state.imageProvider.evict();

return widget.postViewMedia.postView.post.nsfw
? Container()
: Icon(
switch (widget.postViewMedia.media.first.mediaType) {
MediaType.image => Icons.image_not_supported_outlined,
MediaType.video => Icons.video_camera_back_outlined,
MediaType.link => Icons.language_rounded,
// Should never come here
MediaType.text => Icons.text_fields_rounded,
},
color: theme.colorScheme.onSecondaryContainer.withValues(alpha: widget.read == true ? 0.55 : 1.0),
);
}
},
);
}
}

0 comments on commit 1d42304

Please sign in to comment.