From 8987b2de17c96b5e0c0e6e827144367b8b2b3cad Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 8 Jul 2025 12:02:33 -0500 Subject: [PATCH] animation --- .../pages/dev/drift_archive.page.dart | 7 +- .../pages/dev/drift_favorite.page.dart | 7 +- .../common/mesmerizing_sliver_app_bar.dart | 439 +++++++----------- 3 files changed, 180 insertions(+), 273 deletions(-) diff --git a/mobile/lib/presentation/pages/dev/drift_archive.page.dart b/mobile/lib/presentation/pages/dev/drift_archive.page.dart index d1af189abc..07788bc8f9 100644 --- a/mobile/lib/presentation/pages/dev/drift_archive.page.dart +++ b/mobile/lib/presentation/pages/dev/drift_archive.page.dart @@ -1,6 +1,7 @@ import 'package:auto_route/auto_route.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'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -28,8 +29,10 @@ class DriftArchivePage extends StatelessWidget { }, ), ], - child: const Timeline( - appBar: MesmerizingSliverAppBar(), + child: Timeline( + appBar: MesmerizingSliverAppBar( + title: 'archive'.t(context: context), + ), ), ); } diff --git a/mobile/lib/presentation/pages/dev/drift_favorite.page.dart b/mobile/lib/presentation/pages/dev/drift_favorite.page.dart index 57925b039f..58196a1832 100644 --- a/mobile/lib/presentation/pages/dev/drift_favorite.page.dart +++ b/mobile/lib/presentation/pages/dev/drift_favorite.page.dart @@ -1,6 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.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'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -28,8 +29,10 @@ class DriftFavoritePage extends StatelessWidget { }, ), ], - child: const Timeline( - appBar: MesmerizingSliverAppBar(), + child: Timeline( + appBar: MesmerizingSliverAppBar( + title: 'favorites'.t(context: context), + ), ), ); } diff --git a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart index a93f6b5d3a..52ec6e0058 100644 --- a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart @@ -1,66 +1,79 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +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/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; class MesmerizingSliverAppBar extends ConsumerWidget { - const MesmerizingSliverAppBar({super.key}); + const MesmerizingSliverAppBar({ + super.key, + required this.title, + }); + + final String title; + + double _calculateScrollProgress(FlexibleSpaceBarSettings? settings) { + if (settings?.maxExtent == null || settings?.minExtent == null) { + return 1.0; + } + + final deltaExtent = settings!.maxExtent - settings.minExtent; + if (deltaExtent <= 0.0) { + return 1.0; + } + + return (1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent) + .clamp(0.0, 1.0); + } @override Widget build(BuildContext context, WidgetRef ref) { 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, + sliver: SliverAppBar( + expandedHeight: 300.0, + floating: false, + pinned: true, + snap: false, + elevation: 0, + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final settings = context + .dependOnInheritedWidgetOfExactType(); + final scrollProgress = _calculateScrollProgress(settings); - return SliverAppBar( - expandedHeight: 300.0, - floating: false, - pinned: true, - snap: false, - elevation: 0, - flexibleSpace: LayoutBuilder( - builder: (context, constraints) { - final settings = context - .dependOnInheritedWidgetOfExactType(); - final deltaExtent = - settings?.maxExtent != null && settings?.minExtent != null - ? settings!.maxExtent - settings.minExtent - : 0.0; - final t = deltaExtent > 0.0 - ? (1.0 - - (settings!.currentExtent - settings.minExtent) / - deltaExtent) - .clamp(0.0, 1.0) - : 1.0; - - return FlexibleSpaceBar( - centerTitle: true, - titlePadding: EdgeInsets.lerp( - const EdgeInsets.only(left: 16, bottom: 16), - const EdgeInsets.only(left: 0, bottom: 16), - t, - ), - title: AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: t > 0.95 - ? Text( - 'Favorites', - key: const ValueKey('collapsed'), - style: TextStyle( - color: context.primaryColor, - fontWeight: FontWeight.w600, - fontSize: 18, - ), - ) - : null, - ), - background: _ExpandedBackground( - assetCount: assetCount, - scrollProgress: t, - ), - ); - }, + return FlexibleSpaceBar( + centerTitle: true, + title: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: scrollProgress > 0.95 + ? Text( + title, + style: TextStyle( + color: context.primaryColor, + fontWeight: FontWeight.w600, + fontSize: 18, + ), + ) + : null, + ), + background: _ExpandedBackground( + assetCount: assetCount, + scrollProgress: scrollProgress, + title: title, + ), + ); + }, + ), ), ); } @@ -69,10 +82,12 @@ class MesmerizingSliverAppBar extends ConsumerWidget { class _ExpandedBackground extends ConsumerWidget { final int assetCount; final double scrollProgress; + final String title; const _ExpandedBackground({ required this.assetCount, required this.scrollProgress, + required this.title, }); @override @@ -82,7 +97,6 @@ class _ExpandedBackground extends ConsumerWidget { return Stack( fit: StackFit.expand, children: [ - // Random asset background with zooming effect Transform.translate( offset: Offset(0, scrollProgress * 50), child: Transform.scale( @@ -90,104 +104,68 @@ class _ExpandedBackground extends ConsumerWidget { child: _RandomAssetBackground(timelineService: timelineService), ), ), - - // Animated gradient overlay - AnimatedContainer( - duration: const Duration(milliseconds: 300), + Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: Theme.of(context).brightness == Brightness.dark - ? [ - Colors.black - .withValues(alpha: 0.1 + (scrollProgress * 0.4)), - Colors.black - .withValues(alpha: 0.5 + (scrollProgress * 0.3)), - Colors.black - .withValues(alpha: 0.8 + (scrollProgress * 0.2)), - ] - : [ - Colors.transparent, // Clear at the top - Colors.transparent, // Keep middle clear - Colors.black.withValues( - alpha: 0.3 + (scrollProgress * 0.2), - ), // Slightly dark at bottom - ], - stops: const [0.0, 0.9, 1.0], + colors: [ + Colors.transparent, + Colors.transparent, + Colors.black.withValues( + alpha: 0.3 + (scrollProgress * 0.2), + ), + ], + stops: const [0.0, 0.7, 1.0], ), ), ), - - // Title and count in lower left with fade animation Positioned( bottom: 16, left: 16, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: (1.0 - scrollProgress).clamp(0.0, 1.0), - child: Transform.translate( - offset: Offset(0, scrollProgress * 30), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Hero( - tag: 'favorites_title', - child: Material( - color: Colors.transparent, - child: Text( - 'Favorites', - style: TextStyle( - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white - : Colors.white, - fontSize: - (36 - (scrollProgress * 6)).clamp(24.0, 36.0), - fontWeight: FontWeight.bold, - letterSpacing: 0.5, - shadows: [ - Shadow( - offset: const Offset(0, 2), - blurRadius: 12, - color: Theme.of(context).brightness == - Brightness.dark - ? Colors.black54 - : Colors.black45, - ), - ], - ), - ), + 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( - '$assetCount ${assetCount == 1 ? 'favorite' : 'favorites'}', - style: TextStyle( - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white.withValues(alpha: 0.9) - : Colors.white.withValues(alpha: 0.95), - fontSize: 16, - fontWeight: FontWeight.w500, - letterSpacing: 0.2, - shadows: [ - Shadow( - offset: const Offset(0, 1), - blurRadius: 6, - color: - Theme.of(context).brightness == Brightness.dark - ? Colors.black45 - : Colors.black38, - ), - ], - ), - ), - ), - ], + ], + ), ), - ), + 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, + color: Colors.white, + shadows: [ + const Shadow( + offset: Offset(0, 1), + blurRadius: 6, + color: Colors.black45, + ), + ], + ), + ), + ), + ], ), ), ], @@ -196,7 +174,7 @@ class _ExpandedBackground extends ConsumerWidget { } class _RandomAssetBackground extends StatefulWidget { - final timelineService; + final TimelineService timelineService; const _RandomAssetBackground({required this.timelineService}); @@ -213,25 +191,24 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> late Animation _fadeAnimation; BaseAsset? _currentAsset; BaseAsset? _nextAsset; - bool _isFirstLoad = true; @override void initState() { super.initState(); _zoomController = AnimationController( - duration: const Duration(seconds: 12), // Slower for more cinematic effect + duration: const Duration(seconds: 12), vsync: this, ); _fadeController = AnimationController( - duration: const Duration(milliseconds: 500), // Faster initial fade + duration: const Duration(milliseconds: 500), vsync: this, ); _zoomAnimation = Tween( - begin: 1.0, // Start from full image - end: 1.3, // Zoom in gradually + begin: 1.0, + end: 1.3, ).animate( CurvedAnimation( parent: _zoomController, @@ -240,8 +217,8 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> ); _panAnimation = Tween( - begin: Offset.zero, // Start centered - end: const Offset(0.15, -0.1), // Pan to top right corner + begin: Offset.zero, + end: const Offset(0.15, -0.1), ).animate( CurvedAnimation( parent: _zoomController, @@ -255,21 +232,14 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> ).animate( CurvedAnimation( parent: _fadeController, - curve: Curves.easeOut, // Faster curve for initial load + curve: Curves.easeOut, ), ); - // Start loading immediately without waiting - WidgetsBinding.instance.addPostFrameCallback((_) { - _loadRandomAssetFast(); - }); - - // Also try a fallback approach - Future.delayed(const Duration(milliseconds: 100), () { - if (mounted && _currentAsset == null) { - _loadRandomAsset(); - } - }); + Future.delayed( + const Duration(milliseconds: 100), + () => _loadRandomAsset(), + ); } @override @@ -287,100 +257,42 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> }); } - Future _loadRandomAssetFast() async { - // Try to load the first available asset immediately - try { - // Check if assets are already available - // if (widget.timelineService.totalAssets > 0) { - // final assets = widget.timelineService.getAssets(0, 1); - // if (assets.isNotEmpty && mounted) { - // setState(() { - // _currentAsset = assets.first; - // _isFirstLoad = false; - // }); - // await _fadeController.forward(); - // _startZoomCycle(); - // return; - // } - // } - - // If no assets yet, try multiple times with very short delays - for (int i = 0; i < 20; i++) { - await Future.delayed(const Duration(milliseconds: 25)); - if (mounted && widget.timelineService.totalAssets > 0) { - final assets = widget.timelineService.getAssets(0, 1); - if (assets.isNotEmpty && mounted) { - setState(() { - _currentAsset = assets.first; - _isFirstLoad = false; - }); - await _fadeController.forward(); - _startZoomCycle(); - return; - } - } - } - - // Fallback: keep trying with regular method - if (mounted) { - _loadRandomAsset(); - } - } catch (e) { - // Fallback to regular loading on error - if (mounted) { - _loadRandomAsset(); - } - } - } - Future _loadRandomAsset() async { - try { - if (mounted && widget.timelineService.totalAssets > 0) { - final randomIndex = _isFirstLoad - ? 0 // Always load first asset on initial load for speed - : (widget.timelineService.totalAssets > 1) - ? DateTime.now().millisecond % - widget.timelineService.totalAssets - : 0; - final assets = widget.timelineService.getAssets(randomIndex, 1); - if (assets.isNotEmpty && mounted) { - setState(() { - _currentAsset = assets.first; - _isFirstLoad = false; - }); - await _fadeController.forward(); - // Only start zoom cycle if not already running - if (_zoomController.status == AnimationStatus.dismissed) { - _startZoomCycle(); - } - } - } - } catch (e) { - // Handle error and retry once - if (mounted) { - await Future.delayed(const Duration(milliseconds: 200)); - if (mounted && _currentAsset == null) { - // Simple retry without recursion - if (widget.timelineService.totalAssets > 0) { - final assets = widget.timelineService.getAssets(0, 1); - if (assets.isNotEmpty && mounted) { - setState(() { - _currentAsset = assets.first; - _isFirstLoad = false; - }); - _fadeController.forward(); - if (_zoomController.status == AnimationStatus.dismissed) { - _startZoomCycle(); - } - } - } - } - } + if (!mounted) { + return; + } + + if (widget.timelineService.totalAssets == 0) { + setState(() { + _currentAsset = null; + }); + + return; + } + + final randomIndex = (widget.timelineService.totalAssets > 1) + ? DateTime.now().millisecond % widget.timelineService.totalAssets + : 0; + + final assets = widget.timelineService.getAssets(randomIndex, 1); + if (assets.isEmpty) { + return; + } + + setState(() { + _currentAsset = assets.first; + }); + + await _fadeController.forward(); + if (_zoomController.status == AnimationStatus.dismissed) { + _startZoomCycle(); } } Future _loadNextAsset() async { - if (!mounted) return; + if (!mounted) { + return; + } try { if (widget.timelineService.totalAssets > 1) { @@ -406,31 +318,24 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> } } } else { - // If only one asset, restart the zoom - if (mounted) { - _zoomController.reset(); - _startZoomCycle(); - } - } - } catch (e) { - // Handle error and restart cycle - if (mounted) { _zoomController.reset(); _startZoomCycle(); } + } catch (e) { + _zoomController.reset(); + _startZoomCycle(); } } @override Widget build(BuildContext context) { if (widget.timelineService.totalAssets == 0) { - final isDark = Theme.of(context).brightness == Brightness.dark; return Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: isDark + colors: context.isDarkTheme ? [ Colors.deepPurple.withValues(alpha: 0.8), Colors.indigo.withValues(alpha: 0.9), @@ -448,7 +353,6 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> ), child: Stack( children: [ - // Floating elements for visual interest Positioned( top: 40, right: 30, @@ -457,7 +361,7 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> height: 80, decoration: BoxDecoration( shape: BoxShape.circle, - color: isDark + color: context.isDarkTheme ? Colors.white.withValues(alpha: 0.1) : Colors.white.withValues(alpha: 0.2), ), @@ -471,7 +375,7 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> height: 60, decoration: BoxDecoration( shape: BoxShape.circle, - color: isDark + color: context.isDarkTheme ? Colors.white.withValues(alpha: 0.08) : Colors.white.withValues(alpha: 0.15), ), @@ -485,7 +389,7 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> height: 40, decoration: BoxDecoration( shape: BoxShape.circle, - color: isDark + color: context.isDarkTheme ? Colors.white.withValues(alpha: 0.06) : Colors.white.withValues(alpha: 0.12), ), @@ -496,7 +400,7 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> child: Icon( Icons.favorite_outline, size: 100, - color: isDark + color: context.isDarkTheme ? Colors.white.withValues(alpha: 0.15) : Colors.white.withValues(alpha: 0.25), ), @@ -507,14 +411,13 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> } if (_currentAsset == null) { - final isDark = Theme.of(context).brightness == Brightness.dark; return AnimatedContainer( duration: const Duration(milliseconds: 300), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: isDark + colors: context.isDarkTheme ? [ Colors.deepPurple.withValues(alpha: 0.4), Colors.indigo.withValues(alpha: 0.5), @@ -527,14 +430,14 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> ], ), ), - child: Center( + child: const Center( child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( - isDark ? Colors.white70 : Colors.white.withValues(alpha: 0.8), + Colors.white70, ), ), ), @@ -572,8 +475,7 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: Theme.of(context).brightness == - Brightness.dark + colors: context.isDarkTheme ? [ Colors.deepPurple.withValues(alpha: 0.3), Colors.indigo.withValues(alpha: 0.4), @@ -595,8 +497,7 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: Theme.of(context).brightness == - Brightness.dark + colors: context.isDarkTheme ? [ Colors.deepPurple.withValues(alpha: 0.6), Colors.indigo.withValues(alpha: 0.7),