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
59 changes: 49 additions & 10 deletions lib/features/updater/update_available_badge.dart
Original file line number Diff line number Diff line change
@@ -1,47 +1,82 @@
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});

final UpdateController controller;

@override
material.State<UpdateAvailableBadge> createState() =>
_UpdateAvailableBadgeState();
UpdateAvailableBadgeState createState() => UpdateAvailableBadgeState();
}

class _UpdateAvailableBadgeState extends material.State<UpdateAvailableBadge>
class UpdateAvailableBadgeState extends material.State<UpdateAvailableBadge>
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);
if (oldWidget.controller != widget.controller) {
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
Expand All @@ -59,6 +94,8 @@ class _UpdateAvailableBadgeState extends material.State<UpdateAvailableBadge>

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),
Expand All @@ -76,13 +113,15 @@ class _UpdateAvailableBadgeState extends material.State<UpdateAvailableBadge>
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,
Expand Down
92 changes: 92 additions & 0 deletions test/features/updater/update_available_badge_test.dart
Original file line number Diff line number Diff line change
@@ -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<UpdateAvailableBadgeState>(
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<UpdateAvailableBadgeState>(
find.byType(UpdateAvailableBadge),
);
expect(find.textContaining('v9.9.9 available'), findsOneWidget);
expect(state.isPulseAnimating, isTrue);

controller.setPendingUpdate(null);
await tester.pump();
state = tester.state<UpdateAvailableBadgeState>(
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<UpdateAvailableBadgeState>(
find.byType(UpdateAvailableBadge),
);
expect(find.textContaining('v1.0.0 available'), findsOneWidget);
expect(state.isPulseAnimating, isFalse);
});
}
Loading