Compare commits

..

4 Commits

Author SHA1 Message Date
shenlong-tanwen f3dd742a1f refactor: action secondary handler 2026-07-10 04:34:41 +05:30
shenlong-tanwen 41e93a27fd refactor: mobile edit actions 2026-07-10 03:19:21 +05:30
shenlong-tanwen 15c716945a feat: delete action 2026-07-10 03:14:42 +05:30
shenlong-tanwen 4b8f1f3194 feat: album action 2026-07-10 03:14:11 +05:30
37 changed files with 988 additions and 599 deletions
+42 -3
View File
@@ -1,17 +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;
@@ -101,13 +110,35 @@ class AssetService {
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;
}
await _apiRepository.update(remoteIds, isFavorite: isFavorite, visibility: visibility);
await _remoteRepository.update(remoteIds, isFavorite: isFavorite, visibility: visibility);
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 {
@@ -127,4 +158,12 @@ class AssetService {
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);
}
}
}
@@ -11,7 +11,6 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class RemoteAssetRepository extends DriftDatabaseRepository {
final Drift _db;
@@ -183,38 +182,6 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
});
}
Future<void> updateLocation(List<String> ids, LatLng location) {
return _db.batch((batch) async {
for (final id in ids) {
batch.update(
_db.remoteExifEntity,
RemoteExifEntityCompanion(latitude: Value(location.latitude), longitude: Value(location.longitude)),
where: (e) => e.assetId.equals(id),
);
}
});
}
Future<void> updateDateTime(List<String> ids, DateTime dateTime, {String? timeZone}) {
return _db.batch((batch) async {
for (final id in ids) {
batch.update(
_db.remoteExifEntity,
RemoteExifEntityCompanion(
dateTimeOriginal: Value(dateTime),
timeZone: timeZone == null ? const Value.absent() : Value(timeZone),
),
where: (e) => e.assetId.equals(id),
);
batch.update(
_db.remoteAssetEntity,
RemoteAssetEntityCompanion(createdAt: Value(dateTime)),
where: (e) => e.id.equals(id),
);
}
});
}
Future<void> stack(String userId, StackResponse stack) {
return _db.transaction(() async {
final stackIds = await _db.managers.stackEntity
@@ -292,10 +259,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) {
@@ -0,0 +1,34 @@
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class RemoteExifRepository extends DriftDatabaseRepository {
final Drift _db;
const RemoteExifRepository(this._db) : super(_db);
Future<void> update(
List<String> ids, {
Option<DateTime> dateTimeOriginal = const .none(),
Option<String> timeZone = const .none(),
Option<LatLng> location = const .none(),
}) async {
if ([dateTimeOriginal, timeZone, location].every((option) => option.isNone)) {
return;
}
final companion = RemoteExifEntityCompanion(
dateTimeOriginal: dateTimeOriginal.toDriftValue(),
timeZone: timeZone.toDriftValue(),
latitude: location.map((loc) => loc.latitude).toDriftValue(),
longitude: location.map((loc) => loc.longitude).toDriftValue(),
);
return _db.batch((batch) {
for (final id in ids) {
batch.update(_db.remoteExifEntity, companion, where: (a) => a.assetId.equals(id));
}
});
}
}
@@ -31,4 +31,6 @@ abstract class BaseAction {
const BaseAction({required this.scope, required this.icon, required this.label, this.isVisible = true});
Future<void> onAction();
Future<void> Function()? get onSecondaryAction => null;
}
@@ -11,8 +11,9 @@ class _ActionWidgetScope {
final IconData icon;
final String label;
final FutureOr<void> Function() onAction;
final FutureOr<void> Function()? onSecondaryAction;
const _ActionWidgetScope({required this.icon, required this.label, required this.onAction});
const _ActionWidgetScope({required this.icon, required this.label, required this.onAction, this.onSecondaryAction});
}
class _ActionWidget extends ConsumerWidget {
@@ -21,21 +22,35 @@ class _ActionWidget extends ConsumerWidget {
const _ActionWidget({required this.action, required this.builder});
Future<void> _onAction() async {
Future<void> _guard(Future<void> Function() handler) async {
try {
await action.onAction();
await handler();
} catch (error, stackTrace) {
handleError(error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
}
}
Future<void> Function() get _onAction =>
() => _guard(action.onAction);
Future<void> Function()? get _onSecondaryAction {
final onSecondaryAction = action.onSecondaryAction;
if (onSecondaryAction == null) {
return null;
}
return () => _guard(onSecondaryAction);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
if (!action.isVisible) {
return const SizedBox.shrink();
}
return builder(.new(icon: action.icon, label: action.label, onAction: _onAction));
return builder(
.new(icon: action.icon, label: action.label, onAction: _onAction, onSecondaryAction: _onSecondaryAction),
);
}
}
@@ -48,7 +63,8 @@ class ActionIconButtonWidget extends StatelessWidget {
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichIconButton(icon: ctx.icon, onPressed: ctx.onAction, variant: variant),
builder: (ctx) =>
ImmichIconButton(icon: ctx.icon, onPressed: ctx.onAction, onLongPress: ctx.onSecondaryAction, variant: variant),
);
}
@@ -61,7 +77,13 @@ class ActionButtonWidget extends StatelessWidget {
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichTextButton(labelText: ctx.label, icon: ctx.icon, onPressed: ctx.onAction, variant: variant),
builder: (ctx) => ImmichTextButton(
labelText: ctx.label,
icon: ctx.icon,
onPressed: ctx.onAction,
onLongPress: ctx.onSecondaryAction,
variant: variant,
),
);
}
@@ -73,7 +95,12 @@ class ActionColumnButtonWidget extends StatelessWidget {
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichColumnButton(icon: ctx.icon, label: ctx.label, onPressed: ctx.onAction),
builder: (ctx) => ImmichColumnButton(
icon: ctx.icon,
label: ctx.label,
onPressed: ctx.onAction,
onLongPress: ctx.onSecondaryAction,
),
);
}
@@ -3,6 +3,8 @@ import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
import 'package:immich_mobile/presentation/actions/edit_location.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
@@ -15,6 +17,8 @@ class AssetActions {
final LockAction lock;
final DeleteAction delete;
final CleanupLocalAction cleanup;
final EditDateTimeAction editDateTime;
final EditLocationAction editLocation;
const AssetActions({
required this.debug,
@@ -24,6 +28,8 @@ class AssetActions {
required this.lock,
required this.delete,
required this.cleanup,
required this.editDateTime,
required this.editLocation,
});
factory AssetActions.from(ActionScope scope, List<BaseAsset> assets) => .new(
@@ -34,5 +40,7 @@ class AssetActions {
lock: LockAction(assets: assets, scope: scope),
delete: DeleteAction(assets: assets, scope: scope),
cleanup: CleanupLocalAction(assets: assets, scope: scope),
editDateTime: EditDateTimeAction(assets: assets, scope: scope),
editLocation: EditLocationAction(assets: assets, scope: scope),
);
}
@@ -0,0 +1,68 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
import 'package:immich_mobile/utils/semver.dart';
class EditAssetAction extends BaseAction {
final Iterable<RemoteAsset> assets;
EditAssetAction._({required this.assets, required super.scope, super.isVisible})
: super(icon: Icons.tune, label: scope.context.t.edit);
factory EditAssetAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final editable = AssetFilter(
assets,
).owned(scope.authUser.id).where((asset) => asset.isEditable).toList(growable: false);
final isSupported = scope.ref.watch(serverInfoProvider).serverVersion >= const SemVer(major: 2, minor: 6, patch: 0);
return EditAssetAction._(assets: editable, scope: scope, isVisible: isSupported && editable.length == 1);
}
@override
Future<void> onAction() async {
final ActionScope(:context, :ref) = scope;
// TODO(shenlong): Move all EXIF and Apply Edits logic onto the Route
final asset = assets.first;
final repository = ref.read(remoteAssetRepositoryProvider);
final (edits, exif) = await (repository.getAssetEdits(asset.id), repository.getExif(asset.id)).wait;
if (exif == null || !context.mounted) {
return;
}
ref.read(editorStateProvider.notifier).init(edits, exif);
unawaited(
context.pushRoute(
DriftEditImageRoute(
image: Image(image: getFullImageProvider(asset, edited: false)),
applyEdits: (newEdits) => applyEdits(ref, asset.id, newEdits),
),
),
);
}
}
@visibleForTesting
Future<void> applyEdits(WidgetRef ref, String remoteId, List<AssetEdit> edits) async {
final websocket = ref.read(websocketProvider.notifier);
bool isCurrentId(dynamic data) => data is Map && (data['asset'] as Map?)?['id'] == remoteId;
await ref.read(assetServiceProvider).applyEdits(remoteId, edits);
await Future.any([
websocket.waitForEvent('AssetEditReadyV1', isCurrentId, const Duration(seconds: 10)),
websocket.waitForEvent('AssetEditReadyV2', isCurrentId, const Duration(seconds: 10)),
]).catchError((_) {});
}
@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
class EditDateTimeAction extends BaseAction {
final List<String> assetIds;
final RemoteAsset? origin;
EditDateTimeAction._({required this.assetIds, required this.origin, required super.scope, super.isVisible})
: super(icon: Icons.edit_calendar_outlined, label: scope.context.t.control_bottom_app_bar_edit_time);
factory EditDateTimeAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final owned = AssetFilter(assets).owned(scope.authUser.id);
return EditDateTimeAction._(
assetIds: owned.map((asset) => asset.id).toList(growable: false),
origin: owned.firstOrNull,
scope: scope,
isVisible: owned.isNotEmpty,
);
}
@override
Future<void> onAction() async {
final ActionScope(:context, :ref) = scope;
DateTime? initialDate;
String? timeZone;
Duration? offset;
final seed = origin;
if (seed != null) {
final exif = await ref.read(remoteAssetRepositoryProvider).getExif(seed.id);
// Use EXIF timezone information if available (matching web app and display behavior)
DateTime dt = seed.createdAt.toLocal();
offset = dt.timeZoneOffset;
if (exif?.dateTimeOriginal != null) {
timeZone = exif!.timeZone;
(dt, offset) = applyTimezoneOffset(dateTime: exif.dateTimeOriginal!, timeZone: exif.timeZone);
}
initialDate = dt;
}
if (!context.mounted) {
return;
}
final dateTime = await showDateTimePicker(
context: context,
initialDateTime: initialDate,
initialTZ: timeZone,
initialTZOffset: offset,
);
if (dateTime == null) {
return;
}
await save(dateTime);
}
@visibleForTesting
Future<void> save(String dateTime) async {
final ActionScope(:context, :ref) = scope;
await ref.read(assetServiceProvider).update(assetIds, dateTime: .some(dateTime));
ref.invalidate(assetExifProvider);
ref.read(toastRepositoryProvider).success(context.t.edit_date_and_time_action_prompt(count: assetIds.length));
}
}
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/widgets/common/location_picker.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class EditLocationAction extends BaseAction {
final List<String> assetIds;
final RemoteAsset? origin;
const EditLocationAction._({
required this.assetIds,
required this.origin,
required super.scope,
required super.icon,
required super.label,
super.isVisible,
});
factory EditLocationAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final owned = assets
.whereType<RemoteAsset>()
.where((asset) => asset.ownerId == scope.authUser.id)
.toList(growable: false);
return EditLocationAction._(
assetIds: owned.map((asset) => asset.id).toList(growable: false),
origin: owned.length == 1 ? owned.first : null,
scope: scope,
icon: Icons.edit_location_alt_outlined,
label: scope.context.t.control_bottom_app_bar_edit_location,
isVisible: owned.isNotEmpty,
);
}
@override
Future<void> onAction() async {
final ActionScope(:context, :ref) = scope;
LatLng? initialLatLng;
final seed = origin;
if (seed != null) {
final exif = await ref.read(remoteAssetRepositoryProvider).getExif(seed.id);
if (exif?.latitude != null && exif?.longitude != null) {
initialLatLng = LatLng(exif!.latitude!, exif.longitude!);
}
}
if (!context.mounted) {
return;
}
final location = await showLocationPicker(context: context, initialLatLng: initialLatLng);
if (location == null) {
return;
}
await save(location);
}
@visibleForTesting
Future<void> save(LatLng location) async {
final ActionScope(:context, :ref) = scope;
await ref.read(assetServiceProvider).update(assetIds, location: .some(location));
ref.invalidate(assetExifProvider);
ref.read(toastRepositoryProvider).success(context.t.edit_location_action_prompt(count: assetIds.length));
}
}
@@ -12,4 +12,17 @@ class TimelineAction extends BaseAction {
await action.onAction();
scope.ref.read(multiSelectProvider.notifier).reset();
}
@override
Future<void> Function()? get onSecondaryAction {
final inner = action.onSecondaryAction;
if (inner == null) {
return null;
}
return () async {
await inner();
scope.ref.read(multiSelectProvider.notifier).reset();
};
}
}
@@ -1,52 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class EditDateTimeActionButton extends ConsumerWidget {
final ActionSource source;
const EditDateTimeActionButton({super.key, required this.source});
_onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).editDateTime(source, context);
if (result == null) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'edit_date_and_time_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},
);
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
maxWidth: 95.0,
iconData: Icons.edit_calendar_outlined,
label: "control_bottom_app_bar_edit_time".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,59 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class EditImageActionButton extends ConsumerWidget {
const EditImageActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
Future<void> editImage(List<AssetEdit> edits) async {
if (currentAsset == null || currentAsset.remoteId == null) {
return;
}
await ref.read(actionProvider.notifier).applyEdits(ActionSource.viewer, edits);
}
Future<void> onPress() async {
if (currentAsset == null || currentAsset.remoteId == null) {
return;
}
final imageProvider = getFullImageProvider(currentAsset, edited: false);
final image = Image(image: imageProvider);
final (edits, exifInfo) = await (
ref.read(remoteAssetRepositoryProvider).getAssetEdits(currentAsset.remoteId!),
ref.read(remoteAssetRepositoryProvider).getExif(currentAsset.remoteId!),
).wait;
if (exifInfo == null) {
return;
}
ref.read(editorStateProvider.notifier).init(edits, exifInfo);
await context.pushRoute(DriftEditImageRoute(image: image, applyEdits: editImage));
}
return BaseActionButton(
iconData: Icons.tune,
label: "edit".t(context: context),
onPressed: onPress,
);
}
}
@@ -1,48 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class EditLocationActionButton extends ConsumerWidget {
final ActionSource source;
const EditLocationActionButton({super.key, required this.source});
_onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).editLocation(source, context);
if (result == null) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'edit_location_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.edit_location_alt_outlined,
label: "control_bottom_app_bar_edit_location".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -8,6 +9,8 @@ import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -27,16 +30,15 @@ class DateTimeDetails extends ConsumerWidget {
final asset = this.asset;
final exifInfo = this.exifInfo;
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
final editDateTime = EditDateTimeAction(assets: [asset], scope: ActionScope.from(context, ref));
return Column(
children: [
SheetTile(
title: _getDateTime(context, asset, exifInfo),
titleStyle: context.textTheme.labelLarge,
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
onTap: asset.hasRemote && isOwner
? () async => await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context)
: null,
trailing: const Icon(Icons.edit, size: 18),
onTap: editDateTime.onAction,
),
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
],
@@ -1,13 +1,13 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/edit_location.action.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@@ -53,10 +53,6 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
}
}
void editLocation() async {
await ref.read(actionProvider.notifier).editLocation(ActionSource.viewer, context);
}
@override
Widget build(BuildContext context) {
final asset = widget.asset;
@@ -68,6 +64,7 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
return const SizedBox.shrink();
}
final editLocation = EditLocationAction(assets: [asset], scope: ActionScope.from(context, ref));
final locationName = _getLocationName(exifInfo);
final coordinates = "${exifInfo?.latitude?.toStringAsFixed(4)}, ${exifInfo?.longitude?.toStringAsFixed(4)}";
@@ -80,7 +77,7 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
title: 'location'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
trailing: hasCoordinates ? const Icon(Icons.edit_location_alt, size: 20) : null,
onTap: editLocation,
onTap: editLocation.onAction,
),
if (hasCoordinates)
Padding(
@@ -115,7 +112,7 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
color: context.primaryColor,
),
leading: const Icon(Icons.location_off),
onTap: editLocation,
onTap: editLocation.onAction,
),
],
),
@@ -6,9 +6,9 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/edit_asset.action.dart';
import 'package:immich_mobile/presentation/actions/restore.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart';
@@ -16,8 +16,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
class ViewerBottomBar extends ConsumerWidget {
@@ -33,7 +31,6 @@ class ViewerBottomBar extends ConsumerWidget {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final isInLockedView = ref.watch(inLockedViewProvider);
final serverInfo = ref.watch(serverInfoProvider);
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
final originalTheme = context.themeData;
@@ -42,6 +39,7 @@ class ViewerBottomBar extends ConsumerWidget {
final scope = ActionScope.from(context, ref);
final restore = RestoreAction(assets: assets, scope: scope);
final delete = DeleteAction(assets: assets, scope: scope);
final editImage = EditAssetAction(assets: assets, scope: scope);
final actions = <Widget>[
if (restore.isVisible) ActionColumnButtonWidget(action: restore),
const ShareActionButton(source: ActionSource.viewer),
@@ -49,9 +47,7 @@ class ViewerBottomBar extends ConsumerWidget {
if (!isInLockedView) ...[
if (!isInTrash) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
// edit sync was added in 2.6.0
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
const EditImageActionButton(),
if (editImage.isVisible) ActionColumnButtonWidget(action: editImage),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
],
@@ -8,8 +8,6 @@ import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
@@ -87,10 +85,10 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
actions.cleanup,
actions.stack,
actions.lock,
actions.editDateTime,
actions.editLocation,
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
],
],
slivers: [
@@ -9,8 +9,6 @@ import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
@@ -77,10 +75,10 @@ class FavoriteBottomSheet extends ConsumerWidget {
actions.cleanup,
actions.stack,
actions.lock,
actions.editDateTime,
actions.editLocation,
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
],
],
slivers: multiselect.hasRemote
@@ -9,8 +9,6 @@ import 'package:immich_mobile/presentation/actions/asset_actions.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
@@ -91,14 +89,14 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
actions.cleanup,
actions.stack,
actions.lock,
actions.editDateTime,
actions.editLocation,
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
],
if (multiselect.onlyLocal) const UploadActionButton(source: ActionSource.timeline),
],
@@ -10,8 +10,6 @@ import 'package:immich_mobile/presentation/actions/remove_from_album.action.dart
import 'package:immich_mobile/presentation/actions/set_album_cover.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
@@ -100,13 +98,11 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
actions.cleanup,
actions.stack,
actions.lock,
actions.editDateTime,
actions.editLocation,
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
],
const DownloadActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
],
],
if (ownsAlbum)
ActionColumnButtonWidget(
@@ -6,25 +6,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final actionProvider = NotifierProvider<ActionNotifier, void>(ActionNotifier.new, dependencies: [multiSelectProvider]);
@@ -199,50 +193,6 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult?> editLocation(ActionSource source, BuildContext context) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
final isEdited = await _service.editLocation(ids, context);
if (!isEdited) {
return null;
}
// This must be called since editing location
// does not update the currentAsset which means
// the exif provider will not be refreshed automatically
if (source == ActionSource.viewer) {
final currentAsset = ref.read(assetViewerProvider).currentAsset;
if (currentAsset != null) {
ref.invalidate(assetExifProvider(currentAsset));
}
}
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to edit location for assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult?> editDateTime(ActionSource source, BuildContext context) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
final isEdited = await _service.editDateTime(ids, context);
if (!isEdited) {
return null;
}
if (source == ActionSource.viewer) {
ref.invalidate(assetExifProvider);
}
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to edit date and time for assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult?> tagAssets(ActionSource source, BuildContext context) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
@@ -454,37 +404,6 @@ class ActionNotifier extends Notifier<void> {
});
}
}
Future<ActionResult> applyEdits(ActionSource source, List<AssetEdit> edits) async {
final ids = _getOwnedRemoteIdsForSource(source);
if (ids.length != 1) {
_logger.warning('applyEdits called with multiple assets, expected single asset');
return ActionResult(count: ids.length, success: false, error: 'Expected single asset for applying edits');
}
Future<void> editReady;
if (ref.read(serverInfoProvider).serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) {
editReady = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV2", (dynamic data) {
final eventAsset = SyncAssetV2.fromJson(data["asset"]);
return eventAsset?.id == ids.first;
}, const Duration(seconds: 10));
} else {
editReady = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV1", (dynamic data) {
final eventAsset = SyncAssetV1.fromJson(data["asset"]);
return eventAsset?.id == ids.first;
}, const Duration(seconds: 10));
}
try {
await _service.applyEdits(ids.first, edits);
await editReady;
return const ActionResult(count: 1, success: true);
} catch (error, stack) {
_logger.severe('Failed to apply edits to assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
}
extension on Iterable<RemoteAsset> {
@@ -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,10 @@ final remoteAssetRepositoryProvider = Provider<RemoteAssetRepository>(
(ref) => RemoteAssetRepository(ref.watch(driftProvider)),
);
final remoteExifRepositoryProvider = Provider<RemoteExifRepository>(
(ref) => RemoteExifRepository(ref.watch(driftProvider)),
);
final trashedLocalAssetRepository = Provider<DriftTrashedLocalAssetRepository>(
(ref) => DriftTrashedLocalAssetRepository(ref.watch(driftProvider)),
);
@@ -22,6 +27,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),
),
@@ -47,20 +47,6 @@ class AssetApiRepository extends ApiRepository {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility))));
}
Future<void> updateLocation(List<String> ids, LatLng location) async {
return _api.updateAssets(
AssetBulkUpdateDto(
ids: ids,
latitude: Optional.present(location.latitude),
longitude: Optional.present(location.longitude),
),
);
}
Future<void> updateDateTime(List<String> ids, String dateTime) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, dateTimeOriginal: Optional.present(dateTime)));
}
Future<StackResponse> stack(List<String> ids) async {
final responseDto = await checkNull(_stacksApi.createStack(StackCreateDto(assetIds: ids)));
@@ -109,12 +95,17 @@ 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(),
),
);
}
-90
View File
@@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@@ -21,11 +20,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
import 'package:immich_mobile/widgets/common/location_picker.dart';
import 'package:immich_mobile/widgets/common/tag_picker.dart';
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
final actionServiceProvider = Provider<ActionService>(
(ref) => ActionService(
@@ -117,83 +112,6 @@ class ActionService {
return await _deleteLocalAssets(localIds);
}
Future<bool> editLocation(List<String> remoteIds, BuildContext context) async {
maplibre.LatLng? initialLatLng;
if (remoteIds.length == 1) {
final exif = await _remoteAssetRepository.getExif(remoteIds[0]);
if (exif?.latitude != null && exif?.longitude != null) {
initialLatLng = maplibre.LatLng(exif!.latitude!, exif.longitude!);
}
}
final location = await showLocationPicker(context: context, initialLatLng: initialLatLng);
if (location == null) {
return false;
}
await _assetApiRepository.updateLocation(remoteIds, location);
await _remoteAssetRepository.updateLocation(remoteIds, location);
return true;
}
Future<bool> editDateTime(List<String> remoteIds, BuildContext context) async {
DateTime? initialDate;
String? timeZone;
Duration? offset;
if (remoteIds.length == 1) {
final assetId = remoteIds.first;
final asset = await _remoteAssetRepository.get(assetId);
if (asset == null) {
return false;
}
final exifData = await _remoteAssetRepository.getExif(assetId);
// Use EXIF timezone information if available (matching web app and display behavior)
DateTime dt = asset.createdAt.toLocal();
offset = dt.timeZoneOffset;
if (exifData?.dateTimeOriginal != null) {
timeZone = exifData!.timeZone;
(dt, offset) = applyTimezoneOffset(dateTime: exifData.dateTimeOriginal!, timeZone: exifData.timeZone);
}
initialDate = dt;
}
final dateTime = await showDateTimePicker(
context: context,
initialDateTime: initialDate,
initialTZ: timeZone,
initialTZOffset: offset,
);
if (dateTime == null) {
return false;
}
await applyDateTime(remoteIds, dateTime);
return true;
}
@visibleForTesting
Future<void> applyDateTime(List<String> remoteIds, String dateTime) async {
final parsedDateTime = DateTime.parse(dateTime);
final offset = RegExp(r'[+-]\d{2}:\d{2}$').firstMatch(dateTime)?.group(0);
await _assetApiRepository.updateDateTime(remoteIds, dateTime);
await _remoteAssetRepository.updateDateTime(
remoteIds,
parsedDateTime,
timeZone: offset == null ? null : 'UTC$offset',
);
}
Future<bool> updateDescription(String assetId, String description) async {
// update remote first, then local to ensure consistency
await _assetApiRepository.updateDescription(assetId, description);
@@ -267,14 +185,6 @@ class ActionService {
return true;
}
Future<void> applyEdits(String remoteId, List<AssetEdit> edits) async {
if (edits.isEmpty) {
await _assetApiRepository.removeEdits(remoteId);
} else {
await _assetApiRepository.editAsset(remoteId, edits);
}
}
Future<int> _deleteLocalAssets(List<String> localIds) async {
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isEmpty) {
@@ -8,6 +8,7 @@ class ImmichColumnButton extends StatefulWidget {
final IconData icon;
final String label;
final FutureOr<void> Function() onPressed;
final FutureOr<void> Function()? onLongPress;
final bool disabled;
final bool? loading;
@@ -16,6 +17,7 @@ class ImmichColumnButton extends StatefulWidget {
required this.icon,
required this.label,
required this.onPressed,
this.onLongPress,
this.disabled = false,
this.loading,
});
@@ -28,10 +30,10 @@ class _ImmichColumnButtonState extends State<ImmichColumnButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _onPressed() async {
Future<void> _run(FutureOr<void> Function() action) async {
setState(() => _loading = true);
try {
await widget.onPressed();
await action();
} finally {
if (mounted) {
setState(() => _loading = false);
@@ -42,9 +44,12 @@ class _ImmichColumnButtonState extends State<ImmichColumnButton> {
@override
Widget build(BuildContext context) {
final foreground = context.colorOverride ?? Theme.of(context).colorScheme.onSurface;
final handlerDisabled = widget.disabled || _isLoading;
final onLongPress = widget.onLongPress;
return TextButton(
onPressed: widget.disabled || _isLoading ? null : _onPressed,
onPressed: handlerDisabled ? null : () => _run(widget.onPressed),
onLongPress: handlerDisabled || onLongPress == null ? null : () => _run(onLongPress),
style: TextButton.styleFrom(
foregroundColor: foreground,
padding: const .symmetric(horizontal: ImmichSpacing.sm, vertical: ImmichSpacing.md),
@@ -7,6 +7,7 @@ import 'package:immich_ui/src/internal.dart';
class ImmichIconButton extends StatefulWidget {
final IconData icon;
final FutureOr<void> Function() onPressed;
final FutureOr<void> Function()? onLongPress;
final ImmichVariant variant;
final ImmichColor color;
final bool disabled;
@@ -16,6 +17,7 @@ class ImmichIconButton extends StatefulWidget {
super.key,
required this.icon,
required this.onPressed,
this.onLongPress,
this.color = .primary,
this.variant = .filled,
this.disabled = false,
@@ -30,10 +32,10 @@ class _ImmichIconButtonState extends State<ImmichIconButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _onPressed() async {
Future<void> _run(FutureOr<void> Function() action) async {
setState(() => _loading = true);
try {
await widget.onPressed();
await action();
} finally {
if (mounted) {
setState(() => _loading = false);
@@ -66,6 +68,9 @@ class _ImmichIconButtonState extends State<ImmichIconButton> {
},
};
final handlerDisabled = widget.disabled || _isLoading;
final onLongPress = widget.onLongPress;
return IconButton(
icon: _isLoading
? const SizedBox.square(
@@ -73,7 +78,8 @@ class _ImmichIconButtonState extends State<ImmichIconButton> {
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.md),
)
: Icon(widget.icon),
onPressed: widget.disabled || _isLoading ? null : _onPressed,
onPressed: handlerDisabled ? null : () => _run(widget.onPressed),
onLongPress: handlerDisabled || onLongPress == null ? null : () => _run(onLongPress),
style: IconButton.styleFrom(backgroundColor: background, foregroundColor: foreground),
);
}
@@ -7,6 +7,7 @@ class ImmichTextButton extends StatefulWidget {
final String labelText;
final IconData? icon;
final FutureOr<void> Function() onPressed;
final FutureOr<void> Function()? onLongPress;
final ImmichVariant variant;
final bool expanded;
final bool disabled;
@@ -17,6 +18,7 @@ class ImmichTextButton extends StatefulWidget {
required this.labelText,
this.icon,
required this.onPressed,
this.onLongPress,
this.variant = .filled,
this.expanded = true,
@@ -32,10 +34,10 @@ class _ImmichTextButtonState extends State<ImmichTextButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _onPressed() async {
Future<void> _run(FutureOr<void> Function() action) async {
setState(() => _loading = true);
try {
await widget.onPressed();
await action();
} finally {
if (mounted) {
setState(() => _loading = false);
@@ -59,11 +61,26 @@ class _ImmichTextButtonState extends State<ImmichTextButton> {
style: const .new(fontSize: ImmichTextSize.body, fontWeight: .bold),
);
final style = ElevatedButton.styleFrom(padding: const .symmetric(vertical: ImmichSpacing.md));
final onPressed = widget.disabled || _isLoading ? null : _onPressed;
final handlerDisabled = widget.disabled || _isLoading;
final longPress = widget.onLongPress;
final onPressed = handlerDisabled ? null : () => _run(widget.onPressed);
final onLongPress = handlerDisabled || longPress == null ? null : () => _run(longPress);
final button = switch (widget.variant) {
ImmichVariant.filled => ElevatedButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
ImmichVariant.ghost => TextButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
ImmichVariant.filled => ElevatedButton.icon(
style: style,
onPressed: onPressed,
onLongPress: onLongPress,
icon: icon,
label: label,
),
ImmichVariant.ghost => TextButton.icon(
style: style,
onPressed: onPressed,
onLongPress: onLongPress,
icon: icon,
label: label,
),
};
if (widget.expanded) {
@@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
@@ -53,6 +54,8 @@ class MockPartnerRepository extends Mock implements PartnerRepository {}
class MockToastRepository extends Mock implements ToastRepository {}
class MockRemoteExifRepository extends Mock implements RemoteExifRepository {}
// API Repos
class MockUserApiRepository extends Mock implements UserApiRepository {}
@@ -1,116 +0,0 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:mocktail/mocktail.dart';
class MockActionService extends Mock implements ActionService {}
class MockAssetService extends Mock implements AssetService {}
class MockDownloadService extends Mock implements DownloadService {}
class MockForegroundUploadService extends Mock implements ForegroundUploadService {}
class MockUserService extends Mock implements UserService {}
class FakeBuildContext extends Fake implements BuildContext {}
final _user = UserDto(id: 'user-1', email: 'user@test.dev', name: 'user', profileChangedAt: DateTime(2026));
final _asset = RemoteAsset(
id: 'asset-1',
name: 'photo.jpg',
ownerId: 'user-1',
checksum: 'checksum-1',
type: AssetType.image,
createdAt: DateTime(2026, 6, 10, 10, 27),
updatedAt: DateTime(2026, 6, 10, 10, 27),
isEdited: false,
);
void main() {
late ProviderContainer container;
late MockActionService actionService;
late MockAssetService assetService;
setUpAll(() {
registerFallbackValue(FakeBuildContext());
registerFallbackValue(_asset);
registerFallbackValue(<String>[]);
});
setUp(() {
actionService = MockActionService();
assetService = MockAssetService();
final userService = MockUserService();
when(() => actionService.editDateTime(any(), any())).thenAnswer((_) async => true);
when(() => assetService.watchAsset(any())).thenAnswer((_) => const Stream.empty());
when(() => assetService.getExif(any())).thenAnswer((_) async => null);
when(() => userService.tryGetMyUser()).thenReturn(_user);
when(() => userService.watchMyUser()).thenAnswer((_) => const Stream.empty());
container = ProviderContainer(
overrides: [
actionServiceProvider.overrideWithValue(actionService),
assetServiceProvider.overrideWithValue(assetService),
downloadServiceProvider.overrideWithValue(MockDownloadService()),
foregroundUploadServiceProvider.overrideWithValue(MockForegroundUploadService()),
currentUserProvider.overrideWith((ref) => CurrentUserProvider(userService)),
],
);
addTearDown(container.dispose);
});
group('editDateTime', () {
test('refreshes the exif provider when editing from the viewer', () async {
container.read(assetViewerProvider.notifier).setAsset(_asset);
container.listen(assetExifProvider(_asset), (_, __) {});
await container.read(assetExifProvider(_asset).future);
final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.viewer, FakeBuildContext());
expect(result?.success, isTrue);
await container.read(assetExifProvider(_asset).future);
verify(() => assetService.getExif(_asset)).called(2);
});
test('leaves the exif provider cached when editing from the timeline', () async {
container.read(assetViewerProvider.notifier).setAsset(_asset);
container.listen(assetExifProvider(_asset), (_, __) {});
await container.read(assetExifProvider(_asset).future);
final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.timeline, FakeBuildContext());
expect(result?.success, isTrue);
await container.read(assetExifProvider(_asset).future);
verify(() => assetService.getExif(_asset)).called(1);
});
test('does not refresh the exif provider when the edit is cancelled', () async {
when(() => actionService.editDateTime(any(), any())).thenAnswer((_) async => false);
container.read(assetViewerProvider.notifier).setAsset(_asset);
container.listen(assetExifProvider(_asset), (_, __) {});
await container.read(assetExifProvider(_asset).future);
final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.viewer, FakeBuildContext());
expect(result, isNull);
await container.read(assetExifProvider(_asset).future);
verify(() => assetService.getExif(_asset)).called(1);
});
});
}
@@ -99,49 +99,6 @@ void main() {
});
});
group('ActionService.applyDateTime', () {
const ids = ['asset_id_1'];
test('sends the picked value to the api with its offset intact', () async {
const picked = '2026-06-10T19:15:00.000+06:00';
when(() => assetApiRepository.updateDateTime(ids, picked)).thenAnswer((_) async {});
when(
() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC+06:00'),
).thenAnswer((_) async {});
await sut.applyDateTime(ids, picked);
verify(() => assetApiRepository.updateDateTime(ids, picked)).called(1);
verify(() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC+06:00')).called(1);
});
test('handles negative offsets', () async {
const picked = '2026-01-05T08:00:00.000-05:30';
when(() => assetApiRepository.updateDateTime(ids, picked)).thenAnswer((_) async {});
when(
() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC-05:30'),
).thenAnswer((_) async {});
await sut.applyDateTime(ids, picked);
verify(() => assetApiRepository.updateDateTime(ids, picked)).called(1);
verify(() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC-05:30')).called(1);
});
test('writes no timezone when the value has no offset', () async {
const picked = '2026-06-10T13:15:00.000Z';
when(() => assetApiRepository.updateDateTime(ids, picked)).thenAnswer((_) async {});
when(
() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: null),
).thenAnswer((_) async {});
await sut.applyDateTime(ids, picked);
verify(() => assetApiRepository.updateDateTime(ids, picked)).called(1);
verify(() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: null)).called(1);
});
});
group('ActionService.deleteLocal', () {
test('routes deleted ids to trashed repository when Android trash handling is enabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
@@ -10,7 +10,8 @@ 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,
@@ -22,7 +23,7 @@ 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,
+76
View File
@@ -4,14 +4,18 @@ 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/user.model.dart';
import 'package:immich_mobile/platform/native_sync_api.g.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 'factories/local_album_factory.dart';
import 'factories/local_asset_factory.dart';
import 'factories/remote_album_factory.dart';
@@ -20,12 +24,15 @@ 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());
RepositoryMocks() {
resetAll();
@@ -35,14 +42,30 @@ class RepositoryMocks {
_registerFallbacks();
localAlbum.reset();
localAsset.reset();
remoteAsset.reset();
remoteExif.reset();
reset(trashedAsset);
reset(remoteAlbum);
reset(albumApi);
nativeApi.reset();
assetApi.reset();
reset(toast);
_stubLocalAlbumRepository();
_stubLocalAssetRepository();
_stubRemoteAssetRepository();
_stubRemoteExifRepository();
_stubNativeSyncApi();
_stubAssetApiRepository();
}
void _stubRemoteAssetRepository() {
when(remoteAsset.getExif).thenAnswer((_) async => null);
when(remoteAsset.getAssetEdits).thenAnswer((_) async => const []);
when(remoteAsset.update).thenAnswer((_) async {});
}
void _stubRemoteExifRepository() {
when(remoteExif.update).thenAnswer((_) async {});
}
void _stubLocalAlbumRepository() {
@@ -58,6 +81,10 @@ class RepositoryMocks {
void _stubNativeSyncApi() {
when(nativeApi.hashAssets).thenAnswer((_) async => []);
}
void _stubAssetApiRepository() {
when(assetApi.update).thenAnswer((_) async => {});
}
}
class ServiceMocks {
@@ -109,6 +136,7 @@ class ServiceMocks {
when(asset.restoreTrash).thenAnswer((_) async {});
when(asset.trash).thenAnswer((_) async {});
when(asset.delete).thenAnswer((_) async {});
when(asset.applyEdits).thenAnswer((_) async {});
}
void _stubRemoteAlbumService() {
@@ -126,8 +154,13 @@ void _registerFallbacks() {
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());
}
extension type const Stub<T extends Mock>(T mockedClass) {
@@ -151,6 +184,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());
@@ -202,6 +262,8 @@ extension type const AssetServiceStub(MockAssetService service) implements Stub<
any(),
isFavorite: any(named: 'isFavorite'),
visibility: any(named: 'visibility'),
dateTime: any(named: 'dateTime'),
location: any(named: 'location'),
);
Future<void> Function() get stack =>
@@ -218,6 +280,9 @@ extension type const AssetServiceStub(MockAssetService service) implements Stub<
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> {
@@ -240,3 +305,14 @@ extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub<Mo
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'),
);
}
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_ui/immich_ui.dart';
import '../presentation_context.dart';
class _RecordingAction extends BaseAction {
final void Function() onTap;
final void Function()? onLong;
const _RecordingAction._({
required this.onTap,
required this.onLong,
required super.scope,
required super.icon,
required super.label,
super.isVisible,
});
factory _RecordingAction(
ActionScope scope, {
required void Function() onTap,
void Function()? onLong,
bool isVisible = true,
}) => _RecordingAction._(
scope: scope,
onTap: onTap,
onLong: onLong,
icon: Icons.bug_report_outlined,
label: 'test',
isVisible: isVisible,
);
@override
Future<void> onAction() async => onTap();
@override
Future<void> Function()? get onSecondaryAction {
final callback = onLong;
return callback == null ? null : () async => callback();
}
}
void main() {
late PresentationContext context;
setUp(() async {
context = await PresentationContext.create();
});
tearDown(() {
context.dispose();
});
group('ActionIconButtonWidget', () {
testWidgets('renders nothing when the action is not visible', (tester) async {
await tester.pumpActionButton(context, (scope) => _RecordingAction(scope, onTap: () {}, isVisible: false));
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('wires no long press handler when the action has no secondary action', (tester) async {
await tester.pumpActionButton(context, (scope) => _RecordingAction(scope, onTap: () {}));
expect(tester.widget<ImmichIconButton>(find.byType(ImmichIconButton)).onLongPress, isNull);
});
testWidgets('tap runs the primary action', (tester) async {
var taps = 0;
var longPresses = 0;
await tester.pumpActionButton(
context,
(scope) => _RecordingAction(scope, onTap: () => taps++, onLong: () => longPresses++),
);
await tester.tap(find.byType(ImmichIconButton));
await tester.pump();
expect(taps, 1);
expect(longPresses, 0);
});
testWidgets('long press runs the secondary action, not the primary', (tester) async {
var taps = 0;
var longPresses = 0;
await tester.pumpActionButton(
context,
(scope) => _RecordingAction(scope, onTap: () => taps++, onLong: () => longPresses++),
);
await tester.longPress(find.byType(ImmichIconButton));
await tester.pump();
expect(longPresses, 1);
expect(taps, 0);
});
});
}
@@ -0,0 +1,274 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/edit_asset.action.dart';
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
import 'package:immich_mobile/presentation/actions/edit_location.action.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../factories/remote_asset_factory.dart';
import '../../riverpod_mocks.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockAssetService assetService;
const unsupportedVersion = ServerVersion(major: 2, minor: 5, patch: 9);
setUp(() async {
context = await PresentationContext.create();
assetService = context.service.asset.service;
});
tearDown(() {
context.dispose();
});
List<Override> serverVersion(ServerVersion version) => [
serverInfoProvider.overrideWith((ref) => FakeServerInfoNotifier(version: version)),
];
RemoteAsset owned({AssetType type = .image}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, type: type);
group('EditImageAction', () {
testWidgets('visible for a single owned editable asset on a supported server', (tester) async {
final action = await tester.pumpActionButton(
context,
(scope) => EditAssetAction(assets: [owned()], scope: scope),
);
expect(action.isVisible, isTrue);
expect(action.icon, Icons.tune);
expect(action.label, StaticTranslations.instance.edit);
expect(find.byType(ImmichIconButton), findsOneWidget);
});
testWidgets('hidden when the server is older than 2.6.0', (tester) async {
final action = await tester.pumpActionButton(
context,
(scope) => EditAssetAction(assets: [owned()], scope: scope),
overrides: serverVersion(unsupportedVersion),
);
expect(action.isVisible, isFalse);
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('hidden for more than one asset', (tester) async {
final action = await tester.pumpActionButton(
context,
(scope) => EditAssetAction(assets: [owned(), owned()], scope: scope),
);
expect(action.isVisible, isFalse);
});
testWidgets('hidden for an asset owned by someone else', (tester) async {
final action = await tester.pumpActionButton(
context,
(scope) => EditAssetAction(assets: [RemoteAssetFactory.create()], scope: scope),
);
expect(action.isVisible, isFalse);
});
testWidgets('hidden for a non-editable asset', (tester) async {
final action = await tester.pumpActionButton(
context,
(scope) => EditAssetAction(
assets: [owned(type: AssetType.video)],
scope: scope,
),
);
expect(action.isVisible, isFalse);
});
testWidgets('reads the edits and exif for the asset from the repository', (tester) async {
final asset = owned();
final remoteAssetRepo = context.repository.remoteAsset.repo;
await tester.pumpTestAction(context, (scope) => EditAssetAction(assets: [asset], scope: scope));
await tester.pumpAndSettle();
verify(() => remoteAssetRepo.getAssetEdits(asset.id)).called(1);
verify(() => remoteAssetRepo.getExif(asset.id)).called(1);
});
});
group('applyEdits', () {
testWidgets('forwards the edits to the service and waits for both ready events', (tester) async {
late FakeWebsocketNotifier websocket;
const edits = <AssetEdit>[];
late WidgetRef capturedRef;
await tester.pumpTestWidget(
context,
Consumer(
builder: (_, ref, _) {
capturedRef = ref;
return const SizedBox.shrink();
},
),
overrides: [websocketProvider.overrideWith((ref) => websocket = FakeWebsocketNotifier(ref))],
);
await applyEdits(capturedRef, 'asset-1', edits);
verify(() => assetService.applyEdits('asset-1', edits)).called(1);
expect(websocket.waitedEvents, containsAll(['AssetEditReadyV1', 'AssetEditReadyV2']));
});
});
group('EditLocationAction', () {
testWidgets('visible with an owned remote asset', (tester) async {
final action = await tester.pumpActionButton(
context,
(scope) => EditLocationAction(assets: [owned()], scope: scope),
);
expect(action.isVisible, isTrue);
expect(action.icon, Icons.edit_location_alt_outlined);
expect(action.label, StaticTranslations.instance.control_bottom_app_bar_edit_location);
});
testWidgets('hidden without any owned remote asset', (tester) async {
final action = await tester.pumpActionButton(
context,
(scope) => EditLocationAction(assets: [RemoteAssetFactory.create()], scope: scope),
);
expect(action.isVisible, isFalse);
});
testWidgets('collects only the owned remote asset ids', (tester) async {
final mine = owned();
final theirs = RemoteAssetFactory.create();
final action = await tester.pumpActionButton(
context,
(scope) => EditLocationAction(assets: [mine, theirs], scope: scope),
);
expect((action as EditLocationAction).assetIds, [mine.id]);
});
testWidgets('save persists the location, refreshes the viewer exif and toasts', (tester) async {
final asset = owned();
final toast = context.repository.toast;
when(() => assetService.getExif(asset)).thenAnswer((_) async => null);
late EditLocationAction action;
await tester.pumpTestWidget(
context,
Consumer(
builder: (ctx, ref, _) {
action = EditLocationAction(
assets: [asset],
scope: ActionScope(context: ctx, ref: ref, authUser: context.currentUser),
);
// Keep the exif provider alive so a re-fetch after invalidation is observable.
ref.watch(assetExifProvider(asset));
return const SizedBox.shrink();
},
),
);
await tester.pumpAndSettle();
await action.save(const LatLng(1, 2));
await tester.pumpAndSettle();
final location =
verify(() => assetService.update([asset.id], location: captureAny(named: 'location'))).captured.single
as Option<LatLng>;
expect(location.unwrapOrNull?.latitude, 1);
expect(location.unwrapOrNull?.longitude, 2);
verify(() => assetService.getExif(asset)).called(2);
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.edit_location_action_prompt(count: 1));
});
});
group('EditDateTimeAction', () {
testWidgets('visible with an owned remote asset', (tester) async {
final action = await tester.pumpActionButton(
context,
(scope) => EditDateTimeAction(assets: [owned()], scope: scope),
);
expect(action.isVisible, isTrue);
expect(action.icon, Icons.edit_calendar_outlined);
expect(action.label, StaticTranslations.instance.control_bottom_app_bar_edit_time);
});
testWidgets('hidden without any owned remote asset', (tester) async {
final action = await tester.pumpActionButton(
context,
(scope) => EditDateTimeAction(assets: [RemoteAssetFactory.create()], scope: scope),
);
expect(action.isVisible, isFalse);
});
testWidgets('collects only the owned remote asset ids', (tester) async {
final mine = owned();
final theirs = RemoteAssetFactory.create();
final action = await tester.pumpActionButton(
context,
(scope) => EditDateTimeAction(assets: [mine, theirs], scope: scope),
);
expect((action as EditDateTimeAction).assetIds, [mine.id]);
});
testWidgets('save persists the date, refreshes the viewer exif and toasts', (tester) async {
final asset = owned();
final toast = context.repository.toast;
const picked = '2026-06-10T19:15:00.000+06:00';
when(() => assetService.getExif(asset)).thenAnswer((_) async => null);
late EditDateTimeAction action;
await tester.pumpTestWidget(
context,
Consumer(
builder: (ctx, ref, _) {
action = EditDateTimeAction(
assets: [asset],
scope: ActionScope(context: ctx, ref: ref, authUser: context.currentUser),
);
// Keep the exif provider alive so a re-fetch after invalidation is observable.
ref.watch(assetExifProvider(asset));
return const SizedBox.shrink();
},
),
);
await tester.pumpAndSettle();
await action.save(picked);
await tester.pumpAndSettle();
verify(() => assetService.update([asset.id], dateTime: const Some(picked))).called(1);
verify(() => assetService.getExif(asset)).called(2);
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.edit_date_and_time_action_prompt(count: 1));
});
});
}
@@ -49,6 +49,8 @@ class PresentationContext {
List<Override> get overrides => [
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
assetServiceProvider.overrideWithValue(service.asset.service),
remoteAssetRepositoryProvider.overrideWithValue(repository.remoteAsset.repo),
remoteExifRepositoryProvider.overrideWithValue(repository.remoteExif.repo),
partnerServiceProvider.overrideWithValue(service.partner.service),
remoteAlbumServiceProvider.overrideWithValue(service.album.service),
cleanupServiceProvider.overrideWithValue(service.cleanup.service),
+22 -2
View File
@@ -1,9 +1,29 @@
import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import '../domain/service.mock.dart';
class FakeServerInfoNotifier extends ServerInfoNotifier {
FakeServerInfoNotifier({bool trashEnabled = true}) : super(MockServerInfoService()) {
state = state.copyWith(serverFeatures: state.serverFeatures.copyWith(trash: trashEnabled));
FakeServerInfoNotifier({
bool trashEnabled = true,
ServerVersion version = const ServerVersion(major: 2, minor: 6, patch: 0),
}) : super(MockServerInfoService()) {
state = state.copyWith(
serverVersion: version,
serverFeatures: state.serverFeatures.copyWith(trash: trashEnabled),
);
}
}
class FakeWebsocketNotifier extends WebsocketNotifier {
FakeWebsocketNotifier(super.ref);
final List<String> waitedEvents = [];
@override
Future<void> waitForEvent(String event, bool Function(dynamic)? predicate, Duration timeout) {
waitedEvents.add(event);
return Future.value();
}
}
@@ -0,0 +1,79 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:mocktail/mocktail.dart';
import '../../infrastructure/repository.mock.dart';
import '../../repository.mocks.dart';
import '../mocks.dart';
void main() {
late AssetService sut;
late RepositoryMocks mocks;
late MockAssetApiRepository apiRepository;
late MockRemoteAssetRepository remoteRepository;
late MockRemoteExifRepository exifRepository;
setUp(() {
mocks = RepositoryMocks();
apiRepository = mocks.assetApi.api;
remoteRepository = mocks.remoteAsset.repo;
exifRepository = mocks.remoteExif.repo;
sut = AssetService(
remoteRepository: remoteRepository,
exifRepository: exifRepository,
localRepository: MockDriftLocalAssetRepository(),
apiRepository: apiRepository,
);
});
group('AssetService.updateDateTime', () {
const ids = ['asset_id_1'];
test('sends the picked value to the api with its offset intact', () async {
const picked = '2026-06-10T19:15:00.000+06:00';
await sut.update(ids, dateTime: const .some(picked));
verify(() => apiRepository.update(ids, dateTimeOriginal: const .some(picked))).called(1);
verify(() => remoteRepository.update(ids, createdAt: .some(DateTime.parse(picked)))).called(1);
verify(
() => exifRepository.update(
ids,
dateTimeOriginal: .some(DateTime.parse(picked)),
timeZone: const .some('UTC+06:00'),
),
).called(1);
});
test('handles negative offsets', () async {
const picked = '2026-01-05T08:00:00.000-05:30';
await sut.update(ids, dateTime: const .some(picked));
verify(() => remoteRepository.update(ids, createdAt: .some(DateTime.parse(picked)))).called(1);
verify(
() => exifRepository.update(
ids,
dateTimeOriginal: .some(DateTime.parse(picked)),
timeZone: const .some('UTC-05:30'),
),
).called(1);
});
test('writes no timezone when the value has no offset', () async {
const picked = '2026-06-10T13:15:00.000Z';
await sut.update(ids, dateTime: const .some(picked));
verify(() => remoteRepository.update(ids, createdAt: .some(DateTime.parse(picked)))).called(1);
verify(
() => exifRepository.update(ids, dateTimeOriginal: .some(DateTime.parse(picked)), timeZone: const .none()),
).called(1);
});
test('is a no-op when there are no asset ids', () async {
await sut.update(const [], dateTime: const .some('2026-06-10T19:15:00.000+06:00'));
verifyZeroInteractions(apiRepository);
verifyZeroInteractions(remoteRepository);
});
});
}