diff --git a/example/lib/main_screen.dart b/example/lib/main_screen.dart index dd05fad..7bba24a 100644 --- a/example/lib/main_screen.dart +++ b/example/lib/main_screen.dart @@ -18,36 +18,40 @@ class MainScreen extends StatefulWidget { } class _MainScreenState extends State { + double qrSize = 200; + double logoSize = 40; @override Widget build(BuildContext context) { - const String message = + const message = // ignore: lines_longer_than_80_chars 'Hey this is a QR code. Change this value in the main_screen.dart file.'; - final FutureBuilder qrFutureBuilder = FutureBuilder( + final qrFutureBuilder = FutureBuilder( future: _loadOverlayImage(), - builder: (BuildContext ctx, AsyncSnapshot snapshot) { - const double size = 280.0; + builder: (ctx, snapshot) { if (!snapshot.hasData) { - return const SizedBox(width: size, height: size); + return SizedBox(width: qrSize, height: qrSize); } return CustomPaint( - size: const Size.square(size), + size: Size.square(qrSize), painter: QrPainter( data: message, + gapless: true, version: QrVersions.auto, eyeStyle: const QrEyeStyle( - eyeShape: QrEyeShape.square, - color: Color(0xff128760), + eyeShape: QrEyeShape.squareRounded, + radius: 15, + color: Colors.green, ), dataModuleStyle: const QrDataModuleStyle( - dataModuleShape: QrDataModuleShape.circle, - color: Color(0xff1a5441), + dataModuleShape: QrDataModuleShape.squareRounded, + color: Colors.black, + radius: 3, ), // size: 320.0, embeddedImage: snapshot.data, - embeddedImageStyle: const QrEmbeddedImageStyle( - size: Size.square(60), + embeddedImageStyle: QrEmbeddedImageStyle( + size: Size.square(logoSize), ), ), ); @@ -62,11 +66,39 @@ class _MainScreenState extends State { Expanded( child: Center( child: SizedBox( - width: 280, + width: qrSize, child: qrFutureBuilder, ), ), ), + Expanded( + child: Center( + child: SizedBox( + width: qrSize, + child: QrImageView( + data: message, + gapless: true, + version: QrVersions.auto, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.squareRounded, + radius: 15, + color: Colors.green, + ), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.squareRounded, + color: Colors.black, + radius: 3, + ), + // size: 320.0, + embeddedImage: + ExactAssetImage('assets/images/4.0x/logo_yakka.png'), + embeddedImageStyle: QrEmbeddedImageStyle( + size: Size.square(logoSize), + ), + ), + ), + ), + ), Padding( padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 40) .copyWith(bottom: 40), @@ -79,9 +111,8 @@ class _MainScreenState extends State { } Future _loadOverlayImage() async { - final Completer completer = Completer(); - final ByteData byteData = - await rootBundle.load('assets/images/4.0x/logo_yakka.png'); + final completer = Completer(); + final byteData = await rootBundle.load('assets/images/4.0x/logo_yakka.png'); ui.decodeImageFromList(byteData.buffer.asUint8List(), completer.complete); return completer.future; } diff --git a/lib/src/qr_image_view.dart b/lib/src/qr_image_view.dart index e803f5c..24bc9e2 100644 --- a/lib/src/qr_image_view.dart +++ b/lib/src/qr_image_view.dart @@ -184,8 +184,7 @@ class _QrImageViewState extends State { return _errorWidget(context, constraints, _validationResult.error); } // no error, build the regular widget - final widgetSize = - widget.size ?? constraints.biggest.shortestSide; + final widgetSize = widget.size ?? constraints.biggest.shortestSide; if (widget.embeddedImage != null) { // if requesting to embed an image then we need to load via a // FutureBuilder because the image provider will be async. diff --git a/lib/src/qr_painter.dart b/lib/src/qr_painter.dart index f2f3ba2..bb0d666 100644 --- a/lib/src/qr_painter.dart +++ b/lib/src/qr_painter.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'dart:ui' as ui; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:qr/qr.dart'; @@ -221,20 +222,19 @@ class QrPainter extends CustomPainter { ); // DEBUG: draw the inner content boundary -// final paint = Paint()..style = ui.PaintingStyle.stroke; -// paint.strokeWidth = 1; -// paint.color = const Color(0x55222222); -// canvas.drawRect( -// Rect.fromLTWH(paintMetrics.inset, paintMetrics.inset, -// paintMetrics.innerContentSize, paintMetrics.innerContentSize), -// paint); + // final paint = Paint()..style = ui.PaintingStyle.stroke; + // paint.strokeWidth = 1; + // paint.color = const Color(0x55222222); + // canvas.drawRect( + // Rect.fromLTWH(paintMetrics.inset, paintMetrics.inset, + // paintMetrics.innerContentSize, paintMetrics.innerContentSize), + // paint); double left; double top; final gap = !gapless ? _gapSize : 0; // get the painters for the pixel information - final pixelPaint = - _paintCache.firstPaint(QrCodeElement.codePixel); + final pixelPaint = _paintCache.firstPaint(QrCodeElement.codePixel); if (color != null) { pixelPaint!.color = color!; } else { @@ -251,8 +251,7 @@ class QrPainter extends CustomPainter { if (_isFinderPatternPosition(x, y)) { continue; } - final paint = - _qrImage.isDark(y, x) ? pixelPaint : emptyPixelPaint; + final paint = _qrImage.isDark(y, x) ? pixelPaint : emptyPixelPaint; if (paint == null) { continue; } @@ -273,33 +272,100 @@ class QrPainter extends CustomPainter { paintMetrics.pixelSize + pixelHTweak, paintMetrics.pixelSize + pixelVTweak, ); - if (dataModuleStyle.dataModuleShape == QrDataModuleShape.square) { - canvas.drawRect(squareRect, paint); - } else { - final roundedRect = RRect.fromRectAndRadius( - squareRect, - Radius.circular(paintMetrics.pixelSize + pixelHTweak), - ); - canvas.drawRRect(roundedRect, paint); + + switch (dataModuleStyle.dataModuleShape) { + case QrDataModuleShape.square: + canvas.drawRect(squareRect, paint); + break; + case QrDataModuleShape.squareRounded: + double bottomLeft = 0; + double bottomRight = 0; + double topLeft = 0; + double topRight = 0; + if (_qrImage.isDark(y, x)) { + bottomLeft = dataModuleStyle.radius; + bottomRight = dataModuleStyle.radius; + topLeft = dataModuleStyle.radius; + topRight = dataModuleStyle.radius; + if (true) { + if (y - 1 >= 0 && _qrImage.isDark(y - 1, x)) { + topLeft = topRight = 0; + } + if (y + 1 < _qrImage.moduleCount && _qrImage.isDark(y + 1, x)) { + bottomRight = bottomLeft = 0; + } + if (x + 1 < _qrImage.moduleCount && _qrImage.isDark(y, x + 1)) { + bottomRight = topRight = 0; + } + if (x - 1 >= 0 && _qrImage.isDark(y, x - 1)) { + topLeft = bottomLeft = 0; + } + } + } + final roundedRect = RRect.fromRectAndCorners( + squareRect, + bottomLeft: Radius.circular(bottomLeft), + bottomRight: Radius.circular(bottomRight), + topLeft: Radius.circular(topLeft), + topRight: Radius.circular(topRight), + ); + canvas.drawRRect(roundedRect, paint); + break; + case QrDataModuleShape.circle: + final roundedRect = RRect.fromRectAndRadius(squareRect, + Radius.circular(paintMetrics.pixelSize + pixelHTweak)); + canvas.drawRRect(roundedRect, paint); + break; + default: + final roundedRect = RRect.fromRectAndRadius(squareRect, + Radius.circular(paintMetrics.pixelSize + pixelHTweak)); + canvas.drawRRect(roundedRect, paint); } } } if (embeddedImage != null) { + /// background + final originalSizeBackground = Size( + embeddedImage!.width.toDouble() + 10, + embeddedImage!.height.toDouble() + 10, + ); + final requestedSizeBackground = embeddedImageStyle != null + ? Size( + embeddedImageStyle!.size!.width + 10, + embeddedImageStyle!.size!.height + 10, + ) + : null; + final imageSizeBackground = _scaledAspectSize( + size, originalSizeBackground, requestedSizeBackground); + final positionBackground = Offset( + (size.width - imageSizeBackground.width) / 2.0, + (size.height - imageSizeBackground.height) / 2.0, + ); + // draw the image overlay. + _drawImageOverlay( + canvas, + positionBackground, + imageSizeBackground, + QrEmbeddedImageStyle( + color: embeddedImageStyle?.color ?? Colors.white, + ), + ); + + /// image final originalSize = Size( embeddedImage!.width.toDouble(), embeddedImage!.height.toDouble(), ); final requestedSize = embeddedImageStyle != null ? embeddedImageStyle!.size : null; - final imageSize = - _scaledAspectSize(size, originalSize, requestedSize); + final imageSize = _scaledAspectSize(size, originalSize, requestedSize); final position = Offset( (size.width - imageSize.width) / 2.0, (size.height - imageSize.height) / 2.0, ); // draw the image overlay. - _drawImageOverlay(canvas, position, imageSize, embeddedImageStyle); + _drawImageOverlay(canvas, position, imageSize, null); } } @@ -332,9 +398,8 @@ class QrPainter extends CustomPainter { _PaintMetrics metrics, ) { final totalGap = (_finderPatternLimit - 1) * metrics.gapSize; - final radius = - ((_finderPatternLimit * metrics.pixelSize) + totalGap) - - metrics.pixelSize; + final radius = ((_finderPatternLimit * metrics.pixelSize) + totalGap) - + metrics.pixelSize; final strokeAdjust = metrics.pixelSize / 2.0; final edgePos = (metrics.inset + metrics.innerContentSize) - (radius + strokeAdjust); @@ -357,8 +422,8 @@ class QrPainter extends CustomPainter { outerPaint.strokeWidth = metrics.pixelSize; outerPaint.color = color != null ? color! : eyeStyle.color!; - final innerPaint = _paintCache - .firstPaint(QrCodeElement.finderPatternInner, position: position)!; + final innerPaint = _paintCache.firstPaint(QrCodeElement.finderPatternInner, + position: position)!; innerPaint.strokeWidth = metrics.pixelSize; innerPaint.color = emptyColor ?? const Color(0x00ffffff); @@ -372,8 +437,7 @@ class QrPainter extends CustomPainter { dotPaint!.color = eyeStyle.color!; } - final outerRect = - Rect.fromLTWH(offset.dx, offset.dy, radius, radius); + final outerRect = Rect.fromLTWH(offset.dx, offset.dy, radius, radius); final innerRadius = radius - (2 * metrics.pixelSize); final innerRect = Rect.fromLTWH( @@ -392,22 +456,43 @@ class QrPainter extends CustomPainter { dotSize, ); - if (eyeStyle.eyeShape == QrEyeShape.square) { - canvas.drawRect(outerRect, outerPaint); - canvas.drawRect(innerRect, innerPaint); - canvas.drawRect(dotRect, dotPaint); - } else { - final roundedOuterStrokeRect = - RRect.fromRectAndRadius(outerRect, Radius.circular(radius)); - canvas.drawRRect(roundedOuterStrokeRect, outerPaint); - - final roundedInnerStrokeRect = - RRect.fromRectAndRadius(outerRect, Radius.circular(innerRadius)); - canvas.drawRRect(roundedInnerStrokeRect, innerPaint); - - final roundedDotStrokeRect = - RRect.fromRectAndRadius(dotRect, Radius.circular(dotSize)); - canvas.drawRRect(roundedDotStrokeRect, dotPaint); + switch (eyeStyle.eyeShape) { + case QrEyeShape.square: + canvas.drawRect(outerRect, outerPaint); + canvas.drawRect(innerRect, innerPaint); + canvas.drawRect(dotRect, dotPaint); + break; + case QrEyeShape.squareRounded: + final roundedOuterStrokeRect = RRect.fromRectAndRadius( + outerRect, Radius.circular(eyeStyle.radius)); + canvas.drawRRect(roundedOuterStrokeRect, outerPaint); + canvas.drawRect(innerRect, innerPaint); + final roundedDotStrokeRect = RRect.fromRectAndRadius( + dotRect, Radius.circular(eyeStyle.radius / 2)); + canvas.drawRRect(roundedDotStrokeRect, dotPaint); + break; + case QrEyeShape.circle: + final roundedOuterStrokeRect = + RRect.fromRectAndRadius(outerRect, Radius.circular(radius)); + canvas.drawRRect(roundedOuterStrokeRect, outerPaint); + final roundedInnerStrokeRect = + RRect.fromRectAndRadius(outerRect, Radius.circular(innerRadius)); + canvas.drawRRect(roundedInnerStrokeRect, innerPaint); + final roundedDotStrokeRect = + RRect.fromRectAndRadius(dotRect, Radius.circular(dotSize)); + canvas.drawRRect(roundedDotStrokeRect, dotPaint); + break; + + default: + final roundedOuterStrokeRect = + RRect.fromRectAndRadius(outerRect, Radius.circular(radius)); + canvas.drawRRect(roundedOuterStrokeRect, outerPaint); + final roundedInnerStrokeRect = + RRect.fromRectAndRadius(outerRect, Radius.circular(innerRadius)); + canvas.drawRRect(roundedInnerStrokeRect, innerPaint); + final roundedDotStrokeRect = + RRect.fromRectAndRadius(dotRect, Radius.circular(dotSize)); + canvas.drawRRect(roundedDotStrokeRect, dotPaint); } } @@ -447,8 +532,7 @@ class QrPainter extends CustomPainter { } final srcSize = Size(embeddedImage!.width.toDouble(), embeddedImage!.height.toDouble()); - final src = - Alignment.center.inscribe(srcSize, Offset.zero & srcSize); + final src = Alignment.center.inscribe(srcSize, Offset.zero & srcSize); final dst = Alignment.center.inscribe(size, position & size); canvas.drawImageRect(embeddedImage!, src, dst, paint); } diff --git a/lib/src/types.dart b/lib/src/types.dart index b67b185..dc2a8a6 100644 --- a/lib/src/types.dart +++ b/lib/src/types.dart @@ -43,6 +43,9 @@ enum QrEyeShape { /// Use square eye frame. square, + /// Use square rounded eye frame. + squareRounded, + /// Use circular eye frame. circle, } @@ -52,6 +55,9 @@ enum QrDataModuleShape { /// Use square dots. square, + /// Use square rounded eye frame. + squareRounded, + /// Use circular dots. circle, } @@ -60,7 +66,7 @@ enum QrDataModuleShape { @immutable class QrEyeStyle { /// Create a new set of styling options for QR Eye. - const QrEyeStyle({this.eyeShape, this.color}); + const QrEyeStyle({this.eyeShape, this.color, this.radius = 0}); /// Eye shape. final QrEyeShape? eyeShape; @@ -68,6 +74,9 @@ class QrEyeStyle { /// Color to tint the eye. final Color? color; + /// radius + final double radius; + @override int get hashCode => eyeShape.hashCode ^ color.hashCode; @@ -87,6 +96,7 @@ class QrDataModuleStyle { const QrDataModuleStyle({ this.dataModuleShape, this.color, + this.radius = 0, }); /// Eye shape. @@ -95,6 +105,9 @@ class QrDataModuleStyle { /// Color to tint the data modules. final Color? color; + /// radius + final double radius; + @override int get hashCode => dataModuleShape.hashCode ^ color.hashCode; diff --git a/test/.golden/qr_image_logo_golden.png b/test/.golden/qr_image_logo_golden.png index 8856a1b..77d3783 100644 Binary files a/test/.golden/qr_image_logo_golden.png and b/test/.golden/qr_image_logo_golden.png differ diff --git a/test/.golden/qr_image_rounded_golden.png b/test/.golden/qr_image_rounded_golden.png new file mode 100644 index 0000000..b606b4f Binary files /dev/null and b/test/.golden/qr_image_rounded_golden.png differ diff --git a/test/.golden/qr_image_rounded_logo_border_color_golden.png b/test/.golden/qr_image_rounded_logo_border_color_golden.png new file mode 100644 index 0000000..40d9c6c Binary files /dev/null and b/test/.golden/qr_image_rounded_logo_border_color_golden.png differ diff --git a/test/.golden/qr_image_rounded_logo_golden.png b/test/.golden/qr_image_rounded_logo_golden.png new file mode 100644 index 0000000..43dd19e Binary files /dev/null and b/test/.golden/qr_image_rounded_logo_golden.png differ diff --git a/test/failures/qr_image_logo_golden_isolatedDiff.png b/test/failures/qr_image_logo_golden_isolatedDiff.png new file mode 100644 index 0000000..a78aaf4 Binary files /dev/null and b/test/failures/qr_image_logo_golden_isolatedDiff.png differ diff --git a/test/failures/qr_image_logo_golden_maskedDiff.png b/test/failures/qr_image_logo_golden_maskedDiff.png new file mode 100644 index 0000000..a629315 Binary files /dev/null and b/test/failures/qr_image_logo_golden_maskedDiff.png differ diff --git a/test/failures/qr_image_logo_golden_masterImage.png b/test/failures/qr_image_logo_golden_masterImage.png new file mode 100644 index 0000000..8856a1b Binary files /dev/null and b/test/failures/qr_image_logo_golden_masterImage.png differ diff --git a/test/failures/qr_image_logo_golden_testImage.png b/test/failures/qr_image_logo_golden_testImage.png new file mode 100644 index 0000000..fb1950a Binary files /dev/null and b/test/failures/qr_image_logo_golden_testImage.png differ diff --git a/test/image_test.dart b/test/image_test.dart index 578c1c8..1cfcc57 100644 --- a/test/image_test.dart +++ b/test/image_test.dart @@ -183,6 +183,129 @@ void main() { ); }, ); + + testWidgets( + 'QrImageView rounded generates correct image', + (tester) async { + final qrImage = MaterialApp( + home: Center( + child: RepaintBoundary( + child: QrImageView( + data: 'This is a a qr code with a logo', + gapless: true, + version: QrVersions.auto, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.squareRounded, + radius: 15, + color: Colors.green, + ), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.squareRounded, + color: Colors.black, + radius: 3, + ), + ), + ), + ), + ); + await tester.pumpWidget(qrImage); + await expectLater( + find.byType(QrImageView), + matchesGoldenFile( + './.golden/qr_image_rounded_golden.png', + ), + ); + }, + ); + + testWidgets( + 'QrImageView rounded generates correct image with logo', + (tester) async { + await pumpWidgetWithImages( + tester, + MaterialApp( + home: Center( + child: RepaintBoundary( + child: QrImageView( + data: 'This is a a qr code with a logo', + gapless: true, + version: QrVersions.auto, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.squareRounded, + radius: 15, + color: Colors.green, + ), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.squareRounded, + color: Colors.black, + radius: 3, + ), + size: 320.0, + embeddedImage: FileImage(File('test/.images/logo_yakka.png')), + embeddedImageStyle: const QrEmbeddedImageStyle( + size: Size.square(60), + ), + ), + ), + ), + ), + ['test/.images/logo_yakka.png'], + ); + + await tester.pumpAndSettle(); + + await expectLater( + find.byType(QrImageView), + matchesGoldenFile('./.golden/qr_image_rounded_logo_golden.png'), + ); + }, + ); + + testWidgets( + 'QrImageView rounded generates correct image with logo & border color', + (tester) async { + await pumpWidgetWithImages( + tester, + MaterialApp( + home: Center( + child: RepaintBoundary( + child: QrImageView( + data: 'This is a a qr code with a logo', + gapless: true, + version: QrVersions.auto, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.squareRounded, + radius: 15, + color: Colors.green, + ), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.squareRounded, + color: Colors.black, + radius: 3, + ), + size: 320.0, + embeddedImage: FileImage(File('test/.images/logo_yakka.png')), + embeddedImageStyle: const QrEmbeddedImageStyle( + size: Size.square(60), + color: Colors.red, + ), + ), + ), + ), + ), + ['test/.images/logo_yakka.png'], + ); + + await tester.pumpAndSettle(); + + await expectLater( + find.byType(QrImageView), + matchesGoldenFile( + './.golden/qr_image_rounded_logo_border_color_golden.png', + ), + ); + }, + ); } /// Pre-cache images to make sure they show up in golden tests.