diff --git a/flutter_client/lib/shared/widgets/version_gate.dart b/flutter_client/lib/shared/widgets/version_gate.dart index e2ac1119..2d001910 100644 --- a/flutter_client/lib/shared/widgets/version_gate.dart +++ b/flutter_client/lib/shared/widgets/version_gate.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -50,7 +52,12 @@ String _stringFromResult(VersionResult r) { class VersionCheckController extends AsyncNotifier { static const _kResult = 'version_check_result'; static const _kAtMs = 'version_check_at_ms'; - static const _staleMs = 60 * 60 * 1000; // 1h + // 1 minute. The /healthz response is sub-1KB and the call is + // non-blocking, so a tight cadence buys faster recovery from + // server-side min_client_version bumps without measurable cost. The + // gate also acts as a safety net against duplicate calls when the + // periodic timer and a resume event fire close together. + static const _staleMs = 60 * 1000; @override Future build() async { @@ -151,6 +158,13 @@ class VersionGate extends ConsumerStatefulWidget { class _VersionGateState extends ConsumerState with WidgetsBindingObserver { + // Polling interval during active foreground use. Paired with the + // controller's 1m staleness gate so concurrent fires (timer + resume) + // dedupe to a single network call. Tight cadence is affordable — + // /healthz is sub-1KB and the call is non-blocking. + static const _pollInterval = Duration(minutes: 1); + Timer? _pollTimer; + @override void initState() { super.initState(); @@ -158,20 +172,46 @@ class _VersionGateState extends ConsumerState // hydrates from cache + fires a background check when stale. ref.read(versionCheckProvider); WidgetsBinding.instance.addObserver(this); + _startPolling(); } @override void dispose() { + _stopPolling(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) { - // Staleness-gated: cheap when the user just briefly left the app. + void _startPolling() { + _pollTimer?.cancel(); + _pollTimer = Timer.periodic(_pollInterval, (_) { // ignore: unawaited_futures ref.read(versionCheckProvider.notifier).recheckIfStale(); + }); + } + + void _stopPolling() { + _pollTimer?.cancel(); + _pollTimer = null; + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + // Re-arm the timer + fire one immediate (staleness-gated) check + // so users who foreground after a long away period get a fresh + // result without waiting for the next tick. + // ignore: unawaited_futures + ref.read(versionCheckProvider.notifier).recheckIfStale(); + _startPolling(); + case AppLifecycleState.paused: + case AppLifecycleState.inactive: + case AppLifecycleState.hidden: + case AppLifecycleState.detached: + // Stop firing while backgrounded — no need to burn battery on + // health probes the user can't see the result of. + _stopPolling(); } }