OTA Update attempt
This commit is contained in:
parent
f8c79acbbc
commit
0bd2a94ece
|
|
@ -8,6 +8,7 @@
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||||
<!-- Required on Android 13+ to post notifications. Without this the system
|
<!-- Required on Android 13+ to post notifications. Without this the system
|
||||||
will automatically block notifications and the user cannot enable them
|
will automatically block notifications and the user cannot enable them
|
||||||
from settings. -->
|
from settings. -->
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ import 'utils/location_permission.dart';
|
||||||
import 'services/notification_service.dart';
|
import 'services/notification_service.dart';
|
||||||
import 'services/notification_bridge.dart';
|
import 'services/notification_bridge.dart';
|
||||||
import 'services/background_location_service.dart';
|
import 'services/background_location_service.dart';
|
||||||
|
import 'services/app_update_service.dart';
|
||||||
|
import 'widgets/update_dialog.dart';
|
||||||
|
import 'utils/navigation.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
@ -594,6 +597,23 @@ Future<void> main() async {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// perform update check once the first frame has rendered; errors are
|
||||||
|
// intentionally swallowed so a network outage doesn't block startup.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
try {
|
||||||
|
final info = await AppUpdateService.instance.checkForUpdate();
|
||||||
|
if (info.isUpdateAvailable) {
|
||||||
|
showDialog(
|
||||||
|
context: globalNavigatorKey.currentContext!,
|
||||||
|
barrierDismissible: !info.isForceUpdate,
|
||||||
|
builder: (_) => UpdateDialog(info: info),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('update check failed: $e');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Post-startup registration removed: token registration is handled
|
// Post-startup registration removed: token registration is handled
|
||||||
// centrally in the auth state change listener to avoid duplicate inserts.
|
// centrally in the auth state change listener to avoid duplicate inserts.
|
||||||
}
|
}
|
||||||
|
|
|
||||||
59
lib/models/app_version.dart
Normal file
59
lib/models/app_version.dart
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
/// Represents a row in the ``app_versions`` table on the server. The
|
||||||
|
/// `AppUpdateService` fetches the most recent entry and compares it against the
|
||||||
|
/// running application's build number.
|
||||||
|
class AppVersion {
|
||||||
|
/// Incrementing integer that matches the Android `versionCode`.
|
||||||
|
final int versionCode;
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// using the existing install.
|
||||||
|
final int minVersionRequired;
|
||||||
|
|
||||||
|
/// A publicly‑accessible URL pointing at an APK stored in Supabase Storage.
|
||||||
|
final String downloadUrl;
|
||||||
|
|
||||||
|
/// Markdown/plaintext release notes that will be shown in the dialog.
|
||||||
|
final String releaseNotes;
|
||||||
|
|
||||||
|
AppVersion({
|
||||||
|
required this.versionCode,
|
||||||
|
required this.minVersionRequired,
|
||||||
|
required this.downloadUrl,
|
||||||
|
required this.releaseNotes,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AppVersion.fromMap(Map<String, dynamic> map) {
|
||||||
|
return AppVersion(
|
||||||
|
versionCode: map['version_code'] as int,
|
||||||
|
minVersionRequired: map['min_version_required'] as int,
|
||||||
|
downloadUrl: map['download_url'] as String,
|
||||||
|
releaseNotes: map['release_notes'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper type returned by ``AppUpdateService.checkForUpdate`` so callers can
|
||||||
|
/// decide how to present the UI.
|
||||||
|
class AppUpdateInfo {
|
||||||
|
/// The version that was returned from ``app_versions``. ``null`` when the
|
||||||
|
/// table was empty (should not happen in normal operation).
|
||||||
|
final AppVersion? latestVersion;
|
||||||
|
|
||||||
|
/// Current build number as reported by ``package_info_plus``.
|
||||||
|
final int currentBuildNumber;
|
||||||
|
|
||||||
|
/// ``true`` when ``currentBuildNumber < latestVersion.versionCode``.
|
||||||
|
final bool isUpdateAvailable;
|
||||||
|
|
||||||
|
/// ``true`` when ``currentBuildNumber < latestVersion.minVersionRequired``
|
||||||
|
/// (regardless of whether there is a newer release available).
|
||||||
|
final bool isForceUpdate;
|
||||||
|
|
||||||
|
AppUpdateInfo({
|
||||||
|
required this.currentBuildNumber,
|
||||||
|
required this.latestVersion,
|
||||||
|
required this.isUpdateAvailable,
|
||||||
|
required this.isForceUpdate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import '../screens/auth/signup_screen.dart';
|
||||||
import '../screens/admin/offices_screen.dart';
|
import '../screens/admin/offices_screen.dart';
|
||||||
import '../screens/admin/user_management_screen.dart';
|
import '../screens/admin/user_management_screen.dart';
|
||||||
import '../screens/admin/geofence_test_screen.dart';
|
import '../screens/admin/geofence_test_screen.dart';
|
||||||
|
import '../screens/admin/app_update_screen.dart';
|
||||||
import '../screens/dashboard/dashboard_screen.dart';
|
import '../screens/dashboard/dashboard_screen.dart';
|
||||||
import '../screens/notifications/notifications_screen.dart';
|
import '../screens/notifications/notifications_screen.dart';
|
||||||
import '../screens/profile/profile_screen.dart';
|
import '../screens/profile/profile_screen.dart';
|
||||||
|
|
@ -31,11 +32,14 @@ import '../screens/it_service_requests/it_service_requests_list_screen.dart';
|
||||||
import '../screens/it_service_requests/it_service_request_detail_screen.dart';
|
import '../screens/it_service_requests/it_service_request_detail_screen.dart';
|
||||||
import '../theme/m3_motion.dart';
|
import '../theme/m3_motion.dart';
|
||||||
|
|
||||||
|
import '../utils/navigation.dart';
|
||||||
|
|
||||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
final notifier = RouterNotifier(ref);
|
final notifier = RouterNotifier(ref);
|
||||||
ref.onDispose(notifier.dispose);
|
ref.onDispose(notifier.dispose);
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
|
navigatorKey: globalNavigatorKey,
|
||||||
initialLocation: '/dashboard',
|
initialLocation: '/dashboard',
|
||||||
refreshListenable: notifier,
|
refreshListenable: notifier,
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
|
|
@ -104,6 +108,13 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
child: const TeamsScreen(),
|
child: const TeamsScreen(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/settings/app-update',
|
||||||
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: const AppUpdateScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||||
|
|
|
||||||
284
lib/screens/admin/app_update_screen.dart
Normal file
284
lib/screens/admin/app_update_screen.dart
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
|
/// A simple admin-only web page allowing the upload of a new APK and the
|
||||||
|
/// associated metadata. After the APK is uploaded to the "apk_updates"
|
||||||
|
/// bucket the `app_versions` table is updated and any older rows are removed
|
||||||
|
/// so that only the current entry remains.
|
||||||
|
class AppUpdateScreen extends ConsumerStatefulWidget {
|
||||||
|
const AppUpdateScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AppUpdateScreen> createState() => _AppUpdateScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _versionController = TextEditingController();
|
||||||
|
final _minController = TextEditingController();
|
||||||
|
final _notesController = TextEditingController();
|
||||||
|
|
||||||
|
Uint8List? _apkBytes;
|
||||||
|
String? _apkName;
|
||||||
|
bool _isUploading = false;
|
||||||
|
double _progress = 0.0; // 0..1
|
||||||
|
String? _eta;
|
||||||
|
final List<String> _logs = [];
|
||||||
|
Timer? _progressTimer;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadCurrent() async {
|
||||||
|
try {
|
||||||
|
final client = Supabase.instance.client;
|
||||||
|
final data = await client
|
||||||
|
.from('app_versions')
|
||||||
|
.select()
|
||||||
|
.order('version_code', ascending: false)
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
_versionController.text = data['version_code']?.toString() ?? '';
|
||||||
|
_minController.text = data['min_version_required']?.toString() ?? '';
|
||||||
|
_notesController.text = data['release_notes'] ?? '';
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_versionController.dispose();
|
||||||
|
_minController.dispose();
|
||||||
|
_notesController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickApk() async {
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['apk'],
|
||||||
|
);
|
||||||
|
if (result != null && result.files.single.bytes != null) {
|
||||||
|
setState(() {
|
||||||
|
_apkBytes = result.files.single.bytes;
|
||||||
|
_apkName = result.files.single.name;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
if (_apkBytes == null || _apkName == null) {
|
||||||
|
setState(() => _error = 'Please select an APK file.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final vcode = _versionController.text.trim();
|
||||||
|
final minReq = _minController.text.trim();
|
||||||
|
final notes = _notesController.text;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isUploading = true;
|
||||||
|
_progress = 0.0;
|
||||||
|
_eta = null;
|
||||||
|
_logs.clear();
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final client = Supabase.instance.client;
|
||||||
|
// ensure the user is authenticated (browser uploads require correct auth/CORS)
|
||||||
|
final user = client.auth.currentUser;
|
||||||
|
_logs.add('Current user: ${user?.id ?? 'anonymous'}');
|
||||||
|
if (user == null) {
|
||||||
|
throw Exception('Not signed in. Please sign in to perform uploads.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a deterministic object name to avoid accidental nesting
|
||||||
|
final filename = '${DateTime.now().millisecondsSinceEpoch}_$_apkName';
|
||||||
|
final path = filename;
|
||||||
|
_logs.add('Starting upload to bucket apk_updates, path: $path');
|
||||||
|
final stopwatch = Stopwatch()..start();
|
||||||
|
// we cannot track real progress from the SDK, so fake a linear update
|
||||||
|
final estimatedSeconds = (_apkBytes!.length / 250000).clamp(1, 30);
|
||||||
|
_progressTimer = Timer.periodic(const Duration(milliseconds: 200), (t) {
|
||||||
|
final elapsed = stopwatch.elapsed.inMilliseconds / 1000.0;
|
||||||
|
setState(() {
|
||||||
|
_progress = (elapsed / estimatedSeconds).clamp(0.0, 0.9);
|
||||||
|
final remaining = (estimatedSeconds - elapsed).clamp(
|
||||||
|
0.0,
|
||||||
|
double.infinity,
|
||||||
|
);
|
||||||
|
_eta = '${remaining.toStringAsFixed(1)}s';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
final uploadRes = await client.storage
|
||||||
|
.from('apk_updates')
|
||||||
|
.uploadBinary(
|
||||||
|
path,
|
||||||
|
_apkBytes!,
|
||||||
|
fileOptions: const FileOptions(
|
||||||
|
upsert: true,
|
||||||
|
contentType: 'application/vnd.android.package-archive',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
stopwatch.stop();
|
||||||
|
_logs.add('Upload finished (took ${stopwatch.elapsed.inSeconds}s)');
|
||||||
|
_logs.add('Raw upload response: ${uploadRes.runtimeType} - $uploadRes');
|
||||||
|
if (uploadRes is Map) {
|
||||||
|
final Map m = uploadRes as Map;
|
||||||
|
if (m.containsKey('error') && m['error'] != null) {
|
||||||
|
throw Exception('upload failed: ${m['error']}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_progress = 0.95;
|
||||||
|
});
|
||||||
|
// retrieve public URL; various SDK versions return different structures
|
||||||
|
dynamic urlRes = client.storage.from('apk_updates').getPublicUrl(path);
|
||||||
|
_logs.add('Raw getPublicUrl response: ${urlRes.runtimeType} - $urlRes');
|
||||||
|
String url;
|
||||||
|
if (urlRes is String) {
|
||||||
|
url = urlRes;
|
||||||
|
} else if (urlRes is Map) {
|
||||||
|
// supabase responses vary by SDK version
|
||||||
|
if (urlRes['publicUrl'] is String) {
|
||||||
|
url = urlRes['publicUrl'] as String;
|
||||||
|
} else if (urlRes['data'] is String) {
|
||||||
|
url = urlRes['data'] as String;
|
||||||
|
} else if (urlRes['data'] is Map) {
|
||||||
|
final d = urlRes['data'] as Map;
|
||||||
|
url =
|
||||||
|
(d['publicUrl'] ?? d['public_url'] ?? d['url'] ?? d['publicURL'])
|
||||||
|
as String? ??
|
||||||
|
'';
|
||||||
|
} else {
|
||||||
|
url = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
url = '';
|
||||||
|
}
|
||||||
|
if (url.isEmpty)
|
||||||
|
throw Exception(
|
||||||
|
'could not obtain public url, check bucket CORS and policies',
|
||||||
|
);
|
||||||
|
_logs.add('Public URL: $url');
|
||||||
|
// upsert new version in a single statement
|
||||||
|
await client.from('app_versions').upsert({
|
||||||
|
'version_code': vcode,
|
||||||
|
'min_version_required': minReq,
|
||||||
|
'download_url': url,
|
||||||
|
'release_notes': notes,
|
||||||
|
}, onConflict: 'version_code');
|
||||||
|
await client.from('app_versions').delete().neq('version_code', vcode);
|
||||||
|
setState(() {
|
||||||
|
_progress = 1.0;
|
||||||
|
});
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Version saved successfully')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logs.add('Error during upload: $e');
|
||||||
|
setState(() => _error = e.toString());
|
||||||
|
} finally {
|
||||||
|
_progressTimer?.cancel();
|
||||||
|
_progressTimer = null;
|
||||||
|
setState(() => _isUploading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!kIsWeb) {
|
||||||
|
return const Center(
|
||||||
|
child: Text('This page is only available on the web.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('APK Update Uploader')),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _versionController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Version (e.g. 1.2.3)',
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
validator: (v) => (v == null || v.isEmpty) ? 'Required' : null,
|
||||||
|
),
|
||||||
|
TextFormField(
|
||||||
|
controller: _minController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Min Version (e.g. 0.1.1)',
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
validator: (v) => (v == null || v.isEmpty) ? 'Required' : null,
|
||||||
|
),
|
||||||
|
TextFormField(
|
||||||
|
controller: _notesController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Release Notes'),
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isUploading ? null : _pickApk,
|
||||||
|
child: const Text('Select APK'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(_apkName ?? 'no file chosen')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
if (_isUploading) ...[
|
||||||
|
LinearProgressIndicator(value: _progress),
|
||||||
|
if (_eta != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0),
|
||||||
|
child: Text('ETA: $_eta'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 150),
|
||||||
|
child: ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: _logs.map((l) => Text(l)).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
if (_error != null)
|
||||||
|
Text(_error!, style: const TextStyle(color: Colors.red)),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isUploading ? null : _submit,
|
||||||
|
child: _isUploading
|
||||||
|
? const CircularProgressIndicator()
|
||||||
|
: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
136
lib/services/app_update_service.dart
Normal file
136
lib/services/app_update_service.dart
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:ota_update/ota_update.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
|
import '../models/app_version.dart';
|
||||||
|
|
||||||
|
/// Handles the business logic for fetching version information from the
|
||||||
|
/// ``app_versions`` table and downloading/installing a replacement APK.
|
||||||
|
///
|
||||||
|
/// This class is intentionally *not* coupled to any UI framework so that it can
|
||||||
|
/// be unit‑tested easily. Widgets should only call into the public methods and
|
||||||
|
/// display whatever progress / error states they need.
|
||||||
|
class AppUpdateService {
|
||||||
|
AppUpdateService._();
|
||||||
|
|
||||||
|
/// Public singleton that other parts of the app can depend on. The private
|
||||||
|
/// constructor prevents instantiation from tests; they should mock the
|
||||||
|
/// individual methods instead.
|
||||||
|
static final instance = AppUpdateService._();
|
||||||
|
|
||||||
|
final SupabaseClient _client = Supabase.instance.client;
|
||||||
|
|
||||||
|
/// Fetches the most recent record from ``app_versions``. The table is
|
||||||
|
/// expected to contain a single row per release; we use the highest
|
||||||
|
/// ``version_code`` so that historical entries may be kept if desired.
|
||||||
|
Future<AppVersion?> _fetchLatestVersion() async {
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic>? data = await _client
|
||||||
|
.from('app_versions')
|
||||||
|
.select()
|
||||||
|
.order('version_code', ascending: false)
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
if (data == null) return null;
|
||||||
|
return AppVersion.fromMap(data);
|
||||||
|
} catch (e) {
|
||||||
|
// rethrow so callers can handle/log
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare the running build number with the latest data from the server and
|
||||||
|
/// return an [AppUpdateInfo] object describing the relationship. Errors
|
||||||
|
/// (network down, no supabase instance) are simply re‑thrown; the caller can
|
||||||
|
/// decide whether to ignore them or surface them to the user.
|
||||||
|
Future<AppUpdateInfo> checkForUpdate() async {
|
||||||
|
final pkg = await PackageInfo.fromPlatform();
|
||||||
|
final currentBuild = int.tryParse(pkg.buildNumber) ?? 0;
|
||||||
|
|
||||||
|
final serverVersion = await _fetchLatestVersion();
|
||||||
|
|
||||||
|
if (serverVersion == null) {
|
||||||
|
// table empty – nothing to do
|
||||||
|
return AppUpdateInfo(
|
||||||
|
currentBuildNumber: currentBuild,
|
||||||
|
latestVersion: null,
|
||||||
|
isUpdateAvailable: false,
|
||||||
|
isForceUpdate: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final available = currentBuild < serverVersion.versionCode;
|
||||||
|
final force = currentBuild < serverVersion.minVersionRequired;
|
||||||
|
|
||||||
|
return AppUpdateInfo(
|
||||||
|
currentBuildNumber: currentBuild,
|
||||||
|
latestVersion: serverVersion,
|
||||||
|
isUpdateAvailable: available,
|
||||||
|
isForceUpdate: force,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads and installs the APK at [downloadUrl] using `ota_update`.
|
||||||
|
/// Progress is surfaced via [onProgress] (0.0–1.0). The returned future
|
||||||
|
/// completes once the download has finished and installation has started.
|
||||||
|
///
|
||||||
|
/// Throws on permission denial or when the OTA stream reports an error.
|
||||||
|
Future<void> downloadAndInstallApk(
|
||||||
|
String downloadUrl, {
|
||||||
|
required void Function(double) onProgress,
|
||||||
|
}) async {
|
||||||
|
if (!Platform.isAndroid) {
|
||||||
|
throw UnsupportedError(
|
||||||
|
'In‑app updates currently only supported on Android',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final status = await Permission.requestInstallPackages.request();
|
||||||
|
if (!status.isGranted) {
|
||||||
|
throw Exception('installation permission denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
final completer = Completer<void>();
|
||||||
|
StreamSubscription<OtaEvent>? sub;
|
||||||
|
try {
|
||||||
|
sub = OtaUpdate()
|
||||||
|
.execute(
|
||||||
|
downloadUrl,
|
||||||
|
destinationFilename:
|
||||||
|
'app_${DateTime.now().millisecondsSinceEpoch}.apk',
|
||||||
|
)
|
||||||
|
.listen(
|
||||||
|
(event) {
|
||||||
|
switch (event.status) {
|
||||||
|
case OtaStatus.DOWNLOADING:
|
||||||
|
final pct = double.tryParse(event.value ?? '0') ?? 0;
|
||||||
|
onProgress(pct / 100.0);
|
||||||
|
break;
|
||||||
|
case OtaStatus.INSTALLING:
|
||||||
|
if (!completer.isCompleted) completer.complete();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// treat all other statuses as failure; they provide a string
|
||||||
|
// description in `event.value`.
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.completeError(
|
||||||
|
Exception('OTA update failed: ${event.status}'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (e) {
|
||||||
|
if (!completer.isCompleted) completer.completeError(e);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await completer.future;
|
||||||
|
} finally {
|
||||||
|
if (sub != null) await sub.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
lib/utils/navigation.dart
Normal file
6
lib/utils/navigation.dart
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// Application-wide navigator key used for dialogs and external navigation
|
||||||
|
/// triggers (e.g. background message handlers, startup checks).
|
||||||
|
final GlobalKey<NavigatorState> globalNavigatorKey =
|
||||||
|
GlobalKey<NavigatorState>();
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import '../theme/m3_motion.dart';
|
import '../theme/m3_motion.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
@ -439,6 +440,14 @@ List<NavSection> _buildSections(String role) {
|
||||||
icon: Icons.lock_open,
|
icon: Icons.lock_open,
|
||||||
selectedIcon: Icons.lock,
|
selectedIcon: Icons.lock,
|
||||||
),
|
),
|
||||||
|
if (kIsWeb) ...[
|
||||||
|
NavItem(
|
||||||
|
label: 'App Update',
|
||||||
|
route: '/settings/app-update',
|
||||||
|
icon: Icons.system_update_alt,
|
||||||
|
selectedIcon: Icons.system_update,
|
||||||
|
),
|
||||||
|
],
|
||||||
NavItem(
|
NavItem(
|
||||||
label: 'Logout',
|
label: 'Logout',
|
||||||
route: '',
|
route: '',
|
||||||
|
|
|
||||||
112
lib/widgets/update_dialog.dart
Normal file
112
lib/widgets/update_dialog.dart
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../models/app_version.dart';
|
||||||
|
import '../services/app_update_service.dart';
|
||||||
|
|
||||||
|
/// A reusable dialog that can render both flexible and forced updates and
|
||||||
|
/// report download progress. Callers should wrap this with `showDialog` and
|
||||||
|
/// control ``barrierDismissible`` according to ``info.isForceUpdate``.
|
||||||
|
class UpdateDialog extends StatefulWidget {
|
||||||
|
final AppUpdateInfo info;
|
||||||
|
|
||||||
|
const UpdateDialog({required this.info, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UpdateDialog> createState() => _UpdateDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UpdateDialogState extends State<UpdateDialog> {
|
||||||
|
double _progress = 0;
|
||||||
|
bool _downloading = false;
|
||||||
|
bool _failed = false;
|
||||||
|
|
||||||
|
Future<void> _startDownload() async {
|
||||||
|
setState(() {
|
||||||
|
_downloading = true;
|
||||||
|
_failed = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await AppUpdateService.instance.downloadAndInstallApk(
|
||||||
|
widget.info.latestVersion!.downloadUrl,
|
||||||
|
onProgress: (p) => setState(() => _progress = p),
|
||||||
|
);
|
||||||
|
// once the installer launches the app is likely to be stopped; we
|
||||||
|
// don't pop the dialog explicitly.
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_failed = true;
|
||||||
|
_downloading = false;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('Download failed: $err')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final notes = widget.info.latestVersion?.releaseNotes ?? '';
|
||||||
|
|
||||||
|
// WillPopScope is deprecated in newer Flutter versions but PopScope
|
||||||
|
// has a different API; to avoid breaking changes we continue to use the
|
||||||
|
// old widget and suppress the warning.
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
return WillPopScope(
|
||||||
|
onWillPop: () async {
|
||||||
|
// prevent the user from dismissing when download is in progress or
|
||||||
|
// when the dialog is forcing an update
|
||||||
|
return !widget.info.isForceUpdate && !_downloading;
|
||||||
|
},
|
||||||
|
child: AlertDialog(
|
||||||
|
title: const Text('Update Available'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (notes.isNotEmpty) ...[Text(notes), const SizedBox(height: 12)],
|
||||||
|
if (_downloading)
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
LinearProgressIndicator(value: _progress),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('${(_progress * 100).toStringAsFixed(0)}%'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_failed)
|
||||||
|
const Text(
|
||||||
|
'An error occurred while downloading. Please try again.',
|
||||||
|
style: TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: _buildActions(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildActions() {
|
||||||
|
if (_downloading) {
|
||||||
|
// don't show any actions while the apk is being fetched
|
||||||
|
return <Widget>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.info.isForceUpdate) {
|
||||||
|
return [
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _startDownload,
|
||||||
|
child: const Text('Update Now'),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Later'),
|
||||||
|
),
|
||||||
|
FilledButton(onPressed: _startDownload, child: const Text('Update Now')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import firebase_messaging
|
||||||
import flutter_image_compress_macos
|
import flutter_image_compress_macos
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
import geolocator_apple
|
import geolocator_apple
|
||||||
|
import package_info_plus
|
||||||
import printing
|
import printing
|
||||||
import quill_native_bridge_macos
|
import quill_native_bridge_macos
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
|
@ -31,6 +32,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
|
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||||
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
|
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
|
||||||
QuillNativeBridgePlugin.register(with: registry.registrar(forPlugin: "QuillNativeBridgePlugin"))
|
QuillNativeBridgePlugin.register(with: registry.registrar(forPlugin: "QuillNativeBridgePlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
|
|
||||||
44
pubspec.lock
44
pubspec.lock
|
|
@ -353,6 +353,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.4.1"
|
version: "0.4.1"
|
||||||
|
dio:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: dio
|
||||||
|
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.9.2"
|
||||||
|
dio_web_adapter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dio_web_adapter
|
||||||
|
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
ed25519_edwards:
|
ed25519_edwards:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1165,8 +1181,32 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.3.0"
|
version: "9.3.0"
|
||||||
path:
|
ota_update:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: ota_update
|
||||||
|
sha256: "1f4c7c3c4f306729a6c00b84435096ce2d8b28439013f7237173acc699b2abc8"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.1.0"
|
||||||
|
package_info_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: package_info_plus
|
||||||
|
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.0.0"
|
||||||
|
package_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info_plus_platform_interface
|
||||||
|
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.1"
|
||||||
|
path:
|
||||||
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path
|
name: path
|
||||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
|
|
@ -1182,7 +1222,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
path_provider:
|
path_provider:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path_provider
|
name: path_provider
|
||||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||||
|
|
|
||||||
|
|
@ -37,18 +37,22 @@ dependencies:
|
||||||
shared_preferences: ^2.2.0
|
shared_preferences: ^2.2.0
|
||||||
uuid: ^4.1.0
|
uuid: ^4.1.0
|
||||||
skeletonizer: ^2.1.3
|
skeletonizer: ^2.1.3
|
||||||
|
path: ^1.8.0
|
||||||
|
path_provider: ^2.0.0
|
||||||
fl_chart: ^0.70.2
|
fl_chart: ^0.70.2
|
||||||
google_generative_ai: ^0.4.0
|
google_generative_ai: ^0.4.0
|
||||||
http: ^1.2.0
|
http: ^1.2.0
|
||||||
flutter_background_service: ^5.0.12
|
flutter_background_service: ^5.0.12
|
||||||
flutter_background_service_android: ^6.2.7
|
flutter_background_service_android: ^6.2.7
|
||||||
|
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
image_picker: ^1.1.2
|
image_picker: ^1.1.2
|
||||||
flutter_liveness_check: ^1.0.3
|
flutter_liveness_check: ^1.0.3
|
||||||
google_mlkit_face_detection: ^0.13.2
|
google_mlkit_face_detection: ^0.13.2
|
||||||
qr_flutter: ^4.1.0
|
qr_flutter: ^4.1.0
|
||||||
flutter_image_compress: ^2.4.0
|
flutter_image_compress: ^2.4.0
|
||||||
|
dio: ^5.1.2
|
||||||
|
package_info_plus: ^9.0.0
|
||||||
|
ota_update: ^7.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user