diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b22394e1..a1bed615 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -42,14 +42,12 @@ PODS: - Flutter - integration_test (0.0.1): - Flutter - - launch_review (0.0.1): - - Flutter - package_info_plus (0.4.5): - Flutter - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - permission_handler_apple (9.1.1): + - permission_handler_apple (9.3.0): - Flutter - SDWebImage (5.19.2): - SDWebImage/Core (= 5.19.2) @@ -57,7 +55,7 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - sqflite (0.0.3): + - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS - SwiftyGif (5.4.5) @@ -71,12 +69,11 @@ DEPENDENCIES: - flutter_localization (from `.symlinks/plugins/flutter_localization/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - - launch_review (from `.symlinks/plugins/launch_review/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite (from `.symlinks/plugins/sqflite/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) SPEC REPOS: @@ -99,8 +96,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/image_picker_ios/ios" integration_test: :path: ".symlinks/plugins/integration_test/ios" - launch_review: - :path: ".symlinks/plugins/launch_review/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: @@ -109,8 +104,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/permission_handler_apple/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - sqflite: - :path: ".symlinks/plugins/sqflite/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" @@ -121,15 +116,14 @@ SPEC CHECKSUMS: file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_localization: f43b18844a2b3d2c71fd64f04ffd6b1e64dd54d4 - image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425 + image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 - launch_review: 75d5a956ba8eaa493e9c9d4bf4c05e505e8d5ed0 - package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85 - path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c - permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 SDWebImage: dfe95b2466a9823cf9f0c6d01217c06550d7b29a - shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 - sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 6b863eca..be36c6c6 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -140,6 +140,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, C143E155B1BF9D937E877BC4 /* [CP] Embed Pods Frameworks */, + CE347BAC542BC77A1AF7E630 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -268,6 +269,23 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + CE347BAC542BC77A1AF7E630 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 5e31d3d3..c53e2b31 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -48,6 +48,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/lib/core/commands/command_factory/command_factory.dart b/lib/core/commands/command_factory/command_factory.dart index 5b162543..b2192b3b 100644 --- a/lib/core/commands/command_factory/command_factory.dart +++ b/lib/core/commands/command_factory/command_factory.dart @@ -15,9 +15,10 @@ class CommandFactory { PathCommand createPathCommand( PathWithActionHistory path, - Paint paint, - ) => - PathCommand(path, paint); + Paint paint, { + bool isCursor = false, + }) => + PathCommand(path, paint, isCursorPath: isCursor); LineCommand createLineCommand( PathWithActionHistory path, diff --git a/lib/core/commands/command_implementation/graphic/path_command.dart b/lib/core/commands/command_implementation/graphic/path_command.dart index a26fff85..ba0a20b0 100644 --- a/lib/core/commands/command_implementation/graphic/path_command.dart +++ b/lib/core/commands/command_implementation/graphic/path_command.dart @@ -17,12 +17,14 @@ part 'path_command.g.dart'; class PathCommand extends GraphicCommand { final String type; final int version; + final bool isCursorPath; PathCommand( this.path, super.paint, { this.type = SerializerType.PATH_COMMAND, int? version, + this.isCursorPath = false, }) : version = version ?? VersionStrategyManager.strategy.getPathCommandVersion(); diff --git a/lib/core/commands/command_implementation/graphic/path_command.g.dart b/lib/core/commands/command_implementation/graphic/path_command.g.dart index 5711dc2f..cb5748ad 100644 --- a/lib/core/commands/command_implementation/graphic/path_command.g.dart +++ b/lib/core/commands/command_implementation/graphic/path_command.g.dart @@ -12,6 +12,7 @@ PathCommand _$PathCommandFromJson(Map json) => PathCommand( const PaintConverter().fromJson(json['paint'] as Map), type: json['type'] as String? ?? SerializerType.PATH_COMMAND, version: (json['version'] as num?)?.toInt(), + isCursorPath: json['isCursorPath'] as bool? ?? false, ); Map _$PathCommandToJson(PathCommand instance) => @@ -19,5 +20,6 @@ Map _$PathCommandToJson(PathCommand instance) => 'paint': const PaintConverter().toJson(instance.paint), 'type': instance.type, 'version': instance.version, + 'isCursorPath': instance.isCursorPath, 'path': const PathWithActionHistoryConverter().toJson(instance.path), }; diff --git a/lib/core/commands/command_manager/command_manager.dart b/lib/core/commands/command_manager/command_manager.dart index e8ca6e25..1c30ebaa 100644 --- a/lib/core/commands/command_manager/command_manager.dart +++ b/lib/core/commands/command_manager/command_manager.dart @@ -4,6 +4,7 @@ import 'package:paintroid/core/commands/command_implementation/command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/text_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/graphic_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/line_command.dart'; +import 'package:paintroid/core/commands/command_implementation/graphic/path_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/shape/ellipse_shape_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/shape/heart_shape_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/path_command.dart'; @@ -116,7 +117,7 @@ class CommandManager { return ToolData.SHAPES; } else if (command.runtimeType == EllipseShapeCommand) { return ToolData.SHAPES; - } else if (command.runtimeType == TextCommand) { + } else if (command.runtimeType == TextCommand) { return ToolData.TEXT; } else if (command.runtimeType == SprayCommand) { return ToolData.SPRAY; @@ -131,6 +132,12 @@ class CommandManager { return ToolData.BRUSH; } } else { + if (command.runtimeType == PathCommand) { + final pathCommand = command as PathCommand; + if (pathCommand.isCursorPath) { + return ToolData.CURSOR; + } + } return ToolData.BRUSH; } } diff --git a/lib/core/commands/command_painter.dart b/lib/core/commands/command_painter.dart index 8b768569..d70386fb 100644 --- a/lib/core/commands/command_painter.dart +++ b/lib/core/commands/command_painter.dart @@ -6,6 +6,7 @@ import 'package:paintroid/core/enums/tool_types.dart'; import 'package:paintroid/core/providers/state/canvas_state_provider.dart'; import 'package:paintroid/core/providers/state/paint_provider.dart'; import 'package:paintroid/core/providers/state/toolbox_state_provider.dart'; +import 'package:paintroid/core/tools/implementation/cursor_tool.dart'; import 'package:paintroid/core/tools/implementation/shapes_tool.dart'; import 'package:paintroid/core/tools/implementation/text_tool.dart'; import 'package:paintroid/core/tools/implementation/brush_tool.dart'; @@ -31,6 +32,7 @@ class CommandPainter extends CustomPainter { currentTool.type != ToolType.TEXT) { canvas.clipRect(Rect.fromLTWH(0, 0, size.width, size.height)); } + switch (currentTool.type) { case ToolType.LINE: _drawGhostPathsAndVertices(canvas, currentTool as LineTool); @@ -40,6 +42,11 @@ class CommandPainter extends CustomPainter { ..drawShape(canvas, ref.read(paintProvider)) ..drawGuides(canvas); break; + case ToolType.CURSOR: + commandManager.executeLastCommand(canvas); + (currentTool as CursorTool) + .drawCursorIcon(canvas, ref.read(paintProvider)); + break; case ToolType.TEXT: (currentTool as TextTool).drawGuides(canvas, ref.read(paintProvider)); break; diff --git a/lib/core/providers/object/tools/cursor_tool_provider.dart b/lib/core/providers/object/tools/cursor_tool_provider.dart new file mode 100644 index 00000000..89c18bc0 --- /dev/null +++ b/lib/core/providers/object/tools/cursor_tool_provider.dart @@ -0,0 +1,27 @@ +import 'dart:ui'; + +import 'package:paintroid/core/providers/state/canvas_state_provider.dart'; +import 'package:paintroid/core/tools/implementation/cursor_tool.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +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'; + +part 'cursor_tool_provider.g.dart'; + +@riverpod +class CursorToolProvider extends _$CursorToolProvider { + @override + CursorTool build() { + final canvasCenter = ref.read(canvasStateProvider).size.center(Offset.zero); + return CursorTool( + commandManager: ref.watch(commandManagerProvider), + commandFactory: ref.watch(commandFactoryProvider), + graphicFactory: ref.watch(graphicFactoryProvider), + canvasCenter: canvasCenter, + type: ToolType.CURSOR, + ); + } +} diff --git a/lib/core/providers/object/tools/cursor_tool_provider.g.dart b/lib/core/providers/object/tools/cursor_tool_provider.g.dart new file mode 100644 index 00000000..21d3f0f9 --- /dev/null +++ b/lib/core/providers/object/tools/cursor_tool_provider.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'cursor_tool_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$cursorToolProviderHash() => + r'3d71164af06a30920e54814e04a468ce625909f1'; + +/// See also [CursorToolProvider]. +@ProviderFor(CursorToolProvider) +final cursorToolProvider = + AutoDisposeNotifierProvider.internal( + CursorToolProvider.new, + name: r'cursorToolProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$cursorToolProviderHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$CursorToolProvider = 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, deprecated_member_use_from_same_package diff --git a/lib/core/providers/state/toolbox_state_provider.dart b/lib/core/providers/state/toolbox_state_provider.dart index bd4f0dd6..59b0b6e2 100644 --- a/lib/core/providers/state/toolbox_state_provider.dart +++ b/lib/core/providers/state/toolbox_state_provider.dart @@ -4,6 +4,7 @@ import 'package:paintroid/core/commands/command_manager/command_manager_provider 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/cursor_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'; @@ -85,6 +86,9 @@ class ToolBoxStateProvider extends _$ToolBoxStateProvider { (state.currentTool as SprayTool).updateSprayRadius(currentStrokeWidth); ref.read(paintProvider.notifier).updateStrokeWidth(SPRAY_TOOL_RADIUS); break; + case ToolType.CURSOR: + state = state.copyWith(currentTool: ref.read(cursorToolProvider)); + 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 4ad281ca..cf9b7ca3 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'287ea0cf348a191887971afc63b94e77ffd3d24b'; + r'66bd382fcee9031192eec2648456ccf2f1e5d800'; /// See also [ToolBoxStateProvider]. @ProviderFor(ToolBoxStateProvider) diff --git a/lib/core/tools/implementation/brush_tool.dart b/lib/core/tools/implementation/brush_tool.dart index 1513bd7e..04478603 100644 --- a/lib/core/tools/implementation/brush_tool.dart +++ b/lib/core/tools/implementation/brush_tool.dart @@ -8,6 +8,7 @@ import 'package:paintroid/core/tools/tool.dart'; class BrushTool extends Tool { final GraphicFactory graphicFactory; + final bool isCursor; bool isDrawing = false; @visibleForTesting @@ -18,6 +19,7 @@ class BrushTool extends Tool { required super.commandManager, required this.graphicFactory, required super.type, + this.isCursor = false, super.hasAddFunctionality = false, super.hasFinalizeFunctionality = false, }); @@ -28,7 +30,11 @@ class BrushTool extends Tool { pathToDraw = graphicFactory.createPathWithActionHistory() ..moveTo(point.dx, point.dy); Paint savedPaint = graphicFactory.copyPaint(paint); - final command = commandFactory.createPathCommand(pathToDraw, savedPaint); + final command = commandFactory.createPathCommand( + pathToDraw, + savedPaint, + isCursor: isCursor, + ); commandManager.addGraphicCommand(command); } diff --git a/lib/core/tools/implementation/cursor_tool.dart b/lib/core/tools/implementation/cursor_tool.dart new file mode 100644 index 00000000..b4b4333d --- /dev/null +++ b/lib/core/tools/implementation/cursor_tool.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:paintroid/core/tools/implementation/brush_tool.dart'; + +class CursorTool extends BrushTool { + static const double _tapTolerance = 10.0; + static const double _circleRadius = 40.0; + static const double _crossLength = 90.0; + static const double _crossThickness = 10.0; + static const double _strokeWidth = 10.0; + + final Offset canvasCenter; + Offset lastPoint = Offset.zero; + bool isActive = false; + + bool _isCurrentlyDrawing = false; + Offset? _initialTouchPoint; + Offset? _initialCursorPosition; + bool _hasDragged = false; + + CursorTool({ + required super.commandFactory, + required super.commandManager, + required super.graphicFactory, + required this.canvasCenter, + required super.type, + super.isCursor = true, + }) { + lastPoint = canvasCenter; + } + + void toggleActive() => isActive = !isActive; + + void setCursorPosition(Offset position) => lastPoint = position; + + @override + void onDown(Offset point, Paint paint) { + _initializeTouch(point); + } + + @override + void onDrag(Offset point, Paint paint) { + _updateDragState(point); + _updateCursorPosition(point); + + if (isActive && !_isCurrentlyDrawing) { + super.onDown(lastPoint, paint); + _isCurrentlyDrawing = true; + } + + if (isActive && _isCurrentlyDrawing) { + super.onDrag(lastPoint, paint); + } + } + + @override + void onUp(Offset point, Paint paint) { + _updateCursorPosition(point); + + if (_isTap()) { + _handleTap(); + return; + } + + if (isActive && _isCurrentlyDrawing) { + super.onUp(lastPoint, paint); + } + + _finalizeDraw(); + } + + void drawCursorIcon(Canvas canvas, Paint paint) { + final cursorColor = isActive ? Colors.red : Colors.black; + final circlePaint = _createCirclePaint(cursorColor); + final crossPaint = _createCrossPaint(cursorColor); + + canvas.drawCircle(lastPoint, _circleRadius, circlePaint); + _drawCrossLines(canvas, crossPaint); + } + + void _initializeTouch(Offset point) { + _initialTouchPoint = point; + _initialCursorPosition = lastPoint; + _hasDragged = false; + _isCurrentlyDrawing = false; + } + + void _updateDragState(Offset point) { + if (_initialTouchPoint != null) { + final distance = (point - _initialTouchPoint!).distance; + if (distance > _tapTolerance) _hasDragged = true; + } + } + + void _updateCursorPosition(Offset point) { + if (_initialTouchPoint != null && _initialCursorPosition != null) { + final delta = point - _initialTouchPoint!; + lastPoint = _initialCursorPosition! + delta; + } + } + + bool _isTap() => !_hasDragged; + + void _handleTap() { + toggleActive(); + if (!isActive && _isCurrentlyDrawing) { + _isCurrentlyDrawing = false; + _resetTracking(); + } + } + + void _finalizeDraw() { + _isCurrentlyDrawing = false; + _resetTracking(); + } + + void _resetTracking() { + _initialTouchPoint = null; + _initialCursorPosition = null; + _hasDragged = false; + } + + Paint _createCirclePaint(Color color) => Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = _strokeWidth + ..isAntiAlias = true; + + Paint _createCrossPaint(Color color) => Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = _crossThickness + ..strokeCap = StrokeCap.round + ..isAntiAlias = true; + + void _drawCrossLines(Canvas canvas, Paint paint) { + final crossStart = _circleRadius; + final crossEnd = crossStart + _crossLength; + + final lines = [ + ( + Offset(lastPoint.dx, lastPoint.dy - crossEnd), + Offset(lastPoint.dx, lastPoint.dy - crossStart) + ), + ( + Offset(lastPoint.dx, lastPoint.dy + crossStart), + Offset(lastPoint.dx, lastPoint.dy + crossEnd) + ), + ( + Offset(lastPoint.dx - crossEnd, lastPoint.dy), + Offset(lastPoint.dx - crossStart, lastPoint.dy) + ), + ( + Offset(lastPoint.dx + crossStart, lastPoint.dy), + Offset(lastPoint.dx + crossEnd, lastPoint.dy) + ), + ]; + + for (final (start, end) in lines) { + canvas.drawLine(start, end, paint); + } + } +} diff --git a/lib/core/tools/tool.dart b/lib/core/tools/tool.dart index 31cd3280..dc0b9999 100644 --- a/lib/core/tools/tool.dart +++ b/lib/core/tools/tool.dart @@ -1,5 +1,6 @@ import 'dart:ui'; +import 'package:flutter/cupertino.dart'; import 'package:paintroid/core/commands/command_factory/command_factory.dart'; import 'package:paintroid/core/commands/command_manager/command_manager.dart'; import 'package:paintroid/core/enums/tool_types.dart'; diff --git a/test/integration/cursor_tool_test.dart b/test/integration/cursor_tool_test.dart new file mode 100644 index 00000000..f033d396 --- /dev/null +++ b/test/integration/cursor_tool_test.dart @@ -0,0 +1,380 @@ +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/tool_data.dart'; +import 'package:paintroid/core/utils/color_utils.dart'; + +import '../utils/canvas_positions.dart'; +import '../utils/ui_interaction.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('[CURSOR_TOOL]: cursor positioning without drawing', + (WidgetTester tester) async { + UIInteraction.initialize(tester); + await tester.pumpWidget(sut); + await UIInteraction.createNewImage(); + UIInteraction.setColor(Colors.black); + await UIInteraction.selectTool(ToolData.CURSOR.name); + + await UIInteraction.dragFromTo( + CanvasPosition.topLeft, + CanvasPosition.bottomRight, + ); + + var color = await UIInteraction.getPixelColor( + CanvasPosition.centerX, + CanvasPosition.centerY, + ); + expect(color.toValue(), Colors.transparent.toValue()); + }); + } + + if (testID == -1 || testID == 1) { + testWidgets('[CURSOR_TOOL]: toggle cursor active with tap', + (WidgetTester tester) async { + UIInteraction.initialize(tester); + await tester.pumpWidget(sut); + await UIInteraction.createNewImage(); + UIInteraction.setColor(Colors.black); + await UIInteraction.selectTool(ToolData.CURSOR.name); + + await UIInteraction.dragFromTo( + CanvasPosition.center, + CanvasPosition.topLeft, + ); + + await UIInteraction.tapAt(CanvasPosition.center); + + await UIInteraction.dragFromTo( + CanvasPosition.topLeft, + CanvasPosition.bottomRight, + steps: 20, + ); + + var color = await UIInteraction.getPixelColor( + CanvasPosition.centerX, + CanvasPosition.centerY, + ); + expect(color.toValue(), Colors.black.toValue()); + }); + } + + if (testID == -1 || testID == 2) { + testWidgets('[CURSOR_TOOL]: drawing when cursor is active', + (WidgetTester tester) async { + UIInteraction.initialize(tester); + await tester.pumpWidget(sut); + await UIInteraction.createNewImage(); + UIInteraction.setColor(Colors.black); + await UIInteraction.selectTool(ToolData.CURSOR.name); + + await UIInteraction.dragFromTo( + CanvasPosition.center, + CanvasPosition.topLeft, + steps: 20, + ); + + await UIInteraction.tapAt(CanvasPosition.center); + + await UIInteraction.dragFromTo( + CanvasPosition.topLeft, + CanvasPosition.bottomRight, + steps: 20, + ); + + var color = await UIInteraction.getPixelColor( + CanvasPosition.centerX, + CanvasPosition.centerY, + ); + expect(color.toValue(), Colors.black.toValue()); + }); + } + + if (testID == -1 || testID == 3) { + testWidgets('[CURSOR_TOOL]: toggle cursor off stops drawing', + (WidgetTester tester) async { + UIInteraction.initialize(tester); + await tester.pumpWidget(sut); + await UIInteraction.createNewImage(); + UIInteraction.setColor(Colors.black); + await UIInteraction.selectTool(ToolData.CURSOR.name); + + await UIInteraction.dragFromTo( + CanvasPosition.center, + CanvasPosition.topLeft, + steps: 20, + ); + + await UIInteraction.tapAt(CanvasPosition.center); + + await UIInteraction.dragFromTo( + CanvasPosition.topLeft, + CanvasPosition.center, + steps: 20, + ); + + var color = await UIInteraction.getPixelColor( + CanvasPosition.centerX, + CanvasPosition.centerY, + ); + expect(color.toValue(), Colors.black.toValue()); + + await UIInteraction.tapAt(CanvasPosition.center); + + await UIInteraction.dragFromTo( + CanvasPosition.centerRight, + CanvasPosition.bottomRight, + steps: 20, + ); + + color = await UIInteraction.getPixelColor( + CanvasPosition.right, + CanvasPosition.centerY, + ); + expect(color.toValue(), Colors.transparent.toValue()); + }); + } + + if (testID == -1 || testID == 4) { + testWidgets('[CURSOR_TOOL]: cursor position follows drag movement', + (WidgetTester tester) async { + UIInteraction.initialize(tester); + await tester.pumpWidget(sut); + await UIInteraction.createNewImage(); + UIInteraction.setColor(Colors.black); + await UIInteraction.selectTool(ToolData.CURSOR.name); + + await UIInteraction.tapAt(CanvasPosition.center); + + await UIInteraction.dragFromTo( + CanvasPosition.center, + CanvasPosition.topLeft, + steps: 20, + ); + + await UIInteraction.dragFromTo( + CanvasPosition.topLeft, + CanvasPosition.bottomRight, + steps: 20, + ); + + var color = await UIInteraction.getPixelColor( + CanvasPosition.centerX, + CanvasPosition.centerY, + ); + expect(color.toValue(), Colors.black.toValue()); + }); + } + + if (testID == -1 || testID == 5) { + testWidgets('[CURSOR_TOOL]: undo and redo with cursor drawing', + (WidgetTester tester) async { + UIInteraction.initialize(tester); + await tester.pumpWidget(sut); + await UIInteraction.createNewImage(); + UIInteraction.setColor(Colors.black); + await UIInteraction.selectTool(ToolData.CURSOR.name); + + await UIInteraction.dragFromTo( + CanvasPosition.center, + CanvasPosition.topLeft, + steps: 20, + ); + + await UIInteraction.tapAt(CanvasPosition.center); + + await UIInteraction.dragFromTo( + CanvasPosition.topLeft, + CanvasPosition.bottomRight, + steps: 20, + ); + + var color = await UIInteraction.getPixelColor( + CanvasPosition.centerX, + CanvasPosition.centerY, + ); + expect(color.toValue(), Colors.black.toValue()); + + await UIInteraction.clickUndo(); + + color = await UIInteraction.getPixelColor( + CanvasPosition.centerX, + CanvasPosition.centerY, + ); + expect(color.toValue(), Colors.transparent.toValue()); + + await UIInteraction.clickRedo(); + + color = await UIInteraction.getPixelColor( + CanvasPosition.centerX, + CanvasPosition.centerY, + ); + expect(color.toValue(), Colors.black.toValue()); + }); + } + + if (testID == -1 || testID == 6) { + testWidgets('[CURSOR_TOOL]: multiple cursor movements and drawings', + (WidgetTester tester) async { + UIInteraction.initialize(tester); + await tester.pumpWidget(sut); + await UIInteraction.createNewImage(); + UIInteraction.setColor(Colors.black); + await UIInteraction.selectTool(ToolData.CURSOR.name); + + await UIInteraction.tapAt(CanvasPosition.center); + + await UIInteraction.dragFromTo( + CanvasPosition.center, + CanvasPosition.topLeft, + ); + await UIInteraction.dragFromTo( + CanvasPosition.topLeft, + CanvasPosition.topRight, + ); + + await UIInteraction.dragFromTo( + CanvasPosition.topRight, + CanvasPosition.bottomLeft, + ); + await UIInteraction.dragFromTo( + CanvasPosition.bottomLeft, + CanvasPosition.bottomRight, + ); + + var color = await UIInteraction.getPixelColor( + CanvasPosition.right, + CanvasPosition.top, + ); + expect(color.toValue(), Colors.black.toValue()); + + color = await UIInteraction.getPixelColor( + CanvasPosition.right, + CanvasPosition.bottom, + ); + expect(color.toValue(), Colors.black.toValue()); + }); + } + + if (testID == -1 || testID == 7) { + testWidgets('[CURSOR_TOOL]: drawing with different colors', + (WidgetTester tester) async { + UIInteraction.initialize(tester); + await tester.pumpWidget(sut); + await UIInteraction.createNewImage(); + await UIInteraction.selectTool(ToolData.CURSOR.name); + + UIInteraction.setColor(Colors.black); + + await UIInteraction.dragFromTo( + CanvasPosition.center, + CanvasPosition.topLeft, + steps: 20, + ); + + await UIInteraction.tapAt(CanvasPosition.center); + + await UIInteraction.dragFromTo( + CanvasPosition.topLeft, + CanvasPosition.bottomRight, + steps: 20, + ); + + await UIInteraction.tapAt(CanvasPosition.center); + + await UIInteraction.dragFromTo( + CanvasPosition.bottomRight, + CanvasPosition.center, + steps: 20, + ); + + var color = await UIInteraction.getPixelColor( + CanvasPosition.centerX, + CanvasPosition.centerY, + ); + expect(color.toValue(), Colors.black.toValue()); + + UIInteraction.setColor(Colors.red); + + await UIInteraction.dragFromTo( + CanvasPosition.center, + CanvasPosition.topRight, + steps: 20, + ); + + await UIInteraction.tapAt(CanvasPosition.center); + + await UIInteraction.dragFromTo( + CanvasPosition.topRight, + CanvasPosition.bottomLeft, + steps: 20, + ); + + color = await UIInteraction.getPixelColor( + CanvasPosition.centerX, + CanvasPosition.centerY, + ); + expect(color.toValue(), Colors.red.toValue()); + }); + } + + if (testID == -1 || testID == 8) { + testWidgets('[CURSOR_TOOL]: inactive cursor does not interfere with touch', + (WidgetTester tester) async { + UIInteraction.initialize(tester); + await tester.pumpWidget(sut); + await UIInteraction.createNewImage(); + UIInteraction.setColor(Colors.black); + await UIInteraction.selectTool(ToolData.CURSOR.name); + + await UIInteraction.dragFromTo( + CanvasPosition.topLeft, + CanvasPosition.bottomRight, + ); + await UIInteraction.dragFromTo( + CanvasPosition.topRight, + CanvasPosition.bottomLeft, + ); + await UIInteraction.dragFromTo( + CanvasPosition.centerLeft, + CanvasPosition.centerRight, + ); + + var color = await UIInteraction.getPixelColor( + CanvasPosition.centerX, + CanvasPosition.centerY, + ); + expect(color.toValue(), Colors.transparent.toValue()); + + color = await UIInteraction.getPixelColor( + CanvasPosition.left, + CanvasPosition.centerY, + ); + expect(color.toValue(), Colors.transparent.toValue()); + + color = await UIInteraction.getPixelColor( + CanvasPosition.right, + CanvasPosition.centerY, + ); + expect(color.toValue(), Colors.transparent.toValue()); + }); + } +} diff --git a/test/unit/tools/cursor_tool_test.dart b/test/unit/tools/cursor_tool_test.dart new file mode 100644 index 00000000..a6199d30 --- /dev/null +++ b/test/unit/tools/cursor_tool_test.dart @@ -0,0 +1,217 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +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/path_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/commands/path_with_action_history.dart'; +import 'package:paintroid/core/enums/tool_types.dart'; +import 'package:paintroid/core/tools/implementation/cursor_tool.dart'; + +void main() { + late CursorTool sut; + + const Offset pointA = Offset(100, 100); + const Offset pointB = Offset(200, 200); + const Offset canvasCenter = Offset(150, 150); + + Paint paint = Paint(); + + setUp(() { + sut = CursorTool( + commandFactory: const CommandFactory(), + commandManager: CommandManager(), + graphicFactory: const GraphicFactory(), + canvasCenter: canvasCenter, + type: ToolType.CURSOR, + ); + }); + + group('Initialization', () { + test('Should initialize with cursor at canvas center', () { + expect(sut.lastPoint, canvasCenter); + }); + + test('Should initialize with inactive state', () { + expect(sut.isActive, false); + }); + + test('Should return CURSOR as ToolType', () { + expect(sut.type, ToolType.CURSOR); + }); + }); + + group('Cursor positioning', () { + test('Should set cursor position correctly', () { + sut.setCursorPosition(pointA); + expect(sut.lastPoint, pointA); + }); + + test('Should update cursor position when dragging', () { + sut.onDown(pointA, paint); + sut.onDrag(pointB, paint); + + final expectedPosition = canvasCenter + (pointB - pointA); + expect(sut.lastPoint, expectedPosition); + }); + }); + + group('Active state management', () { + test('Should toggle active state', () { + expect(sut.isActive, false); + sut.toggleActive(); + expect(sut.isActive, true); + sut.toggleActive(); + expect(sut.isActive, false); + }); + + test('Should toggle active state on tap', () { + expect(sut.isActive, false); + sut.onDown(pointA, paint); + sut.onUp(pointA, paint); + expect(sut.isActive, true); + }); + + test('Should deactivate when tapping while active', () { + sut.toggleActive(); + expect(sut.isActive, true); + + sut.onDown(pointA, paint); + sut.onUp(pointA, paint); + expect(sut.isActive, false); + }); + }); + + group('Drawing behavior when inactive', () { + test('Should not create PathCommand when inactive on down', () { + expect(sut.commandManager.undoStack.isEmpty, true); + sut.onDown(pointA, paint); + expect(sut.commandManager.undoStack.isEmpty, true); + }); + + test('Should not create PathCommand when inactive on drag', () { + expect(sut.commandManager.undoStack.isEmpty, true); + sut.onDown(pointA, paint); + sut.onDrag(pointB, paint); + expect(sut.commandManager.undoStack.isEmpty, true); + }); + + test('Should not create PathCommand when inactive on up', () { + expect(sut.commandManager.undoStack.isEmpty, true); + sut.onDown(pointA, paint); + sut.onDrag(pointB, paint); + sut.onUp(pointB, paint); + expect(sut.commandManager.undoStack.isEmpty, true); + }); + }); + + group('Drawing behavior when active', () { + setUp(() { + sut.toggleActive(); + }); + + test('Should create PathCommand when active on drag', () { + expect(sut.commandManager.undoStack.isEmpty, true); + sut.onDown(pointA, paint); + sut.onDrag(pointB, paint); + expect(sut.commandManager.undoStack.first is PathCommand, true); + }); + + test('Should add MoveToAction when active on drag', () { + expect(sut.commandManager.undoStack.isEmpty, true); + sut.onDown(pointA, paint); + sut.onDrag(pointB, paint); + final firstAction = (sut.commandManager.undoStack.first as PathCommand) + .path + .actions + .first; + expect(firstAction is MoveToAction, true); + }); + + test('Should add LineToAction when active on drag', () { + expect(sut.commandManager.undoStack.isEmpty, true); + sut.onDown(pointA, paint); + sut.onDrag(pointB, paint); + final lastAction = + (sut.commandManager.undoStack.first as PathCommand).path.actions.last; + expect(lastAction is LineToAction, true); + }); + + test('Should create new PathCommand after completing a stroke', () { + expect(sut.commandManager.undoStack.isEmpty, true); + + const dragOffset = Offset(20, 20); + sut.onDown(pointA, paint); + sut.onDrag(pointA + dragOffset, paint); + sut.onUp(pointA + dragOffset, paint); + expect(sut.commandManager.undoStack.length, 1); + + sut.onDown(pointB, paint); + sut.onDrag(pointB + dragOffset, paint); + sut.onUp(pointB + dragOffset, paint); + expect(sut.commandManager.undoStack.length, 2); + }); + }); + + group('Tap vs Drag detection', () { + test('Should detect tap when movement is within tolerance', () { + const smallOffset = Offset(5, 5); + + sut.onDown(pointA, paint); + sut.onDrag(pointA + smallOffset, paint); + sut.onUp(pointA + smallOffset, paint); + + expect(sut.isActive, true); + }); + + test('Should detect drag when movement exceeds tolerance', () { + const largeOffset = Offset(50, 50); + + sut.onDown(pointA, paint); + sut.onDrag(pointA + largeOffset, paint); + sut.onUp(pointA + largeOffset, paint); + + expect(sut.isActive, false); + }); + }); + + group('Cursor position tracking during drag', () { + test('Should maintain cursor position relative to initial touch', () { + final initialCursorPos = sut.lastPoint; + const touchPoint = Offset(100, 100); + const dragPoint = Offset(150, 150); + + sut.onDown(touchPoint, paint); + sut.onDrag(dragPoint, paint); + + final expectedCursorPos = initialCursorPos + (dragPoint - touchPoint); + expect(sut.lastPoint, expectedCursorPos); + }); + + test('Should update cursor position correctly through multiple drags', () { + final initialCursorPos = sut.lastPoint; + const touchPoint = Offset(100, 100); + const dragPoint1 = Offset(150, 150); + const dragPoint2 = Offset(200, 200); + + sut.onDown(touchPoint, paint); + sut.onDrag(dragPoint1, paint); + sut.onDrag(dragPoint2, paint); + + final expectedCursorPos = initialCursorPos + (dragPoint2 - touchPoint); + expect(sut.lastPoint, expectedCursorPos); + }); + }); + + group('State reset', () { + test('Should reset tracking state after completing gesture', () { + sut.onDown(pointA, paint); + sut.onDrag(pointB, paint); + sut.onUp(pointB, paint); + expect(sut.isActive, false); + }); + }); +} diff --git a/test/unit/tools/text_tool_test.mocks.dart b/test/unit/tools/text_tool_test.mocks.dart index aec80aba..369c1036 100644 --- a/test/unit/tools/text_tool_test.mocks.dart +++ b/test/unit/tools/text_tool_test.mocks.dart @@ -441,8 +441,9 @@ class MockCommandFactory extends _i1.Mock implements _i19.CommandFactory { @override _i4.PathCommand createPathCommand( _i15.PathWithActionHistory? path, - _i13.Paint? paint, - ) => + _i13.Paint? paint, { + bool? isCursor = false, + }) => (super.noSuchMethod( Invocation.method( #createPathCommand, @@ -450,6 +451,7 @@ class MockCommandFactory extends _i1.Mock implements _i19.CommandFactory { path, paint, ], + {#isCursor: isCursor}, ), returnValue: _FakePathCommand_2( this, @@ -459,6 +461,7 @@ class MockCommandFactory extends _i1.Mock implements _i19.CommandFactory { path, paint, ], + {#isCursor: isCursor}, ), ), ) as _i4.PathCommand);