From 8181fb280f67d181d06ecb27888043ff4aa92ce1 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Tue, 8 Jul 2025 16:02:23 +0300 Subject: [PATCH] image thumbnail refactor --- .../lib/extensions/duration_extensions.dart | 12 + mobile/lib/utils/storage_indicator.dart | 9 - .../widgets/asset_grid/thumbnail_image.dart | 428 +++++++++++------- .../asset_viewer/formatted_duration.dart | 14 +- 4 files changed, 273 insertions(+), 190 deletions(-) delete mode 100644 mobile/lib/utils/storage_indicator.dart diff --git a/mobile/lib/extensions/duration_extensions.dart b/mobile/lib/extensions/duration_extensions.dart index ca5ba8310c..492627a727 100644 --- a/mobile/lib/extensions/duration_extensions.dart +++ b/mobile/lib/extensions/duration_extensions.dart @@ -3,3 +3,15 @@ extension TZOffsetExtension on Duration { String formatAsOffset() => "${isNegative ? '-' : '+'}${inHours.abs().toString().padLeft(2, '0')}:${inMinutes.abs().remainder(60).toString().padLeft(2, '0')}"; } + +extension DurationFormatExtension on Duration { + String format() { + final seconds = inSeconds.remainder(60).toString().padLeft(2, '0'); + final minutes = inMinutes.remainder(60).toString().padLeft(2, '0'); + if (inHours == 0) { + return "$minutes:$seconds"; + } + final hours = inHours.toString().padLeft(2, '0'); + return "$hours:$minutes:$seconds"; + } +} diff --git a/mobile/lib/utils/storage_indicator.dart b/mobile/lib/utils/storage_indicator.dart deleted file mode 100644 index a7dad063ca..0000000000 --- a/mobile/lib/utils/storage_indicator.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; - -/// Returns the suitable [IconData] to represent an [Asset]s storage location -IconData storageIcon(Asset asset) => switch (asset.storage) { - AssetState.local => Icons.cloud_off_outlined, - AssetState.remote => Icons.cloud_outlined, - AssetState.merged => Icons.cloud_done_outlined, - }; diff --git a/mobile/lib/widgets/asset_grid/thumbnail_image.dart b/mobile/lib/widgets/asset_grid/thumbnail_image.dart index 25f65b448c..80e6460baf 100644 --- a/mobile/lib/widgets/asset_grid/thumbnail_image.dart +++ b/mobile/lib/widgets/asset_grid/thumbnail_image.dart @@ -1,13 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; -import 'package:immich_mobile/utils/storage_indicator.dart'; -class ThumbnailImage extends ConsumerWidget { +class ThumbnailImage extends StatelessWidget { /// The asset to show the thumbnail image for final Asset asset; @@ -41,144 +40,10 @@ class ThumbnailImage extends ConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final assetContainerColor = context.isDarkTheme ? context.primaryColor.darken(amount: 0.6) : context.primaryColor.lighten(amount: 0.8); - // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id - final isFromDto = asset.id == noDbId; - - Widget buildSelectionIcon() { - if (isSelected) { - return Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: assetContainerColor, - ), - child: Icon( - Icons.check_circle_rounded, - color: context.primaryColor, - ), - ); - } else { - return const Icon( - Icons.circle_outlined, - color: Colors.white, - ); - } - } - - Widget buildVideoIcon() { - final minutes = asset.duration.inMinutes; - final durationString = asset.duration.toString(); - return Positioned( - top: 5, - right: 8, - child: Row( - children: [ - Text( - minutes > 59 - ? durationString.substring(0, 7) // h:mm:ss - : minutes > 0 - ? durationString.substring(2, 7) // mm:ss - : durationString.substring(3, 7), // m:ss - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox( - width: 3, - ), - const Icon( - Icons.play_circle_fill_rounded, - color: Colors.white, - size: 18, - ), - ], - ), - ); - } - - Widget buildStackIcon() { - return Positioned( - top: !asset.isImage ? 28 : 5, - right: 8, - child: Row( - children: [ - if (asset.stackCount > 1) - Text( - "${asset.stackCount}", - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - if (asset.stackCount > 1) - const SizedBox( - width: 3, - ), - const Icon( - Icons.burst_mode_rounded, - color: Colors.white, - size: 18, - ), - ], - ), - ); - } - - Widget buildImage() { - final image = SizedBox.expand( - child: Hero( - tag: isFromDto - ? '${asset.remoteId}-$heroOffset' - : asset.id + heroOffset, - child: Stack( - children: [ - SizedBox.expand( - child: ImmichThumbnail( - asset: asset, - height: 250, - width: 250, - ), - ), - Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [ - Color.fromRGBO(0, 0, 0, 0.1), - Colors.transparent, - Colors.transparent, - Color.fromRGBO(0, 0, 0, 0.1), - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - stops: [0, 0.3, 0.6, 1], - ), - ), - ), - ], - ), - ), - ); - if (!multiselectEnabled || !isSelected) { - return image; - } - return Container( - decoration: BoxDecoration( - color: canDeselect ? assetContainerColor : Colors.grey, - ), - child: ClipRRect( - borderRadius: const BorderRadius.all( - Radius.circular(15.0), - ), - child: image, - ), - ); - } return Stack( children: [ @@ -187,32 +52,30 @@ class ThumbnailImage extends ConsumerWidget { curve: Curves.decelerate, decoration: BoxDecoration( border: multiselectEnabled && isSelected - ? Border.all( - color: canDeselect ? assetContainerColor : Colors.grey, - width: 8, - ) + ? canDeselect + ? Border.all( + color: assetContainerColor, + width: 8, + ) + : const Border( + top: BorderSide(color: Colors.grey, width: 8), + right: BorderSide(color: Colors.grey, width: 8), + bottom: BorderSide(color: Colors.grey, width: 8), + left: BorderSide(color: Colors.grey, width: 8), + ) : const Border(), ), child: Stack( children: [ - buildImage(), - if (showStorageIndicator) - Positioned( - right: 8, - bottom: 5, - child: Icon( - storageIcon(asset), - color: Colors.white.withValues(alpha: .8), - size: 16, - shadows: [ - Shadow( - blurRadius: 5.0, - color: Colors.black.withValues(alpha: 0.6), - offset: const Offset(0.0, 0.0), - ), - ], - ), - ), + ImageIcon( + heroOffset: heroOffset, + asset: asset, + assetContainerColor: assetContainerColor, + multiselectEnabled: multiselectEnabled, + canDeselect: canDeselect, + isSelected: isSelected, + ), + if (showStorageIndicator) StorageIcon(storage: asset.storage), if (asset.isFavorite) const Positioned( left: 8, @@ -223,20 +86,247 @@ class ThumbnailImage extends ConsumerWidget { size: 16, ), ), - if (!asset.isImage) buildVideoIcon(), - if (asset.stackCount > 0) buildStackIcon(), + if (asset.isVideo) VideoIcon(duration: asset.duration), + if (asset.stackCount > 0) + StackIcon(isVideo: asset.isVideo, stackCount: asset.stackCount), ], ), ), if (multiselectEnabled) - Padding( - padding: const EdgeInsets.all(3.0), - child: Align( - alignment: Alignment.topLeft, - child: buildSelectionIcon(), - ), - ), + isSelected + ? const Padding( + padding: EdgeInsets.all(3.0), + child: Align( + alignment: Alignment.topLeft, + child: SelectedIcon(), + ), + ) + : const Icon( + Icons.circle_outlined, + color: Colors.white, + ), ], ); } } + +class SelectedIcon extends StatelessWidget { + const SelectedIcon({super.key}); + + @override + Widget build(BuildContext context) { + final assetContainerColor = context.isDarkTheme + ? context.primaryColor.darken(amount: 0.6) + : context.primaryColor.lighten(amount: 0.8); + + return DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: assetContainerColor, + ), + child: Icon( + Icons.check_circle_rounded, + color: context.primaryColor, + ), + ); + } +} + +class VideoIcon extends StatelessWidget { + final Duration duration; + + const VideoIcon({super.key, required this.duration}); + + @override + Widget build(BuildContext context) { + return Positioned( + top: 5, + right: 8, + child: Row( + children: [ + Text( + duration.format(), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 3), + const Icon( + Icons.play_circle_fill_rounded, + color: Colors.white, + size: 18, + ), + ], + ), + ); + } +} + +class StackIcon extends StatelessWidget { + final bool isVideo; + final int stackCount; + + const StackIcon({super.key, required this.isVideo, required this.stackCount}); + + @override + Widget build(BuildContext context) { + return Positioned( + top: isVideo ? 28 : 5, + right: 8, + child: Row( + children: [ + if (stackCount > 1) + Text( + "$stackCount", + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + if (stackCount > 1) + const SizedBox( + width: 3, + ), + const Icon( + Icons.burst_mode_rounded, + color: Colors.white, + size: 18, + ), + ], + ), + ); + } +} + +class StorageIcon extends StatelessWidget { + final AssetState storage; + + const StorageIcon({super.key, required this.storage}); + + @override + Widget build(BuildContext context) { + return switch (storage) { + AssetState.local => const Positioned( + right: 8, + bottom: 5, + child: Icon( + Icons.cloud_off_outlined, + color: Color.fromRGBO(255, 255, 255, 0.8), + size: 16, + shadows: [ + Shadow( + blurRadius: 5.0, + color: Color.fromRGBO(0, 0, 0, 0.6), + offset: Offset(0.0, 0.0), + ), + ], + ), + ), + AssetState.remote => const Positioned( + right: 8, + bottom: 5, + child: Icon( + Icons.cloud_outlined, + color: Color.fromRGBO(255, 255, 255, 0.8), + size: 16, + shadows: [ + Shadow( + blurRadius: 5.0, + color: Color.fromRGBO(0, 0, 0, 0.6), + offset: Offset(0.0, 0.0), + ), + ], + ), + ), + AssetState.merged => const Positioned( + right: 8, + bottom: 5, + child: Icon( + Icons.cloud_done_outlined, + color: Color.fromRGBO(255, 255, 255, 0.8), + size: 16, + shadows: [ + Shadow( + blurRadius: 5.0, + color: Color.fromRGBO(0, 0, 0, 0.6), + offset: Offset(0.0, 0.0), + ), + ], + ), + ), + }; + } +} + +class ImageIcon extends StatelessWidget { + final int heroOffset; + final Asset asset; + final Color assetContainerColor; + final bool multiselectEnabled; + final bool canDeselect; + final bool isSelected; + + const ImageIcon({ + super.key, + required this.heroOffset, + required this.asset, + required this.assetContainerColor, + required this.multiselectEnabled, + required this.canDeselect, + required this.isSelected, + }); + + @override + Widget build(BuildContext context) { + // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id + final isDto = asset.id == noDbId; + final image = SizedBox.expand( + child: Hero( + tag: isDto ? '${asset.remoteId}-$heroOffset' : asset.id + heroOffset, + child: Stack( + children: [ + SizedBox.expand( + child: ImmichThumbnail( + asset: asset, + height: 250, + width: 250, + ), + ), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Color.fromRGBO(0, 0, 0, 0.1), + Colors.transparent, + Colors.transparent, + Color.fromRGBO(0, 0, 0, 0.1), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0, 0.3, 0.6, 1], + ), + ), + ), + ], + ), + ), + ); + + if (!multiselectEnabled || !isSelected) { + return image; + } + + return DecoratedBox( + decoration: canDeselect + ? BoxDecoration(color: assetContainerColor) + : const BoxDecoration(color: Colors.grey), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(15.0)), + child: image, + ), + ); + } +} diff --git a/mobile/lib/widgets/asset_viewer/formatted_duration.dart b/mobile/lib/widgets/asset_viewer/formatted_duration.dart index a34aab7d12..d18dc92575 100644 --- a/mobile/lib/widgets/asset_viewer/formatted_duration.dart +++ b/mobile/lib/widgets/asset_viewer/formatted_duration.dart @@ -1,15 +1,5 @@ import 'package:flutter/material.dart'; - -@pragma('vm:prefer-inline') -String _formatDuration(Duration position) { - final seconds = position.inSeconds.remainder(60).toString().padLeft(2, "0"); - final minutes = position.inMinutes.remainder(60).toString().padLeft(2, "0"); - if (position.inHours == 0) { - return "$minutes:$seconds"; - } - final hours = position.inHours.toString().padLeft(2, '0'); - return "$hours:$minutes:$seconds"; -} +import 'package:immich_mobile/extensions/duration_extensions.dart'; class FormattedDuration extends StatelessWidget { final Duration data; @@ -20,7 +10,7 @@ class FormattedDuration extends StatelessWidget { return SizedBox( width: data.inHours > 0 ? 70 : 60, // use a fixed width to prevent jitter child: Text( - _formatDuration(data), + data.format(), style: const TextStyle( fontSize: 14.0, color: Colors.white,