mirror of
https://github.com/zebrajr/immich.git
synced 2025-12-06 12:20:54 +01:00
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:
parent
546f841b2c
commit
f32cd74232
|
|
@ -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",
|
||||
|
|
|
|||
BIN
mobile/drift_schemas/main/drift_schema_v1.json
generated
BIN
mobile/drift_schemas/main/drift_schema_v1.json
generated
Binary file not shown.
BIN
mobile/drift_schemas/main/drift_schema_v2.json
generated
BIN
mobile/drift_schemas/main/drift_schema_v2.json
generated
Binary file not shown.
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ class LocalAsset extends BaseAsset {
|
|||
}''';
|
||||
}
|
||||
|
||||
// Not checking for remoteId here
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is! LocalAsset) return false;
|
||||
|
|
|
|||
|
|
@ -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 ?? "<NA>"},
|
||||
visibility: $visibility,
|
||||
stackId: ${stackId ?? "<NA>"},
|
||||
stackCount: $stackCount,
|
||||
checksum: $checksum,
|
||||
livePhotoVideoId: ${livePhotoVideoId ?? "<NA>"},
|
||||
}''';
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,3 +82,27 @@ class Stack {
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,17 @@ class AssetService {
|
|||
: _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 {
|
||||
if (!asset.hasRemote) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -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._();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -34,6 +34,8 @@ class RemoteAssetEntity extends Table
|
|||
|
||||
IntColumn get visibility => intEnum<AssetVisibility>()();
|
||||
|
||||
TextColumn get stackId => text().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
|
@ -55,5 +57,6 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
|
|||
visibility: visibility,
|
||||
livePhotoVideoId: livePhotoVideoId,
|
||||
localId: null,
|
||||
stackId: stackId,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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<RemoteAsset?> 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<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) {
|
||||
return _db.managers.remoteExifEntity
|
||||
.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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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!,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AssetViewer> {
|
|||
double previousExtent = _kBottomSheetMinimumExtent;
|
||||
Offset dragDownPosition = Offset.zero;
|
||||
int totalAssets = 0;
|
||||
int stackIndex = 0;
|
||||
BuildContext? scaffoldContext;
|
||||
Map<String, GlobalKey> videoPlayerKeys = {};
|
||||
|
||||
|
|
@ -167,6 +171,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||
|
||||
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<AssetViewer> {
|
|||
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<AssetViewer> {
|
|||
|
||||
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<AssetViewer> {
|
|||
// 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<AssetViewer> {
|
|||
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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AssetViewerState> {
|
||||
|
|
@ -52,6 +70,10 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
|
|||
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<AssetViewerState> {
|
|||
void toggleControls() {
|
||||
state = state.copyWith(showingControls: !state.showingControls);
|
||||
}
|
||||
|
||||
void setStackIndex(int index) {
|
||||
state = state.copyWith(stackIndex: index);
|
||||
}
|
||||
}
|
||||
|
||||
final assetViewerProvider =
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class ActionNotifier extends Notifier<void> {
|
|||
return _getIdsForSource<LocalAsset>(source).toIds().toList(growable: false);
|
||||
}
|
||||
|
||||
List<String> _getOwnedRemoteForSource(ActionSource source) {
|
||||
List<String> _getOwnedRemoteIdsForSource(ActionSource source) {
|
||||
final ownerId = ref.read(currentUserProvider)?.id;
|
||||
return _getIdsForSource<RemoteAsset>(source)
|
||||
.ownedAssets(ownerId)
|
||||
|
|
@ -58,6 +58,20 @@ class ActionNotifier extends Notifier<void> {
|
|||
.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) {
|
||||
return switch (source) {
|
||||
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(
|
||||
ActionSource source,
|
||||
BuildContext context,
|
||||
|
|
@ -96,7 +101,7 @@ class ActionNotifier extends Notifier<void> {
|
|||
}
|
||||
|
||||
Future<ActionResult> 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<void> {
|
|||
}
|
||||
|
||||
Future<ActionResult> 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<void> {
|
|||
}
|
||||
|
||||
Future<ActionResult> 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<void> {
|
|||
}
|
||||
|
||||
Future<ActionResult> 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<void> {
|
|||
}
|
||||
|
||||
Future<ActionResult> 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<void> {
|
|||
}
|
||||
|
||||
Future<ActionResult> 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<void> {
|
|||
}
|
||||
|
||||
Future<ActionResult> 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<void> {
|
|||
}
|
||||
|
||||
Future<ActionResult> 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<void> {
|
|||
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<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 {
|
||||
final ids = _getAssets(source).toList(growable: false);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Asset> update(String id, {String? description}) async {
|
||||
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) {
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,6 +166,16 @@ class ActionService {
|
|||
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) {
|
||||
return _assetMediaRepository.shareAssets(assets);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -63,8 +63,14 @@ class MapThumbnail extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
Future<void> 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;
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue
Block a user