import 'dart:async'; import 'package:dio/dio.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 '../library/library_providers.dart' show dioProvider; import 'installer.dart'; import 'update_info.dart'; /// Tracks the bundled-server APK version vs. the installed app version. /// Polls /api/client/version on startup + every 24h. Returns null when /// no update is available (or the channel is unreachable / disabled). /// /// Server returns 404 when no APK is bundled (dev environments, pre-CI /// images) — we treat that as "no update channel" and stay silent. class ClientUpdateController extends AsyncNotifier { static const Duration _pollInterval = Duration(hours: 24); Timer? _pollTimer; @override Future build() async { ref.onDispose(() { _pollTimer?.cancel(); _pollTimer = null; }); _pollTimer ??= Timer.periodic(_pollInterval, (_) => ref.invalidateSelf()); return _check(); } Future _check() async { final dio = await ref.read(dioProvider.future); final Response> r; try { r = await dio.get>('/api/client/version'); } on DioException catch (e) { // 404 = no APK bundled; any other error = treat as silent. if (e.response?.statusCode == 404) return null; return null; } if (r.data == null) return null; final info = UpdateInfo.fromJson(r.data!); final installed = (await PackageInfo.fromPlatform()).version; if (!isVersionNewer(info.version, installed)) return null; return info; } } /// True when `serverVersion` is strictly newer than `installedVersion`. /// Both strings are normalized (leading 'v' stripped) and parsed as /// semver. On parse failure, falls back to string inequality (treats /// any difference as "newer" — operator can dismiss if wrong). /// /// Exposed for testing; the polling logic in ClientUpdateController /// is the only production caller. bool isVersionNewer(String serverVersion, String installedVersion) { final svr = serverVersion.replaceFirst(RegExp(r'^v'), ''); final ins = installedVersion.replaceFirst(RegExp(r'^v'), ''); try { return Version.parse(svr) > Version.parse(ins); } catch (_) { return svr != ins; } } final clientUpdateProvider = AsyncNotifierProvider( ClientUpdateController.new); /// In-memory dismissed-versions set. Keyed by version string so a /// later release's banner re-appears even if the operator dismissed /// the previous one. Not persisted — restart re-shows the banner, /// which is acceptable nudging for v1. class _DismissedVersionsNotifier extends Notifier> { @override Set build() => {}; void add(String version) { state = {...state, version}; } } final _dismissedVersionsProvider = NotifierProvider<_DismissedVersionsNotifier, Set>( _DismissedVersionsNotifier.new); /// True when the update banner should render: an UpdateInfo is /// available AND the operator hasn't dismissed this specific version. final shouldShowUpdateBannerProvider = Provider((ref) { final info = ref.watch(clientUpdateProvider).value; if (info == null) return null; final dismissed = ref.watch(_dismissedVersionsProvider); if (dismissed.contains(info.version)) return null; return info; }); /// Dismiss controller: marks the given version as dismissed so the /// banner stops showing for this session. final dismissUpdateProvider = Provider((ref) { return (version) => ref.read(_dismissedVersionsProvider.notifier).add(version); }); /// Installer provider — depends on dio so the install download uses /// the same authenticated client (though /api/client/apk is unauthed, /// reusing dio keeps configuration consistent). final updateInstallerProvider = FutureProvider((ref) async { return UpdateInstaller(await ref.watch(dioProvider.future)); });