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:
Brandon Wees
2026-04-15 09:26:40 -05:00
committed by GitHub
parent 8454cb2631
commit 6dd6053222
30 changed files with 1317 additions and 646 deletions
@@ -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),
),
),
),
);
},
);
}
}
@@ -0,0 +1,210 @@
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.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';
final editorStateProvider = NotifierProvider<EditorProvider, EditorState>(EditorProvider.new);
class EditorProvider extends Notifier<EditorState> {
@override
EditorState build() {
return const EditorState();
}
void clear() {
state = const EditorState();
}
void init(List<AssetEdit> edits, ExifInfo exifInfo) {
clear();
final existingCrop = edits.whereType<CropEdit>().firstOrNull;
final originalWidth = exifInfo.isFlipped ? exifInfo.height : exifInfo.width;
final originalHeight = exifInfo.isFlipped ? exifInfo.width : exifInfo.height;
Rect crop = existingCrop != null && originalWidth != null && originalHeight != null
? convertCropParametersToRect(existingCrop.parameters, originalWidth, originalHeight)
: const Rect.fromLTRB(0, 0, 1, 1);
final transform = normalizeTransformEdits(edits);
state = state.copyWith(
originalWidth: originalWidth,
originalHeight: originalHeight,
crop: crop,
flipHorizontal: transform.mirrorHorizontal,
flipVertical: transform.mirrorVertical,
);
_animateRotation(transform.rotation.toInt(), duration: Duration.zero);
}
void _animateRotation(int angle, {Duration duration = const Duration(milliseconds: 300)}) {
state = state.copyWith(rotationAngle: angle, animationDuration: duration);
}
void normalizeRotation() {
final normalizedAngle = ((state.rotationAngle % 360) + 360) % 360;
if (normalizedAngle != state.rotationAngle) {
state = state.copyWith(rotationAngle: normalizedAngle, animationDuration: Duration.zero);
}
}
void setIsEditing(bool isApplyingEdits) {
state = state.copyWith(isApplyingEdits: isApplyingEdits);
}
void setCrop(Rect crop) {
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 resetEdits() {
_animateRotation(0);
state = state.copyWith(
flipHorizontal: false,
flipVertical: false,
crop: const Rect.fromLTRB(0, 0, 1, 1),
aspectRatio: null,
hasUnsavedEdits: true,
);
}
void rotateCCW() {
_animateRotation(state.rotationAngle - 90);
state = state.copyWith(hasUnsavedEdits: true);
}
void rotateCW() {
_animateRotation(state.rotationAngle + 90);
state = state.copyWith(hasUnsavedEdits: true);
}
void flipHorizontally() {
if (state.rotationAngle % 180 != 0) {
// When rotated 90 or 270 degrees, flipping horizontally is equivalent to flipping vertically
state = state.copyWith(flipVertical: !state.flipVertical, hasUnsavedEdits: true);
} else {
state = state.copyWith(flipHorizontal: !state.flipHorizontal, hasUnsavedEdits: true);
}
}
void flipVertically() {
if (state.rotationAngle % 180 != 0) {
// When rotated 90 or 270 degrees, flipping vertically is equivalent to flipping horizontally
state = state.copyWith(flipHorizontal: !state.flipHorizontal, hasUnsavedEdits: true);
} else {
state = state.copyWith(flipVertical: !state.flipVertical, hasUnsavedEdits: true);
}
}
}
class EditorState {
final bool isApplyingEdits;
final int rotationAngle;
final bool flipHorizontal;
final bool flipVertical;
final Rect crop;
final double? aspectRatio;
final int originalWidth;
final int originalHeight;
final Duration animationDuration;
final bool hasUnsavedEdits;
const EditorState({
bool? isApplyingEdits,
int? rotationAngle,
bool? flipHorizontal,
bool? flipVertical,
Rect? crop,
this.aspectRatio,
int? originalWidth,
int? originalHeight,
Duration? animationDuration,
bool? hasUnsavedEdits,
}) : isApplyingEdits = isApplyingEdits ?? false,
rotationAngle = rotationAngle ?? 0,
flipHorizontal = flipHorizontal ?? false,
flipVertical = flipVertical ?? false,
animationDuration = animationDuration ?? Duration.zero,
originalWidth = originalWidth ?? 0,
originalHeight = originalHeight ?? 0,
crop = crop ?? const Rect.fromLTRB(0, 0, 1, 1),
hasUnsavedEdits = hasUnsavedEdits ?? false;
EditorState copyWith({
bool? isApplyingEdits,
int? rotationAngle,
bool? flipHorizontal,
bool? flipVertical,
double? aspectRatio = double.infinity,
int? originalWidth,
int? originalHeight,
Duration? animationDuration,
Rect? crop,
bool? hasUnsavedEdits,
}) {
return EditorState(
isApplyingEdits: isApplyingEdits ?? this.isApplyingEdits,
rotationAngle: rotationAngle ?? this.rotationAngle,
flipHorizontal: flipHorizontal ?? this.flipHorizontal,
flipVertical: flipVertical ?? this.flipVertical,
aspectRatio: aspectRatio == double.infinity ? this.aspectRatio : aspectRatio,
animationDuration: animationDuration ?? this.animationDuration,
originalWidth: originalWidth ?? this.originalWidth,
originalHeight: originalHeight ?? this.originalHeight,
crop: crop ?? this.crop,
hasUnsavedEdits: hasUnsavedEdits ?? this.hasUnsavedEdits,
);
}
bool get hasEdits {
return rotationAngle != 0 || flipHorizontal || flipVertical || crop != const Rect.fromLTRB(0, 0, 1, 1);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is EditorState &&
other.isApplyingEdits == isApplyingEdits &&
other.rotationAngle == rotationAngle &&
other.flipHorizontal == flipHorizontal &&
other.flipVertical == flipVertical &&
other.crop == crop &&
other.aspectRatio == aspectRatio &&
other.originalWidth == originalWidth &&
other.originalHeight == originalHeight &&
other.animationDuration == animationDuration &&
other.hasUnsavedEdits == hasUnsavedEdits;
}
@override
int get hashCode {
return isApplyingEdits.hashCode ^
rotationAngle.hashCode ^
flipHorizontal.hashCode ^
flipVertical.hashCode ^
crop.hashCode ^
aspectRatio.hashCode ^
originalWidth.hashCode ^
originalHeight.hashCode ^
animationDuration.hashCode ^
hasUnsavedEdits.hashCode;
}
}