Compare commits

...

8 Commits

Author SHA1 Message Date
shenlong-tanwen
74f9a5cbad refactor: asset update method 2026-07-24 19:50:24 +05:30
NOBOIKE
5fa920a5f0 fix(web): use RTL transform origin in AdaptiveImage (#30182) 2026-07-24 10:44:33 +02:00
Jason Rasmussen
acc7e6b299 fix: min faces user preference (#30177) 2026-07-23 17:06:09 -04:00
Adam Gastineau
3af94a4805 fix(mobile): properly align iOS widget loading state (#30173) 2026-07-23 12:31:33 -07:00
Adam Gastineau
f488a28018 chore(mobile): force swift-structured-queries lock to 0.34.0 (#30166) 2026-07-23 17:04:01 +00:00
NOBOIKE
29605f710e fix(web): mirror onboarding navigation icons in RTL (#30158)
Co-authored-by: root <root@yazeed.localdomain>
2026-07-23 17:23:36 +02:00
Yaros
792d88961a feat(mobile): custom date range for map (#26205)
* feat(mobile): custom date range for map

* refactor: rename timerange & remove isvalid

* refactor: rename customtimerange variables

* refactor: add back setRelativeTime

* refactor: implement suggestions

* refactor: suggestions

* fix: ifPresent

* fix: context.locale

* chore: restrict selection

* refactor: move options to mapconfig

* refactor: move model to domain

* chore: locale toLanguageTag

* add map codec tests

* rebase

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-23 20:48:15 +05:30
shenlong
20ae07e228 fix: run background tasks in root isolate (#30101)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-23 20:06:37 +05:30
49 changed files with 1148 additions and 171 deletions

View File

@@ -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",

View File

@@ -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"
}
},
{

View File

@@ -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(

View File

@@ -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) {

View File

@@ -74,6 +74,8 @@ class RemoteAsset extends BaseAsset {
bool get isArchived => visibility == .archive;
bool get isLocked => visibility == .locked;
@override
String toString() {
return '''Asset {

View File

@@ -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)),

View File

@@ -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)';
}

View File

@@ -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>(),

View 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);
}

View File

@@ -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;

View File

@@ -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: () {

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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),
);
}
});
}
}

View File

@@ -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));
}
});
}
}

View File

@@ -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));
}

View File

@@ -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}');
}
}

View File

@@ -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),
);
}
}

View File

@@ -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),
],
),

View File

@@ -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),
),

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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)));
}
},
),
],
);
}
}

View File

@@ -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 {}

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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),
],

View File

@@ -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(

View File

@@ -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() {

View File

@@ -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 {}

View File

@@ -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 {}

View File

@@ -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;

View File

@@ -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());
});

View File

@@ -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(),

View 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,
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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();
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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();
}
}

View 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);
});
});
}

View 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);
});
});
});
}

View File

@@ -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,

View 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);
});
});
});
}

View File

@@ -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
}

View File

@@ -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}

View File

@@ -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?.();