diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b22394e1..7185ab75 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -42,14 +42,14 @@ PODS: - Flutter - integration_test (0.0.1): - Flutter - - launch_review (0.0.1): + - launch_review_latest (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 +57,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 +71,12 @@ 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`) + - launch_review_latest (from `.symlinks/plugins/launch_review_latest/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 +99,8 @@ 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" + launch_review_latest: + :path: ".symlinks/plugins/launch_review_latest/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: @@ -109,8 +109,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 +121,15 @@ 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 + launch_review_latest: d405bc299b841153fc24f566d145b67a49c5245b + 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_painter.dart b/lib/core/commands/command_painter.dart index 02ffc50c..ebc79e26 100644 --- a/lib/core/commands/command_painter.dart +++ b/lib/core/commands/command_painter.dart @@ -5,6 +5,7 @@ import 'package:paintroid/core/commands/command_manager/command_manager_provider import 'package:paintroid/core/enums/tool_types.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/shapes_tool.dart'; import 'package:paintroid/core/tools/line_tool/line_tool.dart'; import 'package:paintroid/core/tools/tool.dart'; @@ -23,6 +24,7 @@ class CommandPainter extends CustomPainter { if (currentTool.type != ToolType.SHAPES) { canvas.clipRect(Rect.fromLTWH(0, 0, size.width, size.height)); } + switch (currentTool.type) { case ToolType.LINE: _drawGhostPathsAndVertices(canvas, currentTool as LineTool); @@ -32,6 +34,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; default: commandManager.executeLastCommand(canvas); 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 f46fd575..30a6c09a 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'; @@ -77,6 +78,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 96cc83ce..a33252ab 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'23e3ddde3194c0acc46abe79fe6d0b0b4bf77ec6'; + r'4c6d05e9bbdf692e3f872050342e842d04ad2f9e'; /// See also [ToolBoxStateProvider]. @ProviderFor(ToolBoxStateProvider) diff --git a/lib/core/tools/implementation/cursor_tool.dart b/lib/core/tools/implementation/cursor_tool.dart new file mode 100644 index 00000000..2466cb70 --- /dev/null +++ b/lib/core/tools/implementation/cursor_tool.dart @@ -0,0 +1,161 @@ +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, + }) { + lastPoint = canvasCenter; + } + + void toggleActive() => isActive = !isActive; + + void setCursorPosition(Offset position) => lastPoint = position; + + @override + void onDown(Offset point, Paint paint) { + _initializeTouch(point); + + if (isActive) { + super.onDown(lastPoint, paint); + _isCurrentlyDrawing = true; + } + } + + @override + void onDrag(Offset point, Paint paint) { + _updateDragState(point); + _updateCursorPosition(point); + + 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..079e27fe 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'; @@ -11,7 +12,7 @@ abstract class Tool { final bool hasAddFunctionality; final bool hasFinalizeFunctionality; - const Tool({ + Tool({ required this.commandManager, required this.commandFactory, required this.type, diff --git a/lib/ui/pages/workspace_page/components/drawing_surface/canvas_painter.dart b/lib/ui/pages/workspace_page/components/drawing_surface/canvas_painter.dart index af7206b1..0d6478cc 100644 --- a/lib/ui/pages/workspace_page/components/drawing_surface/canvas_painter.dart +++ b/lib/ui/pages/workspace_page/components/drawing_surface/canvas_painter.dart @@ -21,7 +21,7 @@ class CanvasPainter extends ConsumerWidget { foregroundDecoration: const BoxDecoration( border: Border.fromBorderSide(BorderSide(width: 0.5)), ), - child: const Stack( + child: Stack( fit: StackFit.expand, children: [ BackgroundLayer(), diff --git a/test/integration/cursor_tool_test.dart b/test/integration/cursor_tool_test.dart new file mode 100644 index 00000000..6c2d610f --- /dev/null +++ b/test/integration/cursor_tool_test.dart @@ -0,0 +1,335 @@ +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.tapAt(CanvasPosition.center); + + await UIInteraction.dragFromTo( + CanvasPosition.topLeft, + CanvasPosition.bottomRight, + ); + + 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.tapAt(CanvasPosition.center); + + await UIInteraction.dragFromTo( + CanvasPosition.topLeft, + CanvasPosition.bottomRight, + ); + + 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.tapAt(CanvasPosition.center); + + await UIInteraction.dragFromTo( + CanvasPosition.topLeft, + CanvasPosition.centerLeft, + ); + + 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, + ); + + 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, + ); + + await UIInteraction.dragFromTo( + CanvasPosition.topLeft, + CanvasPosition.bottomRight, + ); + + 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.tapAt(CanvasPosition.center); + + await UIInteraction.dragFromTo( + CanvasPosition.topLeft, + CanvasPosition.bottomRight, + ); + + 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.tapAt(CanvasPosition.center); + + await UIInteraction.dragFromTo( + CanvasPosition.topLeft, + CanvasPosition.bottomRight, + ); + + await UIInteraction.tapAt(CanvasPosition.center); + + await UIInteraction.dragFromTo( + CanvasPosition.bottomRight, + CanvasPosition.topLeft, + ); + + var color = await UIInteraction.getPixelColor( + CanvasPosition.centerX, + CanvasPosition.centerY, + ); + expect(color.toValue(), Colors.black.toValue()); + + UIInteraction.setColor(Colors.red); + + await UIInteraction.tapAt(CanvasPosition.center); + + await UIInteraction.dragFromTo( + CanvasPosition.topRight, + CanvasPosition.bottomLeft, + ); + + 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..2c875288 --- /dev/null +++ b/test/unit/tools/cursor_tool_test.dart @@ -0,0 +1,215 @@ +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 down', () { + expect(sut.commandManager.undoStack.isEmpty, true); + sut.onDown(pointA, paint); + expect(sut.commandManager.undoStack.first is PathCommand, true); + }); + + test('Should add MoveToAction when active on down', () { + expect(sut.commandManager.undoStack.isEmpty, true); + sut.onDown(pointA, 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); + }); + }); +}