From 6dd60532224b322b85df8fbb3792f2abc85873fa Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Wed, 15 Apr 2026 09:26:40 -0500 Subject: [PATCH] feat: mobile editing (#25397) * feat: mobile editing fix: openapi patch this sucks :pepehands: chore: migrate some changes from the filtering PR chore: color tweak fix: hide edit button on server versions chore: translation * chore: code review changes chore: code review * sealed class * const constant * enum * concurrent queries * chore: major cleanup to use riverpod provider * fix: aspect ratio selection * chore: typesafe websocket event parsing * fix: wrong disable state for save button * chore: remove isCancelled shim * chore: cleanup postframe callback usage * chore: clean import --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> --- mobile/lib/constants/aspect_ratios.dart | 19 + .../domain/models/asset/base_asset.model.dart | 2 + .../models/asset/remote_asset.model.dart | 3 + .../lib/domain/models/asset_edit.model.dart | 32 +- mobile/lib/domain/models/exif.model.dart | 14 + mobile/lib/extensions/object_extensions.dart | 3 + .../entities/asset_edit.entity.dart | 11 +- .../infrastructure/entities/exif.entity.dart | 2 + .../repositories/remote_asset.repository.dart | 9 + .../pages/edit/drift_edit.page.dart | 399 ++++++++++++++++++ .../pages/edit/editor.provider.dart | 210 +++++++++ .../pages/editing/drift_crop.page.dart | 179 -------- .../pages/editing/drift_edit.page.dart | 153 ------- .../pages/editing/drift_filter.page.dart | 159 ------- .../edit_image_action_button.widget.dart | 35 +- .../asset_viewer/bottom_bar.widget.dart | 9 +- .../widgets/images/image_provider.dart | 7 +- .../widgets/images/remote_image_provider.dart | 46 +- .../infrastructure/action.provider.dart | 30 +- mobile/lib/providers/websocket.provider.dart | 21 + .../repositories/asset_api.repository.dart | 28 ++ mobile/lib/routing/router.dart | 9 +- mobile/lib/routing/router.gr.dart | 114 +---- mobile/lib/services/action.service.dart | 9 + mobile/lib/utils/editor.utils.dart | 65 +++ mobile/lib/utils/matrix.utils.dart | 50 +++ .../lib/model/asset_edit_action_item_dto.dart | 4 +- mobile/test/utils/editor_test.dart | 322 ++++++++++++++ open-api/bin/generate-open-api.sh | 1 + .../asset_edit_action_item_dto.dart.patch | 18 + 30 files changed, 1317 insertions(+), 646 deletions(-) create mode 100644 mobile/lib/constants/aspect_ratios.dart create mode 100644 mobile/lib/extensions/object_extensions.dart create mode 100644 mobile/lib/presentation/pages/edit/drift_edit.page.dart create mode 100644 mobile/lib/presentation/pages/edit/editor.provider.dart delete mode 100644 mobile/lib/presentation/pages/editing/drift_crop.page.dart delete mode 100644 mobile/lib/presentation/pages/editing/drift_edit.page.dart delete mode 100644 mobile/lib/presentation/pages/editing/drift_filter.page.dart create mode 100644 mobile/lib/utils/editor.utils.dart create mode 100644 mobile/lib/utils/matrix.utils.dart create mode 100644 mobile/test/utils/editor_test.dart create mode 100644 open-api/patch/asset_edit_action_item_dto.dart.patch diff --git a/mobile/lib/constants/aspect_ratios.dart b/mobile/lib/constants/aspect_ratios.dart new file mode 100644 index 0000000000..9159db4ef1 --- /dev/null +++ b/mobile/lib/constants/aspect_ratios.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +enum AspectRatioPreset { + free(ratio: null, label: 'Free', icon: Icons.crop_free_rounded), + square(ratio: 1.0, label: '1:1', icon: Icons.crop_square_rounded), + ratio16x9(ratio: 16 / 9, label: '16:9', icon: Icons.crop_16_9_rounded), + ratio3x2(ratio: 3 / 2, label: '3:2', icon: Icons.crop_3_2_rounded), + ratio7x5(ratio: 7 / 5, label: '7:5', icon: Icons.crop_7_5_rounded), + ratio9x16(ratio: 9 / 16, label: '9:16', icon: Icons.crop_16_9_rounded, iconRotated: true), + ratio2x3(ratio: 2 / 3, label: '2:3', icon: Icons.crop_3_2_rounded, iconRotated: true), + ratio5x7(ratio: 5 / 7, label: '5:7', icon: Icons.crop_7_5_rounded, iconRotated: true); + + final double? ratio; + final String label; + final IconData icon; + final bool iconRotated; + + const AspectRatioPreset({required this.ratio, required this.label, required this.icon, this.iconRotated = false}); +} diff --git a/mobile/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart index 9ba8cd06f8..85c42fd24f 100644 --- a/mobile/lib/domain/models/asset/base_asset.model.dart +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -71,6 +71,8 @@ sealed class BaseAsset { bool get isLocalOnly => storage == AssetState.local; bool get isRemoteOnly => storage == AssetState.remote; + bool get isEditable => false; + // Overridden in subclasses AssetState get storage; String? get localId; diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index b9a0e64d6a..745e8f46ff 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -43,6 +43,9 @@ class RemoteAsset extends BaseAsset { @override String get heroTag => '${localId ?? checksum}_$id'; + @override + bool get isEditable => isImage && !isMotionPhoto && !isAnimatedImage; + @override String toString() { return '''Asset { diff --git a/mobile/lib/domain/models/asset_edit.model.dart b/mobile/lib/domain/models/asset_edit.model.dart index b3266dba46..9809b9c606 100644 --- a/mobile/lib/domain/models/asset_edit.model.dart +++ b/mobile/lib/domain/models/asset_edit.model.dart @@ -1,21 +1,25 @@ -import "package:openapi/api.dart" as api show AssetEditAction; +import "package:openapi/api.dart" show CropParameters, RotateParameters, MirrorParameters; enum AssetEditAction { rotate, crop, mirror, other } -extension AssetEditActionExtension on AssetEditAction { - api.AssetEditAction? toDto() { - return switch (this) { - AssetEditAction.rotate => api.AssetEditAction.rotate, - AssetEditAction.crop => api.AssetEditAction.crop, - AssetEditAction.mirror => api.AssetEditAction.mirror, - AssetEditAction.other => null, - }; - } +sealed class AssetEdit { + const AssetEdit(); } -class AssetEdit { - final AssetEditAction action; - final Map parameters; +class CropEdit extends AssetEdit { + final CropParameters parameters; - const AssetEdit({required this.action, required this.parameters}); + const CropEdit(this.parameters); +} + +class RotateEdit extends AssetEdit { + final RotateParameters parameters; + + const RotateEdit(this.parameters); +} + +class MirrorEdit extends AssetEdit { + final MirrorParameters parameters; + + const MirrorEdit(this.parameters); } diff --git a/mobile/lib/domain/models/exif.model.dart b/mobile/lib/domain/models/exif.model.dart index d0f78b59de..45b787d586 100644 --- a/mobile/lib/domain/models/exif.model.dart +++ b/mobile/lib/domain/models/exif.model.dart @@ -7,6 +7,8 @@ class ExifInfo { final String? timeZone; final DateTime? dateTimeOriginal; final int? rating; + final int? width; + final int? height; // GPS final double? latitude; @@ -48,6 +50,8 @@ class ExifInfo { this.timeZone, this.dateTimeOriginal, this.rating, + this.width, + this.height, this.isFlipped = false, this.latitude, this.longitude, @@ -74,6 +78,8 @@ class ExifInfo { other.timeZone == timeZone && other.dateTimeOriginal == dateTimeOriginal && other.rating == rating && + other.width == width && + other.height == height && other.latitude == latitude && other.longitude == longitude && other.city == city && @@ -98,6 +104,8 @@ class ExifInfo { timeZone.hashCode ^ dateTimeOriginal.hashCode ^ rating.hashCode ^ + width.hashCode ^ + height.hashCode ^ latitude.hashCode ^ longitude.hashCode ^ city.hashCode ^ @@ -123,6 +131,8 @@ isFlipped: $isFlipped, timeZone: ${timeZone ?? 'NA'}, dateTimeOriginal: ${dateTimeOriginal ?? 'NA'}, rating: ${rating ?? 'NA'}, +width: ${width ?? 'NA'}, +height: ${height ?? 'NA'}, latitude: ${latitude ?? 'NA'}, longitude: ${longitude ?? 'NA'}, city: ${city ?? 'NA'}, @@ -146,6 +156,8 @@ exposureSeconds: ${exposureSeconds ?? 'NA'}, String? timeZone, DateTime? dateTimeOriginal, int? rating, + int? width, + int? height, double? latitude, double? longitude, String? city, @@ -168,6 +180,8 @@ exposureSeconds: ${exposureSeconds ?? 'NA'}, timeZone: timeZone ?? this.timeZone, dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal, rating: rating ?? this.rating, + width: width ?? this.width, + height: height ?? this.height, isFlipped: isFlipped ?? this.isFlipped, latitude: latitude ?? this.latitude, longitude: longitude ?? this.longitude, diff --git a/mobile/lib/extensions/object_extensions.dart b/mobile/lib/extensions/object_extensions.dart new file mode 100644 index 0000000000..4e76532137 --- /dev/null +++ b/mobile/lib/extensions/object_extensions.dart @@ -0,0 +1,3 @@ +extension Let on T { + R let(R Function(T) transform) => transform(this); +} diff --git a/mobile/lib/infrastructure/entities/asset_edit.entity.dart b/mobile/lib/infrastructure/entities/asset_edit.entity.dart index 22d059bdb4..87a05ab8fe 100644 --- a/mobile/lib/infrastructure/entities/asset_edit.entity.dart +++ b/mobile/lib/infrastructure/entities/asset_edit.entity.dart @@ -1,8 +1,10 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart'; +import 'package:immich_mobile/extensions/object_extensions.dart'; import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; +import 'package:openapi/api.dart' hide AssetEditAction; @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)') class AssetEditEntity extends Table with DriftDefaultsMixin { @@ -27,7 +29,12 @@ final JsonTypeConverter2, Uint8List, Object?> editParameter ); extension AssetEditEntityDataDomainEx on AssetEditEntityData { - AssetEdit toDto() { - return AssetEdit(action: action, parameters: parameters); + AssetEdit? toDto() { + return switch (action) { + AssetEditAction.crop => CropParameters.fromJson(parameters)?.let(CropEdit.new), + AssetEditAction.rotate => RotateParameters.fromJson(parameters)?.let(RotateEdit.new), + AssetEditAction.mirror => MirrorParameters.fromJson(parameters)?.let(MirrorEdit.new), + AssetEditAction.other => null, + }; } } diff --git a/mobile/lib/infrastructure/entities/exif.entity.dart b/mobile/lib/infrastructure/entities/exif.entity.dart index 77cae5dbbe..06262f4afc 100644 --- a/mobile/lib/infrastructure/entities/exif.entity.dart +++ b/mobile/lib/infrastructure/entities/exif.entity.dart @@ -152,6 +152,8 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData { fileSize: fileSize, dateTimeOriginal: dateTimeOriginal, rating: rating, + width: width, + height: height, timeZone: timeZone, make: make, model: model, diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index df4172df99..00c0b81850 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -1,7 +1,9 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/stack.model.dart'; +import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart' hide ExifInfo; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; @@ -264,4 +266,11 @@ class RemoteAssetRepository extends DriftDatabaseRepository { Future getCount() { return _db.managers.remoteAssetEntity.count(); } + + Future> getAssetEdits(String assetId) { + final query = _db.assetEditEntity.select() + ..where((row) => row.assetId.equals(assetId) & row.action.equals(AssetEditAction.other.index).not()) + ..orderBy([(row) => OrderingTerm.asc(row.sequence)]); + return query.map((row) => row.toDto()!).get(); + } } diff --git a/mobile/lib/presentation/pages/edit/drift_edit.page.dart b/mobile/lib/presentation/pages/edit/drift_edit.page.dart new file mode 100644 index 0000000000..8f7d874983 --- /dev/null +++ b/mobile/lib/presentation/pages/edit/drift_edit.page.dart @@ -0,0 +1,399 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:auto_route/auto_route.dart'; +import 'package:crop_image/crop_image.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/aspect_ratios.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart'; +import 'package:immich_mobile/providers/theme.provider.dart'; +import 'package:immich_mobile/theme/theme_data.dart'; +import 'package:immich_mobile/utils/editor.utils.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:openapi/api.dart' show RotateParameters, MirrorParameters, MirrorAxis; + +@RoutePage() +class DriftEditImagePage extends ConsumerStatefulWidget { + final Image image; + final Future Function(List edits) applyEdits; + + const DriftEditImagePage({super.key, required this.image, required this.applyEdits}); + + @override + ConsumerState createState() => _DriftEditImagePageState(); +} + +class _DriftEditImagePageState extends ConsumerState with TickerProviderStateMixin { + Future _saveEditedImage() async { + ref.read(editorStateProvider.notifier).setIsEditing(true); + + final editorState = ref.read(editorStateProvider); + final cropParameters = convertRectToCropParameters( + editorState.crop, + editorState.originalWidth, + editorState.originalHeight, + ); + final edits = []; + + if (cropParameters.width != editorState.originalWidth || cropParameters.height != editorState.originalHeight) { + edits.add(CropEdit(cropParameters)); + } + + if (editorState.flipHorizontal) { + edits.add(MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal))); + } + + if (editorState.flipVertical) { + edits.add(MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical))); + } + + final normalizedRotation = (editorState.rotationAngle % 360 + 360) % 360; + if (normalizedRotation != 0) { + edits.add(RotateEdit(RotateParameters(angle: normalizedRotation))); + } + + try { + await widget.applyEdits(edits); + ImmichToast.show(context: context, msg: 'success'.tr(), toastType: ToastType.success); + Navigator.of(context).pop(); + } catch (e) { + ImmichToast.show(context: context, msg: 'error_title'.tr(), toastType: ToastType.error); + } finally { + ref.read(editorStateProvider.notifier).setIsEditing(false); + } + } + + Future _showDiscardChangesDialog() { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('editor_discard_edits_title'.tr()), + content: Text('editor_discard_edits_prompt'.tr()), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + style: ButtonStyle( + foregroundColor: WidgetStateProperty.all(context.themeData.colorScheme.onSurfaceVariant), + ), + child: Text('cancel'.tr()), + ), + TextButton(onPressed: () => Navigator.of(context).pop(true), child: Text('confirm'.tr())), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final hasUnsavedEdits = ref.watch(editorStateProvider.select((state) => state.hasUnsavedEdits)); + + return PopScope( + canPop: !hasUnsavedEdits, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final shouldDiscard = await _showDiscardChangesDialog() ?? false; + if (shouldDiscard && mounted) { + Navigator.of(context).pop(); + } + }, + child: Theme( + data: getThemeData(colorScheme: ref.watch(immichThemeProvider).dark, locale: context.locale), + child: Scaffold( + appBar: AppBar( + backgroundColor: Colors.black, + title: Text("edit".tr()), + leading: ImmichCloseButton(onPressed: () => Navigator.of(context).maybePop()), + actions: [_SaveEditsButton(onSave: _saveEditedImage)], + ), + backgroundColor: Colors.black, + body: SafeArea( + bottom: false, + child: Column( + children: [ + Expanded(child: _EditorPreview(image: widget.image)), + AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + alignment: Alignment.bottomCenter, + clipBehavior: Clip.none, + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: ref.watch(immichThemeProvider).dark.surface, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + _TransformControls(), + Padding( + padding: EdgeInsets.only(bottom: 36, left: 24, right: 24), + child: Row(children: [Spacer(), _ResetEditsButton()]), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _AspectRatioButton extends StatelessWidget { + final AspectRatioPreset ratio; + final bool isSelected; + final VoidCallback onPressed; + + const _AspectRatioButton({required this.ratio, required this.isSelected, required this.onPressed}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.max, + children: [ + IconButton( + iconSize: 36, + icon: Transform.rotate( + angle: ratio.iconRotated ? pi / 2 : 0, + child: Icon(ratio.icon, color: isSelected ? context.primaryColor : context.themeData.iconTheme.color), + ), + onPressed: onPressed, + ), + Text(ratio.label, style: context.textTheme.displayMedium), + ], + ); + } +} + +class _AspectRatioSelector extends ConsumerWidget { + const _AspectRatioSelector(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final editorState = ref.watch(editorStateProvider); + final editorNotifier = ref.read(editorStateProvider.notifier); + + // the whole crop view is rotated, so we need to swap the aspect ratio when the rotation is 90 or 270 degrees + double? selectedAspectRatio = editorState.aspectRatio; + if (editorState.rotationAngle % 180 != 0 && selectedAspectRatio != null) { + selectedAspectRatio = 1 / selectedAspectRatio; + } + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: AspectRatioPreset.values.map((entry) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: _AspectRatioButton( + ratio: entry, + isSelected: selectedAspectRatio == entry.ratio, + onPressed: () => editorNotifier.setAspectRatio(entry.ratio), + ), + ); + }).toList(), + ), + ); + } +} + +class _TransformControls extends ConsumerWidget { + const _TransformControls(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final editorNotifier = ref.read(editorStateProvider.notifier); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 20, right: 20, top: 20, bottom: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + ImmichIconButton( + icon: Icons.rotate_left, + variant: ImmichVariant.ghost, + color: ImmichColor.secondary, + onPressed: editorNotifier.rotateCCW, + ), + const SizedBox(width: 8), + ImmichIconButton( + icon: Icons.rotate_right, + variant: ImmichVariant.ghost, + color: ImmichColor.secondary, + onPressed: editorNotifier.rotateCW, + ), + ], + ), + Row( + children: [ + ImmichIconButton( + icon: Icons.flip, + variant: ImmichVariant.ghost, + color: ImmichColor.secondary, + onPressed: editorNotifier.flipHorizontally, + ), + const SizedBox(width: 8), + Transform.rotate( + angle: pi / 2, + child: ImmichIconButton( + icon: Icons.flip, + variant: ImmichVariant.ghost, + color: ImmichColor.secondary, + onPressed: editorNotifier.flipVertically, + ), + ), + ], + ), + ], + ), + ), + const _AspectRatioSelector(), + const SizedBox(height: 32), + ], + ); + } +} + +class _SaveEditsButton extends ConsumerWidget { + final VoidCallback onSave; + + const _SaveEditsButton({required this.onSave}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isApplyingEdits = ref.watch(editorStateProvider.select((state) => state.isApplyingEdits)); + final hasUnsavedEdits = ref.watch(editorStateProvider.select((state) => state.hasUnsavedEdits)); + + return isApplyingEdits + ? const Padding( + padding: EdgeInsets.all(8.0), + child: SizedBox(width: 28, height: 28, child: CircularProgressIndicator(strokeWidth: 2.5)), + ) + : ImmichIconButton( + icon: Icons.done_rounded, + color: ImmichColor.primary, + variant: ImmichVariant.ghost, + disabled: !hasUnsavedEdits, + onPressed: onSave, + ); + } +} + +class _ResetEditsButton extends ConsumerWidget { + const _ResetEditsButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final editorState = ref.watch(editorStateProvider); + final editorNotifier = ref.read(editorStateProvider.notifier); + + return ImmichTextButton( + labelText: 'reset'.tr(), + onPressed: editorNotifier.resetEdits, + variant: ImmichVariant.ghost, + expanded: false, + disabled: !editorState.hasEdits || editorState.isApplyingEdits, + ); + } +} + +class _EditorPreview extends ConsumerStatefulWidget { + final Image image; + + const _EditorPreview({required this.image}); + + @override + ConsumerState<_EditorPreview> createState() => _EditorPreviewState(); +} + +class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProviderStateMixin { + late final CropController cropController; + + @override + void initState() { + super.initState(); + + cropController = CropController(); + cropController.crop = ref.read(editorStateProvider.select((state) => state.crop)); + cropController.addListener(onCrop); + } + + void onCrop() { + if (!mounted || cropController.crop == ref.read(editorStateProvider).crop) { + return; + } + + ref.read(editorStateProvider.notifier).setCrop(cropController.crop); + } + + @override + void dispose() { + cropController.removeListener(onCrop); + cropController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final editorState = ref.watch(editorStateProvider); + final editorNotifier = ref.read(editorStateProvider.notifier); + + ref.listen(editorStateProvider, (_, current) { + cropController.aspectRatio = current.aspectRatio; + + if (cropController.crop != current.crop) { + cropController.crop = current.crop; + } + }); + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + // Calculate the bounding box size needed for the rotated container + final baseWidth = constraints.maxWidth * 0.9; + final baseHeight = constraints.maxHeight * 0.95; + + return Center( + child: AnimatedRotation( + turns: editorState.rotationAngle / 360, + duration: editorState.animationDuration, + curve: Curves.easeInOut, + onEnd: editorNotifier.normalizeRotation, + child: Transform( + alignment: Alignment.center, + transform: Matrix4.identity() + ..scaleByDouble( + editorState.flipHorizontal ? -1.0 : 1.0, + editorState.flipVertical ? -1.0 : 1.0, + 1.0, + 1.0, + ), + child: Container( + padding: const EdgeInsets.all(10), + width: (editorState.rotationAngle % 180 == 0) ? baseWidth : baseHeight, + height: (editorState.rotationAngle % 180 == 0) ? baseHeight : baseWidth, + child: CropImage(controller: cropController, image: widget.image, gridColor: Colors.white), + ), + ), + ), + ); + }, + ); + } +} diff --git a/mobile/lib/presentation/pages/edit/editor.provider.dart b/mobile/lib/presentation/pages/edit/editor.provider.dart new file mode 100644 index 0000000000..21b5268912 --- /dev/null +++ b/mobile/lib/presentation/pages/edit/editor.provider.dart @@ -0,0 +1,210 @@ +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/utils/editor.utils.dart'; + +final editorStateProvider = NotifierProvider(EditorProvider.new); + +class EditorProvider extends Notifier { + @override + EditorState build() { + return const EditorState(); + } + + void clear() { + state = const EditorState(); + } + + void init(List edits, ExifInfo exifInfo) { + clear(); + + final existingCrop = edits.whereType().firstOrNull; + + final originalWidth = exifInfo.isFlipped ? exifInfo.height : exifInfo.width; + final originalHeight = exifInfo.isFlipped ? exifInfo.width : exifInfo.height; + + Rect crop = existingCrop != null && originalWidth != null && originalHeight != null + ? convertCropParametersToRect(existingCrop.parameters, originalWidth, originalHeight) + : const Rect.fromLTRB(0, 0, 1, 1); + + final transform = normalizeTransformEdits(edits); + + state = state.copyWith( + originalWidth: originalWidth, + originalHeight: originalHeight, + crop: crop, + flipHorizontal: transform.mirrorHorizontal, + flipVertical: transform.mirrorVertical, + ); + + _animateRotation(transform.rotation.toInt(), duration: Duration.zero); + } + + void _animateRotation(int angle, {Duration duration = const Duration(milliseconds: 300)}) { + state = state.copyWith(rotationAngle: angle, animationDuration: duration); + } + + void normalizeRotation() { + final normalizedAngle = ((state.rotationAngle % 360) + 360) % 360; + if (normalizedAngle != state.rotationAngle) { + state = state.copyWith(rotationAngle: normalizedAngle, animationDuration: Duration.zero); + } + } + + void setIsEditing(bool isApplyingEdits) { + state = state.copyWith(isApplyingEdits: isApplyingEdits); + } + + void setCrop(Rect crop) { + state = state.copyWith(crop: crop, hasUnsavedEdits: true); + } + + void setAspectRatio(double? aspectRatio) { + if (aspectRatio != null && state.rotationAngle % 180 != 0) { + // When rotated 90 or 270 degrees, swap width and height for aspect ratio calculations + aspectRatio = 1 / aspectRatio; + } + + state = state.copyWith(aspectRatio: aspectRatio); + } + + void resetEdits() { + _animateRotation(0); + + state = state.copyWith( + flipHorizontal: false, + flipVertical: false, + crop: const Rect.fromLTRB(0, 0, 1, 1), + aspectRatio: null, + hasUnsavedEdits: true, + ); + } + + void rotateCCW() { + _animateRotation(state.rotationAngle - 90); + state = state.copyWith(hasUnsavedEdits: true); + } + + void rotateCW() { + _animateRotation(state.rotationAngle + 90); + state = state.copyWith(hasUnsavedEdits: true); + } + + void flipHorizontally() { + if (state.rotationAngle % 180 != 0) { + // When rotated 90 or 270 degrees, flipping horizontally is equivalent to flipping vertically + state = state.copyWith(flipVertical: !state.flipVertical, hasUnsavedEdits: true); + } else { + state = state.copyWith(flipHorizontal: !state.flipHorizontal, hasUnsavedEdits: true); + } + } + + void flipVertically() { + if (state.rotationAngle % 180 != 0) { + // When rotated 90 or 270 degrees, flipping vertically is equivalent to flipping horizontally + state = state.copyWith(flipHorizontal: !state.flipHorizontal, hasUnsavedEdits: true); + } else { + state = state.copyWith(flipVertical: !state.flipVertical, hasUnsavedEdits: true); + } + } +} + +class EditorState { + final bool isApplyingEdits; + + final int rotationAngle; + final bool flipHorizontal; + final bool flipVertical; + final Rect crop; + final double? aspectRatio; + + final int originalWidth; + final int originalHeight; + + final Duration animationDuration; + + final bool hasUnsavedEdits; + + const EditorState({ + bool? isApplyingEdits, + int? rotationAngle, + bool? flipHorizontal, + bool? flipVertical, + Rect? crop, + this.aspectRatio, + int? originalWidth, + int? originalHeight, + Duration? animationDuration, + bool? hasUnsavedEdits, + }) : isApplyingEdits = isApplyingEdits ?? false, + rotationAngle = rotationAngle ?? 0, + flipHorizontal = flipHorizontal ?? false, + flipVertical = flipVertical ?? false, + animationDuration = animationDuration ?? Duration.zero, + originalWidth = originalWidth ?? 0, + originalHeight = originalHeight ?? 0, + crop = crop ?? const Rect.fromLTRB(0, 0, 1, 1), + hasUnsavedEdits = hasUnsavedEdits ?? false; + + EditorState copyWith({ + bool? isApplyingEdits, + int? rotationAngle, + bool? flipHorizontal, + bool? flipVertical, + double? aspectRatio = double.infinity, + int? originalWidth, + int? originalHeight, + Duration? animationDuration, + Rect? crop, + bool? hasUnsavedEdits, + }) { + return EditorState( + isApplyingEdits: isApplyingEdits ?? this.isApplyingEdits, + rotationAngle: rotationAngle ?? this.rotationAngle, + flipHorizontal: flipHorizontal ?? this.flipHorizontal, + flipVertical: flipVertical ?? this.flipVertical, + aspectRatio: aspectRatio == double.infinity ? this.aspectRatio : aspectRatio, + animationDuration: animationDuration ?? this.animationDuration, + originalWidth: originalWidth ?? this.originalWidth, + originalHeight: originalHeight ?? this.originalHeight, + crop: crop ?? this.crop, + hasUnsavedEdits: hasUnsavedEdits ?? this.hasUnsavedEdits, + ); + } + + bool get hasEdits { + return rotationAngle != 0 || flipHorizontal || flipVertical || crop != const Rect.fromLTRB(0, 0, 1, 1); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is EditorState && + other.isApplyingEdits == isApplyingEdits && + other.rotationAngle == rotationAngle && + other.flipHorizontal == flipHorizontal && + other.flipVertical == flipVertical && + other.crop == crop && + other.aspectRatio == aspectRatio && + other.originalWidth == originalWidth && + other.originalHeight == originalHeight && + other.animationDuration == animationDuration && + other.hasUnsavedEdits == hasUnsavedEdits; + } + + @override + int get hashCode { + return isApplyingEdits.hashCode ^ + rotationAngle.hashCode ^ + flipHorizontal.hashCode ^ + flipVertical.hashCode ^ + crop.hashCode ^ + aspectRatio.hashCode ^ + originalWidth.hashCode ^ + originalHeight.hashCode ^ + animationDuration.hashCode ^ + hasUnsavedEdits.hashCode; + } +} diff --git a/mobile/lib/presentation/pages/editing/drift_crop.page.dart b/mobile/lib/presentation/pages/editing/drift_crop.page.dart deleted file mode 100644 index a213e4c640..0000000000 --- a/mobile/lib/presentation/pages/editing/drift_crop.page.dart +++ /dev/null @@ -1,179 +0,0 @@ -import 'dart:async'; - -import 'package:auto_route/auto_route.dart'; -import 'package:crop_image/crop_image.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart'; -import 'package:immich_ui/immich_ui.dart'; - -/// A widget for cropping an image. -/// This widget uses [HookWidget] to manage its lifecycle and state. It allows -/// users to crop an image and then navigate to the [EditImagePage] with the -/// cropped image. - -@RoutePage() -class DriftCropImagePage extends HookWidget { - final Image image; - final BaseAsset asset; - const DriftCropImagePage({super.key, required this.image, required this.asset}); - - @override - Widget build(BuildContext context) { - final cropController = useCropController(); - final aspectRatio = useState(null); - - return Scaffold( - appBar: AppBar( - backgroundColor: context.scaffoldBackgroundColor, - title: Text("crop".tr()), - leading: const ImmichCloseButton(), - actions: [ - ImmichIconButton( - icon: Icons.done_rounded, - color: ImmichColor.primary, - variant: ImmichVariant.ghost, - onPressed: () async { - final croppedImage = await cropController.croppedImage(); - unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true))); - }, - ), - ], - ), - backgroundColor: context.scaffoldBackgroundColor, - body: SafeArea( - child: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - return Column( - children: [ - Container( - padding: const EdgeInsets.only(top: 20), - width: constraints.maxWidth * 0.9, - height: constraints.maxHeight * 0.6, - child: CropImage(controller: cropController, image: image, gridColor: Colors.white), - ), - Expanded( - child: Container( - width: double.infinity, - decoration: BoxDecoration( - color: context.scaffoldBackgroundColor, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(20), - ), - ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.only(left: 20, right: 20, bottom: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - ImmichIconButton( - icon: Icons.rotate_left, - variant: ImmichVariant.ghost, - color: ImmichColor.secondary, - onPressed: () => cropController.rotateLeft(), - ), - ImmichIconButton( - icon: Icons.rotate_right, - variant: ImmichVariant.ghost, - color: ImmichColor.secondary, - onPressed: () => cropController.rotateRight(), - ), - ], - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: null, - label: 'Free', - ), - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: 1.0, - label: '1:1', - ), - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: 16.0 / 9.0, - label: '16:9', - ), - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: 3.0 / 2.0, - label: '3:2', - ), - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: 7.0 / 5.0, - label: '7:5', - ), - ], - ), - ], - ), - ), - ), - ), - ], - ); - }, - ), - ), - ); - } -} - -class _AspectRatioButton extends StatelessWidget { - final CropController cropController; - final ValueNotifier aspectRatio; - final double? ratio; - final String label; - - const _AspectRatioButton({ - required this.cropController, - required this.aspectRatio, - required this.ratio, - required this.label, - }); - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Icon(switch (label) { - 'Free' => Icons.crop_free_rounded, - '1:1' => Icons.crop_square_rounded, - '16:9' => Icons.crop_16_9_rounded, - '3:2' => Icons.crop_3_2_rounded, - '7:5' => Icons.crop_7_5_rounded, - _ => Icons.crop_free_rounded, - }, color: aspectRatio.value == ratio ? context.primaryColor : context.themeData.iconTheme.color), - onPressed: () { - cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9); - aspectRatio.value = ratio; - cropController.aspectRatio = ratio; - }, - ), - Text(label, style: context.textTheme.displayMedium), - ], - ); - } -} diff --git a/mobile/lib/presentation/pages/editing/drift_edit.page.dart b/mobile/lib/presentation/pages/editing/drift_edit.page.dart deleted file mode 100644 index 6d4ea4d3a6..0000000000 --- a/mobile/lib/presentation/pages/editing/drift_edit.page.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'dart:async'; - -import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/providers/background_sync.provider.dart'; -import 'package:immich_mobile/repositories/file_media.repository.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/foreground_upload.service.dart'; -import 'package:immich_mobile/utils/image_converter.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:logging/logging.dart'; -import 'package:path/path.dart' as p; - -/// A stateless widget that provides functionality for editing an image. -/// -/// This widget allows users to edit an image provided either as an [Asset] or -/// directly as an [Image]. It ensures that exactly one of these is provided. -/// -/// It also includes a conversion method to convert an [Image] to a [Uint8List] to save the image on the user's phone -/// They automatically navigate to the [HomePage] with the edited image saved and they eventually get backed up to the server. -@immutable -@RoutePage() -class DriftEditImagePage extends ConsumerWidget { - final BaseAsset asset; - final Image image; - final bool isEdited; - - const DriftEditImagePage({super.key, required this.asset, required this.image, required this.isEdited}); - - void _exitEditing(BuildContext context) { - // this assumes that the only way to get to this page is from the AssetViewerRoute - context.navigator.popUntil((route) => route.data?.name == AssetViewerRoute.name); - } - - Future _saveEditedImage(BuildContext context, BaseAsset asset, Image image, WidgetRef ref) async { - try { - final Uint8List imageData = await imageToUint8List(image); - LocalAsset? localAsset; - - try { - localAsset = await ref - .read(fileMediaRepositoryProvider) - .saveLocalAsset(imageData, title: "${p.withoutExtension(asset.name)}_edited.jpg"); - } on PlatformException catch (e) { - // OS might not return the saved image back, so we handle that gracefully - // This can happen if app does not have full library access - Logger("SaveEditedImage").warning("Failed to retrieve the saved image back from OS", e); - } - - unawaited(ref.read(backgroundSyncProvider).syncLocal(full: true)); - _exitEditing(context); - ImmichToast.show(durationInSecond: 3, context: context, msg: 'Image Saved!'); - - if (localAsset == null) { - return; - } - - await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset]); - } catch (e) { - ImmichToast.show( - durationInSecond: 6, - context: context, - msg: "error_saving_image".tr(namedArgs: {'error': e.toString()}), - ); - } - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Scaffold( - appBar: AppBar( - title: Text("edit".tr()), - backgroundColor: context.scaffoldBackgroundColor, - leading: IconButton( - icon: Icon(Icons.close_rounded, color: context.primaryColor, size: 24), - onPressed: () => _exitEditing(context), - ), - actions: [ - TextButton( - onPressed: isEdited ? () => _saveEditedImage(context, asset, image, ref) : null, - child: Text("save_to_gallery".tr(), style: TextStyle(color: isEdited ? context.primaryColor : Colors.grey)), - ), - ], - ), - backgroundColor: context.scaffoldBackgroundColor, - body: Center( - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: context.height * 0.7, maxWidth: context.width * 0.9), - child: Container( - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(7)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.2), - spreadRadius: 2, - blurRadius: 10, - offset: const Offset(0, 3), - ), - ], - ), - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(7)), - child: Image(image: image.image, fit: BoxFit.contain), - ), - ), - ), - ), - bottomNavigationBar: Container( - height: 70, - margin: const EdgeInsets.only(bottom: 60, right: 10, left: 10, top: 10), - decoration: BoxDecoration( - color: context.scaffoldBackgroundColor, - borderRadius: const BorderRadius.all(Radius.circular(30)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - icon: Icon(Icons.crop_rotate_rounded, color: context.themeData.iconTheme.color, size: 25), - onPressed: () { - context.pushRoute(DriftCropImageRoute(asset: asset, image: image)); - }, - ), - Text("crop".tr(), style: context.textTheme.displayMedium), - ], - ), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - icon: Icon(Icons.filter, color: context.themeData.iconTheme.color, size: 25), - onPressed: () { - context.pushRoute(DriftFilterImageRoute(asset: asset, image: image)); - }, - ), - Text("filter".tr(), style: context.textTheme.displayMedium), - ], - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/presentation/pages/editing/drift_filter.page.dart b/mobile/lib/presentation/pages/editing/drift_filter.page.dart deleted file mode 100644 index 8198a41bbe..0000000000 --- a/mobile/lib/presentation/pages/editing/drift_filter.page.dart +++ /dev/null @@ -1,159 +0,0 @@ -import 'dart:async'; -import 'dart:ui' as ui; - -import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:immich_mobile/constants/filters.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/routing/router.dart'; - -/// A widget for filtering an image. -/// This widget uses [HookWidget] to manage its lifecycle and state. It allows -/// users to add filters to an image and then navigate to the [EditImagePage] with the -/// final composition.' -@RoutePage() -class DriftFilterImagePage extends HookWidget { - final Image image; - final BaseAsset asset; - - const DriftFilterImagePage({super.key, required this.image, required this.asset}); - - @override - Widget build(BuildContext context) { - final colorFilter = useState(filters[0]); - final selectedFilterIndex = useState(0); - - Future createFilteredImage(ui.Image inputImage, ColorFilter filter) { - final completer = Completer(); - final size = Size(inputImage.width.toDouble(), inputImage.height.toDouble()); - final recorder = ui.PictureRecorder(); - final canvas = Canvas(recorder); - - final paint = Paint()..colorFilter = filter; - canvas.drawImage(inputImage, Offset.zero, paint); - - recorder.endRecording().toImage(size.width.round(), size.height.round()).then((image) { - completer.complete(image); - }); - - return completer.future; - } - - void applyFilter(ColorFilter filter, int index) { - colorFilter.value = filter; - selectedFilterIndex.value = index; - } - - Future applyFilterAndConvert(ColorFilter filter) async { - final completer = Completer(); - image.image - .resolve(ImageConfiguration.empty) - .addListener( - ImageStreamListener((ImageInfo info, bool _) { - completer.complete(info.image); - }), - ); - final uiImage = await completer.future; - - final filteredUiImage = await createFilteredImage(uiImage, filter); - final byteData = await filteredUiImage.toByteData(format: ui.ImageByteFormat.png); - final pngBytes = byteData!.buffer.asUint8List(); - - return Image.memory(pngBytes, fit: BoxFit.contain); - } - - return Scaffold( - appBar: AppBar( - backgroundColor: context.scaffoldBackgroundColor, - title: Text("filter".tr()), - leading: CloseButton(color: context.primaryColor), - actions: [ - IconButton( - icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24), - onPressed: () async { - final filteredImage = await applyFilterAndConvert(colorFilter.value); - unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: filteredImage, isEdited: true))); - }, - ), - ], - ), - backgroundColor: context.scaffoldBackgroundColor, - body: Column( - children: [ - SizedBox( - height: context.height * 0.7, - child: Center( - child: ColorFiltered(colorFilter: colorFilter.value, child: image), - ), - ), - SizedBox( - height: 120, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: filters.length, - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: _FilterButton( - image: image, - label: filterNames[index], - filter: filters[index], - isSelected: selectedFilterIndex.value == index, - onTap: () => applyFilter(filters[index], index), - ), - ); - }, - ), - ), - ], - ), - ); - } -} - -class _FilterButton extends StatelessWidget { - final Image image; - final String label; - final ColorFilter filter; - final bool isSelected; - final VoidCallback onTap; - - const _FilterButton({ - required this.image, - required this.label, - required this.filter, - required this.isSelected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - GestureDetector( - onTap: onTap, - child: Container( - width: 80, - height: 80, - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(10)), - border: isSelected ? Border.all(color: context.primaryColor, width: 3) : null, - ), - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(10)), - child: ColorFiltered( - colorFilter: filter, - child: FittedBox(fit: BoxFit.cover, child: image), - ), - ), - ), - ), - const SizedBox(height: 10), - Text(label, style: context.themeData.textTheme.bodyMedium), - ], - ); - } -} diff --git a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart index cad74ce658..564b02d884 100644 --- a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart @@ -1,10 +1,17 @@ +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 { @@ -14,13 +21,33 @@ class EditImageActionButton extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); - onPress() { - if (currentAsset == null) { + Future editImage(List edits) async { + if (currentAsset == null || currentAsset.remoteId == null) { return; } - final image = Image(image: getFullImageProvider(currentAsset)); - context.pushRoute(DriftEditImageRoute(asset: currentAsset, image: image, isEdited: false)); + await ref.read(actionProvider.notifier).applyEdits(ActionSource.viewer, edits); + } + + Future 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( diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index b51960bb05..cf7ffbd234 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -3,16 +3,18 @@ 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/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_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/action_buttons/add_action_button.widget.dart'; 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/routes.provider.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/utils/semver.dart'; import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart'; class ViewerBottomBar extends ConsumerWidget { @@ -30,6 +32,7 @@ class ViewerBottomBar extends ConsumerWidget { final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final isInLockedView = ref.watch(inLockedViewProvider); + final serverInfo = ref.watch(serverInfoProvider); final originalTheme = context.themeData; @@ -38,7 +41,9 @@ class ViewerBottomBar extends ConsumerWidget { if (!isInLockedView) ...[ if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), - if (asset.type == AssetType.image) const EditImageActionButton(), + // edit sync was added in 2.6.0 + if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) + const EditImageActionButton(), if (asset.hasRemote) AddActionButton(originalTheme: originalTheme), if (isOwner) ...[ diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index 47ebd37014..ea416d9d71 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -147,7 +147,7 @@ mixin CancellableImageProviderMixin on CancellableImageProvide } } -ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920)}) { +ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920), bool edited = true}) { // Create new provider and cache it final ImageProvider provider; if (_shouldUseLocalAsset(asset)) { @@ -170,13 +170,14 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080 thumbhash: thumbhash, assetType: asset.type, isAnimated: asset.isAnimatedImage, + edited: edited, ); } return provider; } -ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution}) { +ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) { if (_shouldUseLocalAsset(asset)) { final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; return LocalThumbProvider(id: id, size: size, assetType: asset.type); @@ -184,7 +185,7 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId; final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : ""; - return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash) : null; + return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash, edited: edited) : null; } bool _shouldUseLocalAsset(BaseAsset asset) => diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index d9cc053ccf..f7fc5868c3 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -13,11 +13,12 @@ import 'package:openapi/api.dart'; class RemoteImageProvider extends CancellableImageProvider with CancellableImageProviderMixin { final String url; + final bool edited; - RemoteImageProvider({required this.url}); + RemoteImageProvider({required this.url, this.edited = true}); - RemoteImageProvider.thumbnail({required String assetId, required String thumbhash}) - : url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash); + RemoteImageProvider.thumbnail({required String assetId, required String thumbhash, this.edited = true}) + : url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash, edited: edited); @override Future obtainKey(ImageConfiguration configuration) { @@ -45,13 +46,13 @@ class RemoteImageProvider extends CancellableImageProvider bool operator ==(Object other) { if (identical(this, other)) return true; if (other is RemoteImageProvider) { - return url == other.url; + return url == other.url && edited == other.edited; } return false; } @override - int get hashCode => url.hashCode; + int get hashCode => url.hashCode ^ edited.hashCode; } class RemoteFullImageProvider extends CancellableImageProvider @@ -60,12 +61,14 @@ class RemoteFullImageProvider extends CancellableImageProvider [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Asset Id', key.assetId), @@ -109,7 +114,12 @@ class RemoteFullImageProvider extends CancellableImageProvider assetId.hashCode ^ thumbhash.hashCode ^ isAnimated.hashCode; + int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ isAnimated.hashCode ^ edited.hashCode; } diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index bad0d986d0..d24e2cc6cd 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -5,21 +5,24 @@ import 'package:background_downloader/background_downloader.dart'; import 'package:flutter/material.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/asset.service.dart'; import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider; 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/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/services/action.service.dart'; import 'package:immich_mobile/services/download.service.dart'; -import 'package:immich_mobile/services/timeline.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; +import 'package:immich_mobile/services/timeline.service.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; import 'package:logging/logging.dart'; +import 'package:openapi/api.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; final actionProvider = NotifierProvider( @@ -490,6 +493,29 @@ class ActionNotifier extends Notifier { }); } } + + Future applyEdits(ActionSource source, List 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'); + } + + final completer = 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 completer; + 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 { diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index 6643404786..8059e54605 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -197,6 +197,27 @@ class WebsocketNotifier extends StateNotifier { state.socket?.on('on_upload_success', _handleOnUploadSuccess); } + Future waitForEvent(String event, bool Function(dynamic)? predicate, Duration timeout) { + final completer = Completer(); + + void handler(dynamic data) { + if (predicate == null || predicate(data)) { + completer.complete(); + state.socket?.off(event, handler); + } + } + + state.socket?.on(event, handler); + + return completer.future.timeout( + timeout, + onTimeout: () { + state.socket?.off(event, handler); + completer.completeError(TimeoutException("Timeout waiting for event: $event")); + }, + ); + } + void addPendingChange(PendingAction action, dynamic value) { final now = DateTime.now(); state = state.copyWith( diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 011b1edc94..d66b39ecde 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart' hide AssetEditAction; import 'package:immich_mobile/domain/models/stack.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; @@ -105,6 +106,14 @@ class AssetApiRepository extends ApiRepository { Future updateRating(String assetId, int rating) { return _api.updateAsset(assetId, UpdateAssetDto(rating: rating)); } + + Future editAsset(String assetId, List edits) { + return _api.editAsset(assetId, AssetEditsCreateDto(edits: edits.map((e) => e.toApi()).toList())); + } + + Future removeEdits(String assetId) async { + return _api.removeAssetEdits(assetId); + } } extension on StackResponseDto { @@ -112,3 +121,22 @@ extension on StackResponseDto { return StackResponse(id: id, primaryAssetId: primaryAssetId, assetIds: assets.map((asset) => asset.id).toList()); } } + +extension on AssetEdit { + AssetEditActionItemDto toApi() { + return switch (this) { + CropEdit(:final parameters) => AssetEditActionItemDto( + action: AssetEditAction.crop, + parameters: parameters.toJson(), + ), + RotateEdit(:final parameters) => AssetEditActionItemDto( + action: AssetEditAction.rotate, + parameters: parameters.toJson(), + ), + MirrorEdit(:final parameters) => AssetEditActionItemDto( + action: AssetEditAction.mirror, + parameters: parameters.toJson(), + ), + }; + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index b385bcbf71..90a17b1617 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; @@ -105,11 +106,9 @@ import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; -import 'package:immich_mobile/presentation/pages/editing/drift_crop.page.dart'; -import 'package:immich_mobile/presentation/pages/profile/profile_picture_crop.page.dart'; -import 'package:immich_mobile/presentation/pages/editing/drift_edit.page.dart'; -import 'package:immich_mobile/presentation/pages/editing/drift_filter.page.dart'; +import 'package:immich_mobile/presentation/pages/edit/drift_edit.page.dart'; import 'package:immich_mobile/presentation/pages/local_timeline.page.dart'; +import 'package:immich_mobile/presentation/pages/profile/profile_picture_crop.page.dart'; import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; @@ -333,8 +332,6 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DriftAlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftMapRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftEditImageRoute.page), - AutoRoute(page: DriftCropImageRoute.page), - AutoRoute(page: DriftFilterImageRoute.page), AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 2d57c16573..18b16c23ef 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1003,70 +1003,20 @@ class DriftCreateAlbumRoute extends PageRouteInfo { ); } -/// generated route for -/// [DriftCropImagePage] -class DriftCropImageRoute extends PageRouteInfo { - DriftCropImageRoute({ - Key? key, - required Image image, - required BaseAsset asset, - List? children, - }) : super( - DriftCropImageRoute.name, - args: DriftCropImageRouteArgs(key: key, image: image, asset: asset), - initialChildren: children, - ); - - static const String name = 'DriftCropImageRoute'; - - static PageInfo page = PageInfo( - name, - builder: (data) { - final args = data.argsAs(); - return DriftCropImagePage( - key: args.key, - image: args.image, - asset: args.asset, - ); - }, - ); -} - -class DriftCropImageRouteArgs { - const DriftCropImageRouteArgs({ - this.key, - required this.image, - required this.asset, - }); - - final Key? key; - - final Image image; - - final BaseAsset asset; - - @override - String toString() { - return 'DriftCropImageRouteArgs{key: $key, image: $image, asset: $asset}'; - } -} - /// generated route for /// [DriftEditImagePage] class DriftEditImageRoute extends PageRouteInfo { DriftEditImageRoute({ Key? key, - required BaseAsset asset, required Image image, - required bool isEdited, + required Future Function(List) applyEdits, List? children, }) : super( DriftEditImageRoute.name, args: DriftEditImageRouteArgs( key: key, - asset: asset, image: image, - isEdited: isEdited, + applyEdits: applyEdits, ), initialChildren: children, ); @@ -1079,9 +1029,8 @@ class DriftEditImageRoute extends PageRouteInfo { final args = data.argsAs(); return DriftEditImagePage( key: args.key, - asset: args.asset, image: args.image, - isEdited: args.isEdited, + applyEdits: args.applyEdits, ); }, ); @@ -1090,22 +1039,19 @@ class DriftEditImageRoute extends PageRouteInfo { class DriftEditImageRouteArgs { const DriftEditImageRouteArgs({ this.key, - required this.asset, required this.image, - required this.isEdited, + required this.applyEdits, }); final Key? key; - final BaseAsset asset; - final Image image; - final bool isEdited; + final Future Function(List) applyEdits; @override String toString() { - return 'DriftEditImageRouteArgs{key: $key, asset: $asset, image: $image, isEdited: $isEdited}'; + return 'DriftEditImageRouteArgs{key: $key, image: $image, applyEdits: $applyEdits}'; } } @@ -1125,54 +1071,6 @@ class DriftFavoriteRoute extends PageRouteInfo { ); } -/// generated route for -/// [DriftFilterImagePage] -class DriftFilterImageRoute extends PageRouteInfo { - DriftFilterImageRoute({ - Key? key, - required Image image, - required BaseAsset asset, - List? children, - }) : super( - DriftFilterImageRoute.name, - args: DriftFilterImageRouteArgs(key: key, image: image, asset: asset), - initialChildren: children, - ); - - static const String name = 'DriftFilterImageRoute'; - - static PageInfo page = PageInfo( - name, - builder: (data) { - final args = data.argsAs(); - return DriftFilterImagePage( - key: args.key, - image: args.image, - asset: args.asset, - ); - }, - ); -} - -class DriftFilterImageRouteArgs { - const DriftFilterImageRouteArgs({ - this.key, - required this.image, - required this.asset, - }); - - final Key? key; - - final Image image; - - final BaseAsset asset; - - @override - String toString() { - return 'DriftFilterImageRouteArgs{key: $key, image: $image, asset: $asset}'; - } -} - /// generated route for /// [DriftLibraryPage] class DriftLibraryRoute extends PageRouteInfo { diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index c435bf9d79..1a6333215a 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; @@ -246,6 +247,14 @@ class ActionService { return true; } + Future applyEdits(String remoteId, List edits) async { + if (edits.isEmpty) { + await _assetApiRepository.removeEdits(remoteId); + } else { + await _assetApiRepository.editAsset(remoteId, edits); + } + } + Future _deleteLocalAssets(List localIds) async { final deletedIds = await _assetMediaRepository.deleteAll(localIds); if (deletedIds.isEmpty) { diff --git a/mobile/lib/utils/editor.utils.dart b/mobile/lib/utils/editor.utils.dart new file mode 100644 index 0000000000..fa2dedf383 --- /dev/null +++ b/mobile/lib/utils/editor.utils.dart @@ -0,0 +1,65 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; +import 'package:immich_mobile/utils/matrix.utils.dart'; +import 'package:openapi/api.dart' hide AssetEditAction; + +Rect convertCropParametersToRect(CropParameters parameters, int originalWidth, int originalHeight) { + return Rect.fromLTWH( + parameters.x.toDouble() / originalWidth, + parameters.y.toDouble() / originalHeight, + parameters.width.toDouble() / originalWidth, + parameters.height.toDouble() / originalHeight, + ); +} + +CropParameters convertRectToCropParameters(Rect rect, int originalWidth, int originalHeight) { + final x = (rect.left * originalWidth).truncate(); + final y = (rect.top * originalHeight).truncate(); + final width = (rect.width * originalWidth).truncate(); + final height = (rect.height * originalHeight).truncate(); + + return CropParameters( + x: max(x, 0).clamp(0, originalWidth), + y: max(y, 0).clamp(0, originalHeight), + width: max(width, 0).clamp(0, originalWidth - x), + height: max(height, 0).clamp(0, originalHeight - y), + ); +} + +AffineMatrix buildAffineFromEdits(List edits) { + return AffineMatrix.compose( + edits.map((edit) { + return switch (edit) { + RotateEdit(:final parameters) => AffineMatrix.rotate(parameters.angle * pi / 180), + MirrorEdit(:final parameters) => + parameters.axis == MirrorAxis.horizontal ? AffineMatrix.flipY() : AffineMatrix.flipX(), + CropEdit() => AffineMatrix.identity(), + }; + }).toList(), + ); +} + +bool isCloseToZero(double value, [double epsilon = 1e-15]) { + return value.abs() < epsilon; +} + +typedef NormalizedTransform = ({double rotation, bool mirrorHorizontal, bool mirrorVertical}); + +NormalizedTransform normalizeTransformEdits(List edits) { + final matrix = buildAffineFromEdits(edits); + + double a = matrix.a; + double b = matrix.b; + double c = matrix.c; + double d = matrix.d; + + final rotation = ((isCloseToZero(a) ? asin(c) : acos(a)) * 180) / pi; + + return ( + rotation: rotation < 0 ? 360 + rotation : rotation, + mirrorHorizontal: false, + mirrorVertical: isCloseToZero(a) ? b == c : a == -d, + ); +} diff --git a/mobile/lib/utils/matrix.utils.dart b/mobile/lib/utils/matrix.utils.dart new file mode 100644 index 0000000000..8363a8b93d --- /dev/null +++ b/mobile/lib/utils/matrix.utils.dart @@ -0,0 +1,50 @@ +import 'dart:math'; + +class AffineMatrix { + final double a; + final double b; + final double c; + final double d; + final double e; + final double f; + + const AffineMatrix(this.a, this.b, this.c, this.d, this.e, this.f); + + @override + String toString() { + return 'AffineMatrix(a: $a, b: $b, c: $c, d: $d, e: $e, f: $f)'; + } + + factory AffineMatrix.identity() { + return const AffineMatrix(1, 0, 0, 1, 0, 0); + } + + AffineMatrix multiply(AffineMatrix other) { + return AffineMatrix( + a * other.a + c * other.b, + b * other.a + d * other.b, + a * other.c + c * other.d, + b * other.c + d * other.d, + a * other.e + c * other.f + e, + b * other.e + d * other.f + f, + ); + } + + factory AffineMatrix.compose([List transformations = const []]) { + return transformations.fold(AffineMatrix.identity(), (acc, matrix) => acc.multiply(matrix)); + } + + factory AffineMatrix.rotate(double angle) { + final cosAngle = cos(angle); + final sinAngle = sin(angle); + return AffineMatrix(cosAngle, -sinAngle, sinAngle, cosAngle, 0, 0); + } + + factory AffineMatrix.flipY() { + return const AffineMatrix(-1, 0, 0, 1, 0, 0); + } + + factory AffineMatrix.flipX() { + return const AffineMatrix(1, 0, 0, -1, 0, 0); + } +} diff --git a/mobile/openapi/lib/model/asset_edit_action_item_dto.dart b/mobile/openapi/lib/model/asset_edit_action_item_dto.dart index 2c7bb82c24..1b19612bf3 100644 --- a/mobile/openapi/lib/model/asset_edit_action_item_dto.dart +++ b/mobile/openapi/lib/model/asset_edit_action_item_dto.dart @@ -19,7 +19,7 @@ class AssetEditActionItemDto { AssetEditAction action; - AssetEditActionItemDtoParameters parameters; + Map parameters; @override bool operator ==(Object other) => identical(this, other) || other is AssetEditActionItemDto && @@ -52,7 +52,7 @@ class AssetEditActionItemDto { return AssetEditActionItemDto( action: AssetEditAction.fromJson(json[r'action'])!, - parameters: AssetEditActionItemDtoParameters.fromJson(json[r'parameters'])!, + parameters: json[r'parameters'], ); } return null; diff --git a/mobile/test/utils/editor_test.dart b/mobile/test/utils/editor_test.dart new file mode 100644 index 0000000000..16f1c08d05 --- /dev/null +++ b/mobile/test/utils/editor_test.dart @@ -0,0 +1,322 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; +import 'package:immich_mobile/utils/editor.utils.dart'; +import 'package:openapi/api.dart' show MirrorAxis, MirrorParameters, RotateParameters; + +List normalizedToEdits(NormalizedTransform transform) { + List edits = []; + + if (transform.mirrorHorizontal) { + edits.add(MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal))); + } + + if (transform.mirrorVertical) { + edits.add(MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical))); + } + + if (transform.rotation != 0) { + edits.add(RotateEdit(RotateParameters(angle: transform.rotation))); + } + + return edits; +} + +bool compareEditAffines(List editsA, List editsB) { + final normA = buildAffineFromEdits(editsA); + final normB = buildAffineFromEdits(editsB); + + return ((normA.a - normB.a).abs() < 0.0001 && + (normA.b - normB.b).abs() < 0.0001 && + (normA.c - normB.c).abs() < 0.0001 && + (normA.d - normB.d).abs() < 0.0001); +} + +void main() { + group('normalizeEdits', () { + test('should handle no edits', () { + final edits = []; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle a single 90° rotation', () { + final edits = [ + RotateEdit(RotateParameters(angle: 90)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle a single 180° rotation', () { + final edits = [ + RotateEdit(RotateParameters(angle: 180)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle a single 270° rotation', () { + final edits = [ + RotateEdit(RotateParameters(angle: 270)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle a single horizontal mirror', () { + final edits = [ + MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle a single vertical mirror', () { + final edits = [ + MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle 90° rotation + horizontal mirror', () { + final edits = [ + RotateEdit(RotateParameters(angle: 90)), + MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle 90° rotation + vertical mirror', () { + final edits = [ + RotateEdit(RotateParameters(angle: 90)), + MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle 90° rotation + both mirrors', () { + final edits = [ + RotateEdit(RotateParameters(angle: 90)), + MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)), + MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle 180° rotation + horizontal mirror', () { + final edits = [ + RotateEdit(RotateParameters(angle: 180)), + MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle 180° rotation + vertical mirror', () { + final edits = [ + RotateEdit(RotateParameters(angle: 180)), + MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle 180° rotation + both mirrors', () { + final edits = [ + RotateEdit(RotateParameters(angle: 180)), + MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)), + MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle 270° rotation + horizontal mirror', () { + final edits = [ + RotateEdit(RotateParameters(angle: 270)), + MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle 270° rotation + vertical mirror', () { + final edits = [ + RotateEdit(RotateParameters(angle: 270)), + MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle 270° rotation + both mirrors', () { + final edits = [ + RotateEdit(RotateParameters(angle: 270)), + MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)), + MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle horizontal mirror + 90° rotation', () { + final edits = [ + MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)), + RotateEdit(RotateParameters(angle: 90)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle horizontal mirror + 180° rotation', () { + final edits = [ + MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)), + RotateEdit(RotateParameters(angle: 180)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle horizontal mirror + 270° rotation', () { + final edits = [ + MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)), + RotateEdit(RotateParameters(angle: 270)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle vertical mirror + 90° rotation', () { + final edits = [ + MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)), + RotateEdit(RotateParameters(angle: 90)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle vertical mirror + 180° rotation', () { + final edits = [ + MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)), + RotateEdit(RotateParameters(angle: 180)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle vertical mirror + 270° rotation', () { + final edits = [ + MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)), + RotateEdit(RotateParameters(angle: 270)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle both mirrors + 90° rotation', () { + final edits = [ + MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)), + MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)), + RotateEdit(RotateParameters(angle: 90)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle both mirrors + 180° rotation', () { + final edits = [ + MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)), + MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)), + RotateEdit(RotateParameters(angle: 180)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle both mirrors + 270° rotation', () { + final edits = [ + MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)), + MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)), + RotateEdit(RotateParameters(angle: 270)), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + }); +} diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index 83a6df31a8..99adf9f4a8 100755 --- a/open-api/bin/generate-open-api.sh +++ b/open-api/bin/generate-open-api.sh @@ -23,6 +23,7 @@ function dart { patch --no-backup-if-mismatch -u ../mobile/openapi/lib/api_client.dart <./patch/api_client.dart.patch patch --no-backup-if-mismatch -u ../mobile/openapi/lib/api.dart <./patch/api.dart.patch patch --no-backup-if-mismatch -u ../mobile/openapi/pubspec.yaml <./patch/pubspec_immich_mobile.yaml.patch + patch --no-backup-if-mismatch -u ../mobile/openapi/lib/model/asset_edit_action_item_dto.dart <./patch/asset_edit_action_item_dto.dart.patch # Don't include analysis_options.yaml for the generated openapi files # so that language servers can properly exclude the mobile/openapi directory rm ../mobile/openapi/analysis_options.yaml diff --git a/open-api/patch/asset_edit_action_item_dto.dart.patch b/open-api/patch/asset_edit_action_item_dto.dart.patch new file mode 100644 index 0000000000..b825795bf4 --- /dev/null +++ b/open-api/patch/asset_edit_action_item_dto.dart.patch @@ -0,0 +1,18 @@ +@@ -20,7 +20,7 @@ class AssetEditActionItemDto { + /// Type of edit action to perform + AssetEditAction action; + +- AssetEditActionItemDtoParameters parameters; ++ Map parameters; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetEditActionItemDto && +@@ -53,7 +53,7 @@ class AssetEditActionItemDto { + + return AssetEditActionItemDto( + action: AssetEditAction.fromJson(json[r'action'])!, +- parameters: AssetEditActionItemDtoParameters.fromJson(json[r'parameters'])!, ++ parameters: json[r'parameters'], + ); + } + return null; \ No newline at end of file