369ed800b9
Cold-start spinner was up to ~38s on slow / remote connections because VersionGate blocked the entire ShellRoute on /healthz, with the default dio's 8s connect + 30s receive timeouts. The /healthz server handler itself is fine (microsecond JSON encode); the blocker was client-side. Three issues fixed in one pass: 1. Optimistic render. VersionGate becomes a ConsumerStatefulWidget that always renders its child and just activates the version check controller on mount. The "you're too old" experience moves from a full-screen hard-block (_TooOldScreen, deleted) to a soft banner above the AppBar that lets the user keep playing cached content while they update. 2. 1h-throttled background check. New VersionCheckController (AsyncNotifier) hydrates from a secure-storage cache on boot, returning the cached result instantly. If the cache is missing or >1h old, fires a background recheck. AppLifecycleState.resumed triggers recheckIfStale so foregrounding after >1h re-checks without per-frame hammering. "Check now" button on the banner bypasses the staleness gate so dev iteration (push new APK, want to see banner clear) doesn't wait an hour. 3. Bounded health-check dio. The /healthz request uses a dedicated dio with connectTimeout: 3s + receiveTimeout: 2s rather than the default 8s / 30s. Health probes should fail fast — if the server can't ack in 5s, the user has bigger problems than a stale min_client_version and the cached value remains in effect. Cache keys live alongside the existing tz cadence cache in flutter_secure_storage (kResult + kAtMs). On any network error or parse failure, _runCheck soft-fails without bumping the timestamp, so the next staleness check will retry. VersionTooOldBanner renders in _ShellWithPlayerBar's Column above the existing UpdateBanner — the two coexist when both apply (server rejects you AND an APK is queued). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
225 lines
8.1 KiB
Dart
225 lines
8.1 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
import 'package:pub_semver/pub_semver.dart';
|
|
|
|
import '../../api/client.dart';
|
|
import '../../api/endpoints/health.dart';
|
|
import '../../auth/auth_provider.dart';
|
|
import '../../theme/theme_extension.dart';
|
|
|
|
/// Result of the most recent /healthz version compatibility check.
|
|
///
|
|
/// `skipped` means the server didn't emit a `min_client_version` field
|
|
/// (older servers, partial deploys). Treat as compatible.
|
|
enum VersionResult { ok, tooOld, skipped }
|
|
|
|
VersionResult _resultFromString(String? s) {
|
|
switch (s) {
|
|
case 'ok':
|
|
return VersionResult.ok;
|
|
case 'tooOld':
|
|
return VersionResult.tooOld;
|
|
default:
|
|
return VersionResult.skipped;
|
|
}
|
|
}
|
|
|
|
String _stringFromResult(VersionResult r) {
|
|
switch (r) {
|
|
case VersionResult.ok:
|
|
return 'ok';
|
|
case VersionResult.tooOld:
|
|
return 'tooOld';
|
|
case VersionResult.skipped:
|
|
return 'skipped';
|
|
}
|
|
}
|
|
|
|
/// Drives the version-compatibility check against /healthz. Non-blocking:
|
|
/// the controller hydrates from a 1h-throttled cache on boot, exposes
|
|
/// the current `VersionResult`, and refreshes itself in the background.
|
|
/// UI (VersionGate + _VersionTooOldBanner) reads this state to decide
|
|
/// whether to surface the "too old" banner — never blocks rendering.
|
|
///
|
|
/// Cache keys live in flutter_secure_storage so the cadence survives
|
|
/// app restarts. 1h throttle bounds /healthz traffic; explicit
|
|
/// `recheck()` (from the banner's "Check now" button or app resume)
|
|
/// bypasses the throttle.
|
|
class VersionCheckController extends AsyncNotifier<VersionResult> {
|
|
static const _kResult = 'version_check_result';
|
|
static const _kAtMs = 'version_check_at_ms';
|
|
static const _staleMs = 60 * 60 * 1000; // 1h
|
|
|
|
@override
|
|
Future<VersionResult> build() async {
|
|
final storage = ref.read(secureStorageProvider);
|
|
final resultStr = await storage.read(key: _kResult);
|
|
final atStr = await storage.read(key: _kAtMs);
|
|
final cached = _resultFromString(resultStr);
|
|
final at = int.tryParse(atStr ?? '') ?? 0;
|
|
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
|
|
|
// Fire background recheck on cold-start when cache is missing or stale.
|
|
// Doesn't block: build returns the cached result immediately; the
|
|
// recheck updates state asynchronously when it lands.
|
|
if (nowMs - at > _staleMs) {
|
|
Future.microtask(_runCheck);
|
|
}
|
|
return cached;
|
|
}
|
|
|
|
/// Force a fresh check, bypassing the 1h staleness gate. Used by the
|
|
/// "Check now" banner button and (indirectly) by the AppLifecycleState
|
|
/// resume observer in VersionGate.
|
|
Future<void> recheck() async => _runCheck();
|
|
|
|
/// Recheck only if the cache is older than the 1h throttle. No-op
|
|
/// when fresh. Used on app resume to avoid hammering /healthz when
|
|
/// the user is briefly switching between apps.
|
|
Future<void> recheckIfStale() async {
|
|
final storage = ref.read(secureStorageProvider);
|
|
final atStr = await storage.read(key: _kAtMs);
|
|
final at = int.tryParse(atStr ?? '') ?? 0;
|
|
if (DateTime.now().millisecondsSinceEpoch - at > _staleMs) {
|
|
await _runCheck();
|
|
}
|
|
}
|
|
|
|
Future<void> _runCheck() async {
|
|
try {
|
|
final url = await ref.read(serverUrlProvider.future);
|
|
if (url == null || url.isEmpty) return;
|
|
// Bounded timeouts for a health probe — the default
|
|
// ApiClient.buildDio dio is tuned for actual data fetches
|
|
// (8s connect + 30s receive). /healthz should resolve in
|
|
// tens of milliseconds; failing fast unblocks slow networks.
|
|
final dio = ApiClient.buildDio(
|
|
baseUrl: url,
|
|
tokenResolver: () async => null,
|
|
);
|
|
dio.options.connectTimeout = const Duration(seconds: 3);
|
|
dio.options.receiveTimeout = const Duration(seconds: 2);
|
|
final body = await HealthApi(dio).check();
|
|
final min = body['min_client_version'];
|
|
VersionResult result;
|
|
if (min == null || min.isEmpty) {
|
|
result = VersionResult.skipped;
|
|
} else {
|
|
final info = await PackageInfo.fromPlatform();
|
|
final mine = Version.parse(info.version);
|
|
final required = Version.parse(min);
|
|
result = mine < required ? VersionResult.tooOld : VersionResult.ok;
|
|
}
|
|
// Persist + flip state. Storage write before state assignment so
|
|
// a hot reload immediately after won't see a fresh state with a
|
|
// stale cache.
|
|
final storage = ref.read(secureStorageProvider);
|
|
await storage.write(key: _kResult, value: _stringFromResult(result));
|
|
await storage.write(
|
|
key: _kAtMs,
|
|
value: DateTime.now().millisecondsSinceEpoch.toString(),
|
|
);
|
|
state = AsyncData(result);
|
|
} on DioException catch (_) {
|
|
// Network error / timeout. Keep the cached state; don't bump the
|
|
// timestamp so the next staleness check still triggers a retry.
|
|
} catch (_) {
|
|
// Other errors (PackageInfo, parsing, etc.). Same handling —
|
|
// soft-fail so the UI never sees an error state from a defensive
|
|
// background check.
|
|
}
|
|
}
|
|
}
|
|
|
|
final versionCheckProvider =
|
|
AsyncNotifierProvider<VersionCheckController, VersionResult>(
|
|
VersionCheckController.new,
|
|
);
|
|
|
|
/// Wraps the shell. Non-blocking: always renders the child. Activates
|
|
/// the version check controller on mount and reruns it on app resume
|
|
/// (gated by the controller's 1h staleness throttle).
|
|
class VersionGate extends ConsumerStatefulWidget {
|
|
const VersionGate({required this.child, super.key});
|
|
final Widget child;
|
|
|
|
@override
|
|
ConsumerState<VersionGate> createState() => _VersionGateState();
|
|
}
|
|
|
|
class _VersionGateState extends ConsumerState<VersionGate>
|
|
with WidgetsBindingObserver {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Read once so the provider's build() runs; the controller
|
|
// hydrates from cache + fires a background check when stale.
|
|
ref.read(versionCheckProvider);
|
|
WidgetsBinding.instance.addObserver(this);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
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.
|
|
// ignore: unawaited_futures
|
|
ref.read(versionCheckProvider.notifier).recheckIfStale();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => widget.child;
|
|
}
|
|
|
|
/// Banner that surfaces when the server reports our client is too old.
|
|
/// Non-blocking: the rest of the app keeps working (offline-mode-style
|
|
/// — locally cached metadata + audio still play). Tap "Check now" to
|
|
/// force a re-check after installing a new build.
|
|
class VersionTooOldBanner extends ConsumerWidget {
|
|
const VersionTooOldBanner({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final asyncResult = ref.watch(versionCheckProvider);
|
|
final result = asyncResult.asData?.value;
|
|
if (result != VersionResult.tooOld) return const SizedBox.shrink();
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
return SafeArea(
|
|
bottom: false,
|
|
child: Container(
|
|
color: fs.error.withValues(alpha: 0.15),
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
child: Row(children: [
|
|
Icon(Icons.warning_amber_rounded, color: fs.error, size: 18),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
"This app is older than the server requires. "
|
|
"You can keep using cached content; install an update when ready.",
|
|
style: TextStyle(color: fs.parchment, fontSize: 13),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () =>
|
|
ref.read(versionCheckProvider.notifier).recheck(),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: fs.parchment,
|
|
minimumSize: const Size(0, 36),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
),
|
|
child: const Text('Check now'),
|
|
),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|