From e0c2cdddd44fb7bb64d9d7960ff814293ebe521e Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Tue, 28 Oct 2025 11:52:01 -0500 Subject: [PATCH] feat: show "appears in" albums on asset viewer bottom sheet (#21925) * feat: show "appears in" albums on asset viewer bottom sheet fix: multiple RemoteAlbumPages in navigation stack this also allows us to not have to set the current album before navigating to RemoteAlbumPage chore: clarification comments handle nested album pages fix: hide "appears in" when an asset is not in any albums fix: way more bottom padding for some reason we can't query the safe area here :/ * fix: bottom sheet now is usable when navigating to another asset viewer * fix: rebase conflict * fix: restore ancestors album to currentRemoteAlbumProvider when popping * fix: view flashing when dismissing a album viewer * chore: code review changes * fix: styling and padding * chore: rework currentRemoteAlbumProvider to be scoped by the Remote album page * fix: override remote album provider on required pages * chore: convert query to all SQL calls instead of matching in Dart * fix: album query * fix: unawaited future * Update deep_link.service.dart --------- Co-authored-by: Alex --- .../domain/services/remote_album.service.dart | 4 + .../repositories/remote_album.repository.dart | 55 +++++++++ .../pages/drift_activities.page.dart | 113 +++++++++--------- .../presentation/pages/drift_album.page.dart | 2 - .../pages/drift_album_options.page.dart | 92 +++++++------- .../pages/drift_create_album.page.dart | 2 - .../pages/drift_remote_album.page.dart | 49 +++----- .../widgets/album/album_selector.widget.dart | 43 +------ .../widgets/album/album_tile.dart | 51 ++++++++ .../asset_viewer/asset_viewer.page.dart | 18 ++- .../asset_viewer/bottom_sheet.widget.dart | 67 ++++++++++- .../widgets/timeline/fixed/segment.model.dart | 2 + .../infrastructure/album.provider.dart | 5 + .../current_album.provider.dart | 32 ++--- mobile/lib/routing/router.dart | 2 +- mobile/lib/routing/router.gr.dart | 66 ++++++++-- mobile/lib/services/deep_link.service.dart | 5 - .../album/remote_album_shared_user_icons.dart | 2 +- .../common/remote_album_sliver_app_bar.dart | 3 +- 19 files changed, 401 insertions(+), 212 deletions(-) create mode 100644 mobile/lib/presentation/widgets/album/album_tile.dart diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index 13dfadb8d8..67e91188e2 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -164,6 +164,10 @@ class RemoteAlbumService { return _repository.getCount(); } + Future> getAlbumsContainingAsset(String assetId) { + return _repository.getAlbumsContainingAsset(assetId); + } + Future> _sortByNewestAsset(List albums) async { // map album IDs to their newest asset dates final Map> assetTimestampFutures = {}; diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index 0526cfb7aa..d7d4a250ad 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -382,6 +382,61 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { return query.map((row) => row.read(_db.remoteAssetEntity.id)!).get(); } + + Future> getAlbumsContainingAsset(String assetId) async { + // Note: this needs to be 2 queries as the where clause filtering causes the assetCount to always be 1 + final albumIdsQuery = _db.remoteAlbumAssetEntity.selectOnly() + ..addColumns([_db.remoteAlbumAssetEntity.albumId]) + ..where(_db.remoteAlbumAssetEntity.assetId.equals(assetId)); + + final albumIds = await albumIdsQuery.map((row) => row.read(_db.remoteAlbumAssetEntity.albumId)!).get(); + + if (albumIds.isEmpty) { + return []; + } + + final assetCount = _db.remoteAlbumAssetEntity.assetId.count(distinct: true); + final query = + _db.remoteAlbumEntity.select().join([ + leftOuterJoin( + _db.remoteAlbumAssetEntity, + _db.remoteAlbumAssetEntity.albumId.equalsExp(_db.remoteAlbumEntity.id), + useColumns: false, + ), + leftOuterJoin( + _db.remoteAssetEntity, + _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId), + useColumns: false, + ), + leftOuterJoin( + _db.userEntity, + _db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId), + useColumns: false, + ), + leftOuterJoin( + _db.remoteAlbumUserEntity, + _db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id), + useColumns: false, + ), + ]) + ..where(_db.remoteAlbumEntity.id.isIn(albumIds) & _db.remoteAssetEntity.deletedAt.isNull()) + ..addColumns([assetCount]) + ..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)]) + ..addColumns([_db.userEntity.name]) + ..groupBy([_db.remoteAlbumEntity.id]); + + return query + .map( + (row) => row + .readTable(_db.remoteAlbumEntity) + .toDto( + ownerName: row.read(_db.userEntity.name) ?? '', + isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 0, + assetCount: row.read(assetCount) ?? 0, + ), + ) + .get(); + } } extension on RemoteAlbumEntityData { diff --git a/mobile/lib/presentation/pages/drift_activities.page.dart b/mobile/lib/presentation/pages/drift_activities.page.dart index d8f8799f7d..731bcb5dba 100644 --- a/mobile/lib/presentation/pages/drift_activities.page.dart +++ b/mobile/lib/presentation/pages/drift_activities.page.dart @@ -3,6 +3,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -18,12 +19,13 @@ import 'package:immich_mobile/widgets/activities/dismissible_activity.dart'; @RoutePage() class DriftActivitiesPage extends HookConsumerWidget { - const DriftActivitiesPage({super.key}); + final RemoteAlbum album; + + const DriftActivitiesPage({super.key, required this.album}); @override Widget build(BuildContext context, WidgetRef ref) { - final album = ref.watch(currentRemoteAlbumProvider)!; - final asset = ref.read(currentAssetNotifier) as RemoteAsset?; + final asset = ref.watch(currentAssetNotifier) as RemoteAsset?; final user = ref.watch(currentUserProvider); final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier); @@ -43,62 +45,65 @@ class DriftActivitiesPage extends HookConsumerWidget { scrollToBottom(); } - return Scaffold( - appBar: AppBar( - title: asset == null ? Text(album.name) : null, - actions: [const LikeActivityActionButton(menuItem: true)], - actionsPadding: const EdgeInsets.only(right: 8), - ), - body: activities.widgetWhen( - onData: (data) { - final liked = data.firstWhereOrNull( - (a) => a.type == ActivityType.like && a.user.id == user?.id && a.assetId == asset?.id, - ); + return ProviderScope( + overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)], + child: Scaffold( + appBar: AppBar( + title: asset == null ? Text(album.name) : null, + actions: [const LikeActivityActionButton(menuItem: true)], + actionsPadding: const EdgeInsets.only(right: 8), + ), + body: activities.widgetWhen( + onData: (data) { + final liked = data.firstWhereOrNull( + (a) => a.type == ActivityType.like && a.user.id == user?.id && a.assetId == asset?.id, + ); - return SafeArea( - child: Stack( - children: [ - ListView.builder( - controller: listViewScrollController, - itemCount: data.length + 1, - itemBuilder: (context, index) { - if (index == data.length) { - return const SizedBox(height: 80); - } - final activity = data[index]; - final canDelete = activity.user.id == user?.id || album.ownerId == user?.id; - return Padding( - padding: const EdgeInsets.all(5), - child: DismissibleActivity( - activity.id, - ActivityTile(activity), - onDismiss: canDelete - ? (activityId) async => await activityNotifier.removeActivity(activity.id) - : null, + return SafeArea( + child: Stack( + children: [ + ListView.builder( + controller: listViewScrollController, + itemCount: data.length + 1, + itemBuilder: (context, index) { + if (index == data.length) { + return const SizedBox(height: 80); + } + final activity = data[index]; + final canDelete = activity.user.id == user?.id || album.ownerId == user?.id; + return Padding( + padding: const EdgeInsets.all(5), + child: DismissibleActivity( + activity.id, + ActivityTile(activity), + onDismiss: canDelete + ? (activityId) async => await activityNotifier.removeActivity(activity.id) + : null, + ), + ); + }, + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + decoration: BoxDecoration( + color: context.scaffoldBackgroundColor, + border: Border(top: BorderSide(color: context.colorScheme.secondaryContainer, width: 1)), + ), + child: DriftActivityTextField( + isEnabled: album.isActivityEnabled, + likeId: liked?.id, + onSubmit: onAddComment, ), - ); - }, - ), - Align( - alignment: Alignment.bottomCenter, - child: Container( - decoration: BoxDecoration( - color: context.scaffoldBackgroundColor, - border: Border(top: BorderSide(color: context.colorScheme.secondaryContainer, width: 1)), - ), - child: DriftActivityTextField( - isEnabled: album.isActivityEnabled, - likeId: liked?.id, - onSubmit: onAddComment, ), ), - ), - ], - ), - ); - }, + ], + ), + ); + }, + ), + resizeToAvoidBottomInset: true, ), - resizeToAvoidBottomInset: true, ); } } diff --git a/mobile/lib/presentation/pages/drift_album.page.dart b/mobile/lib/presentation/pages/drift_album.page.dart index 0835c741ad..a159c6c54a 100644 --- a/mobile/lib/presentation/pages/drift_album.page.dart +++ b/mobile/lib/presentation/pages/drift_album.page.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; @@ -43,7 +42,6 @@ class _DriftAlbumsPageState extends ConsumerState { ), AlbumSelector( onAlbumSelected: (album) { - ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album); context.router.push(RemoteAlbumRoute(album: album)); }, ), diff --git a/mobile/lib/presentation/pages/drift_album_options.page.dart b/mobile/lib/presentation/pages/drift_album_options.page.dart index 2116e5c5cc..9db6e98613 100644 --- a/mobile/lib/presentation/pages/drift_album_options.page.dart +++ b/mobile/lib/presentation/pages/drift_album_options.page.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; @@ -22,15 +23,11 @@ import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; @RoutePage() class DriftAlbumOptionsPage extends HookConsumerWidget { - const DriftAlbumOptionsPage({super.key}); + final RemoteAlbum album; + const DriftAlbumOptionsPage({super.key, required this.album}); @override Widget build(BuildContext context, WidgetRef ref) { - final album = ref.watch(currentRemoteAlbumProvider); - if (album == null) { - return const SizedBox(); - } - final sharedUsersAsync = ref.watch(remoteAlbumSharedUsersProvider(album.id)); final userId = ref.watch(authProvider).userId; final activityEnabled = useState(album.isActivityEnabled); @@ -191,48 +188,51 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { ); } - return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new_rounded), - onPressed: () => context.maybePop(null), + return ProviderScope( + overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)], + child: Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded), + onPressed: () => context.maybePop(null), + ), + centerTitle: true, + title: Text("options".t(context: context)), ), - centerTitle: true, - title: Text("options".t(context: context)), - ), - body: ListView( - children: [ - const SizedBox(height: 8), - if (isOwner) - SwitchListTile.adaptive( - value: activityEnabled.value, - onChanged: (bool value) async { - activityEnabled.value = value; - await ref.read(remoteAlbumProvider.notifier).setActivityStatus(album.id, value); - }, - activeThumbColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor, - dense: true, - title: Text( - "comments_and_likes", - style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500), - ).t(context: context), - subtitle: Text( - "let_others_respond", - style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ).t(context: context), - ), - buildSectionTitle("shared_album_section_people_title".t(context: context)), - if (isOwner) ...[ - ListTile( - leading: const Icon(Icons.person_add_rounded), - title: Text("invite_people".t(context: context)), - onTap: () async => addUsers(), - ), - const Divider(indent: 16), + body: ListView( + children: [ + const SizedBox(height: 8), + if (isOwner) + SwitchListTile.adaptive( + value: activityEnabled.value, + onChanged: (bool value) async { + activityEnabled.value = value; + await ref.read(remoteAlbumProvider.notifier).setActivityStatus(album.id, value); + }, + activeThumbColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor, + dense: true, + title: Text( + "comments_and_likes", + style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500), + ).t(context: context), + subtitle: Text( + "let_others_respond", + style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ).t(context: context), + ), + buildSectionTitle("shared_album_section_people_title".t(context: context)), + if (isOwner) ...[ + ListTile( + leading: const Icon(Icons.person_add_rounded), + title: Text("invite_people".t(context: context)), + onTap: () async => addUsers(), + ), + const Divider(indent: 16), + ], + buildOwnerInfo(), + buildSharedUsersList(), ], - buildOwnerInfo(), - buildSharedUsersList(), - ], + ), ), ); } diff --git a/mobile/lib/presentation/pages/drift_create_album.page.dart b/mobile/lib/presentation/pages/drift_create_album.page.dart index 2e263ba1db..57e5cb09a9 100644 --- a/mobile/lib/presentation/pages/drift_create_album.page.dart +++ b/mobile/lib/presentation/pages/drift_create_album.page.dart @@ -8,7 +8,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/album/album_action_filled_button.dart'; @@ -180,7 +179,6 @@ class _DriftCreateAlbumPageState extends ConsumerState { ); if (album != null) { - ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album); unawaited(context.replaceRoute(RemoteAlbumRoute(album: album))); } } diff --git a/mobile/lib/presentation/pages/drift_remote_album.page.dart b/mobile/lib/presentation/pages/drift_remote_album.page.dart index 2d70978ea5..9a52f28deb 100644 --- a/mobile/lib/presentation/pages/drift_remote_album.page.dart +++ b/mobile/lib/presentation/pages/drift_remote_album.page.dart @@ -168,7 +168,7 @@ class _RemoteAlbumPageState extends ConsumerState { } Future showActivity(BuildContext context) async { - unawaited(context.pushRoute(const DriftActivitiesRoute())); + unawaited(context.pushRoute(DriftActivitiesRoute(album: _album))); } Future showOptionSheet(BuildContext context) async { @@ -224,7 +224,7 @@ class _RemoteAlbumPageState extends ConsumerState { : null, onShowOptions: () { context.pop(); - context.pushRoute(const DriftAlbumOptionsRoute()); + context.pushRoute(DriftAlbumOptionsRoute(album: _album)); }, ); }, @@ -237,35 +237,24 @@ class _RemoteAlbumPageState extends ConsumerState { final user = ref.watch(currentUserProvider); final isOwner = user != null ? user.id == _album.ownerId : false; - return PopScope( - onPopInvokedWithResult: (didPop, _) { - if (didPop) { - Future.microtask(() { - if (mounted) { - ref.read(currentRemoteAlbumProvider.notifier).dispose(); - ref.read(remoteAlbumProvider.notifier).refresh(); - } - }); - } - }, - child: ProviderScope( - overrides: [ - timelineServiceProvider.overrideWith((ref) { - final timelineService = ref.watch(timelineFactoryProvider).remoteAlbum(albumId: _album.id); - ref.onDispose(timelineService.dispose); - return timelineService; - }), - ], - child: Timeline( - appBar: RemoteAlbumSliverAppBar( - icon: Icons.photo_album_outlined, - onShowOptions: () => showOptionSheet(context), - onToggleAlbumOrder: isOwner ? () => toggleAlbumOrder() : null, - onEditTitle: isOwner ? () => showEditTitleAndDescription(context) : null, - onActivity: () => showActivity(context), - ), - bottomSheet: RemoteAlbumBottomSheet(album: _album), + return ProviderScope( + overrides: [ + timelineServiceProvider.overrideWith((ref) { + final timelineService = ref.watch(timelineFactoryProvider).remoteAlbum(albumId: _album.id); + ref.onDispose(timelineService.dispose); + return timelineService; + }), + currentRemoteAlbumScopedProvider.overrideWithValue(_album), + ], + child: Timeline( + appBar: RemoteAlbumSliverAppBar( + icon: Icons.photo_album_outlined, + onShowOptions: () => showOptionSheet(context), + onToggleAlbumOrder: isOwner ? () => toggleAlbumOrder() : null, + onEditTitle: isOwner ? () => showEditTitleAndDescription(context) : null, + onActivity: () => showActivity(context), ), + bottomSheet: RemoteAlbumBottomSheet(album: _album), ), ); } diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index cbac6c8b93..0d5b9a7636 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -12,10 +12,9 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; -import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -516,38 +515,6 @@ class _AlbumList extends ConsumerWidget { sliver: SliverList.builder( itemBuilder: (_, index) { final album = albums[index]; - final albumTile = LargeLeadingTile( - title: Text( - album.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), - ), - subtitle: Text( - '${'items_count'.t(context: context, args: {'count': album.assetCount})} • ${album.ownerId != userId ? 'shared_by_user'.t(context: context, args: {'user': album.ownerName}) : 'owned'.t(context: context)}', - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - onTap: () => onAlbumSelected(album), - leadingPadding: const EdgeInsets.only(right: 16), - leading: album.thumbnailAssetId != null - ? ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(15)), - child: SizedBox(width: 80, height: 80, child: Thumbnail.remote(remoteId: album.thumbnailAssetId!)), - ) - : SizedBox( - width: 80, - height: 80, - 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), - ), - ), - ); final isOwner = album.ownerId == userId; if (isOwner) { @@ -576,11 +543,14 @@ class _AlbumList extends ConsumerWidget { onDismissed: (direction) async { await ref.read(remoteAlbumProvider.notifier).deleteAlbum(album.id); }, - child: albumTile, + child: AlbumTile(album: album, isOwner: isOwner, onAlbumSelected: onAlbumSelected), ), ); } else { - return Padding(padding: const EdgeInsets.only(bottom: 8.0), child: albumTile); + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: AlbumTile(album: album, isOwner: isOwner, onAlbumSelected: onAlbumSelected), + ); } }, itemCount: albums.length, @@ -709,7 +679,6 @@ class AddToAlbumHeader extends ConsumerWidget { return; } - ref.read(currentRemoteAlbumProvider.notifier).setAlbum(newAlbum); ref.read(multiSelectProvider.notifier).reset(); unawaited(context.pushRoute(RemoteAlbumRoute(album: newAlbum))); } diff --git a/mobile/lib/presentation/widgets/album/album_tile.dart b/mobile/lib/presentation/widgets/album/album_tile.dart new file mode 100644 index 0000000000..561b018ef8 --- /dev/null +++ b/mobile/lib/presentation/widgets/album/album_tile.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; + +class AlbumTile extends StatelessWidget { + const AlbumTile({super.key, required this.album, required this.isOwner, this.onAlbumSelected}); + + final RemoteAlbum album; + final bool isOwner; + final Function(RemoteAlbum)? onAlbumSelected; + + @override + Widget build(BuildContext context) { + return LargeLeadingTile( + title: Text( + album.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + ), + subtitle: Text( + '${'items_count'.t(context: context, args: {'count': album.assetCount})} • ${isOwner ? 'owned'.t(context: context) : 'shared_by_user'.t(context: context, args: {'user': album.ownerName})}', + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + onTap: () => onAlbumSelected?.call(album), + leadingPadding: const EdgeInsets.only(right: 16), + leading: album.thumbnailAssetId != null + ? ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(15)), + child: SizedBox(width: 80, height: 80, child: Thumbnail.remote(remoteId: album.thumbnailAssetId!)), + ) + : SizedBox( + width: 80, + height: 80, + 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), + ), + ), + ); + } +} 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 2e3009d934..f8a2c37ccd 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; @@ -13,6 +14,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; @@ -20,7 +22,6 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widge import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; @@ -28,6 +29,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; @@ -39,15 +41,25 @@ class AssetViewerPage extends StatelessWidget { final int initialIndex; final TimelineService timelineService; final int? heroOffset; + final RemoteAlbum? currentAlbum; - const AssetViewerPage({super.key, required this.initialIndex, required this.timelineService, this.heroOffset}); + const AssetViewerPage({ + super.key, + required this.initialIndex, + required this.timelineService, + this.heroOffset, + this.currentAlbum, + }); @override Widget build(BuildContext context) { // This is necessary to ensure that the timeline service is available // since the Timeline and AssetViewer are on different routes / Widget subtrees. return ProviderScope( - overrides: [timelineServiceProvider.overrideWithValue(timelineService)], + overrides: [ + timelineServiceProvider.overrideWithValue(timelineService), + currentRemoteAlbumScopedProvider.overrideWithValue(currentAlbum), + ], child: AssetViewer(initialIndex: initialIndex, heroOffset: heroOffset), ); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index c4fbd2cfe3..00e16fa870 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -1,3 +1,7 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -8,11 +12,14 @@ import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; @@ -20,6 +27,7 @@ import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -132,6 +140,60 @@ class _AssetDetailBottomSheet extends ConsumerWidget { await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context); } + Widget _buildAppearsInList(WidgetRef ref, BuildContext context) { + final isRemote = ref.watch(currentAssetNotifier)?.hasRemote ?? false; + if (!isRemote) { + return const SizedBox.shrink(); + } + + final remoteAsset = ref.watch(currentAssetNotifier) as RemoteAsset; + final userId = ref.watch(currentUserProvider)?.id; + final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAsset.id)); + + return assetAlbums.when( + data: (albums) { + if (albums.isEmpty) { + return const SizedBox.shrink(); + } + + albums.sortBy((a) => a.name); + + return Column( + spacing: 12, + children: [ + if (albums.isNotEmpty) + _SheetTile( + title: 'appears_in'.t(context: context).toUpperCase(), + titleStyle: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(200), + fontWeight: FontWeight.w600, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 24), + child: Column( + spacing: 12, + children: albums.map((album) { + final isOwner = album.ownerId == userId; + return AlbumTile( + album: album, + isOwner: isOwner, + onAlbumSelected: (album) async { + ref.invalidate(assetViewerProvider); + unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album))); + }, + ); + }).toList(), + ), + ), + ], + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ); + } + @override Widget build(BuildContext context, WidgetRef ref) { final asset = ref.watch(currentAssetNotifier); @@ -217,7 +279,10 @@ class _AssetDetailBottomSheet extends ConsumerWidget { color: context.textTheme.bodyMedium?.color?.withAlpha(155), ), ), - const SizedBox(height: 64), + // Appears in (Albums) + _buildAppearsInList(ref, context), + // padding at the bottom to avoid cut-off + const SizedBox(height: 100), ], ); } diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index a0a98c34d7..b879b33f68 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -16,6 +16,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart' import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -163,6 +164,7 @@ class _AssetTileWidget extends ConsumerWidget { initialIndex: assetIndex, timelineService: ref.read(timelineServiceProvider), heroOffset: heroOffset, + currentAlbum: ref.read(currentRemoteAlbumProvider), ), ), ); diff --git a/mobile/lib/providers/infrastructure/album.provider.dart b/mobile/lib/providers/infrastructure/album.provider.dart index 8388480974..1ddabc1604 100644 --- a/mobile/lib/providers/infrastructure/album.provider.dart +++ b/mobile/lib/providers/infrastructure/album.provider.dart @@ -1,4 +1,5 @@ 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/services/local_album.service.dart'; @@ -40,3 +41,7 @@ final remoteAlbumProvider = NotifierProvider, String>( + (ref, assetId) => ref.watch(remoteAlbumServiceProvider).getAlbumsContainingAsset(assetId), +); diff --git a/mobile/lib/providers/infrastructure/current_album.provider.dart b/mobile/lib/providers/infrastructure/current_album.provider.dart index 0d95674ec7..6c2fc248ba 100644 --- a/mobile/lib/providers/infrastructure/current_album.provider.dart +++ b/mobile/lib/providers/infrastructure/current_album.provider.dart @@ -1,36 +1,30 @@ -import 'dart:async'; - import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -final currentRemoteAlbumProvider = AutoDisposeNotifierProvider( +final currentRemoteAlbumScopedProvider = Provider((ref) => null); +final currentRemoteAlbumProvider = NotifierProvider( CurrentAlbumNotifier.new, + dependencies: [currentRemoteAlbumScopedProvider, remoteAlbumServiceProvider], ); -class CurrentAlbumNotifier extends AutoDisposeNotifier { - KeepAliveLink? _keepAliveLink; - StreamSubscription? _assetSubscription; - +class CurrentAlbumNotifier extends Notifier { @override - RemoteAlbum? build() => null; + RemoteAlbum? build() { + final album = ref.watch(currentRemoteAlbumScopedProvider); - void setAlbum(RemoteAlbum album) { - _keepAliveLink?.close(); - _assetSubscription?.cancel(); - state = album; + if (album == null) { + return null; + } - _assetSubscription = ref.watch(remoteAlbumServiceProvider).watchAlbum(album.id).listen((updatedAlbum) { + final watcher = ref.watch(remoteAlbumServiceProvider).watchAlbum(album.id).listen((updatedAlbum) { if (updatedAlbum != null) { state = updatedAlbum; } }); - _keepAliveLink = ref.keepAlive(); - } - void dispose() { - _keepAliveLink?.close(); - _assetSubscription?.cancel(); - state = null; + ref.onDispose(watcher.cancel); + + return album; } } diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 7554c7b1cf..5c0299c414 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -303,7 +303,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DriftBackupAlbumSelectionRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: LocalTimelineRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: MainTimelineRoute.page, guards: [_authGuard, _duplicateGuard]), - AutoRoute(page: RemoteAlbumRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: RemoteAlbumRoute.page, guards: [_authGuard]), AutoRoute( page: AssetViewerRoute.page, guards: [_authGuard, _duplicateGuard], diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 4e488a30c7..4e60e4fb6a 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -448,6 +448,7 @@ class AssetViewerRoute extends PageRouteInfo { required int initialIndex, required TimelineService timelineService, int? heroOffset, + RemoteAlbum? currentAlbum, List? children, }) : super( AssetViewerRoute.name, @@ -456,6 +457,7 @@ class AssetViewerRoute extends PageRouteInfo { initialIndex: initialIndex, timelineService: timelineService, heroOffset: heroOffset, + currentAlbum: currentAlbum, ), initialChildren: children, ); @@ -471,6 +473,7 @@ class AssetViewerRoute extends PageRouteInfo { initialIndex: args.initialIndex, timelineService: args.timelineService, heroOffset: args.heroOffset, + currentAlbum: args.currentAlbum, ); }, ); @@ -482,6 +485,7 @@ class AssetViewerRouteArgs { required this.initialIndex, required this.timelineService, this.heroOffset, + this.currentAlbum, }); final Key? key; @@ -492,9 +496,11 @@ class AssetViewerRouteArgs { final int? heroOffset; + final RemoteAlbum? currentAlbum; + @override String toString() { - return 'AssetViewerRouteArgs{key: $key, initialIndex: $initialIndex, timelineService: $timelineService, heroOffset: $heroOffset}'; + return 'AssetViewerRouteArgs{key: $key, initialIndex: $initialIndex, timelineService: $timelineService, heroOffset: $heroOffset, currentAlbum: $currentAlbum}'; } } @@ -706,36 +712,78 @@ class DownloadInfoRoute extends PageRouteInfo { /// generated route for /// [DriftActivitiesPage] -class DriftActivitiesRoute extends PageRouteInfo { - const DriftActivitiesRoute({List? children}) - : super(DriftActivitiesRoute.name, initialChildren: children); +class DriftActivitiesRoute extends PageRouteInfo { + DriftActivitiesRoute({ + Key? key, + required RemoteAlbum album, + List? children, + }) : super( + DriftActivitiesRoute.name, + args: DriftActivitiesRouteArgs(key: key, album: album), + initialChildren: children, + ); static const String name = 'DriftActivitiesRoute'; static PageInfo page = PageInfo( name, builder: (data) { - return const DriftActivitiesPage(); + final args = data.argsAs(); + return DriftActivitiesPage(key: args.key, album: args.album); }, ); } +class DriftActivitiesRouteArgs { + const DriftActivitiesRouteArgs({this.key, required this.album}); + + final Key? key; + + final RemoteAlbum album; + + @override + String toString() { + return 'DriftActivitiesRouteArgs{key: $key, album: $album}'; + } +} + /// generated route for /// [DriftAlbumOptionsPage] -class DriftAlbumOptionsRoute extends PageRouteInfo { - const DriftAlbumOptionsRoute({List? children}) - : super(DriftAlbumOptionsRoute.name, initialChildren: children); +class DriftAlbumOptionsRoute extends PageRouteInfo { + DriftAlbumOptionsRoute({ + Key? key, + required RemoteAlbum album, + List? children, + }) : super( + DriftAlbumOptionsRoute.name, + args: DriftAlbumOptionsRouteArgs(key: key, album: album), + initialChildren: children, + ); static const String name = 'DriftAlbumOptionsRoute'; static PageInfo page = PageInfo( name, builder: (data) { - return const DriftAlbumOptionsPage(); + final args = data.argsAs(); + return DriftAlbumOptionsPage(key: args.key, album: args.album); }, ); } +class DriftAlbumOptionsRouteArgs { + const DriftAlbumOptionsRouteArgs({this.key, required this.album}); + + final Key? key; + + final RemoteAlbum album; + + @override + String toString() { + return 'DriftAlbumOptionsRouteArgs{key: $key, album: $album}'; + } +} + /// generated route for /// [DriftAlbumsPage] class DriftAlbumsRoute extends PageRouteInfo { diff --git a/mobile/lib/services/deep_link.service.dart b/mobile/lib/services/deep_link.service.dart index 7c8ddce265..d67362aac2 100644 --- a/mobile/lib/services/deep_link.service.dart +++ b/mobile/lib/services/deep_link.service.dart @@ -10,7 +10,6 @@ import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart' as beta_asset_provider; -import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -29,7 +28,6 @@ final deepLinkServiceProvider = Provider( // Below is used for beta timeline ref.watch(timelineFactoryProvider), ref.watch(beta_asset_provider.assetServiceProvider), - ref.watch(currentRemoteAlbumProvider.notifier), ref.watch(remoteAlbumServiceProvider), ref.watch(driftMemoryServiceProvider), ), @@ -46,7 +44,6 @@ class DeepLinkService { /// Used for beta timeline final TimelineFactory _betaTimelineFactory; final beta_asset_service.AssetService _betaAssetService; - final CurrentAlbumNotifier _betaCurrentAlbumNotifier; final RemoteAlbumService _betaRemoteAlbumService; final DriftMemoryService _betaMemoryServiceProvider; @@ -58,7 +55,6 @@ class DeepLinkService { this._currentAlbum, this._betaTimelineFactory, this._betaAssetService, - this._betaCurrentAlbumNotifier, this._betaRemoteAlbumService, this._betaMemoryServiceProvider, ); @@ -176,7 +172,6 @@ class DeepLinkService { return null; } - _betaCurrentAlbumNotifier.setAlbum(album); return RemoteAlbumRoute(album: album); } else { // TODO: Remove this when beta is default diff --git a/mobile/lib/widgets/album/remote_album_shared_user_icons.dart b/mobile/lib/widgets/album/remote_album_shared_user_icons.dart index 9f88b23f92..8913e94136 100644 --- a/mobile/lib/widgets/album/remote_album_shared_user_icons.dart +++ b/mobile/lib/widgets/album/remote_album_shared_user_icons.dart @@ -25,7 +25,7 @@ class RemoteAlbumSharedUserIcons extends ConsumerWidget { } return GestureDetector( - onTap: () => context.pushRoute(const DriftAlbumOptionsRoute()), + onTap: () => context.pushRoute(DriftAlbumOptionsRoute(album: currentAlbum)), child: SizedBox( height: 50, child: ListView.builder( 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 f75dd6e803..c0661bad48 100644 --- a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -18,7 +18,6 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; -import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/album/remote_album_shared_user_icons.dart'; class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget { @@ -89,7 +88,7 @@ class _MesmerizingSliverAppBarState extends ConsumerState context.navigateTo(const TabShellRoute(children: [DriftAlbumsRoute()])), + onPressed: () => context.maybePop(), ), actions: [ if (widget.onToggleAlbumOrder != null)