diff --git a/mobile/apps/photos/assets/image-editor/image-editor-brightness.svg b/mobile/apps/photos/assets/image-editor/image-editor-brightness.svg new file mode 100644 index 0000000000..a468e3b80f --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-brightness.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-contrast.svg b/mobile/apps/photos/assets/image-editor/image-editor-contrast.svg new file mode 100644 index 0000000000..22e2dcaf3f --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-contrast.svg @@ -0,0 +1,3 @@ + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-crop-original.svg b/mobile/apps/photos/assets/image-editor/image-editor-crop-original.svg new file mode 100644 index 0000000000..e2cf9c3492 --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-crop-original.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-crop-rotate.svg b/mobile/apps/photos/assets/image-editor/image-editor-crop-rotate.svg new file mode 100644 index 0000000000..c7f604b8b3 --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-crop-rotate.svg @@ -0,0 +1,4 @@ + + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-crop.svg b/mobile/apps/photos/assets/image-editor/image-editor-crop.svg new file mode 100644 index 0000000000..ccd9d13ccd --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-crop.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-delete.svg b/mobile/apps/photos/assets/image-editor/image-editor-delete.svg new file mode 100644 index 0000000000..392fc4cb52 --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-delete.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-exposure.svg b/mobile/apps/photos/assets/image-editor/image-editor-exposure.svg new file mode 100644 index 0000000000..2d4c5c3f4e --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-exposure.svg @@ -0,0 +1,3 @@ + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-fade.svg b/mobile/apps/photos/assets/image-editor/image-editor-fade.svg new file mode 100644 index 0000000000..9aafd259cf --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-fade.svg @@ -0,0 +1,3 @@ + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-filter.svg b/mobile/apps/photos/assets/image-editor/image-editor-filter.svg new file mode 100644 index 0000000000..639e801937 --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-filter.svg @@ -0,0 +1,3 @@ + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-flip.svg b/mobile/apps/photos/assets/image-editor/image-editor-flip.svg new file mode 100644 index 0000000000..ba76ac2697 --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-flip.svg @@ -0,0 +1,4 @@ + + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-hue.svg b/mobile/apps/photos/assets/image-editor/image-editor-hue.svg new file mode 100644 index 0000000000..96b3df92f3 --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-hue.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-luminance.svg b/mobile/apps/photos/assets/image-editor/image-editor-luminance.svg new file mode 100644 index 0000000000..02ffd024bd --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-luminance.svg @@ -0,0 +1,3 @@ + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-paint.svg b/mobile/apps/photos/assets/image-editor/image-editor-paint.svg new file mode 100644 index 0000000000..1c914cd569 --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-paint.svg @@ -0,0 +1,3 @@ + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-redo.svg b/mobile/apps/photos/assets/image-editor/image-editor-redo.svg new file mode 100644 index 0000000000..8d82ac9ecf --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-redo.svg @@ -0,0 +1,3 @@ + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-saturation.svg b/mobile/apps/photos/assets/image-editor/image-editor-saturation.svg new file mode 100644 index 0000000000..9820828c78 --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-saturation.svg @@ -0,0 +1,4 @@ + + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-sharpness.svg b/mobile/apps/photos/assets/image-editor/image-editor-sharpness.svg new file mode 100644 index 0000000000..976aeec184 --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-sharpness.svg @@ -0,0 +1,3 @@ + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-sticker.svg b/mobile/apps/photos/assets/image-editor/image-editor-sticker.svg new file mode 100644 index 0000000000..cd4cd7e01e --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-sticker.svg @@ -0,0 +1,3 @@ + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-temperature.svg b/mobile/apps/photos/assets/image-editor/image-editor-temperature.svg new file mode 100644 index 0000000000..2616b4a463 --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-temperature.svg @@ -0,0 +1,3 @@ + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-text-align-center.svg b/mobile/apps/photos/assets/image-editor/image-editor-text-align-center.svg new file mode 100644 index 0000000000..755e31b8d0 --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-text-align-center.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-text-align-left.svg b/mobile/apps/photos/assets/image-editor/image-editor-text-align-left.svg new file mode 100644 index 0000000000..931fe8d5cd --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-text-align-left.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-text-align-right.svg b/mobile/apps/photos/assets/image-editor/image-editor-text-align-right.svg new file mode 100644 index 0000000000..5a03574c99 --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-text-align-right.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-text-background.svg b/mobile/apps/photos/assets/image-editor/image-editor-text-background.svg new file mode 100644 index 0000000000..6e9ae615dd --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-text-background.svg @@ -0,0 +1,3 @@ + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-text-color.svg b/mobile/apps/photos/assets/image-editor/image-editor-text-color.svg new file mode 100644 index 0000000000..9f176ab63c --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-text-color.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-text-font.svg b/mobile/apps/photos/assets/image-editor/image-editor-text-font.svg new file mode 100644 index 0000000000..2c248aab4f --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-text-font.svg @@ -0,0 +1,4 @@ + + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-text.svg b/mobile/apps/photos/assets/image-editor/image-editor-text.svg new file mode 100644 index 0000000000..f2b7eb9ff8 --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-text.svg @@ -0,0 +1,4 @@ + + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-tune.svg b/mobile/apps/photos/assets/image-editor/image-editor-tune.svg new file mode 100644 index 0000000000..9de251f75e --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-tune.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/mobile/apps/photos/assets/image-editor/image-editor-undo.svg b/mobile/apps/photos/assets/image-editor/image-editor-undo.svg new file mode 100644 index 0000000000..cb6e99a24d --- /dev/null +++ b/mobile/apps/photos/assets/image-editor/image-editor-undo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/mobile/apps/photos/lib/ente_theme_data.dart b/mobile/apps/photos/lib/ente_theme_data.dart index c7ed51640b..6434e3ffe1 100644 --- a/mobile/apps/photos/lib/ente_theme_data.dart +++ b/mobile/apps/photos/lib/ente_theme_data.dart @@ -233,6 +233,8 @@ extension CustomColorScheme on ColorScheme { ? const Color(0xFF424242) : const Color(0xFFFFFFFF); + Color get imageEditorPrimaryColor => const Color.fromRGBO(8, 194, 37, 1); + Color get defaultBackgroundColor => brightness == Brightness.light ? backgroundBaseLight : backgroundBaseDark; diff --git a/mobile/apps/photos/lib/ui/tools/editor/image_editor/circular_icon_button.dart b/mobile/apps/photos/lib/ui/tools/editor/image_editor/circular_icon_button.dart new file mode 100644 index 0000000000..7a025fe81f --- /dev/null +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/circular_icon_button.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import "package:flutter_svg/svg.dart"; +import "package:photos/ente_theme_data.dart"; +import "package:photos/theme/ente_theme.dart"; + +class CircularIconButton extends StatelessWidget { + final String label; + final VoidCallback onTap; + final String? svgPath; + final double size; + final bool isSelected; + + const CircularIconButton({ + super.key, + required this.label, + required this.onTap, + this.svgPath, + this.size = 60, + this.isSelected = false, + }); + + @override + Widget build(BuildContext context) { + final textTheme = getEnteTextTheme(context); + final colorScheme = getEnteColorScheme(context); + return SizedBox( + width: 90, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onTap: onTap, + child: Container( + height: size, + width: size, + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context) + .colorScheme + .imageEditorPrimaryColor + .withOpacity(0.24) + : colorScheme.backgroundElevated2, + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? Theme.of(context).colorScheme.imageEditorPrimaryColor + : colorScheme.backgroundElevated2, + width: 2, + ), + ), + child: svgPath != null + ? SvgPicture.asset( + svgPath!, + width: 12, + height: 12, + fit: BoxFit.scaleDown, + colorFilter: ColorFilter.mode( + colorScheme.tabIcon, + BlendMode.srcIn, + ), + ) + : const SizedBox(), + ), + ), + const SizedBox(height: 6), + Text( + label, + style: textTheme.small, + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_app_bar.dart b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_app_bar.dart new file mode 100644 index 0000000000..03e84cf75d --- /dev/null +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_app_bar.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import "package:flutter_svg/svg.dart"; +import "package:photos/ente_theme_data.dart"; +import "package:photos/theme/ente_theme.dart"; +import "package:pro_image_editor/models/editor_configs/pro_image_editor_configs.dart"; + +class ImageEditorAppBar extends StatelessWidget implements PreferredSizeWidget { + const ImageEditorAppBar({ + super.key, + required this.configs, + this.undo, + this.redo, + required this.done, + required this.close, + this.enableUndo = false, + this.enableRedo = false, + this.isMainEditor = false, + }); + + final ProImageEditorConfigs configs; + final Function()? undo; + final Function()? redo; + final Function() done; + final Function() close; + final bool enableUndo; + final bool enableRedo; + final bool isMainEditor; + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + return AppBar( + elevation: 0, + automaticallyImplyLeading: false, + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () { + enableUndo ? close() : Navigator.of(context).pop(); + }, + child: Text( + 'Cancel', + style: getEnteTextTheme(context).body, + ), + ), + if (undo != null && redo != null) + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + tooltip: 'Undo', + onPressed: () { + undo != null ? undo!() : null; + }, + icon: SvgPicture.asset( + "assets/image-editor/image-editor-undo.svg", + colorFilter: ColorFilter.mode( + enableUndo ? colorScheme.textBase : colorScheme.textMuted, + BlendMode.srcIn, + ), + ), + ), + const SizedBox(width: 12), + IconButton( + tooltip: 'Redo', + onPressed: () { + redo != null ? redo!() : null; + }, + icon: SvgPicture.asset( + 'assets/image-editor/image-editor-redo.svg', + colorFilter: ColorFilter.mode( + enableRedo ? colorScheme.textBase : colorScheme.textMuted, + BlendMode.srcIn, + ), + ), + ), + ], + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: TextButton( + key: ValueKey(isMainEditor ? 'save_copy' : 'done'), + onPressed: done, + child: Text( + isMainEditor ? 'Save Copy' : 'Done', + style: getEnteTextTheme(context).body.copyWith( + color: isMainEditor + ? (enableUndo + ? Theme.of(context) + .colorScheme + .imageEditorPrimaryColor + : colorScheme.textMuted) + : Theme.of(context) + .colorScheme + .imageEditorPrimaryColor, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_color_picker.dart b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_color_picker.dart new file mode 100644 index 0000000000..b87175d757 --- /dev/null +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_color_picker.dart @@ -0,0 +1,163 @@ +import "package:flutter/material.dart"; +import "package:photos/theme/ente_theme.dart"; + +class ImageEditorColorPicker extends StatefulWidget { + final double value; + final ValueChanged onChanged; + + const ImageEditorColorPicker({ + super.key, + required this.value, + required this.onChanged, + }); + + @override + ColorSliderState createState() => ColorSliderState(); +} + +class ColorSliderState extends State { + Color get _selectedColor { + final hue = widget.value * 360; + return HSVColor.fromAHSV(1.0, hue, 1.0, 1.0).toColor(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: SizedBox( + height: 40, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + height: 36, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(25), + gradient: const LinearGradient( + colors: [ + Color(0xFFFF0000), + Color(0xFFFF8000), + Color(0xFFFFFF00), + Color(0xFF80FF00), + Color(0xFF00FF00), + Color(0xFF00FF80), + Color(0xFF00FFFF), + Color(0xFF0080FF), + Color(0xFF0000FF), + Color(0xFF8000FF), + Color(0xFFFF00FF), + Color(0xFFFF0080), + Color(0xFFFF0000), + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + border: Border.all( + color: colorScheme.backgroundElevated2, + width: 6, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 36, + thumbShape: _CustomThumbShape(_selectedColor), + activeTrackColor: Colors.transparent, + inactiveTrackColor: Colors.transparent, + overlayShape: const RoundSliderOverlayShape(overlayRadius: 0), + trackShape: const _TransparentTrackShape(), + ), + child: Slider( + value: widget.value, + onChanged: widget.onChanged, + min: 0.0, + max: 1.0, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _CustomThumbShape extends SliderComponentShape { + final Color color; + + const _CustomThumbShape(this.color); + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) { + return const Size(28, 28); + } + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation activationAnimation, + required Animation enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + final Canvas canvas = context.canvas; + + final Paint thumbPaint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + canvas.drawCircle(center, 11, thumbPaint); + + final Paint borderPaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3; + + canvas.drawCircle(center, 13, borderPaint); + } +} + +class _TransparentTrackShape extends SliderTrackShape { + const _TransparentTrackShape(); + + @override + Rect getPreferredRect({ + required RenderBox parentBox, + Offset offset = Offset.zero, + required SliderThemeData sliderTheme, + bool isEnabled = false, + bool isDiscrete = false, + }) { + final double trackHeight = sliderTheme.trackHeight!; + final double trackLeft = offset.dx; + final double trackTop = + offset.dy + (parentBox.size.height - trackHeight) / 2; + final double trackWidth = parentBox.size.width; + return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight); + } + + @override + void paint( + PaintingContext context, + Offset offset, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation enableAnimation, + required TextDirection textDirection, + required Offset thumbCenter, + Offset? secondaryOffset, + bool isDiscrete = false, + bool isEnabled = false, + }) {} +} diff --git a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_configs_mixin.dart b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_configs_mixin.dart new file mode 100644 index 0000000000..dd0a5c9c42 --- /dev/null +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_configs_mixin.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import "package:pro_image_editor/mixins/converted_configs.dart"; +import "package:pro_image_editor/models/editor_callbacks/pro_image_editor_callbacks.dart"; +import "package:pro_image_editor/models/editor_configs/pro_image_editor_configs.dart"; + +/// A mixin providing access to simple editor configurations. +mixin SimpleConfigsAccess on StatefulWidget { + ProImageEditorConfigs get configs; + ProImageEditorCallbacks get callbacks; +} + +mixin SimpleConfigsAccessState + on State, ImageEditorConvertedConfigs { + SimpleConfigsAccess get _widget => (widget as SimpleConfigsAccess); + + @override + ProImageEditorConfigs get configs => _widget.configs; + + ProImageEditorCallbacks get callbacks => _widget.callbacks; +} diff --git a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_constants.dart b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_constants.dart new file mode 100644 index 0000000000..c798b278b4 --- /dev/null +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_constants.dart @@ -0,0 +1,8 @@ +/// The duration used for fade-in animations. +const fadeInDuration = Duration(milliseconds: 220); + +/// The stagger delay between multiple fade-in animations +const fadeInDelay = Duration(milliseconds: 25); + +/// The height of the sub-bar. +const editorBottomBarHeight = 180.0; diff --git a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_crop_rotate.dart b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_crop_rotate.dart new file mode 100644 index 0000000000..a222ed92e4 --- /dev/null +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_crop_rotate.dart @@ -0,0 +1,226 @@ +import 'package:flutter/material.dart'; +import "package:flutter_svg/svg.dart"; +import "package:photos/theme/ente_theme.dart"; +import "package:photos/ui/tools/editor/image_editor/circular_icon_button.dart"; +import "package:photos/ui/tools/editor/image_editor/image_editor_configs_mixin.dart"; +import "package:photos/ui/tools/editor/image_editor/image_editor_constants.dart"; +import "package:pro_image_editor/mixins/converted_configs.dart"; +import "package:pro_image_editor/models/editor_callbacks/pro_image_editor_callbacks.dart"; +import "package:pro_image_editor/models/editor_configs/pro_image_editor_configs.dart"; +import "package:pro_image_editor/modules/crop_rotate_editor/crop_rotate_editor.dart"; +import "package:pro_image_editor/widgets/animated/fade_in_up.dart"; + +enum CropAspectRatioType { + original( + label: "Original", + ratio: null, + svg: "assets/image-editor/image-editor-crop-original.svg", + ), + free( + label: "Free", + ratio: null, + svg: "assets/video-editor/video-crop-free-action.svg", + ), + square( + label: "1:1", + ratio: 1.0, + svg: "assets/video-editor/video-crop-ratio_1_1-action.svg", + ), + widescreen( + label: "16:9", + ratio: 16.0 / 9.0, + svg: "assets/video-editor/video-crop-ratio_16_9-action.svg", + ), + portrait( + label: "9:16", + ratio: 9.0 / 16.0, + svg: "assets/video-editor/video-crop-ratio_9_16-action.svg", + ), + photo( + label: "4:3", + ratio: 4.0 / 3.0, + svg: "assets/video-editor/video-crop-ratio_4_3-action.svg", + ), + photo_3_4( + label: "3:4", + ratio: 3.0 / 4.0, + svg: "assets/video-editor/video-crop-ratio_3_4-action.svg", + ); + + const CropAspectRatioType({ + required this.label, + required this.ratio, + required this.svg, + }); + + final String label; + final String svg; + final double? ratio; +} + +class ImageEditorCropRotateBar extends StatefulWidget with SimpleConfigsAccess { + const ImageEditorCropRotateBar({ + super.key, + required this.configs, + required this.callbacks, + required this.editor, + }); + final CropRotateEditorState editor; + + @override + final ProImageEditorConfigs configs; + + @override + final ProImageEditorCallbacks callbacks; + + @override + State createState() => + _ImageEditorCropRotateBarState(); +} + +class _ImageEditorCropRotateBarState extends State + with ImageEditorConvertedConfigs, SimpleConfigsAccessState { + CropAspectRatioType selectedAspectRatio = CropAspectRatioType.original; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildFunctions(constraints), + ], + ); + }, + ); + } + + Widget _buildFunctions(BoxConstraints constraints) { + return BottomAppBar( + color: getEnteColorScheme(context).backgroundBase, + padding: EdgeInsets.zero, + height: editorBottomBarHeight, + child: Align( + alignment: Alignment.bottomCenter, + child: FadeInUp( + duration: fadeInDuration, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularIconButton( + svgPath: "assets/image-editor/image-editor-crop-rotate.svg", + label: "Rotate", + onTap: () { + widget.editor.rotate(); + }, + ), + const SizedBox(width: 6), + CircularIconButton( + svgPath: "assets/image-editor/image-editor-flip.svg", + label: "Flip", + onTap: () { + widget.editor.flip(); + }, + ), + ], + ), + SizedBox( + height: 40, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: CropAspectRatioType.values.length, + itemBuilder: (context, index) { + final aspectRatio = CropAspectRatioType.values[index]; + final isSelected = selectedAspectRatio == aspectRatio; + return Padding( + padding: const EdgeInsets.only( + left: 6.0, + right: 6.0, + ), + child: CropAspectChip( + label: aspectRatio.label, + svg: aspectRatio.svg, + isSelected: isSelected, + onTap: () { + setState(() { + selectedAspectRatio = aspectRatio; + }); + widget.editor + .updateAspectRatio(aspectRatio.ratio ?? -1); + }, + ), + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} + +class CropAspectChip extends StatelessWidget { + final String? label; + final IconData? icon; + final String? svg; + final bool isSelected; + final VoidCallback? onTap; + + const CropAspectChip({ + super.key, + this.label, + this.icon, + this.svg, + required this.isSelected, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + return GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.fillBasePressed + : colorScheme.backgroundElevated2, + borderRadius: BorderRadius.circular(25), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (svg != null) ...[ + SvgPicture.asset( + svg!, + height: 32, + colorFilter: ColorFilter.mode( + isSelected ? colorScheme.backdropBase : colorScheme.tabIcon, + BlendMode.srcIn, + ), + ), + ], + const SizedBox(width: 4), + if (label != null) + Text( + label!, + style: TextStyle( + color: isSelected + ? colorScheme.backdropBase + : colorScheme.tabIcon, + ), + ), + const SizedBox(width: 4), + ], + ), + ), + ); + } +} diff --git a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_filter_bar.dart b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_filter_bar.dart new file mode 100644 index 0000000000..8a29cdba52 --- /dev/null +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_filter_bar.dart @@ -0,0 +1,275 @@ +import 'package:figma_squircle/figma_squircle.dart'; +import 'package:flutter/material.dart'; +import "package:photos/ente_theme_data.dart"; +import "package:photos/theme/ente_theme.dart"; +import 'package:pro_image_editor/pro_image_editor.dart'; + +class GlacierFilterMatrix { + static const saturation = [ + 0.97, + 0.02, + 0.00, + 0.00, + 0.00, + 0.01, + 0.98, + 0.00, + 0.00, + 0.00, + 0.01, + 0.02, + 0.96, + 0.00, + 0.00, + 0.00, + 0.00, + 0.00, + 1.00, + 0.00, + ]; + + static const contrast = [ + 0.94, + 0.00, + 0.00, + 0.00, + 7.07, + 0.00, + 0.94, + 0.00, + 0.00, + 7.07, + 0.00, + 0.00, + 0.94, + 0.00, + 7.07, + 0.00, + 0.00, + 0.00, + 1.00, + 0.00, + ]; + + static const hue = [ + 1.01, + 0.40, + -0.41, + 0.00, + 0.00, + -0.04, + 0.91, + 0.14, + 0.00, + 0.00, + 0.38, + -0.25, + 0.87, + 0.00, + 0.00, + 0.00, + 0.00, + 0.00, + 1.00, + 0.00, + ]; + + static const temperature = [ + 0.80, + 0.00, + 0.00, + 0.00, + 0.00, + 0.00, + 1.00, + 0.00, + 0.00, + 0.00, + 0.00, + 0.00, + 1.00, + 0.00, + 0.00, + 0.00, + 0.00, + 0.00, + 1.00, + 0.00, + ]; +} + +final filterList = [ + const FilterModel( + name: "None", + filters: [], + ), + FilterModel( + name: 'Pop', + filters: [ + ColorFilterAddons.saturation(0.3), + ColorFilterAddons.brightness(0.15), + ], + ), + FilterModel( + name: "Amber", + filters: [ + ColorFilterAddons.rgbScale(1.01, 1.04, 1), + ColorFilterAddons.saturation(0.3), + ], + ), + FilterModel( + name: 'Dust', + filters: [ + ColorFilterAddons.sepia(0.4), + ColorFilterAddons.brightness(0.13), + ColorFilterAddons.contrast(-.05), + ], + ), + FilterModel( + name: 'Carbon', + filters: [ + ColorFilterAddons.contrast(0.2), + ColorFilterAddons.grayscale(), + ], + ), + const FilterModel( + name: 'Glacier', + filters: [ + GlacierFilterMatrix.saturation, + GlacierFilterMatrix.temperature, + GlacierFilterMatrix.hue, + GlacierFilterMatrix.contrast, + ], + ), + FilterModel( + name: 'Haze', + filters: [ + ColorFilterAddons.colorOverlay(228, 130, 225, 0.13), + ColorFilterAddons.saturation(-0.2), + ], + ), + FilterModel( + name: 'Meadow', + filters: [ + ColorFilterAddons.rgbScale(1.05, 1.1, 1), + ], + ), + FilterModel( + name: 'Zest', + filters: [ + ColorFilterAddons.brightness(.1), + ColorFilterAddons.contrast(.1), + ColorFilterAddons.saturation(.15), + ], + ), + FilterModel( + name: 'Retro', + filters: [ + ColorFilterAddons.colorOverlay(25, 240, 252, 0.05), + ColorFilterAddons.sepia(0.3), + ], + ), + FilterModel( + name: 'Sepia', + filters: [ + ColorFilterAddons.contrast(-0.15), + ColorFilterAddons.saturation(0.1), + ], + ), +]; + +class ImageEditorFilterBar extends StatefulWidget { + const ImageEditorFilterBar({ + required this.filterModel, + required this.isSelected, + required this.onSelectFilter, + required this.editorImage, + this.filterKey, + super.key, + }); + + final FilterModel filterModel; + final bool isSelected; + final VoidCallback onSelectFilter; + final Widget editorImage; + final Key? filterKey; + + @override + State createState() => _ImageEditorFilterBarState(); +} + +class _ImageEditorFilterBarState extends State { + @override + Widget build(BuildContext context) { + return buildFilteredOptions( + widget.editorImage, + widget.isSelected, + widget.filterModel.name, + widget.onSelectFilter, + widget.filterKey ?? ValueKey(widget.filterModel.name), + ); + } + + Widget buildFilteredOptions( + Widget editorImage, + bool isSelected, + String filterName, + VoidCallback onSelectFilter, + Key filterKey, + ) { + return GestureDetector( + onTap: () => onSelectFilter(), + child: SizedBox( + height: 90, + width: 80, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Container( + height: 60, + width: 60, + decoration: ShapeDecoration( + shape: SmoothRectangleBorder( + borderRadius: SmoothBorderRadius( + cornerRadius: 12, + cornerSmoothing: 0.6, + ), + side: BorderSide( + color: isSelected + ? Theme.of(context).colorScheme.imageEditorPrimaryColor + : Colors.transparent, + width: 1.5, + ), + ), + ), + child: Padding( + padding: isSelected ? const EdgeInsets.all(2) : EdgeInsets.zero, + child: ClipSmoothRect( + radius: SmoothBorderRadius( + cornerRadius: 9.69, + cornerSmoothing: 0.4, + ), + child: SizedBox( + height: isSelected ? 56 : 60, + width: isSelected ? 56 : 60, + child: editorImage, + ), + ), + ), + ), + const SizedBox(height: 8), + Text( + filterName, + style: isSelected + ? getEnteTextTheme(context).smallBold + : getEnteTextTheme(context).smallMuted, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_main_bottom_bar.dart b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_main_bottom_bar.dart new file mode 100644 index 0000000000..3321e645c1 --- /dev/null +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_main_bottom_bar.dart @@ -0,0 +1,136 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import "package:photos/ui/tools/editor/image_editor/circular_icon_button.dart"; +import "package:photos/ui/tools/editor/image_editor/image_editor_configs_mixin.dart"; +import "package:photos/ui/tools/editor/image_editor/image_editor_constants.dart"; +import "package:pro_image_editor/mixins/converted_configs.dart"; +import 'package:pro_image_editor/pro_image_editor.dart'; + +class ImageEditorMainBottomBar extends StatefulWidget with SimpleConfigsAccess { + const ImageEditorMainBottomBar({ + super.key, + required this.configs, + required this.callbacks, + required this.editor, + }); + + final ProImageEditorState editor; + + @override + final ProImageEditorConfigs configs; + @override + final ProImageEditorCallbacks callbacks; + + @override + State createState() => + ImageEditorMainBottomBarState(); +} + +class ImageEditorMainBottomBarState extends State + with ImageEditorConvertedConfigs, SimpleConfigsAccessState { + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildFunctions(constraints), + ], + ); + }, + ); + } + + Widget _buildFunctions(BoxConstraints constraints) { + return BottomAppBar( + height: editorBottomBarHeight, + padding: EdgeInsets.zero, + clipBehavior: Clip.none, + child: AnimatedSwitcher( + layoutBuilder: (currentChild, previousChildren) => Stack( + clipBehavior: Clip.none, + alignment: Alignment.bottomCenter, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ), + duration: const Duration(milliseconds: 400), + reverseDuration: const Duration(milliseconds: 0), + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axis: Axis.vertical, + axisAlignment: -1, + child: child, + ), + ); + }, + switchInCurve: Curves.ease, + child: widget.editor.isSubEditorOpen && + !widget.editor.isSubEditorClosing + ? const SizedBox.shrink() + : Align( + alignment: Alignment.center, + child: SingleChildScrollView( + clipBehavior: Clip.none, + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: min(constraints.maxWidth, 600), + maxWidth: 600, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: [ + CircularIconButton( + svgPath: "assets/image-editor/image-editor-crop.svg", + label: "Crop", + onTap: () { + widget.editor.openCropRotateEditor(); + }, + ), + CircularIconButton( + svgPath: + "assets/image-editor/image-editor-filter.svg", + label: "Filter", + onTap: () { + widget.editor.openFilterEditor(); + }, + ), + CircularIconButton( + svgPath: "assets/image-editor/image-editor-tune.svg", + label: "Adjust", + onTap: () { + widget.editor.openTuneEditor(); + }, + ), + CircularIconButton( + svgPath: "assets/image-editor/image-editor-paint.svg", + label: "Draw", + onTap: () { + widget.editor.openPaintingEditor(); + }, + ), + CircularIconButton( + svgPath: + "assets/image-editor/image-editor-sticker.svg", + label: "Sticker", + onTap: () { + widget.editor.openEmojiEditor(); + }, + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_page_new.dart b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_page_new.dart new file mode 100644 index 0000000000..f99799378e --- /dev/null +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_page_new.dart @@ -0,0 +1,561 @@ +import "dart:async"; +import "dart:io"; +import "dart:math"; +import "dart:typed_data"; +import 'dart:ui' as ui show Image; + +import 'package:flutter/material.dart'; +import "package:flutter_image_compress/flutter_image_compress.dart"; +import "package:flutter_svg/svg.dart"; +import "package:logging/logging.dart"; +import 'package:path/path.dart' as path; +import "package:photo_manager/photo_manager.dart"; +import "package:photos/core/event_bus.dart"; +import "package:photos/db/files_db.dart"; +import "package:photos/ente_theme_data.dart"; +import "package:photos/events/local_photos_updated_event.dart"; +import "package:photos/generated/l10n.dart"; +import 'package:photos/models/file/file.dart' as ente; +import "package:photos/models/location/location.dart"; +import "package:photos/services/sync/sync_service.dart"; +import "package:photos/theme/ente_theme.dart"; +import "package:photos/ui/components/action_sheet_widget.dart"; +import "package:photos/ui/components/buttons/button_widget.dart"; +import "package:photos/ui/components/models/button_type.dart"; +import "package:photos/ui/notification/toast.dart"; +import "package:photos/ui/tools/editor/image_editor/image_editor_app_bar.dart"; +import "package:photos/ui/tools/editor/image_editor/image_editor_constants.dart"; +import "package:photos/ui/tools/editor/image_editor/image_editor_crop_rotate.dart"; +import "package:photos/ui/tools/editor/image_editor/image_editor_filter_bar.dart"; +import "package:photos/ui/tools/editor/image_editor/image_editor_main_bottom_bar.dart"; +import "package:photos/ui/tools/editor/image_editor/image_editor_paint_bar.dart"; +import "package:photos/ui/tools/editor/image_editor/image_editor_text_bar.dart"; +import "package:photos/ui/tools/editor/image_editor/image_editor_tune_bar.dart"; +import "package:photos/ui/viewer/file/detail_page.dart"; +import "package:photos/utils/dialog_util.dart"; +import "package:photos/utils/navigation_util.dart"; +import "package:pro_image_editor/models/editor_configs/main_editor_configs.dart"; +import 'package:pro_image_editor/pro_image_editor.dart'; + +class NewImageEditor extends StatefulWidget { + final ente.EnteFile originalFile; + final File file; + final DetailPageConfiguration detailPageConfig; + + const NewImageEditor({ + super.key, + required this.file, + required this.originalFile, + required this.detailPageConfig, + }); + + @override + State createState() => _NewImageEditorState(); +} + +class _NewImageEditorState extends State { + final _mainEditorBarKey = GlobalKey(); + final editorKey = GlobalKey(); + final _logger = Logger("ImageEditor"); + + Future saveImage(Uint8List? bytes) async { + if (bytes == null) return; + + final dialog = createProgressDialog(context, S.of(context).saving); + await dialog.show(); + + debugPrint("Image saved with size: ${bytes.length} bytes"); + final DateTime start = DateTime.now(); + + final ui.Image decodedResult = await decodeImageFromList(bytes); + final result = await FlutterImageCompress.compressWithList( + bytes, + minWidth: decodedResult.width, + minHeight: decodedResult.height, + ); + _logger.info('Size after compression = ${result.length}'); + final Duration diff = DateTime.now().difference(start); + _logger.info('image_editor time : $diff'); + + try { + final fileName = + path.basenameWithoutExtension(widget.originalFile.title!) + + "_edited_" + + DateTime.now().microsecondsSinceEpoch.toString() + + ".JPEG"; + //Disabling notifications for assets changing to insert the file into + //files db before triggering a sync. + await PhotoManager.stopChangeNotify(); + final AssetEntity newAsset = + await (PhotoManager.editor.saveImage(result, filename: fileName)); + final newFile = await ente.EnteFile.fromAsset( + widget.originalFile.deviceFolder ?? '', + newAsset, + ); + + newFile.creationTime = widget.originalFile.creationTime; + newFile.collectionID = widget.originalFile.collectionID; + newFile.location = widget.originalFile.location; + if (!newFile.hasLocation && widget.originalFile.localID != null) { + final assetEntity = await widget.originalFile.getAsset; + if (assetEntity != null) { + final latLong = await assetEntity.latlngAsync(); + newFile.location = Location( + latitude: latLong.latitude, + longitude: latLong.longitude, + ); + } + } + newFile.generatedID = await FilesDB.instance.insertAndGetId(newFile); + Bus.instance.fire(LocalPhotosUpdatedEvent([newFile], source: "editSave")); + unawaited(SyncService.instance.sync()); + showShortToast(context, S.of(context).editsSaved); + _logger.info("Original file " + widget.originalFile.toString()); + _logger.info("Saved edits to file " + newFile.toString()); + final files = widget.detailPageConfig.files; + + // the index could be -1 if the files fetched doesn't contain the newly + // edited files + int selectionIndex = + files.indexWhere((file) => file.generatedID == newFile.generatedID); + if (selectionIndex == -1) { + files.add(newFile); + selectionIndex = files.length - 1; + } + await dialog.hide(); + replacePage( + context, + DetailPage( + widget.detailPageConfig.copyWith( + files: files, + selectedIndex: min(selectionIndex, files.length - 1), + ), + ), + ); + } catch (e, s) { + await dialog.hide(); + showToast(context, S.of(context).oopsCouldNotSaveEdits); + _logger.severe(e, s); + } finally { + await PhotoManager.startChangeNotify(); + } + } + + Future _showExitConfirmationDialog(BuildContext context) async { + final actionResult = await showActionSheet( + context: context, + buttons: [ + ButtonWidget( + labelText: S.of(context).yesDiscardChanges, + buttonType: ButtonType.critical, + buttonSize: ButtonSize.large, + shouldStickToDarkTheme: true, + buttonAction: ButtonAction.first, + isInAlert: true, + ), + ButtonWidget( + labelText: S.of(context).no, + buttonType: ButtonType.secondary, + buttonSize: ButtonSize.large, + buttonAction: ButtonAction.second, + shouldStickToDarkTheme: true, + isInAlert: true, + ), + ], + body: S.of(context).doYouWantToDiscardTheEditsYouHaveMade, + actionSheetType: ActionSheetType.defaultActionSheet, + ); + if (actionResult?.action != null && + actionResult!.action == ButtonAction.first) { + replacePage(context, DetailPage(widget.detailPageConfig)); + } + } + + @override + Widget build(BuildContext context) { + final isLightMode = Theme.of(context).brightness == Brightness.light; + final colorScheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + return Scaffold( + resizeToAvoidBottomInset: false, + backgroundColor: colorScheme.backgroundBase, + body: PopScope( + canPop: false, + onPopInvoked: (didPop) { + if (didPop) return; + editorKey.currentState?.disablePopScope = true; + _showExitConfirmationDialog(context); + }, + child: ProImageEditor.file( + key: editorKey, + widget.file, + callbacks: ProImageEditorCallbacks( + onCloseEditor: () { + editorKey.currentState?.disablePopScope = true; + _showExitConfirmationDialog(context); + }, + mainEditorCallbacks: MainEditorCallbacks( + onStartCloseSubEditor: (value) { + _mainEditorBarKey.currentState?.setState(() {}); + }, + onPopInvoked: (didPop, result) { + editorKey.currentState?.disablePopScope = false; + }, + ), + ), + configs: ProImageEditorConfigs( + imageEditorTheme: ImageEditorTheme( + appBarBackgroundColor: colorScheme.backgroundBase, + background: colorScheme.backgroundBase, + bottomBarBackgroundColor: colorScheme.backgroundBase, + filterEditor: FilterEditorTheme( + background: colorScheme.backgroundBase, + ), + paintingEditor: PaintingEditorTheme( + background: colorScheme.backgroundBase, + ), + textEditor: const TextEditorTheme( + background: Colors.transparent, + textFieldMargin: EdgeInsets.only(top: kToolbarHeight), + ), + cropRotateEditor: CropRotateEditorTheme( + background: colorScheme.backgroundBase, + cropCornerColor: + Theme.of(context).colorScheme.imageEditorPrimaryColor, + ), + tuneEditor: TuneEditorTheme( + background: colorScheme.backgroundBase, + ), + emojiEditor: EmojiEditorTheme( + backgroundColor: colorScheme.backgroundBase, + ), + ), + imageGenerationConfigs: const ImageGenerationConfigs( + jpegQuality: 100, + generateInsideSeparateThread: true, + pngLevel: 0, + ), + layerInteraction: const LayerInteraction( + hideToolbarOnInteraction: false, + ), + theme: ThemeData( + scaffoldBackgroundColor: colorScheme.backgroundBase, + appBarTheme: AppBarTheme( + titleTextStyle: textTheme.body, + backgroundColor: colorScheme.backgroundBase, + ), + bottomAppBarTheme: BottomAppBarTheme( + color: colorScheme.backgroundBase, + ), + brightness: isLightMode ? Brightness.light : Brightness.dark, + ), + customWidgets: ImageEditorCustomWidgets( + filterEditor: CustomWidgetsFilterEditor( + slider: ( + editorState, + rebuildStream, + value, + onChanged, + onChangeEnd, + ) => + ReactiveCustomWidget( + builder: (context) { + return const SizedBox.shrink(); + }, + stream: rebuildStream, + ), + filterButton: ( + filter, + isSelected, + scaleFactor, + onSelectFilter, + editorImage, + filterKey, + ) { + return ImageEditorFilterBar( + filterModel: filter, + isSelected: isSelected, + onSelectFilter: () { + onSelectFilter.call(); + editorKey.currentState?.setState(() {}); + }, + editorImage: editorImage, + filterKey: filterKey, + ); + }, + appBar: (editor, rebuildStream) { + return ReactiveCustomAppbar( + builder: (context) { + return ImageEditorAppBar( + key: const Key('image_editor_app_bar'), + configs: editor.configs, + done: () => editor.done(), + close: () => editor.close(), + ); + }, + stream: rebuildStream, + ); + }, + ), + tuneEditor: CustomWidgetsTuneEditor( + appBar: (editor, rebuildStream) { + return ReactiveCustomAppbar( + builder: (context) { + return ImageEditorAppBar( + enableRedo: editor.canRedo, + enableUndo: editor.canUndo, + key: const Key('image_editor_app_bar'), + redo: () => editor.redo(), + undo: () => editor.undo(), + configs: editor.configs, + done: () => editor.done(), + close: () => editor.close(), + ); + }, + stream: rebuildStream, + ); + }, + bottomBar: (editorState, rebuildStream) { + return ReactiveCustomWidget( + builder: (context) { + return ImageEditorTuneBar( + configs: editorState.configs, + callbacks: editorState.callbacks, + editor: editorState, + ); + }, + stream: rebuildStream, + ); + }, + ), + mainEditor: CustomWidgetsMainEditor( + removeLayerArea: (key, rebuildStream) { + return ReactiveCustomWidget( + key: key, + builder: (context) { + return Align( + alignment: Alignment.bottomCenter, + child: StreamBuilder( + stream: rebuildStream, + builder: (context, snapshot) { + final isHovered = editorKey.currentState! + .layerInteractionManager.hoverRemoveBtn; + + return AnimatedContainer( + key: key, + duration: const Duration(milliseconds: 150), + height: 56, + width: 56, + margin: const EdgeInsets.only(bottom: 24), + decoration: BoxDecoration( + color: isHovered + ? colorScheme.warning400.withOpacity(0.8) + : Colors.white, + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(12), + child: Center( + child: SvgPicture.asset( + "assets/image-editor/image-editor-delete.svg", + colorFilter: ColorFilter.mode( + isHovered + ? Colors.white + : colorScheme.warning400 + .withOpacity(0.8), + BlendMode.srcIn, + ), + ), + ), + ); + }, + ), + ); + }, + stream: rebuildStream, + ); + }, + appBar: (editor, rebuildStream) { + return ReactiveCustomAppbar( + builder: (context) { + return ImageEditorAppBar( + enableRedo: editor.canRedo, + enableUndo: editor.canUndo, + key: const Key('image_editor_app_bar'), + redo: () => editor.redoAction(), + undo: () => editor.undoAction(), + configs: editor.configs, + done: () async { + final Uint8List bytes = await editorKey.currentState! + .captureEditorImage(); + await saveImage(bytes); + }, + close: () { + _showExitConfirmationDialog(context); + }, + isMainEditor: true, + ); + }, + stream: rebuildStream, + ); + }, + bottomBar: (editor, rebuildStream, key) => ReactiveCustomWidget( + key: key, + builder: (context) { + return ImageEditorMainBottomBar( + key: _mainEditorBarKey, + editor: editor, + configs: editor.configs, + callbacks: editor.callbacks, + ); + }, + stream: rebuildStream, + ), + ), + paintEditor: CustomWidgetsPaintEditor( + appBar: (editor, rebuildStream) { + return ReactiveCustomAppbar( + builder: (context) { + return ImageEditorAppBar( + enableRedo: editor.canRedo, + enableUndo: editor.canUndo, + key: const Key('image_editor_app_bar'), + redo: () => editor.redoAction(), + undo: () => editor.undoAction(), + configs: editor.configs, + done: () => editor.done(), + close: () => editor.close(), + ); + }, + stream: rebuildStream, + ); + }, + colorPicker: + (paintEditor, rebuildStream, currentColor, setColor) => + null, + bottomBar: (editorState, rebuildStream) { + return ReactiveCustomWidget( + builder: (context) { + return ImageEditorPaintBar( + configs: editorState.configs, + callbacks: editorState.callbacks, + editor: editorState, + i18nColor: 'Color', + ); + }, + stream: rebuildStream, + ); + }, + ), + textEditor: CustomWidgetsTextEditor( + appBar: (textEditor, rebuildStream) => ReactiveCustomAppbar( + builder: (context) { + return ImageEditorAppBar( + key: const Key('image_editor_app_bar'), + configs: textEditor.configs, + done: () => textEditor.done(), + close: () => textEditor.close(), + ); + }, + stream: rebuildStream, + ), + bodyItems: (editor, rebuildStream) { + return [ + ReactiveCustomWidget( + builder: (context) { + return Positioned.fill( + child: GestureDetector( + onTap: () {}, + child: Container( + color: Colors.transparent, + ), + ), + ); + }, + stream: rebuildStream, + ), + ]; + }, + colorPicker: + (textEditor, rebuildStream, currentColor, setColor) => null, + bottomBar: (editorState, rebuildStream) { + return ReactiveCustomWidget( + builder: (context) { + return ImageEditorTextBar( + configs: editorState.configs, + callbacks: editorState.callbacks, + editor: editorState, + ); + }, + stream: rebuildStream, + ); + }, + ), + cropRotateEditor: CustomWidgetsCropRotateEditor( + appBar: (editor, rebuildStream) { + return ReactiveCustomAppbar( + builder: (context) { + return ImageEditorAppBar( + key: const Key('image_editor_app_bar'), + configs: editor.configs, + done: () => editor.done(), + close: () => editor.close(), + enableRedo: editor.canRedo, + enableUndo: editor.canUndo, + redo: () => editor.redoAction(), + undo: () => editor.undoAction(), + ); + }, + stream: rebuildStream, + ); + }, + bottomBar: (cropRotateEditor, rebuildStream) => + ReactiveCustomWidget( + stream: rebuildStream, + builder: (_) => ImageEditorCropRotateBar( + configs: cropRotateEditor.configs, + callbacks: cropRotateEditor.callbacks, + editor: cropRotateEditor, + ), + ), + ), + ), + mainEditorConfigs: const MainEditorConfigs(enableZoom: true), + paintEditorConfigs: const PaintEditorConfigs(enabled: true), + textEditorConfigs: const TextEditorConfigs( + enabled: false, + canToggleBackgroundMode: true, + canToggleTextAlign: true, + ), + cropRotateEditorConfigs: const CropRotateEditorConfigs( + canChangeAspectRatio: true, + canFlip: true, + canRotate: true, + canReset: true, + enabled: true, + ), + filterEditorConfigs: FilterEditorConfigs( + enabled: true, + fadeInUpDuration: fadeInDuration, + fadeInUpStaggerDelayDuration: fadeInDelay, + filterList: filterList, + ), + tuneEditorConfigs: const TuneEditorConfigs(enabled: true), + blurEditorConfigs: const BlurEditorConfigs( + enabled: false, + ), + emojiEditorConfigs: const EmojiEditorConfigs( + enabled: true, + checkPlatformCompatibility: true, + ), + stickerEditorConfigs: StickerEditorConfigs( + enabled: false, + buildStickers: (setLayer, scrollController) { + return const SizedBox.shrink(); + }, + ), + ), + ), + ), + ); + } +} diff --git a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_paint_bar.dart b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_paint_bar.dart new file mode 100644 index 0000000000..0e4d1dd44f --- /dev/null +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_paint_bar.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import "package:photos/theme/ente_theme.dart"; +import "package:photos/ui/tools/editor/image_editor/image_editor_color_picker.dart"; +import "package:photos/ui/tools/editor/image_editor/image_editor_configs_mixin.dart"; +import "package:photos/ui/tools/editor/image_editor/image_editor_constants.dart"; +import "package:pro_image_editor/mixins/converted_configs.dart"; +import "package:pro_image_editor/models/editor_callbacks/pro_image_editor_callbacks.dart"; +import "package:pro_image_editor/models/editor_configs/pro_image_editor_configs.dart"; +import "package:pro_image_editor/modules/paint_editor/paint_editor.dart"; +import "package:pro_image_editor/widgets/animated/fade_in_up.dart"; + +class ImageEditorPaintBar extends StatefulWidget with SimpleConfigsAccess { + const ImageEditorPaintBar({ + super.key, + required this.configs, + required this.callbacks, + required this.editor, + required this.i18nColor, + }); + + final PaintingEditorState editor; + + @override + final ProImageEditorConfigs configs; + @override + final ProImageEditorCallbacks callbacks; + + final String i18nColor; + + @override + State createState() => _ImageEditorPaintBarState(); +} + +class _ImageEditorPaintBarState extends State + with ImageEditorConvertedConfigs, SimpleConfigsAccessState { + double colorSliderValue = 0.5; + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildFunctions(constraints), + ], + ); + }, + ); + } + + Widget _buildFunctions(BoxConstraints constraints) { + return BottomAppBar( + height: editorBottomBarHeight, + padding: EdgeInsets.zero, + child: Align( + alignment: Alignment.center, + child: FadeInUp( + duration: fadeInDuration, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 20.0), + child: Text( + "Brush Color", + style: getEnteTextTheme(context).body, + ), + ), + const SizedBox(height: 24), + ImageEditorColorPicker( + value: colorSliderValue, + onChanged: (value) { + setState(() { + colorSliderValue = value; + }); + final hue = value * 360; + final color = HSVColor.fromAHSV(1.0, hue, 1.0, 1.0).toColor(); + widget.editor.colorChanged(color); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_text_bar.dart b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_text_bar.dart new file mode 100644 index 0000000000..3115aa3933 --- /dev/null +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_text_bar.dart @@ -0,0 +1,380 @@ +import 'package:flutter/material.dart'; +import "package:flutter_svg/svg.dart"; +import "package:photos/theme/ente_theme.dart"; +import "package:photos/ui/tools/editor/image_editor/circular_icon_button.dart"; +import "package:photos/ui/tools/editor/image_editor/image_editor_color_picker.dart"; +import "package:photos/ui/tools/editor/image_editor/image_editor_configs_mixin.dart"; +import "package:photos/ui/tools/editor/image_editor/image_editor_constants.dart"; +import "package:pro_image_editor/mixins/converted_configs.dart"; +import 'package:pro_image_editor/pro_image_editor.dart'; + +class ImageEditorTextBar extends StatefulWidget with SimpleConfigsAccess { + const ImageEditorTextBar({ + super.key, + required this.configs, + required this.callbacks, + required this.editor, + }); + + final TextEditorState editor; + + @override + final ProImageEditorConfigs configs; + @override + final ProImageEditorCallbacks callbacks; + + @override + State createState() => _ImageEditorTextBarState(); +} + +class _ImageEditorTextBarState extends State + with ImageEditorConvertedConfigs, SimpleConfigsAccessState { + int selectedActionIndex = -1; + double colorSliderValue = 0.5; + + void _selectAction(int index) { + setState(() { + selectedActionIndex = selectedActionIndex == index ? -1 : index; + }); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildFunctions(constraints), + ], + ); + }, + ); + } + + Widget _buildFunctions(BoxConstraints constraints) { + return BottomAppBar( + padding: EdgeInsets.zero, + height: editorBottomBarHeight, + child: FadeInUp( + duration: fadeInDuration, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildMainActionButtons(), + _buildHelperWidget(), + ], + ), + ), + ); + } + + Widget _buildMainActionButtons() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + CircularIconButton( + svgPath: "assets/image-editor/image-editor-text-color.svg", + label: "Color", + isSelected: selectedActionIndex == 0, + onTap: () { + _selectAction(0); + }, + ), + CircularIconButton( + svgPath: "assets/image-editor/image-editor-text-font.svg", + label: "Font", + isSelected: selectedActionIndex == 1, + onTap: () { + _selectAction(1); + }, + ), + CircularIconButton( + svgPath: "assets/image-editor/image-editor-text-background.svg", + label: "Background", + isSelected: selectedActionIndex == 2, + onTap: () { + setState(() { + selectedActionIndex = 2; + }); + }, + ), + CircularIconButton( + svgPath: "assets/image-editor/image-editor-text-align-left.svg", + label: "Align", + isSelected: selectedActionIndex == 3, + onTap: () { + setState(() { + selectedActionIndex = 3; + }); + }, + ), + ], + ); + } + + Widget _buildHelperWidget() { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeInOut, + switchOutCurve: Curves.easeInOut, + child: switch (selectedActionIndex) { + 0 => ImageEditorColorPicker( + value: colorSliderValue, + onChanged: (value) { + setState(() { + colorSliderValue = value; + }); + final hue = value * 360; + final color = HSVColor.fromAHSV(1.0, hue, 1.0, 1.0).toColor(); + widget.editor.primaryColor = color; + }, + ), + 1 => _FontPickerWidget(editor: widget.editor), + 2 => _BackgroundPickerWidget(editor: widget.editor), + 3 => _AlignPickerWidget(editor: widget.editor), + _ => const SizedBox.shrink(), + }, + ); + } +} + +class _FontPickerWidget extends StatelessWidget { + final TextEditorState editor; + + const _FontPickerWidget({required this.editor}); + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + if (editor.textEditorConfigs.customTextStyles == null) { + return const SizedBox.shrink(); + } + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: editor.textEditorConfigs.customTextStyles! + .asMap() + .entries + .map((entry) { + final item = entry.value; + final selected = editor.selectedTextStyle; + final bool isSelected = selected.hashCode == item.hashCode; + + return Padding( + padding: const EdgeInsets.only(right: 12), + child: GestureDetector( + onTap: () { + editor.setTextStyle(item); + }, + child: Container( + height: 40, + width: 48, + decoration: BoxDecoration( + color: isSelected + ? colorScheme.fillBasePressed + : colorScheme.backgroundElevated2, + borderRadius: BorderRadius.circular(25), + ), + child: Center( + child: Text( + 'Aa', + style: item.copyWith( + color: isSelected + ? colorScheme.backdropBase + : colorScheme.tabIcon, + ), + ), + ), + ), + ), + ); + }).toList(), + ); + } +} + +class _BackgroundPickerWidget extends StatelessWidget { + final TextEditorState editor; + + const _BackgroundPickerWidget({required this.editor}); + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + final isLightMode = Theme.of(context).brightness == Brightness.light; + final backgroundStyles = { + LayerBackgroundMode.background: { + 'text': 'Aa', + 'selectedBackgroundColor': + isLightMode ? colorScheme.fillFaint : Colors.white, + 'backgroundColor': colorScheme.backgroundElevated2, + 'border': null, + 'textColor': Colors.white, + 'selectedInnerBackgroundColor': Colors.black, + 'innerBackgroundColor': Colors.transparent, + }, + LayerBackgroundMode.backgroundAndColor: { + 'text': 'Aa', + 'selectedBackgroundColor': + isLightMode ? colorScheme.fillFaint : Colors.white, + 'backgroundColor': colorScheme.backgroundElevated2, + 'border': null, + 'textColor': Colors.black, + 'selectedInnerBackgroundColor': Colors.transparent, + 'innerBackgroundColor': Colors.transparent, + }, + LayerBackgroundMode.backgroundAndColorWithOpacity: { + 'text': 'Aa', + 'selectedBackgroundColor': + isLightMode ? colorScheme.fillFaint : Colors.white, + 'backgroundColor': colorScheme.backgroundElevated2, + 'border': null, + 'textColor': Colors.black, + 'selectedInnerBackgroundColor': Colors.black.withOpacity(0.11), + 'innerBackgroundColor': isLightMode + ? Colors.black.withOpacity(0.11) + : Colors.white.withOpacity(0.11), + }, + LayerBackgroundMode.onlyColor: { + 'text': 'Aa', + 'selectedBackgroundColor': + isLightMode ? colorScheme.fillFaint : Colors.black, + 'backgroundColor': colorScheme.backgroundElevated2, + 'border': + isLightMode ? null : Border.all(color: Colors.white, width: 2), + 'textColor': Colors.black, + 'selectedInnerBackgroundColor': Colors.white, + 'innerBackgroundColor': Colors.white.withOpacity(0.6), + }, + }; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: backgroundStyles.entries.map((entry) { + final mode = entry.key; + final style = entry.value; + final isSelected = editor.backgroundColorMode == mode; + + return Padding( + padding: const EdgeInsets.only(right: 12), + child: GestureDetector( + onTap: () { + editor.setState(() { + editor.backgroundColorMode = mode; + }); + }, + child: SizedBox( + height: 40, + width: 48, + child: Container( + decoration: BoxDecoration( + color: isSelected + ? style['selectedBackgroundColor'] as Color + : style['backgroundColor'] as Color, + borderRadius: BorderRadius.circular(25), + border: isSelected ? style['border'] as Border? : null, + ), + child: Stack( + alignment: Alignment.center, + children: [ + Center( + child: Container( + height: 20, + width: 22, + decoration: ShapeDecoration( + color: isSelected + ? style['selectedInnerBackgroundColor'] as Color + : style['innerBackgroundColor'] as Color, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + Center( + child: Text( + style['text'] as String, + style: TextStyle( + color: isSelected + ? style['textColor'] as Color + : colorScheme.tabIcon, + ), + ), + ), + ], + ), + ), + ), + ), + ); + }).toList(), + ); + } +} + +class _AlignPickerWidget extends StatelessWidget { + final TextEditorState editor; + + const _AlignPickerWidget({required this.editor}); + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + final alignments = [ + (TextAlign.left, "assets/image-editor/image-editor-text-align-left.svg"), + ( + TextAlign.center, + "assets/image-editor/image-editor-text-align-center.svg" + ), + ( + TextAlign.right, + "assets/image-editor/image-editor-text-align-right.svg" + ), + ]; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: alignments.map((alignmentData) { + final (alignment, svgPath) = alignmentData; + final isSelected = editor.align == alignment; + + return Padding( + padding: const EdgeInsets.only(right: 12.0), + child: GestureDetector( + onTap: () { + editor.setState(() { + editor.align = alignment; + }); + }, + child: Container( + height: 40, + width: 48, + decoration: BoxDecoration( + color: isSelected + ? colorScheme.fillBasePressed + : colorScheme.backgroundElevated2, + borderRadius: BorderRadius.circular(25), + border: isSelected + ? Border.all(color: Colors.black, width: 2) + : null, + ), + child: Center( + child: SvgPicture.asset( + svgPath, + width: 22, + height: 22, + fit: BoxFit.scaleDown, + colorFilter: ColorFilter.mode( + isSelected ? colorScheme.backdropBase : colorScheme.tabIcon, + BlendMode.srcIn, + ), + ), + ), + ), + ), + ); + }).toList(), + ); + } +} diff --git a/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_tune_bar.dart b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_tune_bar.dart new file mode 100644 index 0000000000..a7b5c69da4 --- /dev/null +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_tune_bar.dart @@ -0,0 +1,651 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import "package:flutter_svg/svg.dart"; +import "package:photos/ente_theme_data.dart"; +import "package:photos/theme/ente_theme.dart"; +import "package:photos/ui/tools/editor/image_editor/image_editor_configs_mixin.dart"; +import "package:photos/ui/tools/editor/image_editor/image_editor_constants.dart"; +import "package:pro_image_editor/mixins/converted_configs.dart"; +import "package:pro_image_editor/models/editor_callbacks/pro_image_editor_callbacks.dart"; +import "package:pro_image_editor/models/editor_configs/pro_image_editor_configs.dart"; +import "package:pro_image_editor/modules/tune_editor/tune_editor.dart"; +import "package:pro_image_editor/widgets/animated/fade_in_up.dart"; + +class ImageEditorTuneBar extends StatefulWidget with SimpleConfigsAccess { + const ImageEditorTuneBar({ + super.key, + required this.configs, + required this.callbacks, + required this.editor, + }); + + final TuneEditorState editor; + + @override + final ProImageEditorConfigs configs; + + @override + final ProImageEditorCallbacks callbacks; + + @override + State createState() => _ImageEditorTuneBarState(); +} + +class _ImageEditorTuneBarState extends State + with ImageEditorConvertedConfigs, SimpleConfigsAccessState { + TuneEditorState get tuneEditor => widget.editor; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildFunctions(constraints), + ], + ); + }, + ); + } + + Widget _buildFunctions(BoxConstraints constraints) { + return SizedBox( + width: double.infinity, + height: editorBottomBarHeight, + child: FadeInUp( + duration: fadeInDuration, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + SizedBox( + height: 90, + child: SingleChildScrollView( + controller: tuneEditor.bottomBarScrollCtrl, + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: List.generate( + tuneEditor.tuneAdjustmentMatrix.length, (index) { + final item = tuneEditor.tuneAdjustmentList[index]; + return TuneItem( + icon: item.icon, + label: item.label, + isSelected: tuneEditor.selectedIndex == index, + value: tuneEditor.tuneAdjustmentMatrix[index].value, + max: item.max, + min: item.min, + onTap: () { + tuneEditor.setState(() { + tuneEditor.selectedIndex = index; + }); + }, + ); + }), + ), + ), + ), + RepaintBoundary( + child: StreamBuilder( + stream: tuneEditor.uiStream.stream, + builder: (context, snapshot) { + final activeOption = + tuneEditor.tuneAdjustmentList[tuneEditor.selectedIndex]; + final activeMatrix = + tuneEditor.tuneAdjustmentMatrix[tuneEditor.selectedIndex]; + + return _TuneAdjustWidget( + min: activeOption.min, + max: activeOption.max, + value: activeMatrix.value, + onChanged: tuneEditor.onChanged, + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class TuneItem extends StatelessWidget { + final IconData icon; + final String label; + final bool isSelected; + final double value; + final double min; + final double max; + final VoidCallback onTap; + + const TuneItem({ + super.key, + required this.icon, + required this.label, + required this.isSelected, + required this.value, + required this.min, + required this.max, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: SizedBox( + width: 90, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressWithValue( + value: value, + min: min, + max: max, + size: 60, + icon: icon, + isSelected: isSelected, + progressColor: + Theme.of(context).colorScheme.imageEditorPrimaryColor, + svgPath: + "assets/image-editor/image-editor-${label.toLowerCase()}.svg", + ), + const SizedBox(height: 8), + Text( + label, + style: getEnteTextTheme(context).small, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} + +class CircularProgressWithValue extends StatefulWidget { + final double value; + final double min; + final double max; + final IconData icon; + final bool isSelected; + final double size; + final Color progressColor; + final String? svgPath; + + const CircularProgressWithValue({ + super.key, + required this.value, + required this.min, + required this.max, + required this.icon, + required this.progressColor, + this.isSelected = false, + this.size = 60, + this.svgPath, + }); + + @override + State createState() => + _CircularProgressWithValueState(); +} + +class _CircularProgressWithValueState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _progressAnimation; + double _previousValue = 0.0; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + animationBehavior: AnimationBehavior.preserve, + ); + + _progressAnimation = Tween( + begin: 0.0, + end: widget.value, + ).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ), + ); + + _animationController.forward(); + } + + @override + void didUpdateWidget(CircularProgressWithValue oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.value != widget.value) { + _previousValue = oldWidget.value; + _progressAnimation = Tween( + begin: _previousValue, + end: widget.value, + ).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ), + ); + _animationController.forward(from: 0.0); + } + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + int _normalizeValueForDisplay(double value, double min, double max) { + if (min == -0.5 && max == 0.5) { + return (value * 200).round(); + } else if (min == 0 && max == 1) { + return (value * 100).round(); + } else if (min == -0.25 && max == 0.25) { + return (value * 400).round(); + } else { + return (value * 100).round(); + } + } + + double _normalizeValueForProgress(double value, double min, double max) { + if (min == -0.5 && max == 0.5) { + return (value.abs() / 0.5).clamp(0.0, 1.0); + } else if (min == 0 && max == 1) { + return (value / 1.0).clamp(0.0, 1.0); + } else if (min == -0.25 && max == 0.25) { + return (value.abs() / 0.25).clamp(0.0, 1.0); + } else { + return (value.abs() / 1.0).clamp(0.0, 1.0); + } + } + + bool _isClockwise(double value, double min, double max) { + if (min >= 0) { + return true; + } else { + return value >= 0; + } + } + + @override + Widget build(BuildContext context) { + final colorTheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + final displayValue = + _normalizeValueForDisplay(widget.value, widget.min, widget.max); + final displayText = displayValue.toString(); + final prefix = displayValue > 0 ? "+" : ""; + final progressColor = widget.progressColor; + + final showValue = displayValue != 0 || widget.isSelected; + + return SizedBox( + width: widget.size, + height: widget.size, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: showValue || widget.isSelected + ? progressColor.withOpacity(0.2) + : colorTheme.backgroundElevated2, + border: Border.all( + color: widget.isSelected + ? progressColor.withOpacity(0.4) + : colorTheme.backgroundElevated2, + width: 2, + ), + ), + ), + AnimatedBuilder( + animation: _progressAnimation, + builder: (context, child) { + final animatedValue = + displayValue == 0 ? 0.0 : _progressAnimation.value; + + final isClockwise = + _isClockwise(animatedValue, widget.min, widget.max); + final progressValue = _normalizeValueForProgress( + animatedValue, + widget.min, + widget.max, + ); + + return SizedBox( + width: widget.size, + height: widget.size, + child: CustomPaint( + painter: CircularProgressPainter( + progress: progressValue, + isClockwise: isClockwise, + color: progressColor, + ), + ), + ); + }, + ), + Align( + alignment: Alignment.center, + child: showValue + ? Text( + "$prefix$displayText", + style: textTheme.smallBold, + ) + : widget.svgPath != null + ? SvgPicture.asset( + widget.svgPath!, + width: 22, + height: 22, + fit: BoxFit.scaleDown, + colorFilter: ColorFilter.mode( + colorTheme.tabIcon, + BlendMode.srcIn, + ), + ) + : Icon( + widget.icon, + color: colorTheme.tabIcon, + size: 20, + ), + ), + ], + ), + ); + } +} + +class _TuneAdjustWidget extends StatelessWidget { + final double min; + final double max; + final double value; + final ValueChanged onChanged; + + const _TuneAdjustWidget({ + required this.min, + required this.max, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + return SizedBox( + height: 40, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(25), + color: colorScheme.backgroundElevated2, + ), + ), + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(25), + ), + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + thumbShape: const _ColorPickerThumbShape(), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 0), + activeTrackColor: + Theme.of(context).colorScheme.imageEditorPrimaryColor, + inactiveTrackColor: colorScheme.backgroundElevated2, + trackShape: const _CenterBasedTrackShape(), + trackHeight: 24, + ), + child: Slider( + value: value, + onChanged: onChanged, + min: min, + max: max, + ), + ), + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 38), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.fillBase.withAlpha(30), + ), + ), + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.fillBase.withAlpha(30), + ), + ), + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.fillBase.withAlpha(30), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _ColorPickerThumbShape extends SliderComponentShape { + const _ColorPickerThumbShape(); + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) { + return const Size(20, 20); + } + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation activationAnimation, + required Animation enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required Size sizeWithOverflow, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double textScaleFactor, + required double value, + }) { + final canvas = context.canvas; + + final trackRect = sliderTheme.trackShape!.getPreferredRect( + parentBox: parentBox, + offset: Offset.zero, + sliderTheme: sliderTheme, + isEnabled: true, + isDiscrete: isDiscrete, + ); + + final constrainedCenter = Offset( + center.dx.clamp(trackRect.left + 15, trackRect.right - 15), + center.dy, + ); + + final paint = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + + canvas.drawCircle(constrainedCenter, 15, paint); + + final innerPaint = Paint() + ..color = const Color.fromRGBO(8, 194, 37, 1) + ..style = PaintingStyle.fill; + canvas.drawCircle(constrainedCenter, 12.5, innerPaint); + } +} + +class _CenterBasedTrackShape extends SliderTrackShape { + const _CenterBasedTrackShape(); + + static const double horizontalPadding = 6.0; + + @override + Rect getPreferredRect({ + required RenderBox parentBox, + Offset offset = Offset.zero, + required SliderThemeData sliderTheme, + bool isEnabled = false, + bool isDiscrete = false, + }) { + final double trackHeight = sliderTheme.trackHeight ?? 8; + final double trackLeft = offset.dx + horizontalPadding; + final double trackTop = + offset.dy + (parentBox.size.height - trackHeight) / 2; + final double trackWidth = parentBox.size.width - (horizontalPadding * 2); + return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight); + } + + @override + void paint( + PaintingContext context, + Offset offset, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation enableAnimation, + required TextDirection textDirection, + required Offset thumbCenter, + Offset? secondaryOffset, + bool isEnabled = false, + bool isDiscrete = false, + double? additionalActiveTrackHeight, + }) { + final Canvas canvas = context.canvas; + final Rect trackRect = getPreferredRect( + parentBox: parentBox, + offset: offset, + sliderTheme: sliderTheme, + isEnabled: isEnabled, + isDiscrete: isDiscrete, + ); + + final double centerX = trackRect.left + trackRect.width / 2; + + final double clampedThumbDx = thumbCenter.dx.clamp( + trackRect.left, + trackRect.right, + ); + + final Paint inactivePaint = Paint() + ..color = sliderTheme.inactiveTrackColor! + ..style = PaintingStyle.fill; + + final RRect inactiveRRect = RRect.fromRectAndRadius( + trackRect, + Radius.circular(trackRect.height / 2), + ); + + canvas.drawRRect(inactiveRRect, inactivePaint); + + if (clampedThumbDx != centerX) { + final Paint activePaint = Paint() + ..color = sliderTheme.activeTrackColor! + ..style = PaintingStyle.fill; + + final Rect activeRect = clampedThumbDx >= centerX + ? Rect.fromLTWH( + centerX, + trackRect.top, + clampedThumbDx - centerX, + trackRect.height, + ) + : Rect.fromLTWH( + clampedThumbDx, + trackRect.top, + centerX - clampedThumbDx, + trackRect.height, + ); + + final RRect activeRRect = RRect.fromRectAndRadius( + activeRect, + Radius.circular(trackRect.height / 2), + ); + + canvas.drawRRect(activeRRect, activePaint); + } + } +} + +class CircularProgressPainter extends CustomPainter { + final double progress; + final bool isClockwise; + final Color color; + + CircularProgressPainter({ + required this.progress, + required this.isClockwise, + required this.color, + }); + + @override + void paint(Canvas canvas, Size size) { + const strokeWidth = 2.5; + final center = Offset(size.width / 2, size.height / 2); + final radius = (size.width - strokeWidth) / 2; + + final backgroundPaint = Paint() + ..color = Colors.transparent + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke; + + final foregroundPaint = Paint() + ..color = color + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + + canvas.drawCircle(center, radius, backgroundPaint); + + if (progress > 0) { + const startAngle = -pi / 2; + final sweepAngle = 2 * pi * progress * (isClockwise ? 1 : -1); + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + startAngle, + sweepAngle, + false, + foregroundPaint, + ); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} diff --git a/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart b/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart index 2df8e6520b..5cb0d8fcbe 100644 --- a/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart +++ b/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart @@ -19,6 +19,7 @@ import "package:photos/services/local_authentication_service.dart"; import "package:photos/states/detail_page_state.dart"; import "package:photos/ui/common/fast_scroll_physics.dart"; import 'package:photos/ui/notification/toast.dart'; +import "package:photos/ui/tools/editor/image_editor/image_editor_page_new.dart"; import 'package:photos/ui/tools/editor/image_editor_page.dart'; import "package:photos/ui/tools/editor/video_editor_page.dart"; import "package:photos/ui/viewer/file/file_app_bar.dart"; @@ -175,7 +176,7 @@ class _DetailPageState extends State { builder: (BuildContext context, int selectedIndex, _) { return FileBottomBar( _files![selectedIndex], - _onEditFileRequested, + _onNewImageEditor, widget.config.mode == DetailPageMode.minimalistic && !isGuestView, onFileRemoved: _onFileRemoved, @@ -357,6 +358,68 @@ class _DetailPageState extends State { } } + Future _onNewImageEditor(EnteFile file) async { + if (file.uploadedFileID != null && + file.ownerID != Configuration.instance.getUserID()) { + _logger.severe( + "Attempt to edit unowned file", + UnauthorizedEditError(), + StackTrace.current, + ); + // ignore: unawaited_futures + showErrorDialog( + context, + S.of(context).sorry, + S.of(context).weDontSupportEditingPhotosAndAlbumsThatYouDont, + ); + return; + } + final dialog = createProgressDialog(context, S.of(context).pleaseWait); + await dialog.show(); + + try { + final ioFile = await getFile(file); + if (ioFile == null) { + showShortToast(context, S.of(context).failedToFetchOriginalForEdit); + await dialog.hide(); + return; + } + if (file.fileType == FileType.video) { + await dialog.hide(); + replacePage( + context, + VideoEditorPage( + file: file, + ioFile: ioFile, + detailPageConfig: widget.config.copyWith( + files: _files, + selectedIndex: _selectedIndexNotifier.value, + ), + ), + ); + return; + } + final imageProvider = + ExtendedFileImageProvider(ioFile, cacheRawData: true); + await precacheImage(imageProvider, context); + await dialog.hide(); + replacePage( + context, + NewImageEditor( + originalFile: file, + file: ioFile, + detailPageConfig: widget.config.copyWith( + files: _files, + selectedIndex: _selectedIndexNotifier.value, + ), + ), + ); + } catch (e) { + await dialog.hide(); + _logger.warning("Failed to initiate edit", e); + } + } + Future _onEditFileRequested(EnteFile file) async { if (file.uploadedFileID != null && file.ownerID != Configuration.instance.getUserID()) { diff --git a/mobile/apps/photos/pubspec.lock b/mobile/apps/photos/pubspec.lock index f8cf5809fe..b5f0505424 100644 --- a/mobile/apps/photos/pubspec.lock +++ b/mobile/apps/photos/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" + sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 url: "https://pub.dev" source: hosted - version: "76.0.0" + version: "72.0.0" _flutterfire_internals: dependency: transitive description: @@ -21,7 +21,7 @@ packages: dependency: transitive description: dart source: sdk - version: "0.3.3" + version: "0.3.2" adaptive_theme: dependency: "direct main" description: @@ -34,10 +34,10 @@ packages: dependency: transitive description: name: analyzer - sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" + sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 url: "https://pub.dev" source: hosted - version: "6.11.0" + version: "6.7.0" android_intent_plus: dependency: "direct main" description: @@ -317,10 +317,10 @@ packages: dependency: "direct main" description: name: collection - sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.18.0" computer: dependency: "direct main" description: @@ -514,6 +514,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.17" + emoji_picker_flutter: + dependency: transitive + description: + name: emoji_picker_flutter + sha256: "08567e6f914d36c32091a96cf2f51d2558c47aa2bd47a590dc4f50e42e0965f6" + url: "https://pub.dev" + source: hosted + version: "3.1.0" encrypt: dependency: "direct main" description: @@ -1416,18 +1424,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.7" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.8" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -1536,10 +1544,10 @@ packages: dependency: transitive description: name: macros - sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" + sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" url: "https://pub.dev" source: hosted - version: "0.1.3-main.0" + version: "0.1.2-main.4" maps_launcher: dependency: "direct main" description: @@ -2064,6 +2072,14 @@ packages: url: "https://github.com/eddyuan/privacy_screen.git" source: git version: "0.0.6" + pro_image_editor: + dependency: "direct main" + description: + name: pro_image_editor + sha256: ee86d144ec76957578fb3dc7dee3d5e9cd03383cb153eb58f531be16ac528c63 + url: "https://pub.dev" + source: hosted + version: "6.0.0" process: dependency: transitive description: @@ -2309,7 +2325,7 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.0" + version: "0.0.99" source_gen: dependency: transitive description: @@ -2434,10 +2450,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.11.1" step_progress_indicator: dependency: "direct main" description: @@ -2466,10 +2482,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.2.0" styled_text: dependency: "direct main" description: @@ -2530,26 +2546,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "713a8789d62f3233c46b4a90b174737b2c04cb6ae4500f2aa8b1be8f03f5e67f" + sha256: "7ee44229615f8f642b68120165ae4c2a75fe77ae2065b1e55ae4711f6cf0899e" url: "https://pub.dev" source: hosted - version: "1.25.8" + version: "1.25.7" test_api: dependency: transitive description: name: test_api - sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.3" + version: "0.7.2" test_core: dependency: transitive description: name: test_core - sha256: "12391302411737c176b0b5d6491f466b0dd56d4763e347b6714efbaa74d7953d" + sha256: "55ea5a652e38a1dfb32943a7973f3681a60f872f8c3a05a14664ad54ef9c6696" url: "https://pub.dev" source: hosted - version: "0.6.5" + version: "0.6.4" thermal: dependency: "direct main" description: @@ -2742,6 +2758,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + vibration: + dependency: transitive + description: + name: vibration + sha256: "3b08a0579c2f9c18d5d78cb5c74f1005f731e02eeca6d72561a2e8059bf98ec3" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + vibration_platform_interface: + dependency: transitive + description: + name: vibration_platform_interface + sha256: "6ffeee63547562a6fef53c05a41d4fdcae2c0595b83ef59a4813b0612cd2bc36" + url: "https://pub.dev" + source: hosted + version: "0.0.3" video_editor: dependency: "direct main" description: @@ -2813,10 +2845,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.3.0" + version: "14.2.5" volume_controller: dependency: transitive description: @@ -2877,10 +2909,10 @@ packages: dependency: transitive description: name: webdriver - sha256: "3d773670966f02a646319410766d3b5e1037efb7f07cc68f844d5e06cd4d61c8" + sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" url: "https://pub.dev" source: hosted - version: "3.0.4" + version: "3.0.3" webkit_inspection_protocol: dependency: transitive description: diff --git a/mobile/apps/photos/pubspec.yaml b/mobile/apps/photos/pubspec.yaml index c57dedf75c..6bc57ac4d9 100644 --- a/mobile/apps/photos/pubspec.yaml +++ b/mobile/apps/photos/pubspec.yaml @@ -173,6 +173,7 @@ dependencies: git: url: https://github.com/eddyuan/privacy_screen.git ref: 855418e + pro_image_editor: 6.0.0 receive_sharing_intent: # pub.dev is behind git: url: https://github.com/KasemJaffer/receive_sharing_intent.git @@ -342,6 +343,7 @@ flutter: assets: - assets/ - assets/video-editor/ + - assets/image-editor/ - assets/icons/ - assets/launcher_icon/ fonts: