mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat: mobile editing (#25397)
* feat: mobile editing fix: openapi patch this sucks :pepehands: chore: migrate some changes from the filtering PR chore: color tweak fix: hide edit button on server versions chore: translation * chore: code review changes chore: code review * sealed class * const constant * enum * concurrent queries * chore: major cleanup to use riverpod provider * fix: aspect ratio selection * chore: typesafe websocket event parsing * fix: wrong disable state for save button * chore: remove isCancelled shim * chore: cleanup postframe callback usage * chore: clean import --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
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: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/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/pages/edit/editor.provider.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_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:openapi/api.dart' show RotateParameters, MirrorParameters, MirrorAxis;
|
||||
|
||||
@RoutePage()
|
||||
class DriftEditImagePage extends ConsumerStatefulWidget {
|
||||
final Image image;
|
||||
final Future<void> Function(List<AssetEdit> edits) applyEdits;
|
||||
|
||||
const DriftEditImagePage({super.key, required this.image, required this.applyEdits});
|
||||
|
||||
@override
|
||||
ConsumerState<DriftEditImagePage> createState() => _DriftEditImagePageState();
|
||||
}
|
||||
|
||||
class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with TickerProviderStateMixin {
|
||||
Future<void> _saveEditedImage() async {
|
||||
ref.read(editorStateProvider.notifier).setIsEditing(true);
|
||||
|
||||
final editorState = ref.read(editorStateProvider);
|
||||
final cropParameters = convertRectToCropParameters(
|
||||
editorState.crop,
|
||||
editorState.originalWidth,
|
||||
editorState.originalHeight,
|
||||
);
|
||||
final edits = <AssetEdit>[];
|
||||
|
||||
if (cropParameters.width != editorState.originalWidth || cropParameters.height != editorState.originalHeight) {
|
||||
edits.add(CropEdit(cropParameters));
|
||||
}
|
||||
|
||||
if (editorState.flipHorizontal) {
|
||||
edits.add(MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)));
|
||||
}
|
||||
|
||||
if (editorState.flipVertical) {
|
||||
edits.add(MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)));
|
||||
}
|
||||
|
||||
final normalizedRotation = (editorState.rotationAngle % 360 + 360) % 360;
|
||||
if (normalizedRotation != 0) {
|
||||
edits.add(RotateEdit(RotateParameters(angle: normalizedRotation)));
|
||||
}
|
||||
|
||||
try {
|
||||
await widget.applyEdits(edits);
|
||||
ImmichToast.show(context: context, msg: 'success'.tr(), toastType: ToastType.success);
|
||||
Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
ImmichToast.show(context: context, msg: 'error_title'.tr(), toastType: ToastType.error);
|
||||
} finally {
|
||||
ref.read(editorStateProvider.notifier).setIsEditing(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool?> _showDiscardChangesDialog() {
|
||||
return 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())),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasUnsavedEdits = ref.watch(editorStateProvider.select((state) => state.hasUnsavedEdits));
|
||||
|
||||
return PopScope(
|
||||
canPop: !hasUnsavedEdits,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
final shouldDiscard = await _showDiscardChangesDialog() ?? false;
|
||||
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: () => Navigator.of(context).maybePop()),
|
||||
actions: [_SaveEditsButton(onSave: _saveEditedImage)],
|
||||
),
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(child: _EditorPreview(image: widget.image)),
|
||||
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: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_TransformControls(),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 36, left: 24, right: 24),
|
||||
child: Row(children: [Spacer(), _ResetEditsButton()]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AspectRatioButton extends StatelessWidget {
|
||||
final AspectRatioPreset ratio;
|
||||
final bool isSelected;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _AspectRatioButton({required this.ratio, required this.isSelected, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
Text(ratio.label, style: context.textTheme.displayMedium),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AspectRatioSelector extends ConsumerWidget {
|
||||
const _AspectRatioSelector();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
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) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: _AspectRatioButton(
|
||||
ratio: entry,
|
||||
isSelected: selectedAspectRatio == entry.ratio,
|
||||
onPressed: () => editorNotifier.setAspectRatio(entry.ratio),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TransformControls extends ConsumerWidget {
|
||||
const _TransformControls();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final editorNotifier = ref.read(editorStateProvider.notifier);
|
||||
|
||||
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: editorNotifier.rotateCCW,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ImmichIconButton(
|
||||
icon: Icons.rotate_right,
|
||||
variant: ImmichVariant.ghost,
|
||||
color: ImmichColor.secondary,
|
||||
onPressed: editorNotifier.rotateCW,
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
ImmichIconButton(
|
||||
icon: Icons.flip,
|
||||
variant: ImmichVariant.ghost,
|
||||
color: ImmichColor.secondary,
|
||||
onPressed: editorNotifier.flipHorizontally,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Transform.rotate(
|
||||
angle: pi / 2,
|
||||
child: ImmichIconButton(
|
||||
icon: Icons.flip,
|
||||
variant: ImmichVariant.ghost,
|
||||
color: ImmichColor.secondary,
|
||||
onPressed: editorNotifier.flipVertically,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const _AspectRatioSelector(),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SaveEditsButton extends ConsumerWidget {
|
||||
final VoidCallback onSave;
|
||||
|
||||
const _SaveEditsButton({required this.onSave});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isApplyingEdits = ref.watch(editorStateProvider.select((state) => state.isApplyingEdits));
|
||||
final hasUnsavedEdits = ref.watch(editorStateProvider.select((state) => state.hasUnsavedEdits));
|
||||
|
||||
return 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,
|
||||
disabled: !hasUnsavedEdits,
|
||||
onPressed: onSave,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResetEditsButton extends ConsumerWidget {
|
||||
const _ResetEditsButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final editorState = ref.watch(editorStateProvider);
|
||||
final editorNotifier = ref.read(editorStateProvider.notifier);
|
||||
|
||||
return ImmichTextButton(
|
||||
labelText: 'reset'.tr(),
|
||||
onPressed: editorNotifier.resetEdits,
|
||||
variant: ImmichVariant.ghost,
|
||||
expanded: false,
|
||||
disabled: !editorState.hasEdits || editorState.isApplyingEdits,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EditorPreview extends ConsumerStatefulWidget {
|
||||
final Image image;
|
||||
|
||||
const _EditorPreview({required this.image});
|
||||
|
||||
@override
|
||||
ConsumerState<_EditorPreview> createState() => _EditorPreviewState();
|
||||
}
|
||||
|
||||
class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProviderStateMixin {
|
||||
late final CropController cropController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
cropController = CropController();
|
||||
cropController.crop = ref.read(editorStateProvider.select((state) => state.crop));
|
||||
cropController.addListener(onCrop);
|
||||
}
|
||||
|
||||
void onCrop() {
|
||||
if (!mounted || cropController.crop == ref.read(editorStateProvider).crop) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(editorStateProvider.notifier).setCrop(cropController.crop);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
cropController.removeListener(onCrop);
|
||||
cropController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final editorState = ref.watch(editorStateProvider);
|
||||
final editorNotifier = ref.read(editorStateProvider.notifier);
|
||||
|
||||
ref.listen(editorStateProvider, (_, current) {
|
||||
cropController.aspectRatio = current.aspectRatio;
|
||||
|
||||
if (cropController.crop != current.crop) {
|
||||
cropController.crop = current.crop;
|
||||
}
|
||||
});
|
||||
|
||||
return 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: editorState.rotationAngle / 360,
|
||||
duration: editorState.animationDuration,
|
||||
curve: Curves.easeInOut,
|
||||
onEnd: editorNotifier.normalizeRotation,
|
||||
child: Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.identity()
|
||||
..scaleByDouble(
|
||||
editorState.flipHorizontal ? -1.0 : 1.0,
|
||||
editorState.flipVertical ? -1.0 : 1.0,
|
||||
1.0,
|
||||
1.0,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
width: (editorState.rotationAngle % 180 == 0) ? baseWidth : baseHeight,
|
||||
height: (editorState.rotationAngle % 180 == 0) ? baseHeight : baseWidth,
|
||||
child: CropImage(controller: cropController, image: widget.image, gridColor: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user