fix(flutter): tighten version-check cadence to 1m during active use
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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -50,7 +52,12 @@ String _stringFromResult(VersionResult r) {
|
|||||||
class VersionCheckController extends AsyncNotifier<VersionResult> {
|
class VersionCheckController extends AsyncNotifier<VersionResult> {
|
||||||
static const _kResult = 'version_check_result';
|
static const _kResult = 'version_check_result';
|
||||||
static const _kAtMs = 'version_check_at_ms';
|
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
|
@override
|
||||||
Future<VersionResult> build() async {
|
Future<VersionResult> build() async {
|
||||||
@@ -151,6 +158,13 @@ class VersionGate extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _VersionGateState extends ConsumerState<VersionGate>
|
class _VersionGateState extends ConsumerState<VersionGate>
|
||||||
with WidgetsBindingObserver {
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -158,20 +172,46 @@ class _VersionGateState extends ConsumerState<VersionGate>
|
|||||||
// hydrates from cache + fires a background check when stale.
|
// hydrates from cache + fires a background check when stale.
|
||||||
ref.read(versionCheckProvider);
|
ref.read(versionCheckProvider);
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
_startPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_stopPolling();
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
void _startPolling() {
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
_pollTimer?.cancel();
|
||||||
if (state == AppLifecycleState.resumed) {
|
_pollTimer = Timer.periodic(_pollInterval, (_) {
|
||||||
// Staleness-gated: cheap when the user just briefly left the app.
|
|
||||||
// ignore: unawaited_futures
|
// ignore: unawaited_futures
|
||||||
ref.read(versionCheckProvider.notifier).recheckIfStale();
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user