2025-07-11 01:55:18 +05:30
import ' package:auto_route/auto_route.dart ' ;
2025-06-16 20:37:45 +05:30
import ' package:flutter/material.dart ' ;
2025-06-24 02:05:25 -05:00
import ' package:hooks_riverpod/hooks_riverpod.dart ' ;
2025-06-16 20:37:45 +05:30
import ' package:immich_mobile/domain/models/asset/base_asset.model.dart ' ;
2025-06-24 02:05:25 -05:00
import ' package:immich_mobile/extensions/build_context_extensions.dart ' ;
2025-07-09 22:16:09 +03:00
import ' package:immich_mobile/extensions/duration_extensions.dart ' ;
2025-06-24 02:05:25 -05:00
import ' package:immich_mobile/extensions/theme_extensions.dart ' ;
2025-06-16 20:37:45 +05:30
import ' package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart ' ;
2025-08-21 14:06:02 -04:00
import ' package:immich_mobile/presentation/widgets/timeline/constants.dart ' ;
2026-05-13 02:53:25 +07:00
import ' package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart ' ;
2026-01-15 20:10:08 -06:00
import ' package:immich_mobile/providers/backup/asset_upload_progress.provider.dart ' ;
2026-05-30 20:57:55 +05:30
import ' package:immich_mobile/providers/infrastructure/settings.provider.dart ' ;
2025-06-24 02:05:25 -05:00
import ' package:immich_mobile/providers/timeline/multiselect.provider.dart ' ;
2025-06-16 20:37:45 +05:30
2026-01-14 08:40:24 -08:00
class ThumbnailTile extends ConsumerStatefulWidget {
2025-06-16 20:37:45 +05:30
const ThumbnailTile (
this . asset , {
2025-08-21 14:06:02 -04:00
this . size = kThumbnailResolution ,
2026-08-10 21:14:23 +06:00
this . remoteSize ,
2025-06-16 20:37:45 +05:30
this . fit = BoxFit . cover ,
2025-10-03 19:37:16 +05:30
this . showStorageIndicator = false ,
2025-07-07 23:41:37 -05:00
this . lockSelection = false ,
2025-07-26 19:29:26 +05:30
this . heroOffset ,
2026-04-29 13:49:47 +03:00
this . showStackIndicator = false ,
2025-06-16 20:37:45 +05:30
super . key ,
} ) ;
2025-08-21 14:06:02 -04:00
final BaseAsset ? asset ;
2025-06-16 20:37:45 +05:30
final Size size ;
2026-08-10 21:14:23 +06:00
/// Physical size to decode for remote thumbnails.
final Size ? remoteSize ;
2025-06-16 20:37:45 +05:30
final BoxFit fit ;
2025-10-03 19:37:16 +05:30
final bool showStorageIndicator ;
2025-07-07 23:41:37 -05:00
final bool lockSelection ;
2025-07-26 19:29:26 +05:30
final int ? heroOffset ;
2026-04-29 13:49:47 +03:00
final bool showStackIndicator ;
2025-06-24 02:05:25 -05:00
2025-06-16 20:37:45 +05:30
@ override
2026-01-14 08:40:24 -08:00
ConsumerState < ThumbnailTile > createState ( ) = > _ThumbnailTileState ( ) ;
}
class _ThumbnailTileState extends ConsumerState < ThumbnailTile > {
bool _hideIndicators = false ;
bool _showSelectionContainer = false ;
@ override
void dispose ( ) {
super . dispose ( ) ;
}
@ override
Widget build ( BuildContext context ) {
final asset = widget . asset ;
final heroIndex = widget . heroOffset ? ? TabsRouterScope . of ( context ) ? . controller . activeIndex ? ? 0 ;
2026-01-20 12:02:54 -08:00
final isCurrentAsset = ref . watch ( assetViewerProvider . select ( ( current ) = > current . currentAsset = = asset ) ) ;
2025-07-11 01:55:18 +05:30
2025-07-29 00:34:03 +05:30
final assetContainerColor = context . isDarkTheme
? context . primaryColor . darken ( amount: 0.4 )
: context . primaryColor . lighten ( amount: 0.75 ) ;
2025-06-24 02:05:25 -05:00
2025-07-01 08:10:25 +05:30
final isSelected = ref . watch (
2025-07-29 00:34:03 +05:30
multiSelectProvider . select ( ( multiselect ) = > multiselect . selectedAssets . contains ( asset ) ) ,
2025-07-01 08:10:25 +05:30
) ;
2025-06-24 02:05:25 -05:00
2025-08-04 17:25:58 -05:00
final bool storageIndicator =
2026-05-13 02:53:25 +07:00
ref . watch ( appConfigProvider . select ( ( s ) = > s . timeline . storageIndicator ) ) & & widget . showStorageIndicator ;
2026-01-14 08:40:24 -08:00
2026-01-20 12:02:54 -08:00
if ( ! isCurrentAsset ) {
_hideIndicators = false ;
}
2026-01-14 08:40:24 -08:00
if ( isSelected ) {
_showSelectionContainer = true ;
}
2025-08-04 17:25:58 -05:00
2026-01-15 20:10:08 -06:00
final uploadProgress = asset is LocalAsset
? ref . watch ( assetUploadProgressProvider . select ( ( map ) = > map [ asset . id ] ) )
: null ;
2025-06-16 20:37:45 +05:30
return Stack (
children: [
2026-01-14 08:40:24 -08:00
Container (
color: widget . lockSelection
? context . colorScheme . surfaceContainerHighest
: _showSelectionContainer
? assetContainerColor
: Colors . transparent ,
) ,
2025-06-24 02:05:25 -05:00
AnimatedContainer (
duration: Durations . short4 ,
curve: Curves . decelerate ,
2026-01-14 08:40:24 -08:00
onEnd: ( ) {
if ( ! isSelected ) {
_showSelectionContainer = false ;
}
} ,
padding: EdgeInsets . all ( isSelected | | widget . lockSelection ? 6 : 0 ) ,
2025-10-24 05:36:49 +02:00
child: TweenAnimationBuilder < double > (
2026-01-14 08:40:24 -08:00
tween: Tween < double > ( begin: 0.0 , end: ( isSelected | | widget . lockSelection ) ? 15.0 : 0.0 ) ,
2025-10-24 05:36:49 +02:00
duration: Durations . short4 ,
curve: Curves . decelerate ,
builder: ( context , value , child ) {
return ClipRRect ( borderRadius: BorderRadius . all ( Radius . circular ( value ) ) , child: child ) ;
} ,
2025-06-24 02:05:25 -05:00
child: Stack (
children: [
Positioned . fill (
2025-07-02 23:54:37 +05:30
child: Hero (
2026-01-20 12:02:54 -08:00
// This key resets the hero animation when the asset is changed in the asset viewer.
// It doesn't seem like the best solution, and only works to reset the hero, not prime the hero of the new active asset for animation,
// but other solutions have failed thus far.
key: ValueKey ( isCurrentAsset ) ,
tag: ' ${ asset ? . heroTag } _ $ heroIndex ' ,
2026-08-10 21:14:23 +06:00
child: Thumbnail . fromAsset ( asset: asset , size: widget . size , remoteSize: widget . remoteSize ) ,
2026-01-14 08:40:24 -08:00
// Placeholderbuilder used to hide indicators on first hero animation, since flightShuttleBuilder isn't called until both source and destination hero exist in widget tree.
placeholderBuilder: ( context , heroSize , child ) {
if ( ! _hideIndicators ) {
WidgetsBinding . instance . addPostFrameCallback ( ( _ ) {
setState ( ( ) = > _hideIndicators = true ) ;
} ) ;
}
return const SizedBox ( ) ;
} ,
flightShuttleBuilder: ( context , animation , direction , from , to ) {
void animationStatusListener ( AnimationStatus status ) {
2026-05-16 03:41:04 +06:00
if ( ! mounted ) {
return ;
}
2026-01-14 08:40:24 -08:00
final heroInFlight = status = = AnimationStatus . forward | | status = = AnimationStatus . reverse ;
if ( _hideIndicators ! = heroInFlight ) {
setState ( ( ) = > _hideIndicators = heroInFlight ) ;
}
if ( status = = AnimationStatus . completed | | status = = AnimationStatus . dismissed ) {
animation . removeStatusListener ( animationStatusListener ) ;
}
}
animation . addStatusListener ( animationStatusListener ) ;
2026-04-21 22:54:40 +02:00
return direction = = HeroFlightDirection . push ? from . widget : to . widget ;
2026-01-14 08:40:24 -08:00
} ,
2025-06-24 02:05:25 -05:00
) ,
) ,
2025-09-27 21:27:34 -05:00
if ( asset ! = null )
2026-01-14 08:40:24 -08:00
AnimatedOpacity (
opacity: _hideIndicators ? 0.0 : 1.0 ,
duration: Durations . short4 ,
child: Align (
alignment: Alignment . topRight ,
2026-04-29 13:49:47 +03:00
child: Column (
mainAxisSize: MainAxisSize . min ,
crossAxisAlignment: CrossAxisAlignment . end ,
children: [
_AssetTypeIcons ( asset: asset ) ,
if ( widget . showStackIndicator ) _StackIndicator ( asset: asset ) ,
] ,
) ,
2026-01-14 08:40:24 -08:00
) ,
2025-06-24 02:05:25 -05:00
) ,
2025-08-21 14:06:02 -04:00
if ( storageIndicator & & asset ! = null )
2026-01-14 08:40:24 -08:00
AnimatedOpacity (
opacity: _hideIndicators ? 0.0 : 1.0 ,
duration: Durations . short4 ,
child: switch ( asset . storage ) {
AssetState . local = > const Align (
alignment: Alignment . bottomRight ,
child: Padding (
padding: EdgeInsets . only ( right: 10.0 , bottom: 6.0 ) ,
child: _TileOverlayIcon ( Icons . cloud_off_outlined ) ,
) ,
2025-06-24 02:05:25 -05:00
) ,
2026-01-14 08:40:24 -08:00
AssetState . remote = > const Align (
alignment: Alignment . bottomRight ,
child: Padding (
padding: EdgeInsets . only ( right: 10.0 , bottom: 6.0 ) ,
child: _TileOverlayIcon ( Icons . cloud_outlined ) ,
) ,
2025-07-09 22:16:09 +03:00
) ,
2026-01-14 08:40:24 -08:00
AssetState . merged = > const Align (
alignment: Alignment . bottomRight ,
child: Padding (
padding: EdgeInsets . only ( right: 10.0 , bottom: 6.0 ) ,
child: _TileOverlayIcon ( Icons . cloud_done_outlined ) ,
) ,
2025-07-09 22:16:09 +03:00
) ,
2026-01-14 08:40:24 -08:00
} ,
) ,
2025-08-21 14:06:02 -04:00
if ( asset ! = null & & asset . isFavorite )
2026-01-14 08:40:24 -08:00
AnimatedOpacity (
duration: Durations . short4 ,
opacity: _hideIndicators ? 0.0 : 1.0 ,
child: const Align (
alignment: Alignment . bottomLeft ,
child: Padding (
padding: EdgeInsets . only ( left: 10.0 , bottom: 6.0 ) ,
child: _TileOverlayIcon ( Icons . favorite_rounded ) ,
) ,
2025-06-24 02:05:25 -05:00
) ,
) ,
2026-01-15 20:10:08 -06:00
if ( uploadProgress ! = null ) _UploadProgressOverlay ( progress: uploadProgress ) ,
2025-06-24 02:05:25 -05:00
] ,
2025-06-16 20:37:45 +05:30
) ,
) ,
2025-06-24 02:05:25 -05:00
) ,
2025-10-24 05:36:49 +02:00
TweenAnimationBuilder < double > (
2026-01-14 08:40:24 -08:00
tween: Tween < double > ( begin: 0.0 , end: ( isSelected | | widget . lockSelection ) ? 1.0 : 0.0 ) ,
2025-10-24 05:36:49 +02:00
duration: Durations . short4 ,
curve: Curves . decelerate ,
builder: ( context , value , child ) {
return Padding (
2026-01-14 08:40:24 -08:00
padding: EdgeInsets . all ( ( isSelected | | widget . lockSelection ) ? value * 3.0 : 3.0 ) ,
2025-10-24 05:36:49 +02:00
child: Align (
alignment: Alignment . topLeft ,
child: Opacity (
2026-01-14 08:40:24 -08:00
opacity: ( isSelected | | widget . lockSelection ) ? 1 : value ,
2025-10-24 05:36:49 +02:00
child: _SelectionIndicator (
2026-01-14 08:40:24 -08:00
isLocked: widget . lockSelection ,
color: widget . lockSelection ? context . colorScheme . surfaceContainerHighest : assetContainerColor ,
2025-10-24 05:36:49 +02:00
) ,
) ,
2025-06-24 02:05:25 -05:00
) ,
2025-10-24 05:36:49 +02:00
) ;
} ,
) ,
2025-06-16 20:37:45 +05:30
] ,
) ;
}
}
2025-06-24 02:05:25 -05:00
class _SelectionIndicator extends StatelessWidget {
2025-07-07 23:41:37 -05:00
final bool isLocked ;
2025-06-24 02:05:25 -05:00
final Color ? color ;
2025-07-07 23:41:37 -05:00
2025-10-24 05:36:49 +02:00
const _SelectionIndicator ( { required this . isLocked , this . color } ) ;
2025-06-24 02:05:25 -05:00
@ override
Widget build ( BuildContext context ) {
2025-07-07 23:41:37 -05:00
if ( isLocked ) {
2025-07-09 22:16:09 +03:00
return DecoratedBox (
2025-07-29 00:34:03 +05:30
decoration: BoxDecoration ( shape: BoxShape . circle , color: color ) ,
child: const Icon ( Icons . check_circle_rounded , color: Colors . grey ) ,
2025-07-07 23:41:37 -05:00
) ;
2025-10-24 05:36:49 +02:00
} else {
2025-07-09 22:16:09 +03:00
return DecoratedBox (
2025-07-29 00:34:03 +05:30
decoration: BoxDecoration ( shape: BoxShape . circle , color: color ) ,
child: Icon ( Icons . check_circle_rounded , color: context . primaryColor ) ,
2025-06-24 02:05:25 -05:00
) ;
}
}
}
2025-06-16 20:37:45 +05:30
class _VideoIndicator extends StatelessWidget {
2025-07-09 22:16:09 +03:00
final Duration duration ;
const _VideoIndicator ( this . duration ) ;
2025-06-16 20:37:45 +05:30
@ override
Widget build ( BuildContext context ) {
return Row (
spacing: 3 ,
mainAxisSize: MainAxisSize . min ,
mainAxisAlignment: MainAxisAlignment . end ,
2025-07-18 10:01:04 +05:30
// CrossAxisAlignment.start looks more centered vertically than CrossAxisAlignment.center
crossAxisAlignment: CrossAxisAlignment . start ,
2025-06-16 20:37:45 +05:30
children: [
Text (
2025-07-09 22:16:09 +03:00
duration . format ( ) ,
style: const TextStyle (
2025-06-16 20:37:45 +05:30
color: Colors . white ,
fontSize: 12 ,
fontWeight: FontWeight . bold ,
2025-07-29 00:34:03 +05:30
shadows: [ Shadow ( blurRadius: 5.0 , color: Color . fromRGBO ( 0 , 0 , 0 , 0.6 ) ) ] ,
2025-06-16 20:37:45 +05:30
) ,
) ,
const _TileOverlayIcon ( Icons . play_circle_outline_rounded ) ,
] ,
) ;
}
}
class _TileOverlayIcon extends StatelessWidget {
final IconData icon ;
const _TileOverlayIcon ( this . icon ) ;
@ override
Widget build ( BuildContext context ) {
return Icon (
icon ,
color: Colors . white ,
size: 16 ,
2026-07-30 01:56:46 -07:00
shadows: const [ Shadow ( blurRadius: 5.0 , color: Color . fromRGBO ( 0 , 0 , 0 , 0.6 ) , offset: Offset . zero ) ] ,
2025-06-16 20:37:45 +05:30
) ;
}
}
2025-09-27 21:27:34 -05:00
class _AssetTypeIcons extends StatelessWidget {
final BaseAsset asset ;
const _AssetTypeIcons ( { required this . asset } ) ;
@ override
Widget build ( BuildContext context ) {
2026-07-07 10:25:43 -07:00
final isLivePhoto = asset . isMotionPhoto ;
2025-09-27 21:27:34 -05:00
return Column (
mainAxisSize: MainAxisSize . min ,
crossAxisAlignment: CrossAxisAlignment . end ,
children: [
if ( asset . isVideo )
Padding ( padding: const EdgeInsets . only ( right: 10.0 , top: 6.0 ) , child: _VideoIndicator ( asset . duration ) ) ,
if ( isLivePhoto )
const Padding (
padding: EdgeInsets . only ( right: 10.0 , top: 6.0 ) ,
child: _TileOverlayIcon ( Icons . motion_photos_on_rounded ) ,
) ,
2026-03-11 18:07:06 +01:00
if ( asset . isAnimatedImage )
const Padding ( padding: EdgeInsets . only ( right: 10.0 , top: 6.0 ) , child: _TileOverlayIcon ( Icons . gif_rounded ) ) ,
2025-09-27 21:27:34 -05:00
] ,
) ;
}
}
2026-01-15 20:10:08 -06:00
2026-04-29 13:49:47 +03:00
class _StackIndicator extends StatelessWidget {
final BaseAsset asset ;
const _StackIndicator ( { required this . asset } ) ;
@ override
Widget build ( BuildContext context ) {
if ( asset is ! RemoteAsset | | ( asset as RemoteAsset ) . stackId = = null ) {
return const SizedBox . shrink ( ) ;
}
return const Padding (
padding: EdgeInsets . only ( right: 10.0 , top: 6.0 ) ,
child: _TileOverlayIcon ( Icons . burst_mode_rounded ) ,
) ;
}
}
2026-01-15 20:10:08 -06:00
class _UploadProgressOverlay extends StatelessWidget {
final double progress ;
const _UploadProgressOverlay ( { required this . progress } ) ;
@ override
Widget build ( BuildContext context ) {
final isError = progress < 0 ;
final percentage = isError ? 0 : ( progress * 100 ) . toInt ( ) ;
return Positioned . fill (
2026-07-30 12:15:35 -07:00
child: ColoredBox (
2026-01-15 20:10:08 -06:00
color: isError ? Colors . red . withValues ( alpha: 0.6 ) : Colors . black54 ,
child: Center (
child: Column (
mainAxisSize: MainAxisSize . min ,
children: [
if ( isError )
const Icon ( Icons . error_outline , color: Colors . white , size: 36 )
else
SizedBox (
width: 36 ,
height: 36 ,
child: CircularProgressIndicator (
value: progress ,
strokeWidth: 3 ,
backgroundColor: Colors . white24 ,
valueColor: const AlwaysStoppedAnimation < Color > ( Colors . white ) ,
) ,
) ,
const SizedBox ( height: 4 ) ,
Text (
isError ? ' Error ' : ' $ percentage % ' ,
style: const TextStyle ( color: Colors . white , fontSize: 12 , fontWeight: FontWeight . bold ) ,
) ,
] ,
) ,
) ,
) ,
) ;
}
}