mirror of
https://github.com/immich-app/immich.git
synced 2026-07-23 05:44:48 +03:00
Compare commits
1 Commits
fix/androi
...
fix/auto-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5302d6ea0e |
@@ -1461,7 +1461,6 @@
|
||||
"move_to_lock_folder_action_prompt": "{count} added to the locked folder",
|
||||
"move_to_locked_folder": "Move to locked folder",
|
||||
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
|
||||
"move_to_locked_folder_local_ios": "These items will be deleted from Photos, but will still be available on the Immich server. They will be in Recently Deleted for 30 days.",
|
||||
"moved_to_trash": "Moved to trash",
|
||||
"mute_memories": "Mute Memories",
|
||||
"my_albums": "My albums",
|
||||
|
||||
@@ -9,6 +9,8 @@ enum SortOrder {
|
||||
|
||||
enum TextSearchType { context, filename, description, ocr }
|
||||
|
||||
enum AssetVisibilityEnum { timeline, hidden, archive, locked }
|
||||
|
||||
enum ActionSource { timeline, viewer }
|
||||
|
||||
enum ShareAssetType { original, preview }
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
@@ -293,20 +292,4 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
..orderBy([(row) => OrderingTerm.asc(row.sequence)]);
|
||||
return query.map((row) => row.toDto()!).get();
|
||||
}
|
||||
|
||||
Future<void> update(
|
||||
List<String> remoteIds, {
|
||||
Option<bool> isFavorite = const .none(),
|
||||
Option<AssetVisibility> visibility = const .none(),
|
||||
}) {
|
||||
final companion = RemoteAssetEntityCompanion(
|
||||
visibility: visibility.toDriftValue(),
|
||||
isFavorite: isFavorite.toDriftValue(),
|
||||
);
|
||||
return _db.batch((batch) {
|
||||
for (final remoteId in remoteIds) {
|
||||
batch.update(_db.remoteAssetEntity, companion, where: (e) => e.id.equals(remoteId));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,15 +16,11 @@ Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref,
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source, context);
|
||||
if (result == null) {
|
||||
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(
|
||||
|
||||
@@ -9,7 +9,6 @@ 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/extensions/platform_extensions.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/infrastructure/album.provider.dart';
|
||||
@@ -25,7 +24,6 @@ import 'package:immich_mobile/services/action.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/common/confirm_dialog.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -70,11 +68,14 @@ class ActionNotifier extends Notifier<void> {
|
||||
return _getAssets(source).whereType<RemoteAsset>().toIds().toList(growable: false);
|
||||
}
|
||||
|
||||
List<String> _getLocalIdsForSource(ActionSource source) {
|
||||
List<String> _getLocalIdsForSource(ActionSource source, {bool ignoreLocalOnly = false}) {
|
||||
final Set<BaseAsset> assets = _getAssets(source);
|
||||
final List<String> localIds = [];
|
||||
|
||||
for (final asset in assets) {
|
||||
if (ignoreLocalOnly && asset.storage != AssetState.merged) {
|
||||
continue;
|
||||
}
|
||||
if (asset is LocalAsset) {
|
||||
localIds.add(asset.id);
|
||||
} else if (asset is RemoteAsset && asset.localId != null) {
|
||||
@@ -180,28 +181,12 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult?> moveToLockFolder(ActionSource source, BuildContext context) async {
|
||||
final assets = _getOwnedRemoteAssetsForSource(source);
|
||||
final ids = assets.toIds().toList(growable: false);
|
||||
final localIds = assets.map((asset) => asset.localId).nonNulls.toList(growable: false);
|
||||
|
||||
if (localIds.isNotEmpty) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => ConfirmDialog(
|
||||
title: "move_to_locked_folder",
|
||||
content: CurrentPlatform.isAndroid ? "delete_dialog_alert_local" : "move_to_locked_folder_local_ios",
|
||||
ok: "confirm",
|
||||
),
|
||||
);
|
||||
if (confirmed != true) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> moveToLockFolder(ActionSource source) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
final localIds = _getLocalIdsForSource(source, ignoreLocalOnly: true);
|
||||
try {
|
||||
final deletedCount = await _service.moveToLockFolder(ids, localIds);
|
||||
return ActionResult(count: ids.length, success: deletedCount == localIds.length);
|
||||
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());
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/repositories/toast.repository.dart';
|
||||
|
||||
final toastRepositoryProvider = Provider<ToastRepository>((ref) => const .new());
|
||||
@@ -1,14 +1,12 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart' hide AssetEditAction;
|
||||
import 'package:immich_mobile/domain/models/stack.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/repositories/api.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:openapi/api.dart' as api show AssetVisibility;
|
||||
import 'package:openapi/api.dart' hide AssetVisibility;
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final assetApiRepositoryProvider = Provider(
|
||||
(ref) => AssetApiRepository(
|
||||
@@ -43,7 +41,7 @@ class AssetApiRepository extends ApiRepository {
|
||||
return response?.count ?? 0;
|
||||
}
|
||||
|
||||
Future<void> updateVisibility(List<String> ids, AssetVisibility visibility) async {
|
||||
Future<void> updateVisibility(List<String> ids, AssetVisibilityEnum visibility) async {
|
||||
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility))));
|
||||
}
|
||||
|
||||
@@ -79,11 +77,11 @@ class AssetApiRepository extends ApiRepository {
|
||||
return _api.downloadAssetWithHttpInfo(id, edited: edited);
|
||||
}
|
||||
|
||||
api.AssetVisibility _mapVisibility(AssetVisibility visibility) => switch (visibility) {
|
||||
AssetVisibility.timeline => api.AssetVisibility.timeline,
|
||||
AssetVisibility.hidden => api.AssetVisibility.hidden,
|
||||
AssetVisibility.locked => api.AssetVisibility.locked,
|
||||
AssetVisibility.archive => api.AssetVisibility.archive,
|
||||
_mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) {
|
||||
AssetVisibilityEnum.timeline => AssetVisibility.timeline,
|
||||
AssetVisibilityEnum.hidden => AssetVisibility.hidden,
|
||||
AssetVisibilityEnum.locked => AssetVisibility.locked,
|
||||
AssetVisibilityEnum.archive => AssetVisibility.archive,
|
||||
};
|
||||
|
||||
Future<String?> getAssetMIMEType(String assetId) async {
|
||||
@@ -108,20 +106,6 @@ class AssetApiRepository extends ApiRepository {
|
||||
Future<void> removeEdits(String assetId) async {
|
||||
return _api.removeAssetEdits(assetId);
|
||||
}
|
||||
|
||||
Future<void> update(
|
||||
List<String> remoteIds, {
|
||||
Option<bool> isFavorite = const .none(),
|
||||
Option<AssetVisibility> visibility = const .none(),
|
||||
}) {
|
||||
return _api.updateAssets(
|
||||
AssetBulkUpdateDto(
|
||||
ids: remoteIds,
|
||||
isFavorite: isFavorite.toOptional(),
|
||||
visibility: visibility.map(_mapVisibility).toOptional(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on StackResponseDto {
|
||||
|
||||
@@ -45,9 +45,9 @@ class AssetMediaRepository {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<List<String>> deleteAll(List<String> ids, {bool trash = true}) async {
|
||||
Future<List<String>> deleteAll(List<String> ids) async {
|
||||
if (CurrentPlatform.isAndroid) {
|
||||
if (trash && await _androidSupportsTrash()) {
|
||||
if (await _androidSupportsTrash()) {
|
||||
return PhotoManager.editor.android.moveToTrash(
|
||||
ids.map((e) => AssetEntity(id: e, width: 1, height: 1, typeInt: 0)).toList(),
|
||||
);
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class ToastOption {
|
||||
final Duration? timeout;
|
||||
final FutureOr<void> Function()? onUndo;
|
||||
|
||||
const ToastOption({this.timeout, this.onUndo});
|
||||
}
|
||||
|
||||
class ToastRepository {
|
||||
const ToastRepository();
|
||||
|
||||
FutureOr<void> success(String message, {ToastOption? toast}) {
|
||||
snackbar.success(message, duration: toast?.timeout);
|
||||
}
|
||||
|
||||
FutureOr<void> info(String message, {ToastOption? toast}) {
|
||||
snackbar.info(message, duration: toast?.timeout);
|
||||
}
|
||||
|
||||
FutureOr<void> error(String message, {ToastOption? toast}) {
|
||||
snackbar.error(message, duration: toast?.timeout);
|
||||
}
|
||||
}
|
||||
@@ -79,28 +79,27 @@ class ActionService {
|
||||
}
|
||||
|
||||
Future<void> archive(List<String> remoteIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, .archive);
|
||||
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.archive);
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.archive);
|
||||
}
|
||||
|
||||
Future<void> unArchive(List<String> remoteIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, .timeline);
|
||||
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
|
||||
}
|
||||
|
||||
Future<int> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, .locked);
|
||||
Future<void> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked);
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked);
|
||||
|
||||
// Locked assets stay on the server, so permanently delete the local copies instead of trashing them
|
||||
if (localIds.isEmpty) {
|
||||
return 0;
|
||||
// Ask user if they want to delete local copies
|
||||
if (localIds.isNotEmpty) {
|
||||
await _deleteLocalAssets(localIds);
|
||||
}
|
||||
return _deleteLocalAssets(localIds, trash: false);
|
||||
}
|
||||
|
||||
Future<void> removeFromLockFolder(List<String> remoteIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, .timeline);
|
||||
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
|
||||
}
|
||||
|
||||
@@ -314,12 +313,12 @@ class ActionService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> _deleteLocalAssets(List<String> localIds, {bool trash = true}) async {
|
||||
final deletedIds = await _assetMediaRepository.deleteAll(localIds, trash: trash);
|
||||
Future<int> _deleteLocalAssets(List<String> localIds) async {
|
||||
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
|
||||
if (deletedIds.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
if (trash && CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds);
|
||||
} else {
|
||||
await _localAssetRepository.delete(deletedIds);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:openapi/api.dart' show Optional;
|
||||
|
||||
sealed class Option<T> {
|
||||
@@ -22,11 +21,6 @@ sealed class Option<T> {
|
||||
None() => null,
|
||||
};
|
||||
|
||||
Option<U> map<U>(U Function(T value) f) => switch (this) {
|
||||
Some(:final value) => Some(f(value)),
|
||||
None() => None<U>(),
|
||||
};
|
||||
|
||||
U fold<U>(U Function(T value) onSome, U Function() onNone) => switch (this) {
|
||||
Some(:final value) => onSome(value),
|
||||
None() => onNone(),
|
||||
@@ -71,10 +65,3 @@ extension OptionToOptional<T> on Option<T> {
|
||||
Some(:final value) => Optional.present(value),
|
||||
};
|
||||
}
|
||||
|
||||
extension OptionToDriftValue<T> on Option<T> {
|
||||
Value<T> toDriftValue() => switch (this) {
|
||||
Some(:final value) => Value(value),
|
||||
None() => const Value.absent(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,24 @@ String sanitizeUrl(String url) {
|
||||
return urlWithSchema.trimRight().replaceFirst(RegExp(r"/+$"), "");
|
||||
}
|
||||
|
||||
/// Validates a user-entered server URL
|
||||
bool isValidServerUrl(String? url) {
|
||||
if (url == null || url.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!url.contains('://')) {
|
||||
// Prepend conforming scheme for URL validation
|
||||
// Uri.tryParse will not parse out host without a scheme provided, even though we assume http(s) when connecting
|
||||
url = 'http://$url';
|
||||
}
|
||||
|
||||
final parsedUrl = Uri.tryParse(url);
|
||||
return parsedUrl != null &&
|
||||
(parsedUrl.scheme.isEmpty || parsedUrl.scheme.startsWith(RegExp(r'https?'))) &&
|
||||
parsedUrl.host.isNotEmpty;
|
||||
}
|
||||
|
||||
String? getServerUrl() {
|
||||
final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint));
|
||||
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
|
||||
|
||||
@@ -43,18 +43,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
|
||||
final log = Logger('LoginForm');
|
||||
|
||||
String? _validateUrl(String? url) {
|
||||
if (url == null || url.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final parsedUrl = Uri.tryParse(url);
|
||||
if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) {
|
||||
return 'login_form_err_invalid_url'.tr();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
String? _validateUrl(String? url) => isValidServerUrl(url) ? null : 'login_form_err_invalid_url'.tr();
|
||||
|
||||
String? _validateEmail(String? email) {
|
||||
if (email == null || email == '') {
|
||||
|
||||
@@ -6,23 +6,18 @@ final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
class SnackbarManager {
|
||||
const SnackbarManager();
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(
|
||||
String message,
|
||||
SnackbarType type, {
|
||||
Duration? duration,
|
||||
}) {
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(String message, SnackbarType type) {
|
||||
final messenger = scaffoldMessengerKey.currentState;
|
||||
final context = scaffoldMessengerKey.currentContext;
|
||||
if (messenger == null || context == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
duration ??= const .new(seconds: 4);
|
||||
messenger.hideCurrentSnackBar();
|
||||
return messenger.showSnackBar(_build(context, message, type, duration));
|
||||
return messenger.showSnackBar(_build(context, message, type));
|
||||
}
|
||||
|
||||
SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration) {
|
||||
SnackBar _build(BuildContext context, String message, SnackbarType type) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.extension<ImmichColors>() ?? ImmichColors.harmonized(theme.colorScheme);
|
||||
final (IconData icon, Color background, Color foreground) = switch (type) {
|
||||
@@ -34,7 +29,7 @@ class SnackbarManager {
|
||||
return SnackBar(
|
||||
behavior: .floating,
|
||||
backgroundColor: background,
|
||||
duration: duration,
|
||||
duration: const .new(seconds: 4),
|
||||
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))),
|
||||
content: Row(
|
||||
children: [
|
||||
@@ -53,14 +48,11 @@ class SnackbarManager {
|
||||
);
|
||||
}
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message, {Duration? duration}) =>
|
||||
show(message, .info, duration: duration);
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message) => show(message, .info);
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message, {Duration? duration}) =>
|
||||
show(message, .success, duration: duration);
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message) => show(message, .success);
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message, {Duration? duration}) =>
|
||||
show(message, .error, duration: duration);
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message) => show(message, .error);
|
||||
}
|
||||
|
||||
const snackbar = SnackbarManager();
|
||||
|
||||
@@ -77,6 +77,40 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('isValidServerUrl', () {
|
||||
test('should treat null as valid', () {
|
||||
expect(isValidServerUrl(null), isTrue);
|
||||
});
|
||||
|
||||
test('should treat empty string as valid', () {
|
||||
expect(isValidServerUrl(''), isTrue);
|
||||
});
|
||||
|
||||
test('should accept a bare host', () {
|
||||
expect(isValidServerUrl('demo.immich.app'), isTrue);
|
||||
});
|
||||
|
||||
test('should accept a bare host with a port', () {
|
||||
expect(isValidServerUrl('192.168.1.1:2283'), isTrue);
|
||||
});
|
||||
|
||||
test('should accept an http URL', () {
|
||||
expect(isValidServerUrl('http://demo.immich.app'), isTrue);
|
||||
});
|
||||
|
||||
test('should accept an https URL', () {
|
||||
expect(isValidServerUrl('https://demo.immich.app:2283/api'), isTrue);
|
||||
});
|
||||
|
||||
test('should reject a non-http scheme', () {
|
||||
expect(isValidServerUrl('ftp://demo.immich.app'), isFalse);
|
||||
});
|
||||
|
||||
test('should reject scheme only input', () {
|
||||
expect(isValidServerUrl('https://'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('punycodeDecodeUrl', () {
|
||||
test('should return null for null input', () {
|
||||
expect(punycodeDecodeUrl(null), isNull);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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';
|
||||
@@ -10,14 +10,11 @@ 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/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/action.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../widget_tester_extensions.dart';
|
||||
|
||||
class MockActionService extends Mock implements ActionService {}
|
||||
|
||||
class MockAssetService extends Mock implements AssetService {}
|
||||
@@ -80,9 +77,7 @@ void main() {
|
||||
container.listen(assetExifProvider(_asset), (_, __) {});
|
||||
await container.read(assetExifProvider(_asset).future);
|
||||
|
||||
final result = await container
|
||||
.read(actionProvider.notifier)
|
||||
.editDateTime(ActionSource.viewer, FakeBuildContext());
|
||||
final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.viewer, FakeBuildContext());
|
||||
|
||||
expect(result?.success, isTrue);
|
||||
await container.read(assetExifProvider(_asset).future);
|
||||
@@ -94,9 +89,7 @@ void main() {
|
||||
container.listen(assetExifProvider(_asset), (_, __) {});
|
||||
await container.read(assetExifProvider(_asset).future);
|
||||
|
||||
final result = await container
|
||||
.read(actionProvider.notifier)
|
||||
.editDateTime(ActionSource.timeline, FakeBuildContext());
|
||||
final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.timeline, FakeBuildContext());
|
||||
|
||||
expect(result?.success, isTrue);
|
||||
await container.read(assetExifProvider(_asset).future);
|
||||
@@ -109,76 +102,11 @@ void main() {
|
||||
container.listen(assetExifProvider(_asset), (_, __) {});
|
||||
await container.read(assetExifProvider(_asset).future);
|
||||
|
||||
final result = await container
|
||||
.read(actionProvider.notifier)
|
||||
.editDateTime(ActionSource.viewer, FakeBuildContext());
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
group('moveToLockFolder', () {
|
||||
Future<ActionResult?> runAction(WidgetTester tester) async {
|
||||
late BuildContext context;
|
||||
await tester.pumpConsumerWidget(
|
||||
Builder(
|
||||
builder: (value) {
|
||||
context = value;
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final result = container.read(actionProvider.notifier).moveToLockFolder(ActionSource.timeline, context);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byType(TextButton).last);
|
||||
await tester.pumpAndSettle();
|
||||
return result;
|
||||
}
|
||||
|
||||
testWidgets('reports failure when local deletion is cancelled', (tester) async {
|
||||
final asset = _asset.copyWith(localId: 'local-owned');
|
||||
container.read(multiSelectProvider.notifier).selectAsset(asset);
|
||||
when(() => actionService.moveToLockFolder(any(), any())).thenAnswer((_) async => 0);
|
||||
|
||||
final result = await runAction(tester);
|
||||
|
||||
expect(result?.success, isFalse);
|
||||
verify(() => actionService.moveToLockFolder(['asset-1'], ['local-owned'])).called(1);
|
||||
});
|
||||
|
||||
testWidgets('reports failure when only some local copies are deleted', (tester) async {
|
||||
final first = _asset.copyWith(localId: 'local-1');
|
||||
final second = _asset.copyWith(id: 'asset-2', localId: 'local-2', checksum: 'checksum-2');
|
||||
container.read(multiSelectProvider.notifier)
|
||||
..selectAsset(first)
|
||||
..selectAsset(second);
|
||||
when(() => actionService.moveToLockFolder(any(), any())).thenAnswer((_) async => 1);
|
||||
|
||||
final result = await runAction(tester);
|
||||
|
||||
expect(result?.success, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('deletes only owned local copies from a mixed selection', (tester) async {
|
||||
final owned = _asset.copyWith(localId: 'local-owned');
|
||||
final partner = _asset.copyWith(
|
||||
id: 'asset-2',
|
||||
localId: 'local-partner',
|
||||
ownerId: 'partner-1',
|
||||
checksum: 'checksum-2',
|
||||
);
|
||||
container.read(multiSelectProvider.notifier)
|
||||
..selectAsset(owned)
|
||||
..selectAsset(partner);
|
||||
when(() => actionService.moveToLockFolder(any(), any())).thenAnswer((_) async => 1);
|
||||
|
||||
final result = await runAction(tester);
|
||||
|
||||
expect(result?.success, isTrue);
|
||||
verify(() => actionService.moveToLockFolder(['asset-1'], ['local-owned'])).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:drift/drift.dart' as drift;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.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/entities/store.entity.dart';
|
||||
@@ -148,13 +147,13 @@ void main() {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
const ids = ['a', 'b'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids, trash: true)).thenAnswer((_) async => ids);
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
|
||||
when(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).thenAnswer((_) async {});
|
||||
|
||||
final result = await sut.deleteLocal(ids);
|
||||
|
||||
expect(result, ids.length);
|
||||
verify(() => assetMediaRepository.deleteAll(ids, trash: true)).called(1);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verify(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).called(1);
|
||||
verifyNever(() => localAssetRepository.delete(any()));
|
||||
});
|
||||
@@ -163,13 +162,13 @@ void main() {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
const ids = ['c'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids, trash: true)).thenAnswer((_) async => ids);
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
|
||||
when(() => localAssetRepository.delete(ids)).thenAnswer((_) async {});
|
||||
|
||||
final result = await sut.deleteLocal(ids);
|
||||
|
||||
expect(result, ids.length);
|
||||
verify(() => assetMediaRepository.deleteAll(ids, trash: true)).called(1);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verify(() => localAssetRepository.delete(ids)).called(1);
|
||||
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
|
||||
});
|
||||
@@ -178,73 +177,14 @@ void main() {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
const ids = ['x'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids, trash: true)).thenAnswer((_) async => <String>[]);
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => <String>[]);
|
||||
|
||||
final result = await sut.deleteLocal(ids);
|
||||
|
||||
expect(result, 0);
|
||||
verify(() => assetMediaRepository.deleteAll(ids, trash: true)).called(1);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
|
||||
verifyNever(() => localAssetRepository.delete(any()));
|
||||
});
|
||||
});
|
||||
|
||||
group('ActionService.moveToLockFolder', () {
|
||||
const remoteIds = ['r1', 'r2'];
|
||||
const localIds = ['l1', 'l2'];
|
||||
|
||||
test('permanently deletes local copies without trashing, even when Android trash handling is on', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
|
||||
when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
|
||||
when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
|
||||
when(() => assetMediaRepository.deleteAll(localIds, trash: false)).thenAnswer((_) async => localIds);
|
||||
when(() => localAssetRepository.delete(localIds)).thenAnswer((_) async {});
|
||||
|
||||
final result = await sut.moveToLockFolder(remoteIds, localIds);
|
||||
|
||||
expect(result, localIds.length);
|
||||
verify(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).called(1);
|
||||
verify(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).called(1);
|
||||
verify(() => assetMediaRepository.deleteAll(localIds, trash: false)).called(1);
|
||||
verify(() => localAssetRepository.delete(localIds)).called(1);
|
||||
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
|
||||
});
|
||||
|
||||
test('locks remote assets without touching local media when there are no local copies', () async {
|
||||
when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
|
||||
when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
|
||||
|
||||
final result = await sut.moveToLockFolder(remoteIds, const []);
|
||||
|
||||
expect(result, 0);
|
||||
verify(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).called(1);
|
||||
verifyNever(() => assetMediaRepository.deleteAll(any(), trash: any(named: 'trash')));
|
||||
});
|
||||
|
||||
test('returns zero when local deletion is cancelled', () async {
|
||||
when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
|
||||
when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
|
||||
when(() => assetMediaRepository.deleteAll(localIds, trash: false)).thenAnswer((_) async => <String>[]);
|
||||
|
||||
final result = await sut.moveToLockFolder(remoteIds, localIds);
|
||||
|
||||
expect(result, 0);
|
||||
verify(() => assetMediaRepository.deleteAll(localIds, trash: false)).called(1);
|
||||
verifyNever(() => localAssetRepository.delete(any()));
|
||||
});
|
||||
|
||||
test('returns the number of local copies deleted from a partial result', () async {
|
||||
const deletedIds = ['l1'];
|
||||
when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
|
||||
when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
|
||||
when(() => assetMediaRepository.deleteAll(localIds, trash: false)).thenAnswer((_) async => deletedIds);
|
||||
when(() => localAssetRepository.delete(deletedIds)).thenAnswer((_) async {});
|
||||
|
||||
final result = await sut.moveToLockFolder(remoteIds, localIds);
|
||||
|
||||
expect(result, deletedIds.length);
|
||||
verify(() => localAssetRepository.delete(deletedIds)).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user