diff --git a/mobile/lib/constants/aspect_ratios.dart b/mobile/lib/constants/aspect_ratios.dart index 9159db4ef1..7a1f46d198 100644 --- a/mobile/lib/constants/aspect_ratios.dart +++ b/mobile/lib/constants/aspect_ratios.dart @@ -1,19 +1,65 @@ 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); +class CropAspectRatio { + final int? numerator; + final int? denominator; - final double? ratio; - final String label; - final IconData icon; - final bool iconRotated; + final String? customLabel; + final IconData? icon; - const AspectRatioPreset({required this.ratio, required this.label, required this.icon, this.iconRotated = false}); + const CropAspectRatio({this.numerator, this.denominator, this.customLabel, this.icon}); + + static const free = CropAspectRatio(customLabel: "Free", icon: Icons.crop_free); + static const original = CropAspectRatio(customLabel: "Original", icon: Icons.crop_original); + + String get label { + return customLabel ?? (numerator != null && denominator != null ? '$numerator:$denominator' : 'Free'); + } + + bool get hasFlippedVariant => numerator != denominator; + double? get ratio => (numerator != null && denominator != null) ? numerator! / denominator! : null; + + CropAspectRatio get flipped { + return CropAspectRatio(numerator: denominator, denominator: numerator, customLabel: customLabel, icon: icon); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + return other is CropAspectRatio && + other.numerator == numerator && + other.denominator == denominator && + other.customLabel == customLabel && + other.icon == icon; + } + + @override + int get hashCode { + return numerator.hashCode ^ denominator.hashCode ^ customLabel.hashCode ^ icon.hashCode; + } } + +const aspectRatioFree = CropAspectRatio(customLabel: "Free", icon: Icons.crop_free); +const aspectRatioOriginal = CropAspectRatio(customLabel: "Original", icon: Icons.crop_original); + +final aspectRatioPresets = [ + CropAspectRatio.free, + CropAspectRatio.original, + + const CropAspectRatio(numerator: 1, denominator: 1), + + // lanscape + const CropAspectRatio(numerator: 16, denominator: 9), + const CropAspectRatio(numerator: 3, denominator: 2), + const CropAspectRatio(numerator: 7, denominator: 5), + const CropAspectRatio(numerator: 4, denominator: 3), + + // portrait + const CropAspectRatio(numerator: 16, denominator: 9).flipped, + const CropAspectRatio(numerator: 3, denominator: 2).flipped, + const CropAspectRatio(numerator: 7, denominator: 5).flipped, + const CropAspectRatio(numerator: 4, denominator: 3).flipped, +]; diff --git a/mobile/lib/presentation/pages/edit/drift_edit.page.dart b/mobile/lib/presentation/pages/edit/drift_edit.page.dart index dcb340cc0e..2e2d39e386 100644 --- a/mobile/lib/presentation/pages/edit/drift_edit.page.dart +++ b/mobile/lib/presentation/pages/edit/drift_edit.page.dart @@ -154,7 +154,7 @@ class _DriftEditImagePageState extends ConsumerState with Ti } class _AspectRatioButton extends StatelessWidget { - final AspectRatioPreset ratio; + final CropAspectRatio ratio; final bool isSelected; final VoidCallback onPressed; @@ -162,15 +162,16 @@ class _AspectRatioButton extends StatelessWidget { @override Widget build(BuildContext context) { + final color = isSelected ? context.primaryColor : context.themeData.iconTheme.color; + 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), - ), + icon: ratio.ratio != null + ? _AspectRatioRect(ratio: ratio.ratio!, color: color) + : Icon(ratio.icon, color: color), onPressed: onPressed, ), Text(ratio.label, style: context.textTheme.displayMedium), @@ -179,6 +180,32 @@ class _AspectRatioButton extends StatelessWidget { } } +class _AspectRatioRect extends StatelessWidget { + final double ratio; + final Color? color; + + const _AspectRatioRect({required this.ratio, required this.color}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 28, + height: 28, + child: Center( + child: AspectRatio( + aspectRatio: ratio, + child: Container( + decoration: BoxDecoration( + border: Border.all(color: color ?? Colors.transparent, width: 3), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + ), + ), + ), + ); + } +} + class _AspectRatioSelector extends ConsumerWidget { const _AspectRatioSelector(); @@ -187,22 +214,16 @@ class _AspectRatioSelector extends ConsumerWidget { 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) { + children: aspectRatioPresets.map((entry) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: _AspectRatioButton( ratio: entry, - isSelected: selectedAspectRatio == entry.ratio, - onPressed: () => editorNotifier.setAspectRatio(entry.ratio), + isSelected: editorState.aspectRatio == entry, + onPressed: () => editorNotifier.setAspectRatio(entry), ), ); }).toList(), @@ -357,8 +378,22 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi final editorState = ref.watch(editorStateProvider); final editorNotifier = ref.read(editorStateProvider.notifier); - ref.listen(editorStateProvider, (_, current) { - cropController.aspectRatio = current.aspectRatio; + ref.listen(editorStateProvider, (previous, current) { + // Only re-apply the aspect ratio when it changes, otherwise the crop rect will shrink on every rotation + if (previous?.aspectRatio != current.aspectRatio) { + double? ratio; + + ratio = switch (current.aspectRatio) { + CropAspectRatio.original => current.originalWidth / current.originalHeight, + _ => current.aspectRatio.ratio, + }; + + if (current.rotationAngle % 180 != 0) { + ratio = ratio != null ? 1 / ratio : null; + } + + cropController.aspectRatio = ratio; + } if (cropController.crop != current.crop) { cropController.crop = current.crop; @@ -386,7 +421,9 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi 1.0, 1.0, ), - child: Container( + child: AnimatedContainer( + duration: editorState.animationDuration, + curve: Curves.easeInOut, padding: const EdgeInsets.all(10), width: (editorState.rotationAngle % 180 == 0) ? baseWidth : baseHeight, height: (editorState.rotationAngle % 180 == 0) ? baseHeight : baseWidth, diff --git a/mobile/lib/presentation/pages/edit/editor.provider.dart b/mobile/lib/presentation/pages/edit/editor.provider.dart index fcfb8be68e..69c8b7bf54 100644 --- a/mobile/lib/presentation/pages/edit/editor.provider.dart +++ b/mobile/lib/presentation/pages/edit/editor.provider.dart @@ -1,5 +1,6 @@ import 'package:flutter/services.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/domain/models/exif.model.dart'; import 'package:immich_mobile/utils/editor.utils.dart'; @@ -60,13 +61,8 @@ class EditorProvider extends Notifier { 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 setAspectRatio(CropAspectRatio preset) { + state = state.copyWith(aspectRatio: preset, hasUnsavedEdits: true); } void resetEdits() { @@ -76,19 +72,19 @@ class EditorProvider extends Notifier { flipHorizontal: false, flipVertical: false, crop: const Rect.fromLTRB(0, 0, 1, 1), - aspectRatio: null, + aspectRatio: CropAspectRatio.free, hasUnsavedEdits: true, ); } void rotateCCW() { _animateRotation(state.rotationAngle - 90); - state = state.copyWith(hasUnsavedEdits: true); + state = state.copyWith(aspectRatio: state.aspectRatio.flipped, hasUnsavedEdits: true); } void rotateCW() { _animateRotation(state.rotationAngle + 90); - state = state.copyWith(hasUnsavedEdits: true); + state = state.copyWith(aspectRatio: state.aspectRatio.flipped, hasUnsavedEdits: true); } void flipHorizontally() { @@ -117,7 +113,7 @@ class EditorState { final bool flipHorizontal; final bool flipVertical; final Rect crop; - final double? aspectRatio; + final CropAspectRatio aspectRatio; final int originalWidth; final int originalHeight; @@ -132,7 +128,7 @@ class EditorState { bool? flipHorizontal, bool? flipVertical, Rect? crop, - this.aspectRatio, + CropAspectRatio? aspectRatio, int? originalWidth, int? originalHeight, Duration? animationDuration, @@ -145,6 +141,7 @@ class EditorState { originalWidth = originalWidth ?? 0, originalHeight = originalHeight ?? 0, crop = crop ?? const Rect.fromLTRB(0, 0, 1, 1), + aspectRatio = aspectRatio ?? CropAspectRatio.free, hasUnsavedEdits = hasUnsavedEdits ?? false; EditorState copyWith({ @@ -152,7 +149,7 @@ class EditorState { int? rotationAngle, bool? flipHorizontal, bool? flipVertical, - double? aspectRatio = double.infinity, + CropAspectRatio? aspectRatio, int? originalWidth, int? originalHeight, Duration? animationDuration, @@ -164,7 +161,7 @@ class EditorState { rotationAngle: rotationAngle ?? this.rotationAngle, flipHorizontal: flipHorizontal ?? this.flipHorizontal, flipVertical: flipVertical ?? this.flipVertical, - aspectRatio: aspectRatio == double.infinity ? this.aspectRatio : aspectRatio, + aspectRatio: aspectRatio ?? this.aspectRatio, animationDuration: animationDuration ?? this.animationDuration, originalWidth: originalWidth ?? this.originalWidth, originalHeight: originalHeight ?? this.originalHeight,