diff --git a/lib/features/updater/update_available_badge.dart b/lib/features/updater/update_available_badge.dart index 0126aafd..71e9e098 100644 --- a/lib/features/updater/update_available_badge.dart +++ b/lib/features/updater/update_available_badge.dart @@ -16,7 +16,14 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; /// [QueryaMotion.effectiveDuration]; Off / OS `disableAnimations` stop the pulse. const Duration kUpdateBadgePulsePeriod = Duration(milliseconds: 1400); -/// Pulsing title-bar chip when a background update check finds a newer release. +/// How many [kUpdateBadgePulsePeriod] legs the chip pulses for when it appears +/// (forward and back count as one leg each, so 6 is three full breaths), after +/// which it settles to the static chip colors. An endless pulse keeps the +/// engine producing a frame at the display rate and costs an idle app ~30% of +/// a CPU core (#1554). +const int kUpdateBadgePulseLegs = 6; + +/// Title-bar chip that pulses a few times when a background update check finds a newer release. class UpdateAvailableBadge extends material.StatefulWidget { const UpdateAvailableBadge({super.key, required this.controller}); @@ -30,6 +37,10 @@ class UpdateAvailableBadgeState extends material.State with material.SingleTickerProviderStateMixin { late final material.AnimationController _pulse; + /// The finite pulse already ran for the update that is pending now; it is + /// cleared when the badge goes away so the next update pulses again. + bool _pulsed = false; + @visibleForTesting bool get isPulseAnimating => _pulse.isAnimating; @@ -77,6 +88,7 @@ class UpdateAvailableBadgeState extends material.State _pulse.stop(); } _pulse.value = 0; + if (!show) _pulsed = false; return; } @@ -86,8 +98,18 @@ class UpdateAvailableBadgeState extends material.State if (periodChanged) { _pulse.duration = period; } - if (!_pulse.isAnimating || periodChanged) { - _pulse.repeat(reverse: true); + final running = _pulse.isAnimating; + if (_pulsed && !running) return; // settled: stay static, stay idle + + if (!running || periodChanged) { + _pulsed = true; + unawaited( + _pulse + .repeat(reverse: true, count: kUpdateBadgePulseLegs) + .whenComplete(() { + if (mounted) _pulse.value = 0; + }), + ); } } diff --git a/test/features/updater/update_available_badge_test.dart b/test/features/updater/update_available_badge_test.dart index 6f61d05a..0a02bb64 100644 --- a/test/features/updater/update_available_badge_test.dart +++ b/test/features/updater/update_available_badge_test.dart @@ -121,4 +121,53 @@ void main() { Duration(microseconds: kUpdateBadgePulsePeriod.inMicroseconds ~/ 2), ); }); + + testWidgets('pulse is finite: settles and stays idle (#1554)', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaMotionScope( + level: QueryaMotionLevel.full, + child: material.Scaffold( + body: UpdateAvailableBadge(controller: controller), + ), + ), + ), + ); + await tester.pump(); + + const manifest = UpdateManifest( + version: '3.0.0', + changelog: '', + assets: [], + ); + controller.setPendingUpdate(manifest); + await tester.pump(); + + final state = tester.state( + find.byType(UpdateAvailableBadge), + ); + expect(state.isPulseAnimating, isTrue); + + await tester.pump( + kUpdateBadgePulsePeriod * kUpdateBadgePulseLegs + + const Duration(milliseconds: 100), + ); + await tester.pump(); + expect(state.isPulseAnimating, isFalse); + expect(find.textContaining('v3.0.0 available'), findsOneWidget); + + // Another sync (controller notification, dependency change) must not + // start the pulse again for the same pending update. + controller.setPendingUpdate(manifest); + await tester.pump(); + expect(state.isPulseAnimating, isFalse); + + // A later update pulses again once the badge went away in between. + controller.setPendingUpdate(null); + await tester.pump(); + controller.setPendingUpdate(manifest); + await tester.pump(); + expect(state.isPulseAnimating, isTrue); + }); }