chore(mobile): centralize duplicate mesmerizing header components (#31590)

* chore(mobile): centralize duplicate mesmerizing header components

* Fix formatting
This commit is contained in:
Adam Gastineau
2026-09-15 12:33:15 -07:00
committed by GitHub
parent 962ffaacc1
commit 64de38092e
10 changed files with 315 additions and 815 deletions
@@ -28,7 +28,7 @@ class ArchivePage extends StatelessWidget {
}),
],
child: Timeline(
appBar: MesmerizingSliverAppBar(title: context.t.archive, icon: Icons.archive_outlined),
appBar: MesmerizingSliverAppBar(title: context.t.archive),
bottomSheet: const ArchiveBottomSheet(),
),
);
@@ -28,7 +28,7 @@ class FavoritePage extends StatelessWidget {
}),
],
child: Timeline(
appBar: MesmerizingSliverAppBar(title: context.t.favorites, icon: Icons.favorite_outline),
appBar: MesmerizingSliverAppBar(title: context.t.favorites),
bottomSheet: const FavoriteBottomSheet(),
),
);
@@ -29,7 +29,7 @@ class PartnerDetailPage extends StatelessWidget {
}),
],
child: Timeline(
appBar: MesmerizingSliverAppBar(title: partner.name, icon: Icons.person_outline),
appBar: MesmerizingSliverAppBar(title: partner.name),
topSliverWidget: _InfoBox(partner: partner),
topSliverWidgetHeight: 110,
bottomSheet: const PartnerDetailBottomSheet(),
@@ -21,9 +21,7 @@ class PlaceDetailPage extends StatelessWidget {
return timelineService;
}),
],
child: Timeline(
appBar: MesmerizingSliverAppBar(title: place, icon: Icons.location_on),
),
child: Timeline(appBar: MesmerizingSliverAppBar(title: place)),
);
}
}
@@ -200,7 +200,6 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
child: Timeline(
topSliverWidget: PendingUploadsBanner(albumId: _album.id),
appBar: RemoteAlbumSliverAppBar(
icon: Icons.photo_album_outlined,
kebabMenu: _AlbumKebabMenu(
album: _album,
onDeleteAlbum: () => deleteAlbum(context),
@@ -0,0 +1,44 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
/// Displays the number of timeline items in this view
///
/// Intended for use in Album style views
class ItemCountText extends ConsumerStatefulWidget {
final TextStyle style;
const ItemCountText({super.key, required this.style});
@override
ConsumerState<ItemCountText> createState() => _ItemCountTextState();
}
class _ItemCountTextState extends ConsumerState<ItemCountText> {
StreamSubscription? _reloadSubscription;
@override
void initState() {
super.initState();
_reloadSubscription = EventStream.shared.listen<TimelineReloadEvent>((_) => setState(() {}));
}
@override
void dispose() {
unawaited(_reloadSubscription?.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) {
final assetCount = ref.watch(timelineServiceProvider.select((service) => service.totalAssets));
return Text(context.t.items_count(count: assetCount), style: context.textTheme.labelLarge?.merge(widget.style));
}
}
@@ -0,0 +1,223 @@
import 'dart:async';
import 'package:flutter/material.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/presentation/widgets/images/image_provider.dart';
/// Randomly selects an asset from the provided [TimelineService] for a persistent Ken Burns zoom
class RandomAssetBackgroundImage extends StatefulWidget {
final TimelineService timelineService;
const RandomAssetBackgroundImage({super.key, required this.timelineService});
@override
State<RandomAssetBackgroundImage> createState() => _RandomAssetBackgroundImageState();
}
class _RandomAssetBackgroundImageState extends State<RandomAssetBackgroundImage> with TickerProviderStateMixin {
late AnimationController _zoomController;
late AnimationController _crossFadeController;
late Animation<double> _zoomAnimation;
late Animation<Offset> _panAnimation;
late Animation<double> _crossFadeAnimation;
BaseAsset? _currentAsset;
BaseAsset? _nextAsset;
bool _isZoomingIn = true;
@override
void initState() {
super.initState();
_zoomController = AnimationController(
duration: const Duration(seconds: 12),
vsync: this,
animationBehavior: AnimationBehavior.preserve,
);
_crossFadeController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
animationBehavior: AnimationBehavior.preserve,
);
_zoomAnimation = Tween<double>(
begin: 1.0,
end: 1.2,
).animate(CurvedAnimation(parent: _zoomController, curve: Curves.easeInOut));
_panAnimation = Tween<Offset>(
begin: Offset.zero,
end: const Offset(0.5, -0.5),
).animate(CurvedAnimation(parent: _zoomController, curve: Curves.easeInOut));
_crossFadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(parent: _crossFadeController, curve: Curves.easeInOutCubic));
Future.delayed(Durations.medium1, () => _loadFirstAsset());
}
@override
void dispose() {
_zoomController.dispose();
_crossFadeController.dispose();
super.dispose();
}
void _startAnimationCycle() {
if (_isZoomingIn) {
unawaited(
_zoomController.forward().then((_) {
unawaited(_loadNextAsset());
}),
);
} else {
unawaited(
_zoomController.reverse().then((_) {
unawaited(_loadNextAsset());
}),
);
}
}
Future<void> _loadFirstAsset() async {
if (!mounted) {
return;
}
if (widget.timelineService.totalAssets == 0) {
setState(() {
_currentAsset = null;
});
return;
}
setState(() {
_currentAsset = widget.timelineService.getRandomAsset();
});
await _crossFadeController.forward();
if (_zoomController.status == AnimationStatus.dismissed) {
if (_isZoomingIn) {
_zoomController.reset();
} else {
_zoomController.value = 1.0;
}
_startAnimationCycle();
}
}
Future<void> _loadNextAsset() async {
if (!mounted) {
return;
}
try {
if (widget.timelineService.totalAssets > 1) {
// Load next asset while keeping current one visible
final nextAsset = widget.timelineService.getRandomAsset();
setState(() {
_nextAsset = nextAsset;
});
await _crossFadeController.reverse();
setState(() {
_currentAsset = _nextAsset;
_nextAsset = null;
});
_crossFadeController.value = 1.0;
_isZoomingIn = !_isZoomingIn;
_startAnimationCycle();
}
} catch (e) {
_zoomController.reset();
_startAnimationCycle();
}
}
@override
Widget build(BuildContext context) {
if (widget.timelineService.totalAssets == 0) {
return const SizedBox.shrink();
}
return AnimatedBuilder(
animation: Listenable.merge([_zoomAnimation, _panAnimation, _crossFadeAnimation]),
builder: (context, child) {
return Transform.scale(
scale: _zoomAnimation.value,
child: Transform.translate(
offset: _panAnimation.value,
child: Stack(
fit: StackFit.expand,
children: [
// Current image
if (_currentAsset != null)
Opacity(
opacity: _crossFadeAnimation.value,
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Image(
alignment: Alignment.topRight,
image: getFullImageProvider(_currentAsset!),
fit: BoxFit.cover,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return Container();
},
errorBuilder: (context, error, stackTrace) {
return SizedBox(
width: double.infinity,
height: double.infinity,
child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]),
);
},
),
),
),
if (_nextAsset != null)
Opacity(
opacity: 1.0 - _crossFadeAnimation.value,
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Image(
alignment: Alignment.topRight,
image: getFullImageProvider(_nextAsset!),
fit: BoxFit.cover,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return const SizedBox.shrink();
},
errorBuilder: (context, error, stackTrace) {
return SizedBox(
width: double.infinity,
height: double.infinity,
child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]),
);
},
),
),
),
],
),
),
);
},
);
}
}
@@ -3,21 +3,16 @@ import 'dart:io';
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/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/sliver_app_bar/item_count_text.widget.dart';
import 'package:immich_mobile/presentation/widgets/sliver_app_bar/random_asset_background_image.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class MesmerizingSliverAppBar extends ConsumerStatefulWidget {
const MesmerizingSliverAppBar({super.key, required this.title, this.icon = Icons.camera});
const MesmerizingSliverAppBar({super.key, required this.title});
final String title;
final IconData icon;
@override
ConsumerState<MesmerizingSliverAppBar> createState() => _MesmerizingSliverAppBarState();
}
@@ -94,11 +89,7 @@ class _MesmerizingSliverAppBarState extends ConsumerState<MesmerizingSliverAppBa
)
: null,
),
background: _ExpandedBackground(
scrollProgress: scrollProgress,
title: widget.title,
icon: widget.icon,
),
background: _ExpandedBackground(scrollProgress: scrollProgress, title: widget.title),
);
},
),
@@ -109,9 +100,8 @@ class _MesmerizingSliverAppBarState extends ConsumerState<MesmerizingSliverAppBa
class _ExpandedBackground extends ConsumerStatefulWidget {
final double scrollProgress;
final String title;
final IconData icon;
const _ExpandedBackground({required this.scrollProgress, required this.title, required this.icon});
const _ExpandedBackground({required this.scrollProgress, required this.title});
@override
ConsumerState<_ExpandedBackground> createState() => _ExpandedBackgroundState();
@@ -156,7 +146,7 @@ class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with S
offset: Offset(0, widget.scrollProgress * 50),
child: Transform.scale(
scale: 1.4 - (widget.scrollProgress * 0.2),
child: _RandomAssetBackground(timelineService: timelineService, icon: widget.icon),
child: RandomAssetBackgroundImage(timelineService: timelineService),
),
),
Container(
@@ -200,7 +190,16 @@ class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with S
),
),
),
AnimatedContainer(duration: const Duration(milliseconds: 300), child: const _ItemCountText()),
AnimatedContainer(
duration: const Duration(milliseconds: 300),
child: const ItemCountText(
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [Shadow(offset: Offset(0, 1), blurRadius: 6, color: Colors.black45)],
),
),
),
],
),
),
@@ -209,258 +208,3 @@ class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with S
);
}
}
class _ItemCountText extends ConsumerStatefulWidget {
const _ItemCountText();
@override
ConsumerState<_ItemCountText> createState() => _ItemCountTextState();
}
class _ItemCountTextState extends ConsumerState<_ItemCountText> {
StreamSubscription? _reloadSubscription;
@override
void initState() {
super.initState();
_reloadSubscription = EventStream.shared.listen<TimelineReloadEvent>((_) => setState(() {}));
}
@override
void dispose() {
unawaited(_reloadSubscription?.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) {
final assetCount = ref.watch(timelineServiceProvider.select((s) => s.totalAssets));
return Text(
context.t.items_count(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)],
),
);
}
}
class _RandomAssetBackground extends StatefulWidget {
final TimelineService timelineService;
final IconData icon;
const _RandomAssetBackground({required this.timelineService, required this.icon});
@override
State<_RandomAssetBackground> createState() => _RandomAssetBackgroundState();
}
class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with TickerProviderStateMixin {
late AnimationController _zoomController;
late AnimationController _crossFadeController;
late Animation<double> _zoomAnimation;
late Animation<Offset> _panAnimation;
late Animation<double> _crossFadeAnimation;
BaseAsset? _currentAsset;
BaseAsset? _nextAsset;
bool _isZoomingIn = true;
@override
void initState() {
super.initState();
_zoomController = AnimationController(
duration: const Duration(seconds: 12),
vsync: this,
animationBehavior: AnimationBehavior.preserve,
);
_crossFadeController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
animationBehavior: AnimationBehavior.preserve,
);
_zoomAnimation = Tween<double>(
begin: 1.0,
end: 1.2,
).animate(CurvedAnimation(parent: _zoomController, curve: Curves.easeInOut));
_panAnimation = Tween<Offset>(
begin: Offset.zero,
end: const Offset(0.5, -0.5),
).animate(CurvedAnimation(parent: _zoomController, curve: Curves.easeInOut));
_crossFadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(parent: _crossFadeController, curve: Curves.easeInOutCubic));
Future.delayed(Durations.medium1, () => _loadFirstAsset());
}
@override
void dispose() {
_zoomController.dispose();
_crossFadeController.dispose();
super.dispose();
}
void _startAnimationCycle() {
if (_isZoomingIn) {
unawaited(
_zoomController.forward().then((_) {
unawaited(_loadNextAsset());
}),
);
} else {
unawaited(
_zoomController.reverse().then((_) {
unawaited(_loadNextAsset());
}),
);
}
}
Future<void> _loadFirstAsset() async {
if (!mounted) {
return;
}
if (widget.timelineService.totalAssets == 0) {
setState(() {
_currentAsset = null;
});
return;
}
setState(() {
_currentAsset = widget.timelineService.getRandomAsset();
});
await _crossFadeController.forward();
if (_zoomController.status == AnimationStatus.dismissed) {
if (_isZoomingIn) {
_zoomController.reset();
} else {
_zoomController.value = 1.0;
}
_startAnimationCycle();
}
}
Future<void> _loadNextAsset() async {
if (!mounted) {
return;
}
try {
if (widget.timelineService.totalAssets > 1) {
// Load next asset while keeping current one visible
final nextAsset = widget.timelineService.getRandomAsset();
setState(() {
_nextAsset = nextAsset;
});
await _crossFadeController.reverse();
setState(() {
_currentAsset = _nextAsset;
_nextAsset = null;
});
_crossFadeController.value = 1.0;
_isZoomingIn = !_isZoomingIn;
_startAnimationCycle();
}
} catch (e) {
_zoomController.reset();
_startAnimationCycle();
}
}
@override
Widget build(BuildContext context) {
if (widget.timelineService.totalAssets == 0) {
return const SizedBox.shrink();
}
return AnimatedBuilder(
animation: Listenable.merge([_zoomAnimation, _panAnimation, _crossFadeAnimation]),
builder: (context, child) {
return Transform.scale(
scale: _zoomAnimation.value,
child: Transform.translate(
offset: _panAnimation.value,
child: Stack(
fit: StackFit.expand,
children: [
// Current image
if (_currentAsset != null)
Opacity(
opacity: _crossFadeAnimation.value,
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Image(
alignment: Alignment.topRight,
image: getFullImageProvider(_currentAsset!),
fit: BoxFit.cover,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return Container();
},
errorBuilder: (context, error, stackTrace) {
return SizedBox(
width: double.infinity,
height: double.infinity,
child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]),
);
},
),
),
),
if (_nextAsset != null)
Opacity(
opacity: 1.0 - _crossFadeAnimation.value,
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Image(
alignment: Alignment.topRight,
image: getFullImageProvider(_nextAsset!),
fit: BoxFit.cover,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return const SizedBox.shrink();
},
errorBuilder: (context, error, stackTrace) {
return SizedBox(
width: double.infinity,
height: double.infinity,
child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]),
);
},
),
),
),
],
),
),
);
},
);
}
}
@@ -5,15 +5,12 @@ import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
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/models/events.model.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/sliver_app_bar/item_count_text.widget.dart';
import 'package:immich_mobile/presentation/widgets/sliver_app_bar/random_asset_background_image.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
@@ -191,7 +188,7 @@ class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with S
offset: Offset(0, widget.scrollProgress * 50),
child: Transform.scale(
scale: 1.4 - (widget.scrollProgress * 0.2),
child: _RandomAssetBackground(timelineService: timelineService),
child: RandomAssetBackgroundImage(timelineService: timelineService),
),
),
ClipRect(
@@ -272,7 +269,16 @@ class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with S
),
),
),
AnimatedContainer(duration: const Duration(milliseconds: 300), child: const _ItemCountText()),
AnimatedContainer(
duration: const Duration(milliseconds: 300),
child: const ItemCountText(
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [Shadow(offset: Offset(0, 1), blurRadius: 6, color: Colors.black45)],
),
),
),
const SizedBox(height: 8),
GestureDetector(
onTap: widget.onBirthdayTap,
@@ -316,256 +322,3 @@ class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with S
);
}
}
class _ItemCountText extends ConsumerStatefulWidget {
const _ItemCountText();
@override
ConsumerState<_ItemCountText> createState() => _ItemCountTextState();
}
class _ItemCountTextState extends ConsumerState<_ItemCountText> {
StreamSubscription? _reloadSubscription;
@override
void initState() {
super.initState();
_reloadSubscription = EventStream.shared.listen<TimelineReloadEvent>((_) => setState(() {}));
}
@override
void dispose() {
unawaited(_reloadSubscription?.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) {
final assetCount = ref.watch(timelineServiceProvider.select((s) => s.totalAssets));
return Text(
context.t.items_count(count: assetCount),
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [const Shadow(offset: Offset(0, 1), blurRadius: 6, color: Colors.black45)],
),
);
}
}
class _RandomAssetBackground extends StatefulWidget {
final TimelineService timelineService;
const _RandomAssetBackground({required this.timelineService});
@override
State<_RandomAssetBackground> createState() => _RandomAssetBackgroundState();
}
class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with TickerProviderStateMixin {
late AnimationController _zoomController;
late AnimationController _crossFadeController;
late Animation<double> _zoomAnimation;
late Animation<Offset> _panAnimation;
late Animation<double> _crossFadeAnimation;
BaseAsset? _currentAsset;
BaseAsset? _nextAsset;
bool _isZoomingIn = true;
@override
void initState() {
super.initState();
_zoomController = AnimationController(
duration: const Duration(seconds: 12),
vsync: this,
animationBehavior: AnimationBehavior.preserve,
);
_crossFadeController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
animationBehavior: AnimationBehavior.preserve,
);
_zoomAnimation = Tween<double>(
begin: 1.0,
end: 1.2,
).animate(CurvedAnimation(parent: _zoomController, curve: Curves.easeInOut));
_panAnimation = Tween<Offset>(
begin: Offset.zero,
end: const Offset(0.5, -0.5),
).animate(CurvedAnimation(parent: _zoomController, curve: Curves.easeInOut));
_crossFadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(parent: _crossFadeController, curve: Curves.easeInOutCubic));
Future.delayed(Durations.medium1, () => _loadFirstAsset());
}
@override
void dispose() {
_zoomController.dispose();
_crossFadeController.dispose();
super.dispose();
}
void _startAnimationCycle() {
if (_isZoomingIn) {
unawaited(
_zoomController.forward().then((_) {
unawaited(_loadNextAsset());
}),
);
} else {
unawaited(
_zoomController.reverse().then((_) {
unawaited(_loadNextAsset());
}),
);
}
}
Future<void> _loadFirstAsset() async {
if (!mounted) {
return;
}
if (widget.timelineService.totalAssets == 0) {
setState(() {
_currentAsset = null;
});
return;
}
setState(() {
_currentAsset = widget.timelineService.getRandomAsset();
});
await _crossFadeController.forward();
if (_zoomController.status == AnimationStatus.dismissed) {
if (_isZoomingIn) {
_zoomController.reset();
} else {
_zoomController.value = 1.0;
}
_startAnimationCycle();
}
}
Future<void> _loadNextAsset() async {
if (!mounted) {
return;
}
try {
if (widget.timelineService.totalAssets > 1) {
// Load next asset while keeping current one visible
final nextAsset = widget.timelineService.getRandomAsset();
setState(() {
_nextAsset = nextAsset;
});
await _crossFadeController.reverse();
setState(() {
_currentAsset = _nextAsset;
_nextAsset = null;
});
_crossFadeController.value = 1.0;
_isZoomingIn = !_isZoomingIn;
_startAnimationCycle();
}
} catch (e) {
_zoomController.reset();
_startAnimationCycle();
}
}
@override
Widget build(BuildContext context) {
if (widget.timelineService.totalAssets == 0) {
return const SizedBox.shrink();
}
return AnimatedBuilder(
animation: Listenable.merge([_zoomAnimation, _panAnimation, _crossFadeAnimation]),
builder: (context, child) {
return Transform.scale(
scale: _zoomAnimation.value,
child: Transform.translate(
offset: _panAnimation.value,
child: Stack(
fit: StackFit.expand,
children: [
// Current image
if (_currentAsset != null)
Opacity(
opacity: _crossFadeAnimation.value,
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Image(
alignment: Alignment.topRight,
image: getFullImageProvider(_currentAsset!),
fit: BoxFit.cover,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return Container();
},
errorBuilder: (context, error, stackTrace) {
return SizedBox(
width: double.infinity,
height: double.infinity,
child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]),
);
},
),
),
),
if (_nextAsset != null)
Opacity(
opacity: 1.0 - _crossFadeAnimation.value,
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Image(
alignment: Alignment.topRight,
image: getFullImageProvider(_nextAsset!),
fit: BoxFit.cover,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return const SizedBox.shrink();
},
errorBuilder: (context, error, stackTrace) {
return SizedBox(
width: double.infinity,
height: double.infinity,
child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]),
);
},
),
),
),
],
),
),
);
},
);
}
}
@@ -5,14 +5,10 @@ import 'dart:ui';
import 'package:auto_route/auto_route.dart';
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/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/datetime_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/sliver_app_bar/item_count_text.widget.dart';
import 'package:immich_mobile/presentation/widgets/sliver_app_bar/random_asset_background_image.widget.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@@ -21,15 +17,8 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/album/remote_album_shared_user_icons.dart';
class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget {
const RemoteAlbumSliverAppBar({
super.key,
this.icon = Icons.camera,
required this.kebabMenu,
this.onEditTitle,
this.onActivity,
});
const RemoteAlbumSliverAppBar({super.key, required this.kebabMenu, this.onEditTitle, this.onActivity});
final IconData icon;
final Widget kebabMenu;
final void Function()? onEditTitle;
final void Function()? onActivity;
@@ -131,11 +120,7 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
});
return FlexibleSpaceBar(
background: _ExpandedBackground(
scrollProgress: scrollProgress,
icon: widget.icon,
onEditTitle: widget.onEditTitle,
),
background: _ExpandedBackground(scrollProgress: scrollProgress, onEditTitle: widget.onEditTitle),
);
},
),
@@ -145,10 +130,9 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
class _ExpandedBackground extends ConsumerStatefulWidget {
final double scrollProgress;
final IconData icon;
final void Function()? onEditTitle;
const _ExpandedBackground({required this.scrollProgress, required this.icon, this.onEditTitle});
const _ExpandedBackground({required this.scrollProgress, this.onEditTitle});
@override
ConsumerState<_ExpandedBackground> createState() => _ExpandedBackgroundState();
@@ -199,7 +183,7 @@ class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with S
offset: Offset(0, widget.scrollProgress * 50),
child: Transform.scale(
scale: 1.4 - (widget.scrollProgress * 0.2),
child: _RandomAssetBackground(timelineService: timelineService, icon: widget.icon),
child: RandomAssetBackgroundImage(timelineService: timelineService),
),
),
ClipRect(
@@ -252,7 +236,15 @@ class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with S
shadows: [Shadow(offset: Offset(0, 2), blurRadius: 12, color: Colors.black87)],
),
),
AnimatedContainer(duration: const Duration(milliseconds: 300), child: const _ItemCountText()),
AnimatedContainer(
duration: const Duration(milliseconds: 300),
child: const ItemCountText(
style: TextStyle(
color: Colors.white,
shadows: [Shadow(offset: Offset(0, 2), blurRadius: 12, color: Colors.black87)],
),
),
),
],
),
GestureDetector(
@@ -289,259 +281,6 @@ class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with S
}
}
class _ItemCountText extends ConsumerStatefulWidget {
const _ItemCountText();
@override
ConsumerState<_ItemCountText> createState() => _ItemCountTextState();
}
class _ItemCountTextState extends ConsumerState<_ItemCountText> {
StreamSubscription? _reloadSubscription;
@override
void initState() {
super.initState();
_reloadSubscription = EventStream.shared.listen<TimelineReloadEvent>((_) => setState(() {}));
}
@override
void dispose() {
unawaited(_reloadSubscription?.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) {
final assetCount = ref.watch(timelineServiceProvider.select((s) => s.totalAssets));
return Text(
context.t.items_count(count: assetCount),
style: context.textTheme.labelLarge?.copyWith(
color: Colors.white,
shadows: [const Shadow(offset: Offset(0, 2), blurRadius: 12, color: Colors.black87)],
),
);
}
}
class _RandomAssetBackground extends StatefulWidget {
final TimelineService timelineService;
final IconData icon;
const _RandomAssetBackground({required this.timelineService, required this.icon});
@override
State<_RandomAssetBackground> createState() => _RandomAssetBackgroundState();
}
class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with TickerProviderStateMixin {
late AnimationController _zoomController;
late AnimationController _crossFadeController;
late Animation<double> _zoomAnimation;
late Animation<Offset> _panAnimation;
late Animation<double> _crossFadeAnimation;
BaseAsset? _currentAsset;
BaseAsset? _nextAsset;
bool _isZoomingIn = true;
@override
void initState() {
super.initState();
_zoomController = AnimationController(
duration: const Duration(seconds: 12),
vsync: this,
animationBehavior: AnimationBehavior.preserve,
);
_crossFadeController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
animationBehavior: AnimationBehavior.preserve,
);
_zoomAnimation = Tween<double>(
begin: 1.0,
end: 1.2,
).animate(CurvedAnimation(parent: _zoomController, curve: Curves.easeInOut));
_panAnimation = Tween<Offset>(
begin: Offset.zero,
end: const Offset(0.5, -0.5),
).animate(CurvedAnimation(parent: _zoomController, curve: Curves.easeInOut));
_crossFadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(parent: _crossFadeController, curve: Curves.easeInOutCubic));
Future.delayed(Durations.medium1, () => _loadFirstAsset());
}
@override
void dispose() {
_zoomController.dispose();
_crossFadeController.dispose();
super.dispose();
}
void _startAnimationCycle() {
if (_isZoomingIn) {
unawaited(
_zoomController.forward().then((_) {
unawaited(_loadNextAsset());
}),
);
} else {
unawaited(
_zoomController.reverse().then((_) {
unawaited(_loadNextAsset());
}),
);
}
}
Future<void> _loadFirstAsset() async {
if (!mounted) {
return;
}
if (widget.timelineService.totalAssets == 0) {
setState(() {
_currentAsset = null;
});
return;
}
setState(() {
_currentAsset = widget.timelineService.getRandomAsset();
});
await _crossFadeController.forward();
if (_zoomController.status == AnimationStatus.dismissed) {
if (_isZoomingIn) {
_zoomController.reset();
} else {
_zoomController.value = 1.0;
}
_startAnimationCycle();
}
}
Future<void> _loadNextAsset() async {
if (!mounted) {
return;
}
try {
if (widget.timelineService.totalAssets > 1) {
// Load next asset while keeping current one visible
final nextAsset = widget.timelineService.getRandomAsset();
setState(() {
_nextAsset = nextAsset;
});
await _crossFadeController.reverse();
setState(() {
_currentAsset = _nextAsset;
_nextAsset = null;
});
_crossFadeController.value = 1.0;
_isZoomingIn = !_isZoomingIn;
_startAnimationCycle();
}
} catch (e) {
_zoomController.reset();
_startAnimationCycle();
}
}
@override
Widget build(BuildContext context) {
if (widget.timelineService.totalAssets == 0) {
return const SizedBox.shrink();
}
return AnimatedBuilder(
animation: Listenable.merge([_zoomAnimation, _panAnimation, _crossFadeAnimation]),
builder: (context, child) {
return Transform.scale(
scale: _zoomAnimation.value,
child: Transform.translate(
offset: _panAnimation.value,
child: Stack(
fit: StackFit.expand,
children: [
// Current image
if (_currentAsset != null)
Opacity(
opacity: _crossFadeAnimation.value,
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Image(
alignment: Alignment.topRight,
image: getFullImageProvider(_currentAsset!),
fit: BoxFit.cover,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return Container();
},
errorBuilder: (context, error, stackTrace) {
return SizedBox(
width: double.infinity,
height: double.infinity,
child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]),
);
},
),
),
),
if (_nextAsset != null)
Opacity(
opacity: 1.0 - _crossFadeAnimation.value,
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Image(
alignment: Alignment.topRight,
image: getFullImageProvider(_nextAsset!),
fit: BoxFit.cover,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return const SizedBox.shrink();
},
errorBuilder: (context, error, stackTrace) {
return SizedBox(
width: double.infinity,
height: double.infinity,
child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]),
);
},
),
),
),
],
),
),
);
},
);
}
}
class _DynamicText extends StatelessWidget {
final String text;
final double maxWidth;