diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index cc28dfafd5..dc32b035b6 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -160,6 +160,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 5dfe4ac9b3..637b5d5ebe 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -372,6 +372,18 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { return query.map((row) => row.read(_db.remoteAssetEntity.id)!).get(); } + + Future> getAlbumsContainingAsset(String assetId) async { + final albumIdsQuery = _db.remoteAlbumAssetEntity.select()..where((row) => row.assetId.equals(assetId)); + + final albumIds = (await albumIdsQuery.get()).map((e) => e.albumId).toSet(); + + if (albumIds.isEmpty) { + return []; + } + + return getAll().then((albums) => albums.where((album) => albumIds.contains(album.id)).toList()); + } } extension on RemoteAlbumEntityData { diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 7d944c54ce..080c578cd3 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -32,6 +32,7 @@ import 'package:immich_mobile/theme/dynamic_theme.dart'; import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/cache/widgets_binding.dart'; +import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/utils/licenses.dart'; import 'package:immich_mobile/utils/migration.dart'; @@ -39,7 +40,6 @@ import 'package:intl/date_symbol_data_local.dart'; import 'package:logging/logging.dart'; import 'package:timezone/data/latest.dart'; import 'package:worker_manager/worker_manager.dart'; -import 'package:immich_mobile/utils/debug_print.dart'; void main() async { ImmichWidgetsBinding(); @@ -240,7 +240,7 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale), routerConfig: router.config( deepLinkBuilder: _deepLinkBuilder, - navigatorObservers: () => [AppNavigationObserver(ref: ref)], + navigatorObservers: () => [AppNavigationObserver(ref: ref), HeroController()], ), ), ); diff --git a/mobile/lib/presentation/pages/drift_remote_album.page.dart b/mobile/lib/presentation/pages/drift_remote_album.page.dart index e0fe5ee62b..0c0a423d2b 100644 --- a/mobile/lib/presentation/pages/drift_remote_album.page.dart +++ b/mobile/lib/presentation/pages/drift_remote_album.page.dart @@ -221,14 +221,15 @@ class _RemoteAlbumPageState extends ConsumerState { @override Widget build(BuildContext context) { return PopScope( + canPop: false, onPopInvokedWithResult: (didPop, _) { - if (didPop) { - Future.microtask(() { - if (mounted) { - ref.read(currentRemoteAlbumProvider.notifier).dispose(); - ref.read(remoteAlbumProvider.notifier).refresh(); - } - }); + if (didPop || !mounted) { + return; + } + final hasAncestor = context.findAncestorWidgetOfExactType() != null; + Navigator.of(context).pop(); + if (!hasAncestor) { + ref.read(currentRemoteAlbumProvider.notifier).dispose(); } }, child: ProviderScope( diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index f79b4bd7b1..534eb3fb21 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -12,7 +12,7 @@ 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'; @@ -516,38 +516,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 +544,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, 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/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 7431290ad8..b623226b3d 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,5 @@ +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,17 +10,20 @@ 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/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'; 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/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'; @@ -130,6 +135,61 @@ 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 albums = ref.watch(remoteAlbumServiceProvider).getAlbumsContainingAsset(remoteAsset.id); + final userId = ref.watch(currentUserProvider)?.id; + + return FutureBuilder( + future: albums, + builder: (_, snap) { + final albums = snap.data ?? []; + if (albums.isEmpty) { + return const SizedBox.shrink(); + } + + albums.sortBy((a) => a.name); + + return Padding( + padding: const EdgeInsets.only(left: 16, right: 16, top: 8), + child: 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, + ), + ), + ...albums.map((album) { + final isOwner = album.ownerId == userId; + return AlbumTile( + album: album, + isOwner: isOwner, + onAlbumSelected: (album) async { + final prevAlbum = ref.read(currentRemoteAlbumProvider); + ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album); + await context.router.push(RemoteAlbumRoute(album: album)); + if (prevAlbum != null) { + ref.read(currentRemoteAlbumProvider.notifier).setAlbum(prevAlbum); + } + }, + ); + }), + ], + ), + ); + }, + ); + } + @override Widget build(BuildContext context, WidgetRef ref) { final asset = ref.watch(currentAssetNotifier); @@ -185,6 +245,10 @@ class _AssetDetailBottomSheet extends ConsumerWidget { color: context.textTheme.bodyMedium?.color?.withAlpha(155), ), ), + // Appears in (Albums) + _buildAppearsInList(ref, context), + // padding at the bottom to avoid cut-off + const SizedBox(height: 100), ], ); } 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)