From 642a1141a2f43d70222dd07fc07b670fbedcab1b Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Mon, 28 Sep 2026 11:13:49 -0300 Subject: [PATCH] refactor: progress widgets update --- CHANGELOG.md | 3 + .../surfaces/progress_indicators.dart | 314 +++++++++--------- test/progress_bar_test.dart | 136 +++++++- test/progress_indicators_geometry_test.dart | 249 ++++++++++++++ test/progress_ring_test.dart | 135 ++++++++ 5 files changed, 684 insertions(+), 153 deletions(-) create mode 100644 test/progress_indicators_geometry_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff3791cd..ec041e502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ - migrate Material UI imports to the standalone `material_ui` package. [learn more](https://flutter.dev/blog/decoupling-material-cupertino) - fix: skip hidden `TitleBar` children when collecting semantics ([#1354](https://github.com/bdlukaa/fluent_ui/pull/1354)) - fix: `ComboBox` no longer throws a layout assertion when opened while fully aligned to the bottom edge of the viewport ([#1350](https://github.com/bdlukaa/fluent_ui/issues/1350)) +- refactor: Progress Widgets Update + - fix: `ProgressBar` and `ProgressRing` do not consume excessive CPU ([#1270](https://github.com/bdlukaa/fluent_ui/issues/1270)) + - fix: `ProgressBar` correctly handle small parents ([#1270](https://github.com/bdlukaa/fluent_ui/issues/969)) ## 4.16.1 diff --git a/lib/src/controls/surfaces/progress_indicators.dart b/lib/src/controls/surfaces/progress_indicators.dart index 32f8e7b65..24a71218c 100644 --- a/lib/src/controls/surfaces/progress_indicators.dart +++ b/lib/src/controls/surfaces/progress_indicators.dart @@ -100,7 +100,8 @@ class ProgressBar extends StatefulWidget { class _ProgressBarState extends State with SingleTickerProviderStateMixin { - late AnimationController _controller; + late final AnimationController _controller; + final _animationState = _ProgressBarAnimationState(); @override void initState() { @@ -128,13 +129,6 @@ class _ProgressBarState extends State super.dispose(); } - double p1 = 0; - double p2 = 0; - double idleFrames = 15; - double cycle = 1; - double idle = 1; - double lastValue = 0; - @override Widget build(BuildContext context) { assert(debugCheckHasFluentTheme(context)); @@ -148,44 +142,41 @@ class _ProgressBarState extends State label: widget.semanticLabel, value: widget.value?.toStringAsFixed(2), maxValueLength: 100, - child: AnimatedBuilder( - animation: _controller, - builder: (context, child) { - var deltaValue = _controller.value - lastValue; - lastValue = _controller.value; - if (deltaValue < 0) deltaValue++; // repeat - return CustomPaint( - painter: _ProgressBarPainter( - value: widget.value == null ? null : widget.value! / 100, - strokeWidth: widget.strokeWidth, - activeColor: - widget.activeColor ?? - theme.accentColor.defaultBrushFor(theme.brightness), - backgroundColor: - widget.backgroundColor ?? theme.inactiveBackgroundColor, - textDirection: direction, - p1: p1, - p2: p2, - idleFrames: idleFrames, - cycle: cycle, - idle: idle, - deltaValue: deltaValue, - onUpdate: (values) { - p1 = values[0]; - p2 = values[1]; - idleFrames = values[2]; - cycle = values[3]; - idle = values[4]; - }, - ), - ); - }, + child: CustomPaint( + painter: _ProgressBarPainter( + animation: _controller, + animationState: _animationState, + value: widget.value == null ? null : widget.value! / 100, + strokeWidth: widget.strokeWidth, + activeColor: + widget.activeColor ?? + theme.accentColor.defaultBrushFor(theme.brightness), + backgroundColor: + widget.backgroundColor ?? theme.inactiveBackgroundColor, + textDirection: direction, + ), ), ), ); } } +class _ProgressBarAnimationState { + double p1 = 0; + double p2 = 0; + double idleFrames = 15; + double cycle = 1; + double idle = 1; + double lastValue = 0; + + double deltaFor(double value) { + var delta = value - lastValue; + lastValue = value; + if (delta < 0) delta++; // repeat + return delta; + } +} + class _ProgressBarPainter extends CustomPainter { static const _step1 = 2.7; static const _step2 = 4.5; @@ -193,36 +184,55 @@ class _ProgressBarPainter extends CustomPainter { static const _short = 0.4; // percentage of short line (0..1) static const _long = 80 / 130; // percentage of long line (0..1) - double p1; - double p2; - double idleFrames; - double cycle; - double idle; - double deltaValue; - - final ValueChanged> onUpdate; - + final Animation animation; + final _ProgressBarAnimationState animationState; final double strokeWidth; final Color backgroundColor; final Color activeColor; final TextDirection textDirection; - final double? value; + final Paint _backgroundPaint; + final Paint _activePaint; _ProgressBarPainter({ - required this.p1, - required this.p2, - required this.idle, - required this.cycle, - required this.idleFrames, - required this.deltaValue, - required this.onUpdate, + required this.animation, + required this.animationState, required this.strokeWidth, required this.backgroundColor, required this.activeColor, required this.textDirection, required this.value, - }); + }) : _backgroundPaint = Paint() + ..color = backgroundColor + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round, + _activePaint = Paint() + ..color = activeColor + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round, + super(repaint: animation); + + static double _calcVelocity(double position, double deltaValue) { + return (1 + math.cos(math.pi * position - (math.pi / 2)) * _velocityScale) * + deltaValue; + } + + void _drawLine( + Canvas canvas, + Size size, + Offset xy1, + Offset xy2, + Paint paint, + ) { + xy1 += Offset(strokeWidth / 2, 0); + xy1 = xy1.clamp(Offset.zero, Offset(size.width, size.height)); + xy2 = xy2.clamp(xy1, Offset(size.width, size.height)); + canvas.drawLine(xy1, xy2, paint); + } @override void paint(Canvas canvas, Size size) { @@ -232,36 +242,24 @@ class _ProgressBarPainter extends CustomPainter { } size = Size(size.width - strokeWidth / 2, size.height - strokeWidth / 2); - - void drawLine(Offset xy1, Offset xy2, Color color) { - xy1 += Offset(strokeWidth / 2, 0); - xy1 = xy1.clamp(Offset.zero, Offset(size.width, size.height)); - xy2 = xy2.clamp(xy1, Offset(size.width, size.height)); - - canvas.drawLine( - xy1, - xy2, - Paint() - ..color = color - ..strokeWidth = strokeWidth - ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round - ..strokeJoin = StrokeJoin.round, - ); - } + if (size.width < 0 || size.height < 0) return; // background line - drawLine( + _drawLine( + canvas, + size, Offset(0, size.height), Offset(size.width, size.height), - backgroundColor, + _backgroundPaint, ); if (value != null) { - drawLine( + _drawLine( + canvas, + size, Offset(0, size.height), Offset(clampDouble(value!, 0, 1) * size.width, size.height), - activeColor, + _activePaint, ); return; } @@ -270,46 +268,52 @@ class _ProgressBarPainter extends CustomPainter { // https://gist.github.com/raitonoberu/21dacaee725806b60ddb45ec68147d30 // https://github.com/raitonoberu - void update() { - onUpdate([p1, p2, idleFrames, cycle, idle]); - } - - Offset coords(double percentage) { - return Offset(size.width * percentage, size.height); - } - - double calcVelocity(double p) { - return (1 + math.cos(math.pi * p - (math.pi / 2)) * _velocityScale) * - deltaValue; - } - - final v1 = calcVelocity(p1); - final v2 = calcVelocity(p2); + final deltaValue = animationState.deltaFor(animation.value); + final v1 = _calcVelocity(animationState.p1, deltaValue); + final v2 = _calcVelocity(animationState.p2, deltaValue); - if (cycle == 1) { + if (animationState.cycle == 1) { // short line - p2 = math.min(p2 + _step1 * v2, 1); - if (p2 - p1 >= _short || p2 == 1) p1 = math.min(p1 + _step1 * v1, 1); + animationState.p2 = math.min(animationState.p2 + _step1 * v2, 1); + if (animationState.p2 - animationState.p1 >= _short || + animationState.p2 == 1) { + animationState.p1 = math.min(animationState.p1 + _step1 * v1, 1); + } } - if (cycle == -1) { + if (animationState.cycle == -1) { // long line - p2 = math.min(p2 + _step2 * v2, 1); - if (p2 - p1 >= _long || p2 == 1) p1 = math.min(p1 + _step2 * v1, 1); + animationState.p2 = math.min(animationState.p2 + _step2 * v2, 1); + if (animationState.p2 - animationState.p1 >= _long || + animationState.p2 == 1) { + animationState.p1 = math.min(animationState.p1 + _step2 * v1, 1); + } } - if (p1 == 1) { + if (animationState.p1 == 1) { // the end reached - idle = idleFrames; - cycle *= -1; - p1 = 0; - p2 = 0; + animationState.idle = animationState.idleFrames; + animationState.cycle *= -1; + animationState.p1 = 0; + animationState.p2 = 0; } - update(); - if (idle != 0) drawLine(coords(p1), coords(p2), activeColor); + if (animationState.idle != 0) { + _drawLine( + canvas, + size, + Offset(size.width * animationState.p1, size.height), + Offset(size.width * animationState.p2, size.height), + _activePaint, + ); + } } @override - bool shouldRepaint(_ProgressBarPainter oldDelegate) => true; + bool shouldRepaint(_ProgressBarPainter oldDelegate) => + strokeWidth != oldDelegate.strokeWidth || + backgroundColor != oldDelegate.backgroundColor || + activeColor != oldDelegate.activeColor || + textDirection != oldDelegate.textDirection || + value != oldDelegate.value; @override bool shouldRebuildSemantics(_ProgressBarPainter oldDelegate) => false; @@ -474,24 +478,20 @@ class _ProgressRingState extends State child: Semantics( label: widget.semanticLabel, value: widget.value?.toStringAsFixed(2), - child: AnimatedBuilder( - animation: _controller, - builder: (context, child) { - return CustomPaint( - painter: _RingPainter( - backgroundColor: - widget.backgroundColor ?? theme.inactiveBackgroundColor, - value: widget.value, - color: - widget.activeColor ?? - theme.accentColor.defaultBrushFor(theme.brightness), - strokeWidth: widget.strokeWidth, - startAngle: _startAngleTween.evaluate(_controller), - sweepAngle: _sweepAngleTween.evaluate(_controller), - backwards: widget.backwards, - ), - ); - }, + child: CustomPaint( + painter: _RingPainter( + animation: _controller, + startAngleTween: _startAngleTween, + sweepAngleTween: _sweepAngleTween, + backgroundColor: + widget.backgroundColor ?? theme.inactiveBackgroundColor, + value: widget.value, + color: + widget.activeColor ?? + theme.accentColor.defaultBrushFor(theme.brightness), + strokeWidth: widget.strokeWidth, + backwards: widget.backwards, + ), ), ), ); @@ -499,23 +499,36 @@ class _ProgressRingState extends State } class _RingPainter extends CustomPainter { + final Animation animation; + final TweenSequence startAngleTween; + final TweenSequence sweepAngleTween; final Color color; final Color backgroundColor; final double strokeWidth; final double? value; - final double startAngle; - final double sweepAngle; final bool backwards; + final Paint _backgroundPaint; + final Paint _activePaint; - const _RingPainter({ + _RingPainter({ + required this.animation, + required this.startAngleTween, + required this.sweepAngleTween, required this.color, required this.backgroundColor, required this.strokeWidth, required this.value, - required this.startAngle, - required this.sweepAngle, required this.backwards, - }); + }) : _backgroundPaint = Paint() + ..color = backgroundColor + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth, + _activePaint = Paint() + ..color = color + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke, + super(repaint: animation); static const double _twoPi = math.pi * 2.0; static const double _epsilon = .001; @@ -530,45 +543,42 @@ class _RingPainter extends CustomPainter { // adapted so that the stroke will be drawn inside the paint area. final offset = Offset(strokeWidth / 2, strokeWidth / 2); size = Size(size.width - strokeWidth, size.height - strokeWidth); + if (size.width < 0 || size.height < 0) return; + + final rect = offset & size; // Background line - canvas.drawArc( - offset & size, - _startAngle, - 100, - false, - Paint() - ..color = backgroundColor - ..style = PaintingStyle.stroke - ..strokeWidth = strokeWidth, - ); - final paint = Paint() - ..color = color - ..strokeWidth = strokeWidth - ..strokeCap = StrokeCap.round - ..style = PaintingStyle.stroke; + canvas.drawArc(rect, _startAngle, 100, false, _backgroundPaint); if (value == null) { + final startAngle = startAngleTween.evaluate(animation); + final sweepAngle = sweepAngleTween.evaluate(animation); canvas.drawArc( - offset & size, + rect, ((backwards ? -startAngle : startAngle) - 90) * _deg2Rad, sweepAngle * _deg2Rad, false, - paint, + _activePaint, ); } else { canvas.drawArc( - offset & size, + rect, _startAngle, clampDouble(value! / 100, 0, 1) * _sweep, false, - paint, + _activePaint, ); } } @override bool shouldRepaint(_RingPainter oldDelegate) => - value == null || value != oldDelegate.value; + color != oldDelegate.color || + backgroundColor != oldDelegate.backgroundColor || + strokeWidth != oldDelegate.strokeWidth || + value != oldDelegate.value || + backwards != oldDelegate.backwards || + startAngleTween != oldDelegate.startAngleTween || + sweepAngleTween != oldDelegate.sweepAngleTween; @override bool shouldRebuildSemantics(_RingPainter oldDelegate) => false; diff --git a/test/progress_bar_test.dart b/test/progress_bar_test.dart index 65d0dd145..7da4d6b28 100644 --- a/test/progress_bar_test.dart +++ b/test/progress_bar_test.dart @@ -1,4 +1,5 @@ import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'app_test.dart'; @@ -55,9 +56,142 @@ void main() { ); expect(find.byType(ProgressBar), findsOneWidget); - // Pump a few frames to verify the indeterminate animation runs without error await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500)); expect(find.byType(ProgressBar), findsOneWidget); }); + + testWidgets( + 'indeterminate animation repaints without rebuilding CustomPaint', + (tester) async { + await tester.pumpWidget(wrapApp(child: const ProgressBar())); + + final customPaint = find.descendant( + of: find.byType(ProgressBar), + matching: find.byType(CustomPaint), + ); + final element = tester.element(customPaint); + final widget = element.widget; + final painter = tester + .renderObject(customPaint) + .painter; + + await tester.pump(const Duration(milliseconds: 250)); + + expect(tester.element(customPaint).widget, same(widget)); + expect( + tester.renderObject(customPaint).painter, + same(painter), + ); + }, + ); + + testWidgets('determinate ProgressBar does not keep a ticker running', ( + tester, + ) async { + await tester.pumpWidget(wrapApp(child: const ProgressBar(value: 50))); + + expect(tester.hasRunningAnimations, isFalse); + await tester.pump(const Duration(seconds: 1)); + expect(tester.hasRunningAnimations, isFalse); + }); + + testWidgets('ProgressBar stops and restarts its animation on mode changes', ( + tester, + ) async { + late void Function(void Function()) update; + double? value; + await tester.pumpWidget( + wrapApp( + child: StatefulBuilder( + builder: (context, setState) { + update = setState; + return ProgressBar(value: value); + }, + ), + ), + ); + expect(tester.hasRunningAnimations, isTrue); + + update(() => value = 50); + await tester.pump(); + await tester.pump(); + expect(tester.hasRunningAnimations, isFalse); + + update(() => value = null); + await tester.pump(); + expect(tester.hasRunningAnimations, isTrue); + }); + + testWidgets('ProgressBar respects TickerMode', (tester) async { + await tester.pumpWidget( + wrapApp(child: const TickerMode(enabled: false, child: ProgressBar())), + ); + expect(tester.hasRunningAnimations, isFalse); + + await tester.pumpWidget( + wrapApp(child: const TickerMode(enabled: true, child: ProgressBar())), + ); + await tester.pump(); + expect(tester.hasRunningAnimations, isTrue); + }); + + testWidgets('ProgressBar handles degenerate constraints', (tester) async { + for (final size in const [ + Size.zero, + Size(1, 0), + Size(0, 1), + Size(1, 1), + Size(4.5, 4.5), + Size(40, 40), + Size(1000, 1000), + ]) { + await tester.pumpWidget( + wrapApp( + child: SizedBox( + width: size.width, + height: size.height, + child: const ProgressBar(value: 50), + ), + ), + ); + expect(tester.takeException(), isNull, reason: 'size: $size'); + } + }); + + testWidgets('ProgressBar preserves custom colors and stroke width', ( + tester, + ) async { + const active = Color(0xff123456); + const background = Color(0xff654321); + await tester.pumpWidget( + wrapApp( + child: const ProgressBar( + value: 50, + activeColor: active, + backgroundColor: background, + strokeWidth: 8, + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('indeterminate ProgressBar semantics do not animate', ( + tester, + ) async { + await tester.pumpWidget( + wrapApp(child: const ProgressBar(semanticLabel: 'Loading')), + ); + final node = tester.getSemantics(find.bySemanticsLabel('Loading')); + final before = node.getSemanticsData(); + + await tester.pump(const Duration(milliseconds: 500)); + + final after = node.getSemanticsData(); + expect(after.label, before.label); + expect(after.value, before.value); + expect(after.flagsCollection, before.flagsCollection); + }); } diff --git a/test/progress_indicators_geometry_test.dart b/test/progress_indicators_geometry_test.dart new file mode 100644 index 000000000..6e6a682e8 --- /dev/null +++ b/test/progress_indicators_geometry_test.dart @@ -0,0 +1,249 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'app_test.dart'; + +class _RecordingCanvas implements Canvas { + final lines = <({Offset start, Offset end, Paint paint})>[]; + final arcs = <({Rect rect, double start, double sweep, Paint paint})>[]; + + @override + dynamic noSuchMethod(Invocation invocation) { + if (invocation.memberName == #drawLine) { + lines.add(( + start: invocation.positionalArguments[0] as Offset, + end: invocation.positionalArguments[1] as Offset, + paint: invocation.positionalArguments[2] as Paint, + )); + return null; + } + if (invocation.memberName == #drawArc) { + arcs.add(( + rect: invocation.positionalArguments[0] as Rect, + start: invocation.positionalArguments[1] as double, + sweep: invocation.positionalArguments[2] as double, + paint: invocation.positionalArguments[4] as Paint, + )); + return null; + } + return null; + } +} + +RenderCustomPaint _customPaint(WidgetTester tester, Type indicator) { + final finder = find.descendant( + of: find.byType(indicator), + matching: find.byType(CustomPaint), + ); + return tester.renderObject(finder); +} + +void main() { + testWidgets('ProgressBar keeps its approved animation geometry', ( + tester, + ) async { + const samples = <({Duration elapsed, double start, double end})>[ + (elapsed: Duration.zero, start: 2.25, end: 2.25), + (elapsed: Duration(milliseconds: 300), start: 2.25, end: 0.27), + (elapsed: Duration(milliseconds: 750), start: 0.675, end: 0.675), + (elapsed: Duration(milliseconds: 1500), start: 2.25, end: 2.25), + ]; + + for (final sample in samples) { + await tester.pumpWidget(wrapApp(child: ProgressBar(key: UniqueKey()))); + await tester.pump(sample.elapsed); + + final renderObject = _customPaint(tester, ProgressBar); + final canvas = _RecordingCanvas(); + renderObject.painter!.paint(canvas, renderObject.size); + + expect(canvas.lines, hasLength(2)); + const strokeWidth = 4.5; + final adjustedWidth = renderObject.size.width - strokeWidth / 2; + final expectedStart = sample.start == 2.25 + ? 2.25 + : adjustedWidth * sample.start + strokeWidth / 2; + final expectedEnd = sample.start == sample.end + ? expectedStart + : adjustedWidth * sample.end; + expect(canvas.lines[1].start.dx, closeTo(expectedStart, 0.0001)); + expect(canvas.lines[1].end.dx, closeTo(expectedEnd, 0.0001)); + } + }); + + testWidgets('ProgressRing keeps its approved tween geometry', (tester) async { + const samples = <({Duration elapsed, double start, double sweep})>[ + (elapsed: Duration(milliseconds: 500), start: 225, sweep: 90), + (elapsed: Duration(milliseconds: 1500), start: 765, sweep: 90), + ]; + + for (final sample in samples) { + await tester.pumpWidget(wrapApp(child: ProgressRing(key: UniqueKey()))); + await tester.pump(sample.elapsed); + + final renderObject = _customPaint(tester, ProgressRing); + final canvas = _RecordingCanvas(); + renderObject.painter!.paint(canvas, renderObject.size); + + expect(canvas.arcs, hasLength(2)); + expect( + canvas.arcs[1].start, + closeTo((sample.start - 90) * 2 * 3.141592653589793 / 360, 0.0001), + ); + expect( + canvas.arcs[1].sweep, + closeTo(sample.sweep * 2 * 3.141592653589793 / 360, 0.0001), + ); + } + }); + + testWidgets('ProgressRing preserves backwards sweep direction', ( + tester, + ) async { + await tester.pumpWidget( + wrapApp(child: const ProgressRing(backwards: true)), + ); + await tester.pump(const Duration(milliseconds: 500)); + + final renderObject = _customPaint(tester, ProgressRing); + final canvas = _RecordingCanvas(); + renderObject.painter!.paint(canvas, renderObject.size); + + expect(canvas.arcs, hasLength(2)); + expect( + canvas.arcs[1].start, + closeTo((-225 - 90) * 2 * 3.141592653589793 / 360, 0.0001), + ); + }); + + testWidgets('ProgressBar updates custom colors after a widget change', ( + tester, + ) async { + const active = Color(0xff123456); + const background = Color(0xff654321); + await tester.pumpWidget( + wrapApp( + child: const ProgressBar( + value: 50, + activeColor: active, + backgroundColor: background, + ), + ), + ); + + final renderObject = _customPaint(tester, ProgressBar); + final canvas = _RecordingCanvas(); + renderObject.painter!.paint(canvas, renderObject.size); + + expect(canvas.lines[0].paint.color.toARGB32(), background.toARGB32()); + expect(canvas.lines[1].paint.color.toARGB32(), active.toARGB32()); + }); + + testWidgets('ProgressRing updates custom colors after a widget change', ( + tester, + ) async { + const active = Color(0xff123456); + const background = Color(0xff654321); + await tester.pumpWidget( + wrapApp( + child: const ProgressRing( + value: 50, + activeColor: active, + backgroundColor: background, + ), + ), + ); + + final renderObject = _customPaint(tester, ProgressRing); + final canvas = _RecordingCanvas(); + renderObject.painter!.paint(canvas, renderObject.size); + + expect(canvas.arcs[0].paint.color.toARGB32(), background.toARGB32()); + expect(canvas.arcs[1].paint.color.toARGB32(), active.toARGB32()); + }); + + testWidgets('theme accent changes reach ProgressBar paint', (tester) async { + final firstTheme = FluentThemeData(accentColor: Colors.red.toAccentColor()); + final secondTheme = FluentThemeData( + accentColor: Colors.green.toAccentColor(), + ); + await tester.pumpWidget( + wrapApp( + child: FluentTheme( + data: firstTheme, + child: const ProgressBar(value: 50), + ), + ), + ); + + var renderObject = _customPaint(tester, ProgressBar); + var canvas = _RecordingCanvas(); + renderObject.painter!.paint(canvas, renderObject.size); + expect( + canvas.lines[1].paint.color.toARGB32(), + firstTheme.accentColor.defaultBrushFor(firstTheme.brightness).toARGB32(), + ); + + await tester.pumpWidget( + wrapApp( + child: FluentTheme( + data: secondTheme, + child: const ProgressBar(value: 50), + ), + ), + ); + + renderObject = _customPaint(tester, ProgressBar); + canvas = _RecordingCanvas(); + renderObject.painter!.paint(canvas, renderObject.size); + expect( + canvas.lines[1].paint.color.toARGB32(), + secondTheme.accentColor + .defaultBrushFor(secondTheme.brightness) + .toARGB32(), + ); + }); + + testWidgets('theme accent changes reach ProgressRing paint', (tester) async { + final firstTheme = FluentThemeData(accentColor: Colors.red.toAccentColor()); + final secondTheme = FluentThemeData( + accentColor: Colors.green.toAccentColor(), + ); + await tester.pumpWidget( + wrapApp( + child: FluentTheme( + data: firstTheme, + child: const ProgressRing(value: 50), + ), + ), + ); + + var renderObject = _customPaint(tester, ProgressRing); + var canvas = _RecordingCanvas(); + renderObject.painter!.paint(canvas, renderObject.size); + expect( + canvas.arcs[1].paint.color.toARGB32(), + firstTheme.accentColor.defaultBrushFor(firstTheme.brightness).toARGB32(), + ); + + await tester.pumpWidget( + wrapApp( + child: FluentTheme( + data: secondTheme, + child: const ProgressRing(value: 50), + ), + ), + ); + + renderObject = _customPaint(tester, ProgressRing); + canvas = _RecordingCanvas(); + renderObject.painter!.paint(canvas, renderObject.size); + expect( + canvas.arcs[1].paint.color.toARGB32(), + secondTheme.accentColor + .defaultBrushFor(secondTheme.brightness) + .toARGB32(), + ); + }); +} diff --git a/test/progress_ring_test.dart b/test/progress_ring_test.dart index 76c6e57cc..54c419572 100644 --- a/test/progress_ring_test.dart +++ b/test/progress_ring_test.dart @@ -1,4 +1,5 @@ import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'app_test.dart'; @@ -15,4 +16,138 @@ void main() { expect(semantics.label, 'Loading'); expect(semantics.value, '66.00'); }); + + testWidgets( + 'indeterminate ProgressRing repaints without rebuilding CustomPaint', + (tester) async { + await tester.pumpWidget(wrapApp(child: const ProgressRing())); + + final customPaint = find.descendant( + of: find.byType(ProgressRing), + matching: find.byType(CustomPaint), + ); + final element = tester.element(customPaint); + final widget = element.widget; + final painter = tester + .renderObject(customPaint) + .painter; + + await tester.pump(const Duration(milliseconds: 250)); + + expect(tester.element(customPaint).widget, same(widget)); + expect( + tester.renderObject(customPaint).painter, + same(painter), + ); + }, + ); + + testWidgets('determinate ProgressRing does not keep a ticker running', ( + tester, + ) async { + await tester.pumpWidget(wrapApp(child: const ProgressRing(value: 50))); + + expect(tester.hasRunningAnimations, isFalse); + await tester.pump(const Duration(seconds: 1)); + expect(tester.hasRunningAnimations, isFalse); + }); + + testWidgets('ProgressRing stops and restarts its animation on mode changes', ( + tester, + ) async { + late void Function(void Function()) update; + double? value; + await tester.pumpWidget( + wrapApp( + child: StatefulBuilder( + builder: (context, setState) { + update = setState; + return ProgressRing(value: value); + }, + ), + ), + ); + expect(tester.hasRunningAnimations, isTrue); + + update(() => value = 50); + await tester.pump(); + await tester.pump(); + expect(tester.hasRunningAnimations, isFalse); + + update(() => value = null); + await tester.pump(); + expect(tester.hasRunningAnimations, isTrue); + }); + + testWidgets('ProgressRing respects TickerMode', (tester) async { + await tester.pumpWidget( + wrapApp(child: const TickerMode(enabled: false, child: ProgressRing())), + ); + expect(tester.hasRunningAnimations, isFalse); + + await tester.pumpWidget( + wrapApp(child: const TickerMode(enabled: true, child: ProgressRing())), + ); + await tester.pump(); + expect(tester.hasRunningAnimations, isTrue); + }); + + testWidgets('ProgressRing handles degenerate constraints', (tester) async { + for (final size in const [ + Size.zero, + Size(1, 0), + Size(0, 1), + Size(1, 1), + Size(4.5, 4.5), + Size(40, 40), + Size(1000, 1000), + ]) { + await tester.pumpWidget( + wrapApp( + child: SizedBox( + width: size.width, + height: size.height, + child: const ProgressRing(value: 50), + ), + ), + ); + expect(tester.takeException(), isNull, reason: 'size: $size'); + } + }); + + testWidgets('ProgressRing preserves custom colors and stroke width', ( + tester, + ) async { + const active = Color(0xff123456); + const background = Color(0xff654321); + await tester.pumpWidget( + wrapApp( + child: const ProgressRing( + value: 50, + activeColor: active, + backgroundColor: background, + strokeWidth: 8, + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('indeterminate ProgressRing semantics do not animate', ( + tester, + ) async { + await tester.pumpWidget( + wrapApp(child: const ProgressRing(semanticLabel: 'Loading')), + ); + final node = tester.getSemantics(find.bySemanticsLabel('Loading')); + final before = node.getSemanticsData(); + + await tester.pump(const Duration(milliseconds: 500)); + + final after = node.getSemanticsData(); + expect(after.label, before.label); + expect(after.value, before.value); + expect(after.flagsCollection, before.flagsCollection); + }); }