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: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),
),
),
);
}

View File

@ -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),
),
),
);
}

View File

@ -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<FlexibleSpaceBarSettings>();
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<FlexibleSpaceBarSettings>();
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<double> _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<double>(
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<Offset>(
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<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 {
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<void> _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<Color>(
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),