diff --git a/mobile/lib/presentation/pages/drift_edit.page.dart b/mobile/lib/presentation/pages/drift_edit.page.dart index 501ab0ca5e..75e957c5d6 100644 --- a/mobile/lib/presentation/pages/drift_edit.page.dart +++ b/mobile/lib/presentation/pages/drift_edit.page.dart @@ -8,6 +8,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/constants/filters.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'; @@ -48,14 +49,16 @@ class _DriftEditImagePageState extends ConsumerState with Ti int _rotationAngle = 0; bool _flipHorizontal = false; bool _flipVertical = false; - - double? aspectRatio; + ColorFilter? _colorFilter; + 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; + String selectedSegment = 'transform'; + void initEditor() { final existingCrop = widget.edits.firstWhereOrNull((edit) => edit.action == AssetEditAction.crop); @@ -198,6 +201,41 @@ class _DriftEditImagePageState extends ConsumerState with Ti }); } + void _applyAspectRatio(double? ratio) { + setState(() { + cropController.aspectRatio = ratio; + _aspectRatio = ratio; + }); + } + + void _applyFilter(ColorFilter? filter) { + setState(() { + _colorFilter = filter; + }); + } + + 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; + _colorFilter = null; + _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; + final isFiltered = _colorFilter != null; + + return isCropped || isRotated || isFlipped || isFiltered; + } + @override Widget build(BuildContext context) { return Theme( @@ -221,18 +259,16 @@ class _DriftEditImagePageState extends ConsumerState with Ti 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; + 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( + return Center( child: AnimatedRotation( turns: _rotationAngle / 360, duration: _rotationAnimationDuration, @@ -245,185 +281,103 @@ class _DriftEditImagePageState extends ConsumerState with Ti 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), + 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, + overlayPainter: MatrixAdjustmentPainter(image: data.data!, filter: _colorFilter), + ); + }, + ), ), ), ), - ), + ); + }, + ), + ), + 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, + ), + secondChild: _FilterControls( + filter: _colorFilter, + previewImage: widget.image, + onApplyFilter: _applyFilter, ), ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + Padding( + padding: const EdgeInsets.only(bottom: 36, left: 24, right: 24), + child: Row( 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, - ), - ), - ], - ), - ], - ), - ), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - spacing: 12, - children: [ - _AspectRatioButton( - cropController: cropController, - currentAspectRatio: aspectRatio, - ratio: null, - label: 'Free', - onPressed: () { - setState(() { - aspectRatio = null; - cropController.aspectRatio = null; - }); - }, - ), - _AspectRatioButton( - cropController: cropController, - currentAspectRatio: aspectRatio, - ratio: 1.0, - label: '1:1', - onPressed: () { - setState(() { - aspectRatio = 1.0; - cropController.aspectRatio = 1.0; - }); - }, - ), - _AspectRatioButton( - cropController: cropController, - currentAspectRatio: aspectRatio, - ratio: 16.0 / 9.0, - label: '16:9', - onPressed: () { - setState(() { - aspectRatio = 16.0 / 9.0; - cropController.aspectRatio = 16.0 / 9.0; - }); - }, - ), - _AspectRatioButton( - cropController: cropController, - currentAspectRatio: aspectRatio, - ratio: 3.0 / 2.0, - label: '3:2', - onPressed: () { - setState(() { - aspectRatio = 3.0 / 2.0; - cropController.aspectRatio = 3.0 / 2.0; - }); - }, - ), - _AspectRatioButton( - cropController: cropController, - currentAspectRatio: aspectRatio, - ratio: 7.0 / 5.0, - label: '7:5', - onPressed: () { - setState(() { - aspectRatio = 7.0 / 5.0; - cropController.aspectRatio = 7.0 / 5.0; - }); - }, - ), - _AspectRatioButton( - cropController: cropController, - currentAspectRatio: aspectRatio, - ratio: 9.0 / 16.0, - label: '9:16', - onPressed: () { - setState(() { - aspectRatio = 9.0 / 16.0; - cropController.aspectRatio = 9.0 / 16.0; - }); - }, - ), - _AspectRatioButton( - cropController: cropController, - currentAspectRatio: aspectRatio, - ratio: 2.0 / 3.0, - label: '2:3', - onPressed: () { - setState(() { - aspectRatio = 2.0 / 3.0; - cropController.aspectRatio = 2.0 / 3.0; - }); - }, - ), - _AspectRatioButton( - cropController: cropController, - currentAspectRatio: aspectRatio, - ratio: 5.0 / 7.0, - label: '5:7', - onPressed: () { - setState(() { - aspectRatio = 5.0 / 7.0; - cropController.aspectRatio = 5.0 / 7.0; - }); - }, - ), - ], - ), + 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", + onPressed: _resetEdits, + variant: ImmichVariant.filled, + expanded: false, + disabled: !hasEdits, + ), ], ), ), - ), + ], ), - ], - ); - }, + ), + ), + ], ), ), ), @@ -432,14 +386,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, @@ -474,3 +426,194 @@ class _AspectRatioButton extends StatelessWidget { ); } } + +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), + ], + ); + } +} + +class _FilterControls extends StatelessWidget { + final ColorFilter? filter; + final Image previewImage; + final void Function(ColorFilter?) onApplyFilter; + + const _FilterControls({required this.filter, required this.previewImage, required this.onApplyFilter}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 24), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: filters.mapIndexed((i, filter) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: _FilterButton( + image: previewImage, + label: filterNames[i], + filter: filter, + isSelected: filter == filters[i], + onTap: () => onApplyFilter(filter), + ), + ); + }).toList(), + ), + ), + ), + ); + } +} + +class _FilterButton extends StatelessWidget { + final Image image; + final String label; + final ColorFilter filter; + final bool isSelected; + final VoidCallback onTap; + + const _FilterButton({ + required this.image, + required this.label, + required this.filter, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + GestureDetector( + onTap: onTap, + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(10)), + border: isSelected ? Border.all(color: context.primaryColor, width: 3) : null, + ), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(10)), + child: ColorFiltered( + colorFilter: filter, + child: Image(image: image.image, fit: BoxFit.cover), + ), + ), + ), + ), + const SizedBox(height: 10), + Text(label, style: context.themeData.textTheme.bodyMedium), + ], + ); + } +} diff --git a/mobile/lib/utils/editor.utils.dart b/mobile/lib/utils/editor.utils.dart index e660e5adbc..c4b4bd148c 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; @@ -73,3 +75,50 @@ AffineMatrix buildAffineFromEdits(List edits) { return (rotation, flipX, flipY); } + +class MatrixAdjustmentPainter extends CustomPainter { + final ui.Image image; + final ColorFilter? filter; + + const MatrixAdjustmentPainter({required this.image, this.filter}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..colorFilter = filter; + + final srcRect = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()); + final dstRect = Rect.fromLTWH(0, 0, size.width, size.height); + + canvas.drawImageRect(image, srcRect, dstRect, paint); + } + + @override + bool shouldRepaint(covariant MatrixAdjustmentPainter oldDelegate) { + return oldDelegate.image != image || oldDelegate.filter != filter; + } +} + +/// 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; +}