diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 268e2ab..c475061 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -14,4 +14,6 @@ jobs: - name: Restore packages run: flutter pub get - name: Analyze - run: flutter analyze \ No newline at end of file + run: flutter analyze + - name: Run Tests without coverage + run: flutter test \ No newline at end of file diff --git a/lib/view/drawing_canvas/drawing_canvas.dart b/lib/view/drawing_canvas/drawing_canvas.dart index d101ac4..a2db4dc 100644 --- a/lib/view/drawing_canvas/drawing_canvas.dart +++ b/lib/view/drawing_canvas/drawing_canvas.dart @@ -8,14 +8,11 @@ import 'package:flutter_drawing_board/view/drawing_canvas/models/sketch.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; class DrawingCanvas extends HookWidget { - final double height; - final double width; final ValueNotifier selectedColor; final ValueNotifier strokeSize; final ValueNotifier backgroundImage; final ValueNotifier eraserSize; final ValueNotifier drawingMode; - final AnimationController sideBarController; final ValueNotifier currentSketch; final ValueNotifier> allSketches; final GlobalKey canvasGlobalKey; @@ -24,13 +21,10 @@ class DrawingCanvas extends HookWidget { const DrawingCanvas({ Key? key, - required this.height, - required this.width, required this.selectedColor, required this.strokeSize, required this.eraserSize, required this.drawingMode, - required this.sideBarController, required this.currentSketch, required this.allSketches, required this.canvasGlobalKey, @@ -114,16 +108,16 @@ class DrawingCanvas extends HookWidget { Widget buildAllSketches(BuildContext context) { return SizedBox( - height: height, - width: width, + height: MediaQuery.of(context).size.height, + width: MediaQuery.of(context).size.width, child: ValueListenableBuilder>( valueListenable: allSketches, builder: (context, sketches, _) { return RepaintBoundary( key: canvasGlobalKey, child: Container( - height: height, - width: width, + height: MediaQuery.of(context).size.height, + width: MediaQuery.of(context).size.width, color: kCanvasColor, child: CustomPaint( painter: SketchPainter( @@ -148,8 +142,8 @@ class DrawingCanvas extends HookWidget { builder: (context, sketch, child) { return RepaintBoundary( child: SizedBox( - height: height, - width: width, + height: MediaQuery.of(context).size.height, + width: MediaQuery.of(context).size.width, child: CustomPaint( painter: SketchPainter( sketches: sketch == null ? [] : [sketch], diff --git a/lib/view/drawing_page.dart b/lib/view/drawing_page.dart index bdf69dd..59430cf 100644 --- a/lib/view/drawing_page.dart +++ b/lib/view/drawing_page.dart @@ -38,13 +38,10 @@ class DrawingPage extends HookWidget { width: double.maxFinite, height: double.maxFinite, child: DrawingCanvas( - width: MediaQuery.of(context).size.width, - height: MediaQuery.of(context).size.height, drawingMode: drawingMode, selectedColor: selectedColor, strokeSize: strokeSize, eraserSize: eraserSize, - sideBarController: animationController, currentSketch: currentSketch, allSketches: allSketches, canvasGlobalKey: canvasGlobalKey, diff --git a/pubspec.lock b/pubspec.lock index c3e6e6e..aac66d8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -162,18 +162,18 @@ packages: dependency: "direct main" description: name: flutter_colorpicker - sha256: "458a6ed8ea480eb16ff892aedb4b7092b2804affd7e046591fb03127e8d8ef8b" + sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.1.0" flutter_hooks: dependency: "direct main" description: name: flutter_hooks - sha256: "2b202559a4ed3656bbb7aae9d8b335fb0037b23acc7ae3f377d1ba0b95c21aec" + sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70 url: "https://pub.dev" source: hosted - version: "0.18.5+1" + version: "0.20.5" flutter_lints: dependency: "direct dev" description: @@ -312,6 +312,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.4" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + url: "https://pub.dev" + source: hosted + version: "10.0.4" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" lints: dependency: transitive description: @@ -324,26 +348,26 @@ packages: dependency: transitive description: name: matcher - sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb url: "https://pub.dev" source: hosted - version: "0.12.16" + version: "0.12.16+1" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.8.0" meta: dependency: transitive description: name: meta - sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.12.0" mime: dependency: transitive description: @@ -356,10 +380,10 @@ packages: dependency: transitive description: name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" url: "https://pub.dev" source: hosted - version: "1.8.3" + version: "1.9.0" path_parsing: dependency: transitive description: @@ -505,10 +529,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.0" typed_data: dependency: transitive description: @@ -629,14 +653,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - web: + vm_service: dependency: transitive description: - name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + name: vm_service + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "14.2.1" win32: dependency: transitive description: @@ -662,5 +686,5 @@ packages: source: hosted version: "6.4.2" sdks: - dart: ">=3.2.0-194.0.dev <4.0.0" - flutter: ">=3.13.0" + dart: ">=3.3.0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/pubspec.yaml b/pubspec.yaml index 9d28966..67f10b7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,8 +32,8 @@ dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.2 - flutter_colorpicker: ^1.0.3 - flutter_hooks: ^0.18.5+1 + flutter_colorpicker: ^1.1.0 + flutter_hooks: ^0.20.5 flutter_svg: ^2.0.9 file_saver: ^0.2.9 font_awesome_flutter: ^10.6.0 diff --git a/test/flutter_test_config.dart b/test/flutter_test_config.dart new file mode 100644 index 0000000..30b276b --- /dev/null +++ b/test/flutter_test_config.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'util/local_file_comparator_with_threshold.dart'; + +/// Customise your threshold here +/// For example, the error threshold here is 0.5% +/// Golden tests will pass if the pixel difference is equal to or below 0.5% +const _kGoldenTestsThreshold = 0.5 / 100; + +Future testExecutable(FutureOr Function() testMain) async { + if (goldenFileComparator is LocalFileComparator) { + final testUrl = (goldenFileComparator as LocalFileComparator).basedir; + + goldenFileComparator = LocalFileComparatorWithThreshold( + // flutter_test's LocalFileComparator expects the test's URI to be passed + // as an argument, but it only uses it to parse the baseDir in order to + // obtain the directory where the golden tests will be placed. + // As such, we use the default `testUrl`, which is only the `baseDir` and + // append a generically named `test.dart` so that the `baseDir` is + // properly extracted. + Uri.parse('$testUrl/test.dart'), + _kGoldenTestsThreshold, + ); + } else { + throw Exception( + 'Expected `goldenFileComparator` to be of type `LocalFileComparator`, ' + 'but it is of type `${goldenFileComparator.runtimeType}`', + ); + } + + await testMain(); +} diff --git a/test/util/local_file_comparator_with_threshold.dart b/test/util/local_file_comparator_with_threshold.dart new file mode 100644 index 0000000..bd51cd9 --- /dev/null +++ b/test/util/local_file_comparator_with_threshold.dart @@ -0,0 +1,42 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// source: https://stackoverflow.com/a/73048760/14052058 + +/// Works just like [LocalFileComparator] but includes a [threshold] that, when +/// exceeded, marks the test as a failure. +class LocalFileComparatorWithThreshold extends LocalFileComparator { + /// Threshold above which tests will be marked as failing. + /// Ranges from 0 to 1, both inclusive. + final double threshold; + + LocalFileComparatorWithThreshold(super.testFile, this.threshold) + : assert(threshold >= 0 && threshold <= 1); + + /// Copy of [LocalFileComparator]'s [compare] method, except for the fact that + /// it checks if the [ComparisonResult.diffPercent] is not greater than + /// [threshold] to decide whether this test is successful or a failure. + @override + Future compare(Uint8List imageBytes, Uri golden) async { + final result = await GoldenFileComparator.compareLists( + imageBytes, + await getGoldenBytes(golden), + ); + + if (!result.passed && result.diffPercent <= threshold) { + debugPrint( + 'A difference of ${result.diffPercent * 100}% was found, but it is ' + 'acceptable since it is not greater than the threshold of ' + '${threshold * 100}%', + ); + + return true; + } + + if (!result.passed) { + final error = await generateFailureOutput(result, golden, basedir); + throw FlutterError(error); + } + return result.passed; + } +} diff --git a/test/view/drawing_canvas/drawing_canvas_test.dart b/test/view/drawing_canvas/drawing_canvas_test.dart new file mode 100644 index 0000000..c27d845 --- /dev/null +++ b/test/view/drawing_canvas/drawing_canvas_test.dart @@ -0,0 +1,194 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart' hide Image; +import 'package:flutter_drawing_board/view/drawing_canvas/drawing_canvas.dart'; +import 'package:flutter_drawing_board/view/drawing_canvas/models/drawing_mode.dart'; +import 'package:flutter_drawing_board/view/drawing_canvas/models/sketch.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Drawing Canvas', () { + testWidgets('renders correctly', (tester) async { + await tester.pumpWidget( + const _Seed(), + ); + expect(find.byType(DrawingCanvas), findsOneWidget); + }); + + testWidgets('renders empty canvas', (WidgetTester tester) async { + await tester.pumpWidget( + const _Seed(), + ); + await expectLater( + find.byType(DrawingCanvas), + matchesGoldenFile('goldens/empty_canvas.png'), + ); + }); + }); + + group('Pencil tool', () { + testWidgets('draw a single stroke', (WidgetTester tester) async { + await tester.pumpWidget( + const _Seed(), + ); + // Simulate drawing + const Offset startPoint = Offset(100, 100); + const Offset endPoint = Offset(200, 200); + await tester.dragFrom(startPoint, endPoint); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(DrawingCanvas), + matchesGoldenFile('goldens/single_stroke_canvas.png'), + ); + }); + + testWidgets('draw stroke with a different color', + (WidgetTester tester) async { + await tester.pumpWidget( + _Seed( + selectedColor: ValueNotifier(Colors.red), + ), + ); + // Simulate drawing + const Offset startPoint = Offset(100, 100); + const Offset endPoint = Offset(200, 200); + await tester.dragFrom(startPoint, endPoint); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(DrawingCanvas), + matchesGoldenFile('goldens/single_stroke_canvas_with_diff_color.png'), + ); + }); + }); + + group('Eraser tool', () { + testWidgets('erase a stroke', (WidgetTester tester) async { + await tester.pumpWidget( + _Seed( + strokeSize: ValueNotifier(10), + eraserSize: ValueNotifier(5), + ), + ); + // Simulate drawing a stroke + const Offset startPoint = Offset(100, 100); + const Offset endPoint = Offset(200, 200); + await tester.dragFrom(startPoint, endPoint); + await tester.pumpAndSettle(); + + // Switch to eraser mode + final _SeedState state = tester.state(find.byType(_Seed)); + state.drawingMode.value = DrawingMode.eraser; + + // Erase part of the stroke + const Offset eraseStartPoint = Offset(150, 150); + const Offset eraseEndPoint = Offset(200, 200); + await tester.dragFrom(eraseStartPoint, eraseEndPoint); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(DrawingCanvas), + matchesGoldenFile('goldens/erased_stroke_canvas.png'), + ); + }); + }); + + group('Polygon tool', () { + testWidgets('draw a polygon', (WidgetTester tester) async { + await tester.pumpWidget( + _Seed( + polygonSides: ValueNotifier(5), + drawingMode: ValueNotifier(DrawingMode.polygon), + ), + ); + + // Simulate drawing a polygon + const Offset startPoint = Offset(100, 100); + const Offset endPoint = Offset(200, 200); + await tester.dragFrom(startPoint, endPoint); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(DrawingCanvas), + matchesGoldenFile('goldens/polygon_canvas.png'), + ); + }); + + testWidgets('draw a filled polygon', (WidgetTester tester) async { + await tester.pumpWidget( + _Seed( + drawingMode: ValueNotifier(DrawingMode.polygon), + polygonSides: ValueNotifier(5), + filled: ValueNotifier(true), + ), + ); + + // Simulate drawing a filled polygon + const Offset startPoint = Offset(100, 100); + const Offset endPoint = Offset(200, 200); + await tester.dragFrom(startPoint, endPoint); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(DrawingCanvas), + matchesGoldenFile('goldens/filled_polygon_canvas.png'), + ); + }); + }); +} + +class _Seed extends StatefulWidget { + final ValueNotifier? selectedColor; + final ValueNotifier? strokeSize; + final ValueNotifier? eraserSize; + final ValueNotifier? drawingMode; + final ValueNotifier? polygonSides; + final ValueNotifier? filled; + + const _Seed({ + Key? key, + this.selectedColor, + this.strokeSize, + this.eraserSize, + this.drawingMode, + this.polygonSides, + this.filled, + }) : super(key: key); + + @override + State<_Seed> createState() => _SeedState(); +} + +class _SeedState extends State<_Seed> { + final ValueNotifier selectedColor = ValueNotifier(Colors.black); + final ValueNotifier strokeSize = ValueNotifier(5.0); + final ValueNotifier eraserSize = ValueNotifier(5.0); + final ValueNotifier drawingMode = + ValueNotifier(DrawingMode.pencil); + final ValueNotifier currentSketch = ValueNotifier(null); + final ValueNotifier> allSketches = + ValueNotifier>([]); + final ValueNotifier polygonSides = ValueNotifier(3); + final ValueNotifier filled = ValueNotifier(false); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: DrawingCanvas( + selectedColor: widget.selectedColor ?? selectedColor, + strokeSize: widget.strokeSize ?? strokeSize, + eraserSize: widget.eraserSize ?? eraserSize, + drawingMode: widget.drawingMode ?? drawingMode, + currentSketch: currentSketch, + allSketches: allSketches, + canvasGlobalKey: GlobalKey(), + filled: widget.filled ?? filled, + polygonSides: widget.polygonSides ?? polygonSides, + backgroundImage: ValueNotifier(null), + ), + ), + ); + } +} diff --git a/test/view/drawing_canvas/goldens/empty_canvas.png b/test/view/drawing_canvas/goldens/empty_canvas.png new file mode 100644 index 0000000..ebb6777 Binary files /dev/null and b/test/view/drawing_canvas/goldens/empty_canvas.png differ diff --git a/test/view/drawing_canvas/goldens/erased_stroke_canvas.png b/test/view/drawing_canvas/goldens/erased_stroke_canvas.png new file mode 100644 index 0000000..2799c33 Binary files /dev/null and b/test/view/drawing_canvas/goldens/erased_stroke_canvas.png differ diff --git a/test/view/drawing_canvas/goldens/filled_polygon_canvas.png b/test/view/drawing_canvas/goldens/filled_polygon_canvas.png new file mode 100644 index 0000000..db79ca9 Binary files /dev/null and b/test/view/drawing_canvas/goldens/filled_polygon_canvas.png differ diff --git a/test/view/drawing_canvas/goldens/polygon_canvas.png b/test/view/drawing_canvas/goldens/polygon_canvas.png new file mode 100644 index 0000000..1b26b0d Binary files /dev/null and b/test/view/drawing_canvas/goldens/polygon_canvas.png differ diff --git a/test/view/drawing_canvas/goldens/single_stroke_canvas.png b/test/view/drawing_canvas/goldens/single_stroke_canvas.png new file mode 100644 index 0000000..5288ecd Binary files /dev/null and b/test/view/drawing_canvas/goldens/single_stroke_canvas.png differ diff --git a/test/view/drawing_canvas/goldens/single_stroke_canvas_with_diff_color.png b/test/view/drawing_canvas/goldens/single_stroke_canvas_with_diff_color.png new file mode 100644 index 0000000..80f2952 Binary files /dev/null and b/test/view/drawing_canvas/goldens/single_stroke_canvas_with_diff_color.png differ diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index ad026f9..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:flutter_drawing_board/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} diff --git a/windows/flutter/ephemeral/.plugin_symlinks/url_launcher_windows b/windows/flutter/ephemeral/.plugin_symlinks/url_launcher_windows new file mode 120000 index 0000000..008aa6a --- /dev/null +++ b/windows/flutter/ephemeral/.plugin_symlinks/url_launcher_windows @@ -0,0 +1 @@ +/Users/jideguru/.pub-cache/hosted/pub.dartlang.org/url_launcher_windows-3.0.1/ \ No newline at end of file