Skip to content

Commit

Permalink
Simplify countdown clock
Browse files Browse the repository at this point in the history
  • Loading branch information
veloce committed Nov 14, 2024
1 parent 35bd609 commit 5874914
Show file tree
Hide file tree
Showing 2 changed files with 12 additions and 154 deletions.
64 changes: 6 additions & 58 deletions lib/src/widgets/countdown_clock.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import 'dart:async';

import 'package:clock/clock.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lichess_mobile/src/constants.dart';
import 'package:lichess_mobile/src/utils/screen.dart';

Expand All @@ -12,12 +11,8 @@ import 'package:lichess_mobile/src/utils/screen.dart';
class CountdownClock extends StatefulWidget {
const CountdownClock({
required this.timeLeft,
this.delay,
this.clockUpdatedAt,
required this.active,
this.emergencyThreshold,
this.onEmergency,
this.onFlag,
this.clockStyle,
this.padLeft = false,
super.key,
Expand All @@ -26,31 +21,15 @@ class CountdownClock extends StatefulWidget {
/// The duration left on the clock.
final Duration timeLeft;

/// The delay before the clock starts counting down.
///
/// This can be used to implement lag compensation.
final Duration? delay;

/// The time at which the clock was updated.
///
/// Use this parameter to synchronize the clock with the time at which the clock
/// event was received from the server and to compensate for UI lag.
final DateTime? clockUpdatedAt;

/// The duration at which the clock should change its background color to indicate an emergency.
///
/// If [onEmergency] is provided, the clock will call it when the emergency threshold is reached.
final Duration? emergencyThreshold;

/// Called when the clock reaches the emergency.
final VoidCallback? onEmergency;

/// If [active] is `true`, the clock starts counting down.
final bool active;

/// Callback when the clock reaches zero.
final VoidCallback? onFlag;

/// Custom color style
final ClockStyle? clockStyle;

Expand All @@ -61,58 +40,43 @@ class CountdownClock extends StatefulWidget {
State<CountdownClock> createState() => _CountdownClockState();
}

const _emergencyDelay = Duration(seconds: 20);
const _tickDelay = Duration(milliseconds: 100);
const _showTenthsThreshold = Duration(seconds: 10);

class _CountdownClockState extends State<CountdownClock> {
Timer? _timer;
Duration timeLeft = Duration.zero;
bool _shouldPlayEmergencyFeedback = true;
DateTime? _nextEmergency;

final _stopwatch = clock.stopwatch();

void startClock() {
final now = clock.now();
final delay = widget.delay ?? Duration.zero;
final clockUpdatedAt = widget.clockUpdatedAt ?? now;
// UI lag diff: the elapsed time between the time the clock should have started
// and the time the clock is actually started
final uiLag = now.difference(clockUpdatedAt);
final realDelay = delay - uiLag;

// real delay is negative, we need to adjust the timeLeft.
if (realDelay < Duration.zero) {
timeLeft = timeLeft + realDelay;
}
timeLeft = timeLeft - uiLag;

_scheduleTick(realDelay);
_scheduleTick();
}

void _scheduleTick(Duration extraDelay) {
void _scheduleTick() {
_timer?.cancel();
final delay = Duration(
milliseconds: (timeLeft < _showTenthsThreshold
? timeLeft.inMilliseconds % 100
: timeLeft.inMilliseconds % 500) +
1,
);
_timer = Timer(delay + extraDelay, _tick);
_timer = Timer(_tickDelay, _tick);
_stopwatch.reset();
_stopwatch.start();
}

void _tick() {
setState(() {
timeLeft = timeLeft - _stopwatch.elapsed;
_playEmergencyFeedback();
if (timeLeft <= Duration.zero) {
widget.onFlag?.call();
timeLeft = Duration.zero;
}
});
if (timeLeft > Duration.zero) {
_scheduleTick(Duration.zero);
_scheduleTick();
}
}

Expand All @@ -121,21 +85,6 @@ class _CountdownClockState extends State<CountdownClock> {
_stopwatch.stop();
}

void _playEmergencyFeedback() {
if (widget.emergencyThreshold != null &&
timeLeft <= widget.emergencyThreshold! &&
_shouldPlayEmergencyFeedback &&
(_nextEmergency == null || _nextEmergency!.isBefore(clock.now()))) {
_shouldPlayEmergencyFeedback = false;
_nextEmergency = clock.now().add(_emergencyDelay);
widget.onEmergency?.call();
HapticFeedback.heavyImpact();
} else if (widget.emergencyThreshold != null &&
timeLeft > widget.emergencyThreshold! * 1.5) {
_shouldPlayEmergencyFeedback = true;
}
}

@override
void initState() {
super.initState();
Expand Down Expand Up @@ -175,7 +124,6 @@ class _CountdownClockState extends State<CountdownClock> {
padLeft: widget.padLeft,
timeLeft: timeLeft,
active: widget.active,
emergencyThreshold: widget.emergencyThreshold,
clockStyle: widget.clockStyle,
),
);
Expand Down
102 changes: 6 additions & 96 deletions test/widgets/countdown_clock_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,8 @@ void main() {
);

expect(find.text('0:01.0', findRichText: true), findsOneWidget);
await tester.pump(const Duration(milliseconds: 10));
expect(find.text('0:00.9', findRichText: true), findsOneWidget);
await tester.pump(const Duration(milliseconds: 10));
expect(find.text('0:00.9', findRichText: true), findsOneWidget);
await tester.pump(const Duration(milliseconds: 900));
expect(find.text('0:00.1', findRichText: true), findsOneWidget);

await tester.pumpWidget(
MaterialApp(
Expand Down Expand Up @@ -172,108 +170,20 @@ void main() {
expect(find.text('0:09.7', findRichText: true), findsOneWidget);
});

testWidgets('starts with a delay if set', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: CountdownClock(
timeLeft: Duration(seconds: 10),
active: true,
delay: Duration(milliseconds: 25),
),
),
);
expect(find.text('0:10', findRichText: true), findsOneWidget);
await tester.pump(const Duration(milliseconds: 25));
expect(find.text('0:10', findRichText: true), findsOneWidget);
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('0:09.9', findRichText: true), findsOneWidget);
});

testWidgets('compensates for UI lag', (WidgetTester tester) async {
testWidgets('UI lag compensation', (WidgetTester tester) async {
final now = clock.now();
await tester.pump(const Duration(milliseconds: 10));
await tester.pump(const Duration(milliseconds: 200));

await tester.pumpWidget(
MaterialApp(
home: CountdownClock(
timeLeft: const Duration(seconds: 10),
active: true,
delay: const Duration(milliseconds: 20),
clockUpdatedAt: now,
),
),
);
expect(find.text('0:10', findRichText: true), findsOneWidget);

await tester.pump(const Duration(milliseconds: 10));
expect(find.text('0:10', findRichText: true), findsOneWidget);

// delay was 20m but UI lagged 10ms so with the compensation the clock has started already
await tester.pump(const Duration(milliseconds: 10));
expect(find.text('0:09.9', findRichText: true), findsOneWidget);
});

testWidgets('UI lag negative start delay', (WidgetTester tester) async {
final now = clock.now();
await tester.pump(const Duration(milliseconds: 20));

await tester.pumpWidget(
MaterialApp(
home: CountdownClock(
timeLeft: const Duration(seconds: 10),
active: true,
delay: const Duration(milliseconds: 10),
clockUpdatedAt: now,
),
),
);
// delay was 10ms but UI lagged 20ms so the clock time is already 10ms ahead
expect(find.text('0:09.9', findRichText: true), findsOneWidget);
});

testWidgets('should call onFlag', (WidgetTester tester) async {
int flagCount = 0;
await tester.pumpWidget(
MaterialApp(
home: CountdownClock(
timeLeft: const Duration(seconds: 10),
active: true,
onFlag: () {
flagCount++;
},
),
),
);
expect(find.text('0:10', findRichText: true), findsOneWidget);
await tester.pump(const Duration(seconds: 11));
expect(flagCount, 1);
expect(find.text('0:00.0', findRichText: true), findsOneWidget);
});

testWidgets('emergency feedback', (WidgetTester tester) async {
int onEmergencyCount = 0;
await tester.pumpWidget(
MaterialApp(
home: CountdownClock(
timeLeft: const Duration(seconds: 10),
active: true,
emergencyThreshold: const Duration(seconds: 5),
onEmergency: () {
onEmergencyCount++;
},
),
),
);

expect(find.text('0:10', findRichText: true), findsOneWidget);
await tester.pump(const Duration(seconds: 5));
expect(find.text('0:05.0', findRichText: true), findsOneWidget);
expect(onEmergencyCount, 0);
await tester.pump(const Duration(milliseconds: 1));
expect(onEmergencyCount, 1);
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('0:04.8', findRichText: true), findsOneWidget);
// emergency is only called once as long as the time is below the threshold
expect(onEmergencyCount, 1);
// UI lagged 200ms so the clock time is already 200ms ahead
expect(find.text('0:09.8', findRichText: true), findsOneWidget);
});
}

0 comments on commit 5874914

Please sign in to comment.