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.WAKE_LOCK" />
|
||||
<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
|
||||
will automatically block notifications and the user cannot enable them
|
||||
from settings. -->
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ import 'utils/location_permission.dart';
|
|||
import 'services/notification_service.dart';
|
||||
import 'services/notification_bridge.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 'dart:convert';
|
||||
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
|
||||
// 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/user_management_screen.dart';
|
||||
import '../screens/admin/geofence_test_screen.dart';
|
||||
import '../screens/admin/app_update_screen.dart';
|
||||
import '../screens/dashboard/dashboard_screen.dart';
|
||||
import '../screens/notifications/notifications_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 '../theme/m3_motion.dart';
|
||||
|
||||
import '../utils/navigation.dart';
|
||||
|
||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
final notifier = RouterNotifier(ref);
|
||||
ref.onDispose(notifier.dispose);
|
||||
|
||||
return GoRouter(
|
||||
navigatorKey: globalNavigatorKey,
|
||||
initialLocation: '/dashboard',
|
||||
refreshListenable: notifier,
|
||||
redirect: (context, state) {
|
||||
|
|
@ -104,6 +108,13 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||
child: const TeamsScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/app-update',
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: const AppUpdateScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/dashboard',
|
||||
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/foundation.dart' show kIsWeb;
|
||||
import '../theme/m3_motion.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
|
@ -439,6 +440,14 @@ List<NavSection> _buildSections(String role) {
|
|||
icon: Icons.lock_open,
|
||||
selectedIcon: Icons.lock,
|
||||
),
|
||||
if (kIsWeb) ...[
|
||||
NavItem(
|
||||
label: 'App Update',
|
||||
route: '/settings/app-update',
|
||||
icon: Icons.system_update_alt,
|
||||
selectedIcon: Icons.system_update,
|
||||
),
|
||||
],
|
||||
NavItem(
|
||||
label: 'Logout',
|
||||
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_local_notifications
|
||||
import geolocator_apple
|
||||
import package_info_plus
|
||||
import printing
|
||||
import quill_native_bridge_macos
|
||||
import shared_preferences_foundation
|
||||
|
|
@ -31,6 +32,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
|
||||
QuillNativeBridgePlugin.register(with: registry.registrar(forPlugin: "QuillNativeBridgePlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
|
|
|
|||
44
pubspec.lock
44
pubspec.lock
|
|
@ -353,6 +353,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1165,8 +1181,32 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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
|
||||
description:
|
||||
name: package_info_plus_platform_interface
|
||||
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
|
|
@ -1182,7 +1222,7 @@ packages:
|
|||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
|
|
|
|||
|
|
@ -37,18 +37,22 @@ dependencies:
|
|||
shared_preferences: ^2.2.0
|
||||
uuid: ^4.1.0
|
||||
skeletonizer: ^2.1.3
|
||||
path: ^1.8.0
|
||||
path_provider: ^2.0.0
|
||||
fl_chart: ^0.70.2
|
||||
google_generative_ai: ^0.4.0
|
||||
http: ^1.2.0
|
||||
flutter_background_service: ^5.0.12
|
||||
flutter_background_service_android: ^6.2.7
|
||||
|
||||
intl: ^0.20.2
|
||||
image_picker: ^1.1.2
|
||||
flutter_liveness_check: ^1.0.3
|
||||
google_mlkit_face_detection: ^0.13.2
|
||||
qr_flutter: ^4.1.0
|
||||
flutter_image_compress: ^2.4.0
|
||||
dio: ^5.1.2
|
||||
package_info_plus: ^9.0.0
|
||||
ota_update: ^7.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user