From 848ba66e22652fc9c108789be5f56e96954b8a10 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 8 Jul 2025 15:31:31 -0500 Subject: [PATCH] better animation --- .../repositories/remote_album.repository.dart | 5 +- .../pages/dev/drift_trash.page.dart | 1 - mobile/lib/utils/remote_album.utils.dart | 28 +- .../common/mesmerizing_sliver_app_bar.dart | 343 ++++++++++++------ 4 files changed, 266 insertions(+), 111 deletions(-) diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index 8050f3b63e..de55626b30 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -9,8 +9,9 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { final Drift _db; const DriftRemoteAlbumRepository(this._db) : super(_db); - Future> getAll( - {Set sortBy = const {}}) { + Future> getAll({ + Set sortBy = const {}, + }) { final assetCount = _db.remoteAlbumAssetEntity.assetId.count(); final query = _db.remoteAlbumEntity.select().join([ diff --git a/mobile/lib/presentation/pages/dev/drift_trash.page.dart b/mobile/lib/presentation/pages/dev/drift_trash.page.dart index 86b00f2282..9cd2fac760 100644 --- a/mobile/lib/presentation/pages/dev/drift_trash.page.dart +++ b/mobile/lib/presentation/pages/dev/drift_trash.page.dart @@ -1,6 +1,5 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; diff --git a/mobile/lib/utils/remote_album.utils.dart b/mobile/lib/utils/remote_album.utils.dart index d1934b4b52..04184ee367 100644 --- a/mobile/lib/utils/remote_album.utils.dart +++ b/mobile/lib/utils/remote_album.utils.dart @@ -2,35 +2,45 @@ import 'package:collection/collection.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; typedef AlbumSortFn = List Function( - List albums, bool isReverse); + List albums, + bool isReverse, +); class _RemoteAlbumSortHandlers { const _RemoteAlbumSortHandlers._(); static const AlbumSortFn created = _sortByCreated; static List _sortByCreated( - List albums, bool isReverse) { + List albums, + bool isReverse, + ) { final sorted = albums.sortedBy((album) => album.createdAt); return (isReverse ? sorted.reversed : sorted).toList(); } static const AlbumSortFn title = _sortByTitle; static List _sortByTitle( - List albums, bool isReverse) { + List albums, + bool isReverse, + ) { final sorted = albums.sortedBy((album) => album.name); return (isReverse ? sorted.reversed : sorted).toList(); } static const AlbumSortFn lastModified = _sortByLastModified; static List _sortByLastModified( - List albums, bool isReverse) { + List albums, + bool isReverse, + ) { final sorted = albums.sortedBy((album) => album.updatedAt); return (isReverse ? sorted.reversed : sorted).toList(); } static const AlbumSortFn assetCount = _sortByAssetCount; static List _sortByAssetCount( - List albums, bool isReverse) { + List albums, + bool isReverse, + ) { final sorted = albums.sorted((a, b) => a.assetCount.compareTo(b.assetCount)); return (isReverse ? sorted.reversed : sorted).toList(); @@ -38,7 +48,9 @@ class _RemoteAlbumSortHandlers { static const AlbumSortFn mostRecent = _sortByMostRecent; static List _sortByMostRecent( - List albums, bool isReverse) { + List albums, + bool isReverse, + ) { final sorted = albums.sorted((a, b) { // For most recent, we sort by updatedAt in descending order return b.updatedAt.compareTo(a.updatedAt); @@ -48,7 +60,9 @@ class _RemoteAlbumSortHandlers { static const AlbumSortFn mostOldest = _sortByMostOldest; static List _sortByMostOldest( - List albums, bool isReverse) { + List albums, + bool isReverse, + ) { final sorted = albums.sorted((a, b) { // For oldest, we sort by createdAt in ascending order return a.createdAt.compareTo(b.createdAt); diff --git a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart index 9d7c757b25..a45dcf90f1 100644 --- a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -5,11 +7,10 @@ import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; -import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; -class MesmerizingSliverAppBar extends ConsumerWidget { +class MesmerizingSliverAppBar extends ConsumerStatefulWidget { const MesmerizingSliverAppBar({ super.key, required this.title, @@ -19,6 +20,15 @@ class MesmerizingSliverAppBar extends ConsumerWidget { final String title; final IconData icon; + @override + ConsumerState createState() => + _MesmerizingSliverAppBarState(); +} + +class _MesmerizingSliverAppBarState + extends ConsumerState { + double _scrollProgress = 0.0; + double _calculateScrollProgress(FlexibleSpaceBarSettings? settings) { if (settings?.maxExtent == null || settings?.minExtent == null) { return 1.0; @@ -34,11 +44,12 @@ class MesmerizingSliverAppBar extends ConsumerWidget { } @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final timelineService = ref.watch(timelineServiceProvider); final assetCount = timelineService.totalAssets; final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); + return SliverAnimatedOpacity( duration: Durations.medium1, opacity: isMultiSelectEnabled ? 0 : 1, @@ -48,19 +59,56 @@ class MesmerizingSliverAppBar extends ConsumerWidget { pinned: true, snap: false, elevation: 0, + leading: IconButton( + icon: Icon( + Platform.isIOS + ? Icons.arrow_back_ios_new_rounded + : Icons.arrow_back, + color: Color.lerp( + Colors.white, + context.primaryColor, + _scrollProgress, + ), + shadows: [ + _scrollProgress < 0.95 + ? Shadow( + offset: const Offset(0, 2), + blurRadius: 5, + color: Colors.black.withValues(alpha: 0.5), + ) + : const Shadow( + offset: Offset(0, 2), + blurRadius: 0, + color: Colors.transparent, + ), + ], + ), + onPressed: () { + context.pop(); + }, + ), flexibleSpace: LayoutBuilder( builder: (context, constraints) { final settings = context .dependOnInheritedWidgetOfExactType(); final scrollProgress = _calculateScrollProgress(settings); + // Update scroll progress for the leading button + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _scrollProgress != scrollProgress) { + setState(() { + _scrollProgress = scrollProgress; + }); + } + }); + return FlexibleSpaceBar( centerTitle: true, title: AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: scrollProgress > 0.95 ? Text( - title, + widget.title, style: TextStyle( color: context.primaryColor, fontWeight: FontWeight.w600, @@ -72,8 +120,8 @@ class MesmerizingSliverAppBar extends ConsumerWidget { background: _ExpandedBackground( assetCount: assetCount, scrollProgress: scrollProgress, - title: title, - icon: icon, + title: widget.title, + icon: widget.icon, ), ); }, @@ -83,7 +131,7 @@ class MesmerizingSliverAppBar extends ConsumerWidget { } } -class _ExpandedBackground extends ConsumerWidget { +class _ExpandedBackground extends ConsumerStatefulWidget { final int assetCount; final double scrollProgress; final String title; @@ -97,19 +145,61 @@ class _ExpandedBackground extends ConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState<_ExpandedBackground> createState() => + _ExpandedBackgroundState(); +} + +class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> + with SingleTickerProviderStateMixin { + late AnimationController _slideController; + late Animation _slideAnimation; + + @override + void initState() { + super.initState(); + + _slideController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + ); + + _slideAnimation = Tween( + begin: const Offset(0, 1.5), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: _slideController, + curve: Curves.easeOutCubic, + ), + ); + + Future.delayed(const Duration(milliseconds: 100), () { + if (mounted) { + _slideController.forward(); + } + }); + } + + @override + void dispose() { + _slideController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { final timelineService = ref.watch(timelineServiceProvider); return Stack( fit: StackFit.expand, children: [ Transform.translate( - offset: Offset(0, scrollProgress * 50), + offset: Offset(0, widget.scrollProgress * 50), child: Transform.scale( - scale: 1.4 - (scrollProgress * 0.2), + scale: 1.4 - (widget.scrollProgress * 0.2), child: _RandomAssetBackground( timelineService: timelineService, - icon: icon, + icon: widget.icon, ), ), ), @@ -122,59 +212,61 @@ class _ExpandedBackground extends ConsumerWidget { Colors.transparent, Colors.transparent, Colors.black.withValues( - alpha: 0.3 + (scrollProgress * 0.2), + alpha: 0.6 + (widget.scrollProgress * 0.2), ), ], - stops: const [0.0, 0.7, 1.0], + stops: const [0.0, 0.65, 1.0], ), ), ), Positioned( bottom: 16, left: 16, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - title, - style: const TextStyle( - color: Colors.white, - fontSize: 36, - fontWeight: FontWeight.bold, - letterSpacing: 0.5, - shadows: [ - Shadow( - offset: Offset(0, 2), - blurRadius: 12, - color: Colors.black45, - ), - ], - ), - ), - const SizedBox(height: 6), - AnimatedContainer( - duration: const Duration(milliseconds: 300), - child: Text( - 'items_count'.t( - context: context, - args: {"count": assetCount}, - ), - style: context.textTheme.labelLarge?.copyWith( - letterSpacing: 0.2, - fontWeight: FontWeight.bold, + child: SlideTransition( + position: _slideAnimation, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.title, + style: const TextStyle( color: Colors.white, + fontSize: 36, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, shadows: [ - const Shadow( - offset: Offset(0, 1), - blurRadius: 6, + Shadow( + offset: Offset(0, 2), + blurRadius: 12, color: Colors.black45, ), ], ), ), - ), - ], + AnimatedContainer( + duration: const Duration(milliseconds: 300), + child: Text( + 'items_count'.t( + context: context, + args: {"count": widget.assetCount}, + ), + style: context.textTheme.labelLarge?.copyWith( + // letterSpacing: 0.2, + fontWeight: FontWeight.bold, + color: Colors.white, + shadows: [ + const Shadow( + offset: Offset(0, 1), + blurRadius: 6, + color: Colors.black45, + ), + ], + ), + ), + ), + ], + ), ), ), ], @@ -198,12 +290,13 @@ class _RandomAssetBackground extends StatefulWidget { class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with TickerProviderStateMixin { late AnimationController _zoomController; - late AnimationController _fadeController; + late AnimationController _crossFadeController; late Animation _zoomAnimation; late Animation _panAnimation; - late Animation _fadeAnimation; + late Animation _crossFadeAnimation; BaseAsset? _currentAsset; BaseAsset? _nextAsset; + bool _isZoomingIn = true; final LinearGradient gradient = LinearGradient( begin: Alignment.topLeft, @@ -222,18 +315,18 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> super.initState(); _zoomController = AnimationController( - duration: const Duration(seconds: 10), + duration: const Duration(seconds: 12), vsync: this, ); - _fadeController = AnimationController( - duration: const Duration(milliseconds: 600), + _crossFadeController = AnimationController( + duration: const Duration(milliseconds: 1200), vsync: this, ); _zoomAnimation = Tween( begin: 1.0, - end: 1.3, + end: 1.2, ).animate( CurvedAnimation( parent: _zoomController, @@ -243,7 +336,7 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> _panAnimation = Tween( begin: Offset.zero, - end: const Offset(0.15, -0.1), + end: const Offset(0.15, -0.5), ).animate( CurvedAnimation( parent: _zoomController, @@ -251,38 +344,42 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> ), ); - _fadeAnimation = Tween( + _crossFadeAnimation = Tween( begin: 0.0, end: 1.0, ).animate( CurvedAnimation( - parent: _fadeController, - curve: Curves.easeOut, + parent: _crossFadeController, + curve: Curves.easeInOutCubic, ), ); Future.delayed( Durations.medium1, - () => _loadRandomAsset(), + () => _loadFirstAsset(), ); } @override void dispose() { _zoomController.dispose(); - _fadeController.dispose(); + _crossFadeController.dispose(); super.dispose(); } - void _startZoomCycle() { - _zoomController.forward().then((_) { - if (mounted) { + void _startAnimationCycle() { + if (_isZoomingIn) { + _zoomController.forward().then((_) { _loadNextAsset(); - } - }); + }); + } else { + _zoomController.reverse().then((_) { + _loadNextAsset(); + }); + } } - Future _loadRandomAsset() async { + Future _loadFirstAsset() async { if (!mounted) { return; } @@ -299,9 +396,15 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> _currentAsset = widget.timelineService.getRandomAsset(); }); - await _fadeController.forward(); + await _crossFadeController.forward(); + if (_zoomController.status == AnimationStatus.dismissed) { - _startZoomCycle(); + if (_isZoomingIn) { + _zoomController.reset(); + } else { + _zoomController.value = 1.0; + } + _startAnimationCycle(); } } @@ -312,24 +415,28 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> try { if (widget.timelineService.totalAssets > 1) { + // Load next asset while keeping current one visible + final nextAsset = widget.timelineService.getRandomAsset(); + setState(() { - _nextAsset = widget.timelineService.getRandomAsset(); + _nextAsset = nextAsset; }); - await _fadeController.reverse(); - + await _crossFadeController.reverse(); setState(() { _currentAsset = _nextAsset; _nextAsset = null; }); - _zoomController.reset(); - await _fadeController.forward(); - _startZoomCycle(); + _crossFadeController.value = 1.0; + + _isZoomingIn = !_isZoomingIn; + + _startAnimationCycle(); } } catch (e) { _zoomController.reset(); - _startZoomCycle(); + _startAnimationCycle(); } } @@ -347,34 +454,68 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> } return AnimatedBuilder( - animation: - Listenable.merge([_zoomAnimation, _panAnimation, _fadeAnimation]), + animation: Listenable.merge( + [_zoomAnimation, _panAnimation, _crossFadeAnimation], + ), builder: (context, child) { return Transform.scale( scale: _zoomAnimation.value, - child: FadeTransition( - opacity: _fadeAnimation, - child: SizedBox( - width: double.infinity, - height: double.infinity, - child: Image( - alignment: Alignment.topRight, - image: getFullImageProvider(_currentAsset!), - fit: BoxFit.cover, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) { - return child; - } - - return Container(); - }, - errorBuilder: (context, error, stackTrace) { - return Container( - decoration: BoxDecoration(gradient: gradient), - ); - }, - ), - ), + child: Stack( + fit: StackFit.expand, + children: [ + // Current image + if (_currentAsset != null) + Opacity( + opacity: _crossFadeAnimation.value, + child: SizedBox( + width: double.infinity, + height: double.infinity, + child: Image( + alignment: Alignment.topRight, + image: getFullImageProvider(_currentAsset!), + fit: BoxFit.cover, + frameBuilder: + (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return Container(); + }, + errorBuilder: (context, error, stackTrace) { + return Container( + decoration: BoxDecoration(gradient: gradient), + ); + }, + ), + ), + ), + // Next image (for cross-fade) + if (_nextAsset != null) + Opacity( + opacity: 1.0 - _crossFadeAnimation.value, + child: SizedBox( + width: double.infinity, + height: double.infinity, + child: Image( + alignment: Alignment.topRight, + image: getFullImageProvider(_nextAsset!), + fit: BoxFit.cover, + frameBuilder: + (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return Container(); + }, + errorBuilder: (context, error, stackTrace) { + return Container( + decoration: BoxDecoration(gradient: gradient), + ); + }, + ), + ), + ), + ], ), ); },