Auction countdown chip
Live countdown chip that ticks on second boundaries, corrects for server clock skew and changes colour as time runs out.
FlutterAnimationfluttercountdowntimerauction
Code
import 'dart:async';
import 'package:flutter/material.dart';
class CountdownChip extends StatefulWidget {
const CountdownChip({
super.key,
required this.endsAt,
this.serverOffset = Duration.zero,
this.onEnded,
});
final DateTime endsAt;
final Duration serverOffset; // serverNow - deviceNow, measured once per session
final VoidCallback? onEnded;
@override
State<CountdownChip> createState() => _CountdownChipState();
}
class _CountdownChipState extends State<CountdownChip> {
Timer? _timer;
late Duration _left = _remaining();
Duration _remaining() {
final left = widget.endsAt.difference(DateTime.now().add(widget.serverOffset));
return left.isNegative ? Duration.zero : left;
}
@override
void initState() {
super.initState();
_schedule();
}
@override
void didUpdateWidget(CountdownChip old) {
super.didUpdateWidget(old);
if (old.endsAt != widget.endsAt) {
_timer?.cancel();
_left = _remaining();
_schedule(); // a late bid extended the auction
}
}
void _schedule() {
if (_left == Duration.zero) return;
// Wake up just after the next whole second so the digits never lag.
final wait = Duration(milliseconds: 1000 - (_left.inMilliseconds % 1000) + 20);
_timer = Timer(wait, () {
if (!mounted) return;
setState(() => _left = _remaining());
if (_left == Duration.zero) {
widget.onEnded?.call();
} else {
_schedule();
}
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
String get _label {
if (_left == Duration.zero) return 'Ended';
final d = _left.inDays, h = _left.inHours % 24, m = _left.inMinutes % 60, s = _left.inSeconds % 60;
String two(int n) => n.toString().padLeft(2, '0');
return d > 0 ? '${d}d ${two(h)}h' : '${two(h)}:${two(m)}:${two(s)}';
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final urgent = _left > Duration.zero && _left < const Duration(minutes: 5);
final ended = _left == Duration.zero;
return Chip(
avatar: Icon(ended ? Icons.lock_clock : Icons.timer_outlined, size: 18,
color: urgent ? scheme.onErrorContainer : null),
label: Text(_label, style: const TextStyle(fontFeatures: [FontFeature.tabularFigures()])),
backgroundColor: urgent ? scheme.errorContainer : (ended ? scheme.surfaceContainerHighest : null),
);
}
}
How it works
What it solves
Auctions are won in the last seconds, so the countdown has to be accurate, must not drift and should signal urgency. A ticking timer based on the device clock alone is wrong whenever the phone's clock is off.
How it works
The chip never counts down by subtracting one second per tick, which accumulates error. Each wake-up recomputes the remaining time from endsAt minus the current time plus serverOffset, a value you measure once from a server response header. The next timer is scheduled for just after the next whole second, so digits change on time instead of lagging by a fraction.
If the parent passes a new endsAt, for example after an anti-sniping extension, didUpdateWidget restarts the schedule. Under five minutes the chip uses the error container colour; at zero it shows Ended and calls onEnded. Tabular figures stop the width from jumping.
Adapting it
Compute serverOffset as the parsed server date minus your local time at response arrival. Adjust the urgency threshold, or add a pulsing animation under thirty seconds. Replace Chip with a custom container for different looks.
Pitfalls
- Timers stop when the app is backgrounded; the next resume tick recalculates correctly because the value is derived, not accumulated.
- Do not close bidding in the client; the server decides when it ends.
- Many chips in one list each own a timer; share a single ticker for long feeds.
How to use it
Copy to lib/auction/. No packages.
CountdownChip(endsAt: lot.endsAt, serverOffset: clock.offset, onEnded: refreshLot)
Measure clock.offset from the HTTP Date header on a trusted request.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.