From f6e2f0466553c0749b9da49a2f200cb486a94adb Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 06:58:25 +0300 Subject: [PATCH] perf(updater): pulse the update chip a few times instead of forever An endless AnimationController.repeat keeps the engine producing a frame at the display rate for as long as a newer release is pending: an otherwise idle app burned ~30% of a CPU core (#1554). The chip now pulses for three breaths when it appears and then settles to its static colors, so the app is idle again. The pulse runs once per pending update and starts again only after the badge went away. Closes #1554 --- .../updater/update_available_badge.dart | 28 +++++++++-- .../updater/update_available_badge_test.dart | 49 +++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) 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); + }); }