mirror of
https://github.com/immich-app/immich.git
synced 2026-07-25 05:50:40 +03:00
Compare commits
8 Commits
fix/dateti
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74f9a5cbad | ||
|
|
5fa920a5f0 | ||
|
|
acc7e6b299 | ||
|
|
3af94a4805 | ||
|
|
f488a28018 | ||
|
|
29605f710e | ||
|
|
792d88961a | ||
|
|
20ae07e228 |
@@ -1524,6 +1524,7 @@
|
||||
"not_available": "N/A",
|
||||
"not_in_any_album": "Not in any album",
|
||||
"not_selected": "Not selected",
|
||||
"not_set": "Not set",
|
||||
"notes": "Notes",
|
||||
"nothing_here_yet": "Nothing here yet",
|
||||
"notification_backup_reliability": "Enable notifications to improve background backup reliability",
|
||||
|
||||
@@ -140,8 +140,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-structured-queries",
|
||||
"state" : {
|
||||
"revision" : "8da8818fccd9959bd683934ddc62cf45bb65b3c8",
|
||||
"version" : "0.31.1"
|
||||
"revision" : "dafddd843f10630aa6b5be47c1b6a59cc4ab6586",
|
||||
"version" : "0.34.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,15 +18,21 @@ struct ImmichWidgetView: View {
|
||||
|
||||
var body: some View {
|
||||
if entry.image == nil {
|
||||
VStack {
|
||||
Image("LaunchImage")
|
||||
.tintedWidgetImageModifier()
|
||||
Text(entry.metadata.error?.errorDescription ?? "")
|
||||
.minimumScaleFactor(0.25)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(16)
|
||||
Image("LaunchImage")
|
||||
.tintedWidgetImageModifier()
|
||||
.overlay(alignment: .bottom) {
|
||||
if let error = entry.metadata.error?.errorDescription {
|
||||
Text(error)
|
||||
.minimumScaleFactor(0.25)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
.alignmentGuide(.bottom) { dimensions in
|
||||
// Place the text below the bottom of the image
|
||||
dimensions[.top] - 8
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ZStack(alignment: .leading) {
|
||||
Color.clear.overlay(
|
||||
|
||||
@@ -60,6 +60,7 @@ 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,6 +74,8 @@ class RemoteAsset extends BaseAsset {
|
||||
|
||||
bool get isArchived => visibility == .archive;
|
||||
|
||||
bool get isLocked => visibility == .locked;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''Asset {
|
||||
|
||||
@@ -153,6 +153,8 @@ class AppConfig {
|
||||
.timelineStorageIndicator => timeline.storageIndicator,
|
||||
.mapShowFavoriteOnly => map.favoritesOnly,
|
||||
.mapRelativeDate => map.relativeDays,
|
||||
.mapCustomFrom => map.customFrom,
|
||||
.mapCustomTo => map.customTo,
|
||||
.mapIncludeArchived => map.includeArchived,
|
||||
.mapThemeMode => map.themeMode,
|
||||
.mapWithPartners => map.withPartners,
|
||||
@@ -207,6 +209,8 @@ class AppConfig {
|
||||
.timelineStorageIndicator => copyWith(timeline: timeline.copyWith(storageIndicator: value as bool)),
|
||||
.mapShowFavoriteOnly => copyWith(map: map.copyWith(favoritesOnly: value as bool)),
|
||||
.mapRelativeDate => copyWith(map: map.copyWith(relativeDays: value as int)),
|
||||
.mapCustomFrom => copyWith(map: map.copyWith(customFrom: .fromNullable(value as DateTime?))),
|
||||
.mapCustomTo => copyWith(map: map.copyWith(customTo: .fromNullable(value as DateTime?))),
|
||||
.mapIncludeArchived => copyWith(map: map.copyWith(includeArchived: value as bool)),
|
||||
.mapThemeMode => copyWith(map: map.copyWith(themeMode: value as ThemeMode)),
|
||||
.mapWithPartners => copyWith(map: map.copyWith(withPartners: value as bool)),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
class MapConfig {
|
||||
final int relativeDays;
|
||||
@@ -6,13 +7,17 @@ class MapConfig {
|
||||
final bool includeArchived;
|
||||
final ThemeMode themeMode;
|
||||
final bool withPartners;
|
||||
final DateTime? customFrom;
|
||||
final DateTime? customTo;
|
||||
|
||||
const MapConfig({
|
||||
this.relativeDays = 0,
|
||||
this.favoritesOnly = false,
|
||||
this.includeArchived = false,
|
||||
this.themeMode = ThemeMode.system,
|
||||
this.themeMode = .system,
|
||||
this.withPartners = false,
|
||||
this.customFrom,
|
||||
this.customTo,
|
||||
});
|
||||
|
||||
MapConfig copyWith({
|
||||
@@ -21,12 +26,16 @@ class MapConfig {
|
||||
bool? includeArchived,
|
||||
ThemeMode? themeMode,
|
||||
bool? withPartners,
|
||||
Option<DateTime>? customFrom,
|
||||
Option<DateTime>? customTo,
|
||||
}) => MapConfig(
|
||||
relativeDays: relativeDays ?? this.relativeDays,
|
||||
favoritesOnly: favoritesOnly ?? this.favoritesOnly,
|
||||
includeArchived: includeArchived ?? this.includeArchived,
|
||||
themeMode: themeMode ?? this.themeMode,
|
||||
withPartners: withPartners ?? this.withPartners,
|
||||
customFrom: customFrom.patch(this.customFrom),
|
||||
customTo: customTo.patch(this.customTo),
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -37,12 +46,15 @@ class MapConfig {
|
||||
other.favoritesOnly == favoritesOnly &&
|
||||
other.includeArchived == includeArchived &&
|
||||
other.themeMode == themeMode &&
|
||||
other.withPartners == withPartners);
|
||||
other.withPartners == withPartners &&
|
||||
other.customFrom == customFrom &&
|
||||
other.customTo == customTo);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners);
|
||||
int get hashCode =>
|
||||
Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners, customFrom, customTo);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners)';
|
||||
'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners, customFrom: $customFrom, customTo: $customTo)';
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ enum SettingsKey<T> {
|
||||
// Map
|
||||
mapShowFavoriteOnly<bool>(),
|
||||
mapRelativeDate<int>(),
|
||||
mapCustomFrom<DateTime?>(),
|
||||
mapCustomTo<DateTime?>(),
|
||||
mapIncludeArchived<bool>(),
|
||||
mapThemeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
|
||||
mapWithPartners<bool>(),
|
||||
|
||||
15
mobile/lib/domain/models/time_range.model.dart
Normal file
15
mobile/lib/domain/models/time_range.model.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
class TimeRange {
|
||||
final DateTime? from;
|
||||
final DateTime? to;
|
||||
|
||||
const TimeRange({this.from, this.to});
|
||||
|
||||
TimeRange copyWith({Option<DateTime>? from, Option<DateTime>? to}) {
|
||||
return TimeRange(from: from.patch(this.from), to: to.patch(this.to));
|
||||
}
|
||||
|
||||
TimeRange clearFrom() => TimeRange(to: to);
|
||||
TimeRange clearTo() => TimeRange(from: from);
|
||||
}
|
||||
@@ -1,16 +1,26 @@
|
||||
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._localRepository, required this._apiRepository});
|
||||
const AssetService({
|
||||
required this._remoteRepository,
|
||||
required this._exifRepository,
|
||||
required this._localRepository,
|
||||
required this._apiRepository,
|
||||
});
|
||||
|
||||
Future<BaseAsset?> getAsset(BaseAsset asset) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
||||
@@ -26,10 +36,6 @@ class AssetService {
|
||||
return _localRepository.getByChecksum(checksum);
|
||||
}
|
||||
|
||||
Future<LocalAsset?> getLocalAsset(String id) {
|
||||
return _localRepository.get(id);
|
||||
}
|
||||
|
||||
Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) {
|
||||
return _remoteRepository.getByChecksum(checksum);
|
||||
}
|
||||
@@ -73,6 +79,99 @@ 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;
|
||||
|
||||
@@ -6,7 +6,10 @@ import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||
import 'package:immich_mobile/domain/services/local_sync.service.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
@@ -14,11 +17,16 @@ import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.d
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.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/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart' show nativeSyncApiProvider;
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/localization.service.dart';
|
||||
@@ -58,12 +66,43 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
final BackgroundWorkerBgHostApi _backgroundHostApi;
|
||||
final _cancellationToken = Completer<void>();
|
||||
final Logger _logger = Logger('BackgroundWorkerBgService');
|
||||
late LocalSyncService _localSyncService;
|
||||
late SyncStreamService _remoteSyncService;
|
||||
late HashService _hashService;
|
||||
|
||||
bool _isCleanedUp = false;
|
||||
|
||||
BackgroundWorkerBgService({required this._drift, required this._driftLogger})
|
||||
: _backgroundHostApi = BackgroundWorkerBgHostApi() {
|
||||
_ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(_drift))]);
|
||||
final ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(_drift))]);
|
||||
_ref = ref;
|
||||
_localSyncService = LocalSyncService(
|
||||
localAlbumRepository: ref.read(localAlbumRepository),
|
||||
localAssetRepository: ref.read(localAssetRepository),
|
||||
nativeSyncApi: ref.read(nativeSyncApiProvider),
|
||||
trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository),
|
||||
assetMediaRepository: ref.read(assetMediaRepositoryProvider),
|
||||
permissionRepository: ref.read(permissionRepositoryProvider),
|
||||
cancellation: _cancellationToken,
|
||||
);
|
||||
_remoteSyncService = SyncStreamService(
|
||||
syncApiRepository: ref.read(syncApiRepositoryProvider),
|
||||
syncStreamRepository: ref.read(syncStreamRepositoryProvider),
|
||||
localAssetRepository: ref.read(localAssetRepository),
|
||||
trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository),
|
||||
assetMediaRepository: ref.read(assetMediaRepositoryProvider),
|
||||
permissionRepository: ref.read(permissionRepositoryProvider),
|
||||
syncMigrationRepository: ref.read(syncMigrationRepositoryProvider),
|
||||
api: ref.read(apiServiceProvider),
|
||||
cancellation: _cancellationToken,
|
||||
);
|
||||
_hashService = HashService(
|
||||
localAlbumRepository: ref.read(localAlbumRepository),
|
||||
localAssetRepository: ref.read(localAssetRepository),
|
||||
nativeSyncApi: ref.read(nativeSyncApiProvider),
|
||||
trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository),
|
||||
cancellation: _cancellationToken,
|
||||
);
|
||||
BackgroundWorkerFlutterApi.setUp(this);
|
||||
}
|
||||
|
||||
@@ -119,11 +158,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
try {
|
||||
final budget = maxSeconds != null ? Duration(seconds: maxSeconds - 1) : null;
|
||||
|
||||
final sync = _ref?.read(backgroundSyncProvider);
|
||||
if (sync == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only for Background Processing tasks
|
||||
if (maxSeconds == null) {
|
||||
await _optimizeDB();
|
||||
@@ -135,9 +169,23 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
// hash and handle_backup read drift state and tolerate stale reads
|
||||
// (server-side dedup catches the rare race). The single budget caps the
|
||||
// whole batch; no phase needs its own timeout.
|
||||
final all = Future.wait<dynamic>([sync.syncLocal(), sync.syncRemote(), sync.hashAssets(), _handleBackup()]);
|
||||
final all = Future.wait<dynamic>([
|
||||
_localSyncService.sync(),
|
||||
_remoteSyncService.sync(),
|
||||
_hashService.hashAssets(),
|
||||
_handleBackup(),
|
||||
]);
|
||||
if (budget != null) {
|
||||
await all.timeout(budget, onTimeout: () => <dynamic>[]);
|
||||
await all.timeout(
|
||||
budget,
|
||||
onTimeout: () {
|
||||
if (!_cancellationToken.isCompleted) {
|
||||
_logger.warning("iOS background upload timed out after ${budget.inSeconds}s, cancelling tasks");
|
||||
_cancellationToken.complete();
|
||||
}
|
||||
return <dynamic>[];
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await all;
|
||||
}
|
||||
@@ -221,7 +269,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
|
||||
try {
|
||||
_isCleanedUp = true;
|
||||
final backgroundSyncManager = _ref?.read(backgroundSyncProvider);
|
||||
final nativeSyncApi = _ref?.read(nativeSyncApiProvider);
|
||||
|
||||
_logger.info("Cleaning up background worker");
|
||||
@@ -230,10 +277,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}
|
||||
|
||||
// Workers share one sqlite connection, so DB teardown must wait until every worker has stopped using it.
|
||||
await Future.wait([
|
||||
if (backgroundSyncManager != null) backgroundSyncManager.cancel(),
|
||||
if (nativeSyncApi != null) nativeSyncApi.cancelHashing(),
|
||||
]);
|
||||
await Future.wait([if (nativeSyncApi != null) nativeSyncApi.cancelHashing()]);
|
||||
await workerManagerPatch.dispose().catchError((_) async {});
|
||||
await Future.wait([LogService.I.dispose(), Store.dispose()]);
|
||||
await _drift.close();
|
||||
@@ -279,18 +323,18 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}
|
||||
|
||||
Future<bool> _syncAssets({Duration? hashTimeout}) async {
|
||||
await _ref?.read(backgroundSyncProvider).syncLocal();
|
||||
await _localSyncService.sync();
|
||||
if (_isCleanedUp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final isSuccess = await _ref?.read(backgroundSyncProvider).syncRemote() ?? false;
|
||||
final isSuccess = await _remoteSyncService.sync();
|
||||
if (_isCleanedUp) {
|
||||
return isSuccess;
|
||||
}
|
||||
|
||||
var hashFuture = _ref?.read(backgroundSyncProvider).hashAssets();
|
||||
if (hashTimeout != null && hashFuture != null) {
|
||||
var hashFuture = _hashService.hashAssets();
|
||||
if (hashTimeout != null) {
|
||||
hashFuture = hashFuture.timeout(
|
||||
hashTimeout,
|
||||
onTimeout: () {
|
||||
|
||||
@@ -341,4 +341,10 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,18 @@ class DriftMapRepository extends DriftDatabaseRepository {
|
||||
condition = condition & _db.remoteAssetEntity.isFavorite.equals(true);
|
||||
}
|
||||
|
||||
if (options.relativeDays != 0) {
|
||||
final timeRange = options.timeRange;
|
||||
final hasCustomRange = timeRange.from != null || timeRange.to != null;
|
||||
|
||||
if (hasCustomRange) {
|
||||
if (timeRange.from != null) {
|
||||
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(timeRange.from!);
|
||||
}
|
||||
|
||||
if (timeRange.to != null) {
|
||||
condition = condition & _db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(timeRange.to!);
|
||||
}
|
||||
} else if (options.relativeDays > 0) {
|
||||
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
|
||||
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate);
|
||||
}
|
||||
|
||||
@@ -72,13 +72,7 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
|
||||
final query = _db.remoteAssetEntity.select()
|
||||
..where(
|
||||
(row) =>
|
||||
row.stackId.equals(stackId) &
|
||||
row.id.equals(asset.id).not() &
|
||||
row.deletedAt.isNull() &
|
||||
row.visibility.equalsValue(AssetVisibility.timeline),
|
||||
)
|
||||
..where((row) => row.stackId.equals(stackId) & row.id.equals(asset.id).not())
|
||||
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]);
|
||||
|
||||
return query.map((row) => row.toDto()).get();
|
||||
@@ -189,38 +183,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) {
|
||||
return _db.transaction(() async {
|
||||
final stackIds = await _db.managers.stackEntity
|
||||
@@ -298,10 +260,16 @@ 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) {
|
||||
@@ -309,4 +277,37 @@ 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),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:drift/drift.dart';
|
||||
import 'package:easy_localization/easy_localization.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/time_range.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
@@ -20,6 +21,7 @@ class TimelineMapOptions {
|
||||
final bool includeArchived;
|
||||
final bool withPartners;
|
||||
final int relativeDays;
|
||||
final TimeRange timeRange;
|
||||
|
||||
const TimelineMapOptions({
|
||||
required this.bounds,
|
||||
@@ -27,6 +29,7 @@ class TimelineMapOptions {
|
||||
this.includeArchived = false,
|
||||
this.withPartners = false,
|
||||
this.relativeDays = 0,
|
||||
this.timeRange = const TimeRange(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -552,8 +555,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
|
||||
}
|
||||
|
||||
if (options.relativeDays != 0) {
|
||||
final timeRange = options.timeRange;
|
||||
|
||||
final hasCustomRange = timeRange.from != null || timeRange.to != null;
|
||||
|
||||
if (hasCustomRange) {
|
||||
if (timeRange.from != null) {
|
||||
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(timeRange.from!));
|
||||
}
|
||||
|
||||
if (timeRange.to != null) {
|
||||
query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(timeRange.to!));
|
||||
}
|
||||
} else if (options.relativeDays > 0) {
|
||||
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
|
||||
|
||||
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
|
||||
}
|
||||
|
||||
@@ -594,8 +610,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
|
||||
}
|
||||
|
||||
if (options.relativeDays != 0) {
|
||||
final timeRange = options.timeRange;
|
||||
|
||||
final hasCustomRange = timeRange.from != null || timeRange.to != null;
|
||||
|
||||
if (hasCustomRange) {
|
||||
if (timeRange.from != null) {
|
||||
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(timeRange.from!));
|
||||
}
|
||||
|
||||
if (timeRange.to != null) {
|
||||
query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(timeRange.to!));
|
||||
}
|
||||
} else if (options.relativeDays > 0) {
|
||||
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
|
||||
|
||||
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ class _ActionWidget extends ConsumerWidget {
|
||||
try {
|
||||
await action.onAction(scope);
|
||||
} catch (error, stackTrace) {
|
||||
handleError(scope.context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
|
||||
handleError(scope.context, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/models/time_range.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/map.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/map/map_state.provider.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class MapState {
|
||||
@@ -15,6 +17,7 @@ class MapState {
|
||||
final bool includeArchived;
|
||||
final bool withPartners;
|
||||
final int relativeDays;
|
||||
final TimeRange timeRange;
|
||||
|
||||
const MapState({
|
||||
this.themeMode = ThemeMode.system,
|
||||
@@ -23,6 +26,7 @@ class MapState {
|
||||
this.includeArchived = false,
|
||||
this.withPartners = false,
|
||||
this.relativeDays = 0,
|
||||
this.timeRange = const TimeRange(),
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -40,6 +44,7 @@ class MapState {
|
||||
bool? includeArchived,
|
||||
bool? withPartners,
|
||||
int? relativeDays,
|
||||
TimeRange? timeRange,
|
||||
}) {
|
||||
return MapState(
|
||||
bounds: bounds ?? this.bounds,
|
||||
@@ -48,6 +53,7 @@ class MapState {
|
||||
includeArchived: includeArchived ?? this.includeArchived,
|
||||
withPartners: withPartners ?? this.withPartners,
|
||||
relativeDays: relativeDays ?? this.relativeDays,
|
||||
timeRange: timeRange ?? this.timeRange,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,6 +63,7 @@ class MapState {
|
||||
includeArchived: includeArchived,
|
||||
withPartners: withPartners,
|
||||
relativeDays: relativeDays,
|
||||
timeRange: timeRange,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,6 +110,24 @@ class MapStateNotifier extends Notifier<MapState> {
|
||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||
}
|
||||
|
||||
void setCustomTimeRange(TimeRange range) {
|
||||
ref.read(settingsProvider).write(.mapCustomFrom, range.from);
|
||||
ref.read(settingsProvider).write(.mapCustomTo, range.to);
|
||||
state = state.copyWith(timeRange: range);
|
||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||
}
|
||||
|
||||
Option<DateTime> parseDateOption(String s) {
|
||||
try {
|
||||
if (s.trim().isEmpty) {
|
||||
return const Option.none();
|
||||
}
|
||||
return Option.some(DateTime.parse(s));
|
||||
} catch (_) {
|
||||
return const Option.none();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
MapState build() {
|
||||
final mapConfig = ref.read(appConfigProvider.select((config) => config.map));
|
||||
@@ -113,6 +138,7 @@ class MapStateNotifier extends Notifier<MapState> {
|
||||
withPartners: mapConfig.withPartners,
|
||||
relativeDays: mapConfig.relativeDays,
|
||||
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
|
||||
timeRange: TimeRange(from: mapConfig.customFrom, to: mapConfig.customTo),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/time_range.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_settings/map_custom_time_range.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart';
|
||||
|
||||
class DriftMapSettingsSheet extends HookConsumerWidget {
|
||||
class DriftMapSettingsSheet extends ConsumerStatefulWidget {
|
||||
const DriftMapSettingsSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<DriftMapSettingsSheet> createState() => _DriftMapSettingsSheetState();
|
||||
}
|
||||
|
||||
class _DriftMapSettingsSheetState extends ConsumerState<DriftMapSettingsSheet> {
|
||||
late bool useCustomRange;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final mapState = ref.read(mapStateProvider);
|
||||
final timeRange = mapState.timeRange;
|
||||
useCustomRange = timeRange.from != null || timeRange.to != null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mapState = ref.watch(mapStateProvider);
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.6,
|
||||
initialChildSize: useCustomRange ? 0.7 : 0.6,
|
||||
builder: (ctx, scrollController) => SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Card(
|
||||
@@ -47,10 +65,41 @@ class DriftMapSettingsSheet extends HookConsumerWidget {
|
||||
selected: mapState.withPartners,
|
||||
onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners),
|
||||
),
|
||||
MapTimeDropDown(
|
||||
relativeTime: mapState.relativeDays,
|
||||
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
|
||||
),
|
||||
if (useCustomRange) ...[
|
||||
MapTimeRange(
|
||||
timeRange: mapState.timeRange,
|
||||
onChanged: (range) {
|
||||
ref.read(mapStateProvider.notifier).setCustomTimeRange(range);
|
||||
},
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: () => setState(() {
|
||||
useCustomRange = false;
|
||||
ref.read(mapStateProvider.notifier).setRelativeTime(0);
|
||||
ref.read(mapStateProvider.notifier).setCustomTimeRange(const TimeRange());
|
||||
}),
|
||||
child: Text(context.t.remove_custom_date_range),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
MapTimeDropDown(
|
||||
relativeTime: mapState.relativeDays,
|
||||
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: () => setState(() {
|
||||
useCustomRange = true;
|
||||
ref.read(mapStateProvider.notifier).setRelativeTime(0);
|
||||
ref.read(mapStateProvider.notifier).setCustomTimeRange(const TimeRange());
|
||||
}),
|
||||
child: Text(context.t.use_custom_date_range),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -15,6 +16,8 @@ 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)),
|
||||
);
|
||||
@@ -22,6 +25,7 @@ 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,28 +43,11 @@ 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)));
|
||||
|
||||
@@ -113,15 +96,39 @@ 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,8 +15,10 @@ 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<LocalAsset> backedUp() => local().where((asset) => asset.remoteAssetId != null);
|
||||
AssetFilter<BaseAsset> backedUp({bool isBackedUp = true}) => where((asset) => asset.isMerged == isBackedUp);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:openapi/api.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:stack_trace/stack_trace.dart';
|
||||
|
||||
void handleError(BuildContext context, Object error, {StackTrace? stack, String? description}) {
|
||||
void handleError(Object error, {StackTrace? stack, String? description}) {
|
||||
String? stackTrace;
|
||||
if (stack != null) {
|
||||
final trace = Trace.from(stack);
|
||||
@@ -23,17 +23,13 @@ void handleError(BuildContext context, Object error, {StackTrace? stack, String?
|
||||
() => '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 = context.t.login_form_server_error;
|
||||
message = StaticTranslations.instance.login_form_server_error;
|
||||
} else {
|
||||
message = context.t.scaffold_body_error_occurred;
|
||||
message = StaticTranslations.instance.scaffold_body_error_occurred;
|
||||
}
|
||||
|
||||
snackbar.error(message);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/time_range.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
class MapTimeRange extends StatelessWidget {
|
||||
const MapTimeRange({super.key, required this.timeRange, required this.onChanged});
|
||||
|
||||
final TimeRange timeRange;
|
||||
final Function(TimeRange) onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(context.t.date_after),
|
||||
subtitle: Text(
|
||||
timeRange.from != null
|
||||
? DateFormat.yMMMd(context.locale.toLanguageTag()).add_jm().format(timeRange.from!)
|
||||
: context.t.not_set,
|
||||
),
|
||||
trailing: timeRange.from != null
|
||||
? IconButton(icon: const Icon(Icons.close), onPressed: () => onChanged(timeRange.clearFrom()))
|
||||
: null,
|
||||
onTap: () async {
|
||||
final initial = timeRange.from ?? DateTime.now();
|
||||
final currentTo = timeRange.to;
|
||||
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: currentTo != null && initial.isAfter(currentTo) ? currentTo : initial,
|
||||
firstDate: DateTime(1970),
|
||||
lastDate: currentTo ?? DateTime.now(),
|
||||
);
|
||||
|
||||
if (picked != null) {
|
||||
onChanged(timeRange.copyWith(from: Option.some(picked)));
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text(context.t.date_before),
|
||||
subtitle: Text(
|
||||
timeRange.to != null
|
||||
? DateFormat.yMMMd(context.locale.toLanguageTag()).add_jm().format(timeRange.to!)
|
||||
: context.t.not_set,
|
||||
),
|
||||
trailing: timeRange.to != null
|
||||
? IconButton(icon: const Icon(Icons.close), onPressed: () => onChanged(timeRange.clearTo()))
|
||||
: null,
|
||||
onTap: () async {
|
||||
final initial = timeRange.to ?? DateTime.now();
|
||||
final currentFrom = timeRange.from;
|
||||
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: currentFrom != null && initial.isBefore(currentFrom) ? currentFrom : initial,
|
||||
firstDate: currentFrom ?? DateTime(1970),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
|
||||
if (picked != null) {
|
||||
onChanged(timeRange.copyWith(to: Option.some(picked)));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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.mock.dart';
|
||||
import '../../service.mocks.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.mock.dart';
|
||||
import '../../service.mocks.dart';
|
||||
|
||||
void main() {
|
||||
late UserService sut;
|
||||
|
||||
@@ -14,6 +14,7 @@ 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
|
||||
@@ -32,6 +33,12 @@ 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).
|
||||
@@ -48,6 +55,7 @@ void main() {
|
||||
assetLoader: const CodegenLoader(),
|
||||
child: ProviderScope(
|
||||
overrides: [
|
||||
...context.overrides,
|
||||
timelineServiceProvider.overrideWithValue(_stubTimelineService()),
|
||||
assetViewerProvider.overrideWith(_SeededAssetViewerNotifier.new),
|
||||
],
|
||||
|
||||
@@ -25,6 +25,8 @@ class _FrozenBucketService implements TimelineService {
|
||||
}
|
||||
|
||||
class _EmptyBucketService implements TimelineService {
|
||||
const _EmptyBucketService();
|
||||
|
||||
@override
|
||||
Stream<List<Bucket>> Function() get watchBuckets =>
|
||||
() => Stream.value(const []);
|
||||
@@ -109,7 +111,7 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWithValue(_EmptyBucketService()),
|
||||
timelineServiceProvider.overrideWithValue(const _EmptyBucketService()),
|
||||
appConfigProvider.overrideWithValue(const AppConfig()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
|
||||
@@ -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 '../../domain/service.mock.dart';
|
||||
import '../../service.mocks.dart';
|
||||
import '../../unit/factories/remote_asset_factory.dart';
|
||||
|
||||
void main() {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/repositories/download.repository.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 {}
|
||||
@@ -17,3 +20,9 @@ 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,6 +1,17 @@
|
||||
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 {}
|
||||
@@ -8,3 +19,27 @@ 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,15 +7,12 @@ 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,7 +10,6 @@ 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';
|
||||
|
||||
@@ -29,13 +28,7 @@ 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,12 +5,13 @@ import '../../utils.dart';
|
||||
class LocalAssetFactory {
|
||||
const LocalAssetFactory();
|
||||
|
||||
static LocalAsset create({String? id, String? name}) {
|
||||
static LocalAsset create({String? id, String? name, String? remoteId}) {
|
||||
id = TestUtils.uuid(id);
|
||||
|
||||
return LocalAsset(
|
||||
id: id,
|
||||
name: name ?? 'local_$id.jpg',
|
||||
remoteId: remoteId,
|
||||
type: AssetType.image,
|
||||
createdAt: TestUtils.yesterday(),
|
||||
updatedAt: TestUtils.now(),
|
||||
|
||||
38
mobile/test/unit/factories/remote_album_factory.dart
Normal file
38
mobile/test/unit/factories/remote_album_factory.dart
Normal file
@@ -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,8 +10,11 @@ class RemoteAssetFactory {
|
||||
String? name,
|
||||
String? ownerId,
|
||||
bool isFavorite = false,
|
||||
AssetVisibility visibility = AssetVisibility.timeline,
|
||||
AssetVisibility visibility = .timeline,
|
||||
AssetType type = .image,
|
||||
String? stackId,
|
||||
DateTime? deletedAt,
|
||||
String? localId,
|
||||
}) {
|
||||
id = TestUtils.uuid(id);
|
||||
|
||||
@@ -20,13 +23,15 @@ class RemoteAssetFactory {
|
||||
name: name ?? 'remote_$id.jpg',
|
||||
ownerId: TestUtils.uuid(ownerId),
|
||||
checksum: 'checksum-$id',
|
||||
type: .image,
|
||||
type: type,
|
||||
createdAt: TestUtils.yesterday(),
|
||||
updatedAt: TestUtils.now(),
|
||||
isFavorite: isFavorite,
|
||||
visibility: visibility,
|
||||
stackId: stackId,
|
||||
isEdited: false,
|
||||
deletedAt: deletedAt,
|
||||
localId: localId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,43 @@
|
||||
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();
|
||||
@@ -29,11 +47,34 @@ 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() {
|
||||
@@ -49,12 +90,31 @@ 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();
|
||||
@@ -65,9 +125,21 @@ 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() {
|
||||
@@ -88,7 +160,43 @@ class ServiceMocks {
|
||||
}
|
||||
|
||||
void _stubAssetService() {
|
||||
when(asset.updateFavorite).thenAnswer((_) async {});
|
||||
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 {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +204,24 @@ 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);
|
||||
}
|
||||
@@ -119,6 +243,33 @@ 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());
|
||||
@@ -165,11 +316,89 @@ extension type const UserServiceStub(MockUserService service) implements Stub<Mo
|
||||
}
|
||||
|
||||
extension type const AssetServiceStub(MockAssetService service) implements Stub<MockAssetService> {
|
||||
Future<void> Function() get updateFavorite =>
|
||||
() => service.updateFavorite(any(), any());
|
||||
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());
|
||||
}
|
||||
|
||||
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 '../../../domain/service.mock.dart';
|
||||
import '../../../service.mocks.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 '../../../domain/service.mock.dart';
|
||||
import '../../../service.mocks.dart';
|
||||
import '../../factories/user_factory.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@ 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';
|
||||
|
||||
@@ -29,6 +32,7 @@ class PresentationContext {
|
||||
service = ServiceMocks(),
|
||||
repository = RepositoryMocks() {
|
||||
setup();
|
||||
addTearDown(dispose);
|
||||
}
|
||||
|
||||
static const String serverEndpoint = 'http://localhost:3000';
|
||||
@@ -43,6 +47,9 @@ 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 {
|
||||
@@ -61,9 +68,7 @@ class PresentationContext {
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
addTearDown(() {
|
||||
service.resetAll();
|
||||
});
|
||||
service.resetAll();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
79
mobile/test/unit/services/asset_service_test.dart
Normal file
79
mobile/test/unit/services/asset_service_test.dart
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
48
mobile/test/unit/services/remote_album_service_test.dart
Normal file
48
mobile/test/unit/services/remote_album_service_test.dart
Normal file
@@ -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 '../../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<LocalAsset> syncedPhotos = AssetFilter(<BaseAsset>[
|
||||
final AssetFilter<BaseAsset> syncedPhotos = AssetFilter(<BaseAsset>[
|
||||
syncedPhoto,
|
||||
offlinePhoto,
|
||||
remotePhoto,
|
||||
|
||||
99
mobile/test/unit/utils/value_codec_test.dart
Normal file
99
mobile/test/unit/utils/value_codec_test.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/value_codec.dart';
|
||||
|
||||
enum _Fruit { apple, banana, cherry }
|
||||
|
||||
void main() {
|
||||
group('MapCodec', () {
|
||||
group('encode', () {
|
||||
test('serializes an empty map to an empty JSON object', () {
|
||||
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
|
||||
expect(codec.encode({}), '{}');
|
||||
});
|
||||
|
||||
test('encodes a string-to-string map as a JSON object', () {
|
||||
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
|
||||
expect(codec.encode({'a': '1', 'b': '2'}), '{"a":"1","b":"2"}');
|
||||
});
|
||||
|
||||
test('stringifies non-string values via the value codec', () {
|
||||
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
|
||||
expect(codec.encode({'x': 10, 'y': 20}), '{"x":"10","y":"20"}');
|
||||
});
|
||||
|
||||
test('stringifies non-string keys via the key codec', () {
|
||||
const codec = MapCodec<int, String>(PrimitiveCodec.integer, PrimitiveCodec.string);
|
||||
expect(codec.encode({1: 'one', 2: 'two'}), '{"1":"one","2":"two"}');
|
||||
});
|
||||
});
|
||||
|
||||
group('decode', () {
|
||||
test('reconstructs a string-to-string map', () {
|
||||
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
|
||||
expect(codec.decode('{"a":"1","b":"2"}'), {'a': '1', 'b': '2'});
|
||||
});
|
||||
|
||||
test('parses values back to their domain type', () {
|
||||
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
|
||||
expect(codec.decode('{"x":"10","y":"20"}'), {'x': 10, 'y': 20});
|
||||
});
|
||||
|
||||
test('parses keys back to their domain type', () {
|
||||
const codec = MapCodec<int, String>(PrimitiveCodec.integer, PrimitiveCodec.string);
|
||||
expect(codec.decode('{"1":"one","2":"two"}'), {1: 'one', 2: 'two'});
|
||||
});
|
||||
|
||||
test('returns an empty map for an empty JSON object', () {
|
||||
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
|
||||
expect(codec.decode('{}'), isEmpty);
|
||||
});
|
||||
|
||||
test('returns an empty map when the payload is not valid JSON', () {
|
||||
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
|
||||
expect(codec.decode('not json'), isEmpty);
|
||||
});
|
||||
|
||||
test('returns an empty map when the JSON root is not an object', () {
|
||||
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
|
||||
expect(codec.decode('[]'), isEmpty);
|
||||
expect(codec.decode('"a string"'), isEmpty);
|
||||
expect(codec.decode('42'), isEmpty);
|
||||
});
|
||||
|
||||
test('skips entries whose value is not a JSON string, keeping the rest', () {
|
||||
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
|
||||
expect(codec.decode('{"x":1,"y":"20"}'), {'y': 20});
|
||||
});
|
||||
|
||||
test('skips entries whose value is a nested object, keeping the rest', () {
|
||||
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
|
||||
expect(codec.decode('{"a":{"nested":"value"},"b":"ok"}'), {'b': 'ok'});
|
||||
});
|
||||
|
||||
test('returns an empty map when every entry is malformed', () {
|
||||
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
|
||||
expect(codec.decode('{"x":1,"y":2}'), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('round trip', () {
|
||||
test('preserves a primitive map through encode then decode', () {
|
||||
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
|
||||
const original = {'one': 1, 'two': 2, 'three': 3};
|
||||
expect(codec.decode(codec.encode(original)), original);
|
||||
});
|
||||
|
||||
test('preserves an enum-valued map by composing with EnumCodec', () {
|
||||
const codec = MapCodec<String, _Fruit>(PrimitiveCodec.string, EnumCodec(_Fruit.values));
|
||||
const original = {'breakfast': _Fruit.banana, 'snack': _Fruit.apple};
|
||||
expect(codec.decode(codec.encode(original)), original);
|
||||
});
|
||||
|
||||
test('preserves a DateTime-valued map by composing with DateTimeCodec', () {
|
||||
const codec = MapCodec<String, DateTime>(PrimitiveCodec.string, DateTimeCodec());
|
||||
final original = {'created': DateTime.utc(2024, 1, 1, 12, 30)};
|
||||
expect(codec.decode(codec.encode(original)), original);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`
|
||||
INSERT INTO "user_metadata" ("userId", "key", "value")
|
||||
SELECT "user"."id", 'preferences', jsonb_build_object('people', jsonb_build_object('minimumFaces', "config"."minFaces"))
|
||||
FROM "user"
|
||||
CROSS JOIN (
|
||||
SELECT "value"->'machineLearning'->'facialRecognition'->'minFaces' AS "minFaces"
|
||||
FROM "system_metadata"
|
||||
WHERE "key" = 'system-config'
|
||||
AND "value"->'machineLearning'->'facialRecognition'->'minFaces' IS NOT NULL
|
||||
) AS "config"
|
||||
ON CONFLICT ("userId", "key") DO UPDATE
|
||||
SET "value" = "user_metadata"."value" || jsonb_build_object(
|
||||
'people',
|
||||
COALESCE("user_metadata"."value"->'people', '{}'::jsonb) || (EXCLUDED."value"->'people')
|
||||
)
|
||||
WHERE "user_metadata"."value"->'people'->'minimumFaces' IS NULL
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// not supported
|
||||
}
|
||||
@@ -63,6 +63,7 @@
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { untrack, type Snippet } from 'svelte';
|
||||
import { languageManager } from '$lib/managers/language-manager.svelte';
|
||||
|
||||
type Props = {
|
||||
asset: AssetResponseDto;
|
||||
@@ -232,7 +233,7 @@
|
||||
style:width={rasterWidth}
|
||||
style:height={rasterHeight}
|
||||
style:transform="scale({rasterScale})"
|
||||
style:transform-origin="0 0"
|
||||
style:transform-origin={languageManager.rtl ? 'right top' : 'left top'}
|
||||
style:will-change={maxRasterPixels > 0 ? 'transform' : undefined}
|
||||
>
|
||||
{#if show.alphaBackground}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { languageManager } from '$lib/managers/language-manager.svelte';
|
||||
import { Button, Icon } from '@immich/ui';
|
||||
import { mdiArrowLeft, mdiArrowRight, mdiCheck } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
@@ -52,7 +53,7 @@
|
||||
<div class="flex w-full place-content-start">
|
||||
<Button
|
||||
shape="round"
|
||||
leadingIcon={mdiArrowLeft}
|
||||
leadingIcon={languageManager.rtl ? mdiArrowRight : mdiArrowLeft}
|
||||
class="flex place-content-center gap-2"
|
||||
onclick={() => {
|
||||
onLeave?.();
|
||||
@@ -67,7 +68,7 @@
|
||||
<div class="flex w-full place-content-end">
|
||||
<Button
|
||||
shape="round"
|
||||
trailingIcon={nextTitle ? mdiArrowRight : mdiCheck}
|
||||
trailingIcon={nextTitle ? (languageManager.rtl ? mdiArrowLeft : mdiArrowRight) : mdiCheck}
|
||||
onclick={() => {
|
||||
onLeave?.();
|
||||
onNext?.();
|
||||
|
||||
Reference in New Issue
Block a user