Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions lib/features/updater/update_available_badge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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});

Expand All @@ -30,6 +37,10 @@ class UpdateAvailableBadgeState extends material.State<UpdateAvailableBadge>
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;

Expand Down Expand Up @@ -77,6 +88,7 @@ class UpdateAvailableBadgeState extends material.State<UpdateAvailableBadge>
_pulse.stop();
}
_pulse.value = 0;
if (!show) _pulsed = false;
return;
}

Expand All @@ -86,8 +98,18 @@ class UpdateAvailableBadgeState extends material.State<UpdateAvailableBadge>
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;
}),
);
}
}

Expand Down
49 changes: 49 additions & 0 deletions test/features/updater/update_available_badge_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<UpdateAvailableBadgeState>(
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);
});
}
Loading