mirror of
https://github.com/immich-app/immich.git
synced 2026-03-23 06:19:11 +03:00
579 lines
20 KiB
Dart
579 lines
20 KiB
Dart
import 'dart:async';
|
|
import 'dart:math';
|
|
|
|
import 'package:auto_route/auto_route.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:hooks_riverpod/hooks_riverpod.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/build_context_extensions.dart';
|
|
import 'package:immich_mobile/providers/theme.provider.dart';
|
|
import 'package:immich_mobile/theme/theme_data.dart';
|
|
import 'package:immich_mobile/utils/editor.utils.dart';
|
|
import 'package:immich_ui/immich_ui.dart';
|
|
import 'package:openapi/api.dart' show CropParameters, RotateParameters, MirrorParameters, MirrorAxis;
|
|
|
|
@RoutePage()
|
|
class DriftEditImagePage extends ConsumerStatefulWidget {
|
|
final Image image;
|
|
final BaseAsset asset;
|
|
final List<AssetEdit> edits;
|
|
final ExifInfo exifInfo;
|
|
final Future<void> Function(List<AssetEdit> edits) applyEdits;
|
|
|
|
const DriftEditImagePage({
|
|
super.key,
|
|
required this.image,
|
|
required this.asset,
|
|
required this.edits,
|
|
required this.exifInfo,
|
|
required this.applyEdits,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<DriftEditImagePage> createState() => _DriftEditImagePageState();
|
|
}
|
|
|
|
typedef AspectRatio = ({double? ratio, String label});
|
|
|
|
class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with TickerProviderStateMixin {
|
|
late final CropController cropController;
|
|
|
|
Duration _rotationAnimationDuration = const Duration(milliseconds: 250);
|
|
|
|
int _rotationAngle = 0;
|
|
bool _flipHorizontal = false;
|
|
bool _flipVertical = false;
|
|
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 _isApplyingEdits = false;
|
|
bool _hasSheetChanges = false;
|
|
late final Rect _initialCrop;
|
|
final String _selectedSegment = 'transform';
|
|
|
|
List<AspectRatio> aspectRatios = [
|
|
(ratio: null, label: 'Free'),
|
|
(ratio: 1.0, label: '1:1'),
|
|
(ratio: 16.0 / 9.0, label: '16:9'),
|
|
(ratio: 3.0 / 2.0, label: '3:2'),
|
|
(ratio: 7.0 / 5.0, label: '7:5'),
|
|
(ratio: 9.0 / 16.0, label: '9:16'),
|
|
(ratio: 2.0 / 3.0, label: '2:3'),
|
|
(ratio: 5.0 / 7.0, label: '5:7'),
|
|
];
|
|
|
|
void initEditor() {
|
|
final existingCrop = widget.edits.firstWhereOrNull((edit) => edit.action == AssetEditAction.crop);
|
|
|
|
Rect crop = existingCrop != null && originalWidth != null && originalHeight != null
|
|
? convertCropParametersToRect(
|
|
CropParameters.fromJson(existingCrop.parameters)!,
|
|
originalWidth!,
|
|
originalHeight!,
|
|
)
|
|
: const Rect.fromLTRB(0, 0, 1, 1);
|
|
|
|
cropController = CropController(defaultCrop: crop);
|
|
|
|
final transform = normalizeTransformEdits(widget.edits);
|
|
|
|
// dont animate to initial rotation
|
|
_rotationAnimationDuration = const Duration(milliseconds: 0);
|
|
_rotationAngle = transform.rotation.toInt();
|
|
|
|
_flipHorizontal = transform.mirrorHorizontal;
|
|
_flipVertical = transform.mirrorVertical;
|
|
|
|
_initialCrop = cropController.crop;
|
|
}
|
|
|
|
bool get hasUnsavedChanges {
|
|
final isCropChanged = cropController.crop != _initialCrop;
|
|
return isCropChanged || _hasSheetChanges;
|
|
}
|
|
|
|
Future<void> _saveEditedImage() async {
|
|
setState(() {
|
|
_isApplyingEdits = true;
|
|
});
|
|
|
|
final cropParameters = convertRectToCropParameters(cropController.crop, originalWidth ?? 0, originalHeight ?? 0);
|
|
final normalizedRotation = (_rotationAngle % 360 + 360) % 360;
|
|
final edits = <AssetEdit>[];
|
|
|
|
if (cropParameters.width != originalWidth || cropParameters.height != originalHeight) {
|
|
edits.add(AssetEdit(action: AssetEditAction.crop, parameters: cropParameters.toJson()));
|
|
}
|
|
|
|
if (_flipHorizontal) {
|
|
edits.add(
|
|
AssetEdit(
|
|
action: AssetEditAction.mirror,
|
|
parameters: MirrorParameters(axis: MirrorAxis.horizontal).toJson(),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_flipVertical) {
|
|
edits.add(
|
|
AssetEdit(
|
|
action: AssetEditAction.mirror,
|
|
parameters: MirrorParameters(axis: MirrorAxis.vertical).toJson(),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (normalizedRotation != 0) {
|
|
edits.add(
|
|
AssetEdit(
|
|
action: AssetEditAction.rotate,
|
|
parameters: RotateParameters(angle: normalizedRotation).toJson(),
|
|
),
|
|
);
|
|
}
|
|
|
|
await widget.applyEdits(edits);
|
|
|
|
setState(() {
|
|
_isApplyingEdits = false;
|
|
});
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
initEditor();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
cropController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _rotateLeft() {
|
|
setState(() {
|
|
_rotationAnimationDuration = const Duration(milliseconds: 150);
|
|
_rotationAngle -= 90;
|
|
_hasSheetChanges = true;
|
|
});
|
|
}
|
|
|
|
void _rotateRight() {
|
|
setState(() {
|
|
_rotationAnimationDuration = const Duration(milliseconds: 150);
|
|
_rotationAngle += 90;
|
|
_hasSheetChanges = true;
|
|
});
|
|
}
|
|
|
|
void _flipHorizontally() {
|
|
setState(() {
|
|
if (_rotationAngle % 180 != 0) {
|
|
// When rotated 90 or 270 degrees, flipping horizontally is equivalent to flipping vertically
|
|
_flipVertical = !_flipVertical;
|
|
} else {
|
|
_flipHorizontal = !_flipHorizontal;
|
|
}
|
|
_hasSheetChanges = true;
|
|
});
|
|
}
|
|
|
|
void _flipVertically() {
|
|
setState(() {
|
|
if (_rotationAngle % 180 != 0) {
|
|
// When rotated 90 or 270 degrees, flipping vertically is equivalent to flipping horizontally
|
|
_flipHorizontal = !_flipHorizontal;
|
|
} else {
|
|
_flipVertical = !_flipVertical;
|
|
}
|
|
_hasSheetChanges = true;
|
|
});
|
|
}
|
|
|
|
void _applyAspectRatio(double? ratio) {
|
|
setState(() {
|
|
if (ratio != null && _rotationAngle % 180 != 0) {
|
|
// When rotated 90 or 270 degrees, swap width and height for aspect ratio calculations
|
|
ratio = 1 / ratio!;
|
|
}
|
|
|
|
cropController.aspectRatio = ratio;
|
|
_aspectRatio = ratio;
|
|
});
|
|
}
|
|
|
|
void _resetEdits() {
|
|
setState(() {
|
|
cropController.aspectRatio = null;
|
|
cropController.crop = const Rect.fromLTRB(0, 0, 1, 1);
|
|
_rotationAnimationDuration = const Duration(milliseconds: 250);
|
|
_rotationAngle = 0;
|
|
_flipHorizontal = false;
|
|
_flipVertical = false;
|
|
_aspectRatio = null;
|
|
});
|
|
}
|
|
|
|
bool get hasEdits {
|
|
final isCropped = cropController.crop != const Rect.fromLTRB(0, 0, 1, 1);
|
|
final isRotated = (_rotationAngle % 360 + 360) % 360 != 0;
|
|
final isFlipped = _flipHorizontal || _flipVertical;
|
|
|
|
return isCropped || isRotated || isFlipped;
|
|
}
|
|
|
|
Future<bool> _showDiscardChangesDialog() async {
|
|
return await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text('editor_discard_edits_title'.tr()),
|
|
content: Text('editor_discard_edits_prompt'.tr()),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
style: ButtonStyle(
|
|
foregroundColor: WidgetStateProperty.all(context.themeData.colorScheme.onSurfaceVariant),
|
|
),
|
|
child: Text('cancel'.tr()),
|
|
),
|
|
TextButton(onPressed: () => Navigator.of(context).pop(true), child: Text('confirm'.tr())),
|
|
],
|
|
),
|
|
) ??
|
|
false;
|
|
}
|
|
|
|
Future<void> _handleClose() async {
|
|
if (hasUnsavedChanges) {
|
|
final shouldDiscard = await _showDiscardChangesDialog();
|
|
if (shouldDiscard && mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
} else {
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return PopScope(
|
|
canPop: !hasUnsavedChanges,
|
|
onPopInvokedWithResult: (didPop, result) async {
|
|
if (didPop) return;
|
|
final shouldDiscard = await _showDiscardChangesDialog();
|
|
if (shouldDiscard && mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
},
|
|
child: Theme(
|
|
data: getThemeData(colorScheme: ref.watch(immichThemeProvider).dark, locale: context.locale),
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.black,
|
|
title: Text("edit".tr()),
|
|
leading: ImmichCloseButton(onPressed: _handleClose),
|
|
actions: [
|
|
_isApplyingEdits
|
|
? const Padding(
|
|
padding: EdgeInsets.all(8.0),
|
|
child: SizedBox(width: 28, height: 28, child: CircularProgressIndicator(strokeWidth: 2.5)),
|
|
)
|
|
: ImmichIconButton(
|
|
icon: Icons.done_rounded,
|
|
color: ImmichColor.primary,
|
|
variant: ImmichVariant.ghost,
|
|
onPressed: _saveEditedImage,
|
|
),
|
|
],
|
|
),
|
|
backgroundColor: Colors.black,
|
|
body: SafeArea(
|
|
bottom: true,
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
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.95;
|
|
|
|
return Center(
|
|
child: AnimatedRotation(
|
|
turns: _rotationAngle / 360,
|
|
duration: _rotationAnimationDuration,
|
|
curve: Curves.easeInOut,
|
|
child: Transform(
|
|
alignment: Alignment.center,
|
|
transform: Matrix4.identity()
|
|
..scaleByDouble(_flipHorizontal ? -1.0 : 1.0, _flipVertical ? -1.0 : 1.0, 1.0, 1.0),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(10),
|
|
width: (_rotationAngle % 180 == 0) ? baseWidth : baseHeight,
|
|
height: (_rotationAngle % 180 == 0) ? baseHeight : baseWidth,
|
|
child: FutureBuilder(
|
|
future: resolveImage(widget.image.image),
|
|
builder: (context, data) {
|
|
if (!data.hasData) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
return CropImage(
|
|
controller: cropController,
|
|
image: widget.image,
|
|
gridColor: Colors.white,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
AnimatedSize(
|
|
duration: const Duration(milliseconds: 250),
|
|
curve: Curves.easeInOut,
|
|
alignment: Alignment.bottomCenter,
|
|
clipBehavior: Clip.none,
|
|
child: Container(
|
|
width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
color: ref.watch(immichThemeProvider).dark.surface,
|
|
borderRadius: const BorderRadius.only(
|
|
topLeft: Radius.circular(20),
|
|
topRight: Radius.circular(20),
|
|
),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
AnimatedCrossFade(
|
|
duration: const Duration(milliseconds: 250),
|
|
firstCurve: Curves.easeInOut,
|
|
secondCurve: Curves.easeInOut,
|
|
sizeCurve: Curves.easeInOut,
|
|
crossFadeState: _selectedSegment == 'transform'
|
|
? CrossFadeState.showFirst
|
|
: CrossFadeState.showSecond,
|
|
firstChild: _TransformControls(
|
|
onRotateLeft: _rotateLeft,
|
|
onRotateRight: _rotateRight,
|
|
onFlipHorizontal: _flipHorizontally,
|
|
onFlipVertical: _flipVertically,
|
|
onAspectRatioSelected: _applyAspectRatio,
|
|
aspectRatio: _aspectRatio,
|
|
),
|
|
// this will never show since the segmented button is not shown yet
|
|
secondChild: const Text("Filters coming soon!"),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 36, left: 24, right: 24),
|
|
child: Row(
|
|
children: [
|
|
// SegmentedButton(
|
|
// segments: [
|
|
// const ButtonSegment<String>(
|
|
// value: 'transform',
|
|
// label: Text('Transform'),
|
|
// icon: Icon(Icons.transform),
|
|
// ),
|
|
// const ButtonSegment<String>(
|
|
// value: 'filters',
|
|
// label: Text('Filters'),
|
|
// icon: Icon(Icons.color_lens),
|
|
// ),
|
|
// ],
|
|
// selected: {selectedSegment},
|
|
// onSelectionChanged: (value) => setState(() {
|
|
// selectedSegment = value.first;
|
|
// }),
|
|
// showSelectedIcon: false,
|
|
// ),
|
|
const Spacer(),
|
|
ImmichTextButton(
|
|
labelText: 'reset'.tr(),
|
|
onPressed: _resetEdits,
|
|
variant: ImmichVariant.ghost,
|
|
expanded: false,
|
|
disabled: !hasEdits,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AspectRatioButton extends StatelessWidget {
|
|
final double? currentAspectRatio;
|
|
final double? ratio;
|
|
final String label;
|
|
final VoidCallback onPressed;
|
|
|
|
const _AspectRatioButton({
|
|
required this.currentAspectRatio,
|
|
required this.ratio,
|
|
required this.label,
|
|
required this.onPressed,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.max,
|
|
children: [
|
|
IconButton(
|
|
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),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AspectRatioSelector extends StatelessWidget {
|
|
final double? currentAspectRatio;
|
|
final void Function(double?) onAspectRatioSelected;
|
|
|
|
const _AspectRatioSelector({required this.currentAspectRatio, required this.onAspectRatioSelected});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final aspectRatios = <String, double?>{
|
|
'Free': null,
|
|
'1:1': 1.0,
|
|
'16:9': 16 / 9,
|
|
'3:2': 3 / 2,
|
|
'7:5': 7 / 5,
|
|
'9:16': 9 / 16,
|
|
'2:3': 2 / 3,
|
|
'5:7': 5 / 7,
|
|
};
|
|
|
|
return SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: aspectRatios.entries.map((entry) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
|
child: _AspectRatioButton(
|
|
currentAspectRatio: currentAspectRatio,
|
|
ratio: entry.value,
|
|
label: entry.key,
|
|
onPressed: () => onAspectRatioSelected(entry.value),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TransformControls extends StatelessWidget {
|
|
final VoidCallback onRotateLeft;
|
|
final VoidCallback onRotateRight;
|
|
final VoidCallback onFlipHorizontal;
|
|
final VoidCallback onFlipVertical;
|
|
final void Function(double?) onAspectRatioSelected;
|
|
final double? aspectRatio;
|
|
|
|
const _TransformControls({
|
|
required this.onRotateLeft,
|
|
required this.onRotateRight,
|
|
required this.onFlipHorizontal,
|
|
required this.onFlipVertical,
|
|
required this.onAspectRatioSelected,
|
|
required this.aspectRatio,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 20, right: 20, top: 20, bottom: 10),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
ImmichIconButton(
|
|
icon: Icons.rotate_left,
|
|
variant: ImmichVariant.ghost,
|
|
color: ImmichColor.secondary,
|
|
onPressed: onRotateLeft,
|
|
),
|
|
const SizedBox(width: 8),
|
|
ImmichIconButton(
|
|
icon: Icons.rotate_right,
|
|
variant: ImmichVariant.ghost,
|
|
color: ImmichColor.secondary,
|
|
onPressed: onRotateRight,
|
|
),
|
|
],
|
|
),
|
|
Row(
|
|
children: [
|
|
ImmichIconButton(
|
|
icon: Icons.flip,
|
|
variant: ImmichVariant.ghost,
|
|
color: ImmichColor.secondary,
|
|
onPressed: onFlipHorizontal,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Transform.rotate(
|
|
angle: pi / 2,
|
|
child: ImmichIconButton(
|
|
icon: Icons.flip,
|
|
variant: ImmichVariant.ghost,
|
|
color: ImmichColor.secondary,
|
|
onPressed: onFlipVertical,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
_AspectRatioSelector(currentAspectRatio: aspectRatio, onAspectRatioSelected: onAspectRatioSelected),
|
|
const SizedBox(height: 32),
|
|
],
|
|
);
|
|
}
|
|
}
|