diff --git a/assets/canvas/black.png b/assets/canvas/black.png new file mode 100644 index 00000000..82e08682 Binary files /dev/null and b/assets/canvas/black.png differ diff --git a/assets/canvas/red.png b/assets/canvas/red.png new file mode 100644 index 00000000..70bd9202 Binary files /dev/null and b/assets/canvas/red.png differ diff --git a/assets/canvas/white.png b/assets/canvas/white.png new file mode 100644 index 00000000..beffdb58 Binary files /dev/null and b/assets/canvas/white.png differ diff --git a/lib/pro_image_editor/core/constants/example_constants.dart b/lib/pro_image_editor/core/constants/example_constants.dart new file mode 100644 index 00000000..1a125e05 --- /dev/null +++ b/lib/pro_image_editor/core/constants/example_constants.dart @@ -0,0 +1,12 @@ +/// A path to a demo image asset in the project. +final String kImageEditorExampleAssetPath = 'assets/demo.png'; + +/// A URL to a demo image hosted on a remote server. +final String kImageEditorExampleNetworkUrl = + 'https://picsum.photos/id/230/2000'; + +/// A URL to a demo image hosted on a remote server. +final String kVideoEditorExampleAssetPath = 'assets/demo.mp4'; + +/// Breakpoint for desktop layout in the image editor example. +final kImageEditorExampleIsDesktopBreakPoint = 900; diff --git a/lib/pro_image_editor/core/mixin/example_helper.dart b/lib/pro_image_editor/core/mixin/example_helper.dart new file mode 100644 index 00000000..91da30b3 --- /dev/null +++ b/lib/pro_image_editor/core/mixin/example_helper.dart @@ -0,0 +1,192 @@ +// Dart imports: +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:vibration/vibration.dart'; + +import '../../features/preview/preview_img.dart'; +import '../constants/example_constants.dart'; +export '../../shared/widgets/prepare_image_widget.dart'; + +/// A mixin that provides helper methods and state management for image editing +/// using the [ProImageEditor]. It is intended to be used in a [StatefulWidget]. +mixin ExampleHelperState on State { + /// The global key used to reference the state of [ProImageEditor]. + final editorKey = GlobalKey(); + + /// Holds the edited image bytes after the editing is complete. + Uint8List? editedBytes; + + /// The time it took to generate the edited image in milliseconds. + double? _generationTime; + + /// Records the start time of the editing process. + DateTime? startEditingTime; + + /// Indicates whether image-resources are pre-cached. + bool isPreCached = true; + + bool _deviceCanVibrate = false; + bool _deviceCanCustomVibrate = false; + + @override + void initState() { + super.initState(); + + Vibration.hasVibrator().then((hasVibrator) async { + _deviceCanVibrate = hasVibrator; + + if (!hasVibrator || !mounted) return; + + _deviceCanCustomVibrate = await Vibration.hasCustomVibrationsSupport(); + }); + } + + /// Determines if the current layout should use desktop mode based on the + /// screen width. + /// + /// Returns `true` if the screen width is greater than or equal to + /// [kImageEditorExampleIsDesktopBreakPoint], otherwise `false`. + bool isDesktopMode(BuildContext context) => + MediaQuery.sizeOf(context).width >= + kImageEditorExampleIsDesktopBreakPoint; + + /// Called when the image editing process starts. + /// Records the time when editing began. + Future onImageEditingStarted() async { + startEditingTime = DateTime.now(); + } + + /// Called when the image editing process is complete. + /// Saves the edited image bytes and calculates the generation time. + /// + /// [bytes] is the edited image in bytes. + Future onImageEditingComplete(Uint8List bytes) async { + editedBytes = bytes; + setGenerationTime(); + } + + /// Calculates the time taken for the image generation in milliseconds + /// and stores it in [_generationTime]. + void setGenerationTime() { + if (startEditingTime != null) { + _generationTime = DateTime.now() + .difference(startEditingTime!) + .inMilliseconds + .toDouble(); + } + } + + /// Closes the image editor and navigates to a preview page showing the + /// edited image. + /// + /// If [showThumbnail] is true, a thumbnail of the image will be displayed. + /// The [rawOriginalImage] can be passed if the unedited image needs to be + /// shown. + /// The [generationConfigs] can be used to pass additional configurations for + /// generating the image. + void onCloseEditor({ + required EditorMode editorMode, + bool enablePop = true, + bool showThumbnail = false, + ui.Image? rawOriginalImage, + final ImageGenerationConfigs? generationConfigs, + }) async { + if (editorMode != EditorMode.main) return Navigator.pop(context); + + if (editedBytes != null) { + // Pre-cache the edited image to improve display performance. + await precacheImage(MemoryImage(editedBytes!), context); + if (!mounted) return; + + // Navigate to the preview page to display the edited image. + editorKey.currentState?.isPopScopeDisabled = true; + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return PreviewImgPage( + imgBytes: editedBytes!, + generationTime: _generationTime, + showThumbnail: showThumbnail, + rawOriginalImage: rawOriginalImage, + generationConfigs: generationConfigs, + ); + }, + ), + ).whenComplete(() { + // Reset the state variables after navigation. + editedBytes = null; + _generationTime = null; + startEditingTime = null; + }); + } + + if (mounted && enablePop) { + Navigator.pop(context); + } + } + + /// Preloads an image into memory to improve performance. + /// + /// Supports both asset and network images. Once the image is cached, it + /// updates the + /// [isPreCached] flag, triggers a widget rebuild, and optionally executes a + /// callback. + /// + /// Parameters: + /// - [assetPath]: The file path of the asset image to be cached. + /// - [networkUrl]: The URL of the network image to be cached. + /// - [onDone]: An optional callback executed after caching is complete. + /// + /// Ensures the widget is still mounted before performing operations. + void preCacheImage({ + String? assetPath, + String? networkUrl, + Function()? onDone, + }) { + isPreCached = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + precacheImage( + assetPath != null + ? AssetImage(assetPath) + : NetworkImage(networkUrl!) as ImageProvider, + context) + .whenComplete(() { + if (!mounted) return; + isPreCached = true; + setState(() {}); + onDone?.call(); + }); + }); + } + + /// Vibrates the device briefly if enabled and supported. + /// + /// If the device supports custom vibrations, it uses the `Vibration.vibrate` + /// method with a duration of 3 milliseconds to produce the vibration. + /// + /// On older Android devices, it initiates vibration using + /// `Vibration.vibrate`, and then, after 3 milliseconds, cancels the + /// vibration using `Vibration.cancel`. + /// + /// This function is used to provide haptic feedback when helper lines are + /// interacted with, enhancing the user experience. + void vibrateLineHit() { + if (_deviceCanVibrate && _deviceCanCustomVibrate) { + Vibration.vibrate(duration: 3); + } else if (!kIsWeb && Platform.isAndroid) { + /// On old android devices we can stop the vibration after 3 milliseconds + /// iOS: only works for custom haptic vibrations using CHHapticEngine. + /// This will set `deviceCanCustomVibrate` anyway to true so it's + /// impossible to fake it. + Vibration.vibrate(); + Future.delayed(const Duration(milliseconds: 3)) + .whenComplete(Vibration.cancel); + } + } +} diff --git a/lib/pro_image_editor/core/models/example_model.dart b/lib/pro_image_editor/core/models/example_model.dart new file mode 100644 index 00000000..b882f6bd --- /dev/null +++ b/lib/pro_image_editor/core/models/example_model.dart @@ -0,0 +1,33 @@ +import 'package:flutter/widgets.dart'; + +/// Represents an example item with a name, path, icon, and associated page. +/// +/// This class is typically used to define examples in an application, where +/// each example has: +/// - A [path]: The navigation route or identifier for the example. +/// - A [name]: A user-friendly name for the example. +/// - An [icon]: An icon representing the example. +/// - A [page]: The widget representing the content of the example. +class Example { + /// Creates an instance of [Example]. + /// + /// All fields are required. + const Example({ + required this.path, + required this.name, + required this.icon, + required this.page, + }); + + /// The navigation route or identifier for the example. + final String path; + + /// The user-friendly name of the example. + final String name; + + /// The icon representing the example. + final IconData icon; + + /// The widget representing the content of the example. + final Widget page; +} diff --git a/lib/pro_image_editor/features/bottom_bar.dart b/lib/pro_image_editor/features/bottom_bar.dart new file mode 100644 index 00000000..b89d09d0 --- /dev/null +++ b/lib/pro_image_editor/features/bottom_bar.dart @@ -0,0 +1,211 @@ +// Flutter imports: +import 'package:flutter/material.dart'; +import 'package:magic_epaper_app/pro_image_editor/features/color_picker.dart'; +import 'package:pro_image_editor/core/models/editor_configs/pro_image_editor_configs.dart'; +import 'package:pro_image_editor/core/ui/pro_image_editor_icons.dart'; + +/// Represents the bottom bar for the paint functionality in the WhatsApp theme. +/// +/// This widget provides controls for adjusting the stroke width and color +/// for paint operations, using a design inspired by WhatsApp. +class BottomBarCustom extends StatefulWidget { + /// Creates a [BottomBarCustom] widget. + /// + /// This bottom bar allows users to select stroke widths and colors for + /// paint, integrating seamlessly with the WhatsApp theme. + /// + /// Example: + /// ``` + /// BottomBarCustom( + /// configs: myEditorConfigs, + /// strokeWidth: 5.0, + /// onSetLineWidth: (width) { + /// // Handle stroke width change + /// }, + /// initColor: Colors.black, + /// onColorChanged: (color) { + /// // Handle color change + /// }, + /// ) + /// ``` + const BottomBarCustom({ + super.key, + required this.configs, + required this.strokeWidth, + required this.onSetLineWidth, + required this.initColor, + required this.onColorChanged, + this.iconStrokeWidthThin = ProImageEditorIcons.penSize1, + this.iconStrokeWidthMedium = ProImageEditorIcons.penSize2, + this.iconStrokeWidthBold = ProImageEditorIcons.penSize3, + }); + + /// The configuration for the image editor. + /// + /// These settings determine various aspects of the bottom bar's behavior + /// and appearance, ensuring it aligns with the application's overall theme. + final ProImageEditorConfigs configs; + + /// The current stroke width for paint. + /// + /// This value determines the thickness of the lines drawn in the paint + /// editor, allowing users to customize the appearance of their artwork. + final double strokeWidth; + + /// Callback function for setting the stroke width. + /// + /// This function is called whenever the user selects a different stroke + /// width, allowing the application to update the line thickness. + final Function(double value) onSetLineWidth; + + /// The initial color for paint. + /// + /// This color sets the initial paint color, providing a starting point + /// for color customization. + final Color initColor; + + /// Callback function for handling color changes. + /// + /// This function is called whenever the user selects a new color, allowing + /// the application to update the paint color. + final ValueChanged onColorChanged; + + /// Icon representing thin stroke width. + /// + /// This icon is used to visually represent the option for selecting a + /// thin stroke width in the paint toolbar. + final IconData iconStrokeWidthThin; + + /// Icon representing medium stroke width. + /// + /// This icon is used to visually represent the option for selecting a + /// medium stroke width in the paint toolbar. + final IconData iconStrokeWidthMedium; + + /// Icon representing bold stroke width. + /// + /// This icon is used to visually represent the option for selecting a + /// bold stroke width in the paint toolbar. + final IconData iconStrokeWidthBold; + + @override + State createState() => _BottomBarCustomState(); +} + +class _BottomBarCustomState extends State { + final double _space = 10; + + bool _showColorPicker = true; + + bool get _isMaterial => + widget.configs.designMode == ImageEditorDesignMode.material; + + @override + Widget build(BuildContext context) { + return Positioned( + bottom: _space, + left: _space, + right: 0, + height: 40, + child: !_isMaterial + ? _buildLineWidths() + : Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + IconButton( + onPressed: () { + setState(() { + _showColorPicker = !_showColorPicker; + }); + }, + icon: Icon( + _showColorPicker ? Icons.draw : Icons.color_lens, + ), + style: IconButton.styleFrom(backgroundColor: Colors.black38), + ), + Container( + margin: const EdgeInsets.fromLTRB(14.0, 4, 0, 4), + width: 1.5, + decoration: BoxDecoration( + color: Colors.white54, + borderRadius: BorderRadius.circular(2), + ), + ), + _showColorPicker + ? Expanded( + child: ColorPickerCustom( + onColorChanged: widget.onColorChanged, + initColor: widget.initColor, + ), + ) + : _buildLineWidths(), + ], + ), + ); + } + + Widget _buildLineWidths() { + ButtonStyle buttonStyle = IconButton.styleFrom( + backgroundColor: Colors.black38, + foregroundColor: + widget.configs.paintEditor.style.bottomBarInactiveItemColor, + padding: const EdgeInsets.all(10), + iconSize: 22, + minimumSize: const Size.fromRadius(10), + ); + return Padding( + padding: const EdgeInsets.only(left: 14), + child: Wrap( + alignment: + _isMaterial ? WrapAlignment.start : WrapAlignment.spaceEvenly, + runAlignment: WrapAlignment.center, + spacing: 10, + children: [ + IconButton( + onPressed: () { + widget.onSetLineWidth(2); + }, + icon: Icon(widget.iconStrokeWidthThin), + style: buttonStyle.copyWith( + backgroundColor: widget.strokeWidth != 2 + ? null + : const WidgetStatePropertyAll(Colors.white), + foregroundColor: widget.strokeWidth != 2 + ? null + : const WidgetStatePropertyAll(Colors.black), + ), + ), + IconButton( + onPressed: () { + widget.onSetLineWidth(5); + }, + icon: Icon(widget.iconStrokeWidthMedium), + style: buttonStyle.copyWith( + backgroundColor: widget.strokeWidth != 5 + ? null + : const WidgetStatePropertyAll(Colors.white), + foregroundColor: widget.strokeWidth != 5 + ? null + : const WidgetStatePropertyAll(Colors.black), + ), + ), + IconButton( + onPressed: () { + widget.onSetLineWidth(10); + }, + icon: Icon(widget.iconStrokeWidthBold), + style: buttonStyle.copyWith( + backgroundColor: widget.strokeWidth != 10 + ? null + : const WidgetStatePropertyAll(Colors.white), + foregroundColor: widget.strokeWidth != 10 + ? null + : const WidgetStatePropertyAll(Colors.black), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pro_image_editor/features/color_picker.dart b/lib/pro_image_editor/features/color_picker.dart new file mode 100644 index 00000000..5caa9f01 --- /dev/null +++ b/lib/pro_image_editor/features/color_picker.dart @@ -0,0 +1,102 @@ +// Flutter imports: +import 'package:flutter/material.dart'; + +/// A stateful widget that provides a color picker inspired by WhatsApp. +/// +/// This color picker allows users to select a color, providing a callback for +/// color changes and initializing with a specified color. +class ColorPickerCustom extends StatefulWidget { + /// Creates a [ColorPickerCustom]. + /// + /// This color picker lets users select a color, triggering a callback when + /// the color changes, and initializing with a specified color. + /// + /// Example: + /// ``` + /// ColorPickerCustom( + /// onColorChanged: (color) { + /// // Handle color change + /// }, + /// initColor: Colors.blue, + /// ) + /// ``` + const ColorPickerCustom({ + super.key, + required this.onColorChanged, + required this.initColor, + }); + + /// Callback for handling color changes. + /// + /// This callback is triggered whenever the user selects a new color, allowing + /// the application to update its UI or perform other actions. + final ValueChanged onColorChanged; + + /// The initial color selected in the color picker. + /// + /// This color sets the initial value of the picker, providing a starting + /// point for color selection. + final Color initColor; + + @override + State createState() => _ColorPickerCustomState(); +} + +class _ColorPickerCustomState extends State { + Color _selectedColor = Colors.black; + + final List _colors = [ + Colors.white, + Colors.black, + Colors.red, + ]; + + @override + void initState() { + super.initState(); + _selectedColor = widget.initColor; + } + + @override + Widget build(BuildContext context) { + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 14), + scrollDirection: Axis.horizontal, + primary: false, + shrinkWrap: true, + itemBuilder: (context, index) { + Color color = _colors[index]; + bool selected = _selectedColor == color; + + double size = !selected ? 20 : 24; + double borderWidth = !selected ? 2.5 : 4; + + return Center( + child: GestureDetector( + onTap: () { + setState(() { + _selectedColor = color; + widget.onColorChanged(color); + }); + }, + child: AnimatedContainer( + margin: const EdgeInsets.symmetric(horizontal: 10.0), + duration: const Duration(milliseconds: 100), + width: size, + height: size, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(100), + border: Border.all( + color: Colors.grey, + width: borderWidth, + ), + ), + ), + ), + ); + }, + itemCount: _colors.length, + ); + } +} diff --git a/lib/pro_image_editor/features/movable_background_image.dart b/lib/pro_image_editor/features/movable_background_image.dart new file mode 100644 index 00000000..6e37795c --- /dev/null +++ b/lib/pro_image_editor/features/movable_background_image.dart @@ -0,0 +1,665 @@ +// Dart imports: +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:magic_epaper_app/pro_image_editor/features/bottom_bar.dart'; +import 'package:magic_epaper_app/pro_image_editor/features/text_bottom_bar.dart'; +import 'package:pro_image_editor/designs/whatsapp/whatsapp_paint_colorpicker.dart'; +import 'package:pro_image_editor/designs/whatsapp/whatsapp_text_colorpicker.dart'; +import 'package:pro_image_editor/designs/whatsapp/whatsapp_text_size_slider.dart'; +import 'package:pro_image_editor/designs/whatsapp/widgets/appbar/whatsapp_paint_appbar.dart'; +import 'package:pro_image_editor/designs/whatsapp/widgets/appbar/whatsapp_text_appbar.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import '../core/mixin/example_helper.dart'; +import '../shared/widgets/material_icon_button.dart'; +import '../shared/widgets/pixel_transparent_painter.dart'; +import 'reorder_layer_example.dart'; + +final bool _useMaterialDesign = + platformDesignMode == ImageEditorDesignMode.material; + +/// The example for movableBackground +class MovableBackgroundImageExample extends StatefulWidget { + /// Creates a new [MovableBackgroundImageExample] widget. + const MovableBackgroundImageExample({super.key}); + + @override + State createState() => + _MovableBackgroundImageExampleState(); +} + +class _MovableBackgroundImageExampleState + extends State + with ExampleHelperState { + late final ScrollController _bottomBarScrollCtrl; + //Uint8List? _transparentBytes; + //double _transparentAspectRatio = -1; + String _currentCanvasColor = 'white'; + + /// Better sense of scale when we start with a large number + final double _initScale = 20; + + /// set the aspect ratio from your image. + final double _imgRatio = 1; + + final _bottomTextStyle = const TextStyle(fontSize: 10.0, color: Colors.white); + + @override + void initState() { + super.initState(); + preCacheImage(assetPath: 'assets/canvas/white.png'); + preCacheImage(assetPath: 'assets/canvas/red.png'); + preCacheImage(assetPath: 'assets/canvas/black.png'); + //_createTransparentImage(_imgRatio); + _bottomBarScrollCtrl = ScrollController(); + } + + @override + void dispose() { + _bottomBarScrollCtrl.dispose(); + super.dispose(); + } + + void _openPicker(ImageSource source) async { + final ImagePicker picker = ImagePicker(); + final XFile? image = await picker.pickImage(source: source); + + if (image == null) return; + + Uint8List? bytes; + + bytes = await image.readAsBytes(); + + if (!mounted) return; + await precacheImage(MemoryImage(bytes), context); + var decodedImage = await decodeImageFromList(bytes); + + if (!mounted) return; + if (kIsWeb || + (!Platform.isWindows && !Platform.isLinux && !Platform.isMacOS)) { + Navigator.pop(context); + } + + editorKey.currentState!.addLayer( + WidgetLayer( + offset: Offset.zero, + scale: _initScale * 0.5, + widget: Image.memory( + bytes, + width: decodedImage.width.toDouble(), + height: decodedImage.height.toDouble(), + fit: BoxFit.cover, + ), + ), + ); + setState(() {}); + } + + void _chooseCameraOrGallery() async { + /// Open directly the gallery if the camera is not supported + if (!kIsWeb && + (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { + _openPicker(ImageSource.gallery); + return; + } + + if (!kIsWeb && Platform.isIOS) { + await showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => CupertinoTheme( + data: const CupertinoThemeData(), + child: CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction( + onPressed: () => _openPicker(ImageSource.camera), + child: const Wrap( + spacing: 7, + runAlignment: WrapAlignment.center, + children: [ + Icon(CupertinoIcons.photo_camera), + Text('Camera'), + ], + ), + ), + CupertinoActionSheetAction( + onPressed: () => _openPicker(ImageSource.gallery), + child: const Wrap( + spacing: 7, + runAlignment: WrapAlignment.center, + children: [ + Icon(CupertinoIcons.photo), + Text('Gallery'), + ], + ), + ), + ], + cancelButton: CupertinoActionSheetAction( + isDefaultAction: true, + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Cancel'), + ), + ), + ), + ); + } else { + await showModalBottomSheet( + context: context, + showDragHandle: true, + constraints: BoxConstraints( + minWidth: min(MediaQuery.sizeOf(context).width, 360), + ), + builder: (context) { + return Material( + color: Colors.transparent, + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(bottom: 24, left: 16, right: 16), + child: Wrap( + spacing: 45, + runSpacing: 30, + crossAxisAlignment: WrapCrossAlignment.center, + runAlignment: WrapAlignment.center, + alignment: WrapAlignment.spaceAround, + children: [ + MaterialIconActionButton( + primaryColor: const Color(0xFFEC407A), + secondaryColor: const Color(0xFFD3396D), + icon: Icons.photo_camera, + text: 'Camera', + onTap: () => _openPicker(ImageSource.camera), + ), + MaterialIconActionButton( + primaryColor: const Color(0xFFBF59CF), + secondaryColor: const Color(0xFFAC44CF), + icon: Icons.image, + text: 'Gallery', + onTap: () => _openPicker(ImageSource.gallery), + ), + ], + ), + ), + ), + ); + }, + ); + } + } + + // Future _createTransparentImage(double aspectRatio) async { + // //double minSize = 1; + + // double width = 240; + // double height = 416; + + // final recorder = ui.PictureRecorder(); + // final canvas = Canvas( + // recorder, Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble())); + // final paint = Paint()..color = Colors.white; + // canvas.drawRect( + // Rect.fromLTWH(0.0, 0.0, width.toDouble(), height.toDouble()), paint); + + // final picture = recorder.endRecording(); + // final img = await picture.toImage(width.toInt(), height.toInt()); + // final pngBytes = await img.toByteData(format: ui.ImageByteFormat.png); + + // _transparentAspectRatio = aspectRatio; + // _transparentBytes = pngBytes!.buffer.asUint8List(); + // } + +// Add this method to handle canvas color changes + void _changeCanvasColor() { + setState(() { + // Cycle through colors: white -> red -> black + switch (_currentCanvasColor) { + case 'white': + _currentCanvasColor = 'red'; + break; + case 'red': + _currentCanvasColor = 'black'; + break; + case 'black': + _currentCanvasColor = 'white'; + break; + } + }); + + // Update the canvas by replacing the first layer + editorKey.currentState?.replaceLayer( + index: 0, // Replace first layer (canvas) + layer: WidgetLayer( + offset: Offset.zero, + scale: _initScale, + widget: Image.asset( + 'assets/canvas/${_currentCanvasColor}.png', + width: _editorSize.width, + height: _editorSize.height, + fit: BoxFit.cover, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + return AnimatedSwitcher( + layoutBuilder: (currentChild, previousChildren) { + return SizedBox( + width: 240, + height: 416, + child: Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ), + ); + }, + duration: const Duration(milliseconds: 100), + child: frame != null + ? child + : const Center( + child: CircularProgressIndicator(), + ), + ); + }, + ), + ), + ); + } + + Size get _editorSize => Size( + MediaQuery.sizeOf(context).width - + MediaQuery.paddingOf(context).horizontal, + MediaQuery.sizeOf(context).height - + kToolbarHeight - + kBottomNavigationBarHeight - + MediaQuery.paddingOf(context).vertical, + ); + + void _openReorderSheet(ProImageEditorState editor) { + showModalBottomSheet( + context: context, + builder: (context) { + return ReorderLayerSheet( + layers: editor.activeLayers, + onReorder: (oldIndex, newIndex) { + editor.moveLayerListPosition( + oldIndex: oldIndex, + newIndex: newIndex, + ); + Navigator.pop(context); + }, + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + /*if (_transparentBytes == null || !isPreCached) { + return const PrepareImageWidget(); + } + print('_transparentBytes: ${_transparentBytes!}'); + var i; + for (i in _transparentBytes!) { + print(i); + }*/ + + return LayoutBuilder(builder: (context, constraints) { + return CustomPaint( + size: Size(constraints.maxWidth, constraints.maxHeight), + painter: const PixelTransparentPainter( + primary: Colors.white, + secondary: Color(0xFFE2E2E2), + ), + child: ProImageEditor.asset( + 'assets/canvas/white.png', + key: editorKey, + callbacks: ProImageEditorCallbacks( + onImageEditingStarted: onImageEditingStarted, + onImageEditingComplete: (Uint8List bytes) async { + Navigator.pop(context, bytes); + }, + onCloseEditor: (editorMode) { + // Handle normal close without editing completion + Navigator.of(context).pop(); + }, + mainEditorCallbacks: MainEditorCallbacks( + helperLines: HelperLinesCallbacks(onLineHit: vibrateLineHit), + onAfterViewInit: () { + editorKey.currentState!.addLayer( + WidgetLayer( + offset: Offset.zero, + scale: _initScale, + widget: Image.asset( + 'assets/canvas/white.png', + width: 240, + height: 416, + frameBuilder: + (context, child, frame, wasSynchronouslyLoaded) { + return AnimatedSwitcher( + layoutBuilder: (currentChild, previousChildren) { + return SizedBox( + width: 240, + height: 416, + child: Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ), + ); + }, + duration: const Duration(milliseconds: 1), + child: frame != null + ? child + : const Center( + child: CircularProgressIndicator(), + ), + ); + }, + ), + ), + ); + }, + ), + ), + configs: ProImageEditorConfigs( + designMode: platformDesignMode, + imageGeneration: ImageGenerationConfigs( + cropToDrawingBounds: true, + allowEmptyEditingCompletion: false, + outputFormat: OutputFormat.png, + + /// Set the pixel ratio manually. You can also set this + /// value higher than the device pixel ratio for higher + /// quality. + customPixelRatio: max(2000 / MediaQuery.sizeOf(context).width, + MediaQuery.devicePixelRatioOf(context)), + ), + mainEditor: MainEditorConfigs( + enableCloseButton: !isDesktopMode(context), + widgets: MainEditorWidgets( + bodyItems: (editor, rebuildStream) { + return [ + ReactiveWidget( + stream: rebuildStream, + builder: (_) => editor.selectedLayerIndex >= 0 || + editor.isSubEditorOpen + ? const SizedBox.shrink() + : Positioned( + bottom: 20, + left: 0, + child: Container( + decoration: BoxDecoration( + color: Colors.lightBlue.shade200, + borderRadius: const BorderRadius.only( + topRight: Radius.circular(100), + bottomRight: Radius.circular(100), + ), + ), + child: IconButton( + onPressed: () => _openReorderSheet(editor), + icon: const Icon( + Icons.reorder, + color: Colors.white, + ), + ), + ), + ), + ), + ]; + }, + bottomBar: (editor, rebuildStream, key) => ReactiveWidget( + stream: rebuildStream, + key: key, + builder: (_) => _bottomNavigationBar( + editor, + constraints, + ), + ), + ), + style: const MainEditorStyle( + uiOverlayStyle: SystemUiOverlayStyle( + statusBarColor: Colors.black, + ), + background: Colors.transparent, + ), + ), + paintEditor: PaintEditorConfigs( + widgets: PaintEditorWidgets( + colorPicker: (editor, rebuildStream, currentColor, setColor) => + null, + bodyItems: _buildPaintEditorBody, + ), + style: const PaintEditorStyle( + uiOverlayStyle: SystemUiOverlayStyle( + statusBarColor: Colors.black, + ), + background: Colors.transparent, + ), + ), + textEditor: TextEditorConfigs( + customTextStyles: [ + GoogleFonts.roboto(), + GoogleFonts.averiaLibre(), + GoogleFonts.lato(), + GoogleFonts.comicNeue(), + GoogleFonts.actor(), + GoogleFonts.odorMeanChey(), + GoogleFonts.nabla(), + ], + widgets: TextEditorWidgets( + appBar: (textEditor, rebuildStream) => null, + colorPicker: (editor, rebuildStream, currentColor, setColor) => + null, + bottomBar: (textEditor, rebuildStream) => null, + bodyItems: _buildTextEditorBody, + ), + style: TextEditorStyle( + textFieldMargin: EdgeInsets.zero, + bottomBarBackground: Colors.transparent, + bottomBarMainAxisAlignment: !_useMaterialDesign + ? MainAxisAlignment.spaceEvenly + : MainAxisAlignment.start), + ), + + /// Crop-Rotate, Filter and Blur editors are not supported + cropRotateEditor: const CropRotateEditorConfigs(enabled: false), + filterEditor: const FilterEditorConfigs(enabled: false), + blurEditor: const BlurEditorConfigs(enabled: false), + + stickerEditor: StickerEditorConfigs( + enabled: false, + initWidth: (_editorSize.aspectRatio > _imgRatio + ? _editorSize.height + : _editorSize.width) / + _initScale, + buildStickers: (setLayer, scrollController) { + // Optionally your code to pick layers + return const SizedBox(); + }, + ), + ), + ), + ); + }); + } + + Widget _bottomNavigationBar( + ProImageEditorState editor, + BoxConstraints constraints, + ) { + return Scrollbar( + controller: _bottomBarScrollCtrl, + scrollbarOrientation: ScrollbarOrientation.top, + thickness: isDesktop ? null : 0, + child: BottomAppBar( + /// kBottomNavigationBarHeight is important that helper-lines will work + height: kBottomNavigationBarHeight, + color: Colors.black, + padding: EdgeInsets.zero, + child: Center( + child: SingleChildScrollView( + controller: _bottomBarScrollCtrl, + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: min(constraints.maxWidth, 500), + maxWidth: 500, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: [ + FlatIconTextButton( + label: Text('Canvas Color', style: _bottomTextStyle), + icon: const Icon( + Icons.check_box_outline_blank, + size: 22.0, + color: Colors.white, + ), + onPressed: _changeCanvasColor, + ), + FlatIconTextButton( + label: Text('Add Image', style: _bottomTextStyle), + icon: const Icon( + Icons.image_outlined, + size: 22.0, + color: Colors.white, + ), + onPressed: _chooseCameraOrGallery, + ), + FlatIconTextButton( + label: Text('Paint', style: _bottomTextStyle), + icon: const Icon( + Icons.edit_rounded, + size: 22.0, + color: Colors.white, + ), + onPressed: editor.openPaintEditor, + ), + FlatIconTextButton( + label: Text('Text', style: _bottomTextStyle), + icon: const Icon( + Icons.text_fields, + size: 22.0, + color: Colors.white, + ), + onPressed: editor.openTextEditor, + ), + FlatIconTextButton( + label: Text('Emoji', style: _bottomTextStyle), + icon: const Icon( + Icons.sentiment_satisfied_alt_rounded, + size: 22.0, + color: Colors.white, + ), + onPressed: editor.openEmojiEditor, + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +List _buildPaintEditorBody( + PaintEditorState paintEditor, + Stream rebuildStream, +) { + return [ + ReactiveWidget( + stream: rebuildStream, + builder: (_) => BottomBarCustom( + configs: paintEditor.configs, + strokeWidth: paintEditor.paintCtrl.strokeWidth, + initColor: paintEditor.paintCtrl.color, + onColorChanged: (color) { + paintEditor.paintCtrl.setColor(color); + paintEditor.uiPickerStream.add(null); + }, + onSetLineWidth: paintEditor.setStrokeWidth, + ), + ), + if (!_useMaterialDesign) + ReactiveWidget( + stream: rebuildStream, + builder: (_) => WhatsappPaintColorpicker(paintEditor: paintEditor), + ), + ReactiveWidget( + stream: rebuildStream, + builder: (_) => WhatsAppPaintAppBar( + configs: paintEditor.configs, + canUndo: paintEditor.canUndo, + onDone: paintEditor.done, + onTapUndo: paintEditor.undoAction, + onClose: paintEditor.close, + activeColor: paintEditor.activeColor, + ), + ), + ]; +} + +List _buildTextEditorBody( + TextEditorState textEditor, + Stream rebuildStream, +) { + return [ + /// Color-Picker + if (_useMaterialDesign) + ReactiveWidget( + stream: rebuildStream, + builder: (_) => Padding( + padding: const EdgeInsets.only(top: kToolbarHeight), + child: WhatsappTextSizeSlider(textEditor: textEditor), + ), + ) + else + ReactiveWidget( + stream: rebuildStream, + builder: (_) => Padding( + padding: const EdgeInsets.only(top: kToolbarHeight), + child: WhatsappTextColorpicker(textEditor: textEditor), + ), + ), + + /// Appbar + ReactiveWidget( + stream: rebuildStream, + builder: (_) => WhatsAppTextAppBar( + configs: textEditor.configs, + align: textEditor.align, + onDone: textEditor.done, + onAlignChange: textEditor.toggleTextAlign, + onBackgroundModeChange: textEditor.toggleBackgroundMode, + ), + ), + + /// Bottombar + ReactiveWidget( + stream: rebuildStream, + builder: (_) => TextBottomBar( + configs: textEditor.configs, + initColor: textEditor.primaryColor, + onColorChanged: (color) { + textEditor.primaryColor = color; + }, + selectedStyle: textEditor.selectedTextStyle, + onFontChange: textEditor.setTextStyle, + ), + ), + ]; +} diff --git a/lib/pro_image_editor/features/preview/preview_img.dart b/lib/pro_image_editor/features/preview/preview_img.dart new file mode 100644 index 00000000..5bd2ec08 --- /dev/null +++ b/lib/pro_image_editor/features/preview/preview_img.dart @@ -0,0 +1,361 @@ +// Dart imports: +import 'dart:io'; +import 'dart:math'; +import 'dart:ui' as ui; + +import 'package:bot_toast/bot_toast.dart'; +import 'package:file_saver/file_saver.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:gal/gal.dart'; +import 'package:intl/intl.dart'; +import 'package:mime/mime.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; + +import '../../shared/widgets/pixel_transparent_painter.dart'; + +/// A page that displays a preview of the generated image. +/// +/// The [PreviewImgPage] widget is a stateful widget that shows a preview of +/// an image created using the provided [imgBytes]. It also supports showing +/// a thumbnail of the original image if [showThumbnail] is set to true. +/// +/// The page can display additional information such as [generationTime], the +/// original raw image as [rawOriginalImage], and optional [generationConfigs] +/// used during the image creation process. +/// +/// If [showThumbnail] is set to true, [rawOriginalImage] must be provided. +/// +/// Example usage: +/// ```dart +/// PreviewImgPage( +/// imgBytes: generatedImageBytes, +/// generationTime: 1200, +/// rawOriginalImage: originalImage, +/// showThumbnail: true, +/// ); +/// ``` +class PreviewImgPage extends StatefulWidget { + /// Creates a new [PreviewImgPage] widget. + /// + /// The [imgBytes] parameter is required and contains the generated image + /// data to be displayed. The [generationTime] is optional and represents + /// the time taken to generate the image. If [showThumbnail] is true, + /// [rawOriginalImage] must be provided. + const PreviewImgPage({ + super.key, + required this.imgBytes, + this.generationTime, + this.rawOriginalImage, + this.generationConfigs, + this.showThumbnail = false, + }) : assert( + showThumbnail == false || rawOriginalImage != null, + 'rawOriginalImage is required if you want to display a thumbnail.', + ); + + /// The image data in bytes to be displayed. + final Uint8List imgBytes; + + /// The time taken to generate the image, in milliseconds. + final double? generationTime; + + /// Whether or not to show a thumbnail of the original image. + final bool showThumbnail; + + /// The original raw image, required if [showThumbnail] is true. + final ui.Image? rawOriginalImage; + + /// Optional configurations used during image generation. + final ImageGenerationConfigs? generationConfigs; + + @override + State createState() => _PreviewImgPageState(); +} + +/// The state for the [PreviewImgPage] widget. +/// +/// This class manages the logic and display of the preview image and optional +/// thumbnail, along with any associated generation information. +class _PreviewImgPageState extends State { + final _valueStyle = const TextStyle(fontStyle: FontStyle.italic); + + Future? _decodedImageInfos; + String _contentType = 'Unknown'; + double? _generationTime; + + Future? _highQualityGeneration; + + late Uint8List _imageBytes; + + final _numberFormatter = NumberFormat(); + + Future _downloadImage() async { + try { + var cancelLoading = BotToast.showLoading(); //popup a loading toast + + List contentSp = _contentType.split('/'); + + String fileName = 'pro_image_editor_' + '${DateTime.now().millisecondsSinceEpoch}' + '.${contentSp.length == 2 ? contentSp[1] : 'jpeg'}'; + + if (kIsWeb || Platform.isWindows || Platform.isLinux) { + final filePath = await FileSaver.instance.saveFile( + name: fileName, + bytes: _imageBytes, + ); + BotToast.showText( + text: 'Image saved at: $filePath', + duration: const Duration(seconds: 7), + ); + } else { + // Check for access permission + final hasAccess = await Gal.hasAccess(); + if (!hasAccess) { + await Gal.requestAccess(); + } + + await Gal.putImageBytes( + _imageBytes, + name: fileName, + ); + BotToast.showText( + text: 'Image saved', + duration: const Duration(seconds: 7), + ); + } + cancelLoading(); + } catch (e) { + debugPrint('Error downloading image: $e'); + } + } + + @override + void initState() { + super.initState(); + _generationTime = widget.generationTime; + _imageBytes = widget.imgBytes; + _setContentType(); + } + + void _setContentType() { + _contentType = lookupMimeType('', headerBytes: _imageBytes) ?? 'Unknown'; + } + + String formatBytes(int bytes, [int decimals = 2]) { + if (bytes <= 0) return '0 B'; + const suffixes = ['B', 'KB', 'MB', 'GB', 'TB']; + var i = (log(bytes) / log(1024)).floor(); + var size = bytes / pow(1024, i); + return '${size.toStringAsFixed(decimals)} ${suffixes[i]}'; + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (context, constraints) { + _decodedImageInfos ??= + decodeImageInfos(bytes: _imageBytes, screenSize: constraints.biggest); + + if (widget.showThumbnail) { + Stopwatch stopwatch = Stopwatch()..start(); + _highQualityGeneration ??= generateHighQualityImage( + widget.rawOriginalImage!, + + /// Set optional configs for the output + configs: widget.generationConfigs ?? const ImageGenerationConfigs(), + context: context, + ).then((res) { + if (res == null) return res; + _imageBytes = res; + _generationTime = stopwatch.elapsedMilliseconds.toDouble(); + stopwatch.stop(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _decodedImageInfos = null; + _setContentType(); + setState(() {}); + }); + return res; + }); + } + return Theme( + data: Theme.of(context), + child: Scaffold( + appBar: AppBar( + title: const Text('Result'), + ), + body: CustomPaint( + painter: const PixelTransparentPainter( + primary: Color.fromARGB(255, 17, 17, 17), + secondary: Color.fromARGB(255, 36, 36, 37), + ), + child: Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + if (!widget.showThumbnail) + Hero( + tag: const ProImageEditorConfigs().heroTag, + child: _buildFinalImage(), + ) + else + _buildThumbnailPreview(), + if (_generationTime != null) _buildGenerationInfos(), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: _downloadImage, + child: const Icon(Icons.download), + ), + ), + ); + }); + } + + Widget _buildFinalImage({Uint8List? bytes}) { + return InteractiveViewer( + maxScale: 7, + minScale: 1, + child: Image.memory( + bytes ?? _imageBytes, + fit: BoxFit.contain, + ), + ); + } + + Widget _buildGenerationInfos() { + TableRow tableSpace = const TableRow( + children: [SizedBox(height: 3), SizedBox()], + ); + return Positioned( + top: 10, + child: ClipRect( + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: 6, sigmaY: 6), + child: Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(7), + ), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + child: FutureBuilder( + future: _decodedImageInfos, + builder: (context, snapshot) { + return Table( + defaultColumnWidth: const IntrinsicColumnWidth(), + children: [ + TableRow(children: [ + const Text('Generation-Time'), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + '${_numberFormatter.format(_generationTime)} ms', + style: _valueStyle, + textAlign: TextAlign.right, + ), + ), + ]), + tableSpace, + TableRow(children: [ + const Text('Image-Size'), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + formatBytes(_imageBytes.length), + style: _valueStyle, + textAlign: TextAlign.right, + ), + ), + ]), + tableSpace, + TableRow(children: [ + const Text('Content-Type'), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + _contentType, + style: _valueStyle, + textAlign: TextAlign.right, + ), + ), + ]), + tableSpace, + TableRow(children: [ + const Text('Dimension'), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + snapshot.connectionState == ConnectionState.done + ? '${_numberFormatter.format( + snapshot.data!.rawSize.width.round(), + )} x ${_numberFormatter.format( + snapshot.data!.rawSize.height.round(), + )}' + : 'Loading...', + style: _valueStyle, + textAlign: TextAlign.right, + ), + ), + ]), + tableSpace, + TableRow(children: [ + const Text('Pixel-Ratio'), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + snapshot.connectionState == ConnectionState.done + ? snapshot.data!.pixelRatio.toStringAsFixed(3) + : 'Loading...', + style: _valueStyle, + textAlign: TextAlign.right, + ), + ), + ]), + ], + ); + }), + ), + ), + ), + ); + } + + Widget _buildThumbnailPreview() { + if (_highQualityGeneration == null) return Container(); + return FutureBuilder( + future: _highQualityGeneration, + builder: (context, snapshot) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: snapshot.connectionState == ConnectionState.done + ? _buildFinalImage(bytes: snapshot.data) + : Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + Hero( + tag: const ProImageEditorConfigs().heroTag, + child: Image.memory( + widget.imgBytes, + fit: BoxFit.contain, + ), + ), + if (snapshot.connectionState != ConnectionState.done) + const Center( + child: SizedBox( + width: 60, + height: 60, + child: FittedBox( + child: PlatformCircularProgressIndicator( + configs: ProImageEditorConfigs(), + ), + ), + ), + ), + ], + ), + ); + }); + } +} diff --git a/lib/pro_image_editor/features/reorder_layer_example.dart b/lib/pro_image_editor/features/reorder_layer_example.dart new file mode 100644 index 00000000..7676cd7d --- /dev/null +++ b/lib/pro_image_editor/features/reorder_layer_example.dart @@ -0,0 +1,241 @@ +// Flutter imports: +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +// Package imports: +import 'package:pro_image_editor/pro_image_editor.dart'; + +// Project imports: +import '../core/mixin/example_helper.dart'; +import '../shared/widgets/prepare_image_widget.dart'; + +/// A widget that demonstrates the ability to reorder layers within a UI. +/// +/// The [ReorderLayerExample] widget is a stateful widget that allows users +/// to reorder different layers, typically used in applications like image +/// or graphic editors. This feature enables users to adjust the stacking +/// order of layers for better control over the composition. +/// +/// The state for this widget is managed by the [_ReorderLayerExampleState] +/// class. +/// +/// Example usage: +/// ```dart +/// ReorderLayerExample(); +/// ``` +class ReorderLayerExample extends StatefulWidget { + /// Creates a new [ReorderLayerExample] widget. + const ReorderLayerExample({super.key}); + + @override + State createState() => _ReorderLayerExampleState(); +} + +/// The state for the [ReorderLayerExample] widget. +/// +/// This class manages the logic and state required for reordering layers +/// within the [ReorderLayerExample] widget. +class _ReorderLayerExampleState extends State + with ExampleHelperState { + @override + void initState() { + super.initState(); + preCacheImage(assetPath: 'assets/canvas/white.png'); + } + + @override + Widget build(BuildContext context) { + if (!isPreCached) return const PrepareImageWidget(); + + return ProImageEditor.asset( + 'assets/canvas/white.png', + key: editorKey, + callbacks: ProImageEditorCallbacks( + onImageEditingStarted: onImageEditingStarted, + onImageEditingComplete: onImageEditingComplete, + onCloseEditor: (editorMode) => onCloseEditor( + editorMode: editorMode, + enablePop: !isDesktopMode(context), + ), + mainEditorCallbacks: MainEditorCallbacks( + helperLines: HelperLinesCallbacks(onLineHit: vibrateLineHit), + ), + ), + configs: ProImageEditorConfigs( + designMode: platformDesignMode, + mainEditor: MainEditorConfigs( + enableCloseButton: !isDesktopMode(context), + widgets: MainEditorWidgets( + bodyItems: (editor, rebuildStream) { + return [ + ReactiveWidget( + stream: rebuildStream, + builder: (_) => + editor.selectedLayerIndex >= 0 || editor.isSubEditorOpen + ? const SizedBox.shrink() + : Positioned( + bottom: 20, + left: 0, + child: Container( + decoration: BoxDecoration( + color: Colors.blue.shade700, + borderRadius: const BorderRadius.only( + topRight: Radius.circular(100), + bottomRight: Radius.circular(100), + ), + ), + child: IconButton( + onPressed: () { + showModalBottomSheet( + context: context, + builder: (context) { + return ReorderLayerSheet( + layers: editor.activeLayers, + onReorder: (oldIndex, newIndex) { + editor.moveLayerListPosition( + oldIndex: oldIndex, + newIndex: newIndex, + ); + Navigator.pop(context); + }, + ); + }, + ); + }, + icon: const Icon( + Icons.reorder, + color: Colors.white, + ), + ), + ), + ), + ), + ]; + }, + ), + ), + ), + ); + } +} + +/// A widget that provides a sheet for reordering layers. +/// +/// The [ReorderLayerSheet] widget allows users to view and reorder a list of +/// layers within an application. It is typically used in scenarios where the +/// user needs to manage the stacking order of different layers, such as in +/// an image or graphic editor. +/// +/// This widget requires a list of [Layer] objects and a [ReorderCallback] +/// function to handle the reorder logic. +/// +/// The state for this widget is managed by the [_ReorderLayerSheetState] class. +/// +/// Example usage: +/// ```dart +/// ReorderLayerSheet( +/// layers: myLayers, +/// onReorder: (oldIndex, newIndex) { /* reorder logic */ }, +/// ); +/// ``` +class ReorderLayerSheet extends StatefulWidget { + /// Creates a new [ReorderLayerSheet] widget. + /// + /// The [layers] parameter is required and represents the list of layers + /// that can be reordered. The [onReorder] callback is required to handle + /// the logic when layers are reordered. + const ReorderLayerSheet({ + super.key, + required this.layers, + required this.onReorder, + }); + + /// A list of [Layer] objects that can be reordered by the user. + final List layers; + + /// A callback that is triggered when the user reorders the layers. + /// This function receives the [oldIndex] and [newIndex] to indicate + /// how the layers were reordered. + final ReorderCallback onReorder; + + @override + State createState() => _ReorderLayerSheetState(); +} + +/// The state for the [ReorderLayerSheet] widget. +/// +/// This class manages the logic and state required for displaying and +/// interacting with the reorderable list of layers. +class _ReorderLayerSheetState extends State { + @override + Widget build(BuildContext context) { + return ReorderableListView.builder( + header: const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + 'Reorder', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.w500), + ), + ), + footer: const SizedBox(height: 30), + dragStartBehavior: DragStartBehavior.down, + itemBuilder: (context, index) { + Layer layer = widget.layers[index]; + return ListTile( + key: ValueKey(layer), + tileColor: Theme.of(context).cardColor, + title: layer.runtimeType == TextLayer + ? Text( + (layer as TextLayer).text, + style: const TextStyle(fontSize: 20), + ) + : layer.runtimeType == EmojiLayer + ? Text( + (layer as EmojiLayer).emoji, + style: const TextStyle(fontSize: 24), + ) + : layer.runtimeType == PaintLayer + ? Builder(builder: (context) { + var paintLayer = layer as PaintLayer; + bool isCensorLayer = + paintLayer.item.mode == PaintMode.pixelate || + paintLayer.item.mode == PaintMode.blur; + return SizedBox( + height: 40, + child: FittedBox( + alignment: Alignment.centerLeft, + child: isCensorLayer + ? const Icon(Icons.blur_circular) + : CustomPaint( + size: paintLayer.size, + willChange: true, + isComplex: layer.item.mode == + PaintMode.freeStyle, + painter: DrawPaintItem( + item: layer.item, + scale: layer.scale, + enabledHitDetection: false, + freeStyleHighPerformance: false, + ), + ), + ), + ); + }) + : layer.runtimeType == WidgetLayer + ? SizedBox( + height: 40, + child: FittedBox( + alignment: Alignment.centerLeft, + child: (layer as WidgetLayer).widget, + ), + ) + : Text( + layer.id.toString(), + ), + ); + }, + itemCount: widget.layers.length, + onReorder: widget.onReorder, + ); + } +} diff --git a/lib/pro_image_editor/features/text_bottom_bar.dart b/lib/pro_image_editor/features/text_bottom_bar.dart new file mode 100644 index 00000000..9ceae6d1 --- /dev/null +++ b/lib/pro_image_editor/features/text_bottom_bar.dart @@ -0,0 +1,141 @@ +// Flutter imports: +import 'package:flutter/material.dart'; +import 'package:magic_epaper_app/pro_image_editor/features/color_picker.dart'; + +// Project imports: +import 'package:pro_image_editor/features/text_editor/widgets/text_editor_bottom_bar.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; + +/// A stateful widget that represents the bottom bar for text editing in the +/// WhatsApp theme. +/// +/// This widget provides controls for adjusting text color and font style, +/// using a design inspired by WhatsApp. + +class TextBottomBar extends StatefulWidget { + /// Creates a [TextBottomBar] widget. + /// + /// This bottom bar allows users to customize text color and font style, + /// integrating seamlessly with the WhatsApp theme. + /// + /// Example: + /// ``` + /// TextBottomBar( + /// configs: myEditorConfigs, + /// initColor: Colors.black, + /// onColorChanged: (color) { + /// // Handle color change + /// }, + /// selectedStyle: TextStyle(fontSize: 16), + /// onFontChange: (style) { + /// // Handle font change + /// }, + /// ) + /// ``` + const TextBottomBar({ + super.key, + required this.configs, + required this.initColor, + required this.onColorChanged, + required this.selectedStyle, + required this.onFontChange, + }); + + /// The configuration for the image editor. + /// + /// These settings determine various aspects of the bottom bar's behavior and + /// appearance, ensuring it aligns with the application's overall theme. + final ProImageEditorConfigs configs; + + /// The initial color for the text. + /// + /// This color sets the initial text color, providing a starting point for + /// color customization. + final Color initColor; + + /// Callback function for handling color changes. + /// + /// This function is called whenever the user selects a new text color, + /// allowing the application to update the text appearance. + final ValueChanged onColorChanged; + + /// The currently selected text style. + /// + /// This style is used to initialize the font selection, providing a starting + /// point for font customization and styling. + final TextStyle selectedStyle; + + /// Callback function for changing the text font style. + /// + /// This function is called whenever the user selects a new font style, + /// allowing the application to update the text style. + final Function(TextStyle style) onFontChange; + + @override + State createState() => _TextBottomBarState(); +} + +class _TextBottomBarState extends State { + final double _space = 10; + + bool _showColorPicker = true; + + @override + Widget build(BuildContext context) { + return Positioned( + bottom: _space + MediaQuery.viewPaddingOf(context).bottom, + left: _space, + right: 0, + height: 40, + child: widget.configs.designMode == ImageEditorDesignMode.cupertino + ? TextEditorBottomBar( + configs: widget.configs, + selectedStyle: widget.selectedStyle, + onFontChange: widget.onFontChange, + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + IconButton( + onPressed: () { + setState(() { + _showColorPicker = !_showColorPicker; + }); + }, + icon: !_showColorPicker + ? const Icon(Icons.color_lens) + : Text( + 'Aa', + style: + widget.configs.textEditor.customTextStyles?.first, + ), + style: IconButton.styleFrom(backgroundColor: Colors.black38), + ), + Container( + margin: const EdgeInsets.fromLTRB(14.0, 4, 0, 4), + width: 1.5, + decoration: BoxDecoration( + color: Colors.white54, + borderRadius: BorderRadius.circular(2), + ), + ), + _showColorPicker + ? Expanded( + child: ColorPickerCustom( + onColorChanged: widget.onColorChanged, + initColor: widget.initColor, + ), + ) + : Expanded( + child: TextEditorBottomBar( + configs: widget.configs, + selectedStyle: widget.selectedStyle, + onFontChange: widget.onFontChange, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pro_image_editor/shared/widgets/demo_build_stickers.dart b/lib/pro_image_editor/shared/widgets/demo_build_stickers.dart new file mode 100644 index 00000000..cedbf86b --- /dev/null +++ b/lib/pro_image_editor/shared/widgets/demo_build_stickers.dart @@ -0,0 +1,178 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; + +/// A widget that demonstrates the building of sticker categories and displays +/// them in a scrollable grid layout. It also allows interaction with stickers +/// to be set in an image editor layer. +class DemoBuildStickers extends StatelessWidget { + /// Creates a [DemoBuildStickers] widget. + /// + /// [setLayer] is a callback function to set the selected sticker widget in + /// the editor. + /// [scrollController] controls the scroll behavior of the sticker list. + /// [categoryColor] defines the background color of the category bar. + DemoBuildStickers({ + super.key, + required this.setLayer, + required this.scrollController, + this.categoryColor = const Color(0xFF424242), + }); + + /// Callback function to set the selected sticker in the image editor layer. + final Function(Widget) setLayer; + + /// Controls the scroll behavior of the sticker grid. + final ScrollController scrollController; + + /// Color for the category selection bar. + final Color categoryColor; + + /// Titles for the sticker categories. + final List demoTitles = [ + 'Recent', + 'Favorites', + 'Shapes', + 'Funny', + 'Boring', + 'Frog', + 'Snow', + 'More' + ]; + @override + Widget build(BuildContext context) { + List slivers = []; + int offset = 0; + for (var element in demoTitles) { + slivers.addAll([ + SliverPadding( + padding: const EdgeInsets.only(bottom: 4), + sliver: SliverToBoxAdapter( + child: Text( + element, + style: const TextStyle(color: Colors.white), + ), + ), + ), + _buildDemoStickers(offset, setLayer), + const SliverToBoxAdapter(child: SizedBox(height: 20)), + ]); + offset += 20; + } + + return Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(8.0, 8.0, 8.0, 0), + child: CustomScrollView( + controller: scrollController, + slivers: slivers, + ), + ), + ), + Container( + height: 50, + color: categoryColor, + child: Row( + children: [ + IconButton( + onPressed: () {}, + icon: const Icon(Icons.watch_later_outlined), + color: Colors.white, + ), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.mood), + color: Colors.white, + ), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.pets), + color: Colors.white, + ), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.coronavirus), + color: Colors.white, + ), + ], + ), + ), + ], + ); + } + + SliverGrid _buildDemoStickers(int offset, Function(Widget) setLayer) { + return SliverGrid.builder( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 80, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + ), + itemCount: max(3, 3 + offset % 6), + itemBuilder: (context, index) { + String url = + 'https://picsum.photos/id/${offset + (index + 3) * 3}/2000'; + var widget = ClipRRect( + borderRadius: BorderRadius.circular(7), + child: Image.network( + url, + width: 120, + height: 120, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + return AnimatedSwitcher( + layoutBuilder: (currentChild, previousChildren) { + return SizedBox( + width: 120, + height: 120, + child: Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ), + ); + }, + duration: const Duration(milliseconds: 200), + child: loadingProgress == null + ? child + : Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ), + ); + }, + ), + ); + return GestureDetector( + onTap: () async { + // Important make sure the image is completely loaded + // cuz the editor will directly take a screenshot + // inside of a background isolated thread. + LoadingDialog.instance.show( + context, + configs: const ProImageEditorConfigs(), + theme: Theme.of(context), + ); + + await precacheImage(NetworkImage(url), context); + LoadingDialog.instance.hide(); + setLayer(widget); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: widget, + ), + ); + }); + } +} diff --git a/lib/pro_image_editor/shared/widgets/material_icon_button.dart b/lib/pro_image_editor/shared/widgets/material_icon_button.dart new file mode 100644 index 00000000..2a0cfb2f --- /dev/null +++ b/lib/pro_image_editor/shared/widgets/material_icon_button.dart @@ -0,0 +1,127 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +/// A stateless widget that displays a material-styled icon button with a custom +/// circular background, half of which is a secondary color. Below the icon, +/// a label text is displayed. +/// +/// The [MaterialIconActionButton] widget requires a primary color, secondary +/// color, icon, text, and a callback function to handle taps. +/// +/// Example usage: +/// ```dart +/// MaterialIconActionButton( +/// primaryColor: Colors.blue, +/// secondaryColor: Colors.green, +/// icon: Icons.camera, +/// text: 'Camera', +/// onTap: () { +/// // Handle tap action +/// }, +/// ); +/// ``` +class MaterialIconActionButton extends StatelessWidget { + /// Creates a new [MaterialIconActionButton] widget. + /// + /// The [primaryColor] is the color of the circular background, while the + /// [secondaryColor] is used for the half-circle overlay. The [icon] is the + /// icon to display in the center, and [text] is the label displayed below + /// the icon. The [onTap] callback is triggered when the button is tapped. + const MaterialIconActionButton({ + super.key, + required this.primaryColor, + required this.secondaryColor, + required this.icon, + required this.text, + required this.onTap, + }); + + /// The primary color for the button's background. + final Color primaryColor; + + /// The secondary color for the half-circle overlay. + final Color secondaryColor; + + /// The icon to display in the center of the button. + final IconData icon; + + /// The label displayed below the icon. + final String text; + + /// The callback function triggered when the button is tapped. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 65, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + InkWell( + borderRadius: BorderRadius.circular(60), + onTap: onTap, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: primaryColor, + borderRadius: BorderRadius.circular(100), + ), + ), + CustomPaint( + painter: CircleHalf(secondaryColor), + size: const Size(60, 60), + ), + Icon(icon, color: Colors.white), + ], + ), + ), + const SizedBox(height: 7), + Text( + text, + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +/// A custom painter class that paints a half-circle. +/// +/// The [CircleHalf] class takes a [color] parameter and paints half of a circle +/// on a canvas, typically used as an overlay for the +/// [MaterialIconActionButton]. +class CircleHalf extends CustomPainter { + /// Creates a new [CircleHalf] painter with the given [color]. + CircleHalf(this.color); + + /// The color to use for paint the half-circle. + final Color color; + + @override + void paint(Canvas canvas, Size size) { + Paint paint = Paint()..color = color; + canvas.drawArc( + Rect.fromCenter( + center: Offset(size.height / 2, size.width / 2), + height: size.height, + width: size.width, + ), + pi, + pi, + false, + paint, + ); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} diff --git a/lib/pro_image_editor/shared/widgets/not_found_example.dart b/lib/pro_image_editor/shared/widgets/not_found_example.dart new file mode 100644 index 00000000..6d0c8096 --- /dev/null +++ b/lib/pro_image_editor/shared/widgets/not_found_example.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +/// A widget that displays a centered "Example not found" message. +/// +/// This widget is useful as a placeholder for screens or routes +/// that have not been implemented yet or when content is unavailable. +class NotFoundExample extends StatefulWidget { + /// Creates a `NotFoundExample` widget. + const NotFoundExample({super.key}); + + @override + State createState() => _NotFoundExampleState(); +} + +class _NotFoundExampleState extends State { + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: Text( + 'Example not found', + style: TextStyle( + fontSize: 20, + color: Colors.red, + ), + ), + ), + ); + } +} diff --git a/lib/pro_image_editor/shared/widgets/paragraph_info_widget.dart b/lib/pro_image_editor/shared/widgets/paragraph_info_widget.dart new file mode 100644 index 00000000..3966aec4 --- /dev/null +++ b/lib/pro_image_editor/shared/widgets/paragraph_info_widget.dart @@ -0,0 +1,47 @@ +import 'package:flutter/widgets.dart'; + +/// A widget that visually highlights a paragraph with a colored left border. +/// +/// Useful for drawing attention to a block of content, such as a tip, +/// warning, or important note. +/// +/// You can customize the [color] of the border and apply optional [margin] +/// around the widget. The [child] is the content displayed inside. +class ParagraphInfoWidget extends StatelessWidget { + /// Creates a [ParagraphInfoWidget]. + /// + /// The [child] is required and will be displayed inside the bordered area. + /// You can optionally customize the [margin] and [color]. + const ParagraphInfoWidget({ + super.key, + required this.child, + this.margin, + this.color = const Color(0xFF0f7dff), + }); + + /// The widget displayed inside the bordered container. + final Widget child; + + /// Optional margin around the container. + final EdgeInsets? margin; + + /// The color of the left border. + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + margin: margin, + padding: const EdgeInsets.only(left: 16), + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: color, + width: 2, + ), + ), + ), + child: child, + ); + } +} diff --git a/lib/pro_image_editor/shared/widgets/pixel_transparent_painter.dart b/lib/pro_image_editor/shared/widgets/pixel_transparent_painter.dart new file mode 100644 index 00000000..066f5817 --- /dev/null +++ b/lib/pro_image_editor/shared/widgets/pixel_transparent_painter.dart @@ -0,0 +1,56 @@ +import 'package:flutter/widgets.dart'; + +/// A custom painter that creates a pixelated transparent pattern. +/// +/// The [PixelTransparentPainter] widget is a [CustomPainter] that paints a +/// checkered grid pattern using two alternating colors. This is often used +/// to represent transparency in image editing applications. +/// +/// The grid is made up of square cells, with the size of each cell controlled +/// by the [cellSize] constant. +/// +/// Example usage: +/// ```dart +/// PixelTransparentPainter( +/// primary: Colors.white, +/// secondary: Colors.grey, +/// ); +/// ``` +class PixelTransparentPainter extends CustomPainter { + /// Creates a new [PixelTransparentPainter] with the given colors. + /// + /// The [primary] and [secondary] colors are used to alternate between the + /// cells in the grid. + const PixelTransparentPainter({ + required this.primary, + required this.secondary, + }); + + /// The primary color used for alternating cells in the grid. + final Color primary; + + /// The secondary color used for alternating cells in the grid. + final Color secondary; + + @override + void paint(Canvas canvas, Size size) { + const cellSize = 22.0; // Size of each square + final numCellsX = size.width / cellSize; + final numCellsY = size.height / cellSize; + + for (int row = 0; row < numCellsY; row++) { + for (int col = 0; col < numCellsX; col++) { + final color = (row + col) % 2 == 0 ? primary : secondary; + canvas.drawRect( + Rect.fromLTWH(col * cellSize, row * cellSize, cellSize, cellSize), + Paint()..color = color, + ); + } + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return false; + } +} diff --git a/lib/pro_image_editor/shared/widgets/prepare_image_widget.dart b/lib/pro_image_editor/shared/widgets/prepare_image_widget.dart new file mode 100644 index 00000000..b82e1ee8 --- /dev/null +++ b/lib/pro_image_editor/shared/widgets/prepare_image_widget.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +/// A widget that displays a loading screen with a progress indicator and a +/// message. +/// +/// Used to inform the user that the editor is preparing a demo image. +/// +/// The UI consists of: +/// - A circular progress indicator. +/// - A message prompting the user to wait, styled with white text on a dark +/// background. +class PrepareImageWidget extends StatelessWidget { + /// Creates a [PrepareImageWidget]. + /// + /// The widget is stateless and serves as a loading screen + /// with a progress indicator and a message. + const PrepareImageWidget({super.key}); + + @override + Widget build(BuildContext context) { + return const Material( + color: Color.fromARGB(255, 19, 21, 22), + child: Padding( + padding: EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + color: Colors.white, + ), + SizedBox(height: 24), // Spacing between the widgets + Text( + 'Please wait...\nThe editor is preparing the demo image', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 20, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/provider/image_loader.dart b/lib/provider/image_loader.dart index d0fbd4fc..dffbc887 100644 --- a/lib/provider/image_loader.dart +++ b/lib/provider/image_loader.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:image/image.dart' as img; import 'package:image_cropper/image_cropper.dart'; @@ -24,4 +26,17 @@ class ImageLoader extends ChangeNotifier { notifyListeners(); } + + Future updateImage({ + required Uint8List bytes, + required int width, + required int height, + }) async { + final decoded = img.decodeImage(bytes); + if (decoded != null) { + final resized = img.copyResize(decoded, width: width, height: height); + image = resized; + notifyListeners(); + } + } } diff --git a/lib/view/image_editor.dart b/lib/view/image_editor.dart index ad047b25..691c848f 100644 --- a/lib/view/image_editor.dart +++ b/lib/view/image_editor.dart @@ -1,4 +1,7 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; +import 'package:magic_epaper_app/pro_image_editor/features/movable_background_image.dart'; import 'package:magic_epaper_app/view/widget/image_list.dart'; import 'package:provider/provider.dart'; import 'package:image/image.dart' as img; @@ -38,6 +41,21 @@ class ImageEditor extends StatelessWidget { }, child: const Text("Import Image"), ), + TextButton( + onPressed: () async { + final canvasBytes = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const MovableBackgroundImageExample(), + ), + ); + imgLoader.updateImage( + bytes: canvasBytes!, + width: epd.width, + height: epd.height, + ); + }, + child: const Text("Open Editor"), + ), ], ), body: Center(child: imgList), diff --git a/pubspec.yaml b/pubspec.yaml index c17d153e..ecea07a4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,13 +2,13 @@ name: magic_epaper_app description: "A new Flutter project." # Prevent accidental publishing to pub.dev. -publish_to: 'none' +publish_to: "none" version: 1.0.0+1 environment: - sdk: '>=3.3.4 <4.0.0' - flutter: '3.29.2' + sdk: ">=3.3.4 <4.0.0" + flutter: "3.29.2" dependencies: flutter: @@ -28,7 +28,19 @@ dependencies: image_cropper: ^9.0.0 app_settings: ^6.1.1 fluttertoast: ^8.2.12 - + pro_image_editor: ^9.4.1 + vibration: ^3.1.3 + gal: ^2.3.1 + file_saver: ^0.2.14 + bot_toast: ^4.1.3 + media_kit: ^1.2.0 + url_launcher: ^6.3.1 + google_fonts: ^6.2.1 + file_picker: ^10.1.2 + flutter_colorpicker: ^1.1.0 + mime: ^2.0.0 + intl: ^0.19.0 + path_provider: ^2.0.15 dev_dependencies: flutter_test: @@ -45,4 +57,5 @@ flutter: assets: # Add assets from the images directory to the application. - assets/images/ - + - assets/images/displays/ + - assets/canvas/