diff --git a/mobile/lib/presentation/pages/editing/drift_edit.page.dart b/mobile/lib/presentation/pages/editing/drift_edit.page.dart index fbd44212c5..eb1e2584ef 100644 --- a/mobile/lib/presentation/pages/editing/drift_edit.page.dart +++ b/mobile/lib/presentation/pages/editing/drift_edit.page.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; import 'package:crop_image/crop_image.dart'; @@ -12,7 +14,9 @@ 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/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/providers/websocket.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'; @@ -44,8 +48,12 @@ class DriftEditImagePage extends ConsumerStatefulWidget { ConsumerState createState() => _DriftEditImagePageState(); } -class _DriftEditImagePageState extends ConsumerState { +class _DriftEditImagePageState extends ConsumerState with TickerProviderStateMixin { late final CropController cropController; + + int _rotationAngle = 0; + Duration _rotationAnimationDuration = const Duration(milliseconds: 250); + double? aspectRatio; late final originalWidth = widget.exifInfo.isFlipped ? widget.exifInfo.height : widget.exifInfo.width; @@ -53,7 +61,7 @@ class _DriftEditImagePageState extends ConsumerState { bool isEditing = false; - (Rect, CropRotation) getInitialEditorState() { + (Rect, double) getInitialEditorState() { final existingCrop = widget.edits.firstWhereOrNull((edit) => edit.action == AssetEditAction.crop); Rect crop = existingCrop != null @@ -68,11 +76,8 @@ class _DriftEditImagePageState extends ConsumerState { 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); + // crop = convertCropRectToRotated(crop, existingRotationAngle); + return (crop, existingRotationParameters?.angle.toDouble() ?? 0); } Future _saveEditedImage() async { @@ -80,9 +85,8 @@ class _DriftEditImagePageState extends ConsumerState { isEditing = true; }); - CropRotation rotation = cropController.rotation; - Rect cropRect = convertCropRectFromRotated(cropController.crop, rotation); - final cropParameters = convertRectToCropParameters(cropRect, originalWidth ?? 0, originalHeight ?? 0); + final cropParameters = convertRectToCropParameters(cropController.crop, originalWidth ?? 0, originalHeight ?? 0); + final normalizedRotation = (_rotationAngle % 360 + 360) % 360; final edits = []; @@ -90,11 +94,11 @@ class _DriftEditImagePageState extends ConsumerState { edits.add(AssetEdit(action: AssetEditAction.crop, parameters: cropParameters.toJson())); } - if (rotation != CropRotation.up) { + if (normalizedRotation != 0) { edits.add( AssetEdit( action: AssetEditAction.rotate, - parameters: RotateParameters(angle: rotation.degrees).toJson(), + parameters: RotateParameters(angle: normalizedRotation).toJson(), ), ); } @@ -129,7 +133,10 @@ class _DriftEditImagePageState extends ConsumerState { super.initState(); final (existingCrop, existingRotationAngle) = getInitialEditorState(); - cropController = CropController(defaultCrop: existingCrop, rotation: existingRotationAngle); + cropController = CropController(defaultCrop: existingCrop); + + _rotationAnimationDuration = const Duration(milliseconds: 0); + _rotationAngle = existingRotationAngle.toInt(); } @override @@ -145,148 +152,217 @@ class _DriftEditImagePageState extends ConsumerState { ); } + void _rotateLeft() { + setState(() { + _rotationAnimationDuration = const Duration(milliseconds: 150); + _rotationAngle -= 90; + }); + } + + void _rotateRight() { + setState(() { + _rotationAnimationDuration = const Duration(milliseconds: 150); + _rotationAngle += 90; + }); + } + @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: context.scaffoldBackgroundColor, - 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: 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), - ), - ), + return Theme( + data: getThemeData(colorScheme: ref.watch(immichThemeProvider).dark, locale: context.locale), + child: Scaffold( + appBar: AppBar( + backgroundColor: Colors.black, + title: Text("edit".tr()), + leading: const ImmichCloseButton(), + actions: [ + isEditing + ? _buildProgressIndicator() + : ImmichIconButton( + icon: Icons.done_rounded, + color: ImmichColor.primary, + variant: ImmichVariant.ghost, + onPressed: _saveEditedImage, + ), + ], + ), + backgroundColor: Colors.black, + body: SafeArea( + bottom: false, + child: 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.8; + + return Column( + children: [ + SizedBox( + width: constraints.maxWidth, + height: constraints.maxHeight * 0.7, 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: AnimatedRotation( + turns: _rotationAngle / 360, + duration: _rotationAnimationDuration, + curve: Curves.easeInOut, + child: Container( + padding: const EdgeInsets.all(10), + width: (_rotationAngle % 180 == 0) ? baseWidth : baseHeight, + height: (_rotationAngle % 180 == 0) ? baseHeight : baseWidth, + 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, top: 20, bottom: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ImmichIconButton( + icon: Icons.rotate_left, + variant: ImmichVariant.ghost, + color: ImmichColor.secondary, + onPressed: _rotateLeft, + ), + ImmichIconButton( + icon: Icons.rotate_right, + variant: ImmichVariant.ghost, + color: ImmichColor.secondary, + onPressed: _rotateRight, + ), + ], + ), + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + spacing: 12, + children: [ + _AspectRatioButton( + cropController: cropController, + currentAspectRatio: aspectRatio, + ratio: null, + label: 'Free', + onPressed: () { + setState(() { + aspectRatio = null; + cropController.aspectRatio = null; + }); + }, + ), + _AspectRatioButton( + cropController: cropController, + currentAspectRatio: aspectRatio, + ratio: 1.0, + label: '1:1', + onPressed: () { + setState(() { + aspectRatio = 1.0; + cropController.aspectRatio = 1.0; + }); + }, + ), + _AspectRatioButton( + cropController: cropController, + currentAspectRatio: aspectRatio, + ratio: 16.0 / 9.0, + label: '16:9', + onPressed: () { + setState(() { + 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(() { + 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(() { + aspectRatio = 7.0 / 5.0; + cropController.aspectRatio = 7.0 / 5.0; + }); + }, + ), + _AspectRatioButton( + cropController: cropController, + currentAspectRatio: aspectRatio, + ratio: 9.0 / 16.0, + label: '9:16', + onPressed: () { + setState(() { + aspectRatio = 9.0 / 16.0; + cropController.aspectRatio = 9.0 / 16.0; + }); + }, + ), + _AspectRatioButton( + cropController: cropController, + currentAspectRatio: aspectRatio, + ratio: 2.0 / 3.0, + label: '2:3', + onPressed: () { + setState(() { + aspectRatio = 2.0 / 3.0; + cropController.aspectRatio = 2.0 / 3.0; + }); + }, + ), + _AspectRatioButton( + cropController: cropController, + currentAspectRatio: aspectRatio, + ratio: 5.0 / 7.0, + label: '5:7', + onPressed: () { + setState(() { + aspectRatio = 5.0 / 7.0; + cropController.aspectRatio = 5.0 / 7.0; + }); + }, + ), + ], + ), + ), + const Spacer(), + ], + ), + ), + ), + ), + ], + ); + }, + ), ), ), ); @@ -311,17 +387,24 @@ class _AspectRatioButton extends StatelessWidget { @override Widget build(BuildContext context) { return Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: MainAxisSize.max, 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), + iconSize: 36, + icon: Transform.rotate( + angle: (ratio ?? 1.0) < 1.0 ? pi / 2 : 0, + child: 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, + '9:16' => Icons.crop_16_9_rounded, + '2:3' => Icons.crop_3_2_rounded, + '5:7' => 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/widgets/action_buttons/edit_image_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart index 9d6633421b..d60fc849d1 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 @@ -22,10 +22,7 @@ class EditImageActionButton extends ConsumerWidget { return; } - final imageProvider = getThumbnailImageProvider(currentAsset, edited: false); - if (imageProvider == null) { - return; - } + final imageProvider = getFullImageProvider(currentAsset, edited: false); final image = Image(image: imageProvider); final edits = await ref.read(remoteAssetRepositoryProvider).getAssetEdits(currentAsset.remoteId!); diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index 1ac779d89a..e4fb08380f 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -102,7 +102,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)) { @@ -120,7 +120,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080 } else { throw ArgumentError("Unsupported asset type: ${asset.runtimeType}"); } - provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash, assetType: asset.type); + provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash, assetType: asset.type, edited: edited); } return provider; diff --git a/mobile/lib/utils/editor.utils.dart b/mobile/lib/utils/editor.utils.dart index 0a7a5efa7d..7129eb4cee 100644 --- a/mobile/lib/utils/editor.utils.dart +++ b/mobile/lib/utils/editor.utils.dart @@ -1,6 +1,5 @@ import 'dart:math'; -import 'package:crop_image/crop_image.dart'; import 'package:flutter/widgets.dart'; import 'package:openapi/api.dart'; @@ -26,21 +25,3 @@ CropParameters convertRectToCropParameters(Rect rect, int originalWidth, int ori 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), - }; -}