feat: cool app bar

This commit is contained in:
Alex 2025-07-07 22:25:14 -05:00
parent d5eff115e8
commit 7173bdf60b
No known key found for this signature in database
GPG Key ID: 53CD082B3A5E1082
6 changed files with 658 additions and 12 deletions

View File

@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.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';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage() @RoutePage()
class DriftArchivePage extends StatelessWidget { class DriftArchivePage extends StatelessWidget {
@ -27,7 +28,9 @@ class DriftArchivePage extends StatelessWidget {
}, },
), ),
], ],
child: const Timeline(), child: const Timeline(
appBar: MesmerizingSliverAppBar(),
),
); );
} }
} }

View File

@ -1,9 +1,10 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.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';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage() @RoutePage()
class DriftFavoritePage extends StatelessWidget { class DriftFavoritePage extends StatelessWidget {
@ -27,7 +28,9 @@ class DriftFavoritePage extends StatelessWidget {
}, },
), ),
], ],
child: const Timeline(), child: const Timeline(
appBar: MesmerizingSliverAppBar(),
),
); );
} }
} }

View File

@ -27,7 +27,12 @@ class DriftLibraryPage extends ConsumerWidget {
return const Scaffold( return const Scaffold(
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
ImmichSliverAppBar(), ImmichSliverAppBar(
snap: false,
floating: false,
pinned: true,
showUploadButton: false,
),
_ActionButtonGrid(), _ActionButtonGrid(),
_CollectionCards(), _CollectionCards(),
_QuickAccessButtonList(), _QuickAccessButtonList(),

View File

@ -325,7 +325,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final isDraggingDown = currentExtent < previousExtent; final isDraggingDown = currentExtent < previousExtent;
previousExtent = currentExtent; previousExtent = currentExtent;
// Closes the bottom sheet if the user is dragging down // Closes the bottom sheet if the user is dragging down
if (isDraggingDown && delta.extent < 0.5) { if (isDraggingDown && delta.extent < 0.55) {
if (dragInProgress) { if (dragInProgress) {
blockGestures = true; blockGestures = true;
} }

View File

@ -20,10 +20,16 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
class Timeline extends StatelessWidget { class Timeline extends StatelessWidget {
const Timeline({super.key, this.topSliverWidget, this.topSliverWidgetHeight}); const Timeline({
super.key,
this.topSliverWidget,
this.topSliverWidgetHeight,
this.appBar,
});
final Widget? topSliverWidget; final Widget? topSliverWidget;
final double? topSliverWidgetHeight; final double? topSliverWidgetHeight;
final Widget? appBar;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -44,6 +50,7 @@ class Timeline extends StatelessWidget {
child: _SliverTimeline( child: _SliverTimeline(
topSliverWidget: topSliverWidget, topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight, topSliverWidgetHeight: topSliverWidgetHeight,
appBar: appBar,
), ),
), ),
), ),
@ -52,10 +59,15 @@ class Timeline extends StatelessWidget {
} }
class _SliverTimeline extends ConsumerStatefulWidget { class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline({this.topSliverWidget, this.topSliverWidgetHeight}); const _SliverTimeline({
this.topSliverWidget,
this.topSliverWidgetHeight,
this.appBar,
});
final Widget? topSliverWidget; final Widget? topSliverWidget;
final double? topSliverWidgetHeight; final double? topSliverWidgetHeight;
final Widget? appBar;
@override @override
ConsumerState createState() => _SliverTimelineState(); ConsumerState createState() => _SliverTimelineState();
@ -105,6 +117,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
primary: true, primary: true,
cacheExtent: maxHeight * 2, cacheExtent: maxHeight * 2,
slivers: [ slivers: [
widget.appBar ??
const ImmichSliverAppBar( const ImmichSliverAppBar(
floating: true, floating: true,
pinned: false, pinned: false,

View File

@ -0,0 +1,622 @@
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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
class MesmerizingSliverAppBar extends ConsumerWidget {
const MesmerizingSliverAppBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final timelineService = ref.watch(timelineServiceProvider);
final assetCount = timelineService.totalAssets;
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,
),
);
},
),
);
}
}
class _ExpandedBackground extends ConsumerWidget {
final int assetCount;
final double scrollProgress;
const _ExpandedBackground({
required this.assetCount,
required this.scrollProgress,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final timelineService = ref.watch(timelineServiceProvider);
return Stack(
fit: StackFit.expand,
children: [
// Random asset background with zooming effect
Transform.translate(
offset: Offset(0, scrollProgress * 50),
child: Transform.scale(
scale: 1.4 - (scrollProgress * 0.2),
child: _RandomAssetBackground(timelineService: timelineService),
),
),
// Animated gradient overlay
AnimatedContainer(
duration: const Duration(milliseconds: 300),
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],
),
),
),
// 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,
),
],
),
),
),
),
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,
),
],
),
),
),
],
),
),
),
),
],
);
}
}
class _RandomAssetBackground extends StatefulWidget {
final timelineService;
const _RandomAssetBackground({required this.timelineService});
@override
State<_RandomAssetBackground> createState() => _RandomAssetBackgroundState();
}
class _RandomAssetBackgroundState extends State<_RandomAssetBackground>
with TickerProviderStateMixin {
late AnimationController _zoomController;
late AnimationController _fadeController;
late Animation<double> _zoomAnimation;
late Animation<Offset> _panAnimation;
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
vsync: this,
);
_fadeController = AnimationController(
duration: const Duration(milliseconds: 500), // Faster initial fade
vsync: this,
);
_zoomAnimation = Tween<double>(
begin: 1.0, // Start from full image
end: 1.3, // Zoom in gradually
).animate(
CurvedAnimation(
parent: _zoomController,
curve: Curves.easeInOut,
),
);
_panAnimation = Tween<Offset>(
begin: Offset.zero, // Start centered
end: const Offset(0.15, -0.1), // Pan to top right corner
).animate(
CurvedAnimation(
parent: _zoomController,
curve: Curves.easeInOut,
),
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(
CurvedAnimation(
parent: _fadeController,
curve: Curves.easeOut, // Faster curve for initial load
),
);
// 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();
}
});
}
@override
void dispose() {
_zoomController.dispose();
_fadeController.dispose();
super.dispose();
}
void _startZoomCycle() {
_zoomController.forward().then((_) {
if (mounted) {
_loadNextAsset();
}
});
}
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();
}
}
}
}
}
}
}
Future<void> _loadNextAsset() async {
if (!mounted) return;
try {
if (widget.timelineService.totalAssets > 1) {
final randomIndex =
DateTime.now().millisecond % widget.timelineService.totalAssets;
final assets = widget.timelineService.getAssets(randomIndex, 1);
if (assets.isNotEmpty && mounted) {
setState(() {
_nextAsset = assets.first;
});
await _fadeController.reverse();
if (mounted) {
setState(() {
_currentAsset = _nextAsset;
_nextAsset = null;
});
_zoomController.reset();
await _fadeController.forward();
_startZoomCycle();
}
}
} 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();
}
}
}
@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.deepPurple.withValues(alpha: 0.8),
Colors.indigo.withValues(alpha: 0.9),
Colors.purple.withValues(alpha: 0.8),
Colors.pink.withValues(alpha: 0.7),
]
: [
Colors.pink.shade300.withValues(alpha: 0.9),
Colors.purple.shade400.withValues(alpha: 0.8),
Colors.indigo.shade400.withValues(alpha: 0.9),
Colors.blue.shade500.withValues(alpha: 0.8),
],
stops: const [0.0, 0.3, 0.7, 1.0],
),
),
child: Stack(
children: [
// Floating elements for visual interest
Positioned(
top: 40,
right: 30,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isDark
? Colors.white.withValues(alpha: 0.1)
: Colors.white.withValues(alpha: 0.2),
),
),
),
Positioned(
bottom: 100,
left: 50,
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.white.withValues(alpha: 0.15),
),
),
),
Positioned(
top: 120,
left: 20,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.white.withValues(alpha: 0.12),
),
),
),
// Heart icon for empty favorites
Center(
child: Icon(
Icons.favorite_outline,
size: 100,
color: isDark
? Colors.white.withValues(alpha: 0.15)
: Colors.white.withValues(alpha: 0.25),
),
),
],
),
);
}
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.deepPurple.withValues(alpha: 0.4),
Colors.indigo.withValues(alpha: 0.5),
Colors.purple.withValues(alpha: 0.4),
]
: [
Colors.blue.shade200.withValues(alpha: 0.6),
Colors.purple.shade300.withValues(alpha: 0.5),
Colors.indigo.shade300.withValues(alpha: 0.6),
],
),
),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
isDark ? Colors.white70 : Colors.white.withValues(alpha: 0.8),
),
),
),
),
);
}
return AnimatedBuilder(
animation:
Listenable.merge([_zoomAnimation, _panAnimation, _fadeAnimation]),
builder: (context, child) {
return Transform.translate(
offset: Offset(
_panAnimation.value.dx * 100, // Convert to pixel offset
_panAnimation.value.dy * 100,
),
child: Transform.scale(
scale: _zoomAnimation.value,
child: FadeTransition(
opacity: _fadeAnimation,
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Image(
image: getFullImageProvider(_currentAsset!),
fit: BoxFit.cover,
frameBuilder:
(context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
// Show a subtle loading state while the full image loads
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: Theme.of(context).brightness ==
Brightness.dark
? [
Colors.deepPurple.withValues(alpha: 0.3),
Colors.indigo.withValues(alpha: 0.4),
Colors.purple.withValues(alpha: 0.3),
]
: [
Colors.blue.shade200.withValues(alpha: 0.5),
Colors.purple.shade300.withValues(alpha: 0.4),
Colors.indigo.shade300.withValues(alpha: 0.5),
],
),
),
);
},
errorBuilder: (context, error, stackTrace) {
// Fallback to a gradient if image fails to load
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: Theme.of(context).brightness ==
Brightness.dark
? [
Colors.deepPurple.withValues(alpha: 0.6),
Colors.indigo.withValues(alpha: 0.7),
Colors.purple.withValues(alpha: 0.6),
]
: [
Colors.blue.shade300.withValues(alpha: 0.7),
Colors.purple.shade400.withValues(alpha: 0.6),
Colors.indigo.shade400.withValues(alpha: 0.7),
],
),
),
);
},
),
),
),
),
);
},
);
}
}