diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index dbafb948..3d8fcdf0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + diff --git a/lib/main.dart b/lib/main.dart index 52e59ec4..c5c7d5f6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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. } diff --git a/lib/models/app_version.dart b/lib/models/app_version.dart new file mode 100644 index 00000000..86d8c31b --- /dev/null +++ b/lib/models/app_version.dart @@ -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 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, + }); +} diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index f2c8ee06..1e49a994 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -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((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((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( diff --git a/lib/screens/admin/app_update_screen.dart b/lib/screens/admin/app_update_screen.dart new file mode 100644 index 00000000..d4e02183 --- /dev/null +++ b/lib/screens/admin/app_update_screen.dart @@ -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 createState() => _AppUpdateScreenState(); +} + +class _AppUpdateScreenState extends ConsumerState { + final _formKey = GlobalKey(); + 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 _logs = []; + Timer? _progressTimer; + String? _error; + + @override + void initState() { + super.initState(); + _loadCurrent(); + } + + Future _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) { + _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 _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 _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'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/services/app_update_service.dart b/lib/services/app_update_service.dart new file mode 100644 index 00000000..271e9281 --- /dev/null +++ b/lib/services/app_update_service.dart @@ -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 _fetchLatestVersion() async { + try { + final Map? 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 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 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(); + StreamSubscription? 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(); + } + } +} diff --git a/lib/utils/navigation.dart b/lib/utils/navigation.dart new file mode 100644 index 00000000..f6a724b8 --- /dev/null +++ b/lib/utils/navigation.dart @@ -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 globalNavigatorKey = + GlobalKey(); diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 2e485fbe..062cf1a6 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -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 _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: '', diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart new file mode 100644 index 00000000..1768317d --- /dev/null +++ b/lib/widgets/update_dialog.dart @@ -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 createState() => _UpdateDialogState(); +} + +class _UpdateDialogState extends State { + double _progress = 0; + bool _downloading = false; + bool _failed = false; + + Future _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 _buildActions() { + if (_downloading) { + // don't show any actions while the apk is being fetched + return []; + } + + 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')), + ]; + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 23acd1c1..bf999e9d 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) diff --git a/pubspec.lock b/pubspec.lock index a6462d2b..4660a5a6 100644 --- a/pubspec.lock +++ b/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" diff --git a/pubspec.yaml b/pubspec.yaml index 5c8b6bed..80287827 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: