chore: migrate some changes from the filtering PR

This commit is contained in:
bwees 2026-03-09 15:21:58 -05:00
parent 8356d50881
commit f257de629d
No known key found for this signature in database
2 changed files with 362 additions and 142 deletions

View File

@ -48,15 +48,17 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
int _rotationAngle = 0;
bool _flipHorizontal = false;
bool _flipVertical = false;
double? aspectRatio;
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 isEditing = false;
bool _isApplyingEdits = false;
bool _hasSheetChanges = false;
late final Rect _initialCrop;
final String _selectedSegment = 'transform';
List<AspectRatio> aspectRatios = const [
List<AspectRatio> aspectRatios = [
(ratio: null, label: 'Free'),
(ratio: 1.0, label: '1:1'),
(ratio: 16.0 / 9.0, label: '16:9'),
@ -88,11 +90,18 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
_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(() {
isEditing = true;
_isApplyingEdits = true;
});
final cropParameters = convertRectToCropParameters(cropController.crop, originalWidth ?? 0, originalHeight ?? 0);
@ -133,7 +142,7 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
await widget.applyEdits(edits);
setState(() {
isEditing = false;
_isApplyingEdits = false;
});
}
@ -153,6 +162,7 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
setState(() {
_rotationAnimationDuration = const Duration(milliseconds: 150);
_rotationAngle -= 90;
_hasSheetChanges = true;
});
}
@ -160,6 +170,7 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
setState(() {
_rotationAnimationDuration = const Duration(milliseconds: 150);
_rotationAngle += 90;
_hasSheetChanges = true;
});
}
@ -171,6 +182,7 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
} else {
_flipHorizontal = !_flipHorizontal;
}
_hasSheetChanges = true;
});
}
@ -182,154 +194,225 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
} 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), child: Text('cancel'.tr())),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text('editor_discard_edits_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 Theme(
data: getThemeData(colorScheme: ref.watch(immichThemeProvider).dark, locale: context.locale),
child: Scaffold(
appBar: AppBar(
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,
title: Text("edit".tr()),
leading: const ImmichCloseButton(),
actions: [
isEditing
? 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: false,
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.8;
body: SafeArea(
bottom: false,
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 Column(
children: [
SizedBox(
width: constraints.maxWidth,
height: constraints.maxHeight * 0.7,
child: 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: CropImage(controller: cropController, image: widget.image, gridColor: Colors.white),
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),
),
),
),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
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!"),
),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
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: _rotateLeft,
),
const SizedBox(width: 8),
ImmichIconButton(
icon: Icons.rotate_right,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: _rotateRight,
),
],
),
Row(
children: [
ImmichIconButton(
icon: Icons.flip,
variant: ImmichVariant.ghost,
color: _flipHorizontal ? ImmichColor.primary : ImmichColor.secondary,
onPressed: _flipHorizontally,
),
const SizedBox(width: 8),
Transform.rotate(
angle: pi / 2,
child: ImmichIconButton(
icon: Icons.flip,
variant: ImmichVariant.ghost,
color: _flipVertical ? ImmichColor.primary : ImmichColor.secondary,
onPressed: _flipVertically,
),
),
],
),
],
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,
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
spacing: 12,
children: aspectRatios.map((aspect) {
return _AspectRatioButton(
cropController: cropController,
currentAspectRatio: aspectRatio,
ratio: aspect.ratio,
label: aspect.label,
onPressed: () {
setState(() {
aspectRatio = aspect.ratio;
cropController.aspectRatio = aspect.ratio;
});
},
);
}).toList(),
),
),
const Spacer(),
],
],
),
),
),
],
),
),
],
);
},
),
],
),
),
),
),
@ -338,14 +421,12 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
}
class _AspectRatioButton extends StatelessWidget {
final CropController cropController;
final double? currentAspectRatio;
final double? ratio;
final String label;
final VoidCallback onPressed;
const _AspectRatioButton({
required this.cropController,
required this.currentAspectRatio,
required this.ratio,
required this.label,
@ -375,7 +456,119 @@ class _AspectRatioButton extends StatelessWidget {
),
onPressed: onPressed,
),
Text(label, style: context.textTheme.displayMedium),
Text(label.tr(), 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),
],
);
}

View File

@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/utils/matrix.utils.dart';
import 'package:openapi/api.dart' hide AssetEditAction;
@ -68,3 +70,28 @@ NormalizedTransform normalizeTransformEdits(List<AssetEdit> edits) {
mirrorVertical: isCloseToZero(a) ? b == c : a == -d,
);
}
/// Helper to resolve an ImageProvider to a ui.Image
Future<ui.Image> resolveImage(ImageProvider provider) {
final completer = Completer<ui.Image>();
final stream = provider.resolve(const ImageConfiguration());
late final ImageStreamListener listener;
listener = ImageStreamListener(
(ImageInfo info, bool sync) {
if (!completer.isCompleted) {
completer.complete(info.image);
}
stream.removeListener(listener);
},
onError: (error, stackTrace) {
if (!completer.isCompleted) {
completer.completeError(error, stackTrace);
}
stream.removeListener(listener);
},
);
stream.addListener(listener);
return completer.future;
}