diff --git a/.github/workflows/basic_dart_check.yml b/.github/workflows/basic_dart_check.yml index 8c98cb1..84f6128 100644 --- a/.github/workflows/basic_dart_check.yml +++ b/.github/workflows/basic_dart_check.yml @@ -15,6 +15,10 @@ jobs: - name: 🛠️ Set up Flutter uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: 3.22.3 + - run: flutter --version - name: ⚙️ Install dependencies run: flutter pub get diff --git a/example/lib/screens/examples/box_example_screen.dart b/example/lib/screens/examples/box_example_screen.dart index 7a31c36..2dbf4df 100644 --- a/example/lib/screens/examples/box_example_screen.dart +++ b/example/lib/screens/examples/box_example_screen.dart @@ -46,8 +46,8 @@ class BoxExampleScreen extends StatelessWidget { TextDecorator.boxed( style: BoxStyle.bubble, text: const Text( - 'Bubble Text 2', - style: TextStyle(fontSize: 32), + 'Franz jagt im komplett verwahrlosten Taxi quer durch Berlin', + style: TextStyle(fontSize: 16), ), strokeWidth: 2, borderRadius: 16, @@ -63,14 +63,17 @@ class BoxExampleScreen extends StatelessWidget { borderRadius: 16, ), const SizedBox(height: 32), - TextDecorator.boxed( - style: BoxStyle.curled, - text: const Text( - 'Franz jagt im komplett verwahrlosten Taxi quer durch Berlin', - style: TextStyle(fontSize: 16), + Padding( + padding: const EdgeInsets.all(16.0), + child: TextDecorator.boxed( + style: BoxStyle.curled, + text: const Text( + 'Franz jagt im komplett verwahrlosten Taxi quer durch Berlin', + style: TextStyle(fontSize: 16), + ), + strokeWidth: 2, + borderRadius: 16, ), - strokeWidth: 2, - borderRadius: 16, ), ], ), diff --git a/lib/flutter_text_decorator.dart b/lib/flutter_text_decorator.dart index f22af01..0eb28e8 100644 --- a/lib/flutter_text_decorator.dart +++ b/lib/flutter_text_decorator.dart @@ -1,3 +1,2 @@ - export 'src/modules/base/styles.dart'; -export 'src/widgets/text_draw_decorator.dart'; +export 'src/widgets/text_decorator.dart'; diff --git a/lib/src/modules/box/enums/box_style.dart b/lib/src/modules/box/enums/box_style.dart index 2e61dcf..4a1298d 100644 --- a/lib/src/modules/box/enums/box_style.dart +++ b/lib/src/modules/box/enums/box_style.dart @@ -21,22 +21,31 @@ enum BoxStyle { /// - `strokeWidth`: Intended for the thickness of the box outline. /// /// See individual enum value documentation for notes on parameter usage for specific styles. - CustomPainter getPainter(Text text, double borderRadius, double strokeWidth) { + CustomPainter getPainter(Text text, double borderRadius, double strokeWidth, EdgeInsets padding) { switch (this) { case BoxStyle.rounded: - return RoundedBoxPainter(text: text, borderRadius: borderRadius, strokeWidth: strokeWidth); + return RoundedBoxPainter( + text: text, + borderRadius: borderRadius, + strokeWidth: strokeWidth, + padding: padding, + ); case BoxStyle.bubble: return BubbleBoxPainter( text: text, - padding: 4, bubbleColor: Colors.orange, tip: const BubbleBoxTip( position: TipPosition.left, orientation: TipOrientation.left, ), + padding: padding, ); case BoxStyle.curled: - return WavyBoxPainter(text: text, borderColor: Colors.black); + return WavyBoxPainter( + text: text, + borderColor: Colors.black, + padding: padding, + ); } } } diff --git a/lib/src/modules/box/painter/bubble_box_painter.dart b/lib/src/modules/box/painter/bubble_box_painter.dart index ff14d8e..377a504 100644 --- a/lib/src/modules/box/painter/bubble_box_painter.dart +++ b/lib/src/modules/box/painter/bubble_box_painter.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_text_decorator/src/modules/box/decorations/bubble_box_tip.dart'; @@ -22,14 +24,14 @@ import 'package:flutter_text_decorator/src/modules/box/decorations/bubble_box_ti class BubbleBoxPainter extends CustomPainter { BubbleBoxPainter({ required this.text, - required this.padding, required this.bubbleColor, + required this.padding, super.repaint, this.borderRadius = 8, this.tip = const BubbleBoxTip(), }); final Text text; - final double padding; //! TODO: fix text not being centered + final EdgeInsets padding; final Color bubbleColor; final double borderRadius; final BubbleBoxTip tip; @@ -48,29 +50,31 @@ class BubbleBoxPainter extends CustomPainter { )..layout(); final textWidth = textPainter.width; + final availableWidth = min(textWidth, size.width); final textHeight = textPainter.height; + final availableHeight = max(textHeight, size.height); // Calculate bubble size - final bubbleWidth = textWidth + padding * 2; - final bubbleHeight = textHeight + padding * 2; + final bubbleWidth = availableWidth + padding.horizontal; + final bubbleHeight = availableHeight + padding.vertical; // Calculate tail size - //! TODO: extract + // !TODO: extract final tailHeight = bubbleHeight * 0.25; final path = Path() // Top left corner - ..moveTo(0, borderRadius) - ..quadraticBezierTo(0, 0, borderRadius, 0) + ..moveTo(0 - padding.left, borderRadius - padding.top) + ..quadraticBezierTo(0 - padding.left, 0 - padding.top, borderRadius - padding.left, 0 - padding.top) // Top right corner - ..lineTo(bubbleWidth - borderRadius, 0) - ..quadraticBezierTo(bubbleWidth, 0, bubbleWidth, borderRadius) + ..lineTo(bubbleWidth - borderRadius - padding.left, 0 - padding.top) + ..quadraticBezierTo(bubbleWidth - padding.left, 0 - padding.top, bubbleWidth - padding.left, borderRadius - padding.top) // Bottom right corner - ..lineTo(bubbleWidth, bubbleHeight - borderRadius) - ..quadraticBezierTo(bubbleWidth, bubbleHeight, bubbleWidth - borderRadius, bubbleHeight); + ..lineTo(bubbleWidth - padding.left, bubbleHeight - borderRadius - padding.top) + ..quadraticBezierTo(bubbleWidth - padding.left, bubbleHeight - padding.top, bubbleWidth - borderRadius - padding.left, bubbleHeight - padding.top); final tipOffset = bubbleWidth * 0.05; @@ -84,11 +88,11 @@ class BubbleBoxPainter extends CustomPainter { // Bottom left corner with tail path - ..lineTo(tipStart, bubbleHeight) - ..lineTo(tipPeak, bubbleHeight + tailHeight) - ..lineTo(tipEnd, bubbleHeight) - ..lineTo(borderRadius, bubbleHeight) - ..quadraticBezierTo(0, bubbleHeight, 0, bubbleHeight - borderRadius) + ..lineTo(tipStart, bubbleHeight - padding.top) + ..lineTo(tipPeak, bubbleHeight + tailHeight - padding.top) + ..lineTo(tipEnd, bubbleHeight - padding.top) + ..lineTo(borderRadius - padding.left, bubbleHeight - padding.top) + ..quadraticBezierTo(0 - padding.left, bubbleHeight - padding.top, 0 - padding.left, bubbleHeight - borderRadius - padding.top) ..close(); // Draw the bubble diff --git a/lib/src/modules/box/painter/rounded_box_painter.dart b/lib/src/modules/box/painter/rounded_box_painter.dart index 9e685d6..450dc73 100644 --- a/lib/src/modules/box/painter/rounded_box_painter.dart +++ b/lib/src/modules/box/painter/rounded_box_painter.dart @@ -30,11 +30,13 @@ class RoundedBoxPainter extends CustomPainter { required this.text, required this.borderRadius, required this.strokeWidth, + required this.padding, super.repaint, }); final Text text; final double borderRadius; final double strokeWidth; + final EdgeInsets padding; @override void paint(Canvas canvas, Size size) { @@ -42,12 +44,8 @@ class RoundedBoxPainter extends CustomPainter { ..strokeWidth = strokeWidth ..style = PaintingStyle.stroke; - // TODO(everyone): Extract and make generic final textSpan = TextSpan(text: text.data, style: text.style); - final textPainter = TextPainter( - text: textSpan, - textDirection: TextDirection.ltr, - )..layout(); + final textPainter = TextPainter(text: textSpan, textDirection: TextDirection.ltr)..layout(); final textWidth = textPainter.width; final boxWidth = min(textWidth, size.width); @@ -60,18 +58,36 @@ class RoundedBoxPainter extends CustomPainter { boxHeight = nLines * textHeight; final centerOffset = Offset( - size.width / 2, - size.height / 2, + _getOffsetDx(size), + _getOffsetDy(size), ); final rrect = RRect.fromRectAndRadius( - Rect.fromCenter(center: centerOffset, width: boxWidth, height: boxHeight), + Rect.fromCenter(center: centerOffset, width: boxWidth + padding.horizontal, height: boxHeight + padding.vertical), Radius.circular(borderRadius), ); canvas.drawRRect(rrect, paint); } + double _getOffsetDx(Size size) { + var dx = size.width / 2; + if (padding.left != padding.right) { + final diff = (padding.left - padding.right).abs() / 2; + dx = padding.left < padding.right ? dx + diff : dx - diff; + } + return dx; + } + + double _getOffsetDy(Size size) { + var dy = size.height / 2; + if (padding.top != padding.bottom) { + final diff = (padding.top - padding.bottom).abs() / 2; + dy = padding.top < padding.bottom ? dy + diff : dy - diff; + } + return dy; + } + @override bool shouldRepaint(covariant CustomPainter oldDelegate) { return false; diff --git a/lib/src/modules/box/painter/wavy_box_painter.dart b/lib/src/modules/box/painter/wavy_box_painter.dart index da891cd..d81cee2 100644 --- a/lib/src/modules/box/painter/wavy_box_painter.dart +++ b/lib/src/modules/box/painter/wavy_box_painter.dart @@ -36,12 +36,18 @@ class WavyBoxPainter extends CustomPainter { WavyBoxPainter({ required this.text, required this.borderColor, + required this.padding, + this.nHorizontalSegments = 12, + this.nVerticalSegments = 3, super.repaint, }); // TODO(everyone): add padding? // TODO(everyone): add fill color? final Text text; final Color borderColor; + final EdgeInsets padding; + final int nHorizontalSegments; + final int nVerticalSegments; @override void paint(Canvas canvas, Size size) { @@ -63,44 +69,44 @@ class WavyBoxPainter extends CustomPainter { final nLines = heightFactor.ceil(); textHeight = nLines * textHeight; - const nHorizontalSegments = 10; - final availableWidth = min(textWidth, size.width); - final lengthSegment = (availableWidth / nHorizontalSegments).ceil(); - final nVerticalSegments = (textHeight / lengthSegment).ceil(); + final availableWidth = min(textWidth, size.width) + padding.horizontal; + final availableHeight = max(textHeight, size.height) + padding.vertical; + final widthHorizontalSegment = availableWidth / nHorizontalSegments; + final heightVerticalSegment = availableHeight / nVerticalSegments; const arcHeight = -10.0; const weight = 1.0; - var lastX2 = 0.0; + var lastX2 = 0.0 - padding.left; // TODO(everyone): Fix corners, maybe with [arcTo]? - final path = Path()..moveTo(0, 0); + final path = Path()..moveTo(0 - padding.left, 0 - padding.top); // Upper left to upper right for (var i = 1; i <= nHorizontalSegments; i++) { - final x2 = i * lengthSegment / 1.0; - path.conicTo(lastX2 + lengthSegment / 2, arcHeight, x2 / 1.0, 0, weight); + final x2 = (i * widthHorizontalSegment / 1.0) - padding.left; + path.conicTo(lastX2 + widthHorizontalSegment / 2, arcHeight - padding.top, x2 / 1.0, 0 - padding.top, weight); lastX2 = x2; } // Upper right to lower right - double lastY2 = 0; + var lastY2 = 0 - padding.top; for (var i = 1; i <= nVerticalSegments; i++) { - final y2 = i * lengthSegment / 1.0; - path.conicTo(lastX2 - arcHeight, lastY2 + lengthSegment / 2, lastX2, y2, weight); + final y2 = (i * heightVerticalSegment / 1.0) - padding.top; + path.conicTo(lastX2 - arcHeight, lastY2 + heightVerticalSegment / 2, lastX2, y2, weight); lastY2 = y2; } // Lower right to lower left for (var i = nHorizontalSegments - 1; i >= 0; i--) { - final x2 = i * lengthSegment / 1.0; - path.conicTo(lastX2 - lengthSegment / 2, lastY2 - arcHeight, x2 / 1.0, lastY2, weight); + final x2 = (i * widthHorizontalSegment / 1.0) - padding.left; + path.conicTo(lastX2 - widthHorizontalSegment / 2, lastY2 - arcHeight, x2 / 1.0, lastY2, weight); lastX2 = x2; } // Lower left to upper right for (var i = nVerticalSegments - 1; i >= 0; i--) { - final y2 = i * lengthSegment / 1.0; - path.conicTo(arcHeight, y2 + lengthSegment / 2, lastX2, y2, weight); + final y2 = (i * heightVerticalSegment / 1.0) - padding.top; + path.conicTo(arcHeight - padding.left, y2 + heightVerticalSegment / 2, 0 - padding.left, y2, weight); lastY2 = y2; } diff --git a/lib/src/widgets/text_draw_decorator.dart b/lib/src/widgets/text_decorator.dart similarity index 98% rename from lib/src/widgets/text_draw_decorator.dart rename to lib/src/widgets/text_decorator.dart index 2fd74b1..01bdcb4 100644 --- a/lib/src/widgets/text_draw_decorator.dart +++ b/lib/src/widgets/text_decorator.dart @@ -41,7 +41,6 @@ import 'package:flutter_text_decorator/src/modules/underline/enums/underline_sty /// ``` /// class TextDecorator extends StatelessWidget { - const TextDecorator({ required this.text, required this.painter, @@ -131,10 +130,11 @@ class TextDecorator extends StatelessWidget { BoxStyle style = BoxStyle.bubble, double borderRadius = 1, double strokeWidth = 1, + EdgeInsets padding = const EdgeInsets.all(8), }) { return TextDecorator( text: text, - painter: style.getPainter(text, borderRadius, strokeWidth), + painter: style.getPainter(text, borderRadius, strokeWidth, padding), ); } diff --git a/test/flutter_test_config.dart b/test/flutter_test_config.dart new file mode 100644 index 0000000..9c6573f --- /dev/null +++ b/test/flutter_test_config.dart @@ -0,0 +1,25 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'utils/local_file_comparer.dart'; + +const _kGoldenTestsThreshold = 0.5 / 100; + +Future testExecutable(FutureOr Function() testMain) async { + if (goldenFileComparator is LocalFileComparator) { + final testUrl = (goldenFileComparator as LocalFileComparator).basedir; + + goldenFileComparator = LocalFileComparatorWithThreshold( + 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/src/widgets/text_decorator/box/bubble.default.png b/test/src/widgets/text_decorator/box/bubble.default.png new file mode 100644 index 0000000..a08eea1 Binary files /dev/null and b/test/src/widgets/text_decorator/box/bubble.default.png differ diff --git a/test/src/widgets/text_decorator/box/curled.default.png b/test/src/widgets/text_decorator/box/curled.default.png new file mode 100644 index 0000000..6de88e6 Binary files /dev/null and b/test/src/widgets/text_decorator/box/curled.default.png differ diff --git a/test/src/widgets/text_decorator/box/rounded.default.png b/test/src/widgets/text_decorator/box/rounded.default.png new file mode 100644 index 0000000..34e1b9e Binary files /dev/null and b/test/src/widgets/text_decorator/box/rounded.default.png differ diff --git a/test/src/widgets/text_decorator_test.dart b/test/src/widgets/text_decorator_test.dart new file mode 100644 index 0000000..5dc489e --- /dev/null +++ b/test/src/widgets/text_decorator_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_text_decorator/flutter_text_decorator.dart'; + +import '../../test_utils/test_util.dart'; + +const _text = Text( + testText, + style: TextStyle(fontSize: 16), +); +const _stokeWidth = 2.0; + +Widget _createCenter(Widget child) => Center(child: Padding(padding: const EdgeInsets.all(32), child: child)); + +void main() { + group('TextDecorator tests', () { + group('Testing TextDecorator.boxed styles', () { + testWidgets( + 'BoxStyle.bubble renders', + (tester) async { + // arrange + final widget = TextDecorator.boxed( + text: _text, + strokeWidth: _stokeWidth, + ); + + // act + await tester.pumpWidget(createTestApp(_createCenter(widget))); + await tester.pumpAndSettle(); + + // assert + await expectLater( + find.byType(Text), + matchesGoldenFile('text_decorator/box/bubble.default.png'), + ); + }, + ); + testWidgets( + 'BoxStyle.rounded renders', + (tester) async { + // arrange + final widget = TextDecorator.boxed( + style: BoxStyle.rounded, + text: _text, + strokeWidth: _stokeWidth, + borderRadius: 2, + ); + + // act + await tester.pumpWidget(createTestApp(_createCenter(widget))); + await tester.pumpAndSettle(); + + // assert + await expectLater( + find.byType(Text), + matchesGoldenFile('text_decorator/box/rounded.default.png'), + ); + }, + ); + + testWidgets( + 'BoxStyle.curled renders', + (tester) async { + // arrange + final widget = TextDecorator.boxed( + style: BoxStyle.curled, + text: _text, + strokeWidth: _stokeWidth, + ); + + // act + await tester.pumpWidget(createTestApp(_createCenter(widget))); + await tester.pumpAndSettle(); + + // assert + await expectLater( + find.byType(Text), + matchesGoldenFile('text_decorator/box/curled.default.png'), + ); + }, + ); + }); + }); +} diff --git a/test/test_utils/test_util.dart b/test/test_utils/test_util.dart new file mode 100644 index 0000000..65fcce8 --- /dev/null +++ b/test/test_utils/test_util.dart @@ -0,0 +1,8 @@ +import 'package:flutter/material.dart'; + +const testText = 'Franz jagt im komplett verwahrlosten Taxi quer durch Berlin'; + +Widget createTestApp(Widget child) => MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold(body: child), + ); diff --git a/test/utils/local_file_comparer.dart b/test/utils/local_file_comparer.dart new file mode 100644 index 0000000..011cab1 --- /dev/null +++ b/test/utils/local_file_comparer.dart @@ -0,0 +1,36 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Works just like [LocalFileComparator] but includes a [threshold] that, when +/// exceeded, marks the test as a failure. +class LocalFileComparatorWithThreshold extends LocalFileComparator { + LocalFileComparatorWithThreshold(super.testFile, this.threshold) : assert(threshold >= 0 && threshold <= 1, 'Threshold must be between 0 and 1'); + + /// Threshold above which tests will be marked as failing. + /// Ranges from 0 to 1, both inclusive. + final double threshold; + + @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; + } +}