From 7a6fb1ba31ac9537dc5c0244373448d09ed59a67 Mon Sep 17 00:00:00 2001 From: AmanRajSinghMourya Date: Wed, 23 Jul 2025 23:47:03 +0530 Subject: [PATCH] Implemented new image editor --- .../image_editor_configs_mixin.dart | 20 + .../image_editor_crop_rotate.dart | 225 ++++++ .../image_editor/image_editor_filter_bar.dart | 101 +++ .../image_editor_main_bottom_bar.dart | 143 ++++ .../image_editor/image_editor_page_new.dart | 513 ++++++++++++++ .../image_editor/image_editor_paint_bar.dart | 88 +++ .../image_editor/image_editor_text_bar.dart | 366 ++++++++++ .../image_editor/image_editor_tune_bar.dart | 649 ++++++++++++++++++ 8 files changed, 2105 insertions(+) create mode 100644 mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_configs_mixin.dart create mode 100644 mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_crop_rotate.dart create mode 100644 mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_filter_bar.dart create mode 100644 mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_main_bottom_bar.dart create mode 100644 mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_page_new.dart create mode 100644 mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_paint_bar.dart create mode 100644 mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_text_bar.dart create mode 100644 mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_tune_bar.dart 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_crop_rotate.dart b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_crop_rotate.dart new file mode 100644 index 0000000000..e7e5c97c66 --- /dev/null +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_crop_rotate.dart @@ -0,0 +1,225 @@ +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, + 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: 12), + CircularIconButton( + svgPath: "assets/image-editor/image-editor-flip.svg", + label: "Flip", + onTap: () { + widget.editor.flip(); + }, + ), + ], + ), + const SizedBox(height: 20), + SizedBox( + height: 48, + 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(right: 12.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: 14, + vertical: 10, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (svg != null) ...[ + SvgPicture.asset( + svg!, + height: 40, + 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, + ), + ), + ], + ), + ), + ); + } +} 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..1fc6927c6e --- /dev/null +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_filter_bar.dart @@ -0,0 +1,101 @@ +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 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..1d5a9ff789 --- /dev/null +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_main_bottom_bar.dart @@ -0,0 +1,143 @@ +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-text.svg", + label: "Text", + onTap: () { + widget.editor.openTextEditor(); + }, + ), + 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.openPaintEditor(); + }, + ), + 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..da15c97924 --- /dev/null +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_page_new.dart @@ -0,0 +1,513 @@ +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:google_fonts/google_fonts.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/utils/editor_safe_area.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( + backgroundColor: colorScheme.backgroundBase, + body: ProImageEditor.file( + key: editorKey, + widget.file, + callbacks: ProImageEditorCallbacks( + mainEditorCallbacks: MainEditorCallbacks( + onStartCloseSubEditor: (value) { + _mainEditorBarKey.currentState?.setState(() {}); + }, + ), + ), + configs: ProImageEditorConfigs( + layerInteraction: const LayerInteractionConfigs( + 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, + ), + mainEditor: MainEditorConfigs( + style: MainEditorStyle( + appBarBackground: colorScheme.backgroundBase, + background: colorScheme.backgroundBase, + bottomBarBackground: colorScheme.backgroundBase, + ), + widgets: MainEditorWidgets( + removeLayerArea: (removeAreaKey, editor, rebuildStream) { + return Align( + alignment: Alignment.bottomCenter, + child: StreamBuilder( + stream: rebuildStream, + builder: (_, __) { + final isHovered = + editor.layerInteractionManager.hoverRemoveBtn; + + return AnimatedContainer( + key: removeAreaKey, + duration: const Duration(milliseconds: 150), + height: 56, + width: 56, + margin: const EdgeInsets.only(bottom: 24), + decoration: BoxDecoration( + color: isHovered + ? const Color.fromARGB(255, 255, 197, 197) + : const Color.fromARGB(255, 255, 255, 255), + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(12), + child: const Center( + child: Icon( + Icons.delete_forever_outlined, + size: 28, + color: Color(0xFFF44336), + ), + ), + ); + }, + ), + ); + }, + 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: PaintEditorConfigs( + style: PaintEditorStyle( + background: colorScheme.backgroundBase, + initialStrokeWidth: 5, + ), + widgets: PaintEditorWidgets( + 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: TextEditorConfigs( + canToggleTextAlign: true, + customTextStyles: [ + GoogleFonts.inter(), + GoogleFonts.giveYouGlory(), + GoogleFonts.dmSerifText(), + GoogleFonts.comicNeue(), + ], + safeArea: const EditorSafeArea( + bottom: false, + top: false, + ), + style: const TextEditorStyle( + background: Colors.transparent, + textFieldMargin: EdgeInsets.only(top: kToolbarHeight), + ), + widgets: TextEditorWidgets( + appBar: (textEditor, rebuildStream) => null, + 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: CropRotateEditorConfigs( + safeArea: const EditorSafeArea( + bottom: false, + top: false, + ), + style: CropRotateEditorStyle( + background: colorScheme.backgroundBase, + cropCornerColor: + Theme.of(context).colorScheme.imageEditorPrimaryColor, + ), + widgets: CropRotateEditorWidgets( + 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, + ), + ), + ), + ), + filterEditor: FilterEditorConfigs( + fadeInUpDuration: fadeInDuration, + fadeInUpStaggerDelayDuration: fadeInDelay, + safeArea: const EditorSafeArea(top: false), + style: FilterEditorStyle( + filterListSpacing: 7, + background: colorScheme.backgroundBase, + ), + widgets: FilterEditorWidgets( + 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, + 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: TuneEditorConfigs( + safeArea: const EditorSafeArea(top: false), + style: TuneEditorStyle( + background: colorScheme.backgroundBase, + ), + widgets: TuneEditorWidgets( + 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, + ); + }, + ), + ), + blurEditor: const BlurEditorConfigs( + enabled: false, + ), + emojiEditor: EmojiEditorConfigs( + icons: const EmojiEditorIcons(), + style: EmojiEditorStyle( + backgroundColor: colorScheme.backgroundBase, + emojiViewConfig: const EmojiViewConfig( + gridPadding: EdgeInsets.zero, + horizontalSpacing: 0, + verticalSpacing: 0, + recentsLimit: 40, + loadingIndicator: Center(child: CircularProgressIndicator()), + replaceEmojiOnLimitExceed: false, + ), + bottomActionBarConfig: const BottomActionBarConfig( + enabled: false, + ), + ), + ), + stickerEditor: const StickerEditorConfigs(enabled: false), + ), + ), + ); + } +} 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..48f7b2a040 --- /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 PaintEditorState 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..db0d16a6e3 --- /dev/null +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_text_bar.dart @@ -0,0 +1,366 @@ +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', + 'backgroundColor': isLightMode ? colorScheme.fillFaint : Colors.white, + 'border': null, + 'textColor': Colors.white, + 'innerBackgroundColor': Colors.black, + }, + LayerBackgroundMode.backgroundAndColor: { + 'text': 'Aa', + 'backgroundColor': isLightMode ? colorScheme.fillFaint : Colors.white, + 'border': null, + 'textColor': Colors.black, + 'innerBackgroundColor': Colors.transparent, + }, + LayerBackgroundMode.backgroundAndColorWithOpacity: { + 'text': 'Aa', + 'backgroundColor': isLightMode ? colorScheme.fillFaint : Colors.white, + 'border': null, + 'textColor': Colors.black, + 'innerBackgroundColor': Colors.black.withOpacity(0.11), + }, + LayerBackgroundMode.onlyColor: { + 'text': 'Aa', + 'backgroundColor': isLightMode ? colorScheme.fillFaint : Colors.black, + 'border': + isLightMode ? null : Border.all(color: Colors.white, width: 2), + 'textColor': Colors.black, + 'innerBackgroundColor': Colors.white, + }, + }; + + 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['backgroundColor'] as Color + : colorScheme.backgroundElevated2, + 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['innerBackgroundColor'] as Color + : colorScheme.backgroundElevated2, + 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..1ebc163103 --- /dev/null +++ b/mobile/apps/photos/lib/ui/tools/editor/image_editor/image_editor_tune_bar.dart @@ -0,0 +1,649 @@ +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 = _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; +}