No need update check for web

This commit is contained in:
Marc Rejohn Castillano 2026-03-12 20:38:19 +08:00
parent 0bd2a94ece
commit 9267ebee2c
5 changed files with 83 additions and 31 deletions

View File

@ -2,13 +2,14 @@
/// `AppUpdateService` fetches the most recent entry and compares it against the /// `AppUpdateService` fetches the most recent entry and compares it against the
/// running application's build number. /// running application's build number.
class AppVersion { class AppVersion {
/// Incrementing integer that matches the Android `versionCode`. /// Version string, e.g. `1.2.3` or `2026.03.12`. Stored as text in
final int versionCode; /// the database so semantic versions are allowed.
final String versionCode;
/// If the device is running a build number that is *strictly less* than this /// If the device is running a build number that is *strictly less* than this
/// value the update is considered "forced" and the user cannot continue /// value the update is considered "forced" and the user cannot continue
/// using the existing install. /// using the existing install. Also a string.
final int minVersionRequired; final String minVersionRequired;
/// A publiclyaccessible URL pointing at an APK stored in Supabase Storage. /// A publiclyaccessible URL pointing at an APK stored in Supabase Storage.
final String downloadUrl; final String downloadUrl;
@ -25,8 +26,8 @@ class AppVersion {
factory AppVersion.fromMap(Map<String, dynamic> map) { factory AppVersion.fromMap(Map<String, dynamic> map) {
return AppVersion( return AppVersion(
versionCode: map['version_code'] as int, versionCode: map['version_code']?.toString() ?? '',
minVersionRequired: map['min_version_required'] as int, minVersionRequired: map['min_version_required']?.toString() ?? '',
downloadUrl: map['download_url'] as String, downloadUrl: map['download_url'] as String,
releaseNotes: map['release_notes'] as String? ?? '', releaseNotes: map['release_notes'] as String? ?? '',
); );
@ -40,8 +41,9 @@ class AppUpdateInfo {
/// table was empty (should not happen in normal operation). /// table was empty (should not happen in normal operation).
final AppVersion? latestVersion; final AppVersion? latestVersion;
/// Current build number as reported by ``package_info_plus``. /// Current build number / version string. May be empty on non-Android
final int currentBuildNumber; /// platforms since checkForUpdate is skipped there.
final String currentBuildNumber;
/// ``true`` when ``currentBuildNumber < latestVersion.versionCode``. /// ``true`` when ``currentBuildNumber < latestVersion.versionCode``.
final bool isUpdateAvailable; final bool isUpdateAvailable;

View File

@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
@ -41,16 +42,40 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
Future<void> _loadCurrent() async { Future<void> _loadCurrent() async {
try { try {
final client = Supabase.instance.client; final client = Supabase.instance.client;
final data = await client final rows = await client.from('app_versions').select().maybeSingle();
.from('app_versions') // when using text versions we can't rely on server-side ordering; instead
.select() // parse locally and choose the greatest semantic version.
.order('version_code', ascending: false) if (rows is List) {
.limit(1) final rowList = rows as List?;
.maybeSingle(); Version? best;
if (data is Map<String, dynamic>) { Map<String, dynamic>? bestRow;
_versionController.text = data['version_code']?.toString() ?? ''; if (rowList != null) {
_minController.text = data['min_version_required']?.toString() ?? ''; for (final r in rowList) {
_notesController.text = data['release_notes'] ?? ''; if (r is Map<String, dynamic>) {
final v = r['version_code']?.toString() ?? '';
Version parsed;
try {
parsed = Version.parse(v);
} catch (_) {
continue;
}
if (best == null || parsed > best) {
best = parsed;
bestRow = r;
}
}
}
}
if (bestRow != null) {
_versionController.text = bestRow['version_code']?.toString() ?? '';
_minController.text =
bestRow['min_version_required']?.toString() ?? '';
_notesController.text = bestRow['release_notes'] ?? '';
}
} else if (rows is Map<String, dynamic>) {
_versionController.text = rows['version_code']?.toString() ?? '';
_minController.text = rows['min_version_required']?.toString() ?? '';
_notesController.text = rows['release_notes'] ?? '';
} }
} catch (_) {} } catch (_) {}
} }
@ -169,10 +194,11 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
} else { } else {
url = ''; url = '';
} }
if (url.isEmpty) if (url.isEmpty) {
throw Exception( throw Exception(
'could not obtain public url, check bucket CORS and policies', 'could not obtain public url, check bucket CORS and policies',
); );
}
_logs.add('Public URL: $url'); _logs.add('Public URL: $url');
// upsert new version in a single statement // upsert new version in a single statement
await client.from('app_versions').upsert({ await client.from('app_versions').upsert({

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:ota_update/ota_update.dart'; import 'package:ota_update/ota_update.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
@ -48,26 +49,48 @@ class AppUpdateService {
/// (network down, no supabase instance) are simply rethrown; the caller can /// (network down, no supabase instance) are simply rethrown; the caller can
/// decide whether to ignore them or surface them to the user. /// decide whether to ignore them or surface them to the user.
Future<AppUpdateInfo> checkForUpdate() async { Future<AppUpdateInfo> checkForUpdate() async {
final pkg = await PackageInfo.fromPlatform(); // only run on Android devices; web and other platforms skip
final currentBuild = int.tryParse(pkg.buildNumber) ?? 0; if (!Platform.isAndroid) {
final serverVersion = await _fetchLatestVersion();
if (serverVersion == null) {
// table empty nothing to do
return AppUpdateInfo( return AppUpdateInfo(
currentBuildNumber: currentBuild, currentBuildNumber: '',
latestVersion: null, latestVersion: null,
isUpdateAvailable: false, isUpdateAvailable: false,
isForceUpdate: false, isForceUpdate: false,
); );
} }
final available = currentBuild < serverVersion.versionCode; final pkg = await PackageInfo.fromPlatform();
final force = currentBuild < serverVersion.minVersionRequired; final currentVersion = pkg.buildNumber.trim();
final serverVersion = await _fetchLatestVersion();
if (serverVersion == null) {
return AppUpdateInfo(
currentBuildNumber: currentVersion,
latestVersion: null,
isUpdateAvailable: false,
isForceUpdate: false,
);
}
// compare using semantic versioning where possible
Version safeParse(String s) {
try {
return Version.parse(s);
} catch (_) {
return Version(0, 0, 0);
}
}
final vCurrent = safeParse(currentVersion);
final vLatest = safeParse(serverVersion.versionCode);
final vMin = safeParse(serverVersion.minVersionRequired);
final available = vCurrent < vLatest;
final force = vCurrent < vMin;
return AppUpdateInfo( return AppUpdateInfo(
currentBuildNumber: currentBuild, currentBuildNumber: currentVersion,
latestVersion: serverVersion, latestVersion: serverVersion,
isUpdateAvailable: available, isUpdateAvailable: available,
isForceUpdate: force, isForceUpdate: force,

View File

@ -1438,7 +1438,7 @@ packages:
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
pub_semver: pub_semver:
dependency: transitive dependency: "direct main"
description: description:
name: pub_semver name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"

View File

@ -52,6 +52,7 @@ dependencies:
flutter_image_compress: ^2.4.0 flutter_image_compress: ^2.4.0
dio: ^5.1.2 dio: ^5.1.2
package_info_plus: ^9.0.0 package_info_plus: ^9.0.0
pub_semver: ^2.1.1
ota_update: ^7.1.0 ota_update: ^7.1.0
dev_dependencies: dev_dependencies: