From 3a48447566e886b72f9d8d4bf8def6fc0403dedb Mon Sep 17 00:00:00 2001 From: Lenkomotive <90652966+Lenkomotive@users.noreply.github.com> Date: Mon, 6 Jan 2025 13:58:47 +0100 Subject: [PATCH] PAINTROID-768: Add Spray Can tool (#98) * test * test * PAINTROID-768: Flutter: Add Spray Can tool * PAINTROID-768: Flutter: Add Spray Can tool * PAINTROID-768: Flutter: Add Spray Can tool - tests * PAINTROID-768: Flutter: Add Spray Can tool - CR * PAINTROID-768: Flutter: Add Spray Can tool - CR * PAINTROID-768: Flutter: Add Spray Can tool - CR * PAINTROID-768: Flutter: Add Spray Can tool - CR --- ios/Podfile.lock | 12 +- .../command_factory/command_factory.dart | 5 + .../graphic/spray_command.dart | 49 +++++ .../graphic/spray_command.g.dart | 25 +++ .../command_manager/command_manager.dart | 4 + .../versioning/serializer_version.dart | 2 + .../versioning/version_strategy.dart | 9 + lib/core/providers/state/paint_provider.dart | 3 +- .../providers/state/spray_tool_provider.dart | 24 +++ .../state/spray_tool_provider.g.dart | 26 +++ .../state/toolbox_state_provider.dart | 19 +- .../state/toolbox_state_provider.g.dart | 2 +- lib/core/tools/implementation/spray_tool.dart | 86 ++++++++ .../shapes_tool_options.dart | 8 +- .../tool_options/spray_tool_options.dart | 14 ++ .../tool_options/stroke_tool_options.dart | 11 +- .../bottom_bar/tool_options/tool_options.dart | 5 +- .../tool_options/widgets/radius_slider.dart | 112 ++++++++++ .../shapes_tool_shape_type_options.dart | 0 ...apes_tool_transformation_mode_options.dart | 0 .../stroke_cap_chips.dart} | 14 +- .../stroke_width_slider.dart} | 13 +- pubspec.lock | 72 ++++--- test/integration/spray_tool_test.dart | 195 ++++++++++++++++++ .../utils/dummy_version_strategy.dart | 5 + test/unit/tools/spray_tool_test.dart | 105 ++++++++++ test/utils/ui_interaction.dart | 47 ++++- .../bottom_control_navigation_bar_test.dart | 31 ++- 28 files changed, 810 insertions(+), 88 deletions(-) create mode 100644 lib/core/commands/command_implementation/graphic/spray_command.dart create mode 100644 lib/core/commands/command_implementation/graphic/spray_command.g.dart create mode 100644 lib/core/providers/state/spray_tool_provider.dart create mode 100644 lib/core/providers/state/spray_tool_provider.g.dart create mode 100644 lib/core/tools/implementation/spray_tool.dart rename lib/ui/pages/workspace_page/components/bottom_bar/tool_options/{shapes_tool => }/shapes_tool_options.dart (72%) create mode 100644 lib/ui/pages/workspace_page/components/bottom_bar/tool_options/spray_tool_options.dart create mode 100644 lib/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/radius_slider.dart rename lib/ui/pages/workspace_page/components/bottom_bar/tool_options/{shapes_tool => widgets}/shapes_tool_shape_type_options.dart (100%) rename lib/ui/pages/workspace_page/components/bottom_bar/tool_options/{shapes_tool => widgets}/shapes_tool_transformation_mode_options.dart (100%) rename lib/ui/pages/workspace_page/components/bottom_bar/tool_options/{stroke_cap_tool_option.dart => widgets/stroke_cap_chips.dart} (88%) rename lib/ui/pages/workspace_page/components/bottom_bar/tool_options/{stroke_width_tool_option.dart => widgets/stroke_width_slider.dart} (91%) create mode 100644 test/integration/spray_tool_test.dart create mode 100644 test/unit/tools/spray_tool_test.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 7cef8453..b56a26e3 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -121,18 +121,18 @@ SPEC CHECKSUMS: file_picker: ce3938a0df3cc1ef404671531facef740d03f920 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_localization: f43b18844a2b3d2c71fd64f04ffd6b1e64dd54d4 - image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 - integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 + image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425 + integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 launch_review: 75d5a956ba8eaa493e9c9d4bf4c05e505e8d5ed0 package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85 - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 SDWebImage: dfe95b2466a9823cf9f0c6d01217c06550d7b29a - shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 - url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe + url_launcher_ios: bbd758c6e7f9fd7b5b1d4cde34d2b95fcce5e812 PODFILE CHECKSUM: 303789365c3a8d7bc562e5e65d7e8e15218ec5c6 -COCOAPODS: 1.15.2 +COCOAPODS: 1.15.0 diff --git a/lib/core/commands/command_factory/command_factory.dart b/lib/core/commands/command_factory/command_factory.dart index 3d9aaab1..e26bf3d0 100644 --- a/lib/core/commands/command_factory/command_factory.dart +++ b/lib/core/commands/command_factory/command_factory.dart @@ -4,6 +4,7 @@ import 'package:paintroid/core/commands/command_implementation/graphic/line_comm import 'package:paintroid/core/commands/command_implementation/graphic/path_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/shape/circle_shape_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/shape/square_shape_command.dart'; +import 'package:paintroid/core/commands/command_implementation/graphic/spray_command.dart'; import 'package:paintroid/core/commands/path_with_action_history.dart'; class CommandFactory { @@ -38,4 +39,8 @@ class CommandFactory { Offset center, ) => CircleShapeCommand(paint, radius, center); + + SprayCommand createSprayCommand(List points, Paint paint) { + return SprayCommand(points, paint); + } } diff --git a/lib/core/commands/command_implementation/graphic/spray_command.dart b/lib/core/commands/command_implementation/graphic/spray_command.dart new file mode 100644 index 00000000..7ef3835c --- /dev/null +++ b/lib/core/commands/command_implementation/graphic/spray_command.dart @@ -0,0 +1,49 @@ +import 'dart:ui'; + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:paintroid/core/commands/command_implementation/graphic/graphic_command.dart'; +import 'package:paintroid/core/json_serialization/converter/offset_converter.dart'; +import 'package:paintroid/core/json_serialization/converter/paint_converter.dart'; +import 'package:paintroid/core/json_serialization/versioning/serializer_version.dart'; +import 'package:paintroid/core/json_serialization/versioning/version_strategy.dart'; + +part 'spray_command.g.dart'; + +@JsonSerializable() +class SprayCommand extends GraphicCommand { + final String type; + final int version; + + @OffsetConverter() + List points; + + SprayCommand( + this.points, + super.paint, { + this.type = SerializerType.SPRAY_COMMAND, + int? version, + }) : version = + version ?? VersionStrategyManager.strategy.getSprayCommandVersion(); + + @override + void call(Canvas canvas) { + canvas.drawPoints(PointMode.points, points, paint); + } + + @override + List get props => [paint, points]; + + @override + Map toJson() => _$SprayCommandToJson(this); + + factory SprayCommand.fromJson(Map json) { + int version = json['version'] as int; + + switch (version) { + case Version.v1: + return _$SprayCommandFromJson(json); + default: + return _$SprayCommandFromJson(json); + } + } +} diff --git a/lib/core/commands/command_implementation/graphic/spray_command.g.dart b/lib/core/commands/command_implementation/graphic/spray_command.g.dart new file mode 100644 index 00000000..df1ae9e7 --- /dev/null +++ b/lib/core/commands/command_implementation/graphic/spray_command.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'spray_command.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SprayCommand _$SprayCommandFromJson(Map json) => SprayCommand( + (json['points'] as List) + .map((e) => + const OffsetConverter().fromJson(e as Map)) + .toList(), + const PaintConverter().fromJson(json['paint'] as Map), + type: json['type'] as String? ?? SerializerType.SPRAY_COMMAND, + version: (json['version'] as num?)?.toInt(), + ); + +Map _$SprayCommandToJson(SprayCommand instance) => + { + 'paint': const PaintConverter().toJson(instance.paint), + 'type': instance.type, + 'version': instance.version, + 'points': instance.points.map(const OffsetConverter().toJson).toList(), + }; diff --git a/lib/core/commands/command_manager/command_manager.dart b/lib/core/commands/command_manager/command_manager.dart index 2a658d86..9f33aa18 100644 --- a/lib/core/commands/command_manager/command_manager.dart +++ b/lib/core/commands/command_manager/command_manager.dart @@ -8,6 +8,7 @@ import 'package:paintroid/core/commands/command_implementation/graphic/shape/squ import 'package:paintroid/core/tools/line_tool/vertex.dart'; import 'package:paintroid/core/tools/line_tool/vertex_stack.dart'; import 'package:paintroid/core/tools/tool_data.dart'; +import 'package:paintroid/core/commands/command_implementation/graphic/spray_command.dart'; enum ActionType { UNDO, REDO } @@ -109,6 +110,9 @@ class CommandManager { return ToolData.SHAPES; } else if (command.runtimeType == CircleShapeCommand) { return ToolData.SHAPES; + } + else if (command.runtimeType == SprayCommand) { + return ToolData.SPRAY; } else { return ToolData.BRUSH; } diff --git a/lib/core/json_serialization/versioning/serializer_version.dart b/lib/core/json_serialization/versioning/serializer_version.dart index 4867c010..5ddc4b1f 100644 --- a/lib/core/json_serialization/versioning/serializer_version.dart +++ b/lib/core/json_serialization/versioning/serializer_version.dart @@ -5,6 +5,7 @@ class SerializerVersion { static const int LINE_COMMAND_VERSION = Version.v1; static const int SQUARE_SHAPE_COMMAND_VERSION = Version.v1; static const int CIRCLE_SHAPE_COMMAND_VERSION = Version.v1; + static const int SPRAY_COMMAND_VERSION = Version.v1; } class Version { @@ -21,4 +22,5 @@ class SerializerType { static const String CLOSE_ACTION = 'CloseAction'; static const String SQUARE_SHAPE_COMMAND = 'SquareShapeCommand'; static const String CIRCLE_SHAPE_COMMAND = 'CircleShapeCommand'; + static const String SPRAY_COMMAND = 'SprayCommand'; } diff --git a/lib/core/json_serialization/versioning/version_strategy.dart b/lib/core/json_serialization/versioning/version_strategy.dart index 10b9d8cb..07941da4 100644 --- a/lib/core/json_serialization/versioning/version_strategy.dart +++ b/lib/core/json_serialization/versioning/version_strategy.dart @@ -2,10 +2,16 @@ import 'package:paintroid/core/json_serialization/versioning/serializer_version. abstract class IVersionStrategy { int getCatrobatImageVersion(); + int getPathCommandVersion(); + int getLineCommandVersion(); + int getSquareShapeCommandVersion(); + int getCircleShapeCommandVersion(); + + int getSprayCommandVersion(); } class ProductionVersionStrategy implements IVersionStrategy { @@ -25,6 +31,9 @@ class ProductionVersionStrategy implements IVersionStrategy { @override int getCircleShapeCommandVersion() => SerializerVersion.CIRCLE_SHAPE_COMMAND_VERSION; + + @override + int getSprayCommandVersion() => SerializerVersion.SPRAY_COMMAND_VERSION; } class VersionStrategyManager { diff --git a/lib/core/providers/state/paint_provider.dart b/lib/core/providers/state/paint_provider.dart index 51f3f0d3..8878e055 100644 --- a/lib/core/providers/state/paint_provider.dart +++ b/lib/core/providers/state/paint_provider.dart @@ -1,10 +1,9 @@ import 'dart:ui'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - import 'package:paintroid/core/commands/graphic_factory/graphic_factory.dart'; import 'package:paintroid/core/commands/graphic_factory/graphic_factory_provider.dart'; import 'package:paintroid/core/enums/tool_types.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'paint_provider.g.dart'; diff --git a/lib/core/providers/state/spray_tool_provider.dart b/lib/core/providers/state/spray_tool_provider.dart new file mode 100644 index 00000000..a9179b9b --- /dev/null +++ b/lib/core/providers/state/spray_tool_provider.dart @@ -0,0 +1,24 @@ +import 'package:paintroid/core/commands/command_factory/command_factory_provider.dart'; +import 'package:paintroid/core/commands/command_manager/command_manager_provider.dart'; +import 'package:paintroid/core/commands/graphic_factory/graphic_factory_provider.dart'; +import 'package:paintroid/core/enums/tool_types.dart'; +import 'package:paintroid/core/providers/state/canvas_state_provider.dart'; +import 'package:paintroid/core/tools/implementation/spray_tool.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'spray_tool_provider.g.dart'; + +@riverpod +class SprayToolProvider extends _$SprayToolProvider { + @override + SprayTool build() { + return SprayTool( + type: ToolType.SPRAY, + commandManager: ref.watch(commandManagerProvider), + commandFactory: ref.watch(commandFactoryProvider), + graphicFactory: ref.watch(graphicFactoryProvider), + drawingSurfaceSize: + ref.watch(canvasStateProvider.select((state) => state.size)), + ); + } +} diff --git a/lib/core/providers/state/spray_tool_provider.g.dart b/lib/core/providers/state/spray_tool_provider.g.dart new file mode 100644 index 00000000..398115a0 --- /dev/null +++ b/lib/core/providers/state/spray_tool_provider.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'spray_tool_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$sprayToolProviderHash() => r'4b0bfa7d0b54e859a229fdbf6fa028c1fd9707ff'; + +/// See also [SprayToolProvider]. +@ProviderFor(SprayToolProvider) +final sprayToolProvider = + AutoDisposeNotifierProvider.internal( + SprayToolProvider.new, + name: r'sprayToolProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$sprayToolProviderHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$SprayToolProvider = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/core/providers/state/toolbox_state_provider.dart b/lib/core/providers/state/toolbox_state_provider.dart index eb28b4e2..f46fd575 100644 --- a/lib/core/providers/state/toolbox_state_provider.dart +++ b/lib/core/providers/state/toolbox_state_provider.dart @@ -1,17 +1,20 @@ import 'dart:ui'; -import 'package:paintroid/core/providers/object/canvas_painter_provider.dart'; -import 'package:paintroid/core/providers/object/tools/shapes_tool_provider.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; + import 'package:paintroid/core/commands/command_manager/command_manager_provider.dart'; import 'package:paintroid/core/enums/tool_types.dart'; +import 'package:paintroid/core/providers/object/canvas_painter_provider.dart'; import 'package:paintroid/core/providers/object/tools/brush_tool_provider.dart'; import 'package:paintroid/core/providers/object/tools/eraser_tool_provider.dart'; import 'package:paintroid/core/providers/object/tools/hand_tool_provider.dart'; import 'package:paintroid/core/providers/object/tools/line_tool_provider.dart'; +import 'package:paintroid/core/providers/object/tools/shapes_tool_provider.dart'; import 'package:paintroid/core/providers/state/paint_provider.dart'; +import 'package:paintroid/core/providers/state/spray_tool_provider.dart'; import 'package:paintroid/core/providers/state/toolbox_state_data.dart'; +import 'package:paintroid/core/tools/implementation/spray_tool.dart'; import 'package:paintroid/core/tools/tool_data.dart'; import 'package:paintroid/ui/utils/toast_utils.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'toolbox_state_provider.g.dart'; @@ -47,6 +50,10 @@ class ToolBoxStateProvider extends _$ToolBoxStateProvider { } void switchTool(ToolData data) { + if (state.currentTool is SprayTool) { + final currentRadius = (state.currentTool as SprayTool).sprayRadius; + ref.read(paintProvider.notifier).updateStrokeWidth(currentRadius); + } switch (data.type) { case ToolType.BRUSH: state = state.copyWith(currentTool: ref.read(brushToolProvider)); @@ -64,6 +71,12 @@ class ToolBoxStateProvider extends _$ToolBoxStateProvider { state = state.copyWith(currentTool: ref.read(shapesToolProvider)); ref.read(canvasPainterProvider.notifier).repaint(); break; + case ToolType.SPRAY: + state = state.copyWith(currentTool: ref.read(sprayToolProvider)); + final currentStrokeWidth = ref.read(paintProvider).strokeWidth; + (state.currentTool as SprayTool).updateSprayRadius(currentStrokeWidth); + ref.read(paintProvider.notifier).updateStrokeWidth(SPRAY_TOOL_RADIUS); + break; default: state = state.copyWith(currentTool: ref.read(brushToolProvider)); break; diff --git a/lib/core/providers/state/toolbox_state_provider.g.dart b/lib/core/providers/state/toolbox_state_provider.g.dart index 9edd351e..113ad284 100644 --- a/lib/core/providers/state/toolbox_state_provider.g.dart +++ b/lib/core/providers/state/toolbox_state_provider.g.dart @@ -7,7 +7,7 @@ part of 'toolbox_state_provider.dart'; // ************************************************************************** String _$toolBoxStateProviderHash() => - r'9fb807cc376c8553d14fc73b338e925672ea0747'; + r'9d4d72e01cae0978f298dd60219e83af48d79239'; /// See also [ToolBoxStateProvider]. @ProviderFor(ToolBoxStateProvider) diff --git a/lib/core/tools/implementation/spray_tool.dart b/lib/core/tools/implementation/spray_tool.dart new file mode 100644 index 00000000..a61a1a94 --- /dev/null +++ b/lib/core/tools/implementation/spray_tool.dart @@ -0,0 +1,86 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:paintroid/core/commands/command_implementation/graphic/spray_command.dart'; +import 'package:paintroid/core/commands/graphic_factory/graphic_factory.dart'; +import 'package:paintroid/core/tools/tool.dart'; + +const SPRAY_TOOL_RADIUS = 10.0; + +class SprayTool extends Tool { + final GraphicFactory graphicFactory; + final Size drawingSurfaceSize; + + @visibleForTesting + late SprayCommand sprayCommand; + late Paint paint; + + SprayTool({ + required super.commandFactory, + required super.commandManager, + required this.graphicFactory, + required super.type, + required this.drawingSurfaceSize, + super.hasAddFunctionality = false, + super.hasFinalizeFunctionality = false, + }); + + double sprayRadius = 20; + final Random random = Random(); + + @override + void onDown(Offset point, Paint paint) { + this.paint = graphicFactory.copyPaint(paint); + final initialPoints = _generateSprayPoints(point); + sprayCommand = commandFactory.createSprayCommand(initialPoints, this.paint); + commandManager.addGraphicCommand(sprayCommand); + } + + @override + void onDrag(Offset point, Paint paint) { + final newPoints = _generateSprayPoints(point); + sprayCommand.points.addAll(newPoints); + } + + @override + void onUp(Offset point, Paint paint) {} + + @override + void onCancel() { + commandManager.discardLastCommand(); + } + + @override + void onCheckmark(Paint paint) {} + + @override + void onPlus() {} + + @override + void onRedo() { + commandManager.redo(); + } + + @override + void onUndo() { + commandManager.undo(); + } + + void updateSprayRadius(double newRadius) { + sprayRadius = newRadius; + } + + List _generateSprayPoints(Offset center) { + List points = []; + final density = sprayRadius / 3; + for (int i = 0; i < density; i++) { + final angle = random.nextDouble() * 2 * pi; + final radius = sqrt(random.nextDouble()) * sprayRadius * 2; + final dx = center.dx + radius * cos(angle); + final dy = center.dy + radius * sin(angle); + points.add(Offset(dx, dy)); + } + return points; + } +} diff --git a/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/shapes_tool/shapes_tool_options.dart b/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/shapes_tool_options.dart similarity index 72% rename from lib/ui/pages/workspace_page/components/bottom_bar/tool_options/shapes_tool/shapes_tool_options.dart rename to lib/ui/pages/workspace_page/components/bottom_bar/tool_options/shapes_tool_options.dart index 89f6e987..dda4094c 100644 --- a/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/shapes_tool/shapes_tool_options.dart +++ b/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/shapes_tool_options.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_options/shapes_tool/shapes_tool_shape_type_options.dart'; -import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_options/shapes_tool/shapes_tool_transformation_mode_options.dart'; -import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_options/stroke_width_tool_option.dart'; +import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/shapes_tool_shape_type_options.dart'; +import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/shapes_tool_transformation_mode_options.dart'; +import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/stroke_width_slider.dart'; class ShapesToolOptions extends StatelessWidget { const ShapesToolOptions({super.key}); @@ -10,7 +10,7 @@ class ShapesToolOptions extends StatelessWidget { Widget build(BuildContext context) { return const Column( children: [ - StrokeWidthToolOption(), + StrokeWidthSlider(), Spacer(), Row( children: [ diff --git a/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/spray_tool_options.dart b/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/spray_tool_options.dart new file mode 100644 index 00000000..e85f7b2d --- /dev/null +++ b/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/spray_tool_options.dart @@ -0,0 +1,14 @@ +import 'package:flutter/cupertino.dart'; +import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/radius_slider.dart'; + +class SprayToolOptions extends StatelessWidget { + const SprayToolOptions({super.key}); + + @override + Widget build(BuildContext context) { + return const Column(children: [ + RadiusSlider(), + Spacer(), + ]); + } +} diff --git a/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/stroke_tool_options.dart b/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/stroke_tool_options.dart index 6726fd34..0d39b9dc 100644 --- a/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/stroke_tool_options.dart +++ b/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/stroke_tool_options.dart @@ -1,17 +1,16 @@ import 'package:flutter/cupertino.dart'; - -import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_options/stroke_cap_tool_option.dart'; -import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_options/stroke_width_tool_option.dart'; - +import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/stroke_cap_chips.dart'; +import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/stroke_width_slider.dart'; class StrokeToolOptions extends StatelessWidget { const StrokeToolOptions({super.key}); + @override Widget build(BuildContext context) { return const Column(children: [ - StrokeWidthToolOption(), + StrokeWidthSlider(), Spacer(), - StrokeCapToolOption(), + StrokeCapChips(), ]); } } diff --git a/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/tool_options.dart b/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/tool_options.dart index 0e3968c6..27fbf4a8 100644 --- a/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/tool_options.dart +++ b/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/tool_options.dart @@ -3,12 +3,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:paintroid/core/enums/tool_types.dart'; import 'package:paintroid/core/providers/state/tool_options_visibility_state_provider.dart'; import 'package:paintroid/core/providers/state/toolbox_state_provider.dart'; -import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_options/shapes_tool/shapes_tool_options.dart'; +import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_options/shapes_tool_options.dart'; +import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_options/spray_tool_options.dart'; import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_options/stroke_tool_options.dart'; import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_options/tool_option.dart'; class ToolOptions extends ConsumerWidget { const ToolOptions({super.key}); + final maxOpacity = 1.0; final minOpacity = 0.0; @@ -29,6 +31,7 @@ class ToolOptions extends ConsumerWidget { ToolType.ERASER => const StrokeToolOptions(), ToolType.LINE => const StrokeToolOptions(), ToolType.SHAPES => const ShapesToolOptions(), + ToolType.SPRAY => const SprayToolOptions(), _ => Container(), }, ), diff --git a/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/radius_slider.dart b/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/radius_slider.dart new file mode 100644 index 00000000..5dfd42e6 --- /dev/null +++ b/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/radius_slider.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:paintroid/core/providers/state/toolbox_state_provider.dart'; +import 'package:paintroid/core/tools/implementation/spray_tool.dart'; +import 'package:paintroid/ui/theme/theme.dart'; + +class RadiusSlider extends ConsumerStatefulWidget { + const RadiusSlider({super.key}); + + @override + ConsumerState createState() => _RadiusSliderState(); +} + +class _RadiusSliderState extends ConsumerState { + late final TextEditingController _radiusTextController; + double _radius = 20; + + void _onChangedTextField(String value) { + final newRadius = int.tryParse(value) ?? 1; + setState(() { + _radius = newRadius.toDouble(); + _radiusTextController.text = newRadius.toString(); + }); + final currentTool = ref.read(toolBoxStateProvider).currentTool; + if (currentTool is SprayTool) { + currentTool.updateSprayRadius(_radius); + } + } + + void _onChangedSlider(double newValue) { + setState(() { + _radius = newValue; + _radiusTextController.text = newValue.toInt().toString(); + }); + final currentTool = ref.read(toolBoxStateProvider).currentTool; + if (currentTool is SprayTool) { + currentTool.updateSprayRadius(_radius); + } + } + + @override + void initState() { + super.initState(); + final currentTool = ref.read(toolBoxStateProvider).currentTool; + if (currentTool is SprayTool) { + _radius = currentTool.sprayRadius; + } + _radiusTextController = TextEditingController( + text: _radius.toInt().toString(), + ); + } + + @override + void dispose() { + _radiusTextController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 25, + child: Row( + children: [ + Expanded( + flex: 1, + child: TextField( + controller: _radiusTextController, + style: PaintroidTheme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'^(100|[1-9][0-9]?)$'), + replacementString: _radius.toInt().toString(), + ), + ], + onChanged: _onChangedTextField, + decoration: InputDecoration( + filled: true, + fillColor: PaintroidTheme.of(context).onSurfaceColor, + contentPadding: EdgeInsets.zero, + hintText: '1', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ), + Expanded( + flex: 8, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + showValueIndicator: ShowValueIndicator.never, + ), + child: Slider( + value: _radius, + min: 1, + max: 100, + divisions: 99, + label: _radius.toInt().toString(), + onChanged: _onChangedSlider, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/shapes_tool/shapes_tool_shape_type_options.dart b/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/shapes_tool_shape_type_options.dart similarity index 100% rename from lib/ui/pages/workspace_page/components/bottom_bar/tool_options/shapes_tool/shapes_tool_shape_type_options.dart rename to lib/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/shapes_tool_shape_type_options.dart diff --git a/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/shapes_tool/shapes_tool_transformation_mode_options.dart b/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/shapes_tool_transformation_mode_options.dart similarity index 100% rename from lib/ui/pages/workspace_page/components/bottom_bar/tool_options/shapes_tool/shapes_tool_transformation_mode_options.dart rename to lib/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/shapes_tool_transformation_mode_options.dart diff --git a/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/stroke_cap_tool_option.dart b/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/stroke_cap_chips.dart similarity index 88% rename from lib/ui/pages/workspace_page/components/bottom_bar/tool_options/stroke_cap_tool_option.dart rename to lib/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/stroke_cap_chips.dart index 44fd47a7..5b4d42fc 100644 --- a/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/stroke_cap_tool_option.dart +++ b/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/stroke_cap_chips.dart @@ -1,20 +1,17 @@ import 'package:flutter/material.dart'; - import 'package:flutter_riverpod/flutter_riverpod.dart'; - import 'package:paintroid/core/providers/state/paint_provider.dart'; import 'package:paintroid/ui/shared/custom_action_chip.dart'; import 'package:paintroid/ui/theme/theme.dart'; -class StrokeCapToolOption extends ConsumerStatefulWidget { - const StrokeCapToolOption({super.key}); +class StrokeCapChips extends ConsumerStatefulWidget { + const StrokeCapChips({super.key}); @override - ConsumerState createState() => - _StrokeCapToolOptionState(); + ConsumerState createState() => _StrokeCapToolOptionState(); } -class _StrokeCapToolOptionState extends ConsumerState { +class _StrokeCapToolOptionState extends ConsumerState { Color _roundChipBackgroundColor = Colors.blue; Color _squareChipBackgroundColor = Colors.white; @@ -41,8 +38,7 @@ class _StrokeCapToolOptionState extends ConsumerState { @override void initState() { super.initState(); - StrokeCap toolStrokeCapOnInit = - ref.read(paintProvider).strokeCap; + StrokeCap toolStrokeCapOnInit = ref.read(paintProvider).strokeCap; _roundChipBackgroundColor = toolStrokeCapOnInit == StrokeCap.round ? Colors.blue : Colors.white; _squareChipBackgroundColor = diff --git a/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/stroke_width_tool_option.dart b/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/stroke_width_slider.dart similarity index 91% rename from lib/ui/pages/workspace_page/components/bottom_bar/tool_options/stroke_width_tool_option.dart rename to lib/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/stroke_width_slider.dart index dfca3595..b4c83880 100644 --- a/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/stroke_width_tool_option.dart +++ b/lib/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/stroke_width_slider.dart @@ -1,20 +1,18 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; - import 'package:flutter_riverpod/flutter_riverpod.dart'; - import 'package:paintroid/core/providers/state/paint_provider.dart'; import 'package:paintroid/ui/theme/theme.dart'; -class StrokeWidthToolOption extends ConsumerStatefulWidget { - const StrokeWidthToolOption({super.key}); +class StrokeWidthSlider extends ConsumerStatefulWidget { + const StrokeWidthSlider({super.key}); @override - ConsumerState createState() => + ConsumerState createState() => _StrokeWidthToolOptionState(); } -class _StrokeWidthToolOptionState extends ConsumerState { +class _StrokeWidthToolOptionState extends ConsumerState { late final TextEditingController _strokeWidthTextController; void _onChangedTextField(String value) { @@ -33,8 +31,7 @@ class _StrokeWidthToolOptionState extends ConsumerState { void initState() { super.initState(); _strokeWidthTextController = TextEditingController( - text: - ref.read(paintProvider).strokeWidth.toInt().toString(), + text: ref.read(paintProvider).strokeWidth.toInt().toString(), ); } diff --git a/pubspec.lock b/pubspec.lock index 41146529..01c51cd8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -292,10 +292,10 @@ packages: dependency: transitive description: name: file - sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" url: "https://pub.dev" source: hosted - version: "6.1.4" + version: "7.0.0" file_picker: dependency: "direct main" description: @@ -607,10 +607,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf url: "https://pub.dev" source: hosted - version: "0.18.1" + version: "0.19.0" io: dependency: transitive description: @@ -651,6 +651,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + url: "https://pub.dev" + source: hosted + version: "10.0.4" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" lints: dependency: transitive description: @@ -679,26 +703,26 @@ packages: dependency: transitive description: name: matcher - sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb url: "https://pub.dev" source: hosted - version: "0.12.16" + version: "0.12.16+1" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.8.0" meta: dependency: transitive description: name: meta - sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.12.0" mime: dependency: transitive description: @@ -751,10 +775,10 @@ packages: dependency: transitive description: name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" url: "https://pub.dev" source: hosted - version: "1.8.3" + version: "1.9.0" path_drawing: dependency: transitive description: @@ -871,10 +895,10 @@ packages: dependency: transitive description: name: platform - sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102 + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.4" plugin_platform_interface: dependency: transitive description: @@ -895,10 +919,10 @@ packages: dependency: transitive description: name: process - sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" + sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" url: "https://pub.dev" source: hosted - version: "4.2.4" + version: "5.0.2" pub_semver: dependency: transitive description: @@ -1196,10 +1220,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.0" timing: dependency: transitive description: @@ -1316,10 +1340,10 @@ packages: dependency: transitive description: name: vm_service - sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583 + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "11.10.0" + version: "14.2.1" watcher: dependency: transitive description: @@ -1348,10 +1372,10 @@ packages: dependency: transitive description: name: webdriver - sha256: "3c923e918918feeb90c4c9fdf1fe39220fa4c0e8e2c0fffaded174498ef86c49" + sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.3" win32: dependency: transitive description: @@ -1393,5 +1417,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.2.0 <4.0.0" - flutter: ">=3.16.0" + dart: ">=3.3.0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/test/integration/spray_tool_test.dart b/test/integration/spray_tool_test.dart new file mode 100644 index 00000000..a9f38b33 --- /dev/null +++ b/test/integration/spray_tool_test.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:paintroid/app.dart'; +import 'package:paintroid/core/tools/implementation/spray_tool.dart'; +import 'package:paintroid/core/tools/tool_data.dart'; + +import '../utils/test_utils.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + const String testIDStr = String.fromEnvironment('id', defaultValue: '-1'); + final testID = int.tryParse(testIDStr) ?? testIDStr; + + late Widget sut; + + setUp(() async { + sut = ProviderScope( + child: App( + showOnboardingPage: false, + ), + ); + }); + + if (testID == -1 || testID == 0) { + testWidgets('[SPRAY_TOOL]: test spray at center colors pixels', + (WidgetTester tester) async { + UIInteraction.initialize(tester); + await tester.pumpWidget(sut); + await UIInteraction.createNewImage(); + await UIInteraction.selectTool(ToolData.SPRAY.name); + + const radius = 50.0; + + (UIInteraction.getCurrentTool() as SprayTool).updateSprayRadius(radius); + + var color = await UIInteraction.getPixelColor( + CanvasPosition.centerX, + CanvasPosition.centerY, + radius: radius.toInt(), + ); + expect(color, Colors.transparent); + + await UIInteraction.tapAt(CanvasPosition.center); + + color = await UIInteraction.getPixelColor( + CanvasPosition.centerX, + CanvasPosition.centerY, + radius: radius.toInt(), + ); + + expect(color, isNot(Colors.transparent)); + }); + } + + if (testID == -1 || testID == 1) { + testWidgets('[SPRAY_TOOL]: test spray at top left colors pixels', + (WidgetTester tester) async { + UIInteraction.initialize(tester); + await tester.pumpWidget(sut); + await UIInteraction.createNewImage(); + await UIInteraction.selectTool(ToolData.SPRAY.name); + + const radius = 50.0; + + (UIInteraction.getCurrentTool() as SprayTool).updateSprayRadius(radius); + + var color = await UIInteraction.getPixelColor( + CanvasPosition.left, + CanvasPosition.top, + radius: radius.toInt(), + ); + expect(color, Colors.transparent); + + await UIInteraction.tapAt(CanvasPosition.topLeft); + + color = await UIInteraction.getPixelColor( + CanvasPosition.left, + CanvasPosition.top, + radius: radius.toInt(), + ); + + expect(color, isNot(Colors.transparent)); + }); + } + + if (testID == -1 || testID == 2) { + testWidgets('[SPRAY_TOOL]: test spray at bottom right colors pixels', + (WidgetTester tester) async { + UIInteraction.initialize(tester); + await tester.pumpWidget(sut); + await UIInteraction.createNewImage(); + await UIInteraction.selectTool(ToolData.SPRAY.name); + + const radius = 50.0; + + (UIInteraction.getCurrentTool() as SprayTool).updateSprayRadius(radius); + + var color = await UIInteraction.getPixelColor( + CanvasPosition.right, + CanvasPosition.bottom, + radius: radius.toInt(), + ); + expect(color, Colors.transparent); + + await UIInteraction.tapAt(CanvasPosition.bottomRight); + + color = await UIInteraction.getPixelColor( + CanvasPosition.right, + CanvasPosition.bottom, + radius: radius.toInt(), + ); + + expect(color, isNot(Colors.transparent)); + }); + } + + if (testID == -1 || testID == 3) { + testWidgets('[SPRAY_TOOL]: test spray with drag colors pixels along path', + (WidgetTester tester) async { + UIInteraction.initialize(tester); + await tester.pumpWidget(sut); + await UIInteraction.createNewImage(); + await UIInteraction.selectTool(ToolData.SPRAY.name); + + const radius = 50.0; + + (UIInteraction.getCurrentTool() as SprayTool).updateSprayRadius(radius); + + var color = await UIInteraction.getPixelColor( + CanvasPosition.centerX, + CanvasPosition.centerY, + radius: radius.toInt(), + ); + expect(color, Colors.transparent); + + await UIInteraction.dragFromTo( + CanvasPosition.topLeft, + CanvasPosition.bottomRight, + steps: 500, + ); + + color = await UIInteraction.getPixelColor( + CanvasPosition.centerX, + CanvasPosition.centerY, + radius: radius.toInt(), + ); + + expect(color, isNot(Colors.transparent)); + }); + } + + if (testID == -1 || testID == 5) { + testWidgets('[SPRAY_TOOL]: test spray undo and redo', + (WidgetTester tester) async { + UIInteraction.initialize(tester); + await tester.pumpWidget(sut); + await UIInteraction.createNewImage(); + await UIInteraction.selectTool(ToolData.SPRAY.name); + + const radius = 50.0; + + (UIInteraction.getCurrentTool() as SprayTool).updateSprayRadius(radius); + + await UIInteraction.tapAt(CanvasPosition.center, times: 10); + + var color = await UIInteraction.getPixelColor( + CanvasPosition.centerX, + CanvasPosition.centerY, + radius: radius.toInt(), + ); + expect(color, isNot(Colors.transparent)); + + await UIInteraction.clickUndo(times: 10); + + color = await UIInteraction.getPixelColor( + CanvasPosition.centerX, + CanvasPosition.centerY, + radius: radius.toInt(), + ); + expect(color, Colors.transparent); + + await UIInteraction.clickRedo(times: 10); + + color = await UIInteraction.getPixelColor( + CanvasPosition.centerX, + CanvasPosition.centerY, + radius: radius.toInt(), + ); + expect(color, isNot(Colors.transparent)); + }); + } +} diff --git a/test/unit/serialization/utils/dummy_version_strategy.dart b/test/unit/serialization/utils/dummy_version_strategy.dart index c42bdd9a..00fdb821 100644 --- a/test/unit/serialization/utils/dummy_version_strategy.dart +++ b/test/unit/serialization/utils/dummy_version_strategy.dart @@ -7,6 +7,7 @@ class DummyVersionStrategy implements IVersionStrategy { final int catrobatImageVersion; final int squareShapeCommandVersion; final int circleShapeCommandVersion; + final int sprayCommandVersion; DummyVersionStrategy({ this.pathCommandVersion = SerializerVersion.PATH_COMMAND_VERSION, @@ -16,6 +17,7 @@ class DummyVersionStrategy implements IVersionStrategy { SerializerVersion.SQUARE_SHAPE_COMMAND_VERSION, this.circleShapeCommandVersion = SerializerVersion.CIRCLE_SHAPE_COMMAND_VERSION, + this.sprayCommandVersion = SerializerVersion.SPRAY_COMMAND_VERSION, }); @override @@ -32,4 +34,7 @@ class DummyVersionStrategy implements IVersionStrategy { @override int getCircleShapeCommandVersion() => circleShapeCommandVersion; + + @override + int getSprayCommandVersion() => sprayCommandVersion; } diff --git a/test/unit/tools/spray_tool_test.dart b/test/unit/tools/spray_tool_test.dart new file mode 100644 index 00000000..70983ce1 --- /dev/null +++ b/test/unit/tools/spray_tool_test.dart @@ -0,0 +1,105 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:paintroid/core/commands/command_factory/command_factory.dart'; +import 'package:paintroid/core/commands/command_implementation/graphic/spray_command.dart'; +import 'package:paintroid/core/commands/command_manager/command_manager.dart'; +import 'package:paintroid/core/commands/graphic_factory/graphic_factory.dart'; +import 'package:paintroid/core/enums/tool_types.dart'; +import 'package:paintroid/core/tools/implementation/spray_tool.dart'; + +void main() { + late SprayTool sut; + + const Offset pointA = Offset(100, 100); + const Offset pointB = Offset(200, 200); + + Paint paint = Paint(); + + setUp(() { + sut = SprayTool( + commandFactory: const CommandFactory(), + commandManager: CommandManager(), + graphicFactory: const GraphicFactory(), + type: ToolType.SPRAY, + drawingSurfaceSize: const Size(1000, 1000), + ); + }); + + group('On tap down event', () { + test('Should create one SprayCommand with initial points', () { + expect(sut.commandManager.undoStack.isEmpty, true); + sut.onDown(pointA, paint); + expect(sut.commandManager.undoStack.length, 1); + expect(sut.commandManager.undoStack.first is SprayCommand, true); + final sprayCommand = sut.commandManager.undoStack.first as SprayCommand; + expect(sprayCommand.points.isNotEmpty, true); + }); + + test('SprayCommand points should increase on onDrag', () { + sut.onDown(pointA, paint); + final sprayCommand = sut.commandManager.undoStack.first as SprayCommand; + int initialPointCount = sprayCommand.points.length; + sut.onDrag(pointB, paint); + expect(sprayCommand.points.length, greaterThan(initialPointCount)); + }); + + test('Generated spray points should be within sprayRadius * 2', () { + sut.updateSprayRadius(50.0); + sut.onDown(pointA, paint); + final sprayCommand = sut.commandManager.undoStack.first as SprayCommand; + for (var point in sprayCommand.points) { + double distance = (point - pointA).distance; + expect(distance, lessThanOrEqualTo(50.0 * 2)); + } + }); + + test('On tap up, no additional commands are created', () { + sut.onDown(pointA, paint); + sut.onUp(pointA, paint); + expect(sut.commandManager.undoStack.length, 1); + }); + }); + + group('SprayTool properties and methods', () { + test('Should return SPRAY as ToolType', () { + expect(sut.type, ToolType.SPRAY); + }); + + test('updateSprayRadius should change sprayRadius', () { + double initialRadius = sut.sprayRadius; + sut.updateSprayRadius(80.0); + expect(sut.sprayRadius, equals(80.0)); + expect(sut.sprayRadius, isNot(equals(initialRadius))); + }); + + test('Spray density adjusts with sprayRadius', () { + sut.updateSprayRadius(30.0); + sut.onDown(pointA, paint); + final sprayCommand1 = sut.commandManager.undoStack.last as SprayCommand; + int pointCount30 = sprayCommand1.points.length; + + sut.updateSprayRadius(60.0); + sut.onDown(pointB, paint); + final sprayCommand2 = sut.commandManager.undoStack.last as SprayCommand; + int pointCount60 = sprayCommand2.points.length; + + expect(pointCount60, greaterThan(pointCount30)); + }); + + test('onCancel discards the last command', () { + sut.onDown(pointA, paint); + expect(sut.commandManager.undoStack.length, 1); + sut.onCancel(); + expect(sut.commandManager.undoStack.isEmpty, true); + }); + + test('onUndo removes the last command from the undoStack', () { + sut.onDown(pointA, paint); + sut.onUp(pointA, paint); + expect(sut.commandManager.undoStack.length, 1); + sut.onUndo(); + expect(sut.commandManager.undoStack.length, 0); + }); + }); +} diff --git a/test/utils/ui_interaction.dart b/test/utils/ui_interaction.dart index f7f12ab7..e9d1ed4b 100644 --- a/test/utils/ui_interaction.dart +++ b/test/utils/ui_interaction.dart @@ -76,7 +76,7 @@ class UIInteraction { return (leftPixel, rightPixel, topPixel, bottomPixel); } - static Future getPixelColor(int x, int y) async { + static Future getPixelColor(int x, int y, {int radius = 0}) async { final container = ProviderScope.containerOf(tester.element(find.byType(App))); final canvasStateNotifier = container.read(canvasStateProvider.notifier); @@ -90,15 +90,35 @@ class UIInteraction { final rawBytes = byteData.buffer.asUint8List(); final image = img.Image.fromBytes(cachedImage.width, cachedImage.height, rawBytes); - var pixel = image.getPixel(x, y); + if (radius != 0) { + for (int i = x - radius; i <= x + radius; i++) { + for (int j = y - radius; j <= y + radius; j++) { + if (i < 0 || i >= image.width || j < 0 || j >= image.height) { + continue; + } + final argbColor = getColorAtPixel(image, i, j); + if (argbColor != 0) { + return Color(argbColor); + } + } + } + return Colors.transparent; + } + + final argbColor = getColorAtPixel(image, x, y); + return Color(argbColor); + } + + static int getColorAtPixel(img.Image image, int x, int y) { + var pixel = image.getPixel(x, y); final a = img.getAlpha(pixel); final r = img.getRed(pixel); final g = img.getGreen(pixel); final b = img.getBlue(pixel); final argbColor = (a << 24) | (r << 16) | (g << 8) | b; - return Color(argbColor); + return argbColor; } static Future createNewImage() async { @@ -201,19 +221,30 @@ class UIInteraction { } } - static Future dragFromTo(Offset from, Offset to) async { + static Future dragFromTo( + Offset from, + Offset to, { + int steps = 1, + }) async { final TestGesture gesture = await tester.startGesture(from); await tester.pumpAndSettle(const Duration(milliseconds: 500)); - await gesture.moveTo(to); - await tester.pumpAndSettle(const Duration(milliseconds: 500)); + final dx = (to.dx - from.dx) / steps; + final dy = (to.dy - from.dy) / steps; + for (int i = 1; i <= steps; i++) { + final Offset nextPoint = Offset(from.dx + dx * i, from.dy + dy * i); + await gesture.moveTo(nextPoint); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + } await gesture.up(); await tester.pumpAndSettle(); } - static Future tapAt(Offset position) async { - await tester.tapAt(position); + static Future tapAt(Offset position, {int times = 0}) async { + for (var i = 0; i <= times; i++) { + await tester.tapAt(position); + } await tester.pumpAndSettle(); } diff --git a/test/widget/workspace_page/bottom_control_navigation_bar_test.dart b/test/widget/workspace_page/bottom_control_navigation_bar_test.dart index a0ec92c5..36f7ce53 100644 --- a/test/widget/workspace_page/bottom_control_navigation_bar_test.dart +++ b/test/widget/workspace_page/bottom_control_navigation_bar_test.dart @@ -1,16 +1,15 @@ import 'package:flutter/material.dart'; - import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; - import 'package:paintroid/core/localization/app_localizations.dart'; import 'package:paintroid/core/tools/tool_data.dart'; -import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_options/stroke_cap_tool_option.dart'; -import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_options/stroke_width_tool_option.dart'; +import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/stroke_cap_chips.dart'; +import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_options/widgets/stroke_width_slider.dart'; import 'package:paintroid/ui/pages/workspace_page/workspace_page.dart'; import 'package:paintroid/ui/theme/theme.dart'; + import '../../utils/bottom_nav_bar_interactions.dart'; void main() { @@ -68,8 +67,8 @@ void main() { final bottomNavBarInteractions = BottomNavBarInteractions(tester); - final animatedOpacity = bottomNavBarInteractions - .getAnimatedOpacityFinder(StrokeWidthToolOption); + final animatedOpacity = + bottomNavBarInteractions.getAnimatedOpacityFinder(StrokeWidthSlider); var animatedOpacityWidget = tester.widget(animatedOpacity); @@ -82,8 +81,8 @@ void main() { final bottomNavBarInteractions = BottomNavBarInteractions(tester); - final animatedOpacity = bottomNavBarInteractions - .getAnimatedOpacityFinder(StrokeWidthToolOption); + final animatedOpacity = + bottomNavBarInteractions.getAnimatedOpacityFinder(StrokeWidthSlider); var animatedOpacityWidget = tester.widget(animatedOpacity); @@ -100,8 +99,8 @@ void main() { await tester.pumpWidget(sut); final bottomNavBarInteractions = BottomNavBarInteractions(tester); - final animatedOpacity = bottomNavBarInteractions - .getAnimatedOpacityFinder(StrokeWidthToolOption); + final animatedOpacity = + bottomNavBarInteractions.getAnimatedOpacityFinder(StrokeWidthSlider); var animatedOpacityWidget = tester.widget(animatedOpacity); @@ -123,8 +122,8 @@ void main() { await tester.pumpWidget(sut); final bottomNavBarInteractions = BottomNavBarInteractions(tester); - final animatedOpacity = bottomNavBarInteractions - .getAnimatedOpacityFinder(StrokeCapToolOption); + final animatedOpacity = + bottomNavBarInteractions.getAnimatedOpacityFinder(StrokeCapChips); var animatedOpacityWidget = tester.widget(animatedOpacity); @@ -136,8 +135,8 @@ void main() { await tester.pumpWidget(sut); final bottomNavBarInteractions = BottomNavBarInteractions(tester); - final animatedOpacity = bottomNavBarInteractions - .getAnimatedOpacityFinder(StrokeCapToolOption); + final animatedOpacity = + bottomNavBarInteractions.getAnimatedOpacityFinder(StrokeCapChips); var animatedOpacityWidget = tester.widget(animatedOpacity); @@ -154,8 +153,8 @@ void main() { await tester.pumpWidget(sut); final bottomNavBarInteractions = BottomNavBarInteractions(tester); - final animatedOpacity = bottomNavBarInteractions - .getAnimatedOpacityFinder(StrokeCapToolOption); + final animatedOpacity = + bottomNavBarInteractions.getAnimatedOpacityFinder(StrokeCapChips); var animatedOpacityWidget = tester.widget(animatedOpacity);