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 <alex.tran1502@gmail.com>
This commit is contained in:
Brandon Wees 2025-10-28 11:52:01 -05:00 committed by GitHub
parent 74f2c10a5a
commit e0c2cdddd4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 401 additions and 212 deletions

View File

@ -164,6 +164,10 @@ class RemoteAlbumService {
return _repository.getCount(); return _repository.getCount();
} }
Future<List<RemoteAlbum>> getAlbumsContainingAsset(String assetId) {
return _repository.getAlbumsContainingAsset(assetId);
}
Future<List<RemoteAlbum>> _sortByNewestAsset(List<RemoteAlbum> albums) async { Future<List<RemoteAlbum>> _sortByNewestAsset(List<RemoteAlbum> albums) async {
// map album IDs to their newest asset dates // map album IDs to their newest asset dates
final Map<String, Future<DateTime?>> assetTimestampFutures = {}; final Map<String, Future<DateTime?>> assetTimestampFutures = {};

View File

@ -382,6 +382,61 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
return query.map((row) => row.read(_db.remoteAssetEntity.id)!).get(); return query.map((row) => row.read(_db.remoteAssetEntity.id)!).get();
} }
Future<List<RemoteAlbum>> 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 { extension on RemoteAlbumEntityData {

View File

@ -3,6 +3,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.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/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_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() @RoutePage()
class DriftActivitiesPage extends HookConsumerWidget { class DriftActivitiesPage extends HookConsumerWidget {
const DriftActivitiesPage({super.key}); final RemoteAlbum album;
const DriftActivitiesPage({super.key, required this.album});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentRemoteAlbumProvider)!; final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
final asset = ref.read(currentAssetNotifier) as RemoteAsset?;
final user = ref.watch(currentUserProvider); final user = ref.watch(currentUserProvider);
final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier); final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier);
@ -43,7 +45,9 @@ class DriftActivitiesPage extends HookConsumerWidget {
scrollToBottom(); scrollToBottom();
} }
return Scaffold( return ProviderScope(
overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)],
child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: asset == null ? Text(album.name) : null, title: asset == null ? Text(album.name) : null,
actions: [const LikeActivityActionButton(menuItem: true)], actions: [const LikeActivityActionButton(menuItem: true)],
@ -99,6 +103,7 @@ class DriftActivitiesPage extends HookConsumerWidget {
}, },
), ),
resizeToAvoidBottomInset: true, resizeToAvoidBottomInset: true,
),
); );
} }
} }

View File

@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.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/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
@ -43,7 +42,6 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
), ),
AlbumSelector( AlbumSelector(
onAlbumSelected: (album) { onAlbumSelected: (album) {
ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album);
context.router.push(RemoteAlbumRoute(album: album)); context.router.push(RemoteAlbumRoute(album: album));
}, },
), ),

View File

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_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() @RoutePage()
class DriftAlbumOptionsPage extends HookConsumerWidget { class DriftAlbumOptionsPage extends HookConsumerWidget {
const DriftAlbumOptionsPage({super.key}); final RemoteAlbum album;
const DriftAlbumOptionsPage({super.key, required this.album});
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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 sharedUsersAsync = ref.watch(remoteAlbumSharedUsersProvider(album.id));
final userId = ref.watch(authProvider).userId; final userId = ref.watch(authProvider).userId;
final activityEnabled = useState(album.isActivityEnabled); final activityEnabled = useState(album.isActivityEnabled);
@ -191,7 +188,9 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
); );
} }
return Scaffold( return ProviderScope(
overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)],
child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded), icon: const Icon(Icons.arrow_back_ios_new_rounded),
@ -234,6 +233,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
buildSharedUsersList(), buildSharedUsersList(),
], ],
), ),
),
); );
} }
} }

View File

@ -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/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.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/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/album/album_action_filled_button.dart'; import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
@ -180,7 +179,6 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
); );
if (album != null) { if (album != null) {
ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album);
unawaited(context.replaceRoute(RemoteAlbumRoute(album: album))); unawaited(context.replaceRoute(RemoteAlbumRoute(album: album)));
} }
} }

View File

@ -168,7 +168,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
} }
Future<void> showActivity(BuildContext context) async { Future<void> showActivity(BuildContext context) async {
unawaited(context.pushRoute(const DriftActivitiesRoute())); unawaited(context.pushRoute(DriftActivitiesRoute(album: _album)));
} }
Future<void> showOptionSheet(BuildContext context) async { Future<void> showOptionSheet(BuildContext context) async {
@ -224,7 +224,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
: null, : null,
onShowOptions: () { onShowOptions: () {
context.pop(); context.pop();
context.pushRoute(const DriftAlbumOptionsRoute()); context.pushRoute(DriftAlbumOptionsRoute(album: _album));
}, },
); );
}, },
@ -237,24 +237,14 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
final user = ref.watch(currentUserProvider); final user = ref.watch(currentUserProvider);
final isOwner = user != null ? user.id == _album.ownerId : false; final isOwner = user != null ? user.id == _album.ownerId : false;
return PopScope( return ProviderScope(
onPopInvokedWithResult: (didPop, _) {
if (didPop) {
Future.microtask(() {
if (mounted) {
ref.read(currentRemoteAlbumProvider.notifier).dispose();
ref.read(remoteAlbumProvider.notifier).refresh();
}
});
}
},
child: ProviderScope(
overrides: [ overrides: [
timelineServiceProvider.overrideWith((ref) { timelineServiceProvider.overrideWith((ref) {
final timelineService = ref.watch(timelineFactoryProvider).remoteAlbum(albumId: _album.id); final timelineService = ref.watch(timelineFactoryProvider).remoteAlbum(albumId: _album.id);
ref.onDispose(timelineService.dispose); ref.onDispose(timelineService.dispose);
return timelineService; return timelineService;
}), }),
currentRemoteAlbumScopedProvider.overrideWithValue(_album),
], ],
child: Timeline( child: Timeline(
appBar: RemoteAlbumSliverAppBar( appBar: RemoteAlbumSliverAppBar(
@ -266,7 +256,6 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
), ),
bottomSheet: RemoteAlbumBottomSheet(album: _album), bottomSheet: RemoteAlbumBottomSheet(album: _album),
), ),
),
); );
} }
} }

View File

@ -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/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/albums/album_search.model.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/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.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/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
@ -516,38 +515,6 @@ class _AlbumList extends ConsumerWidget {
sliver: SliverList.builder( sliver: SliverList.builder(
itemBuilder: (_, index) { itemBuilder: (_, index) {
final album = albums[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; final isOwner = album.ownerId == userId;
if (isOwner) { if (isOwner) {
@ -576,11 +543,14 @@ class _AlbumList extends ConsumerWidget {
onDismissed: (direction) async { onDismissed: (direction) async {
await ref.read(remoteAlbumProvider.notifier).deleteAlbum(album.id); await ref.read(remoteAlbumProvider.notifier).deleteAlbum(album.id);
}, },
child: albumTile, child: AlbumTile(album: album, isOwner: isOwner, onAlbumSelected: onAlbumSelected),
), ),
); );
} else { } 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, itemCount: albums.length,
@ -709,7 +679,6 @@ class AddToAlbumHeader extends ConsumerWidget {
return; return;
} }
ref.read(currentRemoteAlbumProvider.notifier).setAlbum(newAlbum);
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
unawaited(context.pushRoute(RemoteAlbumRoute(album: newAlbum))); unawaited(context.pushRoute(RemoteAlbumRoute(album: newAlbum)));
} }

View File

@ -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),
),
),
);
}
}

View File

@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.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/platform_extensions.dart';
import 'package:immich_mobile/extensions/scroll_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/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.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.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'; 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/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/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/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/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.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/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/cast.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/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/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
@ -39,15 +41,25 @@ class AssetViewerPage extends StatelessWidget {
final int initialIndex; final int initialIndex;
final TimelineService timelineService; final TimelineService timelineService;
final int? heroOffset; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// This is necessary to ensure that the timeline service is available // This is necessary to ensure that the timeline service is available
// since the Timeline and AssetViewer are on different routes / Widget subtrees. // since the Timeline and AssetViewer are on different routes / Widget subtrees.
return ProviderScope( return ProviderScope(
overrides: [timelineServiceProvider.overrideWithValue(timelineService)], overrides: [
timelineServiceProvider.overrideWithValue(timelineService),
currentRemoteAlbumScopedProvider.overrideWithValue(currentAlbum),
],
child: AssetViewer(initialIndex: initialIndex, heroOffset: heroOffset), child: AssetViewer(initialIndex: initialIndex, heroOffset: heroOffset),
); );
} }

View File

@ -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:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_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_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/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/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.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/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/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.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/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/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.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/action_button.utils.dart';
import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/widgets/common/immich_toast.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); 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier); final asset = ref.watch(currentAssetNotifier);
@ -217,7 +279,10 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
color: context.textTheme.bodyMedium?.color?.withAlpha(155), 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),
], ],
); );
} }

View File

@ -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/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/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.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/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@ -163,6 +164,7 @@ class _AssetTileWidget extends ConsumerWidget {
initialIndex: assetIndex, initialIndex: assetIndex,
timelineService: ref.read(timelineServiceProvider), timelineService: ref.read(timelineServiceProvider),
heroOffset: heroOffset, heroOffset: heroOffset,
currentAlbum: ref.read(currentRemoteAlbumProvider),
), ),
), ),
); );

View File

@ -1,4 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.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/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/local_album.service.dart'; import 'package:immich_mobile/domain/services/local_album.service.dart';
@ -40,3 +41,7 @@ final remoteAlbumProvider = NotifierProvider<RemoteAlbumNotifier, RemoteAlbumSta
RemoteAlbumNotifier.new, RemoteAlbumNotifier.new,
dependencies: [remoteAlbumServiceProvider], dependencies: [remoteAlbumServiceProvider],
); );
final albumsContainingAssetProvider = FutureProvider.family<List<RemoteAlbum>, String>(
(ref, assetId) => ref.watch(remoteAlbumServiceProvider).getAlbumsContainingAsset(assetId),
);

View File

@ -1,36 +1,30 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.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/album.model.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
final currentRemoteAlbumProvider = AutoDisposeNotifierProvider<CurrentAlbumNotifier, RemoteAlbum?>( final currentRemoteAlbumScopedProvider = Provider<RemoteAlbum?>((ref) => null);
final currentRemoteAlbumProvider = NotifierProvider<CurrentAlbumNotifier, RemoteAlbum?>(
CurrentAlbumNotifier.new, CurrentAlbumNotifier.new,
dependencies: [currentRemoteAlbumScopedProvider, remoteAlbumServiceProvider],
); );
class CurrentAlbumNotifier extends AutoDisposeNotifier<RemoteAlbum?> { class CurrentAlbumNotifier extends Notifier<RemoteAlbum?> {
KeepAliveLink? _keepAliveLink;
StreamSubscription<RemoteAlbum?>? _assetSubscription;
@override @override
RemoteAlbum? build() => null; RemoteAlbum? build() {
final album = ref.watch(currentRemoteAlbumScopedProvider);
void setAlbum(RemoteAlbum album) { if (album == null) {
_keepAliveLink?.close(); return null;
_assetSubscription?.cancel(); }
state = album;
_assetSubscription = ref.watch(remoteAlbumServiceProvider).watchAlbum(album.id).listen((updatedAlbum) { final watcher = ref.watch(remoteAlbumServiceProvider).watchAlbum(album.id).listen((updatedAlbum) {
if (updatedAlbum != null) { if (updatedAlbum != null) {
state = updatedAlbum; state = updatedAlbum;
} }
}); });
_keepAliveLink = ref.keepAlive();
}
void dispose() { ref.onDispose(watcher.cancel);
_keepAliveLink?.close();
_assetSubscription?.cancel(); return album;
state = null;
} }
} }

View File

@ -303,7 +303,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftBackupAlbumSelectionRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftBackupAlbumSelectionRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: LocalTimelineRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: LocalTimelineRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: MainTimelineRoute.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( AutoRoute(
page: AssetViewerRoute.page, page: AssetViewerRoute.page,
guards: [_authGuard, _duplicateGuard], guards: [_authGuard, _duplicateGuard],

View File

@ -448,6 +448,7 @@ class AssetViewerRoute extends PageRouteInfo<AssetViewerRouteArgs> {
required int initialIndex, required int initialIndex,
required TimelineService timelineService, required TimelineService timelineService,
int? heroOffset, int? heroOffset,
RemoteAlbum? currentAlbum,
List<PageRouteInfo>? children, List<PageRouteInfo>? children,
}) : super( }) : super(
AssetViewerRoute.name, AssetViewerRoute.name,
@ -456,6 +457,7 @@ class AssetViewerRoute extends PageRouteInfo<AssetViewerRouteArgs> {
initialIndex: initialIndex, initialIndex: initialIndex,
timelineService: timelineService, timelineService: timelineService,
heroOffset: heroOffset, heroOffset: heroOffset,
currentAlbum: currentAlbum,
), ),
initialChildren: children, initialChildren: children,
); );
@ -471,6 +473,7 @@ class AssetViewerRoute extends PageRouteInfo<AssetViewerRouteArgs> {
initialIndex: args.initialIndex, initialIndex: args.initialIndex,
timelineService: args.timelineService, timelineService: args.timelineService,
heroOffset: args.heroOffset, heroOffset: args.heroOffset,
currentAlbum: args.currentAlbum,
); );
}, },
); );
@ -482,6 +485,7 @@ class AssetViewerRouteArgs {
required this.initialIndex, required this.initialIndex,
required this.timelineService, required this.timelineService,
this.heroOffset, this.heroOffset,
this.currentAlbum,
}); });
final Key? key; final Key? key;
@ -492,9 +496,11 @@ class AssetViewerRouteArgs {
final int? heroOffset; final int? heroOffset;
final RemoteAlbum? currentAlbum;
@override @override
String toString() { 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<void> {
/// generated route for /// generated route for
/// [DriftActivitiesPage] /// [DriftActivitiesPage]
class DriftActivitiesRoute extends PageRouteInfo<void> { class DriftActivitiesRoute extends PageRouteInfo<DriftActivitiesRouteArgs> {
const DriftActivitiesRoute({List<PageRouteInfo>? children}) DriftActivitiesRoute({
: super(DriftActivitiesRoute.name, initialChildren: children); Key? key,
required RemoteAlbum album,
List<PageRouteInfo>? children,
}) : super(
DriftActivitiesRoute.name,
args: DriftActivitiesRouteArgs(key: key, album: album),
initialChildren: children,
);
static const String name = 'DriftActivitiesRoute'; static const String name = 'DriftActivitiesRoute';
static PageInfo page = PageInfo( static PageInfo page = PageInfo(
name, name,
builder: (data) { builder: (data) {
return const DriftActivitiesPage(); final args = data.argsAs<DriftActivitiesRouteArgs>();
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 /// generated route for
/// [DriftAlbumOptionsPage] /// [DriftAlbumOptionsPage]
class DriftAlbumOptionsRoute extends PageRouteInfo<void> { class DriftAlbumOptionsRoute extends PageRouteInfo<DriftAlbumOptionsRouteArgs> {
const DriftAlbumOptionsRoute({List<PageRouteInfo>? children}) DriftAlbumOptionsRoute({
: super(DriftAlbumOptionsRoute.name, initialChildren: children); Key? key,
required RemoteAlbum album,
List<PageRouteInfo>? children,
}) : super(
DriftAlbumOptionsRoute.name,
args: DriftAlbumOptionsRouteArgs(key: key, album: album),
initialChildren: children,
);
static const String name = 'DriftAlbumOptionsRoute'; static const String name = 'DriftAlbumOptionsRoute';
static PageInfo page = PageInfo( static PageInfo page = PageInfo(
name, name,
builder: (data) { builder: (data) {
return const DriftAlbumOptionsPage(); final args = data.argsAs<DriftAlbumOptionsRouteArgs>();
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 /// generated route for
/// [DriftAlbumsPage] /// [DriftAlbumsPage]
class DriftAlbumsRoute extends PageRouteInfo<void> { class DriftAlbumsRoute extends PageRouteInfo<void> {

View File

@ -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/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.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/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/memory.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
@ -29,7 +28,6 @@ final deepLinkServiceProvider = Provider(
// Below is used for beta timeline // Below is used for beta timeline
ref.watch(timelineFactoryProvider), ref.watch(timelineFactoryProvider),
ref.watch(beta_asset_provider.assetServiceProvider), ref.watch(beta_asset_provider.assetServiceProvider),
ref.watch(currentRemoteAlbumProvider.notifier),
ref.watch(remoteAlbumServiceProvider), ref.watch(remoteAlbumServiceProvider),
ref.watch(driftMemoryServiceProvider), ref.watch(driftMemoryServiceProvider),
), ),
@ -46,7 +44,6 @@ class DeepLinkService {
/// Used for beta timeline /// Used for beta timeline
final TimelineFactory _betaTimelineFactory; final TimelineFactory _betaTimelineFactory;
final beta_asset_service.AssetService _betaAssetService; final beta_asset_service.AssetService _betaAssetService;
final CurrentAlbumNotifier _betaCurrentAlbumNotifier;
final RemoteAlbumService _betaRemoteAlbumService; final RemoteAlbumService _betaRemoteAlbumService;
final DriftMemoryService _betaMemoryServiceProvider; final DriftMemoryService _betaMemoryServiceProvider;
@ -58,7 +55,6 @@ class DeepLinkService {
this._currentAlbum, this._currentAlbum,
this._betaTimelineFactory, this._betaTimelineFactory,
this._betaAssetService, this._betaAssetService,
this._betaCurrentAlbumNotifier,
this._betaRemoteAlbumService, this._betaRemoteAlbumService,
this._betaMemoryServiceProvider, this._betaMemoryServiceProvider,
); );
@ -176,7 +172,6 @@ class DeepLinkService {
return null; return null;
} }
_betaCurrentAlbumNotifier.setAlbum(album);
return RemoteAlbumRoute(album: album); return RemoteAlbumRoute(album: album);
} else { } else {
// TODO: Remove this when beta is default // TODO: Remove this when beta is default

View File

@ -25,7 +25,7 @@ class RemoteAlbumSharedUserIcons extends ConsumerWidget {
} }
return GestureDetector( return GestureDetector(
onTap: () => context.pushRoute(const DriftAlbumOptionsRoute()), onTap: () => context.pushRoute(DriftAlbumOptionsRoute(album: currentAlbum)),
child: SizedBox( child: SizedBox(
height: 50, height: 50,
child: ListView.builder( child: ListView.builder(

View File

@ -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/remote_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.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'; import 'package:immich_mobile/widgets/album/remote_album_shared_user_icons.dart';
class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget { class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget {
@ -89,7 +88,7 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
color: actionIconColor, color: actionIconColor,
shadows: actionIconShadows, shadows: actionIconShadows,
), ),
onPressed: () => context.navigateTo(const TabShellRoute(children: [DriftAlbumsRoute()])), onPressed: () => context.maybePop(),
), ),
actions: [ actions: [
if (widget.onToggleAlbumOrder != null) if (widget.onToggleAlbumOrder != null)