diff --git a/i18n/en.json b/i18n/en.json index 765e03d985..9a624cf23f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -561,6 +561,8 @@ "asset_adding_to_album": "Adding to album…", "asset_created": "Asset created", "asset_description_updated": "Asset description has been updated", + "asset_edit_failed": "Asset edit failed", + "asset_edit_success": "Asset edited successfully", "asset_filename_is_offline": "Asset {filename} is offline", "asset_has_unassigned_faces": "Asset has unassigned faces", "asset_hashing": "Hashing…", diff --git a/mobile/lib/domain/models/asset_edit.model.dart b/mobile/lib/domain/models/asset_edit.model.dart index e97558dfdb..b3266dba46 100644 --- a/mobile/lib/domain/models/asset_edit.model.dart +++ b/mobile/lib/domain/models/asset_edit.model.dart @@ -1 +1,21 @@ +import "package:openapi/api.dart" as api show AssetEditAction; + 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, + }; + } +} + +class AssetEdit { + final AssetEditAction action; + final Map parameters; + + const AssetEdit({required this.action, required 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/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index b9d02f0367..924634ba15 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -1,10 +1,10 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; -import 'package:openapi/api.dart'; typedef _AssetVideoDimension = ({double? width, double? height, bool isFlipped}); @@ -118,11 +118,11 @@ class AssetService { return _localAssetRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection); } - Future getAssetEdits(String assetId) { + Future> getAssetEdits(String assetId) { return _remoteAssetRepository.getAssetEdits(assetId); } - Future editAsset(String assetId, AssetEditActionListDto edits) { + Future editAsset(String assetId, List edits) { return _remoteAssetRepository.editAsset(assetId, edits); } } diff --git a/mobile/lib/infrastructure/entities/asset_edit.entity.dart b/mobile/lib/infrastructure/entities/asset_edit.entity.dart index d36119441e..5a5692dd5e 100644 --- a/mobile/lib/infrastructure/entities/asset_edit.entity.dart +++ b/mobile/lib/infrastructure/entities/asset_edit.entity.dart @@ -1,5 +1,6 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.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'; @@ -23,3 +24,9 @@ class AssetEditEntity extends Table with DriftDefaultsMixin { final JsonTypeConverter2, Uint8List, Object?> editParameterConverter = TypeConverter.jsonb( fromJson: (json) => json as Map, ); + +extension AssetEditEntityDataDomainEx on AssetEditEntityData { + AssetEdit toDto() { + return AssetEdit(action: action, parameters: parameters); + } +} 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 309bd273ec..cb09590575 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -1,7 +1,10 @@ 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/asset_edit.entity.drift.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'; @@ -9,13 +12,12 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift. import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:openapi/api.dart' hide AssetVisibility; +import 'package:uuid/uuid.dart'; class RemoteAssetRepository extends DriftDatabaseRepository { final Drift _db; - final AssetsApi _api; - const RemoteAssetRepository(this._db, this._api) : super(_db); + const RemoteAssetRepository(this._db) : super(_db); /// For testing purposes Future> getSome(String userId) { @@ -267,11 +269,34 @@ class RemoteAssetRepository extends DriftDatabaseRepository { return _db.managers.remoteAssetEntity.count(); } - Future getAssetEdits(String assetId) async { - return _api.getAssetEdits(assetId); + Future> getAssetEdits(String assetId) async { + final query = _db.assetEditEntity.select() + ..where((row) => row.assetId.equals(assetId)) + ..orderBy([(row) => OrderingTerm.asc(row.sequence)]); + + return query.map((row) => row.toDto()).get(); } - Future editAsset(String assetId, AssetEditActionListDto edits) { - return _api.editAsset(assetId, edits); + Future editAsset(String assetId, List edits) async { + await _db.transaction(() async { + await _db.batch((batch) async { + // delete existing edits + batch.deleteWhere(_db.assetEditEntity, (row) => row.assetId.equals(assetId)); + + // insert new edits + for (var i = 0; i < edits.length; i++) { + final edit = edits[i]; + final companion = AssetEditEntityCompanion( + id: Value(const Uuid().v4()), + assetId: Value(assetId), + action: Value(edit.action), + parameters: Value(edit.parameters), + sequence: Value(i), + ); + + batch.insert(_db.assetEditEntity, companion); + } + }); + }); } } 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 index af51eedce9..fbd44212c5 100644 --- a/mobile/lib/presentation/pages/editing/drift_edit.page.dart +++ b/mobile/lib/presentation/pages/editing/drift_edit.page.dart @@ -1,180 +1,331 @@ -import 'dart:async'; -import 'dart:ui'; - import 'package:auto_route/auto_route.dart'; -import 'package:cancellation_token_http/http.dart'; +import 'package:collection/collection.dart'; +import 'package:crop_image/crop_image.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/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/exif.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/providers/infrastructure/asset.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/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/websocket.provider.dart'; +import 'package:immich_mobile/utils/editor.utils.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:logging/logging.dart'; -import 'package:path/path.dart' as p; +import 'package:immich_ui/immich_ui.dart'; +import 'package:openapi/api.dart' show CropParameters, RotateParameters; -/// A stateless widget that provides functionality for editing an image. +/// A stateful 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; +class DriftEditImagePage extends ConsumerStatefulWidget { final Image image; - final bool isEdited; + final BaseAsset asset; + final List edits; + final ExifInfo exifInfo; - const DriftEditImagePage({super.key, required this.asset, required this.image, required this.isEdited}); - Future _imageToUint8List(Image image) async { - final Completer completer = Completer(); - image.image - .resolve(const ImageConfiguration()) - .addListener( - ImageStreamListener((ImageInfo info, bool _) { - info.image.toByteData(format: ImageByteFormat.png).then((byteData) { - if (byteData != null) { - completer.complete(byteData.buffer.asUint8List()); - } else { - completer.completeError('Failed to convert image to bytes'); - } - }); - }, onError: (exception, stackTrace) => completer.completeError(exception)), - ); - return completer.future; + const DriftEditImagePage({ + super.key, + required this.image, + required this.asset, + required this.edits, + required this.exifInfo, + }); + + @override + ConsumerState createState() => _DriftEditImagePageState(); +} + +class _DriftEditImagePageState extends ConsumerState { + late final CropController cropController; + double? aspectRatio; + + late final originalWidth = widget.exifInfo.isFlipped ? widget.exifInfo.height : widget.exifInfo.width; + late final originalHeight = widget.exifInfo.isFlipped ? widget.exifInfo.width : widget.exifInfo.height; + + bool isEditing = false; + + (Rect, CropRotation) getInitialEditorState() { + final existingCrop = widget.edits.firstWhereOrNull((edit) => edit.action == AssetEditAction.crop); + + Rect crop = existingCrop != null + ? convertCropParametersToRect( + CropParameters.fromJson(existingCrop.parameters)!, + originalWidth ?? 0, + originalHeight ?? 0, + ) + : const Rect.fromLTRB(0, 0, 1, 1); + + final existingRotationParameters = RotateParameters.fromJson( + widget.edits.firstWhereOrNull((edit) => edit.action == AssetEditAction.rotate)?.parameters, + ); + + final existingRotationAngle = + CropRotationExtension.fromDegrees(existingRotationParameters?.angle.toInt() ?? 0) ?? CropRotation.up; + + crop = convertCropRectToRotated(crop, existingRotationAngle); + return (crop, existingRotationAngle); } - 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() async { + setState(() { + isEditing = true; + }); - Future _saveEditedImage(BuildContext context, BaseAsset asset, Image image, WidgetRef ref) async { - try { - final Uint8List imageData = await _imageToUint8List(image); - LocalAsset? localAsset; + CropRotation rotation = cropController.rotation; + Rect cropRect = convertCropRectFromRotated(cropController.crop, rotation); + final cropParameters = convertRectToCropParameters(cropRect, originalWidth ?? 0, originalHeight ?? 0); - 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); - } + final edits = []; - unawaited(ref.read(backgroundSyncProvider).syncLocal(full: true)); - _exitEditing(context); - ImmichToast.show(durationInSecond: 3, context: context, msg: 'Image Saved!'); + if (cropParameters.width != originalWidth || cropParameters.height != originalHeight) { + edits.add(AssetEdit(action: AssetEditAction.crop, parameters: cropParameters.toJson())); + } - if (localAsset == null) { - return; - } - - await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], CancellationToken()); - } catch (e) { - ImmichToast.show( - durationInSecond: 6, - context: context, - msg: "error_saving_image".tr(namedArgs: {'error': e.toString()}), + if (rotation != CropRotation.up) { + edits.add( + AssetEdit( + action: AssetEditAction.rotate, + parameters: RotateParameters(angle: rotation.degrees).toJson(), + ), ); } + + try { + final completer = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV1", (dynamic data) { + final eventData = data as Map; + return eventData["asset"]['id'] == widget.asset.remoteId; + }, const Duration(seconds: 10)); + + await ref.read(actionProvider.notifier).applyEdits(ActionSource.viewer, edits); + await completer; + + ImmichToast.show(context: context, msg: 'asset_edit_success'.tr(), toastType: ToastType.success); + + context.pop(); + } catch (e) { + // show error snackbar + if (mounted) { + ImmichToast.show(context: context, msg: 'asset_edit_failed'.tr(), toastType: ToastType.error); + } + return; + } finally { + setState(() { + isEditing = false; + }); + } } @override - Widget build(BuildContext context, WidgetRef ref) { - final edits = ref.read(assetServiceProvider).getAssetEdits(asset.remoteId!); + void initState() { + super.initState(); + final (existingCrop, existingRotationAngle) = getInitialEditorState(); + cropController = CropController(defaultCrop: existingCrop, rotation: existingRotationAngle); + } + + @override + void dispose() { + cropController.dispose(); + super.dispose(); + } + + Widget _buildProgressIndicator() { + return const Padding( + padding: EdgeInsets.all(8.0), + child: SizedBox(width: 28, height: 28, child: CircularProgressIndicator(strokeWidth: 2.5)), + ); + } + + @override + Widget build(BuildContext context) { 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)), - ), + title: Text("edit".tr()), + leading: const ImmichCloseButton(), + actions: [ + isEditing + ? _buildProgressIndicator() + : ImmichIconButton( + icon: Icons.done_rounded, + color: ImmichColor.primary, + variant: ImmichVariant.ghost, + onPressed: _saveEditedImage, + ), ], ), 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), + 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: widget.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, + currentAspectRatio: aspectRatio, + ratio: null, + label: 'Free', + onPressed: () { + setState(() { + cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9); + aspectRatio = null; + cropController.aspectRatio = null; + }); + }, + ), + _AspectRatioButton( + cropController: cropController, + currentAspectRatio: aspectRatio, + ratio: 1.0, + label: '1:1', + onPressed: () { + setState(() { + cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9); + aspectRatio = 1.0; + cropController.aspectRatio = 1.0; + }); + }, + ), + _AspectRatioButton( + cropController: cropController, + currentAspectRatio: aspectRatio, + ratio: 16.0 / 9.0, + label: '16:9', + onPressed: () { + setState(() { + cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9); + aspectRatio = 16.0 / 9.0; + cropController.aspectRatio = 16.0 / 9.0; + }); + }, + ), + _AspectRatioButton( + cropController: cropController, + currentAspectRatio: aspectRatio, + ratio: 3.0 / 2.0, + label: '3:2', + onPressed: () { + setState(() { + cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9); + aspectRatio = 3.0 / 2.0; + cropController.aspectRatio = 3.0 / 2.0; + }); + }, + ), + _AspectRatioButton( + cropController: cropController, + currentAspectRatio: aspectRatio, + ratio: 7.0 / 5.0, + label: '7:5', + onPressed: () { + setState(() { + cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9); + aspectRatio = 7.0 / 5.0; + cropController.aspectRatio = 7.0 / 5.0; + }); + }, + ), + ], + ), + ], + ), + ), + ), ), ], - ), - 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: [ - FutureBuilder( - future: edits, - builder: (ctx, data) { - return Text(data.hasData ? data.data?.edits.length.toString() ?? "" : "..."); - }, - ), - 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), - ], - ), - ], + ); + }, ), ), ); } } + +class _AspectRatioButton extends StatelessWidget { + final CropController cropController; + final double? currentAspectRatio; + final double? ratio; + final String label; + final VoidCallback onPressed; + + const _AspectRatioButton({ + required this.cropController, + required this.currentAspectRatio, + required this.ratio, + required this.label, + required this.onPressed, + }); + + @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: currentAspectRatio == ratio ? context.primaryColor : context.themeData.iconTheme.color), + onPressed: onPressed, + ), + Text(label, 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 index 8198a41bbe..86230d4f97 100644 --- a/mobile/lib/presentation/pages/editing/drift_filter.page.dart +++ b/mobile/lib/presentation/pages/editing/drift_filter.page.dart @@ -1,20 +1,17 @@ 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; @@ -75,7 +72,7 @@ class DriftFilterImagePage extends HookWidget { 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))); + // unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: filteredImage, isEdited: true))); }, ), ], 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 4c7b6ffbdc..9d6633421b 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,9 +1,12 @@ +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/extensions/translate_extensions.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/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -14,13 +17,25 @@ class EditImageActionButton extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final currentAsset = ref.watch(currentAssetNotifier); - onPress() { - if (currentAsset == null) { + Future onPress() async { + if (currentAsset == null || currentAsset.remoteId == null) { return; } - final image = Image(image: getFullImageProvider(currentAsset)); - context.pushRoute(DriftEditImageRoute(asset: currentAsset, image: image, isEdited: false)); + final imageProvider = getThumbnailImageProvider(currentAsset, edited: false); + if (imageProvider == null) { + return; + } + + final image = Image(image: imageProvider); + final edits = await ref.read(remoteAssetRepositoryProvider).getAssetEdits(currentAsset.remoteId!); + final exifInfo = await ref.read(remoteAssetRepositoryProvider).getExif(currentAsset.remoteId!); + + if (exifInfo == null) { + return; + } + + await context.pushRoute(DriftEditImageRoute(asset: currentAsset, image: image, edits: edits, exifInfo: exifInfo)); } return BaseActionButton( diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 2b9196389e..19a54732a3 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -78,7 +78,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget { final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime); final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime); final timezone = 'GMT${timeZoneOffset.formatAsOffset()}'; - return '$date$_kSeparator$time $timezone'; + return '${exifInfo?.width}x${exifInfo?.height} $date$_kSeparator$time $timezone'; } String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) { diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index 6e60c59c7f..1ac779d89a 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -126,7 +126,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080 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); @@ -134,7 +134,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 ? RemoteThumbProvider(assetId: assetId, thumbhash: thumbhash) : null; + return assetId != null ? RemoteThumbProvider(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 08d1ef13db..7e42fe28d4 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -14,8 +14,9 @@ class RemoteThumbProvider extends CancellableImageProvider with CancellableImageProviderMixin { final String assetId; final String thumbhash; + final bool edited; - RemoteThumbProvider({required this.assetId, required this.thumbhash}); + RemoteThumbProvider({required this.assetId, required this.thumbhash, this.edited = true}); @override Future obtainKey(ImageConfiguration configuration) { @@ -36,7 +37,7 @@ class RemoteThumbProvider extends CancellableImageProvider Stream _codec(RemoteThumbProvider key, ImageDecoderCallback decode) { final request = this.request = RemoteImageRequest( - uri: getThumbnailUrlForRemoteId(key.assetId, thumbhash: key.thumbhash), + uri: getThumbnailUrlForRemoteId(key.assetId, thumbhash: key.thumbhash, edited: key.edited), headers: ApiService.getRequestHeaders(), ); return loadRequest(request, decode); @@ -46,14 +47,14 @@ class RemoteThumbProvider extends CancellableImageProvider bool operator ==(Object other) { if (identical(this, other)) return true; if (other is RemoteThumbProvider) { - return assetId == other.assetId && thumbhash == other.thumbhash; + return assetId == other.assetId && thumbhash == other.thumbhash && edited == other.edited; } return false; } @override - int get hashCode => assetId.hashCode ^ thumbhash.hashCode; + int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ edited.hashCode; } class RemoteFullImageProvider extends CancellableImageProvider @@ -61,8 +62,14 @@ class RemoteFullImageProvider extends CancellableImageProvider obtainKey(ImageConfiguration configuration) { @@ -73,7 +80,9 @@ class RemoteFullImageProvider extends CancellableImageProvider [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Asset Id', key.assetId), @@ -92,7 +101,12 @@ class RemoteFullImageProvider extends CancellableImageProvider assetId.hashCode ^ thumbhash.hashCode; + int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ edited.hashCode; } diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 924e9c558a..8355ac499a 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -6,19 +6,20 @@ import 'package:cancellation_token_http/http.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/presentation/widgets/asset_viewer/asset_viewer.state.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/current_asset.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.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:riverpod_annotation/riverpod_annotation.dart'; @@ -469,6 +470,23 @@ 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'); + } + + try { + await _service.applyEdits(ids.first, edits); + 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/infrastructure/asset.provider.dart b/mobile/lib/providers/infrastructure/asset.provider.dart index 585c9b678d..70cb200bf1 100644 --- a/mobile/lib/providers/infrastructure/asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset.provider.dart @@ -3,7 +3,6 @@ import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -12,7 +11,7 @@ final localAssetRepository = Provider( ); final remoteAssetRepositoryProvider = Provider( - (ref) => RemoteAssetRepository(ref.watch(driftProvider), ref.watch(apiServiceProvider).assetsApi), + (ref) => RemoteAssetRepository(ref.watch(driftProvider)), ); final trashedLocalAssetRepository = Provider( diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index f9473ce440..2ad0a2d755 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -206,6 +206,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); + throw 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..517c591c57 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -1,12 +1,13 @@ 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'; 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'; import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:openapi/api.dart'; +import 'package:openapi/api.dart' hide AssetEditAction; final assetApiRepositoryProvider = Provider( (ref) => AssetApiRepository( @@ -105,6 +106,25 @@ class AssetApiRepository extends ApiRepository { Future updateRating(String assetId, int rating) { return _api.updateAsset(assetId, UpdateAssetDto(rating: rating)); } + + Future editAsset(String assetId, List edits) async { + final editDtos = edits + .map((edit) { + if (edit.action == AssetEditAction.other) { + return null; + } + + return AssetEditActionListDtoEditsInner(action: edit.action.toDto()!, parameters: edit.parameters); + }) + .whereType() + .toList(); + + await _api.editAsset(assetId, AssetEditActionListDto(edits: editDtos)); + } + + Future removeEdits(String assetId) async { + await _api.removeAssetEdits(assetId); + } } extension on StackResponseDto { diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 9468b105e5..1863391b82 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -4,6 +4,8 @@ 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/exif.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'; @@ -78,6 +80,7 @@ import 'package:immich_mobile/pages/search/recently_taken.page.dart'; import 'package:immich_mobile/pages/search/search.page.dart'; import 'package:immich_mobile/pages/settings/sync_status.page.dart'; import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; +import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart'; import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; import 'package:immich_mobile/presentation/pages/dev/ui_showcase.page.dart'; @@ -88,7 +91,6 @@ import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart'; import 'package:immich_mobile/presentation/pages/drift_archive.page.dart'; import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/drift_asset_troubleshoot.page.dart'; -import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart'; import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart'; import 'package:immich_mobile/presentation/pages/drift_library.page.dart'; @@ -106,9 +108,7 @@ 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/editing/drift_edit.page.dart'; -import 'package:immich_mobile/presentation/pages/editing/drift_filter.page.dart'; import 'package:immich_mobile/presentation/pages/local_timeline.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'; @@ -332,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 b287d73114..344021caf2 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -982,70 +982,24 @@ 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 BaseAsset asset, + required List edits, + required ExifInfo exifInfo, List? children, }) : super( DriftEditImageRoute.name, args: DriftEditImageRouteArgs( key: key, - asset: asset, image: image, - isEdited: isEdited, + asset: asset, + edits: edits, + exifInfo: exifInfo, ), initialChildren: children, ); @@ -1058,9 +1012,10 @@ class DriftEditImageRoute extends PageRouteInfo { final args = data.argsAs(); return DriftEditImagePage( key: args.key, - asset: args.asset, image: args.image, - isEdited: args.isEdited, + asset: args.asset, + edits: args.edits, + exifInfo: args.exifInfo, ); }, ); @@ -1069,22 +1024,25 @@ class DriftEditImageRoute extends PageRouteInfo { class DriftEditImageRouteArgs { const DriftEditImageRouteArgs({ this.key, - required this.asset, required this.image, - required this.isEdited, + required this.asset, + required this.edits, + required this.exifInfo, }); final Key? key; - final BaseAsset asset; - final Image image; - final bool isEdited; + final BaseAsset asset; + + final List edits; + + final ExifInfo exifInfo; @override String toString() { - return 'DriftEditImageRouteArgs{key: $key, asset: $asset, image: $image, isEdited: $isEdited}'; + return 'DriftEditImageRouteArgs{key: $key, image: $image, asset: $asset, edits: $edits, exifInfo: $exifInfo}'; } } @@ -1104,54 +1062,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 13e491f321..eb33465185 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'; @@ -240,6 +241,16 @@ class ActionService { return _downloadRepository.downloadAllAssets(assets); } + Future applyEdits(String remoteId, List edits) async { + if (edits.isEmpty) { + await _assetApiRepository.removeEdits(remoteId); + } else { + await _assetApiRepository.editAsset(remoteId, edits); + } + + await _remoteAssetRepository.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..0a7a5efa7d --- /dev/null +++ b/mobile/lib/utils/editor.utils.dart @@ -0,0 +1,46 @@ +import 'dart:math'; + +import 'package:crop_image/crop_image.dart'; +import 'package:flutter/widgets.dart'; +import 'package:openapi/api.dart'; + +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).round(); + final y = (rect.top * originalHeight).round(); + final width = (rect.width * originalWidth).round(); + final height = (rect.height * originalHeight).round(); + + 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), + ); +} + +Rect convertCropRectToRotated(Rect cropRect, CropRotation rotation) { + return switch (rotation) { + CropRotation.up => cropRect, + CropRotation.right => Rect.fromLTWH(1 - cropRect.bottom, cropRect.left, cropRect.height, cropRect.width), + CropRotation.down => Rect.fromLTWH(1 - cropRect.right, 1 - cropRect.bottom, cropRect.width, cropRect.height), + CropRotation.left => Rect.fromLTWH(cropRect.top, 1 - cropRect.right, cropRect.height, cropRect.width), + }; +} + +Rect convertCropRectFromRotated(Rect cropRect, CropRotation rotation) { + return switch (rotation) { + CropRotation.up => cropRect, + CropRotation.right => Rect.fromLTWH(cropRect.top, 1 - cropRect.right, cropRect.height, cropRect.width), + CropRotation.down => Rect.fromLTWH(1 - cropRect.right, 1 - cropRect.bottom, cropRect.width, cropRect.height), + CropRotation.left => Rect.fromLTWH(1 - cropRect.bottom, cropRect.left, cropRect.height, cropRect.width), + }; +} diff --git a/mobile/lib/utils/hooks/crop_controller_hook.dart b/mobile/lib/utils/hooks/crop_controller_hook.dart index 663bca3dbf..b5bd536ecb 100644 --- a/mobile/lib/utils/hooks/crop_controller_hook.dart +++ b/mobile/lib/utils/hooks/crop_controller_hook.dart @@ -1,8 +1,14 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:crop_image/crop_image.dart'; import 'dart:ui'; // Import the dart:ui library for Rect +import 'package:crop_image/crop_image.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + /// A hook that provides a [CropController] instance. -CropController useCropController() { - return useMemoized(() => CropController(defaultCrop: const Rect.fromLTRB(0, 0, 1, 1))); +CropController useCropController({Rect? initialCrop, CropRotation? initialRotation}) { + return useMemoized( + () => CropController( + defaultCrop: initialCrop ?? const Rect.fromLTRB(0, 0, 1, 1), + rotation: initialRotation ?? CropRotation.up, + ), + ); }