mirror of
https://github.com/immich-app/immich.git
synced 2026-07-09 21:52:34 -07:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3dd742a1f | |||
| 41e93a27fd | |||
| 15c716945a | |||
| 4b8f1f3194 | |||
| ed0a6c54b9 | |||
| 0393523e61 | |||
| be0e6d17fa | |||
| 5e48cfcaa7 | |||
| 1cf19f19df | |||
| cc5ac5cca5 | |||
| b033a18ce0 |
@@ -78,6 +78,7 @@ sealed class BaseAsset {
|
|||||||
bool get hasLocal => storage == AssetState.local || storage == AssetState.merged;
|
bool get hasLocal => storage == AssetState.local || storage == AssetState.merged;
|
||||||
bool get isLocalOnly => storage == AssetState.local;
|
bool get isLocalOnly => storage == AssetState.local;
|
||||||
bool get isRemoteOnly => storage == AssetState.remote;
|
bool get isRemoteOnly => storage == AssetState.remote;
|
||||||
|
bool get isMerged => storage == AssetState.merged;
|
||||||
|
|
||||||
bool get isEditable => false;
|
bool get isEditable => false;
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ class RemoteAsset extends BaseAsset {
|
|||||||
|
|
||||||
bool get isArchived => visibility == .archive;
|
bool get isArchived => visibility == .archive;
|
||||||
|
|
||||||
|
bool get isLocked => visibility == .locked;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return '''Asset {
|
return '''Asset {
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||||
import 'package:immich_mobile/utils/option.dart';
|
import 'package:immich_mobile/utils/option.dart';
|
||||||
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
|
|
||||||
class AssetService {
|
class AssetService {
|
||||||
final RemoteAssetRepository _remoteRepository;
|
final RemoteAssetRepository _remoteRepository;
|
||||||
|
final RemoteExifRepository _exifRepository;
|
||||||
final DriftLocalAssetRepository _localRepository;
|
final DriftLocalAssetRepository _localRepository;
|
||||||
final AssetApiRepository _apiRepository;
|
final AssetApiRepository _apiRepository;
|
||||||
|
|
||||||
const AssetService({required this._remoteRepository, required this._localRepository, required this._apiRepository});
|
const AssetService({
|
||||||
|
required this._remoteRepository,
|
||||||
|
required this._exifRepository,
|
||||||
|
required this._localRepository,
|
||||||
|
required this._apiRepository,
|
||||||
|
});
|
||||||
|
|
||||||
Future<BaseAsset?> getAsset(BaseAsset asset) {
|
Future<BaseAsset?> getAsset(BaseAsset asset) {
|
||||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
||||||
@@ -101,12 +110,60 @@ class AssetService {
|
|||||||
List<String> remoteIds, {
|
List<String> remoteIds, {
|
||||||
Option<bool> isFavorite = const .none(),
|
Option<bool> isFavorite = const .none(),
|
||||||
Option<AssetVisibility> visibility = const .none(),
|
Option<AssetVisibility> visibility = const .none(),
|
||||||
|
Option<LatLng> location = const .none(),
|
||||||
|
Option<String> dateTime = const .none(),
|
||||||
}) async {
|
}) async {
|
||||||
if (remoteIds.isEmpty) {
|
if (remoteIds.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _apiRepository.update(remoteIds, isFavorite: isFavorite, visibility: visibility);
|
final parsedDateTime = dateTime.map((dt) => DateTime.parse(dt));
|
||||||
await _remoteRepository.update(remoteIds, isFavorite: isFavorite, visibility: visibility);
|
final offset = RegExp(r'[+-]\d{2}:\d{2}$').firstMatch(dateTime.unwrapOrNull ?? '')?.group(0);
|
||||||
|
|
||||||
|
await _apiRepository.update(
|
||||||
|
remoteIds,
|
||||||
|
isFavorite: isFavorite,
|
||||||
|
visibility: visibility,
|
||||||
|
location: location,
|
||||||
|
dateTimeOriginal: dateTime,
|
||||||
|
);
|
||||||
|
await _remoteRepository.update(
|
||||||
|
remoteIds,
|
||||||
|
isFavorite: isFavorite,
|
||||||
|
visibility: visibility,
|
||||||
|
createdAt: parsedDateTime,
|
||||||
|
);
|
||||||
|
await _exifRepository.update(
|
||||||
|
remoteIds,
|
||||||
|
location: location,
|
||||||
|
dateTimeOriginal: parsedDateTime,
|
||||||
|
timeZone: .fromNullable(offset).map((o) => 'UTC$o'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> trash(List<String> remoteIds) async {
|
||||||
|
if (remoteIds.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _apiRepository.delete(remoteIds, false);
|
||||||
|
await _remoteRepository.trash(remoteIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(List<String> remoteIds) async {
|
||||||
|
if (remoteIds.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _apiRepository.delete(remoteIds, true);
|
||||||
|
await _remoteRepository.delete(remoteIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> applyEdits(String remoteId, List<AssetEdit> edits) async {
|
||||||
|
if (edits.isEmpty) {
|
||||||
|
await _apiRepository.removeEdits(remoteId);
|
||||||
|
} else {
|
||||||
|
await _apiRepository.editAsset(remoteId, edits);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,6 +183,12 @@ class RemoteAlbumService {
|
|||||||
return album.added.length;
|
return album.added.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<int> removeAssets({required String albumId, required List<String> assetIds}) async {
|
||||||
|
final result = await _albumApiRepository.removeAssets(albumId, assetIds);
|
||||||
|
await _repository.removeAssets(albumId, result.removed);
|
||||||
|
return result.removed.length;
|
||||||
|
}
|
||||||
|
|
||||||
/// !TODO The name here is not clear as we have addAssets method above,
|
/// !TODO The name here is not clear as we have addAssets method above,
|
||||||
/// which is only add remote assets to album, for the next PR, we will allow
|
/// which is only add remote assets to album, for the next PR, we will allow
|
||||||
/// adding local assets from album from the timeline as well with this flow.
|
/// adding local assets from album from the timeline as well with this flow.
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.
|
|||||||
import 'package:immich_mobile/infrastructure/entities/stack.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:immich_mobile/utils/option.dart';
|
import 'package:immich_mobile/utils/option.dart';
|
||||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
|
||||||
|
|
||||||
class RemoteAssetRepository extends DriftDatabaseRepository {
|
class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||||
final Drift _db;
|
final Drift _db;
|
||||||
@@ -183,38 +182,6 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateLocation(List<String> ids, LatLng location) {
|
|
||||||
return _db.batch((batch) async {
|
|
||||||
for (final id in ids) {
|
|
||||||
batch.update(
|
|
||||||
_db.remoteExifEntity,
|
|
||||||
RemoteExifEntityCompanion(latitude: Value(location.latitude), longitude: Value(location.longitude)),
|
|
||||||
where: (e) => e.assetId.equals(id),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> updateDateTime(List<String> ids, DateTime dateTime, {String? timeZone}) {
|
|
||||||
return _db.batch((batch) async {
|
|
||||||
for (final id in ids) {
|
|
||||||
batch.update(
|
|
||||||
_db.remoteExifEntity,
|
|
||||||
RemoteExifEntityCompanion(
|
|
||||||
dateTimeOriginal: Value(dateTime),
|
|
||||||
timeZone: timeZone == null ? const Value.absent() : Value(timeZone),
|
|
||||||
),
|
|
||||||
where: (e) => e.assetId.equals(id),
|
|
||||||
);
|
|
||||||
batch.update(
|
|
||||||
_db.remoteAssetEntity,
|
|
||||||
RemoteAssetEntityCompanion(createdAt: Value(dateTime)),
|
|
||||||
where: (e) => e.id.equals(id),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> stack(String userId, StackResponse stack) {
|
Future<void> stack(String userId, StackResponse stack) {
|
||||||
return _db.transaction(() async {
|
return _db.transaction(() async {
|
||||||
final stackIds = await _db.managers.stackEntity
|
final stackIds = await _db.managers.stackEntity
|
||||||
@@ -292,10 +259,16 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
|||||||
List<String> remoteIds, {
|
List<String> remoteIds, {
|
||||||
Option<bool> isFavorite = const .none(),
|
Option<bool> isFavorite = const .none(),
|
||||||
Option<AssetVisibility> visibility = const .none(),
|
Option<AssetVisibility> visibility = const .none(),
|
||||||
}) {
|
Option<DateTime> createdAt = const .none(),
|
||||||
|
}) async {
|
||||||
|
if ([isFavorite, visibility, createdAt].every((option) => option.isNone)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final companion = RemoteAssetEntityCompanion(
|
final companion = RemoteAssetEntityCompanion(
|
||||||
visibility: visibility.toDriftValue(),
|
visibility: visibility.toDriftValue(),
|
||||||
isFavorite: isFavorite.toDriftValue(),
|
isFavorite: isFavorite.toDriftValue(),
|
||||||
|
createdAt: createdAt.toDriftValue(),
|
||||||
);
|
);
|
||||||
return _db.batch((batch) {
|
return _db.batch((batch) {
|
||||||
for (final remoteId in remoteIds) {
|
for (final remoteId in remoteIds) {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
|
import 'package:immich_mobile/utils/option.dart';
|
||||||
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
|
|
||||||
|
class RemoteExifRepository extends DriftDatabaseRepository {
|
||||||
|
final Drift _db;
|
||||||
|
|
||||||
|
const RemoteExifRepository(this._db) : super(_db);
|
||||||
|
|
||||||
|
Future<void> update(
|
||||||
|
List<String> ids, {
|
||||||
|
Option<DateTime> dateTimeOriginal = const .none(),
|
||||||
|
Option<String> timeZone = const .none(),
|
||||||
|
Option<LatLng> location = const .none(),
|
||||||
|
}) async {
|
||||||
|
if ([dateTimeOriginal, timeZone, location].every((option) => option.isNone)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final companion = RemoteExifEntityCompanion(
|
||||||
|
dateTimeOriginal: dateTimeOriginal.toDriftValue(),
|
||||||
|
timeZone: timeZone.toDriftValue(),
|
||||||
|
latitude: location.map((loc) => loc.latitude).toDriftValue(),
|
||||||
|
longitude: location.map((loc) => loc.longitude).toDriftValue(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return _db.batch((batch) {
|
||||||
|
for (final id in ids) {
|
||||||
|
batch.update(_db.remoteExifEntity, companion, where: (a) => a.assetId.equals(id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,4 +31,6 @@ abstract class BaseAction {
|
|||||||
const BaseAction({required this.scope, required this.icon, required this.label, this.isVisible = true});
|
const BaseAction({required this.scope, required this.icon, required this.label, this.isVisible = true});
|
||||||
|
|
||||||
Future<void> onAction();
|
Future<void> onAction();
|
||||||
|
|
||||||
|
Future<void> Function()? get onSecondaryAction => null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ class _ActionWidgetScope {
|
|||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final FutureOr<void> Function() onAction;
|
final FutureOr<void> Function() onAction;
|
||||||
|
final FutureOr<void> Function()? onSecondaryAction;
|
||||||
|
|
||||||
const _ActionWidgetScope({required this.icon, required this.label, required this.onAction});
|
const _ActionWidgetScope({required this.icon, required this.label, required this.onAction, this.onSecondaryAction});
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ActionWidget extends ConsumerWidget {
|
class _ActionWidget extends ConsumerWidget {
|
||||||
@@ -21,21 +22,35 @@ class _ActionWidget extends ConsumerWidget {
|
|||||||
|
|
||||||
const _ActionWidget({required this.action, required this.builder});
|
const _ActionWidget({required this.action, required this.builder});
|
||||||
|
|
||||||
Future<void> _onAction() async {
|
Future<void> _guard(Future<void> Function() handler) async {
|
||||||
try {
|
try {
|
||||||
await action.onAction();
|
await handler();
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
handleError(error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
|
handleError(error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> Function() get _onAction =>
|
||||||
|
() => _guard(action.onAction);
|
||||||
|
|
||||||
|
Future<void> Function()? get _onSecondaryAction {
|
||||||
|
final onSecondaryAction = action.onSecondaryAction;
|
||||||
|
if (onSecondaryAction == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => _guard(onSecondaryAction);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
if (!action.isVisible) {
|
if (!action.isVisible) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder(.new(icon: action.icon, label: action.label, onAction: _onAction));
|
return builder(
|
||||||
|
.new(icon: action.icon, label: action.label, onAction: _onAction, onSecondaryAction: _onSecondaryAction),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +63,8 @@ class ActionIconButtonWidget extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => _ActionWidget(
|
Widget build(BuildContext context) => _ActionWidget(
|
||||||
action: action,
|
action: action,
|
||||||
builder: (ctx) => ImmichIconButton(icon: ctx.icon, onPressed: ctx.onAction, variant: variant),
|
builder: (ctx) =>
|
||||||
|
ImmichIconButton(icon: ctx.icon, onPressed: ctx.onAction, onLongPress: ctx.onSecondaryAction, variant: variant),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +77,13 @@ class ActionButtonWidget extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => _ActionWidget(
|
Widget build(BuildContext context) => _ActionWidget(
|
||||||
action: action,
|
action: action,
|
||||||
builder: (ctx) => ImmichTextButton(labelText: ctx.label, icon: ctx.icon, onPressed: ctx.onAction, variant: variant),
|
builder: (ctx) => ImmichTextButton(
|
||||||
|
labelText: ctx.label,
|
||||||
|
icon: ctx.icon,
|
||||||
|
onPressed: ctx.onAction,
|
||||||
|
onLongPress: ctx.onSecondaryAction,
|
||||||
|
variant: variant,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +95,12 @@ class ActionColumnButtonWidget extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => _ActionWidget(
|
Widget build(BuildContext context) => _ActionWidget(
|
||||||
action: action,
|
action: action,
|
||||||
builder: (ctx) => ImmichColumnButton(icon: ctx.icon, label: ctx.label, onPressed: ctx.onAction),
|
builder: (ctx) => ImmichColumnButton(
|
||||||
|
icon: ctx.icon,
|
||||||
|
label: ctx.label,
|
||||||
|
onPressed: ctx.onAction,
|
||||||
|
onLongPress: ctx.onSecondaryAction,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/archive.action.dart';
|
import 'package:immich_mobile/presentation/actions/archive.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/delete.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/edit_location.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/lock.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/stack.action.dart';
|
import 'package:immich_mobile/presentation/actions/stack.action.dart';
|
||||||
|
|
||||||
class AssetActions {
|
class AssetActions {
|
||||||
@@ -10,13 +14,33 @@ class AssetActions {
|
|||||||
final FavoriteAction favorite;
|
final FavoriteAction favorite;
|
||||||
final ArchiveAction archive;
|
final ArchiveAction archive;
|
||||||
final StackAction stack;
|
final StackAction stack;
|
||||||
|
final LockAction lock;
|
||||||
|
final DeleteAction delete;
|
||||||
|
final CleanupLocalAction cleanup;
|
||||||
|
final EditDateTimeAction editDateTime;
|
||||||
|
final EditLocationAction editLocation;
|
||||||
|
|
||||||
const AssetActions({required this.debug, required this.favorite, required this.archive, required this.stack});
|
const AssetActions({
|
||||||
|
required this.debug,
|
||||||
|
required this.favorite,
|
||||||
|
required this.archive,
|
||||||
|
required this.stack,
|
||||||
|
required this.lock,
|
||||||
|
required this.delete,
|
||||||
|
required this.cleanup,
|
||||||
|
required this.editDateTime,
|
||||||
|
required this.editLocation,
|
||||||
|
});
|
||||||
|
|
||||||
static AssetActions from(ActionScope scope, List<BaseAsset> assets) => .new(
|
factory AssetActions.from(ActionScope scope, List<BaseAsset> assets) => .new(
|
||||||
debug: AssetDebugAction(assets: assets, scope: scope),
|
debug: AssetDebugAction(assets: assets, scope: scope),
|
||||||
favorite: FavoriteAction(assets: assets, scope: scope),
|
favorite: FavoriteAction(assets: assets, scope: scope),
|
||||||
archive: ArchiveAction(assets: assets, scope: scope),
|
archive: ArchiveAction(assets: assets, scope: scope),
|
||||||
stack: StackAction(assets: assets, scope: scope),
|
stack: StackAction(assets: assets, scope: scope),
|
||||||
|
lock: LockAction(assets: assets, scope: scope),
|
||||||
|
delete: DeleteAction(assets: assets, scope: scope),
|
||||||
|
cleanup: CleanupLocalAction(assets: assets, scope: scope),
|
||||||
|
editDateTime: EditDateTimeAction(assets: assets, scope: scope),
|
||||||
|
editLocation: EditLocationAction(assets: assets, scope: scope),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
|
||||||
|
|
||||||
|
class CastAction extends BaseAction {
|
||||||
|
const CastAction._({required super.scope, required super.icon, required super.label});
|
||||||
|
|
||||||
|
factory CastAction({required ActionScope scope}) {
|
||||||
|
final casting = scope.ref.watch(castProvider.select((state) => state.isCasting));
|
||||||
|
return CastAction._(
|
||||||
|
scope: scope,
|
||||||
|
icon: casting ? Icons.cast_connected_rounded : Icons.cast_rounded,
|
||||||
|
label: scope.context.t.cast,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction() async {
|
||||||
|
unawaited(showDialog(context: scope.context, builder: (_) => const CastDialog()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
|
import 'package:immich_mobile/services/cleanup.service.dart';
|
||||||
|
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||||
|
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||||
|
|
||||||
|
class DeleteAction extends BaseAction {
|
||||||
|
final List<String> localIds;
|
||||||
|
final List<String> remoteIds;
|
||||||
|
final bool trash;
|
||||||
|
|
||||||
|
DeleteAction._({
|
||||||
|
required this.localIds,
|
||||||
|
required this.remoteIds,
|
||||||
|
required super.scope,
|
||||||
|
required this.trash,
|
||||||
|
super.isVisible,
|
||||||
|
}) : super(icon: Icons.delete_outline, label: trash ? scope.context.t.trash : scope.context.t.delete);
|
||||||
|
|
||||||
|
factory DeleteAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||||
|
final ActionScope(:ref) = scope;
|
||||||
|
|
||||||
|
final localIds = <String>[];
|
||||||
|
final ownedRemote = <RemoteAsset>[];
|
||||||
|
for (final asset in assets) {
|
||||||
|
if (asset.localId case final id?) {
|
||||||
|
localIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset case final RemoteAsset remote when remote.ownerId == scope.authUser.id) {
|
||||||
|
ownedRemote.add(remote);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final remoteIds = ownedRemote.map((asset) => asset.id).toList(growable: false);
|
||||||
|
|
||||||
|
final trashEnabled = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||||
|
// Assets already in trash or in locked page should be permanently deleted irrespective of the trash feature being enabled
|
||||||
|
final trash = trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked);
|
||||||
|
|
||||||
|
return ._(
|
||||||
|
localIds: localIds,
|
||||||
|
remoteIds: remoteIds,
|
||||||
|
trash: trash,
|
||||||
|
scope: scope,
|
||||||
|
isVisible: remoteIds.isNotEmpty || localIds.isNotEmpty,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction() async {
|
||||||
|
final ActionScope(:ref, :context) = scope;
|
||||||
|
final toast = ref.read(toastRepositoryProvider);
|
||||||
|
|
||||||
|
// Local-only
|
||||||
|
// Single prompt on iOS & Android (without MANAGE_MEDIA)
|
||||||
|
// No prompt on Android (with MANAGE_MEDIA)
|
||||||
|
if (remoteIds.isEmpty) {
|
||||||
|
if (localIds.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final count = await cleanupLocalAssets(assetIds: localIds, scope: scope);
|
||||||
|
if (!context.mounted || count <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(context.t.cleanup_deleted_assets(count: count));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trash
|
||||||
|
// No prompt on Android (with MANAGE_MEDIA)
|
||||||
|
// Single prompt on iOS & Android (without MANAGE_MEDIA)
|
||||||
|
// TODO(shenlong): Handle the native prompt response and skip deleting trash when user cancels the prompt
|
||||||
|
if (trash) {
|
||||||
|
if (localIds.isNotEmpty) {
|
||||||
|
await cleanupLocalAssets(assetIds: localIds, scope: scope);
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await ref.read(assetServiceProvider).trash(remoteIds);
|
||||||
|
toast.success(context.t.trash_action_prompt(count: remoteIds.length));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permanent delete
|
||||||
|
// Single prompt on Android (with MANAGE_MEDIA)
|
||||||
|
// Double prompts on iOS & Android (without MANAGE_MEDIA)
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) =>
|
||||||
|
const ConfirmDialog(title: 'delete_dialog_title', content: 'delete_dialog_alert', ok: 'delete_permanently'),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform server deletion first so we don't remove the only local copy if the server delete fails
|
||||||
|
await ref.read(assetServiceProvider).delete(remoteIds);
|
||||||
|
if (localIds.isNotEmpty && context.mounted) {
|
||||||
|
await cleanupLocalAssets(assetIds: localIds, scope: scope, requestPrompt: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(context.t.delete_permanently_action_prompt(count: remoteIds.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CleanupLocalAction extends BaseAction {
|
||||||
|
final List<String> assetIds;
|
||||||
|
|
||||||
|
CleanupLocalAction._({required this.assetIds, required super.scope})
|
||||||
|
: super(
|
||||||
|
icon: Icons.no_cell_outlined,
|
||||||
|
label: scope.context.t.control_bottom_app_bar_delete_from_local,
|
||||||
|
isVisible: assetIds.isNotEmpty,
|
||||||
|
);
|
||||||
|
|
||||||
|
factory CleanupLocalAction({required Iterable<BaseAsset> assets, required ActionScope scope}) => ._(
|
||||||
|
assetIds: AssetFilter(assets).backedUp().map((asset) => asset.localId).nonNulls.toList(growable: false),
|
||||||
|
scope: scope,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction() async {
|
||||||
|
final ActionScope(:ref, :context) = scope;
|
||||||
|
final count = await cleanupLocalAssets(assetIds: assetIds, scope: scope);
|
||||||
|
if (!context.mounted || count <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ref.read(toastRepositoryProvider).success(context.t.cleanup_deleted_assets(count: count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Future<int> cleanupLocalAssets({
|
||||||
|
required List<String> assetIds,
|
||||||
|
required ActionScope scope,
|
||||||
|
bool requestPrompt = true,
|
||||||
|
}) async {
|
||||||
|
final ActionScope(:ref, :context) = scope;
|
||||||
|
|
||||||
|
/// OS prompts on iOS & Android (without MANAGE_MEDIA)
|
||||||
|
/// Custom prompt on Android (with MANAGE_MEDIA)
|
||||||
|
final requireUserPrompt =
|
||||||
|
requestPrompt && CurrentPlatform.isAndroid && ref.read(storeServiceProvider).get(.manageLocalMediaAndroid, false);
|
||||||
|
if (requireUserPrompt) {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => ConfirmDialog(
|
||||||
|
title: context.t.move_to_device_trash,
|
||||||
|
content: context.t.free_up_space_description,
|
||||||
|
ok: context.t.ok,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ref.read(cleanupServiceProvider).deleteLocalAssets(assetIds);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:auto_route/auto_route.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/asset_edit.model.dart';
|
||||||
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||||
|
import 'package:immich_mobile/utils/semver.dart';
|
||||||
|
|
||||||
|
class EditAssetAction extends BaseAction {
|
||||||
|
final Iterable<RemoteAsset> assets;
|
||||||
|
|
||||||
|
EditAssetAction._({required this.assets, required super.scope, super.isVisible})
|
||||||
|
: super(icon: Icons.tune, label: scope.context.t.edit);
|
||||||
|
|
||||||
|
factory EditAssetAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||||
|
final editable = AssetFilter(
|
||||||
|
assets,
|
||||||
|
).owned(scope.authUser.id).where((asset) => asset.isEditable).toList(growable: false);
|
||||||
|
final isSupported = scope.ref.watch(serverInfoProvider).serverVersion >= const SemVer(major: 2, minor: 6, patch: 0);
|
||||||
|
|
||||||
|
return EditAssetAction._(assets: editable, scope: scope, isVisible: isSupported && editable.length == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction() async {
|
||||||
|
final ActionScope(:context, :ref) = scope;
|
||||||
|
|
||||||
|
// TODO(shenlong): Move all EXIF and Apply Edits logic onto the Route
|
||||||
|
final asset = assets.first;
|
||||||
|
final repository = ref.read(remoteAssetRepositoryProvider);
|
||||||
|
final (edits, exif) = await (repository.getAssetEdits(asset.id), repository.getExif(asset.id)).wait;
|
||||||
|
if (exif == null || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(editorStateProvider.notifier).init(edits, exif);
|
||||||
|
unawaited(
|
||||||
|
context.pushRoute(
|
||||||
|
DriftEditImageRoute(
|
||||||
|
image: Image(image: getFullImageProvider(asset, edited: false)),
|
||||||
|
applyEdits: (newEdits) => applyEdits(ref, asset.id, newEdits),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Future<void> applyEdits(WidgetRef ref, String remoteId, List<AssetEdit> edits) async {
|
||||||
|
final websocket = ref.read(websocketProvider.notifier);
|
||||||
|
|
||||||
|
bool isCurrentId(dynamic data) => data is Map && (data['asset'] as Map?)?['id'] == remoteId;
|
||||||
|
await ref.read(assetServiceProvider).applyEdits(remoteId, edits);
|
||||||
|
await Future.any([
|
||||||
|
websocket.waitForEvent('AssetEditReadyV1', isCurrentId, const Duration(seconds: 10)),
|
||||||
|
websocket.waitForEvent('AssetEditReadyV2', isCurrentId, const Duration(seconds: 10)),
|
||||||
|
]).catchError((_) {});
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||||
|
import 'package:immich_mobile/utils/timezone.dart';
|
||||||
|
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
|
||||||
|
|
||||||
|
class EditDateTimeAction extends BaseAction {
|
||||||
|
final List<String> assetIds;
|
||||||
|
final RemoteAsset? origin;
|
||||||
|
|
||||||
|
EditDateTimeAction._({required this.assetIds, required this.origin, required super.scope, super.isVisible})
|
||||||
|
: super(icon: Icons.edit_calendar_outlined, label: scope.context.t.control_bottom_app_bar_edit_time);
|
||||||
|
|
||||||
|
factory EditDateTimeAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||||
|
final owned = AssetFilter(assets).owned(scope.authUser.id);
|
||||||
|
|
||||||
|
return EditDateTimeAction._(
|
||||||
|
assetIds: owned.map((asset) => asset.id).toList(growable: false),
|
||||||
|
origin: owned.firstOrNull,
|
||||||
|
scope: scope,
|
||||||
|
isVisible: owned.isNotEmpty,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction() async {
|
||||||
|
final ActionScope(:context, :ref) = scope;
|
||||||
|
|
||||||
|
DateTime? initialDate;
|
||||||
|
String? timeZone;
|
||||||
|
Duration? offset;
|
||||||
|
|
||||||
|
final seed = origin;
|
||||||
|
if (seed != null) {
|
||||||
|
final exif = await ref.read(remoteAssetRepositoryProvider).getExif(seed.id);
|
||||||
|
|
||||||
|
// Use EXIF timezone information if available (matching web app and display behavior)
|
||||||
|
DateTime dt = seed.createdAt.toLocal();
|
||||||
|
offset = dt.timeZoneOffset;
|
||||||
|
if (exif?.dateTimeOriginal != null) {
|
||||||
|
timeZone = exif!.timeZone;
|
||||||
|
(dt, offset) = applyTimezoneOffset(dateTime: exif.dateTimeOriginal!, timeZone: exif.timeZone);
|
||||||
|
}
|
||||||
|
initialDate = dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final dateTime = await showDateTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialDateTime: initialDate,
|
||||||
|
initialTZ: timeZone,
|
||||||
|
initialTZOffset: offset,
|
||||||
|
);
|
||||||
|
if (dateTime == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await save(dateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Future<void> save(String dateTime) async {
|
||||||
|
final ActionScope(:context, :ref) = scope;
|
||||||
|
|
||||||
|
await ref.read(assetServiceProvider).update(assetIds, dateTime: .some(dateTime));
|
||||||
|
ref.invalidate(assetExifProvider);
|
||||||
|
ref.read(toastRepositoryProvider).success(context.t.edit_date_and_time_action_prompt(count: assetIds.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/common/location_picker.dart';
|
||||||
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
|
|
||||||
|
class EditLocationAction extends BaseAction {
|
||||||
|
final List<String> assetIds;
|
||||||
|
final RemoteAsset? origin;
|
||||||
|
|
||||||
|
const EditLocationAction._({
|
||||||
|
required this.assetIds,
|
||||||
|
required this.origin,
|
||||||
|
required super.scope,
|
||||||
|
required super.icon,
|
||||||
|
required super.label,
|
||||||
|
super.isVisible,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EditLocationAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||||
|
final owned = assets
|
||||||
|
.whereType<RemoteAsset>()
|
||||||
|
.where((asset) => asset.ownerId == scope.authUser.id)
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
return EditLocationAction._(
|
||||||
|
assetIds: owned.map((asset) => asset.id).toList(growable: false),
|
||||||
|
origin: owned.length == 1 ? owned.first : null,
|
||||||
|
scope: scope,
|
||||||
|
icon: Icons.edit_location_alt_outlined,
|
||||||
|
label: scope.context.t.control_bottom_app_bar_edit_location,
|
||||||
|
isVisible: owned.isNotEmpty,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction() async {
|
||||||
|
final ActionScope(:context, :ref) = scope;
|
||||||
|
|
||||||
|
LatLng? initialLatLng;
|
||||||
|
final seed = origin;
|
||||||
|
if (seed != null) {
|
||||||
|
final exif = await ref.read(remoteAssetRepositoryProvider).getExif(seed.id);
|
||||||
|
if (exif?.latitude != null && exif?.longitude != null) {
|
||||||
|
initialLatLng = LatLng(exif!.latitude!, exif.longitude!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final location = await showLocationPicker(context: context, initialLatLng: initialLatLng);
|
||||||
|
if (location == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await save(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Future<void> save(LatLng location) async {
|
||||||
|
final ActionScope(:context, :ref) = scope;
|
||||||
|
|
||||||
|
await ref.read(assetServiceProvider).update(assetIds, location: .some(location));
|
||||||
|
ref.invalidate(assetExifProvider);
|
||||||
|
ref.read(toastRepositoryProvider).success(context.t.edit_location_action_prompt(count: assetIds.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||||
|
|
||||||
|
class LockAction extends BaseAction {
|
||||||
|
final List<String> assetIds;
|
||||||
|
final bool lock;
|
||||||
|
|
||||||
|
const LockAction._({
|
||||||
|
required this.assetIds,
|
||||||
|
required this.lock,
|
||||||
|
required super.scope,
|
||||||
|
required super.icon,
|
||||||
|
required super.label,
|
||||||
|
super.isVisible,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory LockAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||||
|
final ownedAssets = AssetFilter(assets).owned(scope.authUser.id);
|
||||||
|
final lock = ownedAssets.locked(isLocked: false).isNotEmpty;
|
||||||
|
final assetIds = ownedAssets.locked(isLocked: !lock).map((asset) => asset.id).toList(growable: false);
|
||||||
|
|
||||||
|
return LockAction._(
|
||||||
|
assetIds: assetIds,
|
||||||
|
lock: lock,
|
||||||
|
scope: scope,
|
||||||
|
icon: lock ? Icons.lock_rounded : Icons.lock_open_rounded,
|
||||||
|
label: lock ? scope.context.t.move_to_locked_folder : scope.context.t.remove_from_locked_folder,
|
||||||
|
isVisible: assetIds.isNotEmpty,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction() async {
|
||||||
|
final ActionScope(:ref, :context) = scope;
|
||||||
|
|
||||||
|
await ref.read(assetServiceProvider).update(assetIds, visibility: .some(lock ? .locked : .timeline));
|
||||||
|
final message = lock
|
||||||
|
? scope.context.t.move_to_lock_folder_action_prompt(count: assetIds.length)
|
||||||
|
: scope.context.t.remove_from_lock_folder_action_prompt(count: assetIds.length);
|
||||||
|
ref.read(toastRepositoryProvider).success(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
class OpenInBrowserAction extends BaseAction {
|
||||||
|
final String remoteId;
|
||||||
|
final TimelineOrigin origin;
|
||||||
|
|
||||||
|
OpenInBrowserAction({required this.remoteId, required this.origin, required super.scope})
|
||||||
|
: super(icon: Icons.open_in_browser, label: scope.context.t.open_in_browser);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction() async {
|
||||||
|
final serverEndpoint = Store.get(.serverEndpoint).replaceFirst('/api', '');
|
||||||
|
|
||||||
|
final originPath = switch (origin) {
|
||||||
|
.favorite => '/favorites',
|
||||||
|
.trash => '/trash',
|
||||||
|
.archive => '/archive',
|
||||||
|
_ => '',
|
||||||
|
};
|
||||||
|
|
||||||
|
final url = Uri.parse('$serverEndpoint$originPath/photos/$remoteId');
|
||||||
|
if (await canLaunchUrl(url)) {
|
||||||
|
await launchUrl(url, mode: .externalApplication);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||||
|
|
||||||
|
class RemoveFromAlbumAction extends BaseAction {
|
||||||
|
final String albumId;
|
||||||
|
final List<String> assetIds;
|
||||||
|
|
||||||
|
RemoveFromAlbumAction._({required super.scope, required this.albumId, required this.assetIds, super.isVisible})
|
||||||
|
: super(icon: Icons.remove_circle_outline, label: scope.context.t.remove_from_album);
|
||||||
|
|
||||||
|
factory RemoveFromAlbumAction({
|
||||||
|
required Iterable<BaseAsset> assets,
|
||||||
|
required String albumId,
|
||||||
|
required ActionScope scope,
|
||||||
|
}) {
|
||||||
|
final assetIds = AssetFilter(assets).map((asset) => asset.remoteId).nonNulls.toList(growable: false);
|
||||||
|
|
||||||
|
return RemoveFromAlbumAction._(scope: scope, albumId: albumId, assetIds: assetIds, isVisible: assetIds.isNotEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction() async {
|
||||||
|
final ActionScope(:ref, :context) = scope;
|
||||||
|
|
||||||
|
final count = await ref.read(remoteAlbumServiceProvider).removeAssets(albumId: albumId, assetIds: assetIds);
|
||||||
|
ref.read(toastRepositoryProvider).success(context.t.remove_from_album_action_prompt(count: count));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||||
|
|
||||||
|
class SetAlbumCoverAction extends BaseAction {
|
||||||
|
final String albumId;
|
||||||
|
final List<String> assetIds;
|
||||||
|
|
||||||
|
SetAlbumCoverAction._({required super.scope, required this.albumId, required this.assetIds, super.isVisible})
|
||||||
|
: super(icon: Icons.image_outlined, label: scope.context.t.set_as_album_cover);
|
||||||
|
|
||||||
|
factory SetAlbumCoverAction({
|
||||||
|
required Iterable<BaseAsset> assets,
|
||||||
|
required String albumId,
|
||||||
|
required ActionScope scope,
|
||||||
|
}) {
|
||||||
|
final assetIds = AssetFilter(assets).map((asset) => asset.remoteId).nonNulls.toList(growable: false);
|
||||||
|
return SetAlbumCoverAction._(scope: scope, albumId: albumId, assetIds: assetIds, isVisible: assetIds.length == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction() async {
|
||||||
|
final ActionScope(:ref, :context) = scope;
|
||||||
|
|
||||||
|
await ref.read(remoteAlbumServiceProvider).updateAlbum(albumId, thumbnailAssetId: assetIds.first);
|
||||||
|
ref.read(toastRepositoryProvider).success(context.t.album_cover_updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
|
class SetProfilePictureAction extends BaseAction {
|
||||||
|
final BaseAsset asset;
|
||||||
|
|
||||||
|
SetProfilePictureAction({required this.asset, required super.scope})
|
||||||
|
: super(icon: Icons.account_circle_outlined, label: scope.context.t.set_as_profile_picture);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction() async => unawaited(scope.context.pushRoute(ProfilePictureCropRoute(asset: asset)));
|
||||||
|
}
|
||||||
+8
-23
@@ -2,26 +2,23 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.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/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
|
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
class SimilarPhotosActionButton extends ConsumerWidget {
|
class SimilarPhotosAction extends BaseAction {
|
||||||
final String assetId;
|
final String assetId;
|
||||||
final bool iconOnly;
|
|
||||||
final bool menuItem;
|
|
||||||
|
|
||||||
const SimilarPhotosActionButton({super.key, required this.assetId, this.iconOnly = false, this.menuItem = false});
|
SimilarPhotosAction({required this.assetId, required super.scope})
|
||||||
|
: super(icon: Icons.compare, label: scope.context.t.view_similar_photos);
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
@override
|
||||||
if (!context.mounted) {
|
Future<void> onAction() async {
|
||||||
return;
|
final ActionScope(:context, :ref) = scope;
|
||||||
}
|
|
||||||
|
|
||||||
ref.invalidate(assetViewerProvider);
|
ref.invalidate(assetViewerProvider);
|
||||||
ref.invalidate(paginatedSearchProvider);
|
ref.invalidate(paginatedSearchProvider);
|
||||||
@@ -43,16 +40,4 @@ class SimilarPhotosActionButton extends ConsumerWidget {
|
|||||||
|
|
||||||
unawaited(context.navigateTo(const DriftSearchRoute()));
|
unawaited(context.navigateTo(const DriftSearchRoute()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return BaseActionButton(
|
|
||||||
iconData: Icons.compare,
|
|
||||||
label: "view_similar_photos".t(context: context),
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
maxWidth: 100,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
|
class SlideshowAction extends BaseAction {
|
||||||
|
SlideshowAction({required super.scope}) : super(icon: Icons.slideshow, label: scope.context.t.slideshow);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction() async {
|
||||||
|
final ActionScope(:context, :ref) = scope;
|
||||||
|
unawaited(context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,4 +12,17 @@ class TimelineAction extends BaseAction {
|
|||||||
await action.onAction();
|
await action.onAction();
|
||||||
scope.ref.read(multiSelectProvider.notifier).reset();
|
scope.ref.read(multiSelectProvider.notifier).reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> Function()? get onSecondaryAction {
|
||||||
|
final inner = action.onSecondaryAction;
|
||||||
|
if (inner == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () async {
|
||||||
|
await inner();
|
||||||
|
scope.ref.read(multiSelectProvider.notifier).reset();
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|||||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/archive.action.dart';
|
import 'package:immich_mobile/presentation/actions/archive.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/lock.action.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/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
@@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/user.provider.dart';
|
|||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
import 'package:immich_ui/immich_ui.dart';
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
|
|
||||||
enum AddToMenuItem { album, lockedFolder }
|
enum AddToMenuItem { album }
|
||||||
|
|
||||||
class AddActionButton extends ConsumerStatefulWidget {
|
class AddActionButton extends ConsumerStatefulWidget {
|
||||||
const AddActionButton({super.key, this.originalTheme});
|
const AddActionButton({super.key, this.originalTheme});
|
||||||
@@ -36,9 +36,6 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
|||||||
case AddToMenuItem.album:
|
case AddToMenuItem.album:
|
||||||
_openAlbumSelector();
|
_openAlbumSelector();
|
||||||
break;
|
break;
|
||||||
case AddToMenuItem.lockedFolder:
|
|
||||||
performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,11 +70,8 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
|||||||
ActionMenuItemWidget(
|
ActionMenuItemWidget(
|
||||||
action: ArchiveAction(assets: [asset], scope: scope),
|
action: ArchiveAction(assets: [asset], scope: scope),
|
||||||
),
|
),
|
||||||
BaseActionButton(
|
ActionMenuItemWidget(
|
||||||
iconData: Icons.lock_outline,
|
action: LockAction(assets: [asset], scope: scope),
|
||||||
label: "locked_folder".tr(),
|
|
||||||
menuItem: true,
|
|
||||||
onPressed: () => _handleMenuSelection(AddToMenuItem.lockedFolder),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/extensions/build_context_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/providers/cast.provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
|
|
||||||
|
|
||||||
class CastActionButton extends ConsumerWidget {
|
|
||||||
const CastActionButton({super.key, this.iconOnly = false, this.menuItem = false});
|
|
||||||
|
|
||||||
final bool iconOnly;
|
|
||||||
final bool menuItem;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
|
||||||
|
|
||||||
return BaseActionButton(
|
|
||||||
iconData: isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded,
|
|
||||||
iconColor: isCasting ? context.primaryColor : null, // null = default color
|
|
||||||
label: "cast".t(context: context),
|
|
||||||
onPressed: () {
|
|
||||||
showDialog(context: context, builder: (context) => const CastDialog());
|
|
||||||
},
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
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/domain/models/events.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
|
||||||
import 'package:immich_mobile/extensions/build_context_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/providers/infrastructure/action.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|
||||||
|
|
||||||
/// This delete action has the following behavior:
|
|
||||||
/// - Set the deletedAt information, put the asset in the trash in the server
|
|
||||||
/// which will be permanently deleted after the number of days configure by the admin
|
|
||||||
/// - Prompt to delete the asset locally
|
|
||||||
class DeleteActionButton extends ConsumerWidget {
|
|
||||||
final ActionSource source;
|
|
||||||
final bool showConfirmation;
|
|
||||||
final bool iconOnly;
|
|
||||||
final bool menuItem;
|
|
||||||
const DeleteActionButton({
|
|
||||||
super.key,
|
|
||||||
required this.source,
|
|
||||||
this.showConfirmation = false,
|
|
||||||
this.iconOnly = false,
|
|
||||||
this.menuItem = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showConfirmation) {
|
|
||||||
final confirm = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: Text('delete'.t(context: context)),
|
|
||||||
content: Text('delete_action_confirmation_message'.t(context: context)),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(false),
|
|
||||||
child: Text('cancel'.t(context: context)),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
|
||||||
child: Text(
|
|
||||||
'confirm'.t(context: context),
|
|
||||||
style: TextStyle(color: context.colorScheme.error),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (confirm != true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source == ActionSource.viewer) {
|
|
||||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
final successMessage = 'delete_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(
|
|
||||||
maxWidth: 110.0,
|
|
||||||
iconData: Icons.delete_sweep_outlined,
|
|
||||||
label: "delete".t(context: context),
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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/domain/models/events.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.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';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
|
||||||
|
|
||||||
/// This delete action has the following behavior:
|
|
||||||
/// - Prompt to delete the asset locally
|
|
||||||
class DeleteLocalActionButton extends ConsumerWidget {
|
|
||||||
final ActionSource source;
|
|
||||||
final bool iconOnly;
|
|
||||||
final bool menuItem;
|
|
||||||
|
|
||||||
const DeleteLocalActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).deleteLocal(source, context);
|
|
||||||
if (result == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
if (source == ActionSource.viewer) {
|
|
||||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.count == 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.invalidate(localAlbumProvider);
|
|
||||||
|
|
||||||
final successMessage = 'delete_local_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(
|
|
||||||
maxWidth: 95.0,
|
|
||||||
iconData: Icons.no_cell_outlined,
|
|
||||||
label: "control_bottom_app_bar_delete_from_local".t(context: context),
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-80
@@ -1,80 +0,0 @@
|
|||||||
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/domain/models/events.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.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/asset_grid/permanent_delete_dialog.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|
||||||
|
|
||||||
/// This delete action has the following behavior:
|
|
||||||
/// - Delete permanently on the server
|
|
||||||
/// - Prompt to delete the asset locally
|
|
||||||
class DeletePermanentActionButton extends ConsumerWidget {
|
|
||||||
final ActionSource source;
|
|
||||||
final bool iconOnly;
|
|
||||||
final bool menuItem;
|
|
||||||
final bool useShortLabel;
|
|
||||||
|
|
||||||
const DeletePermanentActionButton({
|
|
||||||
super.key,
|
|
||||||
required this.source,
|
|
||||||
this.iconOnly = false,
|
|
||||||
this.menuItem = false,
|
|
||||||
this.useShortLabel = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final count = source == ActionSource.viewer ? 1 : ref.read(multiSelectProvider).selectedAssets.length;
|
|
||||||
final confirm =
|
|
||||||
await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => PermanentDeleteDialog(count: count),
|
|
||||||
) ??
|
|
||||||
false;
|
|
||||||
if (!confirm) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source == ActionSource.viewer) {
|
|
||||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
final successMessage = 'delete_permanently_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(
|
|
||||||
maxWidth: 110.0,
|
|
||||||
iconData: Icons.delete_forever,
|
|
||||||
label: useShortLabel ? "delete".t(context: context) : "delete_permanently".t(context: context),
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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/providers/infrastructure/action.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/asset_grid/permanent_delete_dialog.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|
||||||
|
|
||||||
/// This delete action has the following behavior:
|
|
||||||
/// - Delete permanently on the server
|
|
||||||
/// - Prompt to delete the asset locally
|
|
||||||
///
|
|
||||||
/// This action is used when the asset is selected in multi-selection mode in the trash page
|
|
||||||
class DeleteTrashActionButton extends ConsumerWidget {
|
|
||||||
final ActionSource source;
|
|
||||||
|
|
||||||
const DeleteTrashActionButton({super.key, required this.source});
|
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final selectCount = ref.watch(multiSelectProvider.select((s) => s.selectedAssets.length));
|
|
||||||
|
|
||||||
final confirmDelete =
|
|
||||||
await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => PermanentDeleteDialog(count: selectCount),
|
|
||||||
) ??
|
|
||||||
false;
|
|
||||||
if (!confirmDelete) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
final successMessage = 'assets_permanently_deleted_count'.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 TextButton.icon(
|
|
||||||
icon: Icon(Icons.delete_forever, color: Colors.red[400]),
|
|
||||||
label: Text(
|
|
||||||
"delete".t(context: context),
|
|
||||||
style: TextStyle(fontSize: 14, color: Colors.red[400], fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-52
@@ -1,52 +0,0 @@
|
|||||||
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 EditDateTimeActionButton extends ConsumerWidget {
|
|
||||||
final ActionSource source;
|
|
||||||
|
|
||||||
const EditDateTimeActionButton({super.key, required this.source});
|
|
||||||
|
|
||||||
_onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).editDateTime(source, context);
|
|
||||||
if (result == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
final successMessage = 'edit_date_and_time_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(
|
|
||||||
maxWidth: 95.0,
|
|
||||||
iconData: Icons.edit_calendar_outlined,
|
|
||||||
label: "control_bottom_app_bar_edit_time".t(context: context),
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
|
||||||
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
|
||||||
|
|
||||||
class EditImageActionButton extends ConsumerWidget {
|
|
||||||
const EditImageActionButton({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
|
||||||
|
|
||||||
Future<void> editImage(List<AssetEdit> edits) async {
|
|
||||||
if (currentAsset == null || currentAsset.remoteId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ref.read(actionProvider.notifier).applyEdits(ActionSource.viewer, edits);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> onPress() async {
|
|
||||||
if (currentAsset == null || currentAsset.remoteId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final imageProvider = getFullImageProvider(currentAsset, edited: false);
|
|
||||||
|
|
||||||
final image = Image(image: imageProvider);
|
|
||||||
final (edits, exifInfo) = await (
|
|
||||||
ref.read(remoteAssetRepositoryProvider).getAssetEdits(currentAsset.remoteId!),
|
|
||||||
ref.read(remoteAssetRepositoryProvider).getExif(currentAsset.remoteId!),
|
|
||||||
).wait;
|
|
||||||
|
|
||||||
if (exifInfo == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(editorStateProvider.notifier).init(edits, exifInfo);
|
|
||||||
await context.pushRoute(DriftEditImageRoute(image: image, applyEdits: editImage));
|
|
||||||
}
|
|
||||||
|
|
||||||
return BaseActionButton(
|
|
||||||
iconData: Icons.tune,
|
|
||||||
label: "edit".t(context: context),
|
|
||||||
onPressed: onPress,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-48
@@ -1,48 +0,0 @@
|
|||||||
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 EditLocationActionButton extends ConsumerWidget {
|
|
||||||
final ActionSource source;
|
|
||||||
|
|
||||||
const EditLocationActionButton({super.key, required this.source});
|
|
||||||
|
|
||||||
_onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).editLocation(source, context);
|
|
||||||
if (result == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
final successMessage = 'edit_location_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.edit_location_alt_outlined,
|
|
||||||
label: "control_bottom_app_bar_edit_location".t(context: context),
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-63
@@ -1,63 +0,0 @@
|
|||||||
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/domain/models/events.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.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';
|
|
||||||
|
|
||||||
// Reusable helper: move to locked folder from any source (e.g called from menu)
|
|
||||||
Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source == ActionSource.viewer) {
|
|
||||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
final successMessage = 'move_to_lock_folder_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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MoveToLockFolderActionButton extends ConsumerWidget {
|
|
||||||
final ActionSource source;
|
|
||||||
final bool iconOnly;
|
|
||||||
final bool menuItem;
|
|
||||||
|
|
||||||
const MoveToLockFolderActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
|
||||||
|
|
||||||
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
await performMoveToLockFolderAction(context, ref, source: source);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return BaseActionButton(
|
|
||||||
maxWidth: 115.0,
|
|
||||||
iconData: Icons.lock_outline_rounded,
|
|
||||||
label: "move_to_locked_folder".t(context: context),
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-58
@@ -1,58 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
|
|
||||||
class OpenInBrowserActionButton extends ConsumerWidget {
|
|
||||||
final String remoteId;
|
|
||||||
final TimelineOrigin origin;
|
|
||||||
final bool iconOnly;
|
|
||||||
final bool menuItem;
|
|
||||||
|
|
||||||
const OpenInBrowserActionButton({
|
|
||||||
super.key,
|
|
||||||
required this.remoteId,
|
|
||||||
required this.origin,
|
|
||||||
this.iconOnly = false,
|
|
||||||
this.menuItem = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
void _onTap() async {
|
|
||||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint).replaceFirst('/api', '');
|
|
||||||
|
|
||||||
String originPath = '';
|
|
||||||
switch (origin) {
|
|
||||||
case TimelineOrigin.favorite:
|
|
||||||
originPath = '/favorites';
|
|
||||||
break;
|
|
||||||
case TimelineOrigin.trash:
|
|
||||||
originPath = '/trash';
|
|
||||||
break;
|
|
||||||
case TimelineOrigin.archive:
|
|
||||||
originPath = '/archive';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
final url = '$serverEndpoint$originPath/photos/$remoteId';
|
|
||||||
if (await canLaunchUrl(Uri.parse(url))) {
|
|
||||||
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return BaseActionButton(
|
|
||||||
label: 'open_in_browser'.t(context: context),
|
|
||||||
iconData: Icons.open_in_browser,
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
onPressed: _onTap,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-65
@@ -1,65 +0,0 @@
|
|||||||
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/domain/models/events.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.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 RemoveFromAlbumActionButton extends ConsumerWidget {
|
|
||||||
final String albumId;
|
|
||||||
final ActionSource source;
|
|
||||||
final bool iconOnly;
|
|
||||||
final bool menuItem;
|
|
||||||
|
|
||||||
const RemoveFromAlbumActionButton({
|
|
||||||
super.key,
|
|
||||||
required this.albumId,
|
|
||||||
required this.source,
|
|
||||||
this.iconOnly = false,
|
|
||||||
this.menuItem = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source == ActionSource.viewer) {
|
|
||||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
final successMessage = 'remove_from_album_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.remove_circle_outline,
|
|
||||||
label: "remove_from_album".t(context: context),
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
maxWidth: 100,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-57
@@ -1,57 +0,0 @@
|
|||||||
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 RemoveFromLockFolderActionButton extends ConsumerWidget {
|
|
||||||
final ActionSource source;
|
|
||||||
final bool iconOnly;
|
|
||||||
final bool menuItem;
|
|
||||||
|
|
||||||
const RemoveFromLockFolderActionButton({
|
|
||||||
super.key,
|
|
||||||
required this.source,
|
|
||||||
this.iconOnly = false,
|
|
||||||
this.menuItem = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).removeFromLockFolder(source);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
final successMessage = 'remove_from_lock_folder_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(
|
|
||||||
maxWidth: 100.0,
|
|
||||||
iconData: Icons.lock_open_rounded,
|
|
||||||
label: "remove_from_locked_folder".t(context: context),
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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 SetAlbumCoverActionButton extends ConsumerWidget {
|
|
||||||
final String albumId;
|
|
||||||
final ActionSource source;
|
|
||||||
final bool iconOnly;
|
|
||||||
final bool menuItem;
|
|
||||||
|
|
||||||
const SetAlbumCoverActionButton({
|
|
||||||
super.key,
|
|
||||||
required this.albumId,
|
|
||||||
required this.source,
|
|
||||||
this.iconOnly = false,
|
|
||||||
this.menuItem = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).setAlbumCover(source, albumId);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
final successMessage = 'album_cover_updated'.t(context: context);
|
|
||||||
|
|
||||||
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.image_outlined,
|
|
||||||
label: 'set_as_album_cover'.t(context: context),
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
maxWidth: 100,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
import 'package:auto_route/auto_route.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/extensions/translate_extensions.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
|
||||||
|
|
||||||
class SetProfilePictureActionButton extends ConsumerWidget {
|
|
||||||
final BaseAsset asset;
|
|
||||||
final bool iconOnly;
|
|
||||||
final bool menuItem;
|
|
||||||
|
|
||||||
const SetProfilePictureActionButton({super.key, required this.asset, this.iconOnly = false, this.menuItem = false});
|
|
||||||
|
|
||||||
void _onTap(BuildContext context) {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
context.pushRoute(ProfilePictureCropRoute(asset: asset));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return BaseActionButton(
|
|
||||||
iconData: Icons.account_circle_outlined,
|
|
||||||
label: "set_as_profile_picture".t(context: context),
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
onPressed: () => _onTap(context),
|
|
||||||
maxWidth: 100,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/timeline.provider.dart';
|
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
|
||||||
|
|
||||||
class SlideshowActionButton extends ConsumerWidget {
|
|
||||||
final bool iconOnly;
|
|
||||||
final bool menuItem;
|
|
||||||
|
|
||||||
const SlideshowActionButton({super.key, this.iconOnly = false, this.menuItem = false});
|
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider)));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return BaseActionButton(
|
|
||||||
iconData: Icons.slideshow,
|
|
||||||
label: "slideshow".t(context: context),
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
maxWidth: 100,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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/domain/models/events.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.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';
|
|
||||||
|
|
||||||
/// This delete action has the following behavior:
|
|
||||||
/// - Set the deletedAt information, put the asset in the trash in the server
|
|
||||||
/// which will be permanently deleted after the number of days configure by the admin
|
|
||||||
class TrashActionButton extends ConsumerWidget {
|
|
||||||
final ActionSource source;
|
|
||||||
final bool iconOnly;
|
|
||||||
final bool menuItem;
|
|
||||||
|
|
||||||
const TrashActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source == ActionSource.viewer) {
|
|
||||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).trash(source);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
final successMessage = 'trash_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(
|
|
||||||
maxWidth: 85.0,
|
|
||||||
iconData: Icons.delete_outline_rounded,
|
|
||||||
label: "control_bottom_app_bar_trash_from_immich".t(context: context),
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+6
-4
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
@@ -8,6 +9,8 @@ import 'package:immich_mobile/domain/models/exif.model.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
@@ -27,16 +30,15 @@ class DateTimeDetails extends ConsumerWidget {
|
|||||||
final asset = this.asset;
|
final asset = this.asset;
|
||||||
final exifInfo = this.exifInfo;
|
final exifInfo = this.exifInfo;
|
||||||
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
||||||
|
final editDateTime = EditDateTimeAction(assets: [asset], scope: ActionScope.from(context, ref));
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
SheetTile(
|
SheetTile(
|
||||||
title: _getDateTime(context, asset, exifInfo),
|
title: _getDateTime(context, asset, exifInfo),
|
||||||
titleStyle: context.textTheme.labelLarge,
|
titleStyle: context.textTheme.labelLarge,
|
||||||
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
|
trailing: const Icon(Icons.edit, size: 18),
|
||||||
onTap: asset.hasRemote && isOwner
|
onTap: editDateTime.onAction,
|
||||||
? () async => await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
|
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
|
||||||
],
|
],
|
||||||
|
|||||||
+5
-8
@@ -1,13 +1,13 @@
|
|||||||
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/constants/enums.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/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/edit_location.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
|
||||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
|
|
||||||
@@ -53,10 +53,6 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void editLocation() async {
|
|
||||||
await ref.read(actionProvider.notifier).editLocation(ActionSource.viewer, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final asset = widget.asset;
|
final asset = widget.asset;
|
||||||
@@ -68,6 +64,7 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final editLocation = EditLocationAction(assets: [asset], scope: ActionScope.from(context, ref));
|
||||||
final locationName = _getLocationName(exifInfo);
|
final locationName = _getLocationName(exifInfo);
|
||||||
final coordinates = "${exifInfo?.latitude?.toStringAsFixed(4)}, ${exifInfo?.longitude?.toStringAsFixed(4)}";
|
final coordinates = "${exifInfo?.latitude?.toStringAsFixed(4)}, ${exifInfo?.longitude?.toStringAsFixed(4)}";
|
||||||
|
|
||||||
@@ -80,7 +77,7 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
|||||||
title: 'location'.t(context: context),
|
title: 'location'.t(context: context),
|
||||||
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||||
trailing: hasCoordinates ? const Icon(Icons.edit_location_alt, size: 20) : null,
|
trailing: hasCoordinates ? const Icon(Icons.edit_location_alt, size: 20) : null,
|
||||||
onTap: editLocation,
|
onTap: editLocation.onAction,
|
||||||
),
|
),
|
||||||
if (hasCoordinates)
|
if (hasCoordinates)
|
||||||
Padding(
|
Padding(
|
||||||
@@ -115,7 +112,7 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
|||||||
color: context.primaryColor,
|
color: context.primaryColor,
|
||||||
),
|
),
|
||||||
leading: const Icon(Icons.location_off),
|
leading: const Icon(Icons.location_off),
|
||||||
onTap: editLocation,
|
onTap: editLocation.onAction,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
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/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/delete.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/edit_asset.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/restore.action.dart';
|
import 'package:immich_mobile/presentation/actions/restore.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart';
|
||||||
@@ -19,9 +16,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
|
|||||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
|
||||||
import 'package:immich_mobile/utils/semver.dart';
|
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
|
||||||
|
|
||||||
class ViewerBottomBar extends ConsumerWidget {
|
class ViewerBottomBar extends ConsumerWidget {
|
||||||
@@ -35,39 +29,29 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||||
final user = ref.watch(currentUserProvider);
|
|
||||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
|
||||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||||
final serverInfo = ref.watch(serverInfoProvider);
|
|
||||||
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
|
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
|
||||||
|
|
||||||
final originalTheme = context.themeData;
|
final originalTheme = context.themeData;
|
||||||
|
|
||||||
final assets = [asset];
|
final assets = [asset];
|
||||||
final scope = ActionScope.from(context, ref);
|
final scope = ActionScope.from(context, ref);
|
||||||
|
final restore = RestoreAction(assets: assets, scope: scope);
|
||||||
|
final delete = DeleteAction(assets: assets, scope: scope);
|
||||||
|
final editImage = EditAssetAction(assets: assets, scope: scope);
|
||||||
final actions = <Widget>[
|
final actions = <Widget>[
|
||||||
ActionColumnButtonWidget(
|
if (restore.isVisible) ActionColumnButtonWidget(action: restore),
|
||||||
action: RestoreAction(assets: assets, scope: scope),
|
|
||||||
),
|
|
||||||
const ShareActionButton(source: ActionSource.viewer),
|
const ShareActionButton(source: ActionSource.viewer),
|
||||||
|
|
||||||
if (!isInLockedView) ...[
|
if (!isInLockedView) ...[
|
||||||
if (!isInTrash) ...[
|
if (!isInTrash) ...[
|
||||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||||
// edit sync was added in 2.6.0
|
if (editImage.isVisible) ActionColumnButtonWidget(action: editImage),
|
||||||
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
|
|
||||||
const EditImageActionButton(),
|
|
||||||
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
||||||
],
|
],
|
||||||
if (isOwner) ...[
|
|
||||||
if (asset.isLocalOnly)
|
if (delete.isVisible) ActionColumnButtonWidget(action: delete),
|
||||||
const DeleteLocalActionButton(source: ActionSource.viewer)
|
|
||||||
else if (asset.isTrashed)
|
|
||||||
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
|
|
||||||
else
|
|
||||||
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -7,19 +7,12 @@ import 'package:immich_mobile/presentation/actions/action.dart';
|
|||||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
|
||||||
@@ -48,7 +41,6 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final multiselect = ref.watch(multiSelectProvider);
|
final multiselect = ref.watch(multiSelectProvider);
|
||||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
|
||||||
|
|
||||||
Future<void> addToAlbum(RemoteAlbum album) async {
|
Future<void> addToAlbum(RemoteAlbum album) async {
|
||||||
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
|
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
|
||||||
@@ -89,17 +81,15 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
|||||||
...[
|
...[
|
||||||
actions.favorite,
|
actions.favorite,
|
||||||
actions.archive,
|
actions.archive,
|
||||||
|
actions.delete,
|
||||||
|
actions.cleanup,
|
||||||
actions.stack,
|
actions.stack,
|
||||||
|
actions.lock,
|
||||||
|
actions.editDateTime,
|
||||||
|
actions.editLocation,
|
||||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
||||||
isTrashEnable
|
|
||||||
? const TrashActionButton(source: ActionSource.timeline)
|
|
||||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
|
||||||
const EditDateTimeActionButton(source: ActionSource.timeline),
|
|
||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
|
||||||
],
|
],
|
||||||
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
|
||||||
],
|
],
|
||||||
slivers: [
|
slivers: [
|
||||||
const AddToAlbumHeader(),
|
const AddToAlbumHeader(),
|
||||||
|
|||||||
@@ -8,19 +8,12 @@ import 'package:immich_mobile/presentation/actions/action.dart';
|
|||||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
|
||||||
@@ -30,7 +23,6 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final multiselect = ref.watch(multiSelectProvider);
|
final multiselect = ref.watch(multiSelectProvider);
|
||||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
|
||||||
|
|
||||||
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
|
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
|
||||||
final selectedAssets = multiselect.selectedAssets;
|
final selectedAssets = multiselect.selectedAssets;
|
||||||
@@ -79,17 +71,15 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
|||||||
...[
|
...[
|
||||||
actions.favorite,
|
actions.favorite,
|
||||||
actions.archive,
|
actions.archive,
|
||||||
|
actions.delete,
|
||||||
|
actions.cleanup,
|
||||||
actions.stack,
|
actions.stack,
|
||||||
|
actions.lock,
|
||||||
|
actions.editDateTime,
|
||||||
|
actions.editLocation,
|
||||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
||||||
isTrashEnable
|
|
||||||
? const TrashActionButton(source: ActionSource.timeline)
|
|
||||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
|
||||||
const EditDateTimeActionButton(source: ActionSource.timeline),
|
|
||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
|
||||||
],
|
],
|
||||||
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
|
||||||
],
|
],
|
||||||
slivers: multiselect.hasRemote
|
slivers: multiselect.hasRemote
|
||||||
? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addAssetsToAlbum)]
|
? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addAssetsToAlbum)]
|
||||||
|
|||||||
@@ -8,22 +8,14 @@ import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
|||||||
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
|
||||||
@@ -52,7 +44,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final multiselect = ref.watch(multiSelectProvider);
|
final multiselect = ref.watch(multiSelectProvider);
|
||||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
|
||||||
final tagsEnabled = ref.watch(
|
final tagsEnabled = ref.watch(
|
||||||
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
|
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
|
||||||
);
|
);
|
||||||
@@ -94,23 +85,19 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
|||||||
actions.debug,
|
actions.debug,
|
||||||
actions.favorite,
|
actions.favorite,
|
||||||
actions.archive,
|
actions.archive,
|
||||||
|
actions.delete,
|
||||||
|
actions.cleanup,
|
||||||
actions.stack,
|
actions.stack,
|
||||||
|
actions.lock,
|
||||||
|
actions.editDateTime,
|
||||||
|
actions.editLocation,
|
||||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||||
const ShareActionButton(source: ActionSource.timeline),
|
const ShareActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.hasRemote) ...[
|
if (multiselect.hasRemote) ...[
|
||||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
||||||
isTrashEnable
|
|
||||||
? const TrashActionButton(source: ActionSource.timeline)
|
|
||||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
|
||||||
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
|
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
|
||||||
const EditDateTimeActionButton(source: ActionSource.timeline),
|
|
||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
|
||||||
if (multiselect.onlyLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
|
|
||||||
],
|
],
|
||||||
if (multiselect.onlyLocal || multiselect.hasMerged)
|
|
||||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
|
||||||
if (multiselect.onlyLocal) const UploadActionButton(source: ActionSource.timeline),
|
if (multiselect.onlyLocal) const UploadActionButton(source: ActionSource.timeline),
|
||||||
],
|
],
|
||||||
slivers: [
|
slivers: [
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ import 'package:flutter/material.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/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.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';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
|
||||||
class LocalAlbumBottomSheet extends ConsumerStatefulWidget {
|
class LocalAlbumBottomSheet extends ConsumerStatefulWidget {
|
||||||
@@ -59,15 +63,22 @@ class _LocalAlbumBottomSheetState extends ConsumerState<LocalAlbumBottomSheet> {
|
|||||||
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final scope = ActionScope.from(context, ref);
|
||||||
|
final assets = ref.watch(multiSelectProvider).selectedAssets.toList(growable: false);
|
||||||
|
final actions = AssetActions.from(scope, assets);
|
||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
controller: sheetController,
|
controller: sheetController,
|
||||||
initialChildSize: 0.25,
|
initialChildSize: 0.25,
|
||||||
maxChildSize: 0.85,
|
maxChildSize: 0.85,
|
||||||
shouldCloseOnMinExtent: false,
|
shouldCloseOnMinExtent: false,
|
||||||
actions: const [
|
actions: [
|
||||||
ShareActionButton(source: ActionSource.timeline),
|
const ShareActionButton(source: ActionSource.timeline),
|
||||||
DeleteLocalActionButton(source: ActionSource.timeline),
|
...[
|
||||||
UploadActionButton(source: ActionSource.timeline),
|
actions.delete,
|
||||||
|
actions.cleanup,
|
||||||
|
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||||
|
const UploadActionButton(source: ActionSource.timeline),
|
||||||
],
|
],
|
||||||
slivers: [
|
slivers: [
|
||||||
const AddToAlbumHeader(),
|
const AddToAlbumHeader(),
|
||||||
|
|||||||
+17
-7
@@ -1,26 +1,36 @@
|
|||||||
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/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/lock.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
|
|
||||||
class LockedFolderBottomSheet extends ConsumerWidget {
|
class LockedFolderBottomSheet extends ConsumerWidget {
|
||||||
const LockedFolderBottomSheet({super.key});
|
const LockedFolderBottomSheet({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return const BaseBottomSheet(
|
final scope = ActionScope.from(context, ref);
|
||||||
|
final assets = ref.watch(multiSelectProvider).selectedAssets.toList(growable: false);
|
||||||
|
final actions = AssetActions.from(scope, assets);
|
||||||
|
|
||||||
|
return BaseBottomSheet(
|
||||||
initialChildSize: 0.25,
|
initialChildSize: 0.25,
|
||||||
maxChildSize: 0.4,
|
maxChildSize: 0.4,
|
||||||
shouldCloseOnMinExtent: false,
|
shouldCloseOnMinExtent: false,
|
||||||
actions: [
|
actions: [
|
||||||
ShareActionButton(source: ActionSource.timeline),
|
const ShareActionButton(source: ActionSource.timeline),
|
||||||
DownloadActionButton(source: ActionSource.timeline),
|
const DownloadActionButton(source: ActionSource.timeline),
|
||||||
DeletePermanentActionButton(source: ActionSource.timeline),
|
...[
|
||||||
RemoveFromLockFolderActionButton(source: ActionSource.timeline),
|
actions.delete,
|
||||||
|
LockAction(assets: assets, scope: scope),
|
||||||
|
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-23
@@ -6,22 +6,15 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
|
|||||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/remove_from_album.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/set_album_cover.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
@@ -52,7 +45,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final multiselect = ref.watch(multiSelectProvider);
|
final multiselect = ref.watch(multiSelectProvider);
|
||||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
|
||||||
final ownsAlbum = ref.watch(currentUserProvider)?.id == widget.album.ownerId;
|
final ownsAlbum = ref.watch(currentUserProvider)?.id == widget.album.ownerId;
|
||||||
|
|
||||||
Future<void> addToAlbum(RemoteAlbum album) async {
|
Future<void> addToAlbum(RemoteAlbum album) async {
|
||||||
@@ -84,7 +76,8 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
|||||||
}
|
}
|
||||||
|
|
||||||
final scope = ActionScope.from(context, ref);
|
final scope = ActionScope.from(context, ref);
|
||||||
final actions = AssetActions.from(scope, multiselect.selectedAssets.toList(growable: false));
|
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||||
|
final actions = AssetActions.from(scope, assets);
|
||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
controller: sheetController,
|
controller: sheetController,
|
||||||
@@ -101,23 +94,28 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
|||||||
...[
|
...[
|
||||||
actions.favorite,
|
actions.favorite,
|
||||||
actions.archive,
|
actions.archive,
|
||||||
|
actions.delete,
|
||||||
|
actions.cleanup,
|
||||||
actions.stack,
|
actions.stack,
|
||||||
|
actions.lock,
|
||||||
|
actions.editDateTime,
|
||||||
|
actions.editLocation,
|
||||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||||
],
|
],
|
||||||
const DownloadActionButton(source: ActionSource.timeline),
|
const DownloadActionButton(source: ActionSource.timeline),
|
||||||
if (ownsAlbum) ...[
|
|
||||||
isTrashEnable
|
|
||||||
? const TrashActionButton(source: ActionSource.timeline)
|
|
||||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
|
||||||
const EditDateTimeActionButton(source: ActionSource.timeline),
|
|
||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
if (ownsAlbum)
|
||||||
if (ownsAlbum) RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
|
ActionColumnButtonWidget(
|
||||||
if (ownsAlbum && multiselect.selectedAssets.length == 1)
|
action: TimelineAction(
|
||||||
SetAlbumCoverActionButton(source: ActionSource.timeline, albumId: widget.album.id),
|
action: RemoveFromAlbumAction(assets: assets, albumId: widget.album.id, scope: scope),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (ownsAlbum)
|
||||||
|
ActionColumnButtonWidget(
|
||||||
|
action: TimelineAction(
|
||||||
|
action: SetAlbumCoverAction(assets: assets, albumId: widget.album.id, scope: scope),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
slivers: ownsAlbum
|
slivers: ownsAlbum
|
||||||
? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand)]
|
? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand)]
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
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/constants/enums.dart';
|
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/delete.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/restore.action.dart';
|
import 'package:immich_mobile/presentation/actions/restore.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
|
|
||||||
class TrashBottomBar extends ConsumerWidget {
|
class TrashBottomBar extends ConsumerWidget {
|
||||||
@@ -27,13 +26,9 @@ class TrashBottomBar extends ConsumerWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
const DeleteTrashActionButton(source: ActionSource.timeline),
|
DeleteAction(assets: assets, scope: scope),
|
||||||
ActionColumnButtonWidget(
|
RestoreAction(assets: assets, scope: scope),
|
||||||
action: TimelineAction(
|
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))).toList(growable: false),
|
||||||
action: RestoreAction(assets: assets, scope: scope),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,34 +1,24 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
|
||||||
import 'package:background_downloader/background_downloader.dart';
|
import 'package:background_downloader/background_downloader.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/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
|
||||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||||
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
|
||||||
import 'package:immich_mobile/services/action.service.dart';
|
import 'package:immich_mobile/services/action.service.dart';
|
||||||
import 'package:immich_mobile/services/download.service.dart';
|
import 'package:immich_mobile/services/download.service.dart';
|
||||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||||
import 'package:immich_mobile/utils/semver.dart';
|
|
||||||
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
|
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
|
||||||
|
|
||||||
final actionProvider = NotifierProvider<ActionNotifier, void>(ActionNotifier.new, dependencies: [multiSelectProvider]);
|
final actionProvider = NotifierProvider<ActionNotifier, void>(ActionNotifier.new, dependencies: [multiSelectProvider]);
|
||||||
|
|
||||||
@@ -49,7 +39,6 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
late ActionService _service;
|
late ActionService _service;
|
||||||
late ForegroundUploadService _foregroundUploadService;
|
late ForegroundUploadService _foregroundUploadService;
|
||||||
late DownloadService _downloadService;
|
late DownloadService _downloadService;
|
||||||
late AssetService _assetService;
|
|
||||||
|
|
||||||
ActionNotifier() : super();
|
ActionNotifier() : super();
|
||||||
|
|
||||||
@@ -57,7 +46,6 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
void build() {
|
void build() {
|
||||||
_foregroundUploadService = ref.watch(foregroundUploadServiceProvider);
|
_foregroundUploadService = ref.watch(foregroundUploadServiceProvider);
|
||||||
_service = ref.watch(actionServiceProvider);
|
_service = ref.watch(actionServiceProvider);
|
||||||
_assetService = ref.watch(assetServiceProvider);
|
|
||||||
_downloadService = ref.watch(downloadServiceProvider);
|
_downloadService = ref.watch(downloadServiceProvider);
|
||||||
_downloadService.onImageDownloadStatus = _downloadImageCallback;
|
_downloadService.onImageDownloadStatus = _downloadImageCallback;
|
||||||
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
|
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
|
||||||
@@ -110,21 +98,6 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
return _getAssets(source).whereType<RemoteAsset>().ownedAssets(ownerId).toIds().toList(growable: false);
|
return _getAssets(source).whereType<RemoteAsset>().ownedAssets(ownerId).toIds().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,
|
||||||
@@ -135,16 +108,6 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ActionResult> troubleshoot(ActionSource source, BuildContext context) async {
|
|
||||||
final assets = _getAssets(source);
|
|
||||||
if (assets.length > 1) {
|
|
||||||
return ActionResult(count: assets.length, success: false, error: 'Cannot troubleshoot multiple assets');
|
|
||||||
}
|
|
||||||
unawaited(context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
|
|
||||||
|
|
||||||
return ActionResult(count: assets.length, success: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<ActionResult> shareLink(ActionSource source, BuildContext context) async {
|
Future<ActionResult> shareLink(ActionSource source, BuildContext context) async {
|
||||||
final ids = _getRemoteIdsForSource(source);
|
final ids = _getRemoteIdsForSource(source);
|
||||||
try {
|
try {
|
||||||
@@ -156,41 +119,6 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ActionResult> moveToLockFolder(ActionSource source) async {
|
|
||||||
final ids = _getOwnedRemoteIdsForSource(source);
|
|
||||||
final localIds = _getLocalIdsForSource(source, ignoreLocalOnly: true);
|
|
||||||
try {
|
|
||||||
await _service.moveToLockFolder(ids, localIds);
|
|
||||||
return ActionResult(count: ids.length, success: true);
|
|
||||||
} catch (error, stack) {
|
|
||||||
_logger.severe('Failed to move assets to lock folder', error, stack);
|
|
||||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<ActionResult> removeFromLockFolder(ActionSource source) async {
|
|
||||||
final ids = _getOwnedRemoteIdsForSource(source);
|
|
||||||
try {
|
|
||||||
await _service.removeFromLockFolder(ids);
|
|
||||||
return ActionResult(count: ids.length, success: true);
|
|
||||||
} catch (error, stack) {
|
|
||||||
_logger.severe('Failed to remove assets from lock folder', error, stack);
|
|
||||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<ActionResult> trash(ActionSource source) async {
|
|
||||||
final ids = _getOwnedRemoteIdsForSource(source);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _service.trash(ids);
|
|
||||||
return ActionResult(count: ids.length, success: true);
|
|
||||||
} catch (error, stack) {
|
|
||||||
_logger.severe('Failed to trash assets', error, stack);
|
|
||||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<ActionResult> emptyTrash(String userId) async {
|
Future<ActionResult> emptyTrash(String userId) async {
|
||||||
try {
|
try {
|
||||||
final count = await _service.emptyTrash(userId);
|
final count = await _service.emptyTrash(userId);
|
||||||
@@ -265,50 +193,6 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ActionResult?> editLocation(ActionSource source, BuildContext context) async {
|
|
||||||
final ids = _getOwnedRemoteIdsForSource(source);
|
|
||||||
try {
|
|
||||||
final isEdited = await _service.editLocation(ids, context);
|
|
||||||
if (!isEdited) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This must be called since editing location
|
|
||||||
// does not update the currentAsset which means
|
|
||||||
// the exif provider will not be refreshed automatically
|
|
||||||
if (source == ActionSource.viewer) {
|
|
||||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
|
||||||
if (currentAsset != null) {
|
|
||||||
ref.invalidate(assetExifProvider(currentAsset));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ActionResult(count: ids.length, success: true);
|
|
||||||
} catch (error, stack) {
|
|
||||||
_logger.severe('Failed to edit location for assets', error, stack);
|
|
||||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<ActionResult?> editDateTime(ActionSource source, BuildContext context) async {
|
|
||||||
final ids = _getOwnedRemoteIdsForSource(source);
|
|
||||||
try {
|
|
||||||
final isEdited = await _service.editDateTime(ids, context);
|
|
||||||
if (!isEdited) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source == ActionSource.viewer) {
|
|
||||||
ref.invalidate(assetExifProvider);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ActionResult(count: ids.length, success: true);
|
|
||||||
} catch (error, stack) {
|
|
||||||
_logger.severe('Failed to edit date and time for assets', error, stack);
|
|
||||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<ActionResult?> tagAssets(ActionSource source, BuildContext context) async {
|
Future<ActionResult?> tagAssets(ActionSource source, BuildContext context) async {
|
||||||
final ids = _getOwnedRemoteIdsForSource(source);
|
final ids = _getOwnedRemoteIdsForSource(source);
|
||||||
try {
|
try {
|
||||||
@@ -372,33 +256,6 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ActionResult> removeFromAlbum(ActionSource source, String albumId) async {
|
|
||||||
final ids = _getRemoteIdsForSource(source);
|
|
||||||
try {
|
|
||||||
final removedCount = await _service.removeFromAlbum(ids, albumId);
|
|
||||||
return ActionResult(count: removedCount, success: true);
|
|
||||||
} catch (error, stack) {
|
|
||||||
_logger.severe('Failed to remove assets from album', error, stack);
|
|
||||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<ActionResult> setAlbumCover(ActionSource source, String albumId) async {
|
|
||||||
final assets = _getAssets(source);
|
|
||||||
final asset = assets.first;
|
|
||||||
if (asset is! RemoteAsset) {
|
|
||||||
return const ActionResult(count: 1, success: false, error: 'Asset must be remote');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _service.setAlbumCover(albumId, asset.id);
|
|
||||||
return const ActionResult(count: 1, success: true);
|
|
||||||
} catch (error, stack) {
|
|
||||||
_logger.severe('Failed to set album cover', error, stack);
|
|
||||||
return ActionResult(count: 1, success: false, error: error.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<ActionResult> updateDescription(ActionSource source, String description) async {
|
Future<ActionResult> updateDescription(ActionSource source, String description) async {
|
||||||
final ids = _getRemoteIdsForSource(source);
|
final ids = _getRemoteIdsForSource(source);
|
||||||
if (ids.length != 1) {
|
if (ids.length != 1) {
|
||||||
@@ -431,35 +288,6 @@ 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());
|
|
||||||
if (source == ActionSource.viewer) {
|
|
||||||
final updatedParent = await _assetService.getRemoteAsset(assets.first.id);
|
|
||||||
if (updatedParent != null) {
|
|
||||||
ref.read(assetViewerProvider.notifier).setAsset(updatedParent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
Future<ActionResult> shareAssets(
|
||||||
ActionSource source,
|
ActionSource source,
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
@@ -576,37 +404,6 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ActionResult> applyEdits(ActionSource source, List<AssetEdit> edits) async {
|
|
||||||
final ids = _getOwnedRemoteIdsForSource(source);
|
|
||||||
|
|
||||||
if (ids.length != 1) {
|
|
||||||
_logger.warning('applyEdits called with multiple assets, expected single asset');
|
|
||||||
return ActionResult(count: ids.length, success: false, error: 'Expected single asset for applying edits');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> editReady;
|
|
||||||
if (ref.read(serverInfoProvider).serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) {
|
|
||||||
editReady = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV2", (dynamic data) {
|
|
||||||
final eventAsset = SyncAssetV2.fromJson(data["asset"]);
|
|
||||||
return eventAsset?.id == ids.first;
|
|
||||||
}, const Duration(seconds: 10));
|
|
||||||
} else {
|
|
||||||
editReady = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV1", (dynamic data) {
|
|
||||||
final eventAsset = SyncAssetV1.fromJson(data["asset"]);
|
|
||||||
return eventAsset?.id == ids.first;
|
|
||||||
}, const Duration(seconds: 10));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _service.applyEdits(ids.first, edits);
|
|
||||||
await editReady;
|
|
||||||
return const ActionResult(count: 1, success: true);
|
|
||||||
} catch (error, stack) {
|
|
||||||
_logger.severe('Failed to apply edits to assets', error, stack);
|
|
||||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on Iterable<RemoteAsset> {
|
extension on Iterable<RemoteAsset> {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
@@ -15,6 +16,10 @@ final remoteAssetRepositoryProvider = Provider<RemoteAssetRepository>(
|
|||||||
(ref) => RemoteAssetRepository(ref.watch(driftProvider)),
|
(ref) => RemoteAssetRepository(ref.watch(driftProvider)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final remoteExifRepositoryProvider = Provider<RemoteExifRepository>(
|
||||||
|
(ref) => RemoteExifRepository(ref.watch(driftProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
final trashedLocalAssetRepository = Provider<DriftTrashedLocalAssetRepository>(
|
final trashedLocalAssetRepository = Provider<DriftTrashedLocalAssetRepository>(
|
||||||
(ref) => DriftTrashedLocalAssetRepository(ref.watch(driftProvider)),
|
(ref) => DriftTrashedLocalAssetRepository(ref.watch(driftProvider)),
|
||||||
);
|
);
|
||||||
@@ -22,6 +27,7 @@ final trashedLocalAssetRepository = Provider<DriftTrashedLocalAssetRepository>(
|
|||||||
final assetServiceProvider = Provider(
|
final assetServiceProvider = Provider(
|
||||||
(ref) => AssetService(
|
(ref) => AssetService(
|
||||||
remoteRepository: ref.watch(remoteAssetRepositoryProvider),
|
remoteRepository: ref.watch(remoteAssetRepositoryProvider),
|
||||||
|
exifRepository: ref.watch(remoteExifRepositoryProvider),
|
||||||
localRepository: ref.watch(localAssetRepository),
|
localRepository: ref.watch(localAssetRepository),
|
||||||
apiRepository: ref.watch(assetApiRepositoryProvider),
|
apiRepository: ref.watch(assetApiRepositoryProvider),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -47,20 +47,6 @@ class AssetApiRepository extends ApiRepository {
|
|||||||
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility))));
|
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility))));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateLocation(List<String> ids, LatLng location) async {
|
|
||||||
return _api.updateAssets(
|
|
||||||
AssetBulkUpdateDto(
|
|
||||||
ids: ids,
|
|
||||||
latitude: Optional.present(location.latitude),
|
|
||||||
longitude: Optional.present(location.longitude),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> updateDateTime(List<String> ids, String dateTime) async {
|
|
||||||
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, dateTimeOriginal: Optional.present(dateTime)));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<StackResponse> stack(List<String> ids) async {
|
Future<StackResponse> stack(List<String> ids) async {
|
||||||
final responseDto = await checkNull(_stacksApi.createStack(StackCreateDto(assetIds: ids)));
|
final responseDto = await checkNull(_stacksApi.createStack(StackCreateDto(assetIds: ids)));
|
||||||
|
|
||||||
@@ -109,12 +95,17 @@ class AssetApiRepository extends ApiRepository {
|
|||||||
List<String> remoteIds, {
|
List<String> remoteIds, {
|
||||||
Option<bool> isFavorite = const .none(),
|
Option<bool> isFavorite = const .none(),
|
||||||
Option<AssetVisibility> visibility = const .none(),
|
Option<AssetVisibility> visibility = const .none(),
|
||||||
|
Option<String> dateTimeOriginal = const .none(),
|
||||||
|
Option<LatLng> location = const .none(),
|
||||||
}) {
|
}) {
|
||||||
return _api.updateAssets(
|
return _api.updateAssets(
|
||||||
AssetBulkUpdateDto(
|
AssetBulkUpdateDto(
|
||||||
ids: remoteIds,
|
ids: remoteIds,
|
||||||
isFavorite: isFavorite.toOptional(),
|
isFavorite: isFavorite.toOptional(),
|
||||||
visibility: visibility.map(_mapVisibility).toOptional(),
|
visibility: visibility.map(_mapVisibility).toOptional(),
|
||||||
|
dateTimeOriginal: dateTimeOriginal.toOptional(),
|
||||||
|
latitude: location.map((loc) => loc.latitude).toOptional(),
|
||||||
|
longitude: location.map((loc) => loc.longitude).toOptional(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.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/asset_edit.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
@@ -21,11 +20,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
|||||||
import 'package:immich_mobile/repositories/download.repository.dart';
|
import 'package:immich_mobile/repositories/download.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/utils/timezone.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/location_picker.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/tag_picker.dart';
|
import 'package:immich_mobile/widgets/common/tag_picker.dart';
|
||||||
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
|
|
||||||
|
|
||||||
final actionServiceProvider = Provider<ActionService>(
|
final actionServiceProvider = Provider<ActionService>(
|
||||||
(ref) => ActionService(
|
(ref) => ActionService(
|
||||||
@@ -83,11 +78,6 @@ class ActionService {
|
|||||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
|
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> trash(List<String> remoteIds) async {
|
|
||||||
await _assetApiRepository.delete(remoteIds, false);
|
|
||||||
await _remoteAssetRepository.trash(remoteIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<int> emptyTrash(String userId) async {
|
Future<int> emptyTrash(String userId) async {
|
||||||
final count = await _assetApiRepository.emptyTrash();
|
final count = await _assetApiRepository.emptyTrash();
|
||||||
await _remoteAssetRepository.emptyTrash(userId);
|
await _remoteAssetRepository.emptyTrash(userId);
|
||||||
@@ -122,91 +112,6 @@ class ActionService {
|
|||||||
return await _deleteLocalAssets(localIds);
|
return await _deleteLocalAssets(localIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> editLocation(List<String> remoteIds, BuildContext context) async {
|
|
||||||
maplibre.LatLng? initialLatLng;
|
|
||||||
if (remoteIds.length == 1) {
|
|
||||||
final exif = await _remoteAssetRepository.getExif(remoteIds[0]);
|
|
||||||
|
|
||||||
if (exif?.latitude != null && exif?.longitude != null) {
|
|
||||||
initialLatLng = maplibre.LatLng(exif!.latitude!, exif.longitude!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final location = await showLocationPicker(context: context, initialLatLng: initialLatLng);
|
|
||||||
|
|
||||||
if (location == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _assetApiRepository.updateLocation(remoteIds, location);
|
|
||||||
await _remoteAssetRepository.updateLocation(remoteIds, location);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> editDateTime(List<String> remoteIds, BuildContext context) async {
|
|
||||||
DateTime? initialDate;
|
|
||||||
String? timeZone;
|
|
||||||
Duration? offset;
|
|
||||||
|
|
||||||
if (remoteIds.length == 1) {
|
|
||||||
final assetId = remoteIds.first;
|
|
||||||
final asset = await _remoteAssetRepository.get(assetId);
|
|
||||||
if (asset == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
final exifData = await _remoteAssetRepository.getExif(assetId);
|
|
||||||
|
|
||||||
// Use EXIF timezone information if available (matching web app and display behavior)
|
|
||||||
DateTime dt = asset.createdAt.toLocal();
|
|
||||||
offset = dt.timeZoneOffset;
|
|
||||||
|
|
||||||
if (exifData?.dateTimeOriginal != null) {
|
|
||||||
timeZone = exifData!.timeZone;
|
|
||||||
(dt, offset) = applyTimezoneOffset(dateTime: exifData.dateTimeOriginal!, timeZone: exifData.timeZone);
|
|
||||||
}
|
|
||||||
|
|
||||||
initialDate = dt;
|
|
||||||
}
|
|
||||||
|
|
||||||
final dateTime = await showDateTimePicker(
|
|
||||||
context: context,
|
|
||||||
initialDateTime: initialDate,
|
|
||||||
initialTZ: timeZone,
|
|
||||||
initialTZOffset: offset,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (dateTime == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
await applyDateTime(remoteIds, dateTime);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@visibleForTesting
|
|
||||||
Future<void> applyDateTime(List<String> remoteIds, String dateTime) async {
|
|
||||||
final parsedDateTime = DateTime.parse(dateTime);
|
|
||||||
final offset = RegExp(r'[+-]\d{2}:\d{2}$').firstMatch(dateTime)?.group(0);
|
|
||||||
|
|
||||||
await _assetApiRepository.updateDateTime(remoteIds, dateTime);
|
|
||||||
await _remoteAssetRepository.updateDateTime(
|
|
||||||
remoteIds,
|
|
||||||
parsedDateTime,
|
|
||||||
timeZone: offset == null ? null : 'UTC$offset',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<int> removeFromAlbum(List<String> remoteIds, String albumId) async {
|
|
||||||
final result = await _albumApiRepository.removeAssets(albumId, remoteIds);
|
|
||||||
if (result.removed.isNotEmpty) {
|
|
||||||
await _remoteAlbumRepository.removeAssets(albumId, result.removed);
|
|
||||||
}
|
|
||||||
return result.removed.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> updateDescription(String assetId, String description) async {
|
Future<bool> updateDescription(String assetId, String description) async {
|
||||||
// update remote first, then local to ensure consistency
|
// update remote first, then local to ensure consistency
|
||||||
await _assetApiRepository.updateDescription(assetId, description);
|
await _assetApiRepository.updateDescription(assetId, description);
|
||||||
@@ -280,14 +185,6 @@ class ActionService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> applyEdits(String remoteId, List<AssetEdit> edits) async {
|
|
||||||
if (edits.isEmpty) {
|
|
||||||
await _assetApiRepository.removeEdits(remoteId);
|
|
||||||
} else {
|
|
||||||
await _assetApiRepository.editAsset(remoteId, edits);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<int> _deleteLocalAssets(List<String> localIds) async {
|
Future<int> _deleteLocalAssets(List<String> localIds) async {
|
||||||
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
|
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
|
||||||
if (deletedIds.isEmpty) {
|
if (deletedIds.isEmpty) {
|
||||||
|
|||||||
@@ -12,26 +12,22 @@ import 'package:immich_mobile/presentation/actions/action.dart';
|
|||||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/archive.action.dart';
|
import 'package:immich_mobile/presentation/actions/archive.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/cast.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/delete.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/lock.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/open_in_browser.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/remove_from_album.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/restore.action.dart';
|
import 'package:immich_mobile/presentation/actions/restore.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/set_album_cover.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/set_profile_picture.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/similar_photos.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/slideshow.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/stack.action.dart';
|
import 'package:immich_mobile/presentation/actions/stack.action.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/presentation/widgets/action_buttons/cast_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
@@ -86,9 +82,7 @@ enum ActionButtonType {
|
|||||||
removeFromLockFolder,
|
removeFromLockFolder,
|
||||||
removeFromAlbum,
|
removeFromAlbum,
|
||||||
restoreTrash,
|
restoreTrash,
|
||||||
trash,
|
|
||||||
deleteLocal,
|
deleteLocal,
|
||||||
deletePermanent,
|
|
||||||
delete,
|
delete,
|
||||||
advancedInfo;
|
advancedInfo;
|
||||||
|
|
||||||
@@ -113,25 +107,12 @@ enum ActionButtonType {
|
|||||||
!context.isInLockedView && //
|
!context.isInLockedView && //
|
||||||
context.asset.hasRemote && //
|
context.asset.hasRemote && //
|
||||||
!context.asset.hasLocal,
|
!context.asset.hasLocal,
|
||||||
ActionButtonType.trash =>
|
|
||||||
context.isOwner && //
|
|
||||||
!context.isInLockedView && //
|
|
||||||
context.asset.hasRemote && //
|
|
||||||
context.isTrashEnabled && //
|
|
||||||
context.timelineOrigin != TimelineOrigin.trash,
|
|
||||||
ActionButtonType.restoreTrash =>
|
ActionButtonType.restoreTrash =>
|
||||||
context.isOwner && //
|
context.isOwner && //
|
||||||
!context.isInLockedView && //
|
!context.isInLockedView && //
|
||||||
context.asset.hasRemote && //
|
context.asset.hasRemote && //
|
||||||
context.timelineOrigin == TimelineOrigin.trash,
|
context.timelineOrigin == TimelineOrigin.trash,
|
||||||
ActionButtonType.deletePermanent =>
|
ActionButtonType.delete => true, //
|
||||||
context.isOwner && //
|
|
||||||
context.asset.hasRemote && //
|
|
||||||
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView),
|
|
||||||
ActionButtonType.delete =>
|
|
||||||
context.isOwner && //
|
|
||||||
!context.isInLockedView && //
|
|
||||||
context.asset.hasRemote,
|
|
||||||
ActionButtonType.moveToLockFolder =>
|
ActionButtonType.moveToLockFolder =>
|
||||||
context.isOwner && //
|
context.isOwner && //
|
||||||
!context.isInLockedView && //
|
!context.isInLockedView && //
|
||||||
@@ -142,7 +123,7 @@ enum ActionButtonType {
|
|||||||
context.asset.hasRemote,
|
context.asset.hasRemote,
|
||||||
ActionButtonType.deleteLocal =>
|
ActionButtonType.deleteLocal =>
|
||||||
!context.isInLockedView && //
|
!context.isInLockedView && //
|
||||||
context.asset.hasLocal,
|
context.asset.isMerged,
|
||||||
ActionButtonType.upload =>
|
ActionButtonType.upload =>
|
||||||
!context.isInLockedView && //
|
!context.isInLockedView && //
|
||||||
context.asset.storage == AssetState.local,
|
context.asset.storage == AssetState.local,
|
||||||
@@ -205,68 +186,45 @@ enum ActionButtonType {
|
|||||||
iconOnly: iconOnly,
|
iconOnly: iconOnly,
|
||||||
menuItem: menuItem,
|
menuItem: menuItem,
|
||||||
),
|
),
|
||||||
ActionButtonType.slideshow => SlideshowActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
ActionButtonType.slideshow => ActionMenuItemWidget(action: SlideshowAction(scope: scope)),
|
||||||
ActionButtonType.archive || ActionButtonType.unarchive => ActionMenuItemWidget(
|
ActionButtonType.archive || ActionButtonType.unarchive => ActionMenuItemWidget(
|
||||||
action: ArchiveAction(assets: [context.asset], scope: scope),
|
action: ArchiveAction(assets: [context.asset], scope: scope),
|
||||||
),
|
),
|
||||||
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||||
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
|
||||||
ActionButtonType.restoreTrash => ActionMenuItemWidget(
|
ActionButtonType.restoreTrash => ActionMenuItemWidget(
|
||||||
action: RestoreAction(assets: [context.asset], scope: scope),
|
action: RestoreAction(assets: [context.asset], scope: scope),
|
||||||
),
|
),
|
||||||
ActionButtonType.deletePermanent => DeletePermanentActionButton(
|
ActionButtonType.delete => ActionMenuItemWidget(
|
||||||
source: context.source,
|
action: DeleteAction(assets: [context.asset], scope: scope),
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
),
|
),
|
||||||
ActionButtonType.delete => DeleteActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
ActionButtonType.moveToLockFolder => ActionMenuItemWidget(
|
||||||
ActionButtonType.moveToLockFolder => MoveToLockFolderActionButton(
|
action: LockAction(assets: [context.asset], scope: scope),
|
||||||
source: context.source,
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
),
|
),
|
||||||
ActionButtonType.removeFromLockFolder => RemoveFromLockFolderActionButton(
|
ActionButtonType.removeFromLockFolder => ActionMenuItemWidget(
|
||||||
source: context.source,
|
action: LockAction(assets: [context.asset], scope: scope),
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
),
|
),
|
||||||
ActionButtonType.deleteLocal => DeleteLocalActionButton(
|
ActionButtonType.deleteLocal => ActionMenuItemWidget(
|
||||||
source: context.source,
|
action: CleanupLocalAction(assets: [context.asset], scope: scope),
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
),
|
),
|
||||||
ActionButtonType.upload => UploadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
ActionButtonType.upload => UploadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||||
ActionButtonType.removeFromAlbum => RemoveFromAlbumActionButton(
|
ActionButtonType.removeFromAlbum => ActionMenuItemWidget(
|
||||||
albumId: context.currentAlbum!.id,
|
action: RemoveFromAlbumAction(assets: [context.asset], albumId: context.currentAlbum!.id, scope: scope),
|
||||||
source: context.source,
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
),
|
),
|
||||||
ActionButtonType.setAlbumCover => SetAlbumCoverActionButton(
|
ActionButtonType.setAlbumCover => ActionMenuItemWidget(
|
||||||
albumId: context.currentAlbum!.id,
|
action: SetAlbumCoverAction(assets: [context.asset], albumId: context.currentAlbum!.id, scope: scope),
|
||||||
source: context.source,
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
),
|
),
|
||||||
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
||||||
ActionButtonType.unstack => ActionMenuItemWidget(
|
ActionButtonType.unstack => ActionMenuItemWidget(
|
||||||
action: StackAction(assets: [context.asset], scope: scope),
|
action: StackAction(assets: [context.asset], scope: scope),
|
||||||
),
|
),
|
||||||
ActionButtonType.openInBrowser => OpenInBrowserActionButton(
|
ActionButtonType.openInBrowser => ActionMenuItemWidget(
|
||||||
remoteId: context.asset.remoteId!,
|
action: OpenInBrowserAction(remoteId: context.asset.remoteId!, origin: context.timelineOrigin, scope: scope),
|
||||||
origin: context.timelineOrigin,
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
),
|
),
|
||||||
ActionButtonType.similarPhotos => SimilarPhotosActionButton(
|
ActionButtonType.similarPhotos => ActionMenuItemWidget(
|
||||||
assetId: (context.asset as RemoteAsset).id,
|
action: SimilarPhotosAction(assetId: (context.asset as RemoteAsset).id, scope: scope),
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
),
|
),
|
||||||
ActionButtonType.setProfilePicture => SetProfilePictureActionButton(
|
ActionButtonType.setProfilePicture => ActionMenuItemWidget(
|
||||||
asset: context.asset,
|
action: SetProfilePictureAction(asset: context.asset, scope: scope),
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
),
|
),
|
||||||
ActionButtonType.openInfo => BaseActionButton(
|
ActionButtonType.openInfo => BaseActionButton(
|
||||||
label: 'info'.tr(),
|
label: 'info'.tr(),
|
||||||
@@ -284,7 +242,7 @@ enum ActionButtonType {
|
|||||||
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
|
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ActionButtonType.cast => CastActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
ActionButtonType.cast => ActionMenuItemWidget(action: CastAction(scope: scope)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,8 +253,6 @@ enum ActionButtonType {
|
|||||||
// 0: info
|
// 0: info
|
||||||
ActionButtonType.openInfo => 0,
|
ActionButtonType.openInfo => 0,
|
||||||
// 10: move, remove, and delete
|
// 10: move, remove, and delete
|
||||||
ActionButtonType.trash => 10,
|
|
||||||
ActionButtonType.deletePermanent => 10,
|
|
||||||
ActionButtonType.removeFromLockFolder => 10,
|
ActionButtonType.removeFromLockFolder => 10,
|
||||||
ActionButtonType.removeFromAlbum => 10,
|
ActionButtonType.removeFromAlbum => 10,
|
||||||
ActionButtonType.unstack => 10,
|
ActionButtonType.unstack => 10,
|
||||||
@@ -324,7 +280,6 @@ class ActionButtonBuilder {
|
|||||||
ActionButtonType.archive,
|
ActionButtonType.archive,
|
||||||
ActionButtonType.unarchive,
|
ActionButtonType.unarchive,
|
||||||
ActionButtonType.restoreTrash,
|
ActionButtonType.restoreTrash,
|
||||||
ActionButtonType.deletePermanent,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static List<Widget> build(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
|
static List<Widget> build(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ extension type const AssetFilter<T extends BaseAsset>(Iterable<T> assets) implem
|
|||||||
remote().where((asset) => asset.visibility != visibility);
|
remote().where((asset) => asset.visibility != visibility);
|
||||||
AssetFilter<RemoteAsset> archived({bool isArchived = true}) =>
|
AssetFilter<RemoteAsset> archived({bool isArchived = true}) =>
|
||||||
remote().where((asset) => asset.isArchived == isArchived);
|
remote().where((asset) => asset.isArchived == isArchived);
|
||||||
|
AssetFilter<RemoteAsset> locked({bool isLocked = true}) => remote().where((asset) => asset.isLocked == isLocked);
|
||||||
AssetFilter<RemoteAsset> stacked({bool isStacked = true}) => remote().where((asset) => asset.isStacked == isStacked);
|
AssetFilter<RemoteAsset> stacked({bool isStacked = true}) => remote().where((asset) => asset.isStacked == isStacked);
|
||||||
AssetFilter<RemoteAsset> trashed({bool isTrashed = true}) => remote().where((asset) => asset.isTrashed == isTrashed);
|
AssetFilter<RemoteAsset> trashed({bool isTrashed = true}) => remote().where((asset) => asset.isTrashed == isTrashed);
|
||||||
|
|
||||||
AssetFilter<LocalAsset> local() => AssetFilter(assets.whereType<LocalAsset>());
|
AssetFilter<LocalAsset> local() => AssetFilter(assets.whereType<LocalAsset>());
|
||||||
AssetFilter<LocalAsset> backedUp() => local().where((asset) => asset.remoteAssetId != null);
|
AssetFilter<BaseAsset> backedUp() => where((asset) => asset.isMerged);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class ImmichColumnButton extends StatefulWidget {
|
|||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final FutureOr<void> Function() onPressed;
|
final FutureOr<void> Function() onPressed;
|
||||||
|
final FutureOr<void> Function()? onLongPress;
|
||||||
final bool disabled;
|
final bool disabled;
|
||||||
final bool? loading;
|
final bool? loading;
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ class ImmichColumnButton extends StatefulWidget {
|
|||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.onPressed,
|
required this.onPressed,
|
||||||
|
this.onLongPress,
|
||||||
this.disabled = false,
|
this.disabled = false,
|
||||||
this.loading,
|
this.loading,
|
||||||
});
|
});
|
||||||
@@ -28,10 +30,10 @@ class _ImmichColumnButtonState extends State<ImmichColumnButton> {
|
|||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
bool get _isLoading => widget.loading ?? _loading;
|
bool get _isLoading => widget.loading ?? _loading;
|
||||||
|
|
||||||
Future<void> _onPressed() async {
|
Future<void> _run(FutureOr<void> Function() action) async {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
await widget.onPressed();
|
await action();
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _loading = false);
|
setState(() => _loading = false);
|
||||||
@@ -42,9 +44,12 @@ class _ImmichColumnButtonState extends State<ImmichColumnButton> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final foreground = context.colorOverride ?? Theme.of(context).colorScheme.onSurface;
|
final foreground = context.colorOverride ?? Theme.of(context).colorScheme.onSurface;
|
||||||
|
final handlerDisabled = widget.disabled || _isLoading;
|
||||||
|
final onLongPress = widget.onLongPress;
|
||||||
|
|
||||||
return TextButton(
|
return TextButton(
|
||||||
onPressed: widget.disabled || _isLoading ? null : _onPressed,
|
onPressed: handlerDisabled ? null : () => _run(widget.onPressed),
|
||||||
|
onLongPress: handlerDisabled || onLongPress == null ? null : () => _run(onLongPress),
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: foreground,
|
foregroundColor: foreground,
|
||||||
padding: const .symmetric(horizontal: ImmichSpacing.sm, vertical: ImmichSpacing.md),
|
padding: const .symmetric(horizontal: ImmichSpacing.sm, vertical: ImmichSpacing.md),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:immich_ui/src/internal.dart';
|
|||||||
class ImmichIconButton extends StatefulWidget {
|
class ImmichIconButton extends StatefulWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final FutureOr<void> Function() onPressed;
|
final FutureOr<void> Function() onPressed;
|
||||||
|
final FutureOr<void> Function()? onLongPress;
|
||||||
final ImmichVariant variant;
|
final ImmichVariant variant;
|
||||||
final ImmichColor color;
|
final ImmichColor color;
|
||||||
final bool disabled;
|
final bool disabled;
|
||||||
@@ -16,6 +17,7 @@ class ImmichIconButton extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.onPressed,
|
required this.onPressed,
|
||||||
|
this.onLongPress,
|
||||||
this.color = .primary,
|
this.color = .primary,
|
||||||
this.variant = .filled,
|
this.variant = .filled,
|
||||||
this.disabled = false,
|
this.disabled = false,
|
||||||
@@ -30,10 +32,10 @@ class _ImmichIconButtonState extends State<ImmichIconButton> {
|
|||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
bool get _isLoading => widget.loading ?? _loading;
|
bool get _isLoading => widget.loading ?? _loading;
|
||||||
|
|
||||||
Future<void> _onPressed() async {
|
Future<void> _run(FutureOr<void> Function() action) async {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
await widget.onPressed();
|
await action();
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _loading = false);
|
setState(() => _loading = false);
|
||||||
@@ -66,6 +68,9 @@ class _ImmichIconButtonState extends State<ImmichIconButton> {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
final handlerDisabled = widget.disabled || _isLoading;
|
||||||
|
final onLongPress = widget.onLongPress;
|
||||||
|
|
||||||
return IconButton(
|
return IconButton(
|
||||||
icon: _isLoading
|
icon: _isLoading
|
||||||
? const SizedBox.square(
|
? const SizedBox.square(
|
||||||
@@ -73,7 +78,8 @@ class _ImmichIconButtonState extends State<ImmichIconButton> {
|
|||||||
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.md),
|
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.md),
|
||||||
)
|
)
|
||||||
: Icon(widget.icon),
|
: Icon(widget.icon),
|
||||||
onPressed: widget.disabled || _isLoading ? null : _onPressed,
|
onPressed: handlerDisabled ? null : () => _run(widget.onPressed),
|
||||||
|
onLongPress: handlerDisabled || onLongPress == null ? null : () => _run(onLongPress),
|
||||||
style: IconButton.styleFrom(backgroundColor: background, foregroundColor: foreground),
|
style: IconButton.styleFrom(backgroundColor: background, foregroundColor: foreground),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class ImmichTextButton extends StatefulWidget {
|
|||||||
final String labelText;
|
final String labelText;
|
||||||
final IconData? icon;
|
final IconData? icon;
|
||||||
final FutureOr<void> Function() onPressed;
|
final FutureOr<void> Function() onPressed;
|
||||||
|
final FutureOr<void> Function()? onLongPress;
|
||||||
final ImmichVariant variant;
|
final ImmichVariant variant;
|
||||||
final bool expanded;
|
final bool expanded;
|
||||||
final bool disabled;
|
final bool disabled;
|
||||||
@@ -17,6 +18,7 @@ class ImmichTextButton extends StatefulWidget {
|
|||||||
required this.labelText,
|
required this.labelText,
|
||||||
this.icon,
|
this.icon,
|
||||||
required this.onPressed,
|
required this.onPressed,
|
||||||
|
this.onLongPress,
|
||||||
this.variant = .filled,
|
this.variant = .filled,
|
||||||
this.expanded = true,
|
this.expanded = true,
|
||||||
|
|
||||||
@@ -32,10 +34,10 @@ class _ImmichTextButtonState extends State<ImmichTextButton> {
|
|||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
bool get _isLoading => widget.loading ?? _loading;
|
bool get _isLoading => widget.loading ?? _loading;
|
||||||
|
|
||||||
Future<void> _onPressed() async {
|
Future<void> _run(FutureOr<void> Function() action) async {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
await widget.onPressed();
|
await action();
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _loading = false);
|
setState(() => _loading = false);
|
||||||
@@ -59,11 +61,26 @@ class _ImmichTextButtonState extends State<ImmichTextButton> {
|
|||||||
style: const .new(fontSize: ImmichTextSize.body, fontWeight: .bold),
|
style: const .new(fontSize: ImmichTextSize.body, fontWeight: .bold),
|
||||||
);
|
);
|
||||||
final style = ElevatedButton.styleFrom(padding: const .symmetric(vertical: ImmichSpacing.md));
|
final style = ElevatedButton.styleFrom(padding: const .symmetric(vertical: ImmichSpacing.md));
|
||||||
final onPressed = widget.disabled || _isLoading ? null : _onPressed;
|
final handlerDisabled = widget.disabled || _isLoading;
|
||||||
|
final longPress = widget.onLongPress;
|
||||||
|
final onPressed = handlerDisabled ? null : () => _run(widget.onPressed);
|
||||||
|
final onLongPress = handlerDisabled || longPress == null ? null : () => _run(longPress);
|
||||||
|
|
||||||
final button = switch (widget.variant) {
|
final button = switch (widget.variant) {
|
||||||
ImmichVariant.filled => ElevatedButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
|
ImmichVariant.filled => ElevatedButton.icon(
|
||||||
ImmichVariant.ghost => TextButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
|
style: style,
|
||||||
|
onPressed: onPressed,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
icon: icon,
|
||||||
|
label: label,
|
||||||
|
),
|
||||||
|
ImmichVariant.ghost => TextButton.icon(
|
||||||
|
style: style,
|
||||||
|
onPressed: onPressed,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
icon: icon,
|
||||||
|
label: label,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (widget.expanded) {
|
if (widget.expanded) {
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/partner.service.dart';
|
import 'package:immich_mobile/domain/services/partner.service.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
|
import 'package:immich_mobile/services/cleanup.service.dart';
|
||||||
|
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||||
|
import 'package:immich_mobile/services/gcast.service.dart';
|
||||||
|
import 'package:immich_mobile/services/server_info.service.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
class MockStoreService extends Mock implements StoreService {}
|
class MockStoreService extends Mock implements StoreService {}
|
||||||
@@ -20,3 +25,13 @@ class MockPartnerService extends Mock implements PartnerService {}
|
|||||||
class MockAssetService extends Mock implements AssetService {}
|
class MockAssetService extends Mock implements AssetService {}
|
||||||
|
|
||||||
class MockUserService extends Mock implements UserService {}
|
class MockUserService extends Mock implements UserService {}
|
||||||
|
|
||||||
|
class MockRemoteAlbumService extends Mock implements RemoteAlbumService {}
|
||||||
|
|
||||||
|
class MockGCastService extends Mock implements GCastService {}
|
||||||
|
|
||||||
|
class MockForegroundUploadService extends Mock implements ForegroundUploadService {}
|
||||||
|
|
||||||
|
class MockServerInfoService extends Mock implements ServerInfoService {}
|
||||||
|
|
||||||
|
class MockCleanupService extends Mock implements CleanupService {}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||||
@@ -53,6 +54,8 @@ class MockPartnerRepository extends Mock implements PartnerRepository {}
|
|||||||
|
|
||||||
class MockToastRepository extends Mock implements ToastRepository {}
|
class MockToastRepository extends Mock implements ToastRepository {}
|
||||||
|
|
||||||
|
class MockRemoteExifRepository extends Mock implements RemoteExifRepository {}
|
||||||
|
|
||||||
// API Repos
|
// API Repos
|
||||||
class MockUserApiRepository extends Mock implements UserApiRepository {}
|
class MockUserApiRepository extends Mock implements UserApiRepository {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
|
||||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
|
||||||
import 'package:immich_mobile/services/action.service.dart';
|
|
||||||
import 'package:immich_mobile/services/download.service.dart';
|
|
||||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
|
||||||
import 'package:mocktail/mocktail.dart';
|
|
||||||
|
|
||||||
class MockActionService extends Mock implements ActionService {}
|
|
||||||
|
|
||||||
class MockAssetService extends Mock implements AssetService {}
|
|
||||||
|
|
||||||
class MockDownloadService extends Mock implements DownloadService {}
|
|
||||||
|
|
||||||
class MockForegroundUploadService extends Mock implements ForegroundUploadService {}
|
|
||||||
|
|
||||||
class MockUserService extends Mock implements UserService {}
|
|
||||||
|
|
||||||
class FakeBuildContext extends Fake implements BuildContext {}
|
|
||||||
|
|
||||||
final _user = UserDto(id: 'user-1', email: 'user@test.dev', name: 'user', profileChangedAt: DateTime(2026));
|
|
||||||
|
|
||||||
final _asset = RemoteAsset(
|
|
||||||
id: 'asset-1',
|
|
||||||
name: 'photo.jpg',
|
|
||||||
ownerId: 'user-1',
|
|
||||||
checksum: 'checksum-1',
|
|
||||||
type: AssetType.image,
|
|
||||||
createdAt: DateTime(2026, 6, 10, 10, 27),
|
|
||||||
updatedAt: DateTime(2026, 6, 10, 10, 27),
|
|
||||||
isEdited: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
late ProviderContainer container;
|
|
||||||
late MockActionService actionService;
|
|
||||||
late MockAssetService assetService;
|
|
||||||
|
|
||||||
setUpAll(() {
|
|
||||||
registerFallbackValue(FakeBuildContext());
|
|
||||||
registerFallbackValue(_asset);
|
|
||||||
registerFallbackValue(<String>[]);
|
|
||||||
});
|
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
actionService = MockActionService();
|
|
||||||
assetService = MockAssetService();
|
|
||||||
final userService = MockUserService();
|
|
||||||
|
|
||||||
when(() => actionService.editDateTime(any(), any())).thenAnswer((_) async => true);
|
|
||||||
when(() => assetService.watchAsset(any())).thenAnswer((_) => const Stream.empty());
|
|
||||||
when(() => assetService.getExif(any())).thenAnswer((_) async => null);
|
|
||||||
when(() => userService.tryGetMyUser()).thenReturn(_user);
|
|
||||||
when(() => userService.watchMyUser()).thenAnswer((_) => const Stream.empty());
|
|
||||||
|
|
||||||
container = ProviderContainer(
|
|
||||||
overrides: [
|
|
||||||
actionServiceProvider.overrideWithValue(actionService),
|
|
||||||
assetServiceProvider.overrideWithValue(assetService),
|
|
||||||
downloadServiceProvider.overrideWithValue(MockDownloadService()),
|
|
||||||
foregroundUploadServiceProvider.overrideWithValue(MockForegroundUploadService()),
|
|
||||||
currentUserProvider.overrideWith((ref) => CurrentUserProvider(userService)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
addTearDown(container.dispose);
|
|
||||||
});
|
|
||||||
|
|
||||||
group('editDateTime', () {
|
|
||||||
test('refreshes the exif provider when editing from the viewer', () async {
|
|
||||||
container.read(assetViewerProvider.notifier).setAsset(_asset);
|
|
||||||
container.listen(assetExifProvider(_asset), (_, __) {});
|
|
||||||
await container.read(assetExifProvider(_asset).future);
|
|
||||||
|
|
||||||
final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.viewer, FakeBuildContext());
|
|
||||||
|
|
||||||
expect(result?.success, isTrue);
|
|
||||||
await container.read(assetExifProvider(_asset).future);
|
|
||||||
verify(() => assetService.getExif(_asset)).called(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('leaves the exif provider cached when editing from the timeline', () async {
|
|
||||||
container.read(assetViewerProvider.notifier).setAsset(_asset);
|
|
||||||
container.listen(assetExifProvider(_asset), (_, __) {});
|
|
||||||
await container.read(assetExifProvider(_asset).future);
|
|
||||||
|
|
||||||
final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.timeline, FakeBuildContext());
|
|
||||||
|
|
||||||
expect(result?.success, isTrue);
|
|
||||||
await container.read(assetExifProvider(_asset).future);
|
|
||||||
verify(() => assetService.getExif(_asset)).called(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not refresh the exif provider when the edit is cancelled', () async {
|
|
||||||
when(() => actionService.editDateTime(any(), any())).thenAnswer((_) async => false);
|
|
||||||
container.read(assetViewerProvider.notifier).setAsset(_asset);
|
|
||||||
container.listen(assetExifProvider(_asset), (_, __) {});
|
|
||||||
await container.read(assetExifProvider(_asset).future);
|
|
||||||
|
|
||||||
final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.viewer, FakeBuildContext());
|
|
||||||
|
|
||||||
expect(result, isNull);
|
|
||||||
await container.read(assetExifProvider(_asset).future);
|
|
||||||
verify(() => assetService.getExif(_asset)).called(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -99,49 +99,6 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('ActionService.applyDateTime', () {
|
|
||||||
const ids = ['asset_id_1'];
|
|
||||||
|
|
||||||
test('sends the picked value to the api with its offset intact', () async {
|
|
||||||
const picked = '2026-06-10T19:15:00.000+06:00';
|
|
||||||
when(() => assetApiRepository.updateDateTime(ids, picked)).thenAnswer((_) async {});
|
|
||||||
when(
|
|
||||||
() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC+06:00'),
|
|
||||||
).thenAnswer((_) async {});
|
|
||||||
|
|
||||||
await sut.applyDateTime(ids, picked);
|
|
||||||
|
|
||||||
verify(() => assetApiRepository.updateDateTime(ids, picked)).called(1);
|
|
||||||
verify(() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC+06:00')).called(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles negative offsets', () async {
|
|
||||||
const picked = '2026-01-05T08:00:00.000-05:30';
|
|
||||||
when(() => assetApiRepository.updateDateTime(ids, picked)).thenAnswer((_) async {});
|
|
||||||
when(
|
|
||||||
() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC-05:30'),
|
|
||||||
).thenAnswer((_) async {});
|
|
||||||
|
|
||||||
await sut.applyDateTime(ids, picked);
|
|
||||||
|
|
||||||
verify(() => assetApiRepository.updateDateTime(ids, picked)).called(1);
|
|
||||||
verify(() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC-05:30')).called(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('writes no timezone when the value has no offset', () async {
|
|
||||||
const picked = '2026-06-10T13:15:00.000Z';
|
|
||||||
when(() => assetApiRepository.updateDateTime(ids, picked)).thenAnswer((_) async {});
|
|
||||||
when(
|
|
||||||
() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: null),
|
|
||||||
).thenAnswer((_) async {});
|
|
||||||
|
|
||||||
await sut.applyDateTime(ids, picked);
|
|
||||||
|
|
||||||
verify(() => assetApiRepository.updateDateTime(ids, picked)).called(1);
|
|
||||||
verify(() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: null)).called(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('ActionService.deleteLocal', () {
|
group('ActionService.deleteLocal', () {
|
||||||
test('routes deleted ids to trashed repository when Android trash handling is enabled', () async {
|
test('routes deleted ids to trashed repository when Android trash handling is enabled', () async {
|
||||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import '../../utils.dart';
|
|||||||
class LocalAssetFactory {
|
class LocalAssetFactory {
|
||||||
const LocalAssetFactory();
|
const LocalAssetFactory();
|
||||||
|
|
||||||
static LocalAsset create({String? id, String? name}) {
|
static LocalAsset create({String? id, String? name, String? remoteId}) {
|
||||||
id = TestUtils.uuid(id);
|
id = TestUtils.uuid(id);
|
||||||
|
|
||||||
return LocalAsset(
|
return LocalAsset(
|
||||||
id: id,
|
id: id,
|
||||||
name: name ?? 'local_$id.jpg',
|
name: name ?? 'local_$id.jpg',
|
||||||
|
remoteId: remoteId,
|
||||||
type: AssetType.image,
|
type: AssetType.image,
|
||||||
createdAt: TestUtils.yesterday(),
|
createdAt: TestUtils.yesterday(),
|
||||||
updatedAt: TestUtils.now(),
|
updatedAt: TestUtils.now(),
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
|
|
||||||
|
import '../../utils.dart';
|
||||||
|
|
||||||
|
class RemoteAlbumFactory {
|
||||||
|
const RemoteAlbumFactory();
|
||||||
|
|
||||||
|
static RemoteAlbum create({
|
||||||
|
String? id,
|
||||||
|
String? name,
|
||||||
|
String? ownerId,
|
||||||
|
String? description,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
String? thumbnailAssetId,
|
||||||
|
bool isActivityEnabled = false,
|
||||||
|
AlbumAssetOrder order = AlbumAssetOrder.desc,
|
||||||
|
int assetCount = 0,
|
||||||
|
String? ownerName,
|
||||||
|
bool isShared = false,
|
||||||
|
}) {
|
||||||
|
id = TestUtils.uuid(id);
|
||||||
|
return RemoteAlbum(
|
||||||
|
id: id,
|
||||||
|
name: name ?? 'remote_album_$id',
|
||||||
|
ownerId: TestUtils.uuid(ownerId),
|
||||||
|
description: description ?? '',
|
||||||
|
createdAt: TestUtils.date(createdAt),
|
||||||
|
updatedAt: TestUtils.date(updatedAt),
|
||||||
|
thumbnailAssetId: thumbnailAssetId,
|
||||||
|
isActivityEnabled: isActivityEnabled,
|
||||||
|
order: order,
|
||||||
|
assetCount: assetCount,
|
||||||
|
ownerName: ownerName ?? 'owner_$id',
|
||||||
|
isShared: isShared,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,11 @@ class RemoteAssetFactory {
|
|||||||
String? name,
|
String? name,
|
||||||
String? ownerId,
|
String? ownerId,
|
||||||
bool isFavorite = false,
|
bool isFavorite = false,
|
||||||
AssetVisibility visibility = AssetVisibility.timeline,
|
AssetVisibility visibility = .timeline,
|
||||||
|
AssetType type = .image,
|
||||||
String? stackId,
|
String? stackId,
|
||||||
DateTime? deletedAt,
|
DateTime? deletedAt,
|
||||||
|
String? localId,
|
||||||
}) {
|
}) {
|
||||||
id = TestUtils.uuid(id);
|
id = TestUtils.uuid(id);
|
||||||
|
|
||||||
@@ -21,7 +23,7 @@ class RemoteAssetFactory {
|
|||||||
name: name ?? 'remote_$id.jpg',
|
name: name ?? 'remote_$id.jpg',
|
||||||
ownerId: TestUtils.uuid(ownerId),
|
ownerId: TestUtils.uuid(ownerId),
|
||||||
checksum: 'checksum-$id',
|
checksum: 'checksum-$id',
|
||||||
type: .image,
|
type: type,
|
||||||
createdAt: TestUtils.yesterday(),
|
createdAt: TestUtils.yesterday(),
|
||||||
updatedAt: TestUtils.now(),
|
updatedAt: TestUtils.now(),
|
||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
@@ -29,6 +31,7 @@ class RemoteAssetFactory {
|
|||||||
stackId: stackId,
|
stackId: stackId,
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
deletedAt: deletedAt,
|
deletedAt: deletedAt,
|
||||||
|
localId: localId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,38 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
import 'package:immich_mobile/utils/option.dart';
|
import 'package:immich_mobile/utils/option.dart';
|
||||||
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
import 'package:mocktail/mocktail.dart' as mock;
|
import 'package:mocktail/mocktail.dart' as mock;
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
import '../domain/service.mock.dart';
|
import '../domain/service.mock.dart';
|
||||||
import '../infrastructure/repository.mock.dart';
|
import '../infrastructure/repository.mock.dart';
|
||||||
|
import '../repository.mocks.dart';
|
||||||
import 'factories/local_album_factory.dart';
|
import 'factories/local_album_factory.dart';
|
||||||
import 'factories/local_asset_factory.dart';
|
import 'factories/local_asset_factory.dart';
|
||||||
|
import 'factories/remote_album_factory.dart';
|
||||||
import 'factories/user_factory.dart';
|
import 'factories/user_factory.dart';
|
||||||
|
|
||||||
class RepositoryMocks {
|
class RepositoryMocks {
|
||||||
final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository());
|
final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository());
|
||||||
final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository());
|
final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository());
|
||||||
|
final remoteAsset = RemoteAssetRepositoryStub(MockRemoteAssetRepository());
|
||||||
|
final remoteExif = RemoteExifRepositoryStub(MockRemoteExifRepository());
|
||||||
final trashedAsset = MockTrashedLocalAssetRepository();
|
final trashedAsset = MockTrashedLocalAssetRepository();
|
||||||
final toast = MockToastRepository();
|
final toast = MockToastRepository();
|
||||||
|
final remoteAlbum = MockRemoteAlbumRepository();
|
||||||
|
final albumApi = MockDriftAlbumApiRepository();
|
||||||
|
|
||||||
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
|
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
|
||||||
|
final assetApi = AssetApiRepositoryStub(MockAssetApiRepository());
|
||||||
|
|
||||||
RepositoryMocks() {
|
RepositoryMocks() {
|
||||||
resetAll();
|
resetAll();
|
||||||
@@ -31,12 +42,30 @@ class RepositoryMocks {
|
|||||||
_registerFallbacks();
|
_registerFallbacks();
|
||||||
localAlbum.reset();
|
localAlbum.reset();
|
||||||
localAsset.reset();
|
localAsset.reset();
|
||||||
|
remoteAsset.reset();
|
||||||
|
remoteExif.reset();
|
||||||
reset(trashedAsset);
|
reset(trashedAsset);
|
||||||
|
reset(remoteAlbum);
|
||||||
|
reset(albumApi);
|
||||||
nativeApi.reset();
|
nativeApi.reset();
|
||||||
|
assetApi.reset();
|
||||||
reset(toast);
|
reset(toast);
|
||||||
_stubLocalAlbumRepository();
|
_stubLocalAlbumRepository();
|
||||||
_stubLocalAssetRepository();
|
_stubLocalAssetRepository();
|
||||||
|
_stubRemoteAssetRepository();
|
||||||
|
_stubRemoteExifRepository();
|
||||||
_stubNativeSyncApi();
|
_stubNativeSyncApi();
|
||||||
|
_stubAssetApiRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stubRemoteAssetRepository() {
|
||||||
|
when(remoteAsset.getExif).thenAnswer((_) async => null);
|
||||||
|
when(remoteAsset.getAssetEdits).thenAnswer((_) async => const []);
|
||||||
|
when(remoteAsset.update).thenAnswer((_) async {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stubRemoteExifRepository() {
|
||||||
|
when(remoteExif.update).thenAnswer((_) async {});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _stubLocalAlbumRepository() {
|
void _stubLocalAlbumRepository() {
|
||||||
@@ -52,12 +81,18 @@ class RepositoryMocks {
|
|||||||
void _stubNativeSyncApi() {
|
void _stubNativeSyncApi() {
|
||||||
when(nativeApi.hashAssets).thenAnswer((_) async => []);
|
when(nativeApi.hashAssets).thenAnswer((_) async => []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _stubAssetApiRepository() {
|
||||||
|
when(assetApi.update).thenAnswer((_) async => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ServiceMocks {
|
class ServiceMocks {
|
||||||
final partner = PartnerServiceStub(MockPartnerService());
|
final partner = PartnerServiceStub(MockPartnerService());
|
||||||
final user = UserServiceStub(MockUserService());
|
final user = UserServiceStub(MockUserService());
|
||||||
final asset = AssetServiceStub(MockAssetService());
|
final asset = AssetServiceStub(MockAssetService());
|
||||||
|
final album = RemoteAlbumServiceStub(MockRemoteAlbumService());
|
||||||
|
final cleanup = CleanupServiceStub(MockCleanupService());
|
||||||
|
|
||||||
ServiceMocks() {
|
ServiceMocks() {
|
||||||
resetAll();
|
resetAll();
|
||||||
@@ -68,9 +103,13 @@ class ServiceMocks {
|
|||||||
partner.reset();
|
partner.reset();
|
||||||
user.reset();
|
user.reset();
|
||||||
asset.reset();
|
asset.reset();
|
||||||
|
album.reset();
|
||||||
|
cleanup.reset();
|
||||||
_stubUserService();
|
_stubUserService();
|
||||||
_stubPartnerService();
|
_stubPartnerService();
|
||||||
_stubAssetService();
|
_stubAssetService();
|
||||||
|
_stubRemoteAlbumService();
|
||||||
|
_stubCleanupService();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _stubUserService() {
|
void _stubUserService() {
|
||||||
@@ -95,6 +134,18 @@ class ServiceMocks {
|
|||||||
when(asset.stack).thenAnswer((_) async {});
|
when(asset.stack).thenAnswer((_) async {});
|
||||||
when(asset.unstack).thenAnswer((_) async {});
|
when(asset.unstack).thenAnswer((_) async {});
|
||||||
when(asset.restoreTrash).thenAnswer((_) async {});
|
when(asset.restoreTrash).thenAnswer((_) async {});
|
||||||
|
when(asset.trash).thenAnswer((_) async {});
|
||||||
|
when(asset.delete).thenAnswer((_) async {});
|
||||||
|
when(asset.applyEdits).thenAnswer((_) async {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stubRemoteAlbumService() {
|
||||||
|
when(album.removeAssets).thenAnswer((_) async => 0);
|
||||||
|
when(album.updateAlbum).thenAnswer((_) async => RemoteAlbumFactory.create());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stubCleanupService() {
|
||||||
|
when(cleanup.deleteLocalAssets).thenAnswer((_) async => 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,8 +154,13 @@ void _registerFallbacks() {
|
|||||||
registerFallbackValue(LocalAssetFactory.create());
|
registerFallbackValue(LocalAssetFactory.create());
|
||||||
registerFallbackValue(Uint8List(0));
|
registerFallbackValue(Uint8List(0));
|
||||||
registerFallbackValue(AssetVisibility.timeline);
|
registerFallbackValue(AssetVisibility.timeline);
|
||||||
|
registerFallbackValue(const LatLng(0, 0));
|
||||||
|
registerFallbackValue(<AssetEdit>[]);
|
||||||
registerFallbackValue(const Option<bool>.none());
|
registerFallbackValue(const Option<bool>.none());
|
||||||
registerFallbackValue(const Option<AssetVisibility>.none());
|
registerFallbackValue(const Option<AssetVisibility>.none());
|
||||||
|
registerFallbackValue(const Option<LatLng>.none());
|
||||||
|
registerFallbackValue(const Option<String>.none());
|
||||||
|
registerFallbackValue(const Option<DateTime>.none());
|
||||||
}
|
}
|
||||||
|
|
||||||
extension type const Stub<T extends Mock>(T mockedClass) {
|
extension type const Stub<T extends Mock>(T mockedClass) {
|
||||||
@@ -128,6 +184,33 @@ extension type const LocalAssetRepositoryStub(MockDriftLocalAssetRepository repo
|
|||||||
() => repo.updateHashes(any());
|
() => repo.updateHashes(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension type const RemoteAssetRepositoryStub(MockRemoteAssetRepository repo)
|
||||||
|
implements Stub<MockRemoteAssetRepository> {
|
||||||
|
Future<ExifInfo?> Function() get getExif =>
|
||||||
|
() => repo.getExif(any());
|
||||||
|
|
||||||
|
Future<List<AssetEdit>> Function() get getAssetEdits =>
|
||||||
|
() => repo.getAssetEdits(any());
|
||||||
|
|
||||||
|
Future<void> Function() get update =>
|
||||||
|
() => repo.update(
|
||||||
|
any(),
|
||||||
|
isFavorite: any(named: 'isFavorite'),
|
||||||
|
visibility: any(named: 'visibility'),
|
||||||
|
createdAt: any(named: 'createdAt'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension type const RemoteExifRepositoryStub(MockRemoteExifRepository repo) implements Stub<MockRemoteExifRepository> {
|
||||||
|
Future<void> Function() get update =>
|
||||||
|
() => repo.update(
|
||||||
|
any(),
|
||||||
|
dateTimeOriginal: any(named: 'dateTimeOriginal'),
|
||||||
|
timeZone: any(named: 'timeZone'),
|
||||||
|
location: any(named: 'location'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
extension type const PartnerServiceStub(MockPartnerService service) implements Stub<MockPartnerService> {
|
extension type const PartnerServiceStub(MockPartnerService service) implements Stub<MockPartnerService> {
|
||||||
Stream<Iterable<User>> Function() get getCandidates =>
|
Stream<Iterable<User>> Function() get getCandidates =>
|
||||||
() => service.getCandidates(any());
|
() => service.getCandidates(any());
|
||||||
@@ -179,6 +262,8 @@ extension type const AssetServiceStub(MockAssetService service) implements Stub<
|
|||||||
any(),
|
any(),
|
||||||
isFavorite: any(named: 'isFavorite'),
|
isFavorite: any(named: 'isFavorite'),
|
||||||
visibility: any(named: 'visibility'),
|
visibility: any(named: 'visibility'),
|
||||||
|
dateTime: any(named: 'dateTime'),
|
||||||
|
location: any(named: 'location'),
|
||||||
);
|
);
|
||||||
|
|
||||||
Future<void> Function() get stack =>
|
Future<void> Function() get stack =>
|
||||||
@@ -189,9 +274,45 @@ extension type const AssetServiceStub(MockAssetService service) implements Stub<
|
|||||||
|
|
||||||
Future<void> Function() get restoreTrash =>
|
Future<void> Function() get restoreTrash =>
|
||||||
() => service.restoreTrash(any());
|
() => service.restoreTrash(any());
|
||||||
|
|
||||||
|
Future<void> Function() get trash =>
|
||||||
|
() => service.trash(any());
|
||||||
|
|
||||||
|
Future<void> Function() get delete =>
|
||||||
|
() => service.delete(any());
|
||||||
|
|
||||||
|
Future<void> Function() get applyEdits =>
|
||||||
|
() => service.applyEdits(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
extension type const RemoteAlbumServiceStub(MockRemoteAlbumService service) implements Stub<MockRemoteAlbumService> {
|
||||||
|
Future<int> Function() get removeAssets =>
|
||||||
|
() => service.removeAssets(
|
||||||
|
albumId: any(named: 'albumId'),
|
||||||
|
assetIds: any(named: 'assetIds'),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<RemoteAlbum> Function() get updateAlbum =>
|
||||||
|
() => service.updateAlbum(any(), thumbnailAssetId: any(named: 'thumbnailAssetId'));
|
||||||
|
}
|
||||||
|
|
||||||
|
extension type const CleanupServiceStub(MockCleanupService service) implements Stub<MockCleanupService> {
|
||||||
|
Future<int> Function() get deleteLocalAssets =>
|
||||||
|
() => service.deleteLocalAssets(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub<MockNativeSyncApi> {
|
extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub<MockNativeSyncApi> {
|
||||||
Future<List<HashResult>> Function() get hashAssets =>
|
Future<List<HashResult>> Function() get hashAssets =>
|
||||||
() => api.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'));
|
() => api.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension type const AssetApiRepositoryStub(MockAssetApiRepository api) implements Stub<MockAssetApiRepository> {
|
||||||
|
Future<void> Function() get update =>
|
||||||
|
() => api.update(
|
||||||
|
any(),
|
||||||
|
isFavorite: any(named: 'isFavorite'),
|
||||||
|
visibility: any(named: 'visibility'),
|
||||||
|
dateTimeOriginal: any(named: 'dateTimeOriginal'),
|
||||||
|
location: any(named: 'location'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
|
|
||||||
|
import '../presentation_context.dart';
|
||||||
|
|
||||||
|
class _RecordingAction extends BaseAction {
|
||||||
|
final void Function() onTap;
|
||||||
|
final void Function()? onLong;
|
||||||
|
|
||||||
|
const _RecordingAction._({
|
||||||
|
required this.onTap,
|
||||||
|
required this.onLong,
|
||||||
|
required super.scope,
|
||||||
|
required super.icon,
|
||||||
|
required super.label,
|
||||||
|
super.isVisible,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory _RecordingAction(
|
||||||
|
ActionScope scope, {
|
||||||
|
required void Function() onTap,
|
||||||
|
void Function()? onLong,
|
||||||
|
bool isVisible = true,
|
||||||
|
}) => _RecordingAction._(
|
||||||
|
scope: scope,
|
||||||
|
onTap: onTap,
|
||||||
|
onLong: onLong,
|
||||||
|
icon: Icons.bug_report_outlined,
|
||||||
|
label: 'test',
|
||||||
|
isVisible: isVisible,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction() async => onTap();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> Function()? get onSecondaryAction {
|
||||||
|
final callback = onLong;
|
||||||
|
return callback == null ? null : () async => callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late PresentationContext context;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
context = await PresentationContext.create();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
context.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ActionIconButtonWidget', () {
|
||||||
|
testWidgets('renders nothing when the action is not visible', (tester) async {
|
||||||
|
await tester.pumpActionButton(context, (scope) => _RecordingAction(scope, onTap: () {}, isVisible: false));
|
||||||
|
|
||||||
|
expect(find.byType(ImmichIconButton), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('wires no long press handler when the action has no secondary action', (tester) async {
|
||||||
|
await tester.pumpActionButton(context, (scope) => _RecordingAction(scope, onTap: () {}));
|
||||||
|
|
||||||
|
expect(tester.widget<ImmichIconButton>(find.byType(ImmichIconButton)).onLongPress, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tap runs the primary action', (tester) async {
|
||||||
|
var taps = 0;
|
||||||
|
var longPresses = 0;
|
||||||
|
await tester.pumpActionButton(
|
||||||
|
context,
|
||||||
|
(scope) => _RecordingAction(scope, onTap: () => taps++, onLong: () => longPresses++),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byType(ImmichIconButton));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(taps, 1);
|
||||||
|
expect(longPresses, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('long press runs the secondary action, not the primary', (tester) async {
|
||||||
|
var taps = 0;
|
||||||
|
var longPresses = 0;
|
||||||
|
await tester.pumpActionButton(
|
||||||
|
context,
|
||||||
|
(scope) => _RecordingAction(scope, onTap: () => taps++, onLong: () => longPresses++),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.longPress(find.byType(ImmichIconButton));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(longPresses, 1);
|
||||||
|
expect(taps, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.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/store.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||||
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/delete.action.dart';
|
||||||
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import '../../../domain/service.mock.dart';
|
||||||
|
import '../../factories/local_asset_factory.dart';
|
||||||
|
import '../../factories/remote_asset_factory.dart';
|
||||||
|
import '../../riverpod_mocks.dart';
|
||||||
|
import '../presentation_context.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late PresentationContext context;
|
||||||
|
late MockAssetService assetService;
|
||||||
|
late MockCleanupService cleanupService;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
context = await PresentationContext.create();
|
||||||
|
assetService = context.service.asset.service;
|
||||||
|
cleanupService = context.service.cleanup.service;
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
debugDefaultTargetPlatformOverride = null;
|
||||||
|
await StoreService.I.put(StoreKey.manageLocalMediaAndroid, false);
|
||||||
|
context.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
RemoteAsset owned({AssetVisibility visibility = .timeline, DateTime? deletedAt, String? localId}) =>
|
||||||
|
RemoteAssetFactory.create(
|
||||||
|
ownerId: context.currentUser.id,
|
||||||
|
visibility: visibility,
|
||||||
|
deletedAt: deletedAt,
|
||||||
|
localId: localId,
|
||||||
|
);
|
||||||
|
|
||||||
|
List<Override> disableTrash() => [
|
||||||
|
serverInfoProvider.overrideWith((ref) => FakeServerInfoNotifier(trashEnabled: false)),
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<void> respondToDialog(WidgetTester tester, {required bool confirm}) async {
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
expect(find.byType(ConfirmDialog), findsOneWidget);
|
||||||
|
await tester.tap(find.byType(TextButton).at(confirm ? 1 : 0)); // [cancel, ok]
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
group('DeleteAction', () {
|
||||||
|
group('trash', () {
|
||||||
|
testWidgets('trashes a remote-only owned asset', (tester) async {
|
||||||
|
final asset = owned();
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
verify(() => assetService.trash([asset.id])).called(1);
|
||||||
|
verifyNever(() => assetService.delete(any()));
|
||||||
|
verifyNever(() => cleanupService.deleteLocalAssets(any()));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ignores assets owned by someone else', (tester) async {
|
||||||
|
final mine = owned();
|
||||||
|
final theirs = RemoteAssetFactory.create();
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [mine, theirs], scope: scope));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
verify(() => assetService.trash([mine.id])).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('trashes a merged asset and removes its device copy', (tester) async {
|
||||||
|
final asset = owned(localId: 'local');
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
|
||||||
|
verify(() => assetService.trash([asset.id])).called(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('permanent', () {
|
||||||
|
testWidgets('permanently deletes when the trash feature is disabled', (tester) async {
|
||||||
|
final asset = owned();
|
||||||
|
|
||||||
|
await tester.pumpTestAction(
|
||||||
|
context,
|
||||||
|
(scope) => DeleteAction(assets: [asset], scope: scope),
|
||||||
|
overrides: disableTrash(),
|
||||||
|
);
|
||||||
|
await respondToDialog(tester, confirm: true);
|
||||||
|
|
||||||
|
verify(() => assetService.delete([asset.id])).called(1);
|
||||||
|
verifyNever(() => assetService.trash(any()));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('permanently deletes a merged asset and removes its device copy', (tester) async {
|
||||||
|
final asset = owned(localId: 'local');
|
||||||
|
|
||||||
|
await tester.pumpTestAction(
|
||||||
|
context,
|
||||||
|
(scope) => DeleteAction(assets: [asset], scope: scope),
|
||||||
|
overrides: disableTrash(),
|
||||||
|
);
|
||||||
|
await respondToDialog(tester, confirm: true);
|
||||||
|
|
||||||
|
verify(() => assetService.delete([asset.id])).called(1);
|
||||||
|
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('permanently deletes already trashed assets even with trash enabled', (tester) async {
|
||||||
|
final asset = owned(deletedAt: DateTime(2024));
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
|
||||||
|
await respondToDialog(tester, confirm: true);
|
||||||
|
|
||||||
|
verify(() => assetService.delete([asset.id])).called(1);
|
||||||
|
verifyNever(() => assetService.trash(any()));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('permanently deletes locked folder assets even with trash enabled', (tester) async {
|
||||||
|
final asset = owned(visibility: .locked, localId: 'local');
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
|
||||||
|
await respondToDialog(tester, confirm: true);
|
||||||
|
|
||||||
|
verify(() => assetService.delete([asset.id])).called(1);
|
||||||
|
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('does nothing when the confirmation is cancelled', (tester) async {
|
||||||
|
final asset = owned(visibility: .locked, localId: 'local');
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
|
||||||
|
await respondToDialog(tester, confirm: false);
|
||||||
|
|
||||||
|
verifyNever(() => assetService.delete(any()));
|
||||||
|
verifyNever(() => cleanupService.deleteLocalAssets(any()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('local only', () {
|
||||||
|
testWidgets('removes the device copy with no remote call', (tester) async {
|
||||||
|
final asset = LocalAssetFactory.create();
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
verify(() => cleanupService.deleteLocalAssets([asset.id])).called(1);
|
||||||
|
verifyNever(() => assetService.trash(any()));
|
||||||
|
verifyNever(() => assetService.delete(any()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('prompt handling', () {
|
||||||
|
testWidgets('permanent delete shows a single app dialog', (tester) async {
|
||||||
|
final asset = owned(localId: 'local');
|
||||||
|
|
||||||
|
await tester.pumpTestAction(
|
||||||
|
context,
|
||||||
|
(scope) => DeleteAction(assets: [asset], scope: scope),
|
||||||
|
overrides: disableTrash(),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
expect(find.text(StaticTranslations.instance.delete_dialog_title), findsOneWidget);
|
||||||
|
await tester.tap(find.byType(TextButton).at(1));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text(StaticTranslations.instance.move_to_device_trash), findsNothing);
|
||||||
|
verify(() => assetService.delete([asset.id])).called(1);
|
||||||
|
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('local only delete on Android with MANAGE_MEDIA shows the prompt', (tester) async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||||
|
await StoreService.I.put(StoreKey.manageLocalMediaAndroid, true);
|
||||||
|
final asset = LocalAssetFactory.create();
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
expect(find.text(StaticTranslations.instance.move_to_device_trash), findsOneWidget);
|
||||||
|
await tester.tap(find.byType(TextButton).at(1)); // confirm
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
verify(() => cleanupService.deleteLocalAssets([asset.id])).called(1);
|
||||||
|
debugDefaultTargetPlatformOverride = null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CleanupLocalAction', () {
|
||||||
|
testWidgets('deletes only backed up device copies', (tester) async {
|
||||||
|
final backedUp = LocalAssetFactory.create(remoteId: 'remote');
|
||||||
|
final localOnly = LocalAssetFactory.create();
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, (scope) => CleanupLocalAction(assets: [backedUp, localOnly], scope: scope));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
verify(() => cleanupService.deleteLocalAssets([backedUp.id])).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('is hidden when no backed up assets are selected', (tester) async {
|
||||||
|
final action = await tester.pumpActionButton(
|
||||||
|
context,
|
||||||
|
(scope) => CleanupLocalAction(assets: [LocalAssetFactory.create()], scope: scope),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(action.isVisible, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.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_edit.model.dart';
|
||||||
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/models/server_info/server_version.model.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/edit_asset.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/edit_location.action.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/option.dart';
|
||||||
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import '../../../domain/service.mock.dart';
|
||||||
|
import '../../factories/remote_asset_factory.dart';
|
||||||
|
import '../../riverpod_mocks.dart';
|
||||||
|
import '../presentation_context.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late PresentationContext context;
|
||||||
|
late MockAssetService assetService;
|
||||||
|
|
||||||
|
const unsupportedVersion = ServerVersion(major: 2, minor: 5, patch: 9);
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
context = await PresentationContext.create();
|
||||||
|
assetService = context.service.asset.service;
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
context.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
List<Override> serverVersion(ServerVersion version) => [
|
||||||
|
serverInfoProvider.overrideWith((ref) => FakeServerInfoNotifier(version: version)),
|
||||||
|
];
|
||||||
|
|
||||||
|
RemoteAsset owned({AssetType type = .image}) =>
|
||||||
|
RemoteAssetFactory.create(ownerId: context.currentUser.id, type: type);
|
||||||
|
|
||||||
|
group('EditImageAction', () {
|
||||||
|
testWidgets('visible for a single owned editable asset on a supported server', (tester) async {
|
||||||
|
final action = await tester.pumpActionButton(
|
||||||
|
context,
|
||||||
|
(scope) => EditAssetAction(assets: [owned()], scope: scope),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(action.isVisible, isTrue);
|
||||||
|
expect(action.icon, Icons.tune);
|
||||||
|
expect(action.label, StaticTranslations.instance.edit);
|
||||||
|
expect(find.byType(ImmichIconButton), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hidden when the server is older than 2.6.0', (tester) async {
|
||||||
|
final action = await tester.pumpActionButton(
|
||||||
|
context,
|
||||||
|
(scope) => EditAssetAction(assets: [owned()], scope: scope),
|
||||||
|
overrides: serverVersion(unsupportedVersion),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(action.isVisible, isFalse);
|
||||||
|
expect(find.byType(ImmichIconButton), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hidden for more than one asset', (tester) async {
|
||||||
|
final action = await tester.pumpActionButton(
|
||||||
|
context,
|
||||||
|
(scope) => EditAssetAction(assets: [owned(), owned()], scope: scope),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(action.isVisible, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hidden for an asset owned by someone else', (tester) async {
|
||||||
|
final action = await tester.pumpActionButton(
|
||||||
|
context,
|
||||||
|
(scope) => EditAssetAction(assets: [RemoteAssetFactory.create()], scope: scope),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(action.isVisible, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hidden for a non-editable asset', (tester) async {
|
||||||
|
final action = await tester.pumpActionButton(
|
||||||
|
context,
|
||||||
|
(scope) => EditAssetAction(
|
||||||
|
assets: [owned(type: AssetType.video)],
|
||||||
|
scope: scope,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(action.isVisible, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('reads the edits and exif for the asset from the repository', (tester) async {
|
||||||
|
final asset = owned();
|
||||||
|
final remoteAssetRepo = context.repository.remoteAsset.repo;
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, (scope) => EditAssetAction(assets: [asset], scope: scope));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
verify(() => remoteAssetRepo.getAssetEdits(asset.id)).called(1);
|
||||||
|
verify(() => remoteAssetRepo.getExif(asset.id)).called(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('applyEdits', () {
|
||||||
|
testWidgets('forwards the edits to the service and waits for both ready events', (tester) async {
|
||||||
|
late FakeWebsocketNotifier websocket;
|
||||||
|
const edits = <AssetEdit>[];
|
||||||
|
|
||||||
|
late WidgetRef capturedRef;
|
||||||
|
await tester.pumpTestWidget(
|
||||||
|
context,
|
||||||
|
Consumer(
|
||||||
|
builder: (_, ref, _) {
|
||||||
|
capturedRef = ref;
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
overrides: [websocketProvider.overrideWith((ref) => websocket = FakeWebsocketNotifier(ref))],
|
||||||
|
);
|
||||||
|
|
||||||
|
await applyEdits(capturedRef, 'asset-1', edits);
|
||||||
|
|
||||||
|
verify(() => assetService.applyEdits('asset-1', edits)).called(1);
|
||||||
|
expect(websocket.waitedEvents, containsAll(['AssetEditReadyV1', 'AssetEditReadyV2']));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('EditLocationAction', () {
|
||||||
|
testWidgets('visible with an owned remote asset', (tester) async {
|
||||||
|
final action = await tester.pumpActionButton(
|
||||||
|
context,
|
||||||
|
(scope) => EditLocationAction(assets: [owned()], scope: scope),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(action.isVisible, isTrue);
|
||||||
|
expect(action.icon, Icons.edit_location_alt_outlined);
|
||||||
|
expect(action.label, StaticTranslations.instance.control_bottom_app_bar_edit_location);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hidden without any owned remote asset', (tester) async {
|
||||||
|
final action = await tester.pumpActionButton(
|
||||||
|
context,
|
||||||
|
(scope) => EditLocationAction(assets: [RemoteAssetFactory.create()], scope: scope),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(action.isVisible, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('collects only the owned remote asset ids', (tester) async {
|
||||||
|
final mine = owned();
|
||||||
|
final theirs = RemoteAssetFactory.create();
|
||||||
|
|
||||||
|
final action = await tester.pumpActionButton(
|
||||||
|
context,
|
||||||
|
(scope) => EditLocationAction(assets: [mine, theirs], scope: scope),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((action as EditLocationAction).assetIds, [mine.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('save persists the location, refreshes the viewer exif and toasts', (tester) async {
|
||||||
|
final asset = owned();
|
||||||
|
final toast = context.repository.toast;
|
||||||
|
when(() => assetService.getExif(asset)).thenAnswer((_) async => null);
|
||||||
|
|
||||||
|
late EditLocationAction action;
|
||||||
|
await tester.pumpTestWidget(
|
||||||
|
context,
|
||||||
|
Consumer(
|
||||||
|
builder: (ctx, ref, _) {
|
||||||
|
action = EditLocationAction(
|
||||||
|
assets: [asset],
|
||||||
|
scope: ActionScope(context: ctx, ref: ref, authUser: context.currentUser),
|
||||||
|
);
|
||||||
|
// Keep the exif provider alive so a re-fetch after invalidation is observable.
|
||||||
|
ref.watch(assetExifProvider(asset));
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await action.save(const LatLng(1, 2));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final location =
|
||||||
|
verify(() => assetService.update([asset.id], location: captureAny(named: 'location'))).captured.single
|
||||||
|
as Option<LatLng>;
|
||||||
|
expect(location.unwrapOrNull?.latitude, 1);
|
||||||
|
expect(location.unwrapOrNull?.longitude, 2);
|
||||||
|
|
||||||
|
verify(() => assetService.getExif(asset)).called(2);
|
||||||
|
|
||||||
|
final message = verify(() => toast.success(captureAny())).captured.single as String;
|
||||||
|
expect(message, StaticTranslations.instance.edit_location_action_prompt(count: 1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('EditDateTimeAction', () {
|
||||||
|
testWidgets('visible with an owned remote asset', (tester) async {
|
||||||
|
final action = await tester.pumpActionButton(
|
||||||
|
context,
|
||||||
|
(scope) => EditDateTimeAction(assets: [owned()], scope: scope),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(action.isVisible, isTrue);
|
||||||
|
expect(action.icon, Icons.edit_calendar_outlined);
|
||||||
|
expect(action.label, StaticTranslations.instance.control_bottom_app_bar_edit_time);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hidden without any owned remote asset', (tester) async {
|
||||||
|
final action = await tester.pumpActionButton(
|
||||||
|
context,
|
||||||
|
(scope) => EditDateTimeAction(assets: [RemoteAssetFactory.create()], scope: scope),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(action.isVisible, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('collects only the owned remote asset ids', (tester) async {
|
||||||
|
final mine = owned();
|
||||||
|
final theirs = RemoteAssetFactory.create();
|
||||||
|
|
||||||
|
final action = await tester.pumpActionButton(
|
||||||
|
context,
|
||||||
|
(scope) => EditDateTimeAction(assets: [mine, theirs], scope: scope),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((action as EditDateTimeAction).assetIds, [mine.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('save persists the date, refreshes the viewer exif and toasts', (tester) async {
|
||||||
|
final asset = owned();
|
||||||
|
final toast = context.repository.toast;
|
||||||
|
const picked = '2026-06-10T19:15:00.000+06:00';
|
||||||
|
when(() => assetService.getExif(asset)).thenAnswer((_) async => null);
|
||||||
|
|
||||||
|
late EditDateTimeAction action;
|
||||||
|
await tester.pumpTestWidget(
|
||||||
|
context,
|
||||||
|
Consumer(
|
||||||
|
builder: (ctx, ref, _) {
|
||||||
|
action = EditDateTimeAction(
|
||||||
|
assets: [asset],
|
||||||
|
scope: ActionScope(context: ctx, ref: ref, authUser: context.currentUser),
|
||||||
|
);
|
||||||
|
// Keep the exif provider alive so a re-fetch after invalidation is observable.
|
||||||
|
ref.watch(assetExifProvider(asset));
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await action.save(picked);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
verify(() => assetService.update([asset.id], dateTime: const Some(picked))).called(1);
|
||||||
|
verify(() => assetService.getExif(asset)).called(2);
|
||||||
|
|
||||||
|
final message = verify(() => toast.success(captureAny())).captured.single as String;
|
||||||
|
expect(message, StaticTranslations.instance.edit_date_and_time_action_prompt(count: 1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/lock.action.dart';
|
||||||
|
import 'package:immich_mobile/utils/option.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import '../../../domain/service.mock.dart';
|
||||||
|
import '../../factories/remote_asset_factory.dart';
|
||||||
|
import '../presentation_context.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late PresentationContext context;
|
||||||
|
late MockAssetService assetService;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
context = await PresentationContext.create();
|
||||||
|
assetService = context.service.asset.service;
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
context.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
RemoteAsset owned({AssetVisibility visibility = .timeline}) =>
|
||||||
|
RemoteAssetFactory.create(ownerId: context.currentUser.id, visibility: visibility);
|
||||||
|
|
||||||
|
group('LockAction', () {
|
||||||
|
testWidgets('locks the eligible owned assets', (tester) async {
|
||||||
|
final asset = owned();
|
||||||
|
final action = await tester.pumpTestAction(context, (scope) => LockAction(assets: [asset], scope: scope));
|
||||||
|
|
||||||
|
expect(action.icon, Icons.lock_rounded);
|
||||||
|
expect(action.label, StaticTranslations.instance.move_to_locked_folder);
|
||||||
|
|
||||||
|
verify(() => assetService.update([asset.id], visibility: const Some(.locked))).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('unlocks the eligible owned assets', (tester) async {
|
||||||
|
final asset = owned(visibility: .locked);
|
||||||
|
final action = await tester.pumpTestAction(context, (scope) => LockAction(assets: [asset], scope: scope));
|
||||||
|
|
||||||
|
expect(action.icon, Icons.lock_open_rounded);
|
||||||
|
expect(action.label, StaticTranslations.instance.remove_from_locked_folder);
|
||||||
|
|
||||||
|
verify(() => assetService.update([asset.id], visibility: const Some(.timeline))).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('dispatches on owned state, ignoring assets owned by others', (tester) async {
|
||||||
|
final mine = owned(visibility: .locked);
|
||||||
|
final theirs = RemoteAssetFactory.create();
|
||||||
|
final action = await tester.pumpTestAction(context, (scope) => LockAction(assets: [mine, theirs], scope: scope));
|
||||||
|
expect(action.label, StaticTranslations.instance.remove_from_locked_folder);
|
||||||
|
|
||||||
|
verify(() => assetService.update([mine.id], visibility: const Some(.timeline))).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('batches every eligible owned asset into a single call', (tester) async {
|
||||||
|
final first = owned();
|
||||||
|
final second = owned();
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, (scope) => LockAction(assets: [first, second], scope: scope));
|
||||||
|
|
||||||
|
verify(() => assetService.update([first.id, second.id], visibility: const Some(.locked))).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('locks only the owned assets not already locked', (tester) async {
|
||||||
|
final stale = owned();
|
||||||
|
final alreadyLocked = owned(visibility: .locked);
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, (scope) => LockAction(assets: [stale, alreadyLocked], scope: scope));
|
||||||
|
|
||||||
|
verify(() => assetService.update([stale.id], visibility: const Some(.locked))).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('reports the locked count through the toast repository', (tester) async {
|
||||||
|
final toast = context.repository.toast;
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, (scope) => LockAction(assets: [owned(), owned()], scope: scope));
|
||||||
|
|
||||||
|
final message = verify(() => toast.success(captureAny())).captured.single as String;
|
||||||
|
expect(message, StaticTranslations.instance.move_to_lock_folder_action_prompt(count: 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('reports the unlocked count through the toast repository', (tester) async {
|
||||||
|
final toast = context.repository.toast;
|
||||||
|
|
||||||
|
await tester.pumpTestAction(
|
||||||
|
context,
|
||||||
|
(scope) => LockAction(
|
||||||
|
assets: [
|
||||||
|
owned(visibility: .locked),
|
||||||
|
owned(visibility: .locked),
|
||||||
|
],
|
||||||
|
scope: scope,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final message = verify(() => toast.success(captureAny())).captured.single as String;
|
||||||
|
expect(message, StaticTranslations.instance.remove_from_lock_folder_action_prompt(count: 2));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/remove_from_album.action.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import '../../../domain/service.mock.dart';
|
||||||
|
import '../../factories/remote_asset_factory.dart';
|
||||||
|
import '../presentation_context.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late PresentationContext context;
|
||||||
|
late MockRemoteAlbumService albumService;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
context = await PresentationContext.create();
|
||||||
|
albumService = context.service.album.service;
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
context.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
RemoteAsset remote() => RemoteAssetFactory.create(ownerId: context.currentUser.id);
|
||||||
|
|
||||||
|
group('RemoveFromAlbumAction', () {
|
||||||
|
testWidgets('removes the selected remote assets from the album', (tester) async {
|
||||||
|
final first = remote();
|
||||||
|
final second = remote();
|
||||||
|
final albumId = 'album';
|
||||||
|
final action = await tester.pumpTestAction(
|
||||||
|
context,
|
||||||
|
(scope) => RemoveFromAlbumAction(assets: [first, second], albumId: albumId, scope: scope),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(action.icon, Icons.remove_circle_outline);
|
||||||
|
expect(action.label, StaticTranslations.instance.remove_from_album);
|
||||||
|
verify(() => albumService.removeAssets(albumId: albumId, assetIds: [first.id, second.id])).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('reports the removed count through the toast repository', (tester) async {
|
||||||
|
final toast = context.repository.toast;
|
||||||
|
final albumId = 'album';
|
||||||
|
when(context.service.album.removeAssets).thenAnswer((_) async => 2);
|
||||||
|
|
||||||
|
await tester.pumpTestAction(
|
||||||
|
context,
|
||||||
|
(scope) => RemoveFromAlbumAction(assets: [remote(), remote()], albumId: albumId, scope: scope),
|
||||||
|
);
|
||||||
|
|
||||||
|
final message = verify(() => toast.success(captureAny())).captured.single as String;
|
||||||
|
expect(message, StaticTranslations.instance.remove_from_album_action_prompt(count: 2));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/set_album_cover.action.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import '../../../domain/service.mock.dart';
|
||||||
|
import '../../factories/remote_asset_factory.dart';
|
||||||
|
import '../presentation_context.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late PresentationContext context;
|
||||||
|
late MockRemoteAlbumService albumService;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
context = await PresentationContext.create();
|
||||||
|
albumService = context.service.album.service;
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
context.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
RemoteAsset remote() => RemoteAssetFactory.create(ownerId: context.currentUser.id);
|
||||||
|
|
||||||
|
group('SetAlbumCoverAction', () {
|
||||||
|
testWidgets('sets the selected asset as the album cover', (tester) async {
|
||||||
|
final asset = remote();
|
||||||
|
const albumId = 'album';
|
||||||
|
final action = await tester.pumpTestAction(
|
||||||
|
context,
|
||||||
|
(scope) => SetAlbumCoverAction(assets: [asset], albumId: albumId, scope: scope),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(action.icon, Icons.image_outlined);
|
||||||
|
expect(action.label, StaticTranslations.instance.set_as_album_cover);
|
||||||
|
verify(() => albumService.updateAlbum(albumId, thumbnailAssetId: asset.id)).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('is hidden unless exactly one remote asset is selected', (tester) async {
|
||||||
|
const albumId = 'album';
|
||||||
|
final action = await tester.pumpActionButton(
|
||||||
|
context,
|
||||||
|
(scope) => SetAlbumCoverAction(assets: [remote(), remote()], albumId: albumId, scope: scope),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(action.isVisible, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,16 +13,22 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
import 'package:immich_mobile/services/cleanup.service.dart';
|
||||||
|
import 'package:immich_mobile/services/gcast.service.dart';
|
||||||
import 'package:immich_ui/immich_ui.dart';
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import '../../domain/service.mock.dart';
|
||||||
import '../../test_utils.dart';
|
import '../../test_utils.dart';
|
||||||
import '../factories/user_factory.dart';
|
import '../factories/user_factory.dart';
|
||||||
import '../mocks.dart';
|
import '../mocks.dart';
|
||||||
|
import '../riverpod_mocks.dart';
|
||||||
|
|
||||||
class PresentationContext {
|
class PresentationContext {
|
||||||
PresentationContext._({required UserDto user})
|
PresentationContext._({required UserDto user})
|
||||||
@@ -43,8 +49,15 @@ class PresentationContext {
|
|||||||
List<Override> get overrides => [
|
List<Override> get overrides => [
|
||||||
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
|
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
|
||||||
assetServiceProvider.overrideWithValue(service.asset.service),
|
assetServiceProvider.overrideWithValue(service.asset.service),
|
||||||
|
remoteAssetRepositoryProvider.overrideWithValue(repository.remoteAsset.repo),
|
||||||
|
remoteExifRepositoryProvider.overrideWithValue(repository.remoteExif.repo),
|
||||||
partnerServiceProvider.overrideWithValue(service.partner.service),
|
partnerServiceProvider.overrideWithValue(service.partner.service),
|
||||||
|
remoteAlbumServiceProvider.overrideWithValue(service.album.service),
|
||||||
|
cleanupServiceProvider.overrideWithValue(service.cleanup.service),
|
||||||
|
gCastServiceProvider.overrideWithValue(MockGCastService()),
|
||||||
|
serverInfoProvider.overrideWith((ref) => FakeServerInfoNotifier()),
|
||||||
toastRepositoryProvider.overrideWithValue(repository.toast),
|
toastRepositoryProvider.overrideWithValue(repository.toast),
|
||||||
|
gCastServiceProvider.overrideWithValue(MockGCastService()),
|
||||||
];
|
];
|
||||||
|
|
||||||
static Future<PresentationContext> create() async {
|
static Future<PresentationContext> create() async {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import 'package:immich_mobile/models/server_info/server_version.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||||
|
|
||||||
|
import '../domain/service.mock.dart';
|
||||||
|
|
||||||
|
class FakeServerInfoNotifier extends ServerInfoNotifier {
|
||||||
|
FakeServerInfoNotifier({
|
||||||
|
bool trashEnabled = true,
|
||||||
|
ServerVersion version = const ServerVersion(major: 2, minor: 6, patch: 0),
|
||||||
|
}) : super(MockServerInfoService()) {
|
||||||
|
state = state.copyWith(
|
||||||
|
serverVersion: version,
|
||||||
|
serverFeatures: state.serverFeatures.copyWith(trash: trashEnabled),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeWebsocketNotifier extends WebsocketNotifier {
|
||||||
|
FakeWebsocketNotifier(super.ref);
|
||||||
|
|
||||||
|
final List<String> waitedEvents = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> waitForEvent(String event, bool Function(dynamic)? predicate, Duration timeout) {
|
||||||
|
waitedEvents.add(event);
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import '../../infrastructure/repository.mock.dart';
|
||||||
|
import '../../repository.mocks.dart';
|
||||||
|
import '../mocks.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AssetService sut;
|
||||||
|
late RepositoryMocks mocks;
|
||||||
|
late MockAssetApiRepository apiRepository;
|
||||||
|
late MockRemoteAssetRepository remoteRepository;
|
||||||
|
late MockRemoteExifRepository exifRepository;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mocks = RepositoryMocks();
|
||||||
|
apiRepository = mocks.assetApi.api;
|
||||||
|
remoteRepository = mocks.remoteAsset.repo;
|
||||||
|
exifRepository = mocks.remoteExif.repo;
|
||||||
|
|
||||||
|
sut = AssetService(
|
||||||
|
remoteRepository: remoteRepository,
|
||||||
|
exifRepository: exifRepository,
|
||||||
|
localRepository: MockDriftLocalAssetRepository(),
|
||||||
|
apiRepository: apiRepository,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('AssetService.updateDateTime', () {
|
||||||
|
const ids = ['asset_id_1'];
|
||||||
|
|
||||||
|
test('sends the picked value to the api with its offset intact', () async {
|
||||||
|
const picked = '2026-06-10T19:15:00.000+06:00';
|
||||||
|
await sut.update(ids, dateTime: const .some(picked));
|
||||||
|
|
||||||
|
verify(() => apiRepository.update(ids, dateTimeOriginal: const .some(picked))).called(1);
|
||||||
|
verify(() => remoteRepository.update(ids, createdAt: .some(DateTime.parse(picked)))).called(1);
|
||||||
|
verify(
|
||||||
|
() => exifRepository.update(
|
||||||
|
ids,
|
||||||
|
dateTimeOriginal: .some(DateTime.parse(picked)),
|
||||||
|
timeZone: const .some('UTC+06:00'),
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles negative offsets', () async {
|
||||||
|
const picked = '2026-01-05T08:00:00.000-05:30';
|
||||||
|
await sut.update(ids, dateTime: const .some(picked));
|
||||||
|
|
||||||
|
verify(() => remoteRepository.update(ids, createdAt: .some(DateTime.parse(picked)))).called(1);
|
||||||
|
verify(
|
||||||
|
() => exifRepository.update(
|
||||||
|
ids,
|
||||||
|
dateTimeOriginal: .some(DateTime.parse(picked)),
|
||||||
|
timeZone: const .some('UTC-05:30'),
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writes no timezone when the value has no offset', () async {
|
||||||
|
const picked = '2026-06-10T13:15:00.000Z';
|
||||||
|
await sut.update(ids, dateTime: const .some(picked));
|
||||||
|
|
||||||
|
verify(() => remoteRepository.update(ids, createdAt: .some(DateTime.parse(picked)))).called(1);
|
||||||
|
verify(
|
||||||
|
() => exifRepository.update(ids, dateTimeOriginal: .some(DateTime.parse(picked)), timeZone: const .none()),
|
||||||
|
).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is a no-op when there are no asset ids', () async {
|
||||||
|
await sut.update(const [], dateTime: const .some('2026-06-10T19:15:00.000+06:00'));
|
||||||
|
|
||||||
|
verifyZeroInteractions(apiRepository);
|
||||||
|
verifyZeroInteractions(remoteRepository);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import '../../domain/service.mock.dart';
|
||||||
|
import '../mocks.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late RemoteAlbumService sut;
|
||||||
|
final mocks = RepositoryMocks();
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
registerFallbackValue(<String>[]);
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
sut = RemoteAlbumService(mocks.remoteAlbum, mocks.albumApi, MockForegroundUploadService());
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
mocks.resetAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('RemoteAlbumService', () {
|
||||||
|
group('removeAssets', () {
|
||||||
|
test('persists only the assets the server actually removed, not the whole request', () async {
|
||||||
|
const albumId = 'album-1';
|
||||||
|
const requested = ['asset-1', 'asset-2', 'asset-3'];
|
||||||
|
const removed = ['asset-1', 'asset-3'];
|
||||||
|
|
||||||
|
// The server rejected 'asset-2'
|
||||||
|
when(
|
||||||
|
() => mocks.albumApi.removeAssets(albumId, requested),
|
||||||
|
).thenAnswer((_) async => (removed: removed, failed: ['asset-2']));
|
||||||
|
when(() => mocks.remoteAlbum.removeAssets(albumId, any())).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
final count = await sut.removeAssets(albumId: albumId, assetIds: requested);
|
||||||
|
|
||||||
|
final persisted =
|
||||||
|
verify(() => mocks.remoteAlbum.removeAssets(albumId, captureAny())).captured.single as List<String>;
|
||||||
|
expect(persisted, removed);
|
||||||
|
expect(persisted, isNot(contains('asset-2')));
|
||||||
|
|
||||||
|
expect(count, removed.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ void main() {
|
|||||||
final offlinePhoto = LocalAssetFactory.create();
|
final offlinePhoto = LocalAssetFactory.create();
|
||||||
final remotePhoto = RemoteAssetFactory.create();
|
final remotePhoto = RemoteAssetFactory.create();
|
||||||
|
|
||||||
final AssetFilter<LocalAsset> syncedPhotos = AssetFilter(<BaseAsset>[
|
final AssetFilter<BaseAsset> syncedPhotos = AssetFilter(<BaseAsset>[
|
||||||
syncedPhoto,
|
syncedPhoto,
|
||||||
offlinePhoto,
|
offlinePhoto,
|
||||||
remotePhoto,
|
remotePhoto,
|
||||||
|
|||||||
@@ -447,60 +447,6 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('trash button', () {
|
|
||||||
test('should show when owner, not locked, has remote, and trash enabled', () {
|
|
||||||
final remoteAsset = createRemoteAsset();
|
|
||||||
final context = ActionButtonContext(
|
|
||||||
asset: remoteAsset,
|
|
||||||
isOwner: true,
|
|
||||||
isArchived: false,
|
|
||||||
isTrashEnabled: true,
|
|
||||||
isInLockedView: false,
|
|
||||||
currentAlbum: null,
|
|
||||||
advancedTroubleshooting: false,
|
|
||||||
isStacked: false,
|
|
||||||
source: ActionSource.timeline,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ActionButtonType.trash.shouldShow(context), isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should not show when trash disabled', () {
|
|
||||||
final remoteAsset = createRemoteAsset();
|
|
||||||
final context = ActionButtonContext(
|
|
||||||
asset: remoteAsset,
|
|
||||||
isOwner: true,
|
|
||||||
isArchived: false,
|
|
||||||
isTrashEnabled: false,
|
|
||||||
isInLockedView: false,
|
|
||||||
currentAlbum: null,
|
|
||||||
advancedTroubleshooting: false,
|
|
||||||
isStacked: false,
|
|
||||||
source: ActionSource.timeline,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ActionButtonType.trash.shouldShow(context), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should not show when asset is already trashed', () {
|
|
||||||
final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024));
|
|
||||||
final context = ActionButtonContext(
|
|
||||||
asset: remoteAsset,
|
|
||||||
isOwner: true,
|
|
||||||
isArchived: false,
|
|
||||||
isTrashEnabled: true,
|
|
||||||
isInLockedView: false,
|
|
||||||
currentAlbum: null,
|
|
||||||
advancedTroubleshooting: false,
|
|
||||||
isStacked: false,
|
|
||||||
source: ActionSource.viewer,
|
|
||||||
timelineOrigin: TimelineOrigin.trash,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ActionButtonType.trash.shouldShow(context), isFalse);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('restoreTrash button', () {
|
group('restoreTrash button', () {
|
||||||
test('should show when owner, not locked, has remote, and is in trash timeline', () {
|
test('should show when owner, not locked, has remote, and is in trash timeline', () {
|
||||||
final remoteAsset = createRemoteAsset();
|
final remoteAsset = createRemoteAsset();
|
||||||
@@ -539,60 +485,6 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('deletePermanent button', () {
|
|
||||||
test('should show when owner, not locked, has remote, and trash disabled', () {
|
|
||||||
final remoteAsset = createRemoteAsset();
|
|
||||||
final context = ActionButtonContext(
|
|
||||||
asset: remoteAsset,
|
|
||||||
isOwner: true,
|
|
||||||
isArchived: false,
|
|
||||||
isTrashEnabled: false,
|
|
||||||
isInLockedView: false,
|
|
||||||
currentAlbum: null,
|
|
||||||
advancedTroubleshooting: false,
|
|
||||||
isStacked: false,
|
|
||||||
source: ActionSource.timeline,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should not show when trash enabled', () {
|
|
||||||
final remoteAsset = createRemoteAsset();
|
|
||||||
final context = ActionButtonContext(
|
|
||||||
asset: remoteAsset,
|
|
||||||
isOwner: true,
|
|
||||||
isArchived: false,
|
|
||||||
isTrashEnabled: true,
|
|
||||||
isInLockedView: false,
|
|
||||||
currentAlbum: null,
|
|
||||||
advancedTroubleshooting: false,
|
|
||||||
isStacked: false,
|
|
||||||
source: ActionSource.timeline,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show when asset is trashed even with trash enabled', () {
|
|
||||||
final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024));
|
|
||||||
final context = ActionButtonContext(
|
|
||||||
asset: remoteAsset,
|
|
||||||
isOwner: true,
|
|
||||||
isArchived: false,
|
|
||||||
isTrashEnabled: true,
|
|
||||||
isInLockedView: false,
|
|
||||||
currentAlbum: null,
|
|
||||||
advancedTroubleshooting: false,
|
|
||||||
isStacked: false,
|
|
||||||
source: ActionSource.viewer,
|
|
||||||
timelineOrigin: TimelineOrigin.trash,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('delete button', () {
|
group('delete button', () {
|
||||||
test('should show when owner, not locked, and has remote', () {
|
test('should show when owner, not locked, and has remote', () {
|
||||||
final remoteAsset = createRemoteAsset();
|
final remoteAsset = createRemoteAsset();
|
||||||
@@ -632,7 +524,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('deleteLocal button', () {
|
group('deleteLocal button', () {
|
||||||
test('should show when not locked and asset is local only', () {
|
test('should not show when asset is local only', () {
|
||||||
final localAsset = createLocalAsset();
|
final localAsset = createLocalAsset();
|
||||||
final context = ActionButtonContext(
|
final context = ActionButtonContext(
|
||||||
asset: localAsset,
|
asset: localAsset,
|
||||||
@@ -646,23 +538,6 @@ void main() {
|
|||||||
source: ActionSource.timeline,
|
source: ActionSource.timeline,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should not show when asset is not local only', () {
|
|
||||||
final remoteAsset = createRemoteAsset();
|
|
||||||
final context = ActionButtonContext(
|
|
||||||
asset: remoteAsset,
|
|
||||||
isOwner: true,
|
|
||||||
isArchived: false,
|
|
||||||
isTrashEnabled: true,
|
|
||||||
isInLockedView: false,
|
|
||||||
currentAlbum: null,
|
|
||||||
advancedTroubleshooting: false,
|
|
||||||
isStacked: false,
|
|
||||||
source: ActionSource.timeline,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse);
|
expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user