From 7e8238268bba44bed3d5bf134ec803419f1832de Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Jul 2026 23:35:11 +0300 Subject: [PATCH] perf(ui): stop update badge pulse when hidden or motion off Start AnimationController.repeat only while the badge is visible and motion is enabled; document pulse period as kUpdateBadgePulsePeriod. Closes #363 Co-authored-by: Cursor --- .../updater/update_available_badge.dart | 59 ++++++++++-- .../updater/update_available_badge_test.dart | 92 +++++++++++++++++++ 2 files changed, 141 insertions(+), 10 deletions(-) create mode 100644 test/features/updater/update_available_badge_test.dart diff --git a/lib/features/updater/update_available_badge.dart b/lib/features/updater/update_available_badge.dart index f69bdc04..d9a4e900 100644 --- a/lib/features/updater/update_available_badge.dart +++ b/lib/features/updater/update_available_badge.dart @@ -1,11 +1,18 @@ import 'dart:async' show unawaited; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_context.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/updater/update_controller.dart'; import 'package:querya_desktop/features/updater/update_dialog.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +/// Soft pulse period for the update chip (documented chrome constant; see F9). +const Duration kUpdateBadgePulsePeriod = Duration(milliseconds: 1400); + /// Pulsing title-bar chip when a background update check finds a newer release. class UpdateAvailableBadge extends material.StatefulWidget { const UpdateAvailableBadge({super.key, required this.controller}); @@ -13,24 +20,32 @@ class UpdateAvailableBadge extends material.StatefulWidget { final UpdateController controller; @override - material.State createState() => - _UpdateAvailableBadgeState(); + UpdateAvailableBadgeState createState() => UpdateAvailableBadgeState(); } -class _UpdateAvailableBadgeState extends material.State +class UpdateAvailableBadgeState extends material.State with material.SingleTickerProviderStateMixin { late final material.AnimationController _pulse; + @visibleForTesting + bool get isPulseAnimating => _pulse.isAnimating; + @override void initState() { super.initState(); _pulse = material.AnimationController( vsync: this, - duration: const Duration(milliseconds: 1400), - )..repeat(reverse: true); + duration: kUpdateBadgePulsePeriod, + ); widget.controller.addListener(_onControllerChanged); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _syncPulse(); + } + @override void didUpdateWidget(covariant UpdateAvailableBadge oldWidget) { super.didUpdateWidget(oldWidget); @@ -38,10 +53,30 @@ class _UpdateAvailableBadgeState extends material.State oldWidget.controller.removeListener(_onControllerChanged); widget.controller.addListener(_onControllerChanged); } + _syncPulse(); } void _onControllerChanged() { - if (mounted) setState(() {}); + if (!mounted) return; + setState(() {}); + _syncPulse(); + } + + void _syncPulse() { + final show = widget.controller.showBadge; + final motionOff = + QueryaMotionScope.maybeOf(context) == QueryaMotionLevel.off || + material.MediaQuery.disableAnimationsOf(context); + if (!show || motionOff) { + if (_pulse.isAnimating) { + _pulse.stop(); + } + _pulse.value = 0; + return; + } + if (!_pulse.isAnimating) { + _pulse.repeat(reverse: true); + } } @override @@ -59,6 +94,8 @@ class _UpdateAvailableBadgeState extends material.State final version = widget.controller.pendingUpdate?.version ?? ''; final wb = context.workbench; + // Depend on motion so Off/Reduced rebuilds re-sync the pulse. + context.motionDuration(QueryaMotion.fast); return material.Padding( padding: const material.EdgeInsets.only(right: 8), @@ -76,13 +113,15 @@ class _UpdateAvailableBadgeState extends material.State animation: _pulse, builder: (context, child) { return material.Container( - padding: - const material.EdgeInsets.symmetric(horizontal: 10, vertical: 4), + padding: const material.EdgeInsets.symmetric( + horizontal: 10, vertical: 4), decoration: material.BoxDecoration( - color: wb.accent.withValues(alpha: 0.12 + 0.08 * _pulse.value), + color: + wb.accent.withValues(alpha: 0.12 + 0.08 * _pulse.value), borderRadius: material.BorderRadius.circular(999), border: material.Border.all( - color: wb.accent.withValues(alpha: 0.35 + 0.25 * _pulse.value), + color: + wb.accent.withValues(alpha: 0.35 + 0.25 * _pulse.value), ), ), child: child, diff --git a/test/features/updater/update_available_badge_test.dart b/test/features/updater/update_available_badge_test.dart new file mode 100644 index 00000000..5e859b0a --- /dev/null +++ b/test/features/updater/update_available_badge_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; +import 'package:querya_desktop/core/updater/update_manifest.dart'; +import 'package:querya_desktop/features/updater/update_available_badge.dart'; +import 'package:querya_desktop/features/updater/update_controller.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + late UpdateController controller; + + setUp(() { + controller = UpdateController(); + controller.resetForTest(); + }); + + tearDown(() { + controller.resetForTest(); + controller.dispose(); + }); + + testWidgets('pulse ticker runs only while badge is visible', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaMotionScope( + level: QueryaMotionLevel.full, + child: material.Scaffold( + body: UpdateAvailableBadge(controller: controller), + ), + ), + ), + ); + await tester.pump(); + + var state = tester.state( + find.byType(UpdateAvailableBadge), + ); + expect(find.textContaining('available'), findsNothing); + expect(state.isPulseAnimating, isFalse); + + controller.setPendingUpdate( + const UpdateManifest( + version: '9.9.9', + changelog: '', + assets: [], + ), + ); + await tester.pump(); + state = tester.state( + find.byType(UpdateAvailableBadge), + ); + expect(find.textContaining('v9.9.9 available'), findsOneWidget); + expect(state.isPulseAnimating, isTrue); + + controller.setPendingUpdate(null); + await tester.pump(); + state = tester.state( + find.byType(UpdateAvailableBadge), + ); + expect(find.textContaining('available'), findsNothing); + expect(state.isPulseAnimating, isFalse); + }); + + testWidgets('motion off does not pulse', (tester) async { + controller.setPendingUpdate( + const UpdateManifest( + version: '1.0.0', + changelog: '', + assets: [], + ), + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaMotionScope( + level: QueryaMotionLevel.off, + child: material.Scaffold( + body: UpdateAvailableBadge(controller: controller), + ), + ), + ), + ); + await tester.pump(); + + final state = tester.state( + find.byType(UpdateAvailableBadge), + ); + expect(find.textContaining('v1.0.0 available'), findsOneWidget); + expect(state.isPulseAnimating, isFalse); + }); +}