diff --git a/mobile/lib/presentation/pages/drift_edit.page.dart b/mobile/lib/presentation/pages/drift_edit.page.dart index 4ee2b57657..361bd9372d 100644 --- a/mobile/lib/presentation/pages/drift_edit.page.dart +++ b/mobile/lib/presentation/pages/drift_edit.page.dart @@ -48,15 +48,17 @@ class _DriftEditImagePageState extends ConsumerState 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 aspectRatios = const [ + List 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 with Ti _flipHorizontal = transform.mirrorHorizontal; _flipVertical = transform.mirrorVertical; + + _initialCrop = cropController.crop; + } + + bool get hasUnsavedChanges { + final isCropChanged = cropController.crop != _initialCrop; + return isCropChanged || _hasSheetChanges; } Future _saveEditedImage() async { setState(() { - isEditing = true; + _isApplyingEdits = true; }); final cropParameters = convertRectToCropParameters(cropController.crop, originalWidth ?? 0, originalHeight ?? 0); @@ -133,7 +142,7 @@ class _DriftEditImagePageState extends ConsumerState with Ti await widget.applyEdits(edits); setState(() { - isEditing = false; + _isApplyingEdits = false; }); } @@ -153,6 +162,7 @@ class _DriftEditImagePageState extends ConsumerState with Ti setState(() { _rotationAnimationDuration = const Duration(milliseconds: 150); _rotationAngle -= 90; + _hasSheetChanges = true; }); } @@ -160,6 +170,7 @@ class _DriftEditImagePageState extends ConsumerState with Ti setState(() { _rotationAnimationDuration = const Duration(milliseconds: 150); _rotationAngle += 90; + _hasSheetChanges = true; }); } @@ -171,6 +182,7 @@ class _DriftEditImagePageState extends ConsumerState with Ti } else { _flipHorizontal = !_flipHorizontal; } + _hasSheetChanges = true; }); } @@ -182,154 +194,225 @@ class _DriftEditImagePageState extends ConsumerState 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 _showDiscardChangesDialog() async { + return await showDialog( + 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 _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( + // value: 'transform', + // label: Text('Transform'), + // icon: Icon(Icons.transform), + // ), + // const ButtonSegment( + // 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 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 = { + '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), ], ); } diff --git a/mobile/lib/utils/editor.utils.dart b/mobile/lib/utils/editor.utils.dart index 35bb800165..08a2c2d518 100644 --- a/mobile/lib/utils/editor.utils.dart +++ b/mobile/lib/utils/editor.utils.dart @@ -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 edits) { mirrorVertical: isCloseToZero(a) ? b == c : a == -d, ); } + +/// Helper to resolve an ImageProvider to a ui.Image +Future resolveImage(ImageProvider provider) { + final completer = Completer(); + 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; +}