import 'dart:async'; 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 { static const _kResult = 'version_check_result'; static const _kAtMs = 'version_check_at_ms'; // 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 { 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 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 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 _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.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 createState() => _VersionGateState(); } 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(); // 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); _startPolling(); } @override void dispose() { _stopPolling(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } 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(); } } @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()!; 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'), ), ]), ), ); } }