diff --git a/i18n/en.json b/i18n/en.json index 89780f62b..dfe2954c9 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1795,6 +1795,7 @@ "sort_title": "Title", "source": "Source", "stack": "Stack", + "stack_action_prompt": "{count} stacked", "stack_duplicates": "Stack duplicates", "stack_select_one_photo": "Select one main photo for the stack", "stack_selected_photos": "Stack selected photos", @@ -1905,6 +1906,7 @@ "unselect_all_duplicates": "Unselect all duplicates", "unselect_all_in": "Unselect all in {group}", "unstack": "Un-stack", + "unstack_action_prompt": "{count} unstacked", "unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}", "untagged": "Untagged", "up_next": "Up next", diff --git a/mobile/drift_schemas/main/drift_schema_v1.json b/mobile/drift_schemas/main/drift_schema_v1.json index 03656ce0b..c19bcfb94 100644 Binary files a/mobile/drift_schemas/main/drift_schema_v1.json and b/mobile/drift_schemas/main/drift_schema_v1.json differ diff --git a/mobile/drift_schemas/main/drift_schema_v2.json b/mobile/drift_schemas/main/drift_schema_v2.json index 4c1e3d68a..c19bcfb94 100644 Binary files a/mobile/drift_schemas/main/drift_schema_v2.json and b/mobile/drift_schemas/main/drift_schema_v2.json differ diff --git a/mobile/immich_lint/lib/immich_mobile_immich_lint.dart b/mobile/immich_lint/lib/immich_mobile_immich_lint.dart index e2622fadd..7d3ed4757 100644 --- a/mobile/immich_lint/lib/immich_mobile_immich_lint.dart +++ b/mobile/immich_lint/lib/immich_mobile_immich_lint.dart @@ -23,7 +23,7 @@ class ImmichLinter extends PluginBase { return rules; } - static makeCode(String name, LintOptions options) => LintCode( + static LintCode makeCode(String name, LintOptions options) => LintCode( name: name, problemMessage: options.json["message"] as String, errorSeverity: ErrorSeverity.WARNING, diff --git a/mobile/lib/domain/models/asset/local_asset.model.dart b/mobile/lib/domain/models/asset/local_asset.model.dart index 0c3e8fa94..3466a0f25 100644 --- a/mobile/lib/domain/models/asset/local_asset.model.dart +++ b/mobile/lib/domain/models/asset/local_asset.model.dart @@ -45,6 +45,7 @@ class LocalAsset extends BaseAsset { }'''; } + // Not checking for remoteId here @override bool operator ==(Object other) { if (other is! LocalAsset) return false; diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index 9e4cfa1f1..760a16170 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -14,6 +14,8 @@ class RemoteAsset extends BaseAsset { final String? thumbHash; final AssetVisibility visibility; final String ownerId; + final String? stackId; + final int stackCount; const RemoteAsset({ required this.id, @@ -31,6 +33,8 @@ class RemoteAsset extends BaseAsset { this.thumbHash, this.visibility = AssetVisibility.timeline, super.livePhotoVideoId, + this.stackId, + this.stackCount = 0, }); @override @@ -56,9 +60,14 @@ class RemoteAsset extends BaseAsset { isFavorite: $isFavorite, thumbHash: ${thumbHash ?? ""}, visibility: $visibility, + stackId: ${stackId ?? ""}, + stackCount: $stackCount, + checksum: $checksum, + livePhotoVideoId: ${livePhotoVideoId ?? ""}, }'''; } + // Not checking for localId here @override bool operator ==(Object other) { if (other is! RemoteAsset) return false; @@ -67,7 +76,9 @@ class RemoteAsset extends BaseAsset { id == other.id && ownerId == other.ownerId && thumbHash == other.thumbHash && - visibility == other.visibility; + visibility == other.visibility && + stackId == other.stackId && + stackCount == other.stackCount; } @override @@ -77,7 +88,9 @@ class RemoteAsset extends BaseAsset { ownerId.hashCode ^ localId.hashCode ^ thumbHash.hashCode ^ - visibility.hashCode; + visibility.hashCode ^ + stackId.hashCode ^ + stackCount.hashCode; RemoteAsset copyWith({ String? id, @@ -95,6 +108,8 @@ class RemoteAsset extends BaseAsset { String? thumbHash, AssetVisibility? visibility, String? livePhotoVideoId, + String? stackId, + int? stackCount, }) { return RemoteAsset( id: id ?? this.id, @@ -112,6 +127,8 @@ class RemoteAsset extends BaseAsset { thumbHash: thumbHash ?? this.thumbHash, visibility: visibility ?? this.visibility, livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, + stackId: stackId ?? this.stackId, + stackCount: stackCount ?? this.stackCount, ); } } diff --git a/mobile/lib/domain/models/stack.model.dart b/mobile/lib/domain/models/stack.model.dart index 5404eb8f4..d7faf07a2 100644 --- a/mobile/lib/domain/models/stack.model.dart +++ b/mobile/lib/domain/models/stack.model.dart @@ -82,3 +82,27 @@ class Stack { primaryAssetId.hashCode; } } + +class StackResponse { + final String id; + final String primaryAssetId; + final List assetIds; + + const StackResponse({ + required this.id, + required this.primaryAssetId, + required this.assetIds, + }); + + @override + bool operator ==(covariant StackResponse other) { + if (identical(this, other)) return true; + + return other.id == id && + other.primaryAssetId == primaryAssetId && + other.assetIds == assetIds; + } + + @override + int get hashCode => id.hashCode ^ primaryAssetId.hashCode ^ assetIds.hashCode; +} diff --git a/mobile/lib/domain/models/timeline.model.dart b/mobile/lib/domain/models/timeline.model.dart index 4a49708b7..f3b688b8b 100644 --- a/mobile/lib/domain/models/timeline.model.dart +++ b/mobile/lib/domain/models/timeline.model.dart @@ -1,3 +1,5 @@ +import 'package:immich_mobile/domain/utils/event_stream.dart'; + enum GroupAssetsBy { day, month, @@ -38,3 +40,7 @@ class TimeBucket extends Bucket { @override int get hashCode => super.hashCode ^ date.hashCode; } + +class TimelineReloadEvent extends Event { + const TimelineReloadEvent(); +} diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index 2c9b49318..63b1aad8c 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -24,6 +24,17 @@ class AssetService { : _remoteAssetRepository.watchAsset(id); } + Future> getStack(RemoteAsset asset) async { + if (asset.stackId == null) { + return []; + } + + return _remoteAssetRepository.getStackChildren(asset).then((assets) { + // Include the primary asset in the stack as the first item + return [asset, ...assets]; + }); + } + Future getExif(BaseAsset asset) async { if (!asset.hasRemote) { return null; diff --git a/mobile/lib/domain/utils/event_stream.dart b/mobile/lib/domain/utils/event_stream.dart index 65ee17e12..e728ece58 100644 --- a/mobile/lib/domain/utils/event_stream.dart +++ b/mobile/lib/domain/utils/event_stream.dart @@ -1,17 +1,9 @@ import 'dart:async'; -sealed class Event { +class Event { const Event(); } -class TimelineReloadEvent extends Event { - const TimelineReloadEvent(); -} - -class ViewerOpenBottomSheetEvent extends Event { - const ViewerOpenBottomSheetEvent(); -} - class EventStream { EventStream._(); diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift b/mobile/lib/infrastructure/entities/merged_asset.drift index e07edbc0c..3dc7221c1 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift +++ b/mobile/lib/infrastructure/entities/merged_asset.drift @@ -1,5 +1,6 @@ import 'remote_asset.entity.dart'; import 'local_asset.entity.dart'; +import 'stack.entity.dart'; mergedAsset: SELECT * FROM ( @@ -18,13 +19,33 @@ mergedAsset: SELECT * FROM rae.checksum, rae.owner_id, rae.live_photo_video_id, - 0 as orientation + 0 as orientation, + rae.stack_id, + COALESCE(stack_count.total_count, 0) AS stack_count FROM remote_asset_entity rae LEFT JOIN local_asset_entity lae ON rae.checksum = lae.checksum + LEFT JOIN + stack_entity se ON rae.stack_id = se.id + LEFT JOIN + (SELECT + stack_id, + COUNT(*) AS total_count + FROM remote_asset_entity + WHERE deleted_at IS NULL + AND visibility = 0 + AND stack_id IS NOT NULL + GROUP BY stack_id + ) AS stack_count ON rae.stack_id = stack_count.stack_id WHERE - rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id in ? + rae.deleted_at IS NULL + AND rae.visibility = 0 + AND rae.owner_id in ? + AND ( + rae.stack_id IS NULL + OR rae.id = se.primary_asset_id + ) UNION ALL SELECT NULL as remote_id, @@ -41,7 +62,9 @@ mergedAsset: SELECT * FROM lae.checksum, NULL as owner_id, NULL as live_photo_video_id, - lae.orientation + lae.orientation, + NULL as stack_id, + 0 AS stack_count FROM local_asset_entity lae LEFT JOIN @@ -68,8 +91,16 @@ FROM remote_asset_entity rae LEFT JOIN local_asset_entity lae ON rae.checksum = lae.checksum + LEFT JOIN + stack_entity se ON rae.stack_id = se.id WHERE - rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id in ? + rae.deleted_at IS NULL + AND rae.visibility = 0 + AND rae.owner_id in ? + AND ( + rae.stack_id IS NULL + OR rae.id = se.primary_asset_id + ) UNION ALL SELECT lae.name, diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift.dart b/mobile/lib/infrastructure/entities/merged_asset.drift.dart index 4ee064370..ac3db868e 100644 Binary files a/mobile/lib/infrastructure/entities/merged_asset.drift.dart and b/mobile/lib/infrastructure/entities/merged_asset.drift.dart differ diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.dart index 96193e041..0b2896538 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.dart @@ -34,6 +34,8 @@ class RemoteAssetEntity extends Table IntColumn get visibility => intEnum()(); + TextColumn get stackId => text().nullable()(); + @override Set get primaryKey => {id}; } @@ -55,5 +57,6 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData { visibility: visibility, livePhotoVideoId: livePhotoVideoId, localId: null, + stackId: stackId, ); } diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart index 2bb7cffe5..543ed6598 100644 Binary files a/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart and b/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index 15d445d22..3b826c209 100644 Binary files a/mobile/lib/infrastructure/repositories/db.repository.drift.dart and b/mobile/lib/infrastructure/repositories/db.repository.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index 5815d3197..4b27ce830 100644 Binary files a/mobile/lib/infrastructure/repositories/db.repository.steps.dart and b/mobile/lib/infrastructure/repositories/db.repository.steps.dart differ diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 1f6f1b089..28c229b46 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -1,11 +1,13 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/domain/models/stack.model.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart' hide ExifInfo; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -30,25 +32,66 @@ class RemoteAssetRepository extends DriftDatabaseRepository { } Stream watchAsset(String id) { - final query = _db.remoteAssetEntity - .select() - .addColumns([_db.localAssetEntity.id]).join([ + final stackCountRef = _db.stackEntity.id.count(); + + final query = _db.remoteAssetEntity.select().addColumns([ + _db.localAssetEntity.id, + _db.stackEntity.primaryAssetId, + stackCountRef, + ]).join([ leftOuterJoin( _db.localAssetEntity, _db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum), useColumns: false, ), + leftOuterJoin( + _db.stackEntity, + _db.stackEntity.primaryAssetId.equalsExp(_db.remoteAssetEntity.id), + useColumns: false, + ), + leftOuterJoin( + _db.remoteAssetEntity.createAlias('stacked_assets'), + _db.stackEntity.id.equalsExp( + _db.remoteAssetEntity.createAlias('stacked_assets').stackId, + ), + useColumns: false, + ), ]) - ..where(_db.remoteAssetEntity.id.equals(id)); + ..where(_db.remoteAssetEntity.id.equals(id)) + ..groupBy([ + _db.remoteAssetEntity.id, + _db.localAssetEntity.id, + _db.stackEntity.primaryAssetId, + ]); return query.map((row) { final asset = row.readTable(_db.remoteAssetEntity).toDto(); + final primaryAssetId = row.read(_db.stackEntity.primaryAssetId); + final stackCount = + primaryAssetId == id ? (row.read(stackCountRef) ?? 0) : 0; + return asset.copyWith( localId: row.read(_db.localAssetEntity.id), + stackCount: stackCount, ); }).watchSingleOrNull(); } + Future> getStackChildren(RemoteAsset asset) { + if (asset.stackId == null) { + return Future.value([]); + } + + final query = _db.remoteAssetEntity.select() + ..where( + (row) => + row.stackId.equals(asset.stackId!) & row.id.equals(asset.id).not(), + ) + ..orderBy([(row) => OrderingTerm.desc(row.createdAt)]); + + return query.map((row) => row.toDto()).get(); + } + Future getExif(String id) { return _db.managers.remoteExifEntity .filter((row) => row.assetId.id.equals(id)) @@ -146,4 +189,53 @@ class RemoteAssetRepository extends DriftDatabaseRepository { } }); } + + Future stack(String userId, StackResponse stack) { + return _db.transaction(() async { + final stackIds = await _db.managers.stackEntity + .filter((row) => row.primaryAssetId.id.isIn(stack.assetIds)) + .map((row) => row.id) + .get(); + + await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds)); + + await _db.batch((batch) { + final companion = StackEntityCompanion( + ownerId: Value(userId), + primaryAssetId: Value(stack.primaryAssetId), + ); + + batch.insert( + _db.stackEntity, + companion.copyWith(id: Value(stack.id)), + onConflict: DoUpdate((_) => companion), + ); + + for (final assetId in stack.assetIds) { + batch.update( + _db.remoteAssetEntity, + RemoteAssetEntityCompanion( + stackId: Value(stack.id), + ), + where: (e) => e.id.equals(assetId), + ); + } + }); + }); + } + + Future unStack(List stackIds) { + return _db.transaction(() async { + await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds)); + + // TODO: delete this after adding foreign key on stackId + await _db.batch((batch) { + batch.update( + _db.remoteAssetEntity, + const RemoteAssetEntityCompanion(stackId: Value(null)), + where: (e) => e.stackId.isIn(stackIds), + ); + }); + }); + } } diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index f3f26bb01..d28c68ed7 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -137,6 +137,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { deletedAt: Value(asset.deletedAt), visibility: Value(asset.visibility.toAssetVisibility()), livePhotoVideoId: Value(asset.livePhotoVideoId), + stackId: Value(asset.stackId), ); batch.insert( diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index a6d89b5e8..0c3eee59a 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -89,6 +89,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository { isFavorite: row.isFavorite, durationInSeconds: row.durationInSeconds, livePhotoVideoId: row.livePhotoVideoId, + stackId: row.stackId, + stackCount: row.stackCount, ) : LocalAsset( id: row.localId!, diff --git a/mobile/lib/presentation/pages/dev/main_timeline.page.dart b/mobile/lib/presentation/pages/dev/main_timeline.page.dart index 7216b638e..0582399ea 100644 --- a/mobile/lib/presentation/pages/dev/main_timeline.page.dart +++ b/mobile/lib/presentation/pages/dev/main_timeline.page.dart @@ -13,7 +13,7 @@ class MainTimelinePage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final memoryLaneProvider = ref.watch(driftMemoryFutureProvider); - return memoryLaneProvider.when( + return memoryLaneProvider.maybeWhen( data: (memories) { return memories.isEmpty ? const Timeline(showStorageIndicator: true) @@ -26,8 +26,7 @@ class MainTimelinePage extends ConsumerWidget { showStorageIndicator: true, ); }, - loading: () => const Timeline(showStorageIndicator: true), - error: (error, stackTrace) => const Timeline(showStorageIndicator: true), + orElse: () => const Timeline(showStorageIndicator: true), ); } } diff --git a/mobile/lib/presentation/widgets/action_buttons/stack_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/stack_action_button.widget.dart index dc42eb96f..13782c009 100644 --- a/mobile/lib/presentation/widgets/action_buttons/stack_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/stack_action_button.widget.dart @@ -1,16 +1,56 @@ import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; class StackActionButton extends ConsumerWidget { - const StackActionButton({super.key}); + final ActionSource source; + + const StackActionButton({super.key, required this.source}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final user = ref.watch(currentUserProvider); + if (user == null) { + throw Exception('User must be logged in to access stack action'); + } + + final result = + await ref.read(actionProvider.notifier).stack(user.id, source); + ref.read(multiSelectProvider.notifier).reset(); + + final successMessage = 'stack_action_prompt'.t( + context: context, + args: {'count': result.count.toString()}, + ); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success + ? successMessage + : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } + } @override Widget build(BuildContext context, WidgetRef ref) { return BaseActionButton( iconData: Icons.filter_none_rounded, label: "stack".t(context: context), + onPressed: () => _onTap(context, ref), ); } } diff --git a/mobile/lib/presentation/widgets/action_buttons/unstack_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/unstack_action_button.widget.dart new file mode 100644 index 000000000..c2757043a --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/unstack_action_button.widget.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +class UnStackActionButton extends ConsumerWidget { + final ActionSource source; + + const UnStackActionButton({super.key, required this.source}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).unStack(source); + ref.read(multiSelectProvider.notifier).reset(); + + final successMessage = 'unstack_action_prompt'.t( + context: context, + args: {'count': result.count.toString()}, + ); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success + ? successMessage + : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + iconData: Icons.filter_none_rounded, + label: "unstack".t(context: context), + onPressed: () => _onTap(context, ref), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.provider.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.provider.dart new file mode 100644 index 000000000..cb4e02b56 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.provider.dart @@ -0,0 +1,24 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; + +class StackChildrenNotifier + extends AutoDisposeFamilyAsyncNotifier, BaseAsset?> { + @override + Future> build(BaseAsset? asset) async { + if (asset == null || + asset is! RemoteAsset || + asset.stackId == null || + // The stackCount check is to ensure we only fetch stacks for timelines that have stacks + asset.stackCount == 0) { + return const []; + } + + return ref.watch(assetServiceProvider).getStack(asset); + } +} + +final stackChildrenNotifier = AsyncNotifierProvider.autoDispose + .family, BaseAsset?>( + StackChildrenNotifier.new, +); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart new file mode 100644 index 000000000..8b3d0c657 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; + +class AssetStackRow extends ConsumerWidget { + const AssetStackRow({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + int opacity = ref.watch( + assetViewerProvider.select((state) => state.backgroundOpacity), + ); + final showControls = + ref.watch(assetViewerProvider.select((s) => s.showingControls)); + + if (!showControls) { + opacity = 0; + } + + final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); + + return IgnorePointer( + ignoring: opacity < 255, + child: AnimatedOpacity( + opacity: opacity / 255, + duration: Durations.short2, + child: ref.watch(stackChildrenNotifier(asset)).when( + data: (state) => SizedBox.square( + dimension: 80, + child: _StackList(stack: state), + ), + error: (_, __) => const SizedBox.shrink(), + loading: () => const SizedBox.shrink(), + ), + ), + ); + } +} + +class _StackList extends ConsumerWidget { + final List stack; + + const _StackList({required this.stack}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only( + left: 5, + right: 5, + bottom: 30, + ), + itemCount: stack.length, + itemBuilder: (ctx, index) { + final asset = stack[index]; + return Padding( + padding: const EdgeInsets.only(right: 5), + child: GestureDetector( + onTap: () { + ref.read(assetViewerProvider.notifier).setStackIndex(index); + ref.read(currentAssetNotifier.notifier).setAsset(asset); + }, + child: Container( + height: 60, + width: 60, + decoration: index == + ref.watch(assetViewerProvider.select((s) => s.stackIndex)) + ? const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(6)), + border: Border.fromBorderSide( + BorderSide(color: Colors.white, width: 2), + ), + ) + : const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(6)), + border: null, + ), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: Stack( + fit: StackFit.expand, + children: [ + Image( + fit: BoxFit.cover, + image: getThumbnailImageProvider( + remoteId: asset.id, + size: const Size.square(60), + ), + ), + if (asset.isVideo) + const Icon( + Icons.play_circle_outline_rounded, + color: Colors.white, + size: 16, + shadows: [ + Shadow( + blurRadius: 5.0, + color: Color.fromRGBO(0, 0, 0, 0.6), + offset: Offset(0.0, 0.0), + ), + ], + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} 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 1c0f28413..50f4a0919 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -5,10 +5,13 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.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'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart'; @@ -85,6 +88,7 @@ class _AssetViewerState extends ConsumerState { double previousExtent = _kBottomSheetMinimumExtent; Offset dragDownPosition = Offset.zero; int totalAssets = 0; + int stackIndex = 0; BuildContext? scaffoldContext; Map videoPlayerKeys = {}; @@ -167,6 +171,10 @@ class _AssetViewerState extends ConsumerState { void _onAssetChanged(int index) { final asset = ref.read(timelineServiceProvider).getAsset(index); + // Always holds the current asset from the timeline + ref.read(assetViewerProvider.notifier).setAsset(asset); + // The currentAssetNotifier actually holds the current asset that is displayed + // which could be stack children as well ref.read(currentAssetNotifier.notifier).setAsset(asset); if (asset.isVideo || asset.isMotionPhoto) { ref.read(videoPlaybackValueProvider.notifier).reset(); @@ -488,7 +496,12 @@ class _AssetViewerState extends ConsumerState { ImageChunkEvent? progress, int index, ) { - final asset = ref.read(timelineServiceProvider).getAsset(index); + BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index); + final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull; + if (stackChildren != null && stackChildren.isNotEmpty) { + asset = stackChildren + .elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex))); + } return Container( width: double.infinity, height: double.infinity, @@ -516,9 +529,14 @@ class _AssetViewerState extends ConsumerState { PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) { scaffoldContext ??= ctx; - final asset = ref.read(timelineServiceProvider).getAsset(index); - final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider); + BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index); + final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull; + if (stackChildren != null && stackChildren.isNotEmpty) { + asset = stackChildren + .elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex))); + } + final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider); if (asset.isImage && !isPlayingMotionVideo) { return _imageBuilder(ctx, asset); } @@ -604,6 +622,7 @@ class _AssetViewerState extends ConsumerState { // Using multiple selectors to avoid unnecessary rebuilds for other state changes ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)); + ref.watch(assetViewerProvider.select((s) => s.stackIndex)); ref.watch(isPlayingMotionVideoProvider); // Listen for casting changes and send initial asset to the cast provider @@ -645,7 +664,17 @@ class _AssetViewerState extends ConsumerState { backgroundDecoration: BoxDecoration(color: backgroundColor), enablePanAlways: true, ), - bottomNavigationBar: const ViewerBottomBar(), + bottomNavigationBar: showingBottomSheet + ? const SizedBox.shrink() + : const Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AssetStackRow(), + ViewerBottomBar(), + ], + ), ), ); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart index 020d1d9b2..825b637e8 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart @@ -1,26 +1,40 @@ +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +class ViewerOpenBottomSheetEvent extends Event { + const ViewerOpenBottomSheetEvent(); +} + class AssetViewerState { final int backgroundOpacity; final bool showingBottomSheet; final bool showingControls; + final BaseAsset? currentAsset; + final int stackIndex; const AssetViewerState({ this.backgroundOpacity = 255, this.showingBottomSheet = false, this.showingControls = true, + this.currentAsset, + this.stackIndex = 0, }); AssetViewerState copyWith({ int? backgroundOpacity, bool? showingBottomSheet, bool? showingControls, + BaseAsset? currentAsset, + int? stackIndex, }) { return AssetViewerState( backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity, showingBottomSheet: showingBottomSheet ?? this.showingBottomSheet, showingControls: showingControls ?? this.showingControls, + currentAsset: currentAsset ?? this.currentAsset, + stackIndex: stackIndex ?? this.stackIndex, ); } @@ -36,14 +50,18 @@ class AssetViewerState { return other is AssetViewerState && other.backgroundOpacity == backgroundOpacity && other.showingBottomSheet == showingBottomSheet && - other.showingControls == showingControls; + other.showingControls == showingControls && + other.currentAsset == currentAsset && + other.stackIndex == stackIndex; } @override int get hashCode => backgroundOpacity.hashCode ^ showingBottomSheet.hashCode ^ - showingControls.hashCode; + showingControls.hashCode ^ + currentAsset.hashCode ^ + stackIndex.hashCode; } class AssetViewerStateNotifier extends AutoDisposeNotifier { @@ -52,6 +70,10 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier { return const AssetViewerState(); } + void setAsset(BaseAsset? asset) { + state = state.copyWith(currentAsset: asset, stackIndex: 0); + } + void setOpacity(int opacity) { state = state.copyWith( backgroundOpacity: opacity, @@ -76,6 +98,10 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier { void toggleControls() { state = state.copyWith(showingControls: !state.showingControls); } + + void setStackIndex(int index) { + state = state.copyWith(stackIndex: index); + } } final assetViewerProvider = diff --git a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart index 7b1175e8b..a3d24ec8e 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart @@ -49,7 +49,7 @@ class ArchiveBottomSheet extends ConsumerWidget { const MoveToLockFolderActionButton( source: ActionSource.timeline, ), - const StackActionButton(), + const StackActionButton(source: ActionSource.timeline), ], if (multiselect.hasLocal) ...[ const DeleteLocalActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart index 0615a857a..eefe19194 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart @@ -49,7 +49,7 @@ class FavoriteBottomSheet extends ConsumerWidget { const MoveToLockFolderActionButton( source: ActionSource.timeline, ), - const StackActionButton(), + const StackActionButton(source: ActionSource.timeline), ], if (multiselect.hasLocal) ...[ const DeleteLocalActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart index 61414252d..f9a9dd320 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart @@ -49,7 +49,7 @@ class GeneralBottomSheet extends ConsumerWidget { const MoveToLockFolderActionButton( source: ActionSource.timeline, ), - const StackActionButton(), + const StackActionButton(source: ActionSource.timeline), ], if (multiselect.hasLocal) ...[ const DeleteLocalActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart index c2d0d5c85..b5fecdf7a 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart @@ -52,7 +52,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget { const MoveToLockFolderActionButton( source: ActionSource.timeline, ), - const StackActionButton(), + const StackActionButton(source: ActionSource.timeline), ], if (multiselect.hasLocal) ...[ const DeleteLocalActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index 7e3776adb..ce3d39629 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -53,6 +53,9 @@ class ThumbnailTile extends ConsumerWidget { ) : const BoxDecoration(); + final hasStack = + asset is RemoteAsset && (asset as RemoteAsset).stackCount > 0; + return Stack( children: [ AnimatedContainer( @@ -75,6 +78,19 @@ class ThumbnailTile extends ConsumerWidget { ), ), ), + if (hasStack) + Align( + alignment: Alignment.topRight, + child: Padding( + padding: EdgeInsets.only( + right: 10.0, + top: asset.isVideo ? 24.0 : 6.0, + ), + child: _StackIndicator( + stackCount: (asset as RemoteAsset).stackCount, + ), + ), + ), if (asset.isVideo) Align( alignment: Alignment.topRight, @@ -182,6 +198,40 @@ class _SelectionIndicator extends StatelessWidget { } } +class _StackIndicator extends StatelessWidget { + final int stackCount; + + const _StackIndicator({required this.stackCount}); + + @override + Widget build(BuildContext context) { + return Row( + spacing: 3, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + // CrossAxisAlignment.start looks more centered vertically than CrossAxisAlignment.center + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + stackCount.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + blurRadius: 5.0, + color: Color.fromRGBO(0, 0, 0, 0.6), + ), + ], + ), + ), + const _TileOverlayIcon(Icons.burst_mode_rounded), + ], + ); + } +} + class _VideoIndicator extends StatelessWidget { final Duration duration; const _VideoIndicator(this.duration); @@ -192,8 +242,8 @@ class _VideoIndicator extends StatelessWidget { spacing: 3, mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, - // CrossAxisAlignment.end looks more centered vertically than CrossAxisAlignment.center - crossAxisAlignment: CrossAxisAlignment.end, + // CrossAxisAlignment.start looks more centered vertically than CrossAxisAlignment.center + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( duration.format(), diff --git a/mobile/lib/presentation/widgets/timeline/timeline.state.dart b/mobile/lib/presentation/widgets/timeline/timeline.state.dart index 91003fb1a..6faa4da9f 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.state.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.state.dart @@ -15,6 +15,7 @@ class TimelineArgs { final double spacing; final int columnCount; final bool showStorageIndicator; + final bool withStack; final GroupAssetsBy? groupBy; const TimelineArgs({ @@ -23,6 +24,7 @@ class TimelineArgs { this.spacing = kTimelineSpacing, this.columnCount = kTimelineColumnCount, this.showStorageIndicator = false, + this.withStack = false, this.groupBy, }); @@ -33,6 +35,7 @@ class TimelineArgs { maxHeight == other.maxHeight && columnCount == other.columnCount && showStorageIndicator == other.showStorageIndicator && + withStack == other.withStack && groupBy == other.groupBy; } @@ -43,6 +46,7 @@ class TimelineArgs { spacing.hashCode ^ columnCount.hashCode ^ showStorageIndicator.hashCode ^ + withStack.hashCode ^ groupBy.hashCode; } diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index d27993741..873908832 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -28,6 +28,7 @@ class Timeline extends StatelessWidget { this.topSliverWidget, this.topSliverWidgetHeight, this.showStorageIndicator = false, + this.withStack = false, this.appBar = const ImmichSliverAppBar( floating: true, pinned: false, @@ -42,6 +43,7 @@ class Timeline extends StatelessWidget { final bool showStorageIndicator; final Widget? appBar; final Widget? bottomSheet; + final bool withStack; final GroupAssetsBy? groupBy; @override @@ -58,6 +60,7 @@ class Timeline extends StatelessWidget { settingsProvider.select((s) => s.get(Setting.tilesPerRow)), ), showStorageIndicator: showStorageIndicator, + withStack: withStack, groupBy: groupBy, ), ), diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index b53417d02..456b072a3 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -50,7 +50,7 @@ class ActionNotifier extends Notifier { return _getIdsForSource(source).toIds().toList(growable: false); } - List _getOwnedRemoteForSource(ActionSource source) { + List _getOwnedRemoteIdsForSource(ActionSource source) { final ownerId = ref.read(currentUserProvider)?.id; return _getIdsForSource(source) .ownedAssets(ownerId) @@ -58,6 +58,20 @@ class ActionNotifier extends Notifier { .toList(growable: false); } + List _getOwnedRemoteAssetsForSource(ActionSource source) { + final ownerId = ref.read(currentUserProvider)?.id; + return _getIdsForSource(source).ownedAssets(ownerId).toList(); + } + + Iterable _getIdsForSource(ActionSource source) { + final Set assets = _getAssets(source); + return switch (T) { + const (RemoteAsset) => assets.whereType(), + const (LocalAsset) => assets.whereType(), + _ => const [], + } as Iterable; + } + Set _getAssets(ActionSource source) { return switch (source) { ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets, @@ -68,15 +82,6 @@ class ActionNotifier extends Notifier { }; } - Iterable _getIdsForSource(ActionSource source) { - final Set assets = _getAssets(source); - return switch (T) { - const (RemoteAsset) => assets.whereType(), - const (LocalAsset) => assets.whereType(), - _ => const [], - } as Iterable; - } - Future shareLink( ActionSource source, BuildContext context, @@ -96,7 +101,7 @@ class ActionNotifier extends Notifier { } Future favorite(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.favorite(ids); return ActionResult(count: ids.length, success: true); @@ -111,7 +116,7 @@ class ActionNotifier extends Notifier { } Future unFavorite(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.unFavorite(ids); return ActionResult(count: ids.length, success: true); @@ -126,7 +131,7 @@ class ActionNotifier extends Notifier { } Future archive(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.archive(ids); return ActionResult(count: ids.length, success: true); @@ -141,7 +146,7 @@ class ActionNotifier extends Notifier { } Future unArchive(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.unArchive(ids); return ActionResult(count: ids.length, success: true); @@ -156,7 +161,7 @@ class ActionNotifier extends Notifier { } Future moveToLockFolder(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.moveToLockFolder(ids); return ActionResult(count: ids.length, success: true); @@ -171,7 +176,7 @@ class ActionNotifier extends Notifier { } Future removeFromLockFolder(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.removeFromLockFolder(ids); return ActionResult(count: ids.length, success: true); @@ -186,7 +191,7 @@ class ActionNotifier extends Notifier { } Future trash(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.trash(ids); return ActionResult(count: ids.length, success: true); @@ -201,7 +206,7 @@ class ActionNotifier extends Notifier { } Future delete(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.delete(ids); return ActionResult(count: ids.length, success: true); @@ -234,7 +239,7 @@ class ActionNotifier extends Notifier { ActionSource source, BuildContext context, ) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { final isEdited = await _service.editLocation(ids, context); if (!isEdited) { @@ -270,6 +275,35 @@ class ActionNotifier extends Notifier { } } + Future stack(String userId, ActionSource source) async { + final ids = _getOwnedRemoteIdsForSource(source); + try { + await _service.stack(userId, ids); + return ActionResult(count: ids.length, success: true); + } catch (error, stack) { + _logger.severe('Failed to stack assets', error, stack); + return ActionResult( + count: ids.length, + success: false, + error: error.toString(), + ); + } + } + + Future unStack(ActionSource source) async { + final assets = _getOwnedRemoteAssetsForSource(source); + try { + await _service.unStack(assets.map((e) => e.stackId).nonNulls.toList()); + return ActionResult(count: assets.length, success: true); + } catch (error, stack) { + _logger.severe('Failed to unstack assets', error, stack); + return ActionResult( + count: assets.length, + success: false, + ); + } + } + Future shareAssets(ActionSource source) async { final ids = _getAssets(source).toList(growable: false); diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index cd49369a2..4c854973b 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/stack.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/api.repository.dart'; @@ -11,14 +12,16 @@ final assetApiRepositoryProvider = Provider( (ref) => AssetApiRepository( ref.watch(apiServiceProvider).assetsApi, ref.watch(apiServiceProvider).searchApi, + ref.watch(apiServiceProvider).stacksApi, ), ); class AssetApiRepository extends ApiRepository { final AssetsApi _api; final SearchApi _searchApi; + final StacksApi _stacksApi; - AssetApiRepository(this._api, this._searchApi); + AssetApiRepository(this._api, this._searchApi, this._stacksApi); Future update(String id, {String? description}) async { final response = await checkNull( @@ -84,6 +87,17 @@ class AssetApiRepository extends ApiRepository { ); } + Future stack(List ids) async { + final responseDto = + await checkNull(_stacksApi.createStack(StackCreateDto(assetIds: ids))); + + return responseDto.toStack(); + } + + Future unStack(List ids) async { + return _stacksApi.deleteStacks(BulkIdsDto(ids: ids)); + } + Future downloadAsset(String id) { return _api.downloadAssetWithHttpInfo(id); } @@ -102,3 +116,13 @@ class AssetApiRepository extends ApiRepository { return response.originalMimeType; } } + +extension on StackResponseDto { + StackResponse toStack() { + return StackResponse( + id: id, + primaryAssetId: primaryAssetId, + assetIds: assets.map((asset) => asset.id).toList(), + ); + } +} diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 9559c5d31..adefd5da1 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -166,6 +166,16 @@ class ActionService { return removedCount; } + Future stack(String userId, List remoteIds) async { + final stack = await _assetApiRepository.stack(remoteIds); + await _remoteAssetRepository.stack(userId, stack); + } + + Future unStack(List stackIds) async { + await _remoteAssetRepository.unStack(stackIds); + await _assetApiRepository.unStack(stackIds); + } + Future shareAssets(List assets) { return _assetMediaRepository.shareAssets(assets); } diff --git a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart index faaccfa51..eace57fe5 100644 --- a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; 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 2d35a6702..41eed09d8 100644 --- a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -6,6 +6,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; diff --git a/mobile/lib/widgets/map/map_thumbnail.dart b/mobile/lib/widgets/map/map_thumbnail.dart index 06935cd4b..1e4b061be 100644 --- a/mobile/lib/widgets/map/map_thumbnail.dart +++ b/mobile/lib/widgets/map/map_thumbnail.dart @@ -63,8 +63,14 @@ class MapThumbnail extends HookConsumerWidget { } Future onStyleLoaded() async { - if (showMarkerPin && controller.value != null) { - await controller.value?.addMarkerAtLatLng(centre); + try { + if (showMarkerPin && controller.value != null) { + await controller.value?.addMarkerAtLatLng(centre); + } + } finally { + // Calling methods on the controller after it is disposed will throw an error + // We do not have a way to check if the controller is disposed for now + // https://github.com/maplibre/flutter-maplibre-gl/issues/192 } styleLoaded.value = true; } diff --git a/mobile/test/drift/main/generated/schema_v1.dart b/mobile/test/drift/main/generated/schema_v1.dart index 017220192..38dbb9f82 100644 Binary files a/mobile/test/drift/main/generated/schema_v1.dart and b/mobile/test/drift/main/generated/schema_v1.dart differ diff --git a/mobile/test/drift/main/generated/schema_v2.dart b/mobile/test/drift/main/generated/schema_v2.dart index bdba8db55..8345cef90 100644 Binary files a/mobile/test/drift/main/generated/schema_v2.dart and b/mobile/test/drift/main/generated/schema_v2.dart differ