animation

This commit is contained in:
Alex 2025-07-08 12:02:33 -05:00
parent 99eb879188
commit 8987b2de17
No known key found for this signature in database
GPG Key ID: 53CD082B3A5E1082
3 changed files with 180 additions and 273 deletions

View File

@ -1,6 +1,7 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@ -28,8 +29,10 @@ class DriftArchivePage extends StatelessWidget {
}, },
), ),
], ],
child: const Timeline( child: Timeline(
appBar: MesmerizingSliverAppBar(), appBar: MesmerizingSliverAppBar(
title: 'archive'.t(context: context),
),
), ),
); );
} }

View File

@ -1,6 +1,7 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@ -28,8 +29,10 @@ class DriftFavoritePage extends StatelessWidget {
}, },
), ),
], ],
child: const Timeline( child: Timeline(
appBar: MesmerizingSliverAppBar(), appBar: MesmerizingSliverAppBar(
title: 'favorites'.t(context: context),
),
), ),
); );
} }

View File

@ -1,19 +1,45 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/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/image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.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 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final timelineService = ref.watch(timelineServiceProvider); final timelineService = ref.watch(timelineServiceProvider);
final assetCount = timelineService.totalAssets; final assetCount = timelineService.totalAssets;
final isMultiSelectEnabled =
return SliverAppBar( ref.watch(multiSelectProvider.select((s) => s.isEnabled));
return SliverAnimatedOpacity(
duration: Durations.medium1,
opacity: isMultiSelectEnabled ? 0 : 1,
sliver: SliverAppBar(
expandedHeight: 300.0, expandedHeight: 300.0,
floating: false, floating: false,
pinned: true, pinned: true,
@ -23,30 +49,15 @@ class MesmerizingSliverAppBar extends ConsumerWidget {
builder: (context, constraints) { builder: (context, constraints) {
final settings = context final settings = context
.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>(); .dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final deltaExtent = final scrollProgress = _calculateScrollProgress(settings);
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( return FlexibleSpaceBar(
centerTitle: true, centerTitle: true,
titlePadding: EdgeInsets.lerp(
const EdgeInsets.only(left: 16, bottom: 16),
const EdgeInsets.only(left: 0, bottom: 16),
t,
),
title: AnimatedSwitcher( title: AnimatedSwitcher(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
child: t > 0.95 child: scrollProgress > 0.95
? Text( ? Text(
'Favorites', title,
key: const ValueKey('collapsed'),
style: TextStyle( style: TextStyle(
color: context.primaryColor, color: context.primaryColor,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -57,11 +68,13 @@ class MesmerizingSliverAppBar extends ConsumerWidget {
), ),
background: _ExpandedBackground( background: _ExpandedBackground(
assetCount: assetCount, assetCount: assetCount,
scrollProgress: t, scrollProgress: scrollProgress,
title: title,
), ),
); );
}, },
), ),
),
); );
} }
} }
@ -69,10 +82,12 @@ class MesmerizingSliverAppBar extends ConsumerWidget {
class _ExpandedBackground extends ConsumerWidget { class _ExpandedBackground extends ConsumerWidget {
final int assetCount; final int assetCount;
final double scrollProgress; final double scrollProgress;
final String title;
const _ExpandedBackground({ const _ExpandedBackground({
required this.assetCount, required this.assetCount,
required this.scrollProgress, required this.scrollProgress,
required this.title,
}); });
@override @override
@ -82,7 +97,6 @@ class _ExpandedBackground extends ConsumerWidget {
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
// Random asset background with zooming effect
Transform.translate( Transform.translate(
offset: Offset(0, scrollProgress * 50), offset: Offset(0, scrollProgress * 50),
child: Transform.scale( child: Transform.scale(
@ -90,96 +104,62 @@ class _ExpandedBackground extends ConsumerWidget {
child: _RandomAssetBackground(timelineService: timelineService), child: _RandomAssetBackground(timelineService: timelineService),
), ),
), ),
Container(
// Animated gradient overlay
AnimatedContainer(
duration: const Duration(milliseconds: 300),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: Theme.of(context).brightness == Brightness.dark colors: [
? [ Colors.transparent,
Colors.black Colors.transparent,
.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( Colors.black.withValues(
alpha: 0.3 + (scrollProgress * 0.2), alpha: 0.3 + (scrollProgress * 0.2),
), // Slightly dark at bottom ),
], ],
stops: const [0.0, 0.9, 1.0], stops: const [0.0, 0.7, 1.0],
), ),
), ),
), ),
// Title and count in lower left with fade animation
Positioned( Positioned(
bottom: 16, bottom: 16,
left: 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Hero( Text(
tag: 'favorites_title', title,
child: Material( style: const TextStyle(
color: Colors.transparent, color: Colors.white,
child: Text( fontSize: 36,
'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, fontWeight: FontWeight.bold,
letterSpacing: 0.5, letterSpacing: 0.5,
shadows: [ shadows: [
Shadow( Shadow(
offset: const Offset(0, 2), offset: Offset(0, 2),
blurRadius: 12, blurRadius: 12,
color: Theme.of(context).brightness == color: Colors.black45,
Brightness.dark
? Colors.black54
: Colors.black45,
), ),
], ],
), ),
), ),
),
),
const SizedBox(height: 6), const SizedBox(height: 6),
AnimatedContainer( AnimatedContainer(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
child: Text( child: Text(
'$assetCount ${assetCount == 1 ? 'favorite' : 'favorites'}', 'items_count'.t(
style: TextStyle( context: context,
color: Theme.of(context).brightness == Brightness.dark args: {"count": assetCount},
? Colors.white.withValues(alpha: 0.9) ),
: Colors.white.withValues(alpha: 0.95), style: context.textTheme.labelLarge?.copyWith(
fontSize: 16,
fontWeight: FontWeight.w500,
letterSpacing: 0.2, letterSpacing: 0.2,
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [ shadows: [
Shadow( const Shadow(
offset: const Offset(0, 1), offset: Offset(0, 1),
blurRadius: 6, blurRadius: 6,
color: color: Colors.black45,
Theme.of(context).brightness == Brightness.dark
? Colors.black45
: Colors.black38,
), ),
], ],
), ),
@ -188,15 +168,13 @@ class _ExpandedBackground extends ConsumerWidget {
], ],
), ),
), ),
),
),
], ],
); );
} }
} }
class _RandomAssetBackground extends StatefulWidget { class _RandomAssetBackground extends StatefulWidget {
final timelineService; final TimelineService timelineService;
const _RandomAssetBackground({required this.timelineService}); const _RandomAssetBackground({required this.timelineService});
@ -213,25 +191,24 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground>
late Animation<double> _fadeAnimation; late Animation<double> _fadeAnimation;
BaseAsset? _currentAsset; BaseAsset? _currentAsset;
BaseAsset? _nextAsset; BaseAsset? _nextAsset;
bool _isFirstLoad = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_zoomController = AnimationController( _zoomController = AnimationController(
duration: const Duration(seconds: 12), // Slower for more cinematic effect duration: const Duration(seconds: 12),
vsync: this, vsync: this,
); );
_fadeController = AnimationController( _fadeController = AnimationController(
duration: const Duration(milliseconds: 500), // Faster initial fade duration: const Duration(milliseconds: 500),
vsync: this, vsync: this,
); );
_zoomAnimation = Tween<double>( _zoomAnimation = Tween<double>(
begin: 1.0, // Start from full image begin: 1.0,
end: 1.3, // Zoom in gradually end: 1.3,
).animate( ).animate(
CurvedAnimation( CurvedAnimation(
parent: _zoomController, parent: _zoomController,
@ -240,8 +217,8 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground>
); );
_panAnimation = Tween<Offset>( _panAnimation = Tween<Offset>(
begin: Offset.zero, // Start centered begin: Offset.zero,
end: const Offset(0.15, -0.1), // Pan to top right corner end: const Offset(0.15, -0.1),
).animate( ).animate(
CurvedAnimation( CurvedAnimation(
parent: _zoomController, parent: _zoomController,
@ -255,21 +232,14 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground>
).animate( ).animate(
CurvedAnimation( CurvedAnimation(
parent: _fadeController, parent: _fadeController,
curve: Curves.easeOut, // Faster curve for initial load curve: Curves.easeOut,
), ),
); );
// Start loading immediately without waiting Future.delayed(
WidgetsBinding.instance.addPostFrameCallback((_) { const Duration(milliseconds: 100),
_loadRandomAssetFast(); () => _loadRandomAsset(),
}); );
// Also try a fallback approach
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted && _currentAsset == null) {
_loadRandomAsset();
}
});
} }
@override @override
@ -287,100 +257,42 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground>
}); });
} }
Future<void> _loadRandomAssetFast() async { Future<void> _loadRandomAsset() async {
// Try to load the first available asset immediately if (!mounted) {
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; return;
} }
}
if (widget.timelineService.totalAssets == 0) {
setState(() {
_currentAsset = null;
});
return;
} }
// Fallback: keep trying with regular method final randomIndex = (widget.timelineService.totalAssets > 1)
if (mounted) { ? DateTime.now().millisecond % widget.timelineService.totalAssets
_loadRandomAsset();
}
} catch (e) {
// Fallback to regular loading on error
if (mounted) {
_loadRandomAsset();
}
}
}
Future<void> _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; : 0;
final assets = widget.timelineService.getAssets(randomIndex, 1); final assets = widget.timelineService.getAssets(randomIndex, 1);
if (assets.isNotEmpty && mounted) { if (assets.isEmpty) {
return;
}
setState(() { setState(() {
_currentAsset = assets.first; _currentAsset = assets.first;
_isFirstLoad = false;
}); });
await _fadeController.forward(); await _fadeController.forward();
// Only start zoom cycle if not already running
if (_zoomController.status == AnimationStatus.dismissed) { if (_zoomController.status == AnimationStatus.dismissed) {
_startZoomCycle(); _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();
}
}
}
}
}
}
}
Future<void> _loadNextAsset() async { Future<void> _loadNextAsset() async {
if (!mounted) return; if (!mounted) {
return;
}
try { try {
if (widget.timelineService.totalAssets > 1) { if (widget.timelineService.totalAssets > 1) {
@ -406,31 +318,24 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground>
} }
} }
} else { } else {
// If only one asset, restart the zoom
if (mounted) {
_zoomController.reset(); _zoomController.reset();
_startZoomCycle(); _startZoomCycle();
} }
}
} catch (e) { } catch (e) {
// Handle error and restart cycle
if (mounted) {
_zoomController.reset(); _zoomController.reset();
_startZoomCycle(); _startZoomCycle();
} }
} }
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.timelineService.totalAssets == 0) { if (widget.timelineService.totalAssets == 0) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: isDark colors: context.isDarkTheme
? [ ? [
Colors.deepPurple.withValues(alpha: 0.8), Colors.deepPurple.withValues(alpha: 0.8),
Colors.indigo.withValues(alpha: 0.9), Colors.indigo.withValues(alpha: 0.9),
@ -448,7 +353,6 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground>
), ),
child: Stack( child: Stack(
children: [ children: [
// Floating elements for visual interest
Positioned( Positioned(
top: 40, top: 40,
right: 30, right: 30,
@ -457,7 +361,7 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground>
height: 80, height: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: isDark color: context.isDarkTheme
? Colors.white.withValues(alpha: 0.1) ? Colors.white.withValues(alpha: 0.1)
: Colors.white.withValues(alpha: 0.2), : Colors.white.withValues(alpha: 0.2),
), ),
@ -471,7 +375,7 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground>
height: 60, height: 60,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: isDark color: context.isDarkTheme
? Colors.white.withValues(alpha: 0.08) ? Colors.white.withValues(alpha: 0.08)
: Colors.white.withValues(alpha: 0.15), : Colors.white.withValues(alpha: 0.15),
), ),
@ -485,7 +389,7 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground>
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: isDark color: context.isDarkTheme
? Colors.white.withValues(alpha: 0.06) ? Colors.white.withValues(alpha: 0.06)
: Colors.white.withValues(alpha: 0.12), : Colors.white.withValues(alpha: 0.12),
), ),
@ -496,7 +400,7 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground>
child: Icon( child: Icon(
Icons.favorite_outline, Icons.favorite_outline,
size: 100, size: 100,
color: isDark color: context.isDarkTheme
? Colors.white.withValues(alpha: 0.15) ? Colors.white.withValues(alpha: 0.15)
: Colors.white.withValues(alpha: 0.25), : Colors.white.withValues(alpha: 0.25),
), ),
@ -507,14 +411,13 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground>
} }
if (_currentAsset == null) { if (_currentAsset == null) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: isDark colors: context.isDarkTheme
? [ ? [
Colors.deepPurple.withValues(alpha: 0.4), Colors.deepPurple.withValues(alpha: 0.4),
Colors.indigo.withValues(alpha: 0.5), Colors.indigo.withValues(alpha: 0.5),
@ -527,14 +430,14 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground>
], ],
), ),
), ),
child: Center( child: const Center(
child: SizedBox( child: SizedBox(
width: 24, width: 24,
height: 24, height: 24,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
isDark ? Colors.white70 : Colors.white.withValues(alpha: 0.8), Colors.white70,
), ),
), ),
), ),
@ -572,8 +475,7 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground>
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: Theme.of(context).brightness == colors: context.isDarkTheme
Brightness.dark
? [ ? [
Colors.deepPurple.withValues(alpha: 0.3), Colors.deepPurple.withValues(alpha: 0.3),
Colors.indigo.withValues(alpha: 0.4), Colors.indigo.withValues(alpha: 0.4),
@ -595,8 +497,7 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground>
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: Theme.of(context).brightness == colors: context.isDarkTheme
Brightness.dark
? [ ? [
Colors.deepPurple.withValues(alpha: 0.6), Colors.deepPurple.withValues(alpha: 0.6),
Colors.indigo.withValues(alpha: 0.7), Colors.indigo.withValues(alpha: 0.7),