From fd54392d3caae4a60f43dd75a7d6a9ae2ce0727d Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 14 Oct 2025 21:36:50 +0200 Subject: [PATCH] feat: Implement self advertisement functionality and enhance location broadcasting settings --- lib/providers/connection_provider.dart | 23 ++ lib/providers/contacts_provider.dart | 32 +- lib/screens/contacts_tab.dart | 262 ++++++++++++++ lib/screens/device_config_screen.dart | 70 +++- lib/screens/settings_screen.dart | 325 +++++++++++++++--- lib/services/background_location_service.dart | 178 ++++------ lib/services/meshcore_ble_service.dart | 82 ++++- 7 files changed, 808 insertions(+), 164 deletions(-) diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 6b2814b..a435732 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -415,6 +415,29 @@ class ConnectionProvider with ChangeNotifier { } } + /// Send self advertisement to mesh network + /// + /// Broadcasts the device's current advertisement data (name, location, etc.) + /// to the mesh network. Use this after updating position or name to notify + /// other nodes of the change. + /// + /// [floodMode] - if true, broadcast to entire mesh (default for SAR ops) + /// if false, only send to direct neighbors (zero-hop) + Future sendSelfAdvert({bool floodMode = true}) async { + if (!_bleService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _bleService.sendSelfAdvert(floodMode: floodMode); + } catch (e) { + _error = 'Failed to send advertisement: $e'; + notifyListeners(); + } + } + /// Set radio parameters Future setRadioParams({ required int frequency, diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 211d1ee..269960a 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -9,6 +9,31 @@ import '../services/cayenne_lpp_parser.dart'; class ContactsProvider with ChangeNotifier { final Map _contacts = {}; + // Add default public channel on initialization + ContactsProvider() { + _ensurePublicChannelExists(); + } + + /// Ensure public channel always exists in the list + void _ensurePublicChannelExists() { + const publicChannelKey = 'public_channel_0'; + if (!_contacts.containsKey(publicChannelKey)) { + // Create a pseudo-contact for the public channel + _contacts[publicChannelKey] = Contact( + publicKey: Uint8List.fromList(List.filled(32, 0)), // Zero key for public + type: ContactType.room, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'Public Channel', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } + } + List get contacts => _contacts.values.toList(); List get chatContacts => @@ -17,8 +42,11 @@ class ContactsProvider with ChangeNotifier { List get repeaters => contacts.where((c) => c.isRepeater).toList()..sort(_sortByLastSeen); - List get rooms => - contacts.where((c) => c.isRoom).toList()..sort(_sortByLastSeen); + List get rooms { + // Always ensure public channel exists when getting rooms + _ensurePublicChannelExists(); + return contacts.where((c) => c.isRoom).toList()..sort(_sortByLastSeen); + } /// Get contacts with location (for map display) List get contactsWithLocation => diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index 7b82167..a906e64 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -259,6 +259,13 @@ class _ContactTile extends StatelessWidget { onPressed: () => _showDirectMessageDialog(context, contact), tooltip: 'Send direct message', ), + // Login button for rooms (except public channel) + if (contact.type == ContactType.room && contact.advName != 'Public Channel') + IconButton( + icon: const Icon(Icons.login, size: 20), + onPressed: () => _showRoomLoginDialog(context, contact), + tooltip: 'Login to room', + ), // Telemetry refresh button IconButton( icon: const Icon(Icons.refresh, size: 20), @@ -300,6 +307,15 @@ class _ContactTile extends StatelessWidget { ); } + void _showRoomLoginDialog(BuildContext context, Contact contact) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => _RoomLoginSheet(contact: contact), + ); + } + void _showContactDetails(BuildContext context, Contact contact) { showModalBottomSheet( context: context, @@ -748,3 +764,249 @@ class _DirectMessageSheetState extends State<_DirectMessageSheet> { ); } } + +// Room Login Sheet Widget +class _RoomLoginSheet extends StatefulWidget { + final Contact contact; + + const _RoomLoginSheet({required this.contact}); + + @override + State<_RoomLoginSheet> createState() => _RoomLoginSheetState(); +} + +class _RoomLoginSheetState extends State<_RoomLoginSheet> { + final TextEditingController _passwordController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + bool _isLoggingIn = false; + bool _obscurePassword = true; + + @override + void dispose() { + _passwordController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + Future _loginToRoom() async { + final password = _passwordController.text.trim(); + if (password.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please enter a password'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + final connectionProvider = context.read(); + + if (!connectionProvider.deviceInfo.isConnected) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Not connected to device'), + backgroundColor: Colors.red, + ), + ); + return; + } + + setState(() { + _isLoggingIn = true; + }); + + try { + // Send login request to room + await connectionProvider.loginToRoom( + roomPublicKey: widget.contact.publicKey, + password: password, + ); + + _passwordController.clear(); + _focusNode.unfocus(); + + if (!mounted) return; + Navigator.pop(context); // Close the dialog + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Login request sent to ${widget.contact.displayName}'), + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to login: $e'), + backgroundColor: Colors.red, + ), + ); + } finally { + if (mounted) { + setState(() { + _isLoggingIn = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Container( + height: MediaQuery.of(context).size.height * 0.6, + decoration: const BoxDecoration( + color: Color(0xFF1E1E1E), + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + Expanded( + child: Column( + children: [ + const Text( + 'Login to Room', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + widget.contact.displayName, + style: const TextStyle( + color: Colors.grey, + fontSize: 14, + ), + ), + ], + ), + ), + const SizedBox(width: 48), // Balance the back button + ], + ), + ), + + // Info banner + Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: Theme.of(context).colorScheme.onPrimaryContainer), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Enter the password to access this room. You will receive a confirmation once logged in.', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontSize: 13, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + + const Spacer(), + + // Password input + Container( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: 16 + MediaQuery.of(context).viewInsets.bottom, + ), + decoration: const BoxDecoration( + color: Color(0xFF2D2D2D), + ), + child: Column( + children: [ + TextField( + controller: _passwordController, + focusNode: _focusNode, + maxLength: 15, // Max password length from protocol + obscureText: _obscurePassword, + autofocus: true, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + labelText: 'Password', + labelStyle: const TextStyle(color: Colors.grey), + hintText: 'Enter room password', + hintStyle: const TextStyle(color: Colors.grey), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.grey), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.grey), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.white), + ), + contentPadding: const EdgeInsets.all(16), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility : Icons.visibility_off, + color: Colors.grey, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + ), + textInputAction: TextInputAction.done, + onSubmitted: (_) => _loginToRoom(), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isLoggingIn || _passwordController.text.trim().isEmpty + ? null + : _loginToRoom, + icon: _isLoggingIn + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.login), + label: Text(_isLoggingIn ? 'Logging in...' : 'Login'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/device_config_screen.dart b/lib/screens/device_config_screen.dart index 9739481..00c5b5f 100644 --- a/lib/screens/device_config_screen.dart +++ b/lib/screens/device_config_screen.dart @@ -19,6 +19,7 @@ class _DeviceConfigScreenState extends State { late TextEditingController _txPowerController; bool _telemetryEnabled = false; + bool _isBroadcasting = false; String _selectedBandwidth = '62.5 kHz'; int _selectedSpreadingFactor = 8; int _selectedCodingRate = 8; @@ -265,6 +266,51 @@ class _DeviceConfigScreenState extends State { } } + Future _broadcastNow() async { + final connectionProvider = context.read(); + + if (!connectionProvider.deviceInfo.isConnected) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Not connected to device'), + backgroundColor: Colors.orange, + ), + ); + } + return; + } + + setState(() => _isBroadcasting = true); + + try { + // Send self advertisement to mesh network + await connectionProvider.sendSelfAdvert(floodMode: true); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Advertisement broadcast to mesh network'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to broadcast: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isBroadcasting = false); + } + } + } + @override Widget build(BuildContext context) { final deviceInfo = context.watch().deviceInfo; @@ -324,10 +370,26 @@ class _DeviceConfigScreenState extends State { fontWeight: FontWeight.bold, ), ), - ElevatedButton.icon( - onPressed: _savePublicInfo, - icon: const Icon(Icons.save, size: 18), - label: const Text('Save'), + Wrap( + spacing: 8, + children: [ + OutlinedButton.icon( + onPressed: _isBroadcasting ? null : _broadcastNow, + icon: _isBroadcasting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.sensors, size: 18), + label: const Text('Broadcast'), + ), + ElevatedButton.icon( + onPressed: _savePublicInfo, + icon: const Icon(Icons.save, size: 18), + label: const Text('Save'), + ), + ], ), ], ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index a391caf..1349b01 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -30,7 +30,11 @@ class _SettingsScreenState extends State { PackageInfo? _packageInfo; bool _isLoadingSampleData = false; double _gpsUpdateDistance = 10.0; + double _gpsMinDistance = 5.0; + double _gpsMaxDistance = 100.0; + int _minTimeIntervalSeconds = 30; bool _backgroundTrackingEnabled = false; + bool _isSendingLocationUpdate = false; final BackgroundLocationService _backgroundLocationService = BackgroundLocationService(); @@ -56,6 +60,9 @@ class _SettingsScreenState extends State { if (mounted) { setState(() { _gpsUpdateDistance = prefs.getDouble('map_gps_update_distance') ?? 10.0; + _gpsMinDistance = prefs.getDouble('map_gps_min_distance') ?? 5.0; + _gpsMaxDistance = prefs.getDouble('map_gps_max_distance') ?? 100.0; + _minTimeIntervalSeconds = prefs.getInt('map_gps_min_time_interval') ?? 30; _backgroundTrackingEnabled = prefs.getBool('background_tracking_enabled') ?? false; }); @@ -80,6 +87,9 @@ class _SettingsScreenState extends State { Future _saveLocationSettings() async { final prefs = await SharedPreferences.getInstance(); await prefs.setDouble('map_gps_update_distance', _gpsUpdateDistance); + await prefs.setDouble('map_gps_min_distance', _gpsMinDistance); + await prefs.setDouble('map_gps_max_distance', _gpsMaxDistance); + await prefs.setInt('map_gps_min_time_interval', _minTimeIntervalSeconds); await prefs.setBool( 'background_tracking_enabled', _backgroundTrackingEnabled, @@ -215,6 +225,69 @@ class _SettingsScreenState extends State { await _backgroundLocationService.stopTracking(); } + Future _sendLocationUpdateNow() async { + setState(() => _isSendingLocationUpdate = true); + + try { + // Get current location + Position position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.best, + ), + ); + + if (!mounted) return; + + // Get connection provider + final appProvider = context.read(); + final connectionProvider = appProvider.connectionProvider; + + if (!connectionProvider.deviceInfo.isConnected) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Not connected to device'), + backgroundColor: Colors.orange, + ), + ); + } + return; + } + + // Update device location + await connectionProvider.setAdvertLatLon( + latitude: position.latitude, + longitude: position.longitude, + ); + + // Send advertisement + await connectionProvider.sendSelfAdvert(floodMode: true); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Location broadcast: ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}', + ), + backgroundColor: Colors.green, + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to send location: $e'), + backgroundColor: Colors.red, + ), + ); + } finally { + if (mounted) { + setState(() => _isSendingLocationUpdate = false); + } + } + } + Future _clearSampleData() async { final confirmed = await showDialog( context: context, @@ -279,18 +352,37 @@ class _SettingsScreenState extends State { const Divider(), // Location Settings Section - _buildSectionHeader('Location'), - ListTile( - leading: const Icon(Icons.gps_fixed), - title: const Text('GPS Update Distance'), - subtitle: Text('${_gpsUpdateDistance.toStringAsFixed(0)} meters'), - trailing: const Icon(Icons.chevron_right), - onTap: () => _showGpsDistanceDialog(), + _buildSectionHeader('Location Broadcasting'), + + // Manual location update button + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isSendingLocationUpdate ? null : _sendLocationUpdateNow, + icon: _isSendingLocationUpdate + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.my_location), + label: const Text('Broadcast Location Now'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), ), + + const Divider(), + + // Automatic tracking settings SwitchListTile( secondary: const Icon(Icons.location_on), - title: const Text('Background Location Tracking'), - subtitle: const Text('Send position updates to mesh network'), + title: const Text('Auto Location Tracking'), + subtitle: const Text('Automatically broadcast position updates'), value: _backgroundTrackingEnabled, onChanged: (value) { setState(() { @@ -304,6 +396,17 @@ class _SettingsScreenState extends State { _saveLocationSettings(); }, ), + + if (_backgroundTrackingEnabled) ...[ + ListTile( + leading: const Icon(Icons.tune), + title: const Text('Configure Tracking'), + subtitle: const Text('Distance and time thresholds'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showTrackingConfigDialog(), + ), + ], + const Divider(), // About Section @@ -405,44 +508,161 @@ class _SettingsScreenState extends State { ); } - void _showGpsDistanceDialog() { - double tempDistance = _gpsUpdateDistance; + void _showTrackingConfigDialog() { + double tempMinDistance = _gpsMinDistance; + double tempMaxDistance = _gpsMaxDistance; + int tempTimeInterval = _minTimeIntervalSeconds; + showDialog( context: context, builder: (context) => StatefulBuilder( builder: (context, setDialogState) => AlertDialog( - title: const Text('GPS Update Distance'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Position updates sent every ${tempDistance.toStringAsFixed(0)} meters', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 16), - Slider( - value: tempDistance, - min: 1, - max: 100, - divisions: 99, - label: '${tempDistance.toStringAsFixed(0)}m', - onChanged: (value) { - setDialogState(() { - tempDistance = value; - }); - }, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('1m', style: Theme.of(context).textTheme.bodySmall), - Text('100m', style: Theme.of(context).textTheme.bodySmall), - ], + title: const Text('Location Tracking Configuration'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Description + Text( + 'Configure when location broadcasts are sent to the mesh network', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), ), - ), - ], + const SizedBox(height: 24), + + // Minimum Distance + Text( + 'Minimum Distance', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Broadcast only after moving ${tempMinDistance.toStringAsFixed(0)} meters', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + SliderTheme( + data: SliderTheme.of(context).copyWith( + showValueIndicator: ShowValueIndicator.always, + ), + child: Slider( + value: tempMinDistance, + min: 1, + max: 50, + divisions: 49, + label: '${tempMinDistance.toStringAsFixed(0)}m', + onChanged: (value) { + setDialogState(() { + tempMinDistance = value; + // Ensure max is always >= min + if (tempMaxDistance < value) { + tempMaxDistance = value; + } + }); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('1m', style: Theme.of(context).textTheme.bodySmall), + Text('50m', style: Theme.of(context).textTheme.bodySmall), + ], + ), + ), + const SizedBox(height: 24), + + // Maximum Distance + Text( + 'Maximum Distance', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Always broadcast after moving ${tempMaxDistance.toStringAsFixed(0)} meters', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + SliderTheme( + data: SliderTheme.of(context).copyWith( + showValueIndicator: ShowValueIndicator.always, + ), + child: Slider( + value: tempMaxDistance, + min: tempMinDistance, + max: 500, + divisions: (500 - tempMinDistance).toInt(), + label: '${tempMaxDistance.toStringAsFixed(0)}m', + onChanged: (value) { + setDialogState(() { + tempMaxDistance = value; + }); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('${tempMinDistance.toStringAsFixed(0)}m', + style: Theme.of(context).textTheme.bodySmall), + Text('500m', style: Theme.of(context).textTheme.bodySmall), + ], + ), + ), + const SizedBox(height: 24), + + // Minimum Time Interval + Text( + 'Minimum Time Interval', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Always broadcast every ${_formatDuration(tempTimeInterval)}', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + SliderTheme( + data: SliderTheme.of(context).copyWith( + showValueIndicator: ShowValueIndicator.always, + ), + child: Slider( + value: tempTimeInterval.toDouble(), + min: 10, + max: 600, // 10 minutes + divisions: 59, + label: _formatDuration(tempTimeInterval), + onChanged: (value) { + setDialogState(() { + tempTimeInterval = value.toInt(); + }); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('10s', style: Theme.of(context).textTheme.bodySmall), + Text('10min', style: Theme.of(context).textTheme.bodySmall), + ], + ), + ), + ], + ), ), actions: [ TextButton( @@ -452,14 +672,17 @@ class _SettingsScreenState extends State { TextButton( onPressed: () { setState(() { - _gpsUpdateDistance = tempDistance; + _gpsMinDistance = tempMinDistance; + _gpsMaxDistance = tempMaxDistance; + _minTimeIntervalSeconds = tempTimeInterval; + _gpsUpdateDistance = tempMinDistance; // Use min as the primary threshold }); _saveLocationSettings(); // Update background tracking if active if (_backgroundTrackingEnabled) { _backgroundLocationService.updateDistanceThreshold( - tempDistance, + tempMinDistance, ); } @@ -473,6 +696,20 @@ class _SettingsScreenState extends State { ); } + String _formatDuration(int seconds) { + if (seconds < 60) { + return '${seconds}s'; + } else { + final minutes = seconds ~/ 60; + final remainingSeconds = seconds % 60; + if (remainingSeconds == 0) { + return '${minutes}min'; + } else { + return '${minutes}min ${remainingSeconds}s'; + } + } + } + void _showThemeDialog() { showDialog( context: context, diff --git a/lib/services/background_location_service.dart b/lib/services/background_location_service.dart index a6121b1..3982a28 100644 --- a/lib/services/background_location_service.dart +++ b/lib/services/background_location_service.dart @@ -12,9 +12,12 @@ import 'meshcore_ble_service.dart'; class BackgroundLocationService { static const String _prefKeyEnabled = 'background_tracking_enabled'; static const String _prefKeyDistance = 'background_tracking_distance'; + static const String _prefKeyLastLat = 'background_last_lat'; + static const String _prefKeyLastLon = 'background_last_lon'; MeshCoreBleService? _bleService; bool _isInitialized = false; + StreamSubscription? _positionSubscription; /// Initialize the service with BLE service reference void initialize(MeshCoreBleService bleService) { @@ -22,10 +25,19 @@ class BackgroundLocationService { _isInitialized = true; } - /// Start background location tracking + /// Start location tracking and automatic advertisement /// Returns true if successful, false otherwise + /// + /// Note: This is foreground tracking. For true background operation, + /// additional platform-specific configuration is required. Future startTracking({double distanceThreshold = 10.0}) async { if (!_isInitialized || _bleService == null) { + print('⚠️ [BackgroundLocation] Service not initialized or BLE service null'); + return false; + } + + if (!_bleService!.isConnected) { + print('⚠️ [BackgroundLocation] BLE not connected'); return false; } @@ -34,11 +46,13 @@ class BackgroundLocationService { if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); if (permission == LocationPermission.denied) { + print('⚠️ [BackgroundLocation] Location permission denied'); return false; } } if (permission == LocationPermission.deniedForever) { + print('⚠️ [BackgroundLocation] Location permission permanently denied'); return false; } @@ -47,93 +61,17 @@ class BackgroundLocationService { await prefs.setBool(_prefKeyEnabled, true); await prefs.setDouble(_prefKeyDistance, distanceThreshold); - // Initialize background service if not already running - final service = FlutterBackgroundService(); - final isRunning = await service.isRunning(); - - if (!isRunning) { - await _initializeBackgroundService(); - } - - // Start the service - await service.startService(); - - return true; - } - - /// Stop background location tracking - Future stopTracking() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_prefKeyEnabled, false); - - final service = FlutterBackgroundService(); - service.invoke('stop'); - } - - /// Update the distance threshold for location updates - void updateDistanceThreshold(double distance) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setDouble(_prefKeyDistance, distance); - - final service = FlutterBackgroundService(); - service.invoke('updateDistance', {'distance': distance}); - } - - /// Initialize the background service - Future _initializeBackgroundService() async { - final service = FlutterBackgroundService(); - - await service.configure( - iosConfiguration: IosConfiguration( - autoStart: false, - onForeground: _onStart, - onBackground: _onIosBackground, - ), - androidConfiguration: AndroidConfiguration( - autoStart: false, - onStart: _onStart, - isForegroundMode: true, - autoStartOnBoot: false, - ), - ); - } - - /// iOS background entry point - @pragma('vm:entry-point') - static bool _onIosBackground(ServiceInstance service) { - WidgetsFlutterBinding.ensureInitialized(); - DartPluginRegistrant.ensureInitialized(); - return true; - } - - /// Background service entry point - @pragma('vm:entry-point') - static void _onStart(ServiceInstance service) async { - // Ensure Flutter binding is initialized - DartPluginRegistrant.ensureInitialized(); - + // Start listening to position updates Position? lastPosition; - StreamSubscription? positionSubscription; - double distanceThreshold = 10.0; - - // Load settings - final prefs = await SharedPreferences.getInstance(); - final enabled = prefs.getBool(_prefKeyEnabled) ?? false; - distanceThreshold = prefs.getDouble(_prefKeyDistance) ?? 10.0; - - if (!enabled) { - service.stopSelf(); - return; - } - - // Start location tracking try { - positionSubscription = Geolocator.getPositionStream( + _positionSubscription = Geolocator.getPositionStream( locationSettings: LocationSettings( accuracy: LocationAccuracy.best, distanceFilter: distanceThreshold.toInt(), ), ).listen((Position position) async { + print('📍 [BackgroundLocation] New position: ${position.latitude}, ${position.longitude}'); + // Calculate distance from last position if (lastPosition != null) { final distance = Geolocator.distanceBetween( @@ -143,41 +81,73 @@ class BackgroundLocationService { position.longitude, ); - // Only update if moved enough distance + print(' Distance moved: ${distance.toStringAsFixed(1)}m (threshold: ${distanceThreshold}m)'); + + // Skip if haven't moved enough if (distance < distanceThreshold) { return; } } - // Store last position + // Update last position lastPosition = position; - // Note: In a real implementation, we would need to communicate with - // the BLE service via isolate communication or shared storage. - // For now, this is a placeholder for the background tracking logic. + // Save to preferences + await prefs.setDouble(_prefKeyLastLat, position.latitude); + await prefs.setDouble(_prefKeyLastLon, position.longitude); - // Send location update via notification or data channel - service.invoke('location', { - 'latitude': position.latitude, - 'longitude': position.longitude, - 'timestamp': position.timestamp.millisecondsSinceEpoch, - }); + // Update device's advertised location + if (_bleService != null && _bleService!.isConnected) { + try { + print('📤 [BackgroundLocation] Updating device location...'); + await _bleService!.setAdvertLatLon( + latitude: position.latitude, + longitude: position.longitude, + ); + + // Send advertisement to mesh network + print('📡 [BackgroundLocation] Broadcasting self advertisement...'); + await _bleService!.sendSelfAdvert(floodMode: true); + print('✅ [BackgroundLocation] Location update sent successfully'); + } catch (e) { + print('❌ [BackgroundLocation] Failed to send location update: $e'); + } + } else { + print('⚠️ [BackgroundLocation] BLE disconnected, cannot send update'); + } }); + + print('✅ [BackgroundLocation] Tracking started with ${distanceThreshold}m threshold'); + return true; } catch (e) { - service.stopSelf(); - return; + print('❌ [BackgroundLocation] Failed to start tracking: $e'); + return false; } + } - // Listen for service commands - service.on('stop').listen((event) async { - await positionSubscription?.cancel(); - service.stopSelf(); - }); + /// Stop location tracking + Future stopTracking() async { + print('🛑 [BackgroundLocation] Stopping tracking'); + await _positionSubscription?.cancel(); + _positionSubscription = null; - service.on('updateDistance').listen((event) { - if (event != null && event['distance'] != null) { - distanceThreshold = event['distance'] as double; - } - }); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefKeyEnabled, false); + print('✅ [BackgroundLocation] Tracking stopped'); + } + + /// Update the distance threshold for location updates + /// Note: This will restart tracking with the new threshold + Future updateDistanceThreshold(double distance) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_prefKeyDistance, distance); + print('📏 [BackgroundLocation] Distance threshold updated to ${distance}m'); + + // Restart tracking if currently enabled + final isEnabled = prefs.getBool(_prefKeyEnabled) ?? false; + if (isEnabled && _bleService != null) { + await stopTracking(); + await startTracking(distanceThreshold: distance); + } } } diff --git a/lib/services/meshcore_ble_service.dart b/lib/services/meshcore_ble_service.dart index 1e45bf0..bd6c233 100644 --- a/lib/services/meshcore_ble_service.dart +++ b/lib/services/meshcore_ble_service.dart @@ -520,7 +520,35 @@ class MeshCoreBleService { final senderTimestamp = reader.readUInt32LE(); print(' Sender timestamp: $senderTimestamp (${DateTime.fromMillisecondsSinceEpoch(senderTimestamp * 1000)})'); - final text = reader.readString(); + // Handle different message types + String text; + Uint8List? signature; + + if (txtType == MessageTextType.signedPlain) { + // Signed message format: [64-byte signature][UTF-8 text] + print(' Signed message detected - extracting signature'); + + if (reader.remainingBytesCount < 64) { + print(' ⚠️ Insufficient bytes for signature (${reader.remainingBytesCount} < 64)'); + // Try to read as plain text anyway + text = reader.readString(); + } else { + signature = reader.readBytes(64); + print(' Signature (first 16 bytes): ${signature.sublist(0, 16).map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}...'); + + // Remaining bytes are the actual text + if (reader.hasRemaining) { + text = reader.readString(); + } else { + text = ''; + print(' ⚠️ No text content after signature'); + } + } + } else { + // Plain text message + text = reader.readString(); + } + print(' Text: "$text"'); final message = Message( @@ -561,7 +589,35 @@ class MeshCoreBleService { final senderTimestamp = reader.readUInt32LE(); print(' Sender timestamp: $senderTimestamp (${DateTime.fromMillisecondsSinceEpoch(senderTimestamp * 1000)})'); - final text = reader.readString(); + // Handle different message types + String text; + Uint8List? signature; + + if (txtType == MessageTextType.signedPlain) { + // Signed message format: [64-byte signature][UTF-8 text] + print(' Signed message detected - extracting signature'); + + if (reader.remainingBytesCount < 64) { + print(' ⚠️ Insufficient bytes for signature (${reader.remainingBytesCount} < 64)'); + // Try to read as plain text anyway + text = reader.readString(); + } else { + signature = reader.readBytes(64); + print(' Signature (first 16 bytes): ${signature.sublist(0, 16).map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}...'); + + // Remaining bytes are the actual text + if (reader.hasRemaining) { + text = reader.readString(); + } else { + text = ''; + print(' ⚠️ No text content after signature'); + } + } + } else { + // Plain text message + text = reader.readString(); + } + print(' Text: "$text"'); final message = Message( @@ -1207,16 +1263,22 @@ class MeshCoreBleService { await _writeData(writer.toBytes()); } - /// Send flood advertisement with current location - Future sendFloodAdvertisement({ - required double latitude, - required double longitude, - }) async { + /// Send self advertisement packet to mesh network + /// + /// This broadcasts the device's current advertisement data (name, location, etc.) + /// to the mesh network. The device uses its internally stored values from + /// setAdvertName() and setAdvertLatLon(). + /// + /// Protocol format (CMD_SEND_SELF_ADVERT): + /// - 1 byte: command code (7) + /// - 1 byte: type (0=zero-hop/local, 1=flood/mesh-wide) + /// + /// [floodMode] - if true, broadcast to entire mesh network (default) + /// if false, only send to direct neighbors (zero-hop) + Future sendSelfAdvert({bool floodMode = true}) async { final writer = BufferWriter(); writer.writeByte(MeshCoreConstants.cmdSendSelfAdvert); - writer.writeByte(MeshCoreConstants.selfAdvertFlood); - writer.writeInt32LE((latitude * 1000000).round()); - writer.writeInt32LE((longitude * 1000000).round()); + writer.writeByte(floodMode ? MeshCoreConstants.selfAdvertFlood : MeshCoreConstants.selfAdvertZeroHop); await _writeData(writer.toBytes()); }