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,66 +1,79 @@
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 =
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<FlexibleSpaceBarSettings>();
final scrollProgress = _calculateScrollProgress(settings);
return SliverAppBar( return FlexibleSpaceBar(
expandedHeight: 300.0, centerTitle: true,
floating: false, title: AnimatedSwitcher(
pinned: true, duration: const Duration(milliseconds: 200),
snap: false, child: scrollProgress > 0.95
elevation: 0, ? Text(
flexibleSpace: LayoutBuilder( title,
builder: (context, constraints) { style: TextStyle(
final settings = context color: context.primaryColor,
.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>(); fontWeight: FontWeight.w600,
final deltaExtent = fontSize: 18,
settings?.maxExtent != null && settings?.minExtent != null ),
? settings!.maxExtent - settings.minExtent )
: 0.0; : null,
final t = deltaExtent > 0.0 ),
? (1.0 - background: _ExpandedBackground(
(settings!.currentExtent - settings.minExtent) / assetCount: assetCount,
deltaExtent) scrollProgress: scrollProgress,
.clamp(0.0, 1.0) title: title,
: 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,
),
);
},
), ),
); );
} }
@ -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,104 +104,68 @@ 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(
Colors.black alpha: 0.3 + (scrollProgress * 0.2),
.withValues(alpha: 0.5 + (scrollProgress * 0.3)), ),
Colors.black ],
.withValues(alpha: 0.8 + (scrollProgress * 0.2)), stops: const [0.0, 0.7, 1.0],
]
: [
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],
), ),
), ),
), ),
// Title and count in lower left with fade animation
Positioned( Positioned(
bottom: 16, bottom: 16,
left: 16, left: 16,
child: AnimatedOpacity( child: Column(
duration: const Duration(milliseconds: 200), crossAxisAlignment: CrossAxisAlignment.start,
opacity: (1.0 - scrollProgress).clamp(0.0, 1.0), mainAxisSize: MainAxisSize.min,
child: Transform.translate( children: [
offset: Offset(0, scrollProgress * 30), Text(
child: Column( title,
crossAxisAlignment: CrossAxisAlignment.start, style: const TextStyle(
mainAxisSize: MainAxisSize.min, color: Colors.white,
children: [ fontSize: 36,
Hero( fontWeight: FontWeight.bold,
tag: 'favorites_title', letterSpacing: 0.5,
child: Material( shadows: [
color: Colors.transparent, Shadow(
child: Text( offset: Offset(0, 2),
'Favorites', blurRadius: 12,
style: TextStyle( color: Colors.black45,
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,
),
],
),
),
), ),
), ],
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 { 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 {
// 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<void> _loadRandomAsset() async { Future<void> _loadRandomAsset() async {
try { if (!mounted) {
if (mounted && widget.timelineService.totalAssets > 0) { return;
final randomIndex = _isFirstLoad }
? 0 // Always load first asset on initial load for speed
: (widget.timelineService.totalAssets > 1) if (widget.timelineService.totalAssets == 0) {
? DateTime.now().millisecond % setState(() {
widget.timelineService.totalAssets _currentAsset = null;
: 0; });
final assets = widget.timelineService.getAssets(randomIndex, 1);
if (assets.isNotEmpty && mounted) { return;
setState(() { }
_currentAsset = assets.first;
_isFirstLoad = false; final randomIndex = (widget.timelineService.totalAssets > 1)
}); ? DateTime.now().millisecond % widget.timelineService.totalAssets
await _fadeController.forward(); : 0;
// Only start zoom cycle if not already running
if (_zoomController.status == AnimationStatus.dismissed) { final assets = widget.timelineService.getAssets(randomIndex, 1);
_startZoomCycle(); if (assets.isEmpty) {
} return;
} }
}
} catch (e) { setState(() {
// Handle error and retry once _currentAsset = assets.first;
if (mounted) { });
await Future.delayed(const Duration(milliseconds: 200));
if (mounted && _currentAsset == null) { await _fadeController.forward();
// Simple retry without recursion if (_zoomController.status == AnimationStatus.dismissed) {
if (widget.timelineService.totalAssets > 0) { _startZoomCycle();
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();
_startZoomCycle();
}
}
} catch (e) {
// Handle error and restart cycle
if (mounted) {
_zoomController.reset(); _zoomController.reset();
_startZoomCycle(); _startZoomCycle();
} }
} catch (e) {
_zoomController.reset();
_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),