mirror of
https://github.com/immich-app/immich.git
synced 2026-07-06 20:47:00 -07:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3beaeba72 | |||
| d0b779d51e | |||
| 61210eab28 | |||
| b6cb534ea6 |
@@ -1,19 +1,65 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
enum AspectRatioPreset {
|
class CropAspectRatio {
|
||||||
free(ratio: null, label: 'Free', icon: Icons.crop_free_rounded),
|
final int? numerator;
|
||||||
square(ratio: 1.0, label: '1:1', icon: Icons.crop_square_rounded),
|
final int? denominator;
|
||||||
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? customLabel;
|
||||||
final String label;
|
final IconData? icon;
|
||||||
final IconData icon;
|
|
||||||
final bool iconRotated;
|
|
||||||
|
|
||||||
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,
|
||||||
|
];
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AspectRatioButton extends StatelessWidget {
|
class _AspectRatioButton extends StatelessWidget {
|
||||||
final AspectRatioPreset ratio;
|
final CropAspectRatio ratio;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onPressed;
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
@@ -162,15 +162,16 @@ class _AspectRatioButton extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final color = isSelected ? context.primaryColor : context.themeData.iconTheme.color;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.max,
|
mainAxisSize: MainAxisSize.max,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
iconSize: 36,
|
iconSize: 36,
|
||||||
icon: Transform.rotate(
|
icon: ratio.ratio != null
|
||||||
angle: ratio.iconRotated ? pi / 2 : 0,
|
? _AspectRatioRect(ratio: ratio.ratio!, color: color)
|
||||||
child: Icon(ratio.icon, color: isSelected ? context.primaryColor : context.themeData.iconTheme.color),
|
: Icon(ratio.icon, color: color),
|
||||||
),
|
|
||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
),
|
),
|
||||||
Text(ratio.label, style: context.textTheme.displayMedium),
|
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: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _AspectRatioSelector extends ConsumerWidget {
|
class _AspectRatioSelector extends ConsumerWidget {
|
||||||
const _AspectRatioSelector();
|
const _AspectRatioSelector();
|
||||||
|
|
||||||
@@ -187,22 +214,16 @@ class _AspectRatioSelector extends ConsumerWidget {
|
|||||||
final editorState = ref.watch(editorStateProvider);
|
final editorState = ref.watch(editorStateProvider);
|
||||||
final editorNotifier = ref.read(editorStateProvider.notifier);
|
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(
|
return SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: AspectRatioPreset.values.map((entry) {
|
children: aspectRatioPresets.map((entry) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
child: _AspectRatioButton(
|
child: _AspectRatioButton(
|
||||||
ratio: entry,
|
ratio: entry,
|
||||||
isSelected: selectedAspectRatio == entry.ratio,
|
isSelected: editorState.aspectRatio == entry,
|
||||||
onPressed: () => editorNotifier.setAspectRatio(entry.ratio),
|
onPressed: () => editorNotifier.setAspectRatio(entry),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
@@ -357,8 +378,24 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi
|
|||||||
final editorState = ref.watch(editorStateProvider);
|
final editorState = ref.watch(editorStateProvider);
|
||||||
final editorNotifier = ref.read(editorStateProvider.notifier);
|
final editorNotifier = ref.read(editorStateProvider.notifier);
|
||||||
|
|
||||||
ref.listen(editorStateProvider, (_, current) {
|
ref.listen(editorStateProvider, (previous, current) {
|
||||||
cropController.aspectRatio = current.aspectRatio;
|
// 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;
|
||||||
|
|
||||||
|
switch (current.aspectRatio) {
|
||||||
|
case CropAspectRatio.original:
|
||||||
|
ratio = current.originalWidth / current.originalHeight;
|
||||||
|
default:
|
||||||
|
ratio = current.aspectRatio.ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.rotationAngle % 180 != 0) {
|
||||||
|
ratio = ratio != null ? 1 / ratio : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
cropController.aspectRatio = ratio;
|
||||||
|
}
|
||||||
|
|
||||||
if (cropController.crop != current.crop) {
|
if (cropController.crop != current.crop) {
|
||||||
cropController.crop = current.crop;
|
cropController.crop = current.crop;
|
||||||
@@ -386,7 +423,9 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi
|
|||||||
1.0,
|
1.0,
|
||||||
1.0,
|
1.0,
|
||||||
),
|
),
|
||||||
child: Container(
|
child: AnimatedContainer(
|
||||||
|
duration: editorState.animationDuration,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
width: (editorState.rotationAngle % 180 == 0) ? baseWidth : baseHeight,
|
width: (editorState.rotationAngle % 180 == 0) ? baseWidth : baseHeight,
|
||||||
height: (editorState.rotationAngle % 180 == 0) ? baseHeight : baseWidth,
|
height: (editorState.rotationAngle % 180 == 0) ? baseHeight : baseWidth,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/asset_edit.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||||
import 'package:immich_mobile/utils/editor.utils.dart';
|
import 'package:immich_mobile/utils/editor.utils.dart';
|
||||||
@@ -60,13 +61,8 @@ class EditorProvider extends Notifier<EditorState> {
|
|||||||
state = state.copyWith(crop: crop, hasUnsavedEdits: true);
|
state = state.copyWith(crop: crop, hasUnsavedEdits: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setAspectRatio(double? aspectRatio) {
|
void setAspectRatio(CropAspectRatio preset) {
|
||||||
if (aspectRatio != null && state.rotationAngle % 180 != 0) {
|
state = state.copyWith(aspectRatio: preset, hasUnsavedEdits: true);
|
||||||
// When rotated 90 or 270 degrees, swap width and height for aspect ratio calculations
|
|
||||||
aspectRatio = 1 / aspectRatio;
|
|
||||||
}
|
|
||||||
|
|
||||||
state = state.copyWith(aspectRatio: aspectRatio);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void resetEdits() {
|
void resetEdits() {
|
||||||
@@ -76,19 +72,19 @@ class EditorProvider extends Notifier<EditorState> {
|
|||||||
flipHorizontal: false,
|
flipHorizontal: false,
|
||||||
flipVertical: false,
|
flipVertical: false,
|
||||||
crop: const Rect.fromLTRB(0, 0, 1, 1),
|
crop: const Rect.fromLTRB(0, 0, 1, 1),
|
||||||
aspectRatio: null,
|
aspectRatio: CropAspectRatio.free,
|
||||||
hasUnsavedEdits: true,
|
hasUnsavedEdits: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void rotateCCW() {
|
void rotateCCW() {
|
||||||
_animateRotation(state.rotationAngle - 90);
|
_animateRotation(state.rotationAngle - 90);
|
||||||
state = state.copyWith(hasUnsavedEdits: true);
|
state = state.copyWith(aspectRatio: state.aspectRatio.flipped, hasUnsavedEdits: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void rotateCW() {
|
void rotateCW() {
|
||||||
_animateRotation(state.rotationAngle + 90);
|
_animateRotation(state.rotationAngle + 90);
|
||||||
state = state.copyWith(hasUnsavedEdits: true);
|
state = state.copyWith(aspectRatio: state.aspectRatio.flipped, hasUnsavedEdits: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void flipHorizontally() {
|
void flipHorizontally() {
|
||||||
@@ -117,7 +113,7 @@ class EditorState {
|
|||||||
final bool flipHorizontal;
|
final bool flipHorizontal;
|
||||||
final bool flipVertical;
|
final bool flipVertical;
|
||||||
final Rect crop;
|
final Rect crop;
|
||||||
final double? aspectRatio;
|
final CropAspectRatio aspectRatio;
|
||||||
|
|
||||||
final int originalWidth;
|
final int originalWidth;
|
||||||
final int originalHeight;
|
final int originalHeight;
|
||||||
@@ -132,7 +128,7 @@ class EditorState {
|
|||||||
bool? flipHorizontal,
|
bool? flipHorizontal,
|
||||||
bool? flipVertical,
|
bool? flipVertical,
|
||||||
Rect? crop,
|
Rect? crop,
|
||||||
this.aspectRatio,
|
CropAspectRatio? aspectRatio,
|
||||||
int? originalWidth,
|
int? originalWidth,
|
||||||
int? originalHeight,
|
int? originalHeight,
|
||||||
Duration? animationDuration,
|
Duration? animationDuration,
|
||||||
@@ -145,6 +141,7 @@ class EditorState {
|
|||||||
originalWidth = originalWidth ?? 0,
|
originalWidth = originalWidth ?? 0,
|
||||||
originalHeight = originalHeight ?? 0,
|
originalHeight = originalHeight ?? 0,
|
||||||
crop = crop ?? const Rect.fromLTRB(0, 0, 1, 1),
|
crop = crop ?? const Rect.fromLTRB(0, 0, 1, 1),
|
||||||
|
aspectRatio = aspectRatio ?? CropAspectRatio.free,
|
||||||
hasUnsavedEdits = hasUnsavedEdits ?? false;
|
hasUnsavedEdits = hasUnsavedEdits ?? false;
|
||||||
|
|
||||||
EditorState copyWith({
|
EditorState copyWith({
|
||||||
@@ -152,7 +149,7 @@ class EditorState {
|
|||||||
int? rotationAngle,
|
int? rotationAngle,
|
||||||
bool? flipHorizontal,
|
bool? flipHorizontal,
|
||||||
bool? flipVertical,
|
bool? flipVertical,
|
||||||
double? aspectRatio = double.infinity,
|
CropAspectRatio? aspectRatio,
|
||||||
int? originalWidth,
|
int? originalWidth,
|
||||||
int? originalHeight,
|
int? originalHeight,
|
||||||
Duration? animationDuration,
|
Duration? animationDuration,
|
||||||
@@ -164,7 +161,7 @@ class EditorState {
|
|||||||
rotationAngle: rotationAngle ?? this.rotationAngle,
|
rotationAngle: rotationAngle ?? this.rotationAngle,
|
||||||
flipHorizontal: flipHorizontal ?? this.flipHorizontal,
|
flipHorizontal: flipHorizontal ?? this.flipHorizontal,
|
||||||
flipVertical: flipVertical ?? this.flipVertical,
|
flipVertical: flipVertical ?? this.flipVertical,
|
||||||
aspectRatio: aspectRatio == double.infinity ? this.aspectRatio : aspectRatio,
|
aspectRatio: aspectRatio ?? this.aspectRatio,
|
||||||
animationDuration: animationDuration ?? this.animationDuration,
|
animationDuration: animationDuration ?? this.animationDuration,
|
||||||
originalWidth: originalWidth ?? this.originalWidth,
|
originalWidth: originalWidth ?? this.originalWidth,
|
||||||
originalHeight: originalHeight ?? this.originalHeight,
|
originalHeight: originalHeight ?? this.originalHeight,
|
||||||
|
|||||||
Reference in New Issue
Block a user