feat: show stacks in asset viewer (#19935)

* feat: show stacks in asset viewer

* fix: global key issue and flash on stack asset change

* feat(mobile): stack and unstack action (#19941)

* feat(mobile): stack and unstack action

* add custom model

* use stackId from ActionSource

* Update mobile/lib/providers/infrastructure/action.provider.dart

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>

* fix: lint

* fix: bad merge

* fix: test

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: Daimolean <92239625+wuzihao051119@users.noreply.github.com>
Co-authored-by: wuzihao051119 <wuzihao051119@outlook.com>
This commit is contained in:
shenlong 2025-07-18 10:01:04 +05:30 committed by GitHub
parent 546f841b2c
commit f32cd74232
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 659 additions and 58 deletions

View File

@ -1795,6 +1795,7 @@
"sort_title": "Title", "sort_title": "Title",
"source": "Source", "source": "Source",
"stack": "Stack", "stack": "Stack",
"stack_action_prompt": "{count} stacked",
"stack_duplicates": "Stack duplicates", "stack_duplicates": "Stack duplicates",
"stack_select_one_photo": "Select one main photo for the stack", "stack_select_one_photo": "Select one main photo for the stack",
"stack_selected_photos": "Stack selected photos", "stack_selected_photos": "Stack selected photos",
@ -1905,6 +1906,7 @@
"unselect_all_duplicates": "Unselect all duplicates", "unselect_all_duplicates": "Unselect all duplicates",
"unselect_all_in": "Unselect all in {group}", "unselect_all_in": "Unselect all in {group}",
"unstack": "Un-stack", "unstack": "Un-stack",
"unstack_action_prompt": "{count} unstacked",
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}", "unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
"untagged": "Untagged", "untagged": "Untagged",
"up_next": "Up next", "up_next": "Up next",

Binary file not shown.

Binary file not shown.

View File

@ -23,7 +23,7 @@ class ImmichLinter extends PluginBase {
return rules; return rules;
} }
static makeCode(String name, LintOptions options) => LintCode( static LintCode makeCode(String name, LintOptions options) => LintCode(
name: name, name: name,
problemMessage: options.json["message"] as String, problemMessage: options.json["message"] as String,
errorSeverity: ErrorSeverity.WARNING, errorSeverity: ErrorSeverity.WARNING,

View File

@ -45,6 +45,7 @@ class LocalAsset extends BaseAsset {
}'''; }''';
} }
// Not checking for remoteId here
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (other is! LocalAsset) return false; if (other is! LocalAsset) return false;

View File

@ -14,6 +14,8 @@ class RemoteAsset extends BaseAsset {
final String? thumbHash; final String? thumbHash;
final AssetVisibility visibility; final AssetVisibility visibility;
final String ownerId; final String ownerId;
final String? stackId;
final int stackCount;
const RemoteAsset({ const RemoteAsset({
required this.id, required this.id,
@ -31,6 +33,8 @@ class RemoteAsset extends BaseAsset {
this.thumbHash, this.thumbHash,
this.visibility = AssetVisibility.timeline, this.visibility = AssetVisibility.timeline,
super.livePhotoVideoId, super.livePhotoVideoId,
this.stackId,
this.stackCount = 0,
}); });
@override @override
@ -56,9 +60,14 @@ class RemoteAsset extends BaseAsset {
isFavorite: $isFavorite, isFavorite: $isFavorite,
thumbHash: ${thumbHash ?? "<NA>"}, thumbHash: ${thumbHash ?? "<NA>"},
visibility: $visibility, visibility: $visibility,
stackId: ${stackId ?? "<NA>"},
stackCount: $stackCount,
checksum: $checksum,
livePhotoVideoId: ${livePhotoVideoId ?? "<NA>"},
}'''; }''';
} }
// Not checking for localId here
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (other is! RemoteAsset) return false; if (other is! RemoteAsset) return false;
@ -67,7 +76,9 @@ class RemoteAsset extends BaseAsset {
id == other.id && id == other.id &&
ownerId == other.ownerId && ownerId == other.ownerId &&
thumbHash == other.thumbHash && thumbHash == other.thumbHash &&
visibility == other.visibility; visibility == other.visibility &&
stackId == other.stackId &&
stackCount == other.stackCount;
} }
@override @override
@ -77,7 +88,9 @@ class RemoteAsset extends BaseAsset {
ownerId.hashCode ^ ownerId.hashCode ^
localId.hashCode ^ localId.hashCode ^
thumbHash.hashCode ^ thumbHash.hashCode ^
visibility.hashCode; visibility.hashCode ^
stackId.hashCode ^
stackCount.hashCode;
RemoteAsset copyWith({ RemoteAsset copyWith({
String? id, String? id,
@ -95,6 +108,8 @@ class RemoteAsset extends BaseAsset {
String? thumbHash, String? thumbHash,
AssetVisibility? visibility, AssetVisibility? visibility,
String? livePhotoVideoId, String? livePhotoVideoId,
String? stackId,
int? stackCount,
}) { }) {
return RemoteAsset( return RemoteAsset(
id: id ?? this.id, id: id ?? this.id,
@ -112,6 +127,8 @@ class RemoteAsset extends BaseAsset {
thumbHash: thumbHash ?? this.thumbHash, thumbHash: thumbHash ?? this.thumbHash,
visibility: visibility ?? this.visibility, visibility: visibility ?? this.visibility,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId,
stackCount: stackCount ?? this.stackCount,
); );
} }
} }

View File

@ -82,3 +82,27 @@ class Stack {
primaryAssetId.hashCode; primaryAssetId.hashCode;
} }
} }
class StackResponse {
final String id;
final String primaryAssetId;
final List<String> 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;
}

View File

@ -1,3 +1,5 @@
import 'package:immich_mobile/domain/utils/event_stream.dart';
enum GroupAssetsBy { enum GroupAssetsBy {
day, day,
month, month,
@ -38,3 +40,7 @@ class TimeBucket extends Bucket {
@override @override
int get hashCode => super.hashCode ^ date.hashCode; int get hashCode => super.hashCode ^ date.hashCode;
} }
class TimelineReloadEvent extends Event {
const TimelineReloadEvent();
}

View File

@ -24,6 +24,17 @@ class AssetService {
: _remoteAssetRepository.watchAsset(id); : _remoteAssetRepository.watchAsset(id);
} }
Future<List<RemoteAsset>> 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<ExifInfo?> getExif(BaseAsset asset) async { Future<ExifInfo?> getExif(BaseAsset asset) async {
if (!asset.hasRemote) { if (!asset.hasRemote) {
return null; return null;

View File

@ -1,17 +1,9 @@
import 'dart:async'; import 'dart:async';
sealed class Event { class Event {
const Event(); const Event();
} }
class TimelineReloadEvent extends Event {
const TimelineReloadEvent();
}
class ViewerOpenBottomSheetEvent extends Event {
const ViewerOpenBottomSheetEvent();
}
class EventStream { class EventStream {
EventStream._(); EventStream._();

View File

@ -1,5 +1,6 @@
import 'remote_asset.entity.dart'; import 'remote_asset.entity.dart';
import 'local_asset.entity.dart'; import 'local_asset.entity.dart';
import 'stack.entity.dart';
mergedAsset: SELECT * FROM mergedAsset: SELECT * FROM
( (
@ -18,13 +19,33 @@ mergedAsset: SELECT * FROM
rae.checksum, rae.checksum,
rae.owner_id, rae.owner_id,
rae.live_photo_video_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 FROM
remote_asset_entity rae remote_asset_entity rae
LEFT JOIN LEFT JOIN
local_asset_entity lae ON rae.checksum = lae.checksum 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 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 UNION ALL
SELECT SELECT
NULL as remote_id, NULL as remote_id,
@ -41,7 +62,9 @@ mergedAsset: SELECT * FROM
lae.checksum, lae.checksum,
NULL as owner_id, NULL as owner_id,
NULL as live_photo_video_id, NULL as live_photo_video_id,
lae.orientation lae.orientation,
NULL as stack_id,
0 AS stack_count
FROM FROM
local_asset_entity lae local_asset_entity lae
LEFT JOIN LEFT JOIN
@ -68,8 +91,16 @@ FROM
remote_asset_entity rae remote_asset_entity rae
LEFT JOIN LEFT JOIN
local_asset_entity lae ON rae.checksum = lae.checksum local_asset_entity lae ON rae.checksum = lae.checksum
LEFT JOIN
stack_entity se ON rae.stack_id = se.id
WHERE 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 UNION ALL
SELECT SELECT
lae.name, lae.name,

View File

@ -34,6 +34,8 @@ class RemoteAssetEntity extends Table
IntColumn get visibility => intEnum<AssetVisibility>()(); IntColumn get visibility => intEnum<AssetVisibility>()();
TextColumn get stackId => text().nullable()();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
@ -55,5 +57,6 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
visibility: visibility, visibility: visibility,
livePhotoVideoId: livePhotoVideoId, livePhotoVideoId: livePhotoVideoId,
localId: null, localId: null,
stackId: stackId,
); );
} }

View File

@ -1,11 +1,13 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.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/exif.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' import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'
hide ExifInfo; hide ExifInfo;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; 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.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.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:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
@ -30,25 +32,66 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
} }
Stream<RemoteAsset?> watchAsset(String id) { Stream<RemoteAsset?> watchAsset(String id) {
final query = _db.remoteAssetEntity final stackCountRef = _db.stackEntity.id.count();
.select()
.addColumns([_db.localAssetEntity.id]).join([ final query = _db.remoteAssetEntity.select().addColumns([
_db.localAssetEntity.id,
_db.stackEntity.primaryAssetId,
stackCountRef,
]).join([
leftOuterJoin( leftOuterJoin(
_db.localAssetEntity, _db.localAssetEntity,
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum), _db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
useColumns: false, 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) { return query.map((row) {
final asset = row.readTable(_db.remoteAssetEntity).toDto(); 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( return asset.copyWith(
localId: row.read(_db.localAssetEntity.id), localId: row.read(_db.localAssetEntity.id),
stackCount: stackCount,
); );
}).watchSingleOrNull(); }).watchSingleOrNull();
} }
Future<List<RemoteAsset>> 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<ExifInfo?> getExif(String id) { Future<ExifInfo?> getExif(String id) {
return _db.managers.remoteExifEntity return _db.managers.remoteExifEntity
.filter((row) => row.assetId.id.equals(id)) .filter((row) => row.assetId.id.equals(id))
@ -146,4 +189,53 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
} }
}); });
} }
Future<void> 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<void> unStack(List<String> 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),
);
});
});
}
} }

View File

@ -137,6 +137,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
deletedAt: Value(asset.deletedAt), deletedAt: Value(asset.deletedAt),
visibility: Value(asset.visibility.toAssetVisibility()), visibility: Value(asset.visibility.toAssetVisibility()),
livePhotoVideoId: Value(asset.livePhotoVideoId), livePhotoVideoId: Value(asset.livePhotoVideoId),
stackId: Value(asset.stackId),
); );
batch.insert( batch.insert(

View File

@ -89,6 +89,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
isFavorite: row.isFavorite, isFavorite: row.isFavorite,
durationInSeconds: row.durationInSeconds, durationInSeconds: row.durationInSeconds,
livePhotoVideoId: row.livePhotoVideoId, livePhotoVideoId: row.livePhotoVideoId,
stackId: row.stackId,
stackCount: row.stackCount,
) )
: LocalAsset( : LocalAsset(
id: row.localId!, id: row.localId!,

View File

@ -13,7 +13,7 @@ class MainTimelinePage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final memoryLaneProvider = ref.watch(driftMemoryFutureProvider); final memoryLaneProvider = ref.watch(driftMemoryFutureProvider);
return memoryLaneProvider.when( return memoryLaneProvider.maybeWhen(
data: (memories) { data: (memories) {
return memories.isEmpty return memories.isEmpty
? const Timeline(showStorageIndicator: true) ? const Timeline(showStorageIndicator: true)
@ -26,8 +26,7 @@ class MainTimelinePage extends ConsumerWidget {
showStorageIndicator: true, showStorageIndicator: true,
); );
}, },
loading: () => const Timeline(showStorageIndicator: true), orElse: () => const Timeline(showStorageIndicator: true),
error: (error, stackTrace) => const Timeline(showStorageIndicator: true),
); );
} }
} }

View File

@ -1,16 +1,56 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.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 { 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton( return BaseActionButton(
iconData: Icons.filter_none_rounded, iconData: Icons.filter_none_rounded,
label: "stack".t(context: context), label: "stack".t(context: context),
onPressed: () => _onTap(context, ref),
); );
} }
} }

View File

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

View File

@ -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<List<RemoteAsset>, BaseAsset?> {
@override
Future<List<RemoteAsset>> 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<StackChildrenNotifier, List<RemoteAsset>, BaseAsset?>(
StackChildrenNotifier.new,
);

View File

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

View File

@ -5,10 +5,13 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_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/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_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart';
@ -85,6 +88,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
double previousExtent = _kBottomSheetMinimumExtent; double previousExtent = _kBottomSheetMinimumExtent;
Offset dragDownPosition = Offset.zero; Offset dragDownPosition = Offset.zero;
int totalAssets = 0; int totalAssets = 0;
int stackIndex = 0;
BuildContext? scaffoldContext; BuildContext? scaffoldContext;
Map<String, GlobalKey> videoPlayerKeys = {}; Map<String, GlobalKey> videoPlayerKeys = {};
@ -167,6 +171,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _onAssetChanged(int index) { void _onAssetChanged(int index) {
final asset = ref.read(timelineServiceProvider).getAsset(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); ref.read(currentAssetNotifier.notifier).setAsset(asset);
if (asset.isVideo || asset.isMotionPhoto) { if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset(); ref.read(videoPlaybackValueProvider.notifier).reset();
@ -488,7 +496,12 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
ImageChunkEvent? progress, ImageChunkEvent? progress,
int index, 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( return Container(
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
@ -516,9 +529,14 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) { PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
scaffoldContext ??= ctx; scaffoldContext ??= ctx;
final asset = ref.read(timelineServiceProvider).getAsset(index); BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index);
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider); 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) { if (asset.isImage && !isPlayingMotionVideo) {
return _imageBuilder(ctx, asset); return _imageBuilder(ctx, asset);
} }
@ -604,6 +622,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
// Using multiple selectors to avoid unnecessary rebuilds for other state changes // Using multiple selectors to avoid unnecessary rebuilds for other state changes
ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)); ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity));
ref.watch(assetViewerProvider.select((s) => s.stackIndex));
ref.watch(isPlayingMotionVideoProvider); ref.watch(isPlayingMotionVideoProvider);
// Listen for casting changes and send initial asset to the cast provider // Listen for casting changes and send initial asset to the cast provider
@ -645,7 +664,17 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
backgroundDecoration: BoxDecoration(color: backgroundColor), backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true, enablePanAlways: true,
), ),
bottomNavigationBar: const ViewerBottomBar(), bottomNavigationBar: showingBottomSheet
? const SizedBox.shrink()
: const Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AssetStackRow(),
ViewerBottomBar(),
],
),
), ),
); );
} }

View File

@ -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:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
class ViewerOpenBottomSheetEvent extends Event {
const ViewerOpenBottomSheetEvent();
}
class AssetViewerState { class AssetViewerState {
final int backgroundOpacity; final int backgroundOpacity;
final bool showingBottomSheet; final bool showingBottomSheet;
final bool showingControls; final bool showingControls;
final BaseAsset? currentAsset;
final int stackIndex;
const AssetViewerState({ const AssetViewerState({
this.backgroundOpacity = 255, this.backgroundOpacity = 255,
this.showingBottomSheet = false, this.showingBottomSheet = false,
this.showingControls = true, this.showingControls = true,
this.currentAsset,
this.stackIndex = 0,
}); });
AssetViewerState copyWith({ AssetViewerState copyWith({
int? backgroundOpacity, int? backgroundOpacity,
bool? showingBottomSheet, bool? showingBottomSheet,
bool? showingControls, bool? showingControls,
BaseAsset? currentAsset,
int? stackIndex,
}) { }) {
return AssetViewerState( return AssetViewerState(
backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity, backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity,
showingBottomSheet: showingBottomSheet ?? this.showingBottomSheet, showingBottomSheet: showingBottomSheet ?? this.showingBottomSheet,
showingControls: showingControls ?? this.showingControls, showingControls: showingControls ?? this.showingControls,
currentAsset: currentAsset ?? this.currentAsset,
stackIndex: stackIndex ?? this.stackIndex,
); );
} }
@ -36,14 +50,18 @@ class AssetViewerState {
return other is AssetViewerState && return other is AssetViewerState &&
other.backgroundOpacity == backgroundOpacity && other.backgroundOpacity == backgroundOpacity &&
other.showingBottomSheet == showingBottomSheet && other.showingBottomSheet == showingBottomSheet &&
other.showingControls == showingControls; other.showingControls == showingControls &&
other.currentAsset == currentAsset &&
other.stackIndex == stackIndex;
} }
@override @override
int get hashCode => int get hashCode =>
backgroundOpacity.hashCode ^ backgroundOpacity.hashCode ^
showingBottomSheet.hashCode ^ showingBottomSheet.hashCode ^
showingControls.hashCode; showingControls.hashCode ^
currentAsset.hashCode ^
stackIndex.hashCode;
} }
class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> { class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
@ -52,6 +70,10 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
return const AssetViewerState(); return const AssetViewerState();
} }
void setAsset(BaseAsset? asset) {
state = state.copyWith(currentAsset: asset, stackIndex: 0);
}
void setOpacity(int opacity) { void setOpacity(int opacity) {
state = state.copyWith( state = state.copyWith(
backgroundOpacity: opacity, backgroundOpacity: opacity,
@ -76,6 +98,10 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
void toggleControls() { void toggleControls() {
state = state.copyWith(showingControls: !state.showingControls); state = state.copyWith(showingControls: !state.showingControls);
} }
void setStackIndex(int index) {
state = state.copyWith(stackIndex: index);
}
} }
final assetViewerProvider = final assetViewerProvider =

View File

@ -49,7 +49,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
const MoveToLockFolderActionButton( const MoveToLockFolderActionButton(
source: ActionSource.timeline, source: ActionSource.timeline,
), ),
const StackActionButton(), const StackActionButton(source: ActionSource.timeline),
], ],
if (multiselect.hasLocal) ...[ if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline), const DeleteLocalActionButton(source: ActionSource.timeline),

View File

@ -49,7 +49,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
const MoveToLockFolderActionButton( const MoveToLockFolderActionButton(
source: ActionSource.timeline, source: ActionSource.timeline,
), ),
const StackActionButton(), const StackActionButton(source: ActionSource.timeline),
], ],
if (multiselect.hasLocal) ...[ if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline), const DeleteLocalActionButton(source: ActionSource.timeline),

View File

@ -49,7 +49,7 @@ class GeneralBottomSheet extends ConsumerWidget {
const MoveToLockFolderActionButton( const MoveToLockFolderActionButton(
source: ActionSource.timeline, source: ActionSource.timeline,
), ),
const StackActionButton(), const StackActionButton(source: ActionSource.timeline),
], ],
if (multiselect.hasLocal) ...[ if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline), const DeleteLocalActionButton(source: ActionSource.timeline),

View File

@ -52,7 +52,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget {
const MoveToLockFolderActionButton( const MoveToLockFolderActionButton(
source: ActionSource.timeline, source: ActionSource.timeline,
), ),
const StackActionButton(), const StackActionButton(source: ActionSource.timeline),
], ],
if (multiselect.hasLocal) ...[ if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline), const DeleteLocalActionButton(source: ActionSource.timeline),

View File

@ -53,6 +53,9 @@ class ThumbnailTile extends ConsumerWidget {
) )
: const BoxDecoration(); : const BoxDecoration();
final hasStack =
asset is RemoteAsset && (asset as RemoteAsset).stackCount > 0;
return Stack( return Stack(
children: [ children: [
AnimatedContainer( 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) if (asset.isVideo)
Align( Align(
alignment: Alignment.topRight, 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 { class _VideoIndicator extends StatelessWidget {
final Duration duration; final Duration duration;
const _VideoIndicator(this.duration); const _VideoIndicator(this.duration);
@ -192,8 +242,8 @@ class _VideoIndicator extends StatelessWidget {
spacing: 3, spacing: 3,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
// CrossAxisAlignment.end looks more centered vertically than CrossAxisAlignment.center // CrossAxisAlignment.start looks more centered vertically than CrossAxisAlignment.center
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
duration.format(), duration.format(),

View File

@ -15,6 +15,7 @@ class TimelineArgs {
final double spacing; final double spacing;
final int columnCount; final int columnCount;
final bool showStorageIndicator; final bool showStorageIndicator;
final bool withStack;
final GroupAssetsBy? groupBy; final GroupAssetsBy? groupBy;
const TimelineArgs({ const TimelineArgs({
@ -23,6 +24,7 @@ class TimelineArgs {
this.spacing = kTimelineSpacing, this.spacing = kTimelineSpacing,
this.columnCount = kTimelineColumnCount, this.columnCount = kTimelineColumnCount,
this.showStorageIndicator = false, this.showStorageIndicator = false,
this.withStack = false,
this.groupBy, this.groupBy,
}); });
@ -33,6 +35,7 @@ class TimelineArgs {
maxHeight == other.maxHeight && maxHeight == other.maxHeight &&
columnCount == other.columnCount && columnCount == other.columnCount &&
showStorageIndicator == other.showStorageIndicator && showStorageIndicator == other.showStorageIndicator &&
withStack == other.withStack &&
groupBy == other.groupBy; groupBy == other.groupBy;
} }
@ -43,6 +46,7 @@ class TimelineArgs {
spacing.hashCode ^ spacing.hashCode ^
columnCount.hashCode ^ columnCount.hashCode ^
showStorageIndicator.hashCode ^ showStorageIndicator.hashCode ^
withStack.hashCode ^
groupBy.hashCode; groupBy.hashCode;
} }

View File

@ -28,6 +28,7 @@ class Timeline extends StatelessWidget {
this.topSliverWidget, this.topSliverWidget,
this.topSliverWidgetHeight, this.topSliverWidgetHeight,
this.showStorageIndicator = false, this.showStorageIndicator = false,
this.withStack = false,
this.appBar = const ImmichSliverAppBar( this.appBar = const ImmichSliverAppBar(
floating: true, floating: true,
pinned: false, pinned: false,
@ -42,6 +43,7 @@ class Timeline extends StatelessWidget {
final bool showStorageIndicator; final bool showStorageIndicator;
final Widget? appBar; final Widget? appBar;
final Widget? bottomSheet; final Widget? bottomSheet;
final bool withStack;
final GroupAssetsBy? groupBy; final GroupAssetsBy? groupBy;
@override @override
@ -58,6 +60,7 @@ class Timeline extends StatelessWidget {
settingsProvider.select((s) => s.get(Setting.tilesPerRow)), settingsProvider.select((s) => s.get(Setting.tilesPerRow)),
), ),
showStorageIndicator: showStorageIndicator, showStorageIndicator: showStorageIndicator,
withStack: withStack,
groupBy: groupBy, groupBy: groupBy,
), ),
), ),

View File

@ -50,7 +50,7 @@ class ActionNotifier extends Notifier<void> {
return _getIdsForSource<LocalAsset>(source).toIds().toList(growable: false); return _getIdsForSource<LocalAsset>(source).toIds().toList(growable: false);
} }
List<String> _getOwnedRemoteForSource(ActionSource source) { List<String> _getOwnedRemoteIdsForSource(ActionSource source) {
final ownerId = ref.read(currentUserProvider)?.id; final ownerId = ref.read(currentUserProvider)?.id;
return _getIdsForSource<RemoteAsset>(source) return _getIdsForSource<RemoteAsset>(source)
.ownedAssets(ownerId) .ownedAssets(ownerId)
@ -58,6 +58,20 @@ class ActionNotifier extends Notifier<void> {
.toList(growable: false); .toList(growable: false);
} }
List<RemoteAsset> _getOwnedRemoteAssetsForSource(ActionSource source) {
final ownerId = ref.read(currentUserProvider)?.id;
return _getIdsForSource<RemoteAsset>(source).ownedAssets(ownerId).toList();
}
Iterable<T> _getIdsForSource<T extends BaseAsset>(ActionSource source) {
final Set<BaseAsset> assets = _getAssets(source);
return switch (T) {
const (RemoteAsset) => assets.whereType<RemoteAsset>(),
const (LocalAsset) => assets.whereType<LocalAsset>(),
_ => const [],
} as Iterable<T>;
}
Set<BaseAsset> _getAssets(ActionSource source) { Set<BaseAsset> _getAssets(ActionSource source) {
return switch (source) { return switch (source) {
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets, ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
@ -68,15 +82,6 @@ class ActionNotifier extends Notifier<void> {
}; };
} }
Iterable<T> _getIdsForSource<T extends BaseAsset>(ActionSource source) {
final Set<BaseAsset> assets = _getAssets(source);
return switch (T) {
const (RemoteAsset) => assets.whereType<RemoteAsset>(),
const (LocalAsset) => assets.whereType<LocalAsset>(),
_ => const [],
} as Iterable<T>;
}
Future<ActionResult> shareLink( Future<ActionResult> shareLink(
ActionSource source, ActionSource source,
BuildContext context, BuildContext context,
@ -96,7 +101,7 @@ class ActionNotifier extends Notifier<void> {
} }
Future<ActionResult> favorite(ActionSource source) async { Future<ActionResult> favorite(ActionSource source) async {
final ids = _getOwnedRemoteForSource(source); final ids = _getOwnedRemoteIdsForSource(source);
try { try {
await _service.favorite(ids); await _service.favorite(ids);
return ActionResult(count: ids.length, success: true); return ActionResult(count: ids.length, success: true);
@ -111,7 +116,7 @@ class ActionNotifier extends Notifier<void> {
} }
Future<ActionResult> unFavorite(ActionSource source) async { Future<ActionResult> unFavorite(ActionSource source) async {
final ids = _getOwnedRemoteForSource(source); final ids = _getOwnedRemoteIdsForSource(source);
try { try {
await _service.unFavorite(ids); await _service.unFavorite(ids);
return ActionResult(count: ids.length, success: true); return ActionResult(count: ids.length, success: true);
@ -126,7 +131,7 @@ class ActionNotifier extends Notifier<void> {
} }
Future<ActionResult> archive(ActionSource source) async { Future<ActionResult> archive(ActionSource source) async {
final ids = _getOwnedRemoteForSource(source); final ids = _getOwnedRemoteIdsForSource(source);
try { try {
await _service.archive(ids); await _service.archive(ids);
return ActionResult(count: ids.length, success: true); return ActionResult(count: ids.length, success: true);
@ -141,7 +146,7 @@ class ActionNotifier extends Notifier<void> {
} }
Future<ActionResult> unArchive(ActionSource source) async { Future<ActionResult> unArchive(ActionSource source) async {
final ids = _getOwnedRemoteForSource(source); final ids = _getOwnedRemoteIdsForSource(source);
try { try {
await _service.unArchive(ids); await _service.unArchive(ids);
return ActionResult(count: ids.length, success: true); return ActionResult(count: ids.length, success: true);
@ -156,7 +161,7 @@ class ActionNotifier extends Notifier<void> {
} }
Future<ActionResult> moveToLockFolder(ActionSource source) async { Future<ActionResult> moveToLockFolder(ActionSource source) async {
final ids = _getOwnedRemoteForSource(source); final ids = _getOwnedRemoteIdsForSource(source);
try { try {
await _service.moveToLockFolder(ids); await _service.moveToLockFolder(ids);
return ActionResult(count: ids.length, success: true); return ActionResult(count: ids.length, success: true);
@ -171,7 +176,7 @@ class ActionNotifier extends Notifier<void> {
} }
Future<ActionResult> removeFromLockFolder(ActionSource source) async { Future<ActionResult> removeFromLockFolder(ActionSource source) async {
final ids = _getOwnedRemoteForSource(source); final ids = _getOwnedRemoteIdsForSource(source);
try { try {
await _service.removeFromLockFolder(ids); await _service.removeFromLockFolder(ids);
return ActionResult(count: ids.length, success: true); return ActionResult(count: ids.length, success: true);
@ -186,7 +191,7 @@ class ActionNotifier extends Notifier<void> {
} }
Future<ActionResult> trash(ActionSource source) async { Future<ActionResult> trash(ActionSource source) async {
final ids = _getOwnedRemoteForSource(source); final ids = _getOwnedRemoteIdsForSource(source);
try { try {
await _service.trash(ids); await _service.trash(ids);
return ActionResult(count: ids.length, success: true); return ActionResult(count: ids.length, success: true);
@ -201,7 +206,7 @@ class ActionNotifier extends Notifier<void> {
} }
Future<ActionResult> delete(ActionSource source) async { Future<ActionResult> delete(ActionSource source) async {
final ids = _getOwnedRemoteForSource(source); final ids = _getOwnedRemoteIdsForSource(source);
try { try {
await _service.delete(ids); await _service.delete(ids);
return ActionResult(count: ids.length, success: true); return ActionResult(count: ids.length, success: true);
@ -234,7 +239,7 @@ class ActionNotifier extends Notifier<void> {
ActionSource source, ActionSource source,
BuildContext context, BuildContext context,
) async { ) async {
final ids = _getOwnedRemoteForSource(source); final ids = _getOwnedRemoteIdsForSource(source);
try { try {
final isEdited = await _service.editLocation(ids, context); final isEdited = await _service.editLocation(ids, context);
if (!isEdited) { if (!isEdited) {
@ -270,6 +275,35 @@ class ActionNotifier extends Notifier<void> {
} }
} }
Future<ActionResult> 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<ActionResult> 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<ActionResult> shareAssets(ActionSource source) async { Future<ActionResult> shareAssets(ActionSource source) async {
final ids = _getAssets(source).toList(growable: false); final ids = _getAssets(source).toList(growable: false);

View File

@ -1,6 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:immich_mobile/constants/enums.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/entities/asset.entity.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:immich_mobile/repositories/api.repository.dart';
@ -11,14 +12,16 @@ final assetApiRepositoryProvider = Provider(
(ref) => AssetApiRepository( (ref) => AssetApiRepository(
ref.watch(apiServiceProvider).assetsApi, ref.watch(apiServiceProvider).assetsApi,
ref.watch(apiServiceProvider).searchApi, ref.watch(apiServiceProvider).searchApi,
ref.watch(apiServiceProvider).stacksApi,
), ),
); );
class AssetApiRepository extends ApiRepository { class AssetApiRepository extends ApiRepository {
final AssetsApi _api; final AssetsApi _api;
final SearchApi _searchApi; final SearchApi _searchApi;
final StacksApi _stacksApi;
AssetApiRepository(this._api, this._searchApi); AssetApiRepository(this._api, this._searchApi, this._stacksApi);
Future<Asset> update(String id, {String? description}) async { Future<Asset> update(String id, {String? description}) async {
final response = await checkNull( final response = await checkNull(
@ -84,6 +87,17 @@ class AssetApiRepository extends ApiRepository {
); );
} }
Future<StackResponse> stack(List<String> ids) async {
final responseDto =
await checkNull(_stacksApi.createStack(StackCreateDto(assetIds: ids)));
return responseDto.toStack();
}
Future<void> unStack(List<String> ids) async {
return _stacksApi.deleteStacks(BulkIdsDto(ids: ids));
}
Future<Response> downloadAsset(String id) { Future<Response> downloadAsset(String id) {
return _api.downloadAssetWithHttpInfo(id); return _api.downloadAssetWithHttpInfo(id);
} }
@ -102,3 +116,13 @@ class AssetApiRepository extends ApiRepository {
return response.originalMimeType; return response.originalMimeType;
} }
} }
extension on StackResponseDto {
StackResponse toStack() {
return StackResponse(
id: id,
primaryAssetId: primaryAssetId,
assetIds: assets.map((asset) => asset.id).toList(),
);
}
}

View File

@ -166,6 +166,16 @@ class ActionService {
return removedCount; return removedCount;
} }
Future<void> stack(String userId, List<String> remoteIds) async {
final stack = await _assetApiRepository.stack(remoteIds);
await _remoteAssetRepository.stack(userId, stack);
}
Future<void> unStack(List<String> stackIds) async {
await _remoteAssetRepository.unStack(stackIds);
await _assetApiRepository.unStack(stackIds);
}
Future<int> shareAssets(List<BaseAsset> assets) { Future<int> shareAssets(List<BaseAsset> assets) {
return _assetMediaRepository.shareAssets(assets); return _assetMediaRepository.shareAssets(assets);
} }

View File

@ -4,6 +4,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';

View File

@ -6,6 +6,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';

View File

@ -63,8 +63,14 @@ class MapThumbnail extends HookConsumerWidget {
} }
Future<void> onStyleLoaded() async { Future<void> onStyleLoaded() async {
if (showMarkerPin && controller.value != null) { try {
await controller.value?.addMarkerAtLatLng(centre); 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; styleLoaded.value = true;
} }