diff --git a/mobile/lib/presentation/pages/archive.page.dart b/mobile/lib/presentation/pages/archive.page.dart index 82595f98f..d32503547 100644 --- a/mobile/lib/presentation/pages/archive.page.dart +++ b/mobile/lib/presentation/pages/archive.page.dart @@ -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(), ), ); diff --git a/mobile/lib/presentation/pages/favorite.page.dart b/mobile/lib/presentation/pages/favorite.page.dart index 2898b4f02..4ed1bffe9 100644 --- a/mobile/lib/presentation/pages/favorite.page.dart +++ b/mobile/lib/presentation/pages/favorite.page.dart @@ -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(), ), ); diff --git a/mobile/lib/presentation/pages/partner_detail.page.dart b/mobile/lib/presentation/pages/partner_detail.page.dart index 2e03a8ab5..3f8da1f98 100644 --- a/mobile/lib/presentation/pages/partner_detail.page.dart +++ b/mobile/lib/presentation/pages/partner_detail.page.dart @@ -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(), diff --git a/mobile/lib/presentation/pages/place_detail.page.dart b/mobile/lib/presentation/pages/place_detail.page.dart index ea0547f80..1b6675160 100644 --- a/mobile/lib/presentation/pages/place_detail.page.dart +++ b/mobile/lib/presentation/pages/place_detail.page.dart @@ -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)), ); } } diff --git a/mobile/lib/presentation/pages/remote_album.page.dart b/mobile/lib/presentation/pages/remote_album.page.dart index dc51f2e75..79b2d2673 100644 --- a/mobile/lib/presentation/pages/remote_album.page.dart +++ b/mobile/lib/presentation/pages/remote_album.page.dart @@ -200,7 +200,6 @@ class _RemoteAlbumPageState extends ConsumerState { child: Timeline( topSliverWidget: PendingUploadsBanner(albumId: _album.id), appBar: RemoteAlbumSliverAppBar( - icon: Icons.photo_album_outlined, kebabMenu: _AlbumKebabMenu( album: _album, onDeleteAlbum: () => deleteAlbum(context), diff --git a/mobile/lib/presentation/widgets/sliver_app_bar/item_count_text.widget.dart b/mobile/lib/presentation/widgets/sliver_app_bar/item_count_text.widget.dart new file mode 100644 index 000000000..79c3e839b --- /dev/null +++ b/mobile/lib/presentation/widgets/sliver_app_bar/item_count_text.widget.dart @@ -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 createState() => _ItemCountTextState(); +} + +class _ItemCountTextState extends ConsumerState { + StreamSubscription? _reloadSubscription; + + @override + void initState() { + super.initState(); + _reloadSubscription = EventStream.shared.listen((_) => 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)); + } +} diff --git a/mobile/lib/presentation/widgets/sliver_app_bar/random_asset_background_image.widget.dart b/mobile/lib/presentation/widgets/sliver_app_bar/random_asset_background_image.widget.dart new file mode 100644 index 000000000..fa5ccd3f1 --- /dev/null +++ b/mobile/lib/presentation/widgets/sliver_app_bar/random_asset_background_image.widget.dart @@ -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 createState() => _RandomAssetBackgroundImageState(); +} + +class _RandomAssetBackgroundImageState extends State with TickerProviderStateMixin { + late AnimationController _zoomController; + late AnimationController _crossFadeController; + late Animation _zoomAnimation; + late Animation _panAnimation; + late Animation _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( + begin: 1.0, + end: 1.2, + ).animate(CurvedAnimation(parent: _zoomController, curve: Curves.easeInOut)); + + _panAnimation = Tween( + begin: Offset.zero, + end: const Offset(0.5, -0.5), + ).animate(CurvedAnimation(parent: _zoomController, curve: Curves.easeInOut)); + + _crossFadeAnimation = Tween( + 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 _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 _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]), + ); + }, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart index ac53bca72..dd14221fc 100644 --- a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart @@ -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 createState() => _MesmerizingSliverAppBarState(); } @@ -94,11 +89,7 @@ class _MesmerizingSliverAppBarState extends ConsumerState 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((_) => 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 _zoomAnimation; - late Animation _panAnimation; - late Animation _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( - begin: 1.0, - end: 1.2, - ).animate(CurvedAnimation(parent: _zoomController, curve: Curves.easeInOut)); - - _panAnimation = Tween( - begin: Offset.zero, - end: const Offset(0.5, -0.5), - ).animate(CurvedAnimation(parent: _zoomController, curve: Curves.easeInOut)); - - _crossFadeAnimation = Tween( - 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 _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 _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]), - ); - }, - ), - ), - ), - ], - ), - ), - ); - }, - ); - } -} diff --git a/mobile/lib/widgets/common/person_sliver_app_bar.dart b/mobile/lib/widgets/common/person_sliver_app_bar.dart index 56eb3c609..d8bf361e4 100644 --- a/mobile/lib/widgets/common/person_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/person_sliver_app_bar.dart @@ -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((_) => 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 _zoomAnimation; - late Animation _panAnimation; - late Animation _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( - begin: 1.0, - end: 1.2, - ).animate(CurvedAnimation(parent: _zoomController, curve: Curves.easeInOut)); - - _panAnimation = Tween( - begin: Offset.zero, - end: const Offset(0.5, -0.5), - ).animate(CurvedAnimation(parent: _zoomController, curve: Curves.easeInOut)); - - _crossFadeAnimation = Tween( - 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 _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 _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]), - ); - }, - ), - ), - ), - ], - ), - ), - ); - }, - ); - } -} diff --git a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart index 807309f4b..cf3be4188 100644 --- a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -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 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((_) => 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 _zoomAnimation; - late Animation _panAnimation; - late Animation _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( - begin: 1.0, - end: 1.2, - ).animate(CurvedAnimation(parent: _zoomController, curve: Curves.easeInOut)); - - _panAnimation = Tween( - begin: Offset.zero, - end: const Offset(0.5, -0.5), - ).animate(CurvedAnimation(parent: _zoomController, curve: Curves.easeInOut)); - - _crossFadeAnimation = Tween( - 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 _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 _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;