refactor: mobile edit asset actions

This commit is contained in:
shenlong-tanwen
2026-07-27 15:09:22 +05:30
parent 290e7fd4b5
commit c0fe5a534b
21 changed files with 478 additions and 521 deletions
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/generated/translations.g.dart';
@@ -13,16 +14,16 @@ class AssetDebugAction extends AssetActionBuilder {
@override
ActionData? build(BuildContext context, WidgetRef ref) {
final assets = ref.watch(assetsActionProvider(source)).assets;
final asset = ref.watch(assetsActionProvider(source)).assets.singleOrNull;
final troubleshootEnabled = ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
if (!troubleshootEnabled || assets.length != 1) {
if (!troubleshootEnabled || asset == null) {
return null;
}
return .new(
icon: Icons.help_outline_rounded,
label: context.t.troubleshoot,
onAction: () async => unawaited(context.pushRoute(AssetTroubleshootRoute(asset: assets.single))),
onAction: () async => unawaited(context.pushRoute(AssetTroubleshootRoute(asset: asset))),
);
}
}
@@ -0,0 +1,84 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.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/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/error_handler.dart';
import 'package:immich_mobile/utils/semver.dart';
const _minimumServerVersion = SemVer(major: 2, minor: 6, patch: 0);
final _stateProvider = Provider.family.autoDispose<RemoteAsset?, ActionSource>((ref, source) {
final isSupported = ref.watch(serverInfoProvider.select((state) => state.serverVersion >= _minimumServerVersion));
if (!isSupported) {
return null;
}
final AssetsActionState(:ownedAssets) = ref.watch(assetsActionProvider(source));
return ownedAssets.where((asset) => asset.isEditable).singleOrNull;
});
class EditAssetAction extends AssetActionBuilder {
const EditAssetAction({required super.source});
@override
ActionData? build(BuildContext context, WidgetRef ref) {
if (!ref.watch(_stateProvider(source).select((asset) => asset != null))) {
return null;
}
return .new(icon: Icons.tune, label: context.t.edit, onAction: () => _edit(context, ref));
}
Future<void> _edit(BuildContext context, WidgetRef ref) async {
final asset = ref.read(_stateProvider(source));
if (asset == null) {
return;
}
try {
// TODO(shenlong): Move all EXIF and Apply Edits logic onto the Route
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),
),
),
);
} catch (error, stack) {
handleError(error, stack: stack, description: "Failed to open the editor for the asset");
}
}
}
@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 .new(seconds: 10)),
websocket.waitForEvent('AssetEditReadyV2', isCurrentId, const .new(seconds: 10)),
]).catchError((_) {});
}
@@ -0,0 +1,98 @@
import 'package:collection/collection.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/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/error_handler.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
typedef _State = ({List<String> assetIds, RemoteAsset? origin});
final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, source) {
final AssetsActionState(:ownedAssets) = ref.watch(assetsActionProvider(source));
if (ownedAssets.isEmpty) {
return null;
}
return (assetIds: ownedAssets.map((asset) => asset.id).toList(growable: false), origin: ownedAssets.singleOrNull);
});
class EditDateTimeAction extends AssetActionBuilder {
const EditDateTimeAction({required super.source});
@override
ActionData? build(BuildContext context, WidgetRef ref) {
if (!ref.watch(_stateProvider(source).select((state) => state != null))) {
return null;
}
return .new(
icon: Icons.edit_calendar_outlined,
label: context.t.control_bottom_app_bar_edit_time,
onAction: () => _edit(context, ref),
);
}
Future<void> _edit(BuildContext context, WidgetRef ref) async {
final state = ref.read(_stateProvider(source));
if (state == null) {
return;
}
final (:assetIds, :origin) = state;
try {
DateTime? initialDate;
String? timeZone;
Duration? offset;
if (origin != null) {
final exif = await ref.read(remoteAssetRepositoryProvider).getExif(origin.id);
// Prefer the EXIF timezone, so the picker opens on what the asset actually shows.
DateTime dateTime = origin.createdAt.toLocal();
offset = dateTime.timeZoneOffset;
if (exif?.dateTimeOriginal case final original?) {
timeZone = exif!.timeZone;
(dateTime, offset) = applyTimezoneOffset(dateTime: original, timeZone: exif.timeZone);
}
initialDate = dateTime;
if (!context.mounted) {
return;
}
}
final picked = await showDateTimePicker(
context: context,
initialDateTime: initialDate,
initialTZ: timeZone,
initialTZOffset: offset,
);
if (picked == null || !context.mounted) {
return;
}
await saveDateTime(context, ref, assetIds, picked);
ref.read(assetsActionProvider(source).notifier).clearSelect();
} catch (error, stack) {
handleError(error, stack: stack, description: "Failed to update the date and time for assets");
}
}
}
@visibleForTesting
Future<void> saveDateTime(BuildContext context, WidgetRef ref, List<String> assetIds, String dateTime) async {
final message = context.t.edit_date_and_time_action_prompt(count: assetIds.length);
final toast = ref.read(toastRepositoryProvider);
await ref.read(assetServiceProvider).update(assetIds, dateTime: .some(dateTime));
ref.invalidate(assetExifProvider);
toast.success(message);
}
@@ -0,0 +1,83 @@
import 'package:collection/collection.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/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/error_handler.dart';
import 'package:immich_mobile/widgets/common/location_picker.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
typedef _State = ({List<String> assetIds, RemoteAsset? origin});
final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, source) {
final AssetsActionState(:ownedAssets) = ref.watch(assetsActionProvider(source));
if (ownedAssets.isEmpty) {
return null;
}
return (assetIds: ownedAssets.map((asset) => asset.id).toList(growable: false), origin: ownedAssets.singleOrNull);
});
class EditLocationAction extends AssetActionBuilder {
const EditLocationAction({required super.source});
@override
ActionData? build(BuildContext context, WidgetRef ref) {
if (!ref.watch(_stateProvider(source).select((state) => state != null))) {
return null;
}
return .new(
icon: Icons.edit_location_alt_outlined,
label: context.t.control_bottom_app_bar_edit_location,
onAction: () => _edit(context, ref),
);
}
Future<void> _edit(BuildContext context, WidgetRef ref) async {
final state = ref.read(_stateProvider(source));
if (state == null) {
return;
}
final (:assetIds, :origin) = state;
try {
LatLng? initialLatLng;
if (origin != null) {
final exif = await ref.read(remoteAssetRepositoryProvider).getExif(origin.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 || !context.mounted) {
return;
}
await saveLocation(context, ref, assetIds, location);
ref.read(assetsActionProvider(source).notifier).clearSelect();
} catch (error, stack) {
handleError(error, stack: stack, description: "Failed to update the location for assets");
}
}
}
@visibleForTesting
Future<void> saveLocation(BuildContext context, WidgetRef ref, List<String> assetIds, LatLng location) async {
final message = context.t.edit_location_action_prompt(count: assetIds.length);
final toast = ref.read(toastRepositoryProvider);
await ref.read(assetServiceProvider).update(assetIds, location: .some(location));
ref.invalidate(assetExifProvider);
toast.success(message);
}
@@ -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,7 @@ 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/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 +29,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 = const EditDateTimeAction(source: .viewer).build(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: editDateTime == null ? null : const Icon(Icons.edit, size: 18),
onTap: editDateTime?.onAction,
),
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
],
@@ -1,13 +1,12 @@
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/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 +52,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 +63,7 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
return const SizedBox.shrink();
}
final editLocation = const EditLocationAction(source: .viewer).build(context, ref);
final locationName = _getLocationName(exifInfo);
final coordinates = "${exifInfo?.latitude?.toStringAsFixed(4)}, ${exifInfo?.longitude?.toStringAsFixed(4)}";
@@ -79,8 +75,8 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
SheetTile(
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,
trailing: hasCoordinates && editLocation != null ? const Icon(Icons.edit_location_alt, size: 20) : null,
onTap: editLocation?.onAction,
),
if (hasCoordinates)
Padding(
@@ -115,7 +111,7 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
color: context.primaryColor,
),
leading: const Icon(Icons.location_off),
onTap: editLocation,
onTap: editLocation?.onAction,
),
],
),
@@ -5,9 +5,9 @@ import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.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';
@@ -15,8 +15,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';
import 'package:immich_ui/immich_ui.dart';
@@ -41,7 +39,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;
@@ -53,9 +50,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(),
..._actionColumnButtons(context, ref, const [EditAssetAction(source: .viewer)]),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
],
..._actionColumnButtons(context, ref, const [DeleteAction(source: .viewer)]),
@@ -4,14 +4,14 @@ 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/presentation/actions/action.widget.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/delete.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.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';
@@ -83,8 +83,8 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
const ActionColumnButton(action: FavoriteAction(source: .timeline)),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: DeleteAction(source: .timeline)),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: EditDateTimeAction(source: .timeline)),
const ActionColumnButton(action: EditLocationAction(source: .timeline)),
const ActionColumnButton(action: LockAction(source: .timeline)),
const ActionColumnButton(action: StackAction(source: .timeline)),
],
@@ -5,14 +5,14 @@ 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/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.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/delete.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.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';
@@ -80,8 +80,8 @@ class FavoriteBottomSheet extends ConsumerWidget {
const ActionColumnButton(action: ArchiveAction(source: .timeline)),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: DeleteAction(source: .timeline)),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: EditDateTimeAction(source: .timeline)),
const ActionColumnButton(action: EditLocationAction(source: .timeline)),
const ActionColumnButton(action: LockAction(source: .timeline)),
const ActionColumnButton(action: StackAction(source: .timeline)),
],
@@ -4,6 +4,8 @@ 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/presentation/actions/action.widget.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/archive.action.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
@@ -11,8 +13,6 @@ import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/stack.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';
@@ -90,8 +90,8 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const ActionColumnButton(action: FavoriteAction(source: .timeline)),
const ActionColumnButton(action: ArchiveAction(source: .timeline)),
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: EditDateTimeAction(source: .timeline)),
const ActionColumnButton(action: EditLocationAction(source: .timeline)),
const ActionColumnButton(action: LockAction(source: .timeline)),
const ActionColumnButton(action: StackAction(source: .timeline)),
],
@@ -4,14 +4,14 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.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/delete.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.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/remove_from_album_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
@@ -97,8 +97,8 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
const DownloadActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[
const ActionColumnButton(action: DeleteAction(source: .timeline)),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: EditDateTimeAction(source: .timeline)),
const ActionColumnButton(action: EditLocationAction(source: .timeline)),
const ActionColumnButton(action: LockAction(source: .timeline)),
const ActionColumnButton(action: StackAction(source: .timeline)),
],
@@ -6,23 +6,17 @@ 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/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/routing/router.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final actionProvider = NotifierProvider<ActionNotifier, void>(ActionNotifier.new, dependencies: [multiSelectProvider]);
@@ -119,50 +113,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 {
@@ -406,36 +356,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> {
-89
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/services/tag.service.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
@@ -16,11 +15,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(
@@ -69,83 +64,6 @@ class ActionService {
return count;
}
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<int> removeFromAlbum(List<String> remoteIds, String albumId) async {
final result = await _albumApiRepository.removeAssets(albumId, remoteIds);
if (result.removed.isNotEmpty) {
@@ -217,11 +135,4 @@ 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);
}
}
}
@@ -1,112 +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/foreground_upload.service.dart';
import 'package:mocktail/mocktail.dart';
class MockActionService extends Mock implements ActionService {}
class MockAssetService extends Mock implements AssetService {}
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),
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);
});
});
}
+8
View File
@@ -0,0 +1,8 @@
import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
class StubServerInfoNotifier extends ServerInfoNotifier {
StubServerInfoNotifier(super.serverInfoService, {required ServerVersion version}) {
state = state.copyWith(serverVersion: version);
}
}
@@ -89,46 +89,4 @@ 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);
});
});
}
@@ -0,0 +1,172 @@
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/models/server_info/server_version.model.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.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/server_info.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 '../../../riverpod_mocks.dart';
import '../../../service.mocks.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockAssetService assetService;
setUp(() async {
context = await PresentationContext.create();
assetService = context.service.asset.service;
});
tearDown(() {
context.dispose();
});
RemoteAsset owned({AssetType type = .image}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, type: type);
const supportedVersion = ServerVersion(major: 2, minor: 6, patch: 0);
List<Override> reportedVersion(ServerVersion version) => [
serverInfoProvider.overrideWith((ref) => StubServerInfoNotifier(context.service.serverInfo, version: version)),
];
late BuildContext actionContext;
late WidgetRef actionRef;
Future<void> pumpAction(
WidgetTester tester,
ActionBuilder action,
Set<BaseAsset> selection, {
List<Override> overrides = const [],
}) => tester.pumpTestWidget(
context,
Consumer(
builder: (widgetContext, ref, _) {
actionContext = widgetContext;
actionRef = ref;
return ActionIconButton(action: action);
},
),
overrides: [...context.selected(selection), ...overrides],
);
group('EditAssetAction', () {
Future<void> pumpEditAsset(
WidgetTester tester,
Set<BaseAsset> selection, {
ServerVersion version = supportedVersion,
}) => pumpAction(
tester,
const EditAssetAction(source: .timeline),
selection,
overrides: [...reportedVersion(version)],
);
testWidgets('offers to edit a single owned editable asset', (tester) async {
await pumpEditAsset(tester, {owned()});
expect(find.byType(ImmichIconButton), findsOneWidget);
});
testWidgets('is hidden when the server predates edit sync', (tester) async {
await pumpEditAsset(tester, {owned()}, version: const ServerVersion(major: 2, minor: 5, patch: 9));
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('is hidden for more than one asset', (tester) async {
await pumpEditAsset(tester, {owned(), owned()});
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('is hidden for an asset owned by someone else', (tester) async {
await pumpEditAsset(tester, {RemoteAssetFactory.create()});
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('is hidden for a non-editable asset', (tester) async {
await pumpEditAsset(tester, {owned(type: .video)});
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('reads the edits and exif for the asset it is about to open', (tester) async {
final asset = owned();
await pumpEditAsset(tester, {asset});
await tester.tap(find.byType(ImmichIconButton));
await tester.pump();
verify(() => context.repository.remoteAsset.repo.getAssetEdits(asset.id)).called(1);
verify(() => context.repository.remoteAsset.repo.getExif(asset.id)).called(1);
});
});
group('EditLocationAction', () {
testWidgets('offers to edit an owned remote asset', (tester) async {
await pumpAction(tester, const EditLocationAction(source: .timeline), {owned()});
expect(find.byType(ImmichIconButton), findsOneWidget);
});
testWidgets('is hidden without any owned remote asset', (tester) async {
await pumpAction(tester, const EditLocationAction(source: .timeline), {RemoteAssetFactory.create()});
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('saves the location against every owned asset and toasts the count', (tester) async {
final mine = owned();
final theirs = RemoteAssetFactory.create();
await pumpAction(tester, const EditLocationAction(source: .timeline), {mine, theirs});
await saveLocation(actionContext, actionRef, [mine.id], const LatLng(1, 2));
await tester.pumpAndSettle();
final location =
verify(() => assetService.update([mine.id], location: captureAny(named: 'location'))).captured.single
as Option<LatLng>;
expect(location.unwrapOrNull?.latitude, 1);
expect(location.unwrapOrNull?.longitude, 2);
});
});
group('EditDateTimeAction', () {
testWidgets('offers to edit an owned remote asset', (tester) async {
await pumpAction(tester, const EditDateTimeAction(source: .timeline), {owned()});
expect(find.byType(ImmichIconButton), findsOneWidget);
});
testWidgets('is hidden without any owned remote asset', (tester) async {
await pumpAction(tester, const EditDateTimeAction(source: .timeline), {RemoteAssetFactory.create()});
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('saves the date against every owned asset and toasts the count', (tester) async {
const picked = '2026-06-10T19:15:00.000+06:00';
final mine = owned();
final theirs = RemoteAssetFactory.create();
await pumpAction(tester, const EditDateTimeAction(source: .timeline), {mine, theirs});
await saveDateTime(actionContext, actionRef, [mine.id], picked);
await tester.pumpAndSettle();
verify(() => assetService.update([mine.id], dateTime: const Some(picked))).called(1);
});
});
}
@@ -54,6 +54,7 @@ class PresentationContext {
gCastServiceProvider.overrideWithValue(service.cast),
serverInfoServiceProvider.overrideWithValue(service.serverInfo),
inLockedViewProvider.overrideWithValue(false),
remoteAssetRepositoryProvider.overrideWithValue(repository.remoteAsset.repo),
];
List<Override> selected(Set<BaseAsset> assets) => [