From ae5de910064d42d640605ace8b2446ae6fb4bd25 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 13 May 2026 16:30:40 -0400 Subject: [PATCH] fix(flutter): tighten version-check cadence to 1m during active use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the staleness gate from 1h to 1m and adds a Timer.periodic that fires recheckIfStale every minute while the app is foregrounded. Net effect: ~1 check per minute of active use, ~60 KB/hr data — trivially affordable for the value of faster recovery when min_client_version bumps server-side. Timer is paired with the lifecycle observer: started in initState + on resume, stopped in dispose + on pause/inactive/hidden/detached so backgrounded apps don't burn battery on probes the user can't see. Staleness gate still wraps the call so concurrent triggers (timer + resume firing close together) dedupe to one network call. Manual "Check now" still bypasses the gate via recheck(). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/shared/widgets/version_gate.dart | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) 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(); } }