From feff1899eeef883948a2b4045e5daace16d1a3c6 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 10 Jul 2025 10:13:46 -0500 Subject: [PATCH] feat: expanded sliver app bar (#19827) * use mutex * feat: cool app bar * animation * adapt to more pages * animation * better animation * fix: asset count * Revert "fix: asset count" This reverts commit 673a5b264be0ae851aa63a28ecc316c27cfc412b. * fix: asset count * fix: shaky animation on Android * tunning * offset SizedBox to fix scroll jump on multiselect --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../lib/domain/models/album/album.model.dart | 6 +- .../domain/services/remote_album.service.dart | 12 +- .../lib/domain/services/timeline.service.dart | 3 + .../repositories/remote_album.repository.dart | 8 +- .../pages/dev/drift_archive.page.dart | 11 +- .../pages/dev/drift_favorite.page.dart | 11 +- .../pages/dev/drift_local_album.page.dart | 2 +- .../pages/dev/drift_trash.page.dart | 14 +- .../pages/dev/local_timeline.page.dart | 15 +- .../pages/dev/media_stat.page.dart | 4 +- .../pages/dev/remote_timeline.page.dart | 17 +- .../presentation/pages/drift_album.page.dart | 31 +- .../pages/drift_library.page.dart | 7 +- .../asset_viewer/asset_viewer.page.dart | 2 +- .../widgets/timeline/timeline.widget.dart | 24 +- .../infrastructure/remote_album.provider.dart | 12 +- mobile/lib/routing/router.dart | 2 + mobile/lib/routing/router.gr.dart | 24 +- mobile/lib/utils/remote_album.utils.dart | 35 +- .../widgets/common/immich_sliver_app_bar.dart | 5 +- .../common/mesmerizing_sliver_app_bar.dart | 563 ++++++++++++++++++ 21 files changed, 733 insertions(+), 75 deletions(-) create mode 100644 mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart diff --git a/mobile/lib/domain/models/album/album.model.dart b/mobile/lib/domain/models/album/album.model.dart index 29f75f29ef..7cafca9116 100644 --- a/mobile/lib/domain/models/album/album.model.dart +++ b/mobile/lib/domain/models/album/album.model.dart @@ -11,7 +11,7 @@ enum AlbumUserRole { } // Model for an album stored in the server -class Album { +class RemoteAlbum { final String id; final String name; final String ownerId; @@ -24,7 +24,7 @@ class Album { final int assetCount; final String ownerName; - const Album({ + const RemoteAlbum({ required this.id, required this.name, required this.ownerId, @@ -57,7 +57,7 @@ class Album { @override bool operator ==(Object other) { - if (other is! Album) return false; + if (other is! RemoteAlbum) return false; if (identical(this, other)) return true; return id == other.id && name == other.name && diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index f3106470d2..9ff00e1ce3 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -8,26 +8,26 @@ class RemoteAlbumService { const RemoteAlbumService(this._repository); - Future> getAll() { + Future> getAll() { return _repository.getAll(); } - List sortAlbums( - List albums, + List sortAlbums( + List albums, RemoteAlbumSortMode sortMode, { bool isReverse = false, }) { return sortMode.sortFn(albums, isReverse); } - List searchAlbums( - List albums, + List searchAlbums( + List albums, String query, String? userId, [ QuickFilterMode filterMode = QuickFilterMode.all, ]) { final lowerQuery = query.toLowerCase(); - List filtered = albums; + List filtered = albums; // Apply text search filter if (query.isNotEmpty) { diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index b3d6b51402..840d260950 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -210,6 +210,9 @@ class TimelineService { Future preCacheAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index))); + BaseAsset getRandomAsset() => + _buffer.elementAt(math.Random().nextInt(_buffer.length)); + BaseAsset getAsset(int index) { if (!hasRange(index, 1)) { throw RangeError( diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index b1b73b4cdc..de55626b30 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -9,7 +9,9 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { final Drift _db; const DriftRemoteAlbumRepository(this._db) : super(_db); - Future> getAll({Set sortBy = const {}}) { + Future> getAll({ + Set sortBy = const {}, + }) { final assetCount = _db.remoteAlbumAssetEntity.assetId.count(); final query = _db.remoteAlbumEntity.select().join([ @@ -59,8 +61,8 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { } extension on RemoteAlbumEntityData { - Album toDto({int assetCount = 0, required String ownerName}) { - return Album( + RemoteAlbum toDto({int assetCount = 0, required String ownerName}) { + return RemoteAlbum( id: id, name: name, ownerId: ownerId, diff --git a/mobile/lib/presentation/pages/dev/drift_archive.page.dart b/mobile/lib/presentation/pages/dev/drift_archive.page.dart index 14657f7149..fa847c74b5 100644 --- a/mobile/lib/presentation/pages/dev/drift_archive.page.dart +++ b/mobile/lib/presentation/pages/dev/drift_archive.page.dart @@ -1,9 +1,11 @@ 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: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'; +import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; @RoutePage() class DriftArchivePage extends StatelessWidget { @@ -27,7 +29,12 @@ class DriftArchivePage extends StatelessWidget { }, ), ], - child: const Timeline(), + child: Timeline( + appBar: MesmerizingSliverAppBar( + title: 'archive'.t(context: context), + icon: Icons.archive_outlined, + ), + ), ); } } diff --git a/mobile/lib/presentation/pages/dev/drift_favorite.page.dart b/mobile/lib/presentation/pages/dev/drift_favorite.page.dart index 4055ad863b..43f270574b 100644 --- a/mobile/lib/presentation/pages/dev/drift_favorite.page.dart +++ b/mobile/lib/presentation/pages/dev/drift_favorite.page.dart @@ -1,9 +1,11 @@ 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: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'; +import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; @RoutePage() class DriftFavoritePage extends StatelessWidget { @@ -27,7 +29,12 @@ class DriftFavoritePage extends StatelessWidget { }, ), ], - child: const Timeline(), + child: Timeline( + appBar: MesmerizingSliverAppBar( + title: 'favorites'.t(context: context), + icon: Icons.favorite_outline, + ), + ), ); } } diff --git a/mobile/lib/presentation/pages/dev/drift_local_album.page.dart b/mobile/lib/presentation/pages/dev/drift_local_album.page.dart index f47811b6da..0ad9abd2fa 100644 --- a/mobile/lib/presentation/pages/dev/drift_local_album.page.dart +++ b/mobile/lib/presentation/pages/dev/drift_local_album.page.dart @@ -103,7 +103,7 @@ class _AlbumList extends ConsumerWidget { ), ), onTap: () => - context.pushRoute(LocalTimelineRoute(albumId: album.id)), + context.pushRoute(LocalTimelineRoute(album: album)), ), ); }, diff --git a/mobile/lib/presentation/pages/dev/drift_trash.page.dart b/mobile/lib/presentation/pages/dev/drift_trash.page.dart index cbcfe50112..9cd2fac760 100644 --- a/mobile/lib/presentation/pages/dev/drift_trash.page.dart +++ b/mobile/lib/presentation/pages/dev/drift_trash.page.dart @@ -1,6 +1,7 @@ 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: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'; @@ -27,7 +28,16 @@ class DriftTrashPage extends StatelessWidget { }, ), ], - child: const Timeline(), + child: Timeline( + appBar: SliverAppBar( + title: Text('trash'.t(context: context)), + floating: true, + snap: true, + pinned: true, + centerTitle: true, + elevation: 0, + ), + ), ); } } diff --git a/mobile/lib/presentation/pages/dev/local_timeline.page.dart b/mobile/lib/presentation/pages/dev/local_timeline.page.dart index 3a98a81e9e..f966109289 100644 --- a/mobile/lib/presentation/pages/dev/local_timeline.page.dart +++ b/mobile/lib/presentation/pages/dev/local_timeline.page.dart @@ -1,14 +1,16 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; @RoutePage() class LocalTimelinePage extends StatelessWidget { - final String albumId; + final LocalAlbum album; - const LocalTimelinePage({super.key, required this.albumId}); + const LocalTimelinePage({super.key, required this.album}); @override Widget build(BuildContext context) { @@ -16,14 +18,17 @@ class LocalTimelinePage extends StatelessWidget { overrides: [ timelineServiceProvider.overrideWith( (ref) { - final timelineService = - ref.watch(timelineFactoryProvider).localAlbum(albumId: albumId); + final timelineService = ref + .watch(timelineFactoryProvider) + .localAlbum(albumId: album.id); ref.onDispose(timelineService.dispose); return timelineService; }, ), ], - child: const Timeline(), + child: Timeline( + appBar: MesmerizingSliverAppBar(title: album.name), + ), ); } } diff --git a/mobile/lib/presentation/pages/dev/media_stat.page.dart b/mobile/lib/presentation/pages/dev/media_stat.page.dart index e5745fa629..0a77d9dfe8 100644 --- a/mobile/lib/presentation/pages/dev/media_stat.page.dart +++ b/mobile/lib/presentation/pages/dev/media_stat.page.dart @@ -125,7 +125,7 @@ class LocalMediaSummaryPage extends StatelessWidget { name: album.name, countFuture: countFuture, onTap: () => context.router.push( - LocalTimelineRoute(albumId: album.id), + LocalTimelineRoute(album: album), ), ); }, @@ -226,7 +226,7 @@ class RemoteMediaSummaryPage extends StatelessWidget { name: album.name, countFuture: countFuture, onTap: () => context.router.push( - RemoteTimelineRoute(albumId: album.id), + RemoteTimelineRoute(album: album), ), ); }, diff --git a/mobile/lib/presentation/pages/dev/remote_timeline.page.dart b/mobile/lib/presentation/pages/dev/remote_timeline.page.dart index 6568f0f74f..2930a3c3d8 100644 --- a/mobile/lib/presentation/pages/dev/remote_timeline.page.dart +++ b/mobile/lib/presentation/pages/dev/remote_timeline.page.dart @@ -1,14 +1,16 @@ 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:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; @RoutePage() class RemoteTimelinePage extends StatelessWidget { - final String albumId; + final RemoteAlbum album; - const RemoteTimelinePage({super.key, required this.albumId}); + const RemoteTimelinePage({super.key, required this.album}); @override Widget build(BuildContext context) { @@ -18,13 +20,18 @@ class RemoteTimelinePage extends StatelessWidget { (ref) { final timelineService = ref .watch(timelineFactoryProvider) - .remoteAlbum(albumId: albumId); + .remoteAlbum(albumId: album.id); ref.onDispose(timelineService.dispose); return timelineService; }, ), ], - child: const Timeline(), + child: Timeline( + appBar: MesmerizingSliverAppBar( + title: album.name, + icon: Icons.photo_album_outlined, + ), + ), ); } } diff --git a/mobile/lib/presentation/pages/drift_album.page.dart b/mobile/lib/presentation/pages/drift_album.page.dart index 3298f12b65..4a7b12126a 100644 --- a/mobile/lib/presentation/pages/drift_album.page.dart +++ b/mobile/lib/presentation/pages/drift_album.page.dart @@ -475,7 +475,7 @@ class _AlbumList extends StatelessWidget { final bool isLoading; final String? error; - final List albums; + final List albums; final String? userId; @override @@ -555,7 +555,7 @@ class _AlbumList extends StatelessWidget { ), ), onTap: () => context.router.push( - RemoteTimelineRoute(albumId: album.id), + RemoteTimelineRoute(album: album), ), leadingPadding: const EdgeInsets.only( right: 16, @@ -573,13 +573,24 @@ class _AlbumList extends StatelessWidget { ), ), ) - : const SizedBox( + : SizedBox( width: 80, height: 80, - child: Icon( - Icons.photo_album_rounded, - size: 40, - color: Colors.grey, + child: Container( + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainer, + borderRadius: + const BorderRadius.all(Radius.circular(16)), + border: Border.all( + color: context.colorScheme.outline.withAlpha(50), + width: 1, + ), + ), + child: const Icon( + Icons.photo_album_rounded, + size: 24, + color: Colors.grey, + ), ), ), ), @@ -599,7 +610,7 @@ class _AlbumGrid extends StatelessWidget { required this.error, }); - final List albums; + final List albums; final String? userId; final bool isLoading; final String? error; @@ -674,14 +685,14 @@ class _GridAlbumCard extends StatelessWidget { required this.userId, }); - final Album album; + final RemoteAlbum album; final String? userId; @override Widget build(BuildContext context) { return GestureDetector( onTap: () => context.router.push( - RemoteTimelineRoute(albumId: album.id), + RemoteTimelineRoute(album: album), ), child: Card( elevation: 0, diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart index 0efa8040e7..2b0ec191e2 100644 --- a/mobile/lib/presentation/pages/drift_library.page.dart +++ b/mobile/lib/presentation/pages/drift_library.page.dart @@ -27,7 +27,12 @@ class DriftLibraryPage extends ConsumerWidget { return const Scaffold( body: CustomScrollView( slivers: [ - ImmichSliverAppBar(), + ImmichSliverAppBar( + snap: false, + floating: false, + pinned: true, + showUploadButton: false, + ), _ActionButtonGrid(), _CollectionCards(), _QuickAccessButtonList(), diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index aea821d945..0f0b5a5a67 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -335,7 +335,7 @@ class _AssetViewerState extends ConsumerState { final isDraggingDown = currentExtent < previousExtent; previousExtent = currentExtent; // Closes the bottom sheet if the user is dragging down - if (isDraggingDown && delta.extent < 0.5) { + if (isDraggingDown && delta.extent < 0.55) { if (dragInProgress) { blockGestures = true; } diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 9fb164e2dc..6d3351e9c2 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; 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/mesmerizing_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart'; class Timeline extends StatelessWidget { @@ -25,10 +26,12 @@ class Timeline extends StatelessWidget { super.key, this.topSliverWidget, this.topSliverWidgetHeight, + this.appBar, }); final Widget? topSliverWidget; final double? topSliverWidgetHeight; + final Widget? appBar; @override Widget build(BuildContext context) { @@ -49,6 +52,7 @@ class Timeline extends StatelessWidget { child: _SliverTimeline( topSliverWidget: topSliverWidget, topSliverWidgetHeight: topSliverWidgetHeight, + appBar: appBar, ), ), ), @@ -60,10 +64,12 @@ class _SliverTimeline extends ConsumerStatefulWidget { const _SliverTimeline({ this.topSliverWidget, this.topSliverWidgetHeight, + this.appBar, }); final Widget? topSliverWidget; final double? topSliverWidgetHeight; + final Widget? appBar; @override ConsumerState createState() => _SliverTimelineState(); @@ -100,6 +106,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { onData: (segments) { final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; final statusBarHeight = context.padding.top; + final double appBarExpandedHeight = + widget.appBar != null && widget.appBar is MesmerizingSliverAppBar + ? 200 + : 0; final totalAppBarHeight = statusBarHeight + kToolbarHeight; const scrubberBottomPadding = 100.0; @@ -112,7 +122,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { timelineHeight: maxHeight, topPadding: totalAppBarHeight + 10, bottomPadding: context.padding.bottom + scrubberBottomPadding, - monthSegmentSnappingOffset: widget.topSliverWidgetHeight, + monthSegmentSnappingOffset: + widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight, child: CustomScrollView( primary: true, cacheExtent: maxHeight * 2, @@ -120,11 +131,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { if (isSelectionMode) const SelectionSliverAppBar() else - const ImmichSliverAppBar( - floating: true, - pinned: false, - snap: false, - ), + widget.appBar ?? + const ImmichSliverAppBar( + floating: true, + pinned: false, + snap: false, + ), if (widget.topSliverWidget != null) widget.topSliverWidget!, _SliverSegmentedList( segments: segments, diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart index 2e5a475b9c..b80d791b0a 100644 --- a/mobile/lib/providers/infrastructure/remote_album.provider.dart +++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart @@ -8,21 +8,21 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'album.provider.dart'; class RemoteAlbumState { - final List albums; - final List filteredAlbums; + final List albums; + final List filteredAlbums; final bool isLoading; final String? error; const RemoteAlbumState({ required this.albums, - List? filteredAlbums, + List? filteredAlbums, this.isLoading = false, this.error, }) : filteredAlbums = filteredAlbums ?? albums; RemoteAlbumState copyWith({ - List? albums, - List? filteredAlbums, + List? albums, + List? filteredAlbums, bool? isLoading, String? error, }) { @@ -66,7 +66,7 @@ class RemoteAlbumNotifier extends Notifier { return const RemoteAlbumState(albums: [], filteredAlbums: []); } - Future> getAll() async { + Future> getAll() async { state = state.copyWith(isLoading: true, error: null); try { diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 18aa937a9d..7becbd4804 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -1,6 +1,8 @@ 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/album/album.model.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart'; diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index b13320b1c0..8e8da4d8d0 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1211,11 +1211,11 @@ class LocalMediaSummaryRoute extends PageRouteInfo { class LocalTimelineRoute extends PageRouteInfo { LocalTimelineRoute({ Key? key, - required String albumId, + required LocalAlbum album, List? children, }) : super( LocalTimelineRoute.name, - args: LocalTimelineRouteArgs(key: key, albumId: albumId), + args: LocalTimelineRouteArgs(key: key, album: album), initialChildren: children, ); @@ -1225,21 +1225,21 @@ class LocalTimelineRoute extends PageRouteInfo { name, builder: (data) { final args = data.argsAs(); - return LocalTimelinePage(key: args.key, albumId: args.albumId); + return LocalTimelinePage(key: args.key, album: args.album); }, ); } class LocalTimelineRouteArgs { - const LocalTimelineRouteArgs({this.key, required this.albumId}); + const LocalTimelineRouteArgs({this.key, required this.album}); final Key? key; - final String albumId; + final LocalAlbum album; @override String toString() { - return 'LocalTimelineRouteArgs{key: $key, albumId: $albumId}'; + return 'LocalTimelineRouteArgs{key: $key, album: $album}'; } } @@ -1765,11 +1765,11 @@ class RemoteMediaSummaryRoute extends PageRouteInfo { class RemoteTimelineRoute extends PageRouteInfo { RemoteTimelineRoute({ Key? key, - required String albumId, + required RemoteAlbum album, List? children, }) : super( RemoteTimelineRoute.name, - args: RemoteTimelineRouteArgs(key: key, albumId: albumId), + args: RemoteTimelineRouteArgs(key: key, album: album), initialChildren: children, ); @@ -1779,21 +1779,21 @@ class RemoteTimelineRoute extends PageRouteInfo { name, builder: (data) { final args = data.argsAs(); - return RemoteTimelinePage(key: args.key, albumId: args.albumId); + return RemoteTimelinePage(key: args.key, album: args.album); }, ); } class RemoteTimelineRouteArgs { - const RemoteTimelineRouteArgs({this.key, required this.albumId}); + const RemoteTimelineRouteArgs({this.key, required this.album}); final Key? key; - final String albumId; + final RemoteAlbum album; @override String toString() { - return 'RemoteTimelineRouteArgs{key: $key, albumId: $albumId}'; + return 'RemoteTimelineRouteArgs{key: $key, album: $album}'; } } diff --git a/mobile/lib/utils/remote_album.utils.dart b/mobile/lib/utils/remote_album.utils.dart index 4fc7ba5f74..04184ee367 100644 --- a/mobile/lib/utils/remote_album.utils.dart +++ b/mobile/lib/utils/remote_album.utils.dart @@ -1,38 +1,56 @@ import 'package:collection/collection.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; -typedef AlbumSortFn = List Function(List albums, bool isReverse); +typedef AlbumSortFn = List Function( + List albums, + bool isReverse, +); class _RemoteAlbumSortHandlers { const _RemoteAlbumSortHandlers._(); static const AlbumSortFn created = _sortByCreated; - static List _sortByCreated(List albums, bool isReverse) { + static List _sortByCreated( + List albums, + bool isReverse, + ) { final sorted = albums.sortedBy((album) => album.createdAt); return (isReverse ? sorted.reversed : sorted).toList(); } static const AlbumSortFn title = _sortByTitle; - static List _sortByTitle(List albums, bool isReverse) { + static List _sortByTitle( + List albums, + bool isReverse, + ) { final sorted = albums.sortedBy((album) => album.name); return (isReverse ? sorted.reversed : sorted).toList(); } static const AlbumSortFn lastModified = _sortByLastModified; - static List _sortByLastModified(List albums, bool isReverse) { + static List _sortByLastModified( + List albums, + bool isReverse, + ) { final sorted = albums.sortedBy((album) => album.updatedAt); return (isReverse ? sorted.reversed : sorted).toList(); } static const AlbumSortFn assetCount = _sortByAssetCount; - static List _sortByAssetCount(List albums, bool isReverse) { + static List _sortByAssetCount( + List albums, + bool isReverse, + ) { final sorted = albums.sorted((a, b) => a.assetCount.compareTo(b.assetCount)); return (isReverse ? sorted.reversed : sorted).toList(); } static const AlbumSortFn mostRecent = _sortByMostRecent; - static List _sortByMostRecent(List albums, bool isReverse) { + static List _sortByMostRecent( + List albums, + bool isReverse, + ) { final sorted = albums.sorted((a, b) { // For most recent, we sort by updatedAt in descending order return b.updatedAt.compareTo(a.updatedAt); @@ -41,7 +59,10 @@ class _RemoteAlbumSortHandlers { } static const AlbumSortFn mostOldest = _sortByMostOldest; - static List _sortByMostOldest(List albums, bool isReverse) { + static List _sortByMostOldest( + List albums, + bool isReverse, + ) { final sorted = albums.sorted((a, b) { // For oldest, we sort by createdAt in ascending order return a.createdAt.compareTo(b.createdAt); diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index ff0e88e5d7..bff1ab4bb1 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -73,7 +73,10 @@ class ImmichSliverAppBar extends ConsumerWidget { onPressed: () => context.pop(), ), IconButton( - onPressed: () => ref.read(backgroundSyncProvider).syncRemote(), + onPressed: () { + ref.read(backgroundSyncProvider).syncLocal(full: true); + ref.read(backgroundSyncProvider).syncRemote(); + }, icon: const Icon( Icons.sync, ), diff --git a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart new file mode 100644 index 0000000000..36f944dbcd --- /dev/null +++ b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart @@ -0,0 +1,563 @@ +import 'dart:async'; +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/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/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 ConsumerStatefulWidget { + const MesmerizingSliverAppBar({ + super.key, + required this.title, + this.icon = Icons.camera, + }); + + final String title; + final IconData icon; + + @override + ConsumerState createState() => + _MesmerizingSliverAppBarState(); +} + +class _MesmerizingSliverAppBarState + extends ConsumerState { + double _scrollProgress = 0.0; + + 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) { + final isMultiSelectEnabled = + ref.watch(multiSelectProvider.select((s) => s.isEnabled)); + + return isMultiSelectEnabled + ? SliverToBoxAdapter( + child: switch (_scrollProgress) { + < 0.8 => const SizedBox(height: 120), + _ => const SizedBox(height: 352), + }, + ) + : SliverAppBar( + expandedHeight: 300.0, + floating: false, + pinned: true, + snap: false, + elevation: 0, + leading: IconButton( + icon: Icon( + Platform.isIOS + ? Icons.arrow_back_ios_new_rounded + : Icons.arrow_back, + color: Color.lerp( + Colors.white, + context.primaryColor, + _scrollProgress, + ), + shadows: [ + _scrollProgress < 0.95 + ? Shadow( + offset: const Offset(0, 2), + blurRadius: 5, + color: Colors.black.withValues(alpha: 0.5), + ) + : const Shadow( + offset: Offset(0, 2), + blurRadius: 0, + color: Colors.transparent, + ), + ], + ), + onPressed: () { + context.pop(); + }, + ), + flexibleSpace: Builder( + builder: (context) { + final settings = context.dependOnInheritedWidgetOfExactType< + FlexibleSpaceBarSettings>(); + final scrollProgress = _calculateScrollProgress(settings); + + // Update scroll progress for the leading button + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _scrollProgress != scrollProgress) { + setState(() { + _scrollProgress = scrollProgress; + }); + } + }); + + return FlexibleSpaceBar( + centerTitle: true, + title: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: scrollProgress > 0.95 + ? Text( + widget.title, + style: TextStyle( + color: context.primaryColor, + fontWeight: FontWeight.w600, + fontSize: 18, + ), + ) + : null, + ), + background: _ExpandedBackground( + scrollProgress: scrollProgress, + title: widget.title, + icon: widget.icon, + ), + ); + }, + ), + ); + } +} + +class _ExpandedBackground extends ConsumerStatefulWidget { + final double scrollProgress; + final String title; + final IconData icon; + + const _ExpandedBackground({ + required this.scrollProgress, + required this.title, + required this.icon, + }); + + @override + ConsumerState<_ExpandedBackground> createState() => + _ExpandedBackgroundState(); +} + +class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> + with SingleTickerProviderStateMixin { + late AnimationController _slideController; + late Animation _slideAnimation; + + @override + void initState() { + super.initState(); + + _slideController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + ); + + _slideAnimation = Tween( + begin: const Offset(0, 1.5), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: _slideController, + curve: Curves.easeOutCubic, + ), + ); + + Future.delayed(const Duration(milliseconds: 100), () { + if (mounted) { + _slideController.forward(); + } + }); + } + + @override + void dispose() { + _slideController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final timelineService = ref.watch(timelineServiceProvider); + + return Stack( + fit: StackFit.expand, + children: [ + Transform.translate( + offset: Offset(0, widget.scrollProgress * 50), + child: Transform.scale( + scale: 1.4 - (widget.scrollProgress * 0.2), + child: _RandomAssetBackground( + timelineService: timelineService, + icon: widget.icon, + ), + ), + ), + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.transparent, + Colors.black.withValues( + alpha: 0.6 + (widget.scrollProgress * 0.2), + ), + ], + stops: const [0.0, 0.65, 1.0], + ), + ), + ), + Positioned( + bottom: 16, + left: 16, + right: 16, + child: SlideTransition( + position: _slideAnimation, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: double.infinity, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text( + widget.title, + maxLines: 1, + 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, + ), + ], + ), + ), + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 300), + child: const _ItemCountText(), + ), + ], + ), + ), + ), + ], + ); + } +} + +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() { + _reloadSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final assetCount = ref.watch( + timelineServiceProvider.select((s) => s.totalAssets), + ); + + return 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, + ), + ], + ), + ); + } +} + +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, + ); + + _crossFadeController = AnimationController( + duration: const Duration(milliseconds: 1200), + vsync: this, + ); + + _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) { + _zoomController.forward().then((_) { + _loadNextAsset(); + }); + } else { + _zoomController.reverse().then((_) { + _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, + filterQuality: FilterQuality.low, + child: Transform.translate( + offset: _panAnimation.value, + filterQuality: FilterQuality.low, + 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], + ), + ); + }, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +}