mirror of
https://github.com/immich-app/immich.git
synced 2026-07-25 14:00:45 +03:00
Compare commits
2 Commits
refactor/a
...
fix/min-fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70f9d4ae02 | ||
|
|
4a5f13d0e5 |
@@ -60,7 +60,6 @@ sealed class BaseAsset {
|
||||
bool get hasLocal => storage == AssetState.local || storage == AssetState.merged;
|
||||
bool get isLocalOnly => storage == AssetState.local;
|
||||
bool get isRemoteOnly => storage == AssetState.remote;
|
||||
bool get isMerged => storage == .merged;
|
||||
|
||||
// Same asset even if localId is known on one side but not the other (heroTag isn't stable then)
|
||||
bool refersToSameAsset(BaseAsset other) {
|
||||
|
||||
@@ -74,8 +74,6 @@ class RemoteAsset extends BaseAsset {
|
||||
|
||||
bool get isArchived => visibility == .archive;
|
||||
|
||||
bool get isLocked => visibility == .locked;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''Asset {
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
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_edit.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/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/utils/option.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class AssetService {
|
||||
final RemoteAssetRepository _remoteRepository;
|
||||
final RemoteExifRepository _exifRepository;
|
||||
final DriftLocalAssetRepository _localRepository;
|
||||
final AssetApiRepository _apiRepository;
|
||||
|
||||
const AssetService({
|
||||
required this._remoteRepository,
|
||||
required this._exifRepository,
|
||||
required this._localRepository,
|
||||
required this._apiRepository,
|
||||
});
|
||||
const AssetService({required this._remoteRepository, required this._localRepository, required this._apiRepository});
|
||||
|
||||
Future<BaseAsset?> getAsset(BaseAsset asset) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
||||
@@ -36,6 +26,10 @@ class AssetService {
|
||||
return _localRepository.getByChecksum(checksum);
|
||||
}
|
||||
|
||||
Future<LocalAsset?> getLocalAsset(String id) {
|
||||
return _localRepository.get(id);
|
||||
}
|
||||
|
||||
Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) {
|
||||
return _remoteRepository.getByChecksum(checksum);
|
||||
}
|
||||
@@ -79,99 +73,6 @@ class AssetService {
|
||||
return _localRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection);
|
||||
}
|
||||
|
||||
Future<void> restoreTrash(List<String> remoteIds) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _apiRepository.restoreTrash(remoteIds);
|
||||
await _remoteRepository.restoreTrash(remoteIds);
|
||||
}
|
||||
|
||||
Future<void> stack(String userId, List<String> remoteIds) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final stack = await _apiRepository.stack(remoteIds);
|
||||
await _remoteRepository.stack(userId, stack);
|
||||
}
|
||||
|
||||
Future<void> unstack(List<String> stackIds) async {
|
||||
if (stackIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _remoteRepository.unStack(stackIds);
|
||||
await _apiRepository.unStack(stackIds);
|
||||
}
|
||||
|
||||
Future<void> update(
|
||||
List<String> remoteIds, {
|
||||
Option<bool> isFavorite = const .none(),
|
||||
Option<AssetVisibility> visibility = const .none(),
|
||||
Option<LatLng> location = const .none(),
|
||||
Option<String> dateTime = const .none(),
|
||||
}) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final parsedDateTime = dateTime.map((dt) => DateTime.parse(dt));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(shenlong): remove after action migration
|
||||
Future<LocalAsset?> getLocalAsset(String id) {
|
||||
return _localRepository.get(id);
|
||||
}
|
||||
|
||||
Future<void> updateFavorite(List<String> remoteIds, bool isFavorite) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
return;
|
||||
|
||||
@@ -341,10 +341,4 @@ class RemoteAlbumService {
|
||||
|
||||
return sortedAlbums;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,10 +50,12 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
|
||||
faces.isVisible.equals(true) &
|
||||
faces.deletedAt.isNull(),
|
||||
)
|
||||
..groupBy([people.id], having: faces.id.count().isBiggerOrEqualValue(minFaces) | people.name.equals('').not())
|
||||
..groupBy([
|
||||
people.id,
|
||||
], having: faces.assetId.count(distinct: true).isBiggerOrEqualValue(minFaces) | people.name.equals('').not())
|
||||
..orderBy([
|
||||
OrderingTerm(expression: people.name.equals('').not(), mode: OrderingMode.desc),
|
||||
OrderingTerm(expression: faces.id.count(), mode: OrderingMode.desc),
|
||||
OrderingTerm(expression: faces.assetId.count(distinct: true), mode: OrderingMode.desc),
|
||||
]);
|
||||
|
||||
return query.map((row) {
|
||||
|
||||
@@ -72,7 +72,13 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
|
||||
final query = _db.remoteAssetEntity.select()
|
||||
..where((row) => row.stackId.equals(stackId) & row.id.equals(asset.id).not())
|
||||
..where(
|
||||
(row) =>
|
||||
row.stackId.equals(stackId) &
|
||||
row.id.equals(asset.id).not() &
|
||||
row.deletedAt.isNull() &
|
||||
row.visibility.equalsValue(AssetVisibility.timeline),
|
||||
)
|
||||
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]);
|
||||
|
||||
return query.map((row) => row.toDto()).get();
|
||||
@@ -183,6 +189,38 @@ 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) {
|
||||
return _db.transaction(() async {
|
||||
final stackIds = await _db.managers.stackEntity
|
||||
@@ -260,16 +298,10 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
List<String> remoteIds, {
|
||||
Option<bool> isFavorite = 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(
|
||||
visibility: visibility.toDriftValue(),
|
||||
isFavorite: isFavorite.toDriftValue(),
|
||||
createdAt: createdAt.toDriftValue(),
|
||||
);
|
||||
return _db.batch((batch) {
|
||||
for (final remoteId in remoteIds) {
|
||||
@@ -277,37 +309,4 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// TODO(shenlong): remove after action migration
|
||||
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),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ class _ActionWidget extends ConsumerWidget {
|
||||
try {
|
||||
await action.onAction(scope);
|
||||
} catch (error, stackTrace) {
|
||||
handleError(scope.context, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
|
||||
handleError(scope.context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:hooks_riverpod/hooks_riverpod.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/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/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
@@ -16,8 +15,6 @@ final remoteAssetRepositoryProvider = Provider<RemoteAssetRepository>(
|
||||
(ref) => RemoteAssetRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
|
||||
final remoteExifRepositoryProvider = Provider((ref) => RemoteExifRepository(ref.watch(driftProvider)));
|
||||
|
||||
final trashedLocalAssetRepository = Provider<DriftTrashedLocalAssetRepository>(
|
||||
(ref) => DriftTrashedLocalAssetRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
@@ -25,7 +22,6 @@ final trashedLocalAssetRepository = Provider<DriftTrashedLocalAssetRepository>(
|
||||
final assetServiceProvider = Provider(
|
||||
(ref) => AssetService(
|
||||
remoteRepository: ref.watch(remoteAssetRepositoryProvider),
|
||||
exifRepository: ref.watch(remoteExifRepositoryProvider),
|
||||
localRepository: ref.watch(localAssetRepository),
|
||||
apiRepository: ref.watch(assetApiRepositoryProvider),
|
||||
),
|
||||
|
||||
@@ -43,11 +43,28 @@ class AssetApiRepository extends ApiRepository {
|
||||
return response?.count ?? 0;
|
||||
}
|
||||
|
||||
// TODO(shenlong): remove after action migration
|
||||
Future<void> updateVisibility(List<String> ids, AssetVisibility visibility) async {
|
||||
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility))));
|
||||
}
|
||||
|
||||
Future<void> updateFavorite(List<String> ids, bool isFavorite) async {
|
||||
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, isFavorite: Optional.present(isFavorite)));
|
||||
}
|
||||
|
||||
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 {
|
||||
final responseDto = await checkNull(_stacksApi.createStack(StackCreateDto(assetIds: ids)));
|
||||
|
||||
@@ -96,39 +113,15 @@ class AssetApiRepository extends ApiRepository {
|
||||
List<String> remoteIds, {
|
||||
Option<bool> isFavorite = const .none(),
|
||||
Option<AssetVisibility> visibility = const .none(),
|
||||
Option<String> dateTimeOriginal = const .none(),
|
||||
Option<LatLng> location = const .none(),
|
||||
}) {
|
||||
return _api.updateAssets(
|
||||
AssetBulkUpdateDto(
|
||||
ids: remoteIds,
|
||||
isFavorite: isFavorite.toOptional(),
|
||||
visibility: visibility.map(_mapVisibility).toOptional(),
|
||||
dateTimeOriginal: dateTimeOriginal.toOptional(),
|
||||
latitude: location.map((loc) => loc.latitude).toOptional(),
|
||||
longitude: location.map((loc) => loc.longitude).toOptional(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// TODO(shenlong): remove after action migration
|
||||
Future<void> updateFavorite(List<String> ids, bool isFavorite) async {
|
||||
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, isFavorite: Optional.present(isFavorite)));
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
extension on StackResponseDto {
|
||||
|
||||
@@ -15,10 +15,8 @@ extension type const AssetFilter<T extends BaseAsset>(Iterable<T> assets) implem
|
||||
remote().where((asset) => asset.visibility != visibility);
|
||||
AssetFilter<RemoteAsset> archived({bool isArchived = true}) =>
|
||||
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> trashed({bool isTrashed = true}) => remote().where((asset) => asset.isTrashed == isTrashed);
|
||||
|
||||
AssetFilter<LocalAsset> local() => AssetFilter(assets.whereType<LocalAsset>());
|
||||
AssetFilter<BaseAsset> backedUp({bool isBackedUp = true}) => where((asset) => asset.isMerged == isBackedUp);
|
||||
AssetFilter<LocalAsset> backedUp() => local().where((asset) => asset.remoteAssetId != null);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:openapi/api.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:stack_trace/stack_trace.dart';
|
||||
|
||||
void handleError(Object error, {StackTrace? stack, String? description}) {
|
||||
void handleError(BuildContext context, Object error, {StackTrace? stack, String? description}) {
|
||||
String? stackTrace;
|
||||
if (stack != null) {
|
||||
final trace = Trace.from(stack);
|
||||
@@ -23,13 +23,17 @@ void handleError(Object error, {StackTrace? stack, String? description}) {
|
||||
() => 'Error${description != null ? ' ($description)' : ''}: $error${stackTrace != null ? '\n$stackTrace' : ''}',
|
||||
);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final String message;
|
||||
if (serverErrorMessage(error) case String serverMessage) {
|
||||
message = serverMessage;
|
||||
} else if (isConnectionError(error)) {
|
||||
message = StaticTranslations.instance.login_form_server_error;
|
||||
message = context.t.login_form_server_error;
|
||||
} else {
|
||||
message = StaticTranslations.instance.scaffold_body_error_occurred;
|
||||
message = context.t.scaffold_body_error_occurred;
|
||||
}
|
||||
|
||||
snackbar.error(message);
|
||||
|
||||
@@ -32,6 +32,17 @@ sealed class Option<T> {
|
||||
None() => onNone(),
|
||||
};
|
||||
|
||||
Option<U> flatMap<U>(Option<U> Function(T value) f) => switch (this) {
|
||||
Some(:final value) => f(value),
|
||||
None() => const Option.none(),
|
||||
};
|
||||
|
||||
void ifPresent(void Function(T value) f) {
|
||||
if (this case Some(:final value)) {
|
||||
f(value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => switch (this) {
|
||||
Some(:final value) => 'Some($value)',
|
||||
|
||||
22
mobile/test/domain/service.mock.dart
Normal file
22
mobile/test/domain/service.mock.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/domain/services/partner.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/utils/background_sync.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockStoreService extends Mock implements StoreService {}
|
||||
|
||||
class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
|
||||
|
||||
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
||||
|
||||
class MockAppSettingsService extends Mock implements AppSettingsService {}
|
||||
|
||||
class MockPartnerService extends Mock implements PartnerService {}
|
||||
|
||||
class MockAssetService extends Mock implements AssetService {}
|
||||
|
||||
class MockUserService extends Mock implements UserService {}
|
||||
@@ -16,10 +16,10 @@ import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../domain/service.mock.dart';
|
||||
import '../../fixtures/asset.stub.dart';
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../../repository.mocks.dart';
|
||||
import '../../service.mocks.dart';
|
||||
|
||||
void main() {
|
||||
late LocalSyncService sut;
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../../service.mocks.dart';
|
||||
import '../service.mock.dart';
|
||||
|
||||
void main() {
|
||||
// A container with the service's deps overridden but cancellationProvider left
|
||||
|
||||
@@ -9,7 +9,7 @@ import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../fixtures/user.stub.dart';
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../../service.mocks.dart';
|
||||
import '../service.mock.dart';
|
||||
|
||||
void main() {
|
||||
late UserService sut;
|
||||
|
||||
@@ -74,4 +74,53 @@ void main() {
|
||||
expect(people, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('getAllPeople', () {
|
||||
test('counts distinct assets, not face records, against minFaces', () async {
|
||||
// Regression check: a person can have multiple face records on the same asset
|
||||
// (e.g., metadata import + ML detection), which must not inflate the count used
|
||||
// to compare against minFaces. An unnamed person with 2 distinct photos but 3
|
||||
// face records (2 of them on the same photo) must not pass a minFaces of 3.
|
||||
final user = await ctx.newUser();
|
||||
final asset1 = await ctx.newRemoteAsset(ownerId: user.id);
|
||||
final asset2 = await ctx.newRemoteAsset(ownerId: user.id);
|
||||
|
||||
final person = await ctx.newPerson(ownerId: user.id, name: '');
|
||||
await ctx.newFace(assetId: asset1.id, personId: person.id);
|
||||
await ctx.newFace(assetId: asset1.id, personId: person.id);
|
||||
await ctx.newFace(assetId: asset2.id, personId: person.id);
|
||||
|
||||
final people = await sut.getAllPeople(minFaces: 3);
|
||||
|
||||
expect(people, isEmpty);
|
||||
});
|
||||
|
||||
test('returns unnamed people who meet minFaces based on distinct assets', () async {
|
||||
final user = await ctx.newUser();
|
||||
final asset1 = await ctx.newRemoteAsset(ownerId: user.id);
|
||||
final asset2 = await ctx.newRemoteAsset(ownerId: user.id);
|
||||
final asset3 = await ctx.newRemoteAsset(ownerId: user.id);
|
||||
|
||||
final person = await ctx.newPerson(ownerId: user.id, name: '');
|
||||
await ctx.newFace(assetId: asset1.id, personId: person.id);
|
||||
await ctx.newFace(assetId: asset2.id, personId: person.id);
|
||||
await ctx.newFace(assetId: asset3.id, personId: person.id);
|
||||
|
||||
final people = await sut.getAllPeople(minFaces: 3);
|
||||
|
||||
expect(people.map((p) => p.id), [person.id]);
|
||||
});
|
||||
|
||||
test('always returns named people regardless of minFaces', () async {
|
||||
final user = await ctx.newUser();
|
||||
final asset = await ctx.newRemoteAsset(ownerId: user.id);
|
||||
|
||||
final person = await ctx.newPerson(ownerId: user.id, name: 'Jane');
|
||||
await ctx.newFace(assetId: asset.id, personId: person.id);
|
||||
|
||||
final people = await sut.getAllPeople(minFaces: 3);
|
||||
|
||||
expect(people.map((p) => p.id), [person.id]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
import '../../../fixtures/asset.stub.dart';
|
||||
import '../../../unit/presentation/presentation_context.dart';
|
||||
|
||||
class _SeededAssetViewerNotifier extends AssetViewerStateNotifier {
|
||||
@override
|
||||
@@ -33,12 +32,6 @@ TimelineService _stubTimelineService() {
|
||||
}
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
|
||||
setUp(() async {
|
||||
context = await PresentationContext.create();
|
||||
});
|
||||
|
||||
testWidgets('status bar icons are light while the asset viewer is open in light mode', (tester) async {
|
||||
// Emulate arriving from a light-themed page whose AppBar set dark status
|
||||
// bar icons (the state the viewer is opened from in light mode).
|
||||
@@ -55,7 +48,6 @@ void main() {
|
||||
assetLoader: const CodegenLoader(),
|
||||
child: ProviderScope(
|
||||
overrides: [
|
||||
...context.overrides,
|
||||
timelineServiceProvider.overrideWithValue(_stubTimelineService()),
|
||||
assetViewerProvider.overrideWith(_SeededAssetViewerNotifier.new),
|
||||
],
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../service.mocks.dart';
|
||||
import '../../domain/service.mock.dart';
|
||||
import '../../unit/factories/remote_asset_factory.dart';
|
||||
|
||||
void main() {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import 'package:immich_mobile/domain/services/tag.service.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_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/auth.repository.dart';
|
||||
import 'package:immich_mobile/repositories/auth_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/download.repository.dart';
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/repositories/toast.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockAssetApiRepository extends Mock implements AssetApiRepository {}
|
||||
@@ -20,9 +17,3 @@ class MockAuthApiRepository extends Mock implements AuthApiRepository {}
|
||||
class MockAuthRepository extends Mock implements AuthRepository {}
|
||||
|
||||
class MockTagService extends Mock implements TagService {}
|
||||
|
||||
class MockDownloadRepository extends Mock implements DownloadRepository {}
|
||||
|
||||
class MockRemoteExifRepository extends Mock implements RemoteExifRepository {}
|
||||
|
||||
class MockToastRepository extends Mock implements ToastRepository {}
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import 'package:immich_mobile/domain/services/asset.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/user.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/services/api.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/network.service.dart';
|
||||
import 'package:immich_mobile/services/server_info.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockApiService extends Mock implements ApiService {}
|
||||
@@ -19,27 +8,3 @@ class MockApiService extends Mock implements ApiService {}
|
||||
class MockNetworkService extends Mock implements NetworkService {}
|
||||
|
||||
class MockAppSettingService extends Mock implements AppSettingsService {}
|
||||
|
||||
class MockStoreService extends Mock implements StoreService {}
|
||||
|
||||
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
||||
|
||||
class MockAppSettingsService extends Mock implements AppSettingsService {}
|
||||
|
||||
class MockPartnerService extends Mock implements PartnerService {}
|
||||
|
||||
class MockAssetService extends Mock implements AssetService {}
|
||||
|
||||
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 {}
|
||||
|
||||
class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
|
||||
|
||||
@@ -7,12 +7,15 @@ import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/repositories/download.repository.dart';
|
||||
import 'package:immich_mobile/services/action.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
|
||||
class MockDownloadRepository extends Mock implements DownloadRepository {}
|
||||
|
||||
void main() {
|
||||
late ActionService sut;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
import '../domain/service.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
import '../service.mocks.dart';
|
||||
|
||||
@@ -28,7 +29,13 @@ void main() {
|
||||
apiService = MockApiService();
|
||||
networkService = MockNetworkService();
|
||||
backgroundSyncManager = MockBackgroundSyncManager();
|
||||
sut = AuthService(authApiRepository, authRepository, apiService, networkService, backgroundSyncManager);
|
||||
sut = AuthService(
|
||||
authApiRepository,
|
||||
authRepository,
|
||||
apiService,
|
||||
networkService,
|
||||
backgroundSyncManager,
|
||||
);
|
||||
|
||||
registerFallbackValue(Uri());
|
||||
});
|
||||
|
||||
@@ -5,13 +5,12 @@ import '../../utils.dart';
|
||||
class LocalAssetFactory {
|
||||
const LocalAssetFactory();
|
||||
|
||||
static LocalAsset create({String? id, String? name, String? remoteId}) {
|
||||
static LocalAsset create({String? id, String? name}) {
|
||||
id = TestUtils.uuid(id);
|
||||
|
||||
return LocalAsset(
|
||||
id: id,
|
||||
name: name ?? 'local_$id.jpg',
|
||||
remoteId: remoteId,
|
||||
type: AssetType.image,
|
||||
createdAt: TestUtils.yesterday(),
|
||||
updatedAt: TestUtils.now(),
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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,11 +10,8 @@ class RemoteAssetFactory {
|
||||
String? name,
|
||||
String? ownerId,
|
||||
bool isFavorite = false,
|
||||
AssetVisibility visibility = .timeline,
|
||||
AssetType type = .image,
|
||||
AssetVisibility visibility = AssetVisibility.timeline,
|
||||
String? stackId,
|
||||
DateTime? deletedAt,
|
||||
String? localId,
|
||||
}) {
|
||||
id = TestUtils.uuid(id);
|
||||
|
||||
@@ -23,15 +20,13 @@ class RemoteAssetFactory {
|
||||
name: name ?? 'remote_$id.jpg',
|
||||
ownerId: TestUtils.uuid(ownerId),
|
||||
checksum: 'checksum-$id',
|
||||
type: type,
|
||||
type: .image,
|
||||
createdAt: TestUtils.yesterday(),
|
||||
updatedAt: TestUtils.now(),
|
||||
isFavorite: isFavorite,
|
||||
visibility: visibility,
|
||||
stackId: stackId,
|
||||
isEdited: false,
|
||||
deletedAt: deletedAt,
|
||||
localId: localId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,25 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/widgets.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/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/tag.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/services/foreground_upload.service.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';
|
||||
|
||||
import '../domain/service.mock.dart';
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
import '../service.mocks.dart';
|
||||
import 'factories/local_album_factory.dart';
|
||||
import 'factories/local_asset_factory.dart';
|
||||
import 'factories/remote_album_factory.dart';
|
||||
import 'factories/user_factory.dart';
|
||||
|
||||
class RepositoryMocks {
|
||||
final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository());
|
||||
final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository());
|
||||
final remoteAsset = RemoteAssetRepositoryStub(MockRemoteAssetRepository());
|
||||
final remoteExif = RemoteExifRepositoryStub(MockRemoteExifRepository());
|
||||
final trashedAsset = MockTrashedLocalAssetRepository();
|
||||
final toast = MockToastRepository();
|
||||
final remoteAlbum = MockRemoteAlbumRepository();
|
||||
final albumApi = MockDriftAlbumApiRepository();
|
||||
|
||||
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
|
||||
final assetApi = AssetApiRepositoryStub(MockAssetApiRepository());
|
||||
final assetMedia = AssetMediaRepositoryStub(MockAssetMediaRepository());
|
||||
final download = DownloadRepositoryStub(MockDownloadRepository());
|
||||
|
||||
RepositoryMocks() {
|
||||
resetAll();
|
||||
@@ -47,34 +29,11 @@ class RepositoryMocks {
|
||||
_registerFallbacks();
|
||||
localAlbum.reset();
|
||||
localAsset.reset();
|
||||
remoteAsset.reset();
|
||||
remoteExif.reset();
|
||||
reset(trashedAsset);
|
||||
reset(remoteAlbum);
|
||||
reset(albumApi);
|
||||
nativeApi.reset();
|
||||
assetApi.reset();
|
||||
assetMedia.reset();
|
||||
download.reset();
|
||||
reset(toast);
|
||||
_stubLocalAlbumRepository();
|
||||
_stubLocalAssetRepository();
|
||||
_stubRemoteAssetRepository();
|
||||
_stubRemoteExifRepository();
|
||||
_stubNativeSyncApi();
|
||||
_stubAssetApiRepository();
|
||||
_stubAssetMediaRepository();
|
||||
_stubDownloadRepository();
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -90,31 +49,12 @@ class RepositoryMocks {
|
||||
void _stubNativeSyncApi() {
|
||||
when(nativeApi.hashAssets).thenAnswer((_) async => []);
|
||||
}
|
||||
|
||||
void _stubAssetApiRepository() {
|
||||
when(assetApi.update).thenAnswer((_) async => {});
|
||||
}
|
||||
|
||||
void _stubAssetMediaRepository() {
|
||||
when(assetMedia.shareAssets).thenAnswer((_) async => 1);
|
||||
}
|
||||
|
||||
void _stubDownloadRepository() {
|
||||
when(download.downloadAllAssets).thenAnswer((_) async => const []);
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceMocks {
|
||||
final partner = PartnerServiceStub(MockPartnerService());
|
||||
final user = UserServiceStub(MockUserService());
|
||||
final asset = AssetServiceStub(MockAssetService());
|
||||
final album = RemoteAlbumServiceStub(MockRemoteAlbumService());
|
||||
final cleanup = CleanupServiceStub(MockCleanupService());
|
||||
final tag = TagServiceStub(MockTagService());
|
||||
final backgroundSync = MockBackgroundSyncManager();
|
||||
final upload = MockForegroundUploadService();
|
||||
final cast = MockGCastService();
|
||||
final serverInfo = MockServerInfoService();
|
||||
|
||||
ServiceMocks() {
|
||||
resetAll();
|
||||
@@ -125,21 +65,9 @@ class ServiceMocks {
|
||||
partner.reset();
|
||||
user.reset();
|
||||
asset.reset();
|
||||
album.reset();
|
||||
cleanup.reset();
|
||||
tag.reset();
|
||||
reset(cast);
|
||||
reset(serverInfo);
|
||||
reset(backgroundSync);
|
||||
reset(upload);
|
||||
_stubUserService();
|
||||
_stubPartnerService();
|
||||
_stubAssetService();
|
||||
_stubRemoteAlbumService();
|
||||
_stubCleanupService();
|
||||
_stubTagService();
|
||||
_stubBackgroundSync();
|
||||
_stubForegroundUpload();
|
||||
}
|
||||
|
||||
void _stubUserService() {
|
||||
@@ -160,43 +88,7 @@ class ServiceMocks {
|
||||
}
|
||||
|
||||
void _stubAssetService() {
|
||||
when(asset.update).thenAnswer((_) async {});
|
||||
when(asset.stack).thenAnswer((_) async {});
|
||||
when(asset.unstack).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);
|
||||
}
|
||||
|
||||
void _stubTagService() {
|
||||
when(tag.bulkTagAssets).thenAnswer((_) async => 0);
|
||||
when(tag.upsertTags).thenAnswer((_) async => const []);
|
||||
when(tag.getAllTags).thenAnswer((_) async => const {});
|
||||
}
|
||||
|
||||
void _stubBackgroundSync() {
|
||||
when(() => backgroundSync.syncLocal()).thenAnswer((_) async {});
|
||||
when(() => backgroundSync.hashAssets()).thenAnswer((_) async {});
|
||||
}
|
||||
|
||||
void _stubForegroundUpload() {
|
||||
when(
|
||||
() => upload.uploadManual(
|
||||
any(),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
callbacks: any(named: 'callbacks'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
when(asset.updateFavorite).thenAnswer((_) async {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,24 +96,8 @@ void _registerFallbacks() {
|
||||
registerFallbackValue(LocalAlbumFactory.create());
|
||||
registerFallbackValue(LocalAssetFactory.create());
|
||||
registerFallbackValue(Uint8List(0));
|
||||
registerFallbackValue(AssetVisibility.timeline);
|
||||
registerFallbackValue(const LatLng(0, 0));
|
||||
registerFallbackValue(<AssetEdit>[]);
|
||||
registerFallbackValue(const Option<bool>.none());
|
||||
registerFallbackValue(const Option<AssetVisibility>.none());
|
||||
registerFallbackValue(const Option<LatLng>.none());
|
||||
registerFallbackValue(const Option<String>.none());
|
||||
registerFallbackValue(const Option<DateTime>.none());
|
||||
registerFallbackValue(<BaseAsset>[]);
|
||||
registerFallbackValue(<RemoteAsset>[]);
|
||||
registerFallbackValue(<LocalAsset>[]);
|
||||
registerFallbackValue(ShareAssetType.original);
|
||||
registerFallbackValue(const UploadCallbacks());
|
||||
registerFallbackValue(_FakeBuildContext());
|
||||
}
|
||||
|
||||
class _FakeBuildContext extends Fake implements BuildContext {}
|
||||
|
||||
extension type const Stub<T extends Mock>(T mockedClass) {
|
||||
void reset() => mock.reset(mockedClass);
|
||||
}
|
||||
@@ -243,33 +119,6 @@ extension type const LocalAssetRepositoryStub(MockDriftLocalAssetRepository repo
|
||||
() => 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> {
|
||||
Stream<Iterable<User>> Function() get getCandidates =>
|
||||
() => service.getCandidates(any());
|
||||
@@ -316,89 +165,11 @@ extension type const UserServiceStub(MockUserService service) implements Stub<Mo
|
||||
}
|
||||
|
||||
extension type const AssetServiceStub(MockAssetService service) implements Stub<MockAssetService> {
|
||||
Future<void> Function() get update =>
|
||||
() => service.update(
|
||||
any(),
|
||||
isFavorite: any(named: 'isFavorite'),
|
||||
visibility: any(named: 'visibility'),
|
||||
dateTime: any(named: 'dateTime'),
|
||||
location: any(named: 'location'),
|
||||
);
|
||||
|
||||
Future<void> Function() get stack =>
|
||||
() => service.stack(any(), any());
|
||||
|
||||
Future<void> Function() get unstack =>
|
||||
() => service.unstack(any());
|
||||
|
||||
Future<void> Function() get restoreTrash =>
|
||||
() => 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());
|
||||
Future<void> Function() get updateFavorite =>
|
||||
() => service.updateFavorite(any(), any());
|
||||
}
|
||||
|
||||
extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub<MockNativeSyncApi> {
|
||||
Future<List<HashResult>> Function() get hashAssets =>
|
||||
() => 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'),
|
||||
);
|
||||
}
|
||||
|
||||
extension type const AssetMediaRepositoryStub(MockAssetMediaRepository api) implements Stub<MockAssetMediaRepository> {
|
||||
Future<int> Function() get shareAssets =>
|
||||
() => api.shareAssets(
|
||||
any(),
|
||||
any(),
|
||||
fileType: any(named: 'fileType'),
|
||||
cancelCompleter: any(named: 'cancelCompleter'),
|
||||
onAssetDownloadProgress: any(named: 'onAssetDownloadProgress'),
|
||||
);
|
||||
}
|
||||
|
||||
extension type const DownloadRepositoryStub(MockDownloadRepository repo) implements Stub<MockDownloadRepository> {
|
||||
Future<List<bool>> Function() get downloadAllAssets =>
|
||||
() => repo.downloadAllAssets(any());
|
||||
}
|
||||
|
||||
extension type const TagServiceStub(MockTagService service) implements Stub<MockTagService> {
|
||||
Future<int> Function() get bulkTagAssets =>
|
||||
() => service.bulkTagAssets(any(), any());
|
||||
|
||||
Future<List<Tag>> Function() get upsertTags =>
|
||||
() => service.upsertTags(any());
|
||||
|
||||
Future<Set<Tag>> Function() get getAllTags =>
|
||||
() => service.getAllTags();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../service.mocks.dart';
|
||||
import '../../../domain/service.mock.dart';
|
||||
import '../../factories/remote_asset_factory.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/partner.action.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../service.mocks.dart';
|
||||
import '../../../domain/service.mock.dart';
|
||||
import '../../factories/user_factory.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
|
||||
@@ -15,10 +15,7 @@ import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/gcast.service.dart';
|
||||
import 'package:immich_mobile/services/server_info.service.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
@@ -32,7 +29,6 @@ class PresentationContext {
|
||||
service = ServiceMocks(),
|
||||
repository = RepositoryMocks() {
|
||||
setup();
|
||||
addTearDown(dispose);
|
||||
}
|
||||
|
||||
static const String serverEndpoint = 'http://localhost:3000';
|
||||
@@ -47,9 +43,6 @@ class PresentationContext {
|
||||
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
|
||||
assetServiceProvider.overrideWithValue(service.asset.service),
|
||||
partnerServiceProvider.overrideWithValue(service.partner.service),
|
||||
gCastServiceProvider.overrideWithValue(service.cast),
|
||||
serverInfoServiceProvider.overrideWithValue(service.serverInfo),
|
||||
inLockedViewProvider.overrideWithValue(false),
|
||||
];
|
||||
|
||||
static Future<PresentationContext> create() async {
|
||||
@@ -68,7 +61,9 @@ class PresentationContext {
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
service.resetAll();
|
||||
addTearDown(() {
|
||||
service.resetAll();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../service.mocks.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 remotePhoto = RemoteAssetFactory.create();
|
||||
|
||||
final AssetFilter<BaseAsset> syncedPhotos = AssetFilter(<BaseAsset>[
|
||||
final AssetFilter<LocalAsset> syncedPhotos = AssetFilter(<BaseAsset>[
|
||||
syncedPhoto,
|
||||
offlinePhoto,
|
||||
remotePhoto,
|
||||
|
||||
@@ -42,7 +42,7 @@ group by
|
||||
having
|
||||
(
|
||||
"person"."name" != $3
|
||||
or count("asset_face"."assetId") >= COALESCE(
|
||||
or count(distinct ("asset_face"."assetId")) >= COALESCE(
|
||||
(
|
||||
SELECT
|
||||
value -> 'people' ->> 'minimumFaces'
|
||||
@@ -59,7 +59,7 @@ order by
|
||||
"person"."isHidden" asc,
|
||||
"person"."isFavorite" desc,
|
||||
NULLIF(person.name, '') is null asc,
|
||||
count("asset_face"."assetId") desc,
|
||||
count(distinct ("asset_face"."assetId")) desc,
|
||||
NULLIF(person.name, '') asc nulls last,
|
||||
"person"."createdAt"
|
||||
limit
|
||||
|
||||
@@ -168,7 +168,7 @@ export class PersonRepository {
|
||||
eb.or([
|
||||
eb('person.name', '!=', ''),
|
||||
eb(
|
||||
(innerEb) => innerEb.fn.count('asset_face.assetId'),
|
||||
(innerEb) => innerEb.fn.count(innerEb.fn('distinct', ['asset_face.assetId'])),
|
||||
'>=',
|
||||
sql<number>`COALESCE(
|
||||
(SELECT value -> 'people' ->> 'minimumFaces'
|
||||
@@ -201,7 +201,7 @@ export class PersonRepository {
|
||||
.$if(!options?.closestFaceAssetId, (qb) =>
|
||||
qb
|
||||
.orderBy(sql`NULLIF(person.name, '') is null`, 'asc')
|
||||
.orderBy((eb) => eb.fn.count('asset_face.assetId'), 'desc')
|
||||
.orderBy((eb) => eb.fn.count(eb.fn('distinct', ['asset_face.assetId'])), 'desc')
|
||||
.orderBy(sql`NULLIF(person.name, '')`, (om) => om.asc().nullsLast())
|
||||
.orderBy('person.createdAt'),
|
||||
)
|
||||
|
||||
@@ -1507,12 +1507,17 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
describe('handleGeneratePersonThumbnail', () => {
|
||||
it('should skip if machine learning is disabled', async () => {
|
||||
it('should generate a thumbnail even if machine learning is disabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled);
|
||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
|
||||
mocks.media.generateThumbnail.mockResolvedValue();
|
||||
mocks.media.decodeImage.mockResolvedValue({
|
||||
data: Buffer.from(''),
|
||||
info: { width: 1000, height: 1000 } as OutputInfo,
|
||||
});
|
||||
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.Skipped);
|
||||
expect(mocks.asset.getByIds).not.toHaveBeenCalled();
|
||||
expect(mocks.systemMetadata.get).toHaveBeenCalled();
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.Success);
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip a person not found', async () => {
|
||||
|
||||
@@ -43,7 +43,7 @@ import { getAssetFile, getDimensions } from 'src/utils/asset.util';
|
||||
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
|
||||
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { clamp, isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
|
||||
import { clamp } from 'src/utils/misc';
|
||||
import { getOutputDimensions } from 'src/utils/transform';
|
||||
|
||||
interface UpsertFileOptions {
|
||||
@@ -410,11 +410,7 @@ export class MediaService extends BaseService {
|
||||
|
||||
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
|
||||
async handleGeneratePersonThumbnail({ id }: JobOf<JobName.PersonGenerateThumbnail>): Promise<JobStatus> {
|
||||
const { machineLearning, metadata, image } = await this.getConfig({ withCache: true });
|
||||
if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) {
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
const { image } = await this.getConfig({ withCache: true });
|
||||
const data = await this.personRepository.getDataForThumbnailGenerationJob(id);
|
||||
if (!data) {
|
||||
this.logger.error(`Could not generate person thumbnail for ${id}: missing data`);
|
||||
|
||||
Reference in New Issue
Block a user