mirror of
https://github.com/immich-app/immich.git
synced 2026-07-28 22:51:21 -07:00
refactor: mobile edit asset actions
This commit is contained in:
@@ -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);
|
||||
}
|
||||
-52
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
-48
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -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),
|
||||
],
|
||||
|
||||
+5
-9
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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) => [
|
||||
|
||||
Reference in New Issue
Block a user