From b9f385d6174d681f1cbb412c067ee1fb29ed7eee Mon Sep 17 00:00:00 2001 From: DevAnuragT Date: Tue, 3 Mar 2026 03:22:50 +0530 Subject: [PATCH 1/4] feat: add fill tool implementation and command wiring --- .../command_factory/command_factory.dart | 10 ++ .../command_implementation/command.dart | 3 + .../graphic/fill_command.dart | 68 +++++++ .../command_manager/command_manager.dart | 3 + .../versioning/serializer_version.dart | 2 + .../versioning/version_strategy.dart | 5 + .../object/tools/fill_tool_provider.dart | 52 ++++++ .../state/toolbox_state_provider.dart | 4 + lib/core/tools/implementation/fill_tool.dart | 167 ++++++++++++++++++ test/unit/command/command_factory_test.dart | 9 + .../utils/dummy_version_strategy.dart | 5 + test/unit/tools/fill_tool_test.dart | 99 +++++++++++ 12 files changed, 427 insertions(+) create mode 100644 lib/core/commands/command_implementation/graphic/fill_command.dart create mode 100644 lib/core/providers/object/tools/fill_tool_provider.dart create mode 100644 lib/core/tools/implementation/fill_tool.dart create mode 100644 test/unit/tools/fill_tool_test.dart diff --git a/lib/core/commands/command_factory/command_factory.dart b/lib/core/commands/command_factory/command_factory.dart index 202d9010..01a9bed5 100644 --- a/lib/core/commands/command_factory/command_factory.dart +++ b/lib/core/commands/command_factory/command_factory.dart @@ -4,6 +4,7 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/clipboard_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/delete_region_command.dart'; +import 'package:paintroid/core/commands/command_implementation/graphic/fill_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/text_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/line_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/path_command.dart'; @@ -136,4 +137,13 @@ class CommandFactory { Paint(), region, ); + + FillCommand createFillCommand( + Paint paint, + Uint8List imageData, + ) => + FillCommand( + paint, + imageData, + ); } diff --git a/lib/core/commands/command_implementation/command.dart b/lib/core/commands/command_implementation/command.dart index 3265bfb8..613ba633 100644 --- a/lib/core/commands/command_implementation/command.dart +++ b/lib/core/commands/command_implementation/command.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/clipboard_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/delete_region_command.dart'; +import 'package:paintroid/core/commands/command_implementation/graphic/fill_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'; @@ -34,6 +35,8 @@ abstract class Command with EquatableMixin { return DeleteRegionCommand.fromJson(json); case SerializerType.TEXT_COMMAND: return TextCommand.fromJson(json); + case SerializerType.FILL_COMMAND: + return FillCommand.fromJson(json); case SerializerType.HEART_SHAPE_COMMAND: return HeartShapeCommand.fromJson(json); case SerializerType.STAR_SHAPE_COMMAND: diff --git a/lib/core/commands/command_implementation/graphic/fill_command.dart b/lib/core/commands/command_implementation/graphic/fill_command.dart new file mode 100644 index 00000000..10a9182a --- /dev/null +++ b/lib/core/commands/command_implementation/graphic/fill_command.dart @@ -0,0 +1,68 @@ +// ignore_for_file: must_be_immutable + +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:paintroid/core/commands/command_implementation/graphic/graphic_command.dart'; +import 'package:paintroid/core/json_serialization/converter/paint_converter.dart'; +import 'package:paintroid/core/json_serialization/converter/uint8list_base64_converter.dart'; +import 'package:paintroid/core/json_serialization/versioning/serializer_version.dart'; +import 'package:paintroid/core/json_serialization/versioning/version_strategy.dart'; + +class FillCommand extends GraphicCommand { + final Uint8List imageData; + final int version; + final String type; + + ui.Image? _runtimeImage; + + FillCommand( + super.paint, + this.imageData, { + int? version, + this.type = SerializerType.FILL_COMMAND, + }) : version = + version ?? VersionStrategyManager.strategy.getFillCommandVersion(); + + @override + Future prepareForRuntime() async { + if (_runtimeImage != null || imageData.isEmpty) { + return; + } + final buffer = await ui.ImmutableBuffer.fromUint8List(imageData); + final descriptor = await ui.ImageDescriptor.encoded(buffer); + final codec = await descriptor.instantiateCodec(); + final frameInfo = await codec.getNextFrame(); + _runtimeImage = frameInfo.image; + } + + @override + void call(ui.Canvas canvas) { + if (_runtimeImage == null) { + return; + } + canvas.drawImage(_runtimeImage!, ui.Offset.zero, ui.Paint()); + } + + @override + Map toJson() { + return { + 'paint': const PaintConverter().toJson(paint), + 'imageData': const Uint8ListBase64Converter().toJson(imageData), + 'version': version, + 'type': type, + }; + } + + factory FillCommand.fromJson(Map json) { + return FillCommand( + const PaintConverter().fromJson(json['paint'] as Map), + const Uint8ListBase64Converter().fromJson(json['imageData'] as String), + version: json['version'] as int? ?? Version.v1, + type: json['type'] as String? ?? SerializerType.FILL_COMMAND, + ); + } + + @override + List get props => [paint, imageData, version, type]; +} diff --git a/lib/core/commands/command_manager/command_manager.dart b/lib/core/commands/command_manager/command_manager.dart index ffa1ce97..657d22bf 100644 --- a/lib/core/commands/command_manager/command_manager.dart +++ b/lib/core/commands/command_manager/command_manager.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:paintroid/core/commands/command_implementation/command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/clipboard_command.dart'; +import 'package:paintroid/core/commands/command_implementation/graphic/fill_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'; @@ -123,6 +124,8 @@ class CommandManager { return ToolData.TEXT; } else if (command.runtimeType == SprayCommand) { return ToolData.SPRAY; + } else if (command.runtimeType == FillCommand) { + return ToolData.FILL; } else if (command.runtimeType == StarShapeCommand) { return ToolData.SHAPES; } else if (command.runtimeType == HeartShapeCommand) { diff --git a/lib/core/json_serialization/versioning/serializer_version.dart b/lib/core/json_serialization/versioning/serializer_version.dart index de4c3048..fc27fcc8 100644 --- a/lib/core/json_serialization/versioning/serializer_version.dart +++ b/lib/core/json_serialization/versioning/serializer_version.dart @@ -11,6 +11,7 @@ class SerializerVersion { static const int SPRAY_COMMAND_VERSION = Version.v1; static const int CLIPBOARD_COMMAND_VERSION = Version.v1; static const int DELETE_REGION_COMMAND_VERSION = Version.v1; + static const int FILL_COMMAND_VERSION = Version.v1; } class Version { @@ -33,4 +34,5 @@ class SerializerType { static const String SPRAY_COMMAND = 'SprayCommand'; static const String CLIPBOARD_COMMAND = 'ClipboardCommand'; static const String DELETE_REGION_COMMAND = 'DeleteRegionCommand'; + static const String FILL_COMMAND = 'FillCommand'; } diff --git a/lib/core/json_serialization/versioning/version_strategy.dart b/lib/core/json_serialization/versioning/version_strategy.dart index 2c3d843f..6ffbd606 100644 --- a/lib/core/json_serialization/versioning/version_strategy.dart +++ b/lib/core/json_serialization/versioning/version_strategy.dart @@ -22,6 +22,8 @@ abstract class IVersionStrategy { int getClipboardCommandVersion(); int getDeleteRegionCommandVersion(); + + int getFillCommandVersion(); } class ProductionVersionStrategy implements IVersionStrategy { @@ -63,6 +65,9 @@ class ProductionVersionStrategy implements IVersionStrategy { @override int getDeleteRegionCommandVersion() => SerializerVersion.DELETE_REGION_COMMAND_VERSION; + + @override + int getFillCommandVersion() => SerializerVersion.FILL_COMMAND_VERSION; } class VersionStrategyManager { diff --git a/lib/core/providers/object/tools/fill_tool_provider.dart b/lib/core/providers/object/tools/fill_tool_provider.dart new file mode 100644 index 00000000..f0d2468b --- /dev/null +++ b/lib/core/providers/object/tools/fill_tool_provider.dart @@ -0,0 +1,52 @@ +import 'dart:ui' as ui; + +import 'package:flutter_riverpod/flutter_riverpod.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'; +import 'package:paintroid/core/providers/object/canvas_painter_provider.dart'; +import 'package:paintroid/core/providers/state/canvas_state_provider.dart'; +import 'package:paintroid/core/tools/implementation/fill_tool.dart'; + +final fillToolProvider = Provider((ref) { + Future sourceImageProvider() async { + final canvasState = ref.read(canvasStateProvider); + final size = canvasState.size; + if (size.width <= 0 || size.height <= 0) { + return null; + } + + final recorder = ref.read(graphicFactoryProvider).createPictureRecorder(); + final canvas = + ref.read(graphicFactoryProvider).createCanvasWithRecorder(recorder); + final bounds = ui.Rect.fromLTWH(0, 0, size.width, size.height); + canvas.clipRect(bounds); + + if (canvasState.backgroundImage != null) { + canvas.drawImage( + canvasState.backgroundImage!, ui.Offset.zero, ui.Paint()); + } + if (canvasState.cachedImage != null) { + canvas.drawImage(canvasState.cachedImage!, ui.Offset.zero, ui.Paint()); + } + + final picture = recorder.endRecording(); + return picture.toImage(size.width.toInt(), size.height.toInt()); + } + + Future onFillApplied() async { + await ref.read(canvasStateProvider.notifier).updateCachedImage(); + ref.read(canvasPainterProvider.notifier).repaint(); + } + + return FillTool( + commandManager: ref.watch(commandManagerProvider), + commandFactory: ref.watch(commandFactoryProvider), + graphicFactory: ref.watch(graphicFactoryProvider), + type: ToolType.FILL, + getSourceImage: sourceImageProvider, + onFillApplied: onFillApplied, + ); +}); diff --git a/lib/core/providers/state/toolbox_state_provider.dart b/lib/core/providers/state/toolbox_state_provider.dart index e051305a..9b40fb98 100644 --- a/lib/core/providers/state/toolbox_state_provider.dart +++ b/lib/core/providers/state/toolbox_state_provider.dart @@ -6,6 +6,7 @@ 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/clipboard_tool_provider.dart'; import 'package:paintroid/core/providers/object/tools/eraser_tool_provider.dart'; +import 'package:paintroid/core/providers/object/tools/fill_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'; @@ -77,6 +78,9 @@ class ToolBoxStateProvider extends _$ToolBoxStateProvider { state = state.copyWith(currentTool: ref.read(shapesToolProvider)); ref.read(canvasPainterProvider.notifier).repaint(); break; + case ToolType.FILL: + state = state.copyWith(currentTool: ref.read(fillToolProvider)); + break; case ToolType.TEXT: state = state.copyWith(currentTool: ref.read(textToolProvider)); break; diff --git a/lib/core/tools/implementation/fill_tool.dart b/lib/core/tools/implementation/fill_tool.dart new file mode 100644 index 00000000..9b566911 --- /dev/null +++ b/lib/core/tools/implementation/fill_tool.dart @@ -0,0 +1,167 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:paintroid/core/commands/graphic_factory/graphic_factory.dart'; +import 'package:paintroid/core/tools/tool.dart'; + +class FillTool extends Tool { + final GraphicFactory graphicFactory; + final Future Function() getSourceImage; + final Future Function() onFillApplied; + bool _isFilling = false; + + FillTool({ + required super.commandFactory, + required super.commandManager, + required super.type, + required this.graphicFactory, + required this.getSourceImage, + required this.onFillApplied, + super.hasAddFunctionality = false, + super.hasFinalizeFunctionality = false, + }); + + @override + void onDown(ui.Offset point, ui.Paint paint) { + if (_isFilling) { + return; + } + _isFilling = true; + unawaited(_fill(point, paint)); + } + + Future _fill(ui.Offset point, ui.Paint paint) async { + try { + final sourceImage = await getSourceImage(); + if (sourceImage == null) { + return; + } + + final width = sourceImage.width; + final height = sourceImage.height; + if (width <= 0 || height <= 0) { + return; + } + + final startX = point.dx.floor(); + final startY = point.dy.floor(); + if (startX < 0 || startX >= width || startY < 0 || startY >= height) { + return; + } + + final sourceData = + await sourceImage.toByteData(format: ui.ImageByteFormat.rawRgba); + if (sourceData == null) { + return; + } + final pixels = sourceData.buffer.asUint8List(); + + final startIndex = (startY * width + startX) * 4; + final sourceR = pixels[startIndex]; + final sourceG = pixels[startIndex + 1]; + final sourceB = pixels[startIndex + 2]; + final sourceA = pixels[startIndex + 3]; + + final targetColor = paint.color; + final targetR = (targetColor.r * 255).round(); + final targetG = (targetColor.g * 255).round(); + final targetB = (targetColor.b * 255).round(); + final targetA = (targetColor.a * 255).round(); + + if (sourceR == targetR && + sourceG == targetG && + sourceB == targetB && + sourceA == targetA) { + return; + } + + final queue = Queue()..add(startY * width + startX); + while (queue.isNotEmpty) { + final pixelPos = queue.removeFirst(); + final x = pixelPos % width; + final y = pixelPos ~/ width; + final index = pixelPos * 4; + + if (pixels[index] != sourceR || + pixels[index + 1] != sourceG || + pixels[index + 2] != sourceB || + pixels[index + 3] != sourceA) { + continue; + } + + pixels[index] = targetR; + pixels[index + 1] = targetG; + pixels[index + 2] = targetB; + pixels[index + 3] = targetA; + + if (x > 0) queue.add(pixelPos - 1); + if (x < width - 1) queue.add(pixelPos + 1); + if (y > 0) queue.add(pixelPos - width); + if (y < height - 1) queue.add(pixelPos + width); + } + + final filledImage = await _decodeFromRgba( + Uint8List.fromList(pixels), + width, + height, + ); + final pngData = + await filledImage.toByteData(format: ui.ImageByteFormat.png); + if (pngData == null) { + return; + } + + final savedPaint = graphicFactory.copyPaint(paint); + final command = commandFactory.createFillCommand( + savedPaint, + pngData.buffer.asUint8List(), + ); + await command.prepareForRuntime(); + commandManager.addGraphicCommand(command); + await onFillApplied(); + } finally { + _isFilling = false; + } + } + + Future _decodeFromRgba(Uint8List rgba, int width, int height) { + final completer = Completer(); + ui.decodeImageFromPixels( + rgba, + width, + height, + ui.PixelFormat.rgba8888, + completer.complete, + ); + return completer.future; + } + + @override + void onDrag(ui.Offset point, ui.Paint paint) {} + + @override + void onUp(ui.Offset point, ui.Paint paint) {} + + @override + void onCancel() { + _isFilling = false; + } + + @override + void onCheckmark(ui.Paint paint) {} + + @override + void onPlus() {} + + @override + void onUndo() { + commandManager.undo(); + } + + @override + void onRedo() { + commandManager.redo(); + } +} diff --git a/test/unit/command/command_factory_test.dart b/test/unit/command/command_factory_test.dart index 95f952dd..429a104d 100644 --- a/test/unit/command/command_factory_test.dart +++ b/test/unit/command/command_factory_test.dart @@ -1,8 +1,10 @@ +import 'dart:typed_data'; 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/fill_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/path_command.dart'; import 'package:paintroid/core/commands/path_with_action_history.dart'; @@ -23,4 +25,11 @@ void main() { expect(command, isA()); expect(command, equals(expected)); }); + + test('Should return a valid instance of FillCommand', () { + final imageData = Uint8List.fromList([137, 80, 78, 71]); + final command = sut.createFillCommand(testPaint, imageData); + expect(command, isA()); + expect(command.imageData, equals(imageData)); + }); } diff --git a/test/unit/serialization/utils/dummy_version_strategy.dart b/test/unit/serialization/utils/dummy_version_strategy.dart index e1a78232..43f0f2d4 100644 --- a/test/unit/serialization/utils/dummy_version_strategy.dart +++ b/test/unit/serialization/utils/dummy_version_strategy.dart @@ -13,6 +13,7 @@ class DummyVersionStrategy implements IVersionStrategy { final int clipboardCommandVersion; final int deleteRegionCommandVersion; final int textCommandVersion; + final int fillCommandVersion; DummyVersionStrategy({ this.pathCommandVersion = SerializerVersion.PATH_COMMAND_VERSION, @@ -30,6 +31,7 @@ class DummyVersionStrategy implements IVersionStrategy { this.deleteRegionCommandVersion = SerializerVersion.DELETE_REGION_COMMAND_VERSION, this.textCommandVersion = SerializerVersion.TEXT_COMMAND_VERSION, + this.fillCommandVersion = SerializerVersion.FILL_COMMAND_VERSION, }); @override @@ -64,4 +66,7 @@ class DummyVersionStrategy implements IVersionStrategy { @override int getTextCommandVersion() => textCommandVersion; + + @override + int getFillCommandVersion() => fillCommandVersion; } diff --git a/test/unit/tools/fill_tool_test.dart b/test/unit/tools/fill_tool_test.dart new file mode 100644 index 00000000..0b53fa9b --- /dev/null +++ b/test/unit/tools/fill_tool_test.dart @@ -0,0 +1,99 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui' as 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/fill_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/fill_tool.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late CommandManager commandManager; + late FillTool sut; + late ui.Paint paint; + + Future imageFromRgba(Uint8List rgba, int width, int height) { + final completer = Completer(); + ui.decodeImageFromPixels( + rgba, + width, + height, + ui.PixelFormat.rgba8888, + completer.complete, + ); + return completer.future; + } + + setUp(() { + commandManager = CommandManager(); + paint = ui.Paint()..color = const ui.Color(0xFF000000); + }); + + test('Does not add command when source image is null', () async { + sut = FillTool( + commandFactory: const CommandFactory(), + commandManager: commandManager, + graphicFactory: const GraphicFactory(), + type: ToolType.FILL, + getSourceImage: () async => null, + onFillApplied: () async {}, + ); + + sut.onDown(const ui.Offset(0, 0), paint); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(commandManager.undoStack, isEmpty); + }); + + test('Adds FillCommand when fill is applied', () async { + final source = await imageFromRgba( + Uint8List.fromList([ + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + ]), + 2, + 2, + ); + + final appliedCompleter = Completer(); + + sut = FillTool( + commandFactory: const CommandFactory(), + commandManager: commandManager, + graphicFactory: const GraphicFactory(), + type: ToolType.FILL, + getSourceImage: () async => source, + onFillApplied: () async { + if (!appliedCompleter.isCompleted) { + appliedCompleter.complete(); + } + }, + ); + + sut.onDown(const ui.Offset(0, 0), paint); + await appliedCompleter.future; + + expect(commandManager.undoStack.length, 1); + expect(commandManager.undoStack.first, isA()); + }); +} From 9d057a5deb4721b958e0c9d181ad97a57b5b754c Mon Sep 17 00:00:00 2001 From: DevAnuragT Date: Thu, 5 Mar 2026 17:59:50 +0530 Subject: [PATCH 2/4] test: add nested closed-region fill regression --- test/unit/tools/fill_tool_test.dart | 88 +++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/test/unit/tools/fill_tool_test.dart b/test/unit/tools/fill_tool_test.dart index 0b53fa9b..40997d54 100644 --- a/test/unit/tools/fill_tool_test.dart +++ b/test/unit/tools/fill_tool_test.dart @@ -30,6 +30,16 @@ void main() { return completer.future; } + Future imageFromPng(Uint8List pngBytes) async { + final buffer = await ui.ImmutableBuffer.fromUint8List(pngBytes); + final descriptor = await ui.ImageDescriptor.encoded(buffer); + final codec = await descriptor.instantiateCodec(); + final frameInfo = await codec.getNextFrame(); + return frameInfo.image; + } + + int rgbaIndex(int x, int y, int width) => (y * width + x) * 4; + setUp(() { commandManager = CommandManager(); paint = ui.Paint()..color = const ui.Color(0xFF000000); @@ -96,4 +106,82 @@ void main() { expect(commandManager.undoStack.length, 1); expect(commandManager.undoStack.first, isA()); }); + + test('Filling outer region does not fill inner enclosed region', () async { + const width = 9; + const height = 9; + final rgba = Uint8List(width * height * 4); + + void setPixel(int x, int y, int r, int g, int b, int a) { + final index = rgbaIndex(x, y, width); + rgba[index] = r; + rgba[index + 1] = g; + rgba[index + 2] = b; + rgba[index + 3] = a; + } + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + setPixel(x, y, 255, 255, 255, 255); + } + } + + for (int x = 1; x <= 7; x++) { + setPixel(x, 1, 0, 0, 0, 255); + setPixel(x, 7, 0, 0, 0, 255); + } + for (int y = 1; y <= 7; y++) { + setPixel(1, y, 0, 0, 0, 255); + setPixel(7, y, 0, 0, 0, 255); + } + + for (int x = 3; x <= 5; x++) { + setPixel(x, 3, 0, 0, 0, 255); + setPixel(x, 5, 0, 0, 0, 255); + } + for (int y = 3; y <= 5; y++) { + setPixel(3, y, 0, 0, 0, 255); + setPixel(5, y, 0, 0, 0, 255); + } + + final source = await imageFromRgba(rgba, width, height); + final appliedCompleter = Completer(); + + sut = FillTool( + commandFactory: const CommandFactory(), + commandManager: commandManager, + graphicFactory: const GraphicFactory(), + type: ToolType.FILL, + getSourceImage: () async => source, + onFillApplied: () async { + if (!appliedCompleter.isCompleted) { + appliedCompleter.complete(); + } + }, + ); + + sut.onDown(const ui.Offset(2, 2), paint); + await appliedCompleter.future; + + expect(commandManager.undoStack.length, 1); + final fillCommand = commandManager.undoStack.first as FillCommand; + final outputImage = await imageFromPng(fillCommand.imageData); + final outputData = + await outputImage.toByteData(format: ui.ImageByteFormat.rawRgba); + expect(outputData, isNotNull); + + final outputPixels = outputData!.buffer.asUint8List(); + + final innerIndex = rgbaIndex(4, 4, width); + expect(outputPixels[innerIndex], 255); + expect(outputPixels[innerIndex + 1], 255); + expect(outputPixels[innerIndex + 2], 255); + expect(outputPixels[innerIndex + 3], 255); + + final annulusIndex = rgbaIndex(2, 2, width); + expect(outputPixels[annulusIndex], 0); + expect(outputPixels[annulusIndex + 1], 0); + expect(outputPixels[annulusIndex + 2], 0); + expect(outputPixels[annulusIndex + 3], 255); + }); } From 3c8f542017d05e0a02d628a4fe8d67b7fe92867a Mon Sep 17 00:00:00 2001 From: DevAnuragT Date: Mon, 23 Mar 2026 13:13:20 +0530 Subject: [PATCH 3/4] test(serialization): add shape command v2 deserialization coverage --- .../ellipse_shape_serializer_test.dart | 38 +++++++++++++++++ .../command/heart_shape_serializer_test.dart | 39 +++++++++++++++++ .../rectangle_shape_serializer_test.dart | 40 ++++++++++++++++++ .../command/star_shape_serializer_test.dart | 42 +++++++++++++++++++ 4 files changed, 159 insertions(+) diff --git a/test/unit/serialization/command/ellipse_shape_serializer_test.dart b/test/unit/serialization/command/ellipse_shape_serializer_test.dart index 171a6d94..5c58bd7d 100644 --- a/test/unit/serialization/command/ellipse_shape_serializer_test.dart +++ b/test/unit/serialization/command/ellipse_shape_serializer_test.dart @@ -42,4 +42,42 @@ void main() { expect(deserializedCommand.type, equals(type)); }); }); + + group('Version 2', () { + test('Test Ellipse deserialization for version 2', () { + const type = SerializerType.ELLIPSE_SHAPE_COMMAND; + final originalPaint = DummyPaintFactory.createPaint(version: Version.v1); + const center = Offset(100, 100); + const radius = 50.0; + const angle = 0.0; + final style = ShapeStyle.outline; + + final command = DummyCommandFactory.createEllipseShapeCommand( + originalPaint, + radius, + radius, + center, + style, + angle, + ); + + final json = command.toJson(); + json['version'] = Version.v2; + final deserializedCommand = EllipseShapeCommand.fromJson(json); + + expect( + DummyPaintFactory.comparePaint( + originalPaint, + deserializedCommand.paint, + version: Version.v1, + ), + isTrue); + expect(deserializedCommand.version, equals(Version.v2)); + expect(deserializedCommand.center, equals(center)); + expect(deserializedCommand.radiusX, equals(radius)); + expect(deserializedCommand.radiusY, equals(radius)); + expect(deserializedCommand.type, equals(type)); + expect(deserializedCommand.angle, equals(angle)); + }); + }); } diff --git a/test/unit/serialization/command/heart_shape_serializer_test.dart b/test/unit/serialization/command/heart_shape_serializer_test.dart index cc5ca7ef..d418c3fb 100644 --- a/test/unit/serialization/command/heart_shape_serializer_test.dart +++ b/test/unit/serialization/command/heart_shape_serializer_test.dart @@ -43,4 +43,43 @@ void main() { expect(deserializedCommand.style, equals(ShapeStyle.outline)); }); }); + + group('Version 2', () { + test('Test Heart deserialization for version 2', () { + const type = SerializerType.HEART_SHAPE_COMMAND; + final originalPaint = DummyPaintFactory.createPaint(version: Version.v1); + const center = Offset(100, 100); + const width = 50.0; + const height = 50.0; + const angle = 0.0; + + final command = DummyCommandFactory.createHeartShapeCommand( + originalPaint, + width, + height, + angle, + center, + ShapeStyle.outline, + ); + + final json = command.toJson(); + json['version'] = Version.v2; + final deserializedCommand = HeartShapeCommand.fromJson(json); + + expect( + DummyPaintFactory.comparePaint( + originalPaint, + deserializedCommand.paint, + version: Version.v1, + ), + isTrue); + expect(deserializedCommand.version, equals(Version.v2)); + expect(deserializedCommand.center, equals(center)); + expect(deserializedCommand.type, equals(type)); + expect(deserializedCommand.angle, equals(angle)); + expect(deserializedCommand.width, equals(width)); + expect(deserializedCommand.height, equals(height)); + expect(deserializedCommand.style, equals(ShapeStyle.outline)); + }); + }); } diff --git a/test/unit/serialization/command/rectangle_shape_serializer_test.dart b/test/unit/serialization/command/rectangle_shape_serializer_test.dart index bc6d01a2..58cf6231 100644 --- a/test/unit/serialization/command/rectangle_shape_serializer_test.dart +++ b/test/unit/serialization/command/rectangle_shape_serializer_test.dart @@ -44,4 +44,44 @@ void main() { expect(deserializedCommand.type, equals(type)); }); }); + + group('Version 2', () { + test('Test SquareShapeCommand deserialization for version 2', () { + const type = SerializerType.SQUARE_SHAPE_COMMAND; + + final originalPaint = DummyPaintFactory.createPaint(version: Version.v1); + const originalTopLeft = Offset(0, 0); + const originalTopRight = Offset(1, 0); + const originalBottomLeft = Offset(0, 1); + const originalBottomRight = Offset(1, 1); + + final command = DummyCommandFactory.createSquareShapeCommand( + originalPaint, + originalTopLeft, + originalTopRight, + originalBottomLeft, + originalBottomRight, + ShapeStyle.outline, + version: Version.v1, + ); + + final json = command.toJson(); + json['version'] = Version.v2; + final deserializedCommand = SquareShapeCommand.fromJson(json); + + expect( + DummyPaintFactory.comparePaint( + originalPaint, + deserializedCommand.paint, + version: Version.v1, + ), + isTrue); + expect(deserializedCommand.version, equals(Version.v2)); + expect(deserializedCommand.topLeft, equals(originalTopLeft)); + expect(deserializedCommand.topRight, equals(originalTopRight)); + expect(deserializedCommand.bottomLeft, equals(originalBottomLeft)); + expect(deserializedCommand.bottomRight, equals(originalBottomRight)); + expect(deserializedCommand.type, equals(type)); + }); + }); } diff --git a/test/unit/serialization/command/star_shape_serializer_test.dart b/test/unit/serialization/command/star_shape_serializer_test.dart index 0295e240..9588e594 100644 --- a/test/unit/serialization/command/star_shape_serializer_test.dart +++ b/test/unit/serialization/command/star_shape_serializer_test.dart @@ -45,4 +45,46 @@ void main() { expect(deserializedCommand.angle, equals(angle)); }); }); + + group('Version 2', () { + test('Test Star deserialization for version 2', () { + const type = SerializerType.STAR_SHAPE_COMMAND; + final originalPaint = DummyPaintFactory.createPaint(version: Version.v1); + const center = Offset(100, 100); + const radius = 50.0; + const numberOfPoints = 5; + const angle = 0.0; + + final style = ShapeStyle.outline; + + final command = DummyCommandFactory.createStarShapeCommand( + originalPaint, + numberOfPoints, + angle, + center, + style, + radius, + radius, + ); + + final json = command.toJson(); + json['version'] = Version.v2; + final deserializedCommand = StarShapeCommand.fromJson(json); + + expect( + DummyPaintFactory.comparePaint( + originalPaint, + deserializedCommand.paint, + version: Version.v1, + ), + isTrue); + expect(deserializedCommand.version, equals(Version.v2)); + expect(deserializedCommand.center, equals(center)); + expect(deserializedCommand.radiusX, equals(radius)); + expect(deserializedCommand.radiusY, equals(radius)); + expect(deserializedCommand.type, equals(type)); + expect(deserializedCommand.numberOfPoints, equals(numberOfPoints)); + expect(deserializedCommand.angle, equals(angle)); + }); + }); } \ No newline at end of file From 52d9727e58dafc3cc1d8759cc5409d76968dfd14 Mon Sep 17 00:00:00 2001 From: DevAnuragT Date: Mon, 23 Mar 2026 13:26:35 +0530 Subject: [PATCH 4/4] Revert "test(serialization): add shape command v2 deserialization coverage" This reverts commit 3c8f542017d05e0a02d628a4fe8d67b7fe92867a. --- .../ellipse_shape_serializer_test.dart | 38 ----------------- .../command/heart_shape_serializer_test.dart | 39 ----------------- .../rectangle_shape_serializer_test.dart | 40 ------------------ .../command/star_shape_serializer_test.dart | 42 ------------------- 4 files changed, 159 deletions(-) diff --git a/test/unit/serialization/command/ellipse_shape_serializer_test.dart b/test/unit/serialization/command/ellipse_shape_serializer_test.dart index 5c58bd7d..171a6d94 100644 --- a/test/unit/serialization/command/ellipse_shape_serializer_test.dart +++ b/test/unit/serialization/command/ellipse_shape_serializer_test.dart @@ -42,42 +42,4 @@ void main() { expect(deserializedCommand.type, equals(type)); }); }); - - group('Version 2', () { - test('Test Ellipse deserialization for version 2', () { - const type = SerializerType.ELLIPSE_SHAPE_COMMAND; - final originalPaint = DummyPaintFactory.createPaint(version: Version.v1); - const center = Offset(100, 100); - const radius = 50.0; - const angle = 0.0; - final style = ShapeStyle.outline; - - final command = DummyCommandFactory.createEllipseShapeCommand( - originalPaint, - radius, - radius, - center, - style, - angle, - ); - - final json = command.toJson(); - json['version'] = Version.v2; - final deserializedCommand = EllipseShapeCommand.fromJson(json); - - expect( - DummyPaintFactory.comparePaint( - originalPaint, - deserializedCommand.paint, - version: Version.v1, - ), - isTrue); - expect(deserializedCommand.version, equals(Version.v2)); - expect(deserializedCommand.center, equals(center)); - expect(deserializedCommand.radiusX, equals(radius)); - expect(deserializedCommand.radiusY, equals(radius)); - expect(deserializedCommand.type, equals(type)); - expect(deserializedCommand.angle, equals(angle)); - }); - }); } diff --git a/test/unit/serialization/command/heart_shape_serializer_test.dart b/test/unit/serialization/command/heart_shape_serializer_test.dart index d418c3fb..cc5ca7ef 100644 --- a/test/unit/serialization/command/heart_shape_serializer_test.dart +++ b/test/unit/serialization/command/heart_shape_serializer_test.dart @@ -43,43 +43,4 @@ void main() { expect(deserializedCommand.style, equals(ShapeStyle.outline)); }); }); - - group('Version 2', () { - test('Test Heart deserialization for version 2', () { - const type = SerializerType.HEART_SHAPE_COMMAND; - final originalPaint = DummyPaintFactory.createPaint(version: Version.v1); - const center = Offset(100, 100); - const width = 50.0; - const height = 50.0; - const angle = 0.0; - - final command = DummyCommandFactory.createHeartShapeCommand( - originalPaint, - width, - height, - angle, - center, - ShapeStyle.outline, - ); - - final json = command.toJson(); - json['version'] = Version.v2; - final deserializedCommand = HeartShapeCommand.fromJson(json); - - expect( - DummyPaintFactory.comparePaint( - originalPaint, - deserializedCommand.paint, - version: Version.v1, - ), - isTrue); - expect(deserializedCommand.version, equals(Version.v2)); - expect(deserializedCommand.center, equals(center)); - expect(deserializedCommand.type, equals(type)); - expect(deserializedCommand.angle, equals(angle)); - expect(deserializedCommand.width, equals(width)); - expect(deserializedCommand.height, equals(height)); - expect(deserializedCommand.style, equals(ShapeStyle.outline)); - }); - }); } diff --git a/test/unit/serialization/command/rectangle_shape_serializer_test.dart b/test/unit/serialization/command/rectangle_shape_serializer_test.dart index 58cf6231..bc6d01a2 100644 --- a/test/unit/serialization/command/rectangle_shape_serializer_test.dart +++ b/test/unit/serialization/command/rectangle_shape_serializer_test.dart @@ -44,44 +44,4 @@ void main() { expect(deserializedCommand.type, equals(type)); }); }); - - group('Version 2', () { - test('Test SquareShapeCommand deserialization for version 2', () { - const type = SerializerType.SQUARE_SHAPE_COMMAND; - - final originalPaint = DummyPaintFactory.createPaint(version: Version.v1); - const originalTopLeft = Offset(0, 0); - const originalTopRight = Offset(1, 0); - const originalBottomLeft = Offset(0, 1); - const originalBottomRight = Offset(1, 1); - - final command = DummyCommandFactory.createSquareShapeCommand( - originalPaint, - originalTopLeft, - originalTopRight, - originalBottomLeft, - originalBottomRight, - ShapeStyle.outline, - version: Version.v1, - ); - - final json = command.toJson(); - json['version'] = Version.v2; - final deserializedCommand = SquareShapeCommand.fromJson(json); - - expect( - DummyPaintFactory.comparePaint( - originalPaint, - deserializedCommand.paint, - version: Version.v1, - ), - isTrue); - expect(deserializedCommand.version, equals(Version.v2)); - expect(deserializedCommand.topLeft, equals(originalTopLeft)); - expect(deserializedCommand.topRight, equals(originalTopRight)); - expect(deserializedCommand.bottomLeft, equals(originalBottomLeft)); - expect(deserializedCommand.bottomRight, equals(originalBottomRight)); - expect(deserializedCommand.type, equals(type)); - }); - }); } diff --git a/test/unit/serialization/command/star_shape_serializer_test.dart b/test/unit/serialization/command/star_shape_serializer_test.dart index 9588e594..0295e240 100644 --- a/test/unit/serialization/command/star_shape_serializer_test.dart +++ b/test/unit/serialization/command/star_shape_serializer_test.dart @@ -45,46 +45,4 @@ void main() { expect(deserializedCommand.angle, equals(angle)); }); }); - - group('Version 2', () { - test('Test Star deserialization for version 2', () { - const type = SerializerType.STAR_SHAPE_COMMAND; - final originalPaint = DummyPaintFactory.createPaint(version: Version.v1); - const center = Offset(100, 100); - const radius = 50.0; - const numberOfPoints = 5; - const angle = 0.0; - - final style = ShapeStyle.outline; - - final command = DummyCommandFactory.createStarShapeCommand( - originalPaint, - numberOfPoints, - angle, - center, - style, - radius, - radius, - ); - - final json = command.toJson(); - json['version'] = Version.v2; - final deserializedCommand = StarShapeCommand.fromJson(json); - - expect( - DummyPaintFactory.comparePaint( - originalPaint, - deserializedCommand.paint, - version: Version.v1, - ), - isTrue); - expect(deserializedCommand.version, equals(Version.v2)); - expect(deserializedCommand.center, equals(center)); - expect(deserializedCommand.radiusX, equals(radius)); - expect(deserializedCommand.radiusY, equals(radius)); - expect(deserializedCommand.type, equals(type)); - expect(deserializedCommand.numberOfPoints, equals(numberOfPoints)); - expect(deserializedCommand.angle, equals(angle)); - }); - }); } \ No newline at end of file