DEV3 Technologies home

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.

Related snippets