OTA Update attempt

This commit is contained in:
Marc Rejohn Castillano 2026-03-11 22:28:38 +08:00
parent f8c79acbbc
commit 0bd2a94ece
12 changed files with 687 additions and 3 deletions

View File

@ -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. -->

View File

@ -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.
} }

View 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 publiclyaccessible 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,
});
}

View File

@ -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(

View 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'),
),
],
),
),
),
);
}
}

View 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 unittested 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 rethrown; 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.01.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(
'Inapp 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();
}
}
}

View 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>();

View File

@ -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: '',

View 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')),
];
}
}

View File

@ -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"))

View File

@ -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"

View File

@ -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: