From ccec842672aca7e7b6eda60b0d678513c4599962 Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 14 Oct 2025 11:18:59 +0200 Subject: [PATCH] feat: Add display name getter to DeviceInfo and update usage in HomeScreen; refactor contact type handling in MapMarkers --- lib/models/device_info.dart | 9 + lib/providers/connection_provider.dart | 122 +++ lib/screens/device_config_screen.dart | 1018 ++++++++++++++++++++++++ lib/screens/home_screen.dart | 43 +- lib/screens/map_tab.dart | 8 +- lib/services/meshcore_ble_service.dart | 71 +- lib/utils/sample_data_generator.dart | 4 +- lib/widgets/map_markers.dart | 32 +- 8 files changed, 1285 insertions(+), 22 deletions(-) create mode 100644 lib/screens/device_config_screen.dart diff --git a/lib/models/device_info.dart b/lib/models/device_info.dart index f655b19..386a7b7 100644 --- a/lib/models/device_info.dart +++ b/lib/models/device_info.dart @@ -114,6 +114,15 @@ class DeviceInfo { .join(''); } + /// Get display name with "MeshCore-" prefix removed + String? get displayName { + if (deviceName == null) return null; + if (deviceName!.startsWith('MeshCore-')) { + return deviceName!.substring(9); // Remove "MeshCore-" (9 characters) + } + return deviceName; + } + DeviceInfo copyWith({ String? deviceId, String? deviceName, diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 1c2896e..ff135ec 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -104,6 +104,31 @@ class ConnectionProvider with ChangeNotifier { onTelemetryReceived?.call(publicKey, lppData); }; + _bleService.onSelfInfoReceived = (selfInfo) { + print('📥 [Provider] Received SelfInfo:'); + print(' TX Power: ${selfInfo['txPower']} / ${selfInfo['maxTxPower']} dBm'); + print(' Radio: freq=${selfInfo['radioFreq']}, bw=${selfInfo['radioBw']}, sf=${selfInfo['radioSf']}, cr=${selfInfo['radioCr']}'); + print(' Position: ${selfInfo['advLat'] / 1000000.0}, ${selfInfo['advLon'] / 1000000.0}'); + print(' Self Name: ${selfInfo['selfName']}'); + + _deviceInfo = _deviceInfo.copyWith( + deviceType: selfInfo['deviceType'] as int?, + txPower: selfInfo['txPower'] as int?, + maxTxPower: selfInfo['maxTxPower'] as int?, + publicKey: selfInfo['publicKey'] as Uint8List?, + advLat: selfInfo['advLat'] as int?, + advLon: selfInfo['advLon'] as int?, + manualAddContacts: selfInfo['manualAddContacts'] as bool?, + radioFreq: selfInfo['radioFreq'] as int?, + radioBw: selfInfo['radioBw'] as int?, + radioSf: selfInfo['radioSf'] as int?, + radioCr: selfInfo['radioCr'] as int?, + selfName: selfInfo['selfName'] as String?, + ); + notifyListeners(); + print('✅ [Provider] Device info updated with SelfInfo'); + }; + // Activity indicators _bleService.onRxActivity = () { _rxActivity = true; @@ -299,6 +324,103 @@ class ConnectionProvider with ChangeNotifier { } } + /// Set advertised name + Future setAdvertName(String name) async { + if (!_bleService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _bleService.setAdvertName(name); + } catch (e) { + _error = 'Failed to set name: $e'; + notifyListeners(); + } + } + + /// Set advertised position + Future setAdvertLatLon({ + required double latitude, + required double longitude, + }) async { + if (!_bleService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _bleService.setAdvertLatLon( + latitude: latitude, + longitude: longitude, + ); + } catch (e) { + _error = 'Failed to set position: $e'; + notifyListeners(); + } + } + + /// Set radio parameters + Future setRadioParams({ + required int frequency, + required int bandwidth, + required int spreadingFactor, + required int codingRate, + }) async { + if (!_bleService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _bleService.setRadioParams( + frequency: frequency, + bandwidth: bandwidth, + spreadingFactor: spreadingFactor, + codingRate: codingRate, + ); + } catch (e) { + _error = 'Failed to set radio params: $e'; + notifyListeners(); + } + } + + /// Set transmit power + Future setTxPower(int powerDbm) async { + if (!_bleService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _bleService.setTxPower(powerDbm); + } catch (e) { + _error = 'Failed to set TX power: $e'; + notifyListeners(); + } + } + + /// Request fresh device info (triggers SelfInfo response) + Future refreshDeviceInfo() async { + if (!_bleService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + // The device query command triggers a SelfInfo response + await _bleService.refreshDeviceInfo(); + } catch (e) { + _error = 'Failed to refresh device info: $e'; + notifyListeners(); + } + } + /// Clear error message void clearError() { _error = null; diff --git a/lib/screens/device_config_screen.dart b/lib/screens/device_config_screen.dart new file mode 100644 index 0000000..1d96477 --- /dev/null +++ b/lib/screens/device_config_screen.dart @@ -0,0 +1,1018 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:geolocator/geolocator.dart'; +import '../providers/connection_provider.dart'; + +class DeviceConfigScreen extends StatefulWidget { + const DeviceConfigScreen({super.key}); + + @override + State createState() => _DeviceConfigScreenState(); +} + +class _DeviceConfigScreenState extends State { + late TextEditingController _nameController; + late TextEditingController _latController; + late TextEditingController _lonController; + late TextEditingController _freqController; + late TextEditingController _txPowerController; + + bool _sharePosition = false; + String _selectedBandwidth = '62.5 kHz'; + int _selectedSpreadingFactor = 8; + int _selectedCodingRate = 8; + + final List _bandwidthOptions = [ + '7.8 kHz', + '10.4 kHz', + '15.6 kHz', + '20.8 kHz', + '31.25 kHz', + '41.7 kHz', + '62.5 kHz', + '125 kHz', + '250 kHz', + '500 kHz', + ]; + + @override + void initState() { + super.initState(); + final deviceInfo = context.read().deviceInfo; + + _nameController = TextEditingController( + text: deviceInfo.selfName ?? deviceInfo.displayName ?? '', + ); + _latController = TextEditingController( + text: deviceInfo.advLat != null ? (deviceInfo.advLat! / 1000000).toStringAsFixed(6) : '0.0', + ); + _lonController = TextEditingController( + text: deviceInfo.advLon != null ? (deviceInfo.advLon! / 1000000).toStringAsFixed(6) : '0.0', + ); + _freqController = TextEditingController( + text: deviceInfo.radioFreq != null ? (deviceInfo.radioFreq! / 1000).toStringAsFixed(3) : '869.618', + ); + _txPowerController = TextEditingController( + text: deviceInfo.txPower?.toString() ?? '20', + ); + + if (deviceInfo.radioBw != null && deviceInfo.radioBw! >= 0 && deviceInfo.radioBw! <= 9) { + _selectedBandwidth = _bandwidthFromValue(deviceInfo.radioBw!); + } + if (deviceInfo.radioSf != null) { + // Validate spreading factor is in valid range (7-12) + if (deviceInfo.radioSf! >= 7 && deviceInfo.radioSf! <= 12) { + _selectedSpreadingFactor = deviceInfo.radioSf!; + } else { + debugPrint('⚠️ Invalid spreading factor from device: ${deviceInfo.radioSf}. Using default: 8'); + _selectedSpreadingFactor = 8; + } + } + if (deviceInfo.radioCr != null) { + // Validate coding rate is in valid range (5-8) + if (deviceInfo.radioCr! >= 5 && deviceInfo.radioCr! <= 8) { + _selectedCodingRate = deviceInfo.radioCr!; + } else { + debugPrint('⚠️ Invalid coding rate from device: ${deviceInfo.radioCr}. Using default: 8'); + _selectedCodingRate = 8; + } + } + _sharePosition = (deviceInfo.advLat != null && deviceInfo.advLat! != 0) || + (deviceInfo.advLon != null && deviceInfo.advLon! != 0); + } + + @override + void dispose() { + _nameController.dispose(); + _latController.dispose(); + _lonController.dispose(); + _freqController.dispose(); + _txPowerController.dispose(); + super.dispose(); + } + + String _bandwidthFromValue(int bw) { + // Convert bandwidth value to display string + switch (bw) { + case 0: return '7.8 kHz'; + case 1: return '10.4 kHz'; + case 2: return '15.6 kHz'; + case 3: return '20.8 kHz'; + case 4: return '31.25 kHz'; + case 5: return '41.7 kHz'; + case 6: return '62.5 kHz'; + case 7: return '125 kHz'; + case 8: return '250 kHz'; + case 9: return '500 kHz'; + default: return '62.5 kHz'; + } + } + + int _bandwidthToValue(String bw) { + return _bandwidthOptions.indexOf(bw); + } + + Future _savePublicInfo() async { + final connectionProvider = context.read(); + + try { + // Save name + if (_nameController.text.isNotEmpty) { + await connectionProvider.setAdvertName(_nameController.text); + } + + // Save position if share position is enabled + if (_sharePosition) { + final lat = double.tryParse(_latController.text) ?? 0.0; + final lon = double.tryParse(_lonController.text) ?? 0.0; + await connectionProvider.setAdvertLatLon( + latitude: lat, + longitude: lon, + ); + } else { + // Clear position + await connectionProvider.setAdvertLatLon( + latitude: 0.0, + longitude: 0.0, + ); + } + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Public info saved'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to save public info: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _saveRadioSettings() async { + final connectionProvider = context.read(); + + try { + // Parse and save frequency (convert from MHz to Hz) + final freq = (double.tryParse(_freqController.text) ?? 869.618) * 1000; + + await connectionProvider.setRadioParams( + frequency: freq.round(), + bandwidth: _bandwidthToValue(_selectedBandwidth), + spreadingFactor: _selectedSpreadingFactor, + codingRate: _selectedCodingRate, + ); + + // Save TX power + final txPower = int.tryParse(_txPowerController.text) ?? 20; + await connectionProvider.setTxPower(txPower); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Radio settings saved'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to save radio settings: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _refreshDeviceInfo() async { + final connectionProvider = context.read(); + + try { + await connectionProvider.refreshDeviceInfo(); + + if (context.mounted) { + // Update controllers with fresh data + final deviceInfo = connectionProvider.deviceInfo; + setState(() { + if (deviceInfo.selfName != null) { + _nameController.text = deviceInfo.selfName!; + } + if (deviceInfo.advLat != null) { + _latController.text = (deviceInfo.advLat! / 1000000).toStringAsFixed(6); + } + if (deviceInfo.advLon != null) { + _lonController.text = (deviceInfo.advLon! / 1000000).toStringAsFixed(6); + } + if (deviceInfo.radioFreq != null) { + _freqController.text = (deviceInfo.radioFreq! / 1000).toStringAsFixed(3); + } + if (deviceInfo.txPower != null) { + _txPowerController.text = deviceInfo.txPower.toString(); + } + if (deviceInfo.radioBw != null && deviceInfo.radioBw! >= 0 && deviceInfo.radioBw! <= 9) { + _selectedBandwidth = _bandwidthFromValue(deviceInfo.radioBw!); + } + if (deviceInfo.radioSf != null) { + // Validate spreading factor is in valid range (7-12) + if (deviceInfo.radioSf! >= 7 && deviceInfo.radioSf! <= 12) { + _selectedSpreadingFactor = deviceInfo.radioSf!; + } else { + debugPrint('⚠️ Invalid spreading factor from device: ${deviceInfo.radioSf}. Using default: 8'); + _selectedSpreadingFactor = 8; + } + } + if (deviceInfo.radioCr != null) { + // Validate coding rate is in valid range (5-8) + if (deviceInfo.radioCr! >= 5 && deviceInfo.radioCr! <= 8) { + _selectedCodingRate = deviceInfo.radioCr!; + } else { + debugPrint('⚠️ Invalid coding rate from device: ${deviceInfo.radioCr}. Using default: 8'); + _selectedCodingRate = 8; + } + } + _sharePosition = (deviceInfo.advLat != null && deviceInfo.advLat! != 0) || + (deviceInfo.advLon != null && deviceInfo.advLon! != 0); + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Device info refreshed'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to refresh: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _useCurrentLocation() async { + try { + // Check if location services are enabled + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Location services are disabled'), + backgroundColor: Colors.orange, + ), + ); + } + return; + } + + // Check location permission + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Location permission denied'), + backgroundColor: Colors.red, + ), + ); + } + return; + } + } + + if (permission == LocationPermission.deniedForever) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Location permission permanently denied'), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + // Get current position + Position position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.best, + ), + ); + + setState(() { + _latController.text = position.latitude.toStringAsFixed(6); + _lonController.text = position.longitude.toStringAsFixed(6); + _sharePosition = true; + }); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Location updated'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to get location: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final deviceInfo = context.watch().deviceInfo; + final publicKeyHex = deviceInfo.publicKey != null + ? deviceInfo.publicKey! + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + : ''; + final publicKeyShort = publicKeyHex.isNotEmpty && publicKeyHex.length >= 16 + ? '${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}' + : 'unknown'; + + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + return Scaffold( + backgroundColor: isDark ? const Color(0xFF121212) : const Color(0xFFF5F5F5), + appBar: AppBar( + elevation: 0, + backgroundColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + title: const Text('Device Settings'), + centerTitle: false, + ), + body: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + const SizedBox(height: 8), + + // Device Card + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: theme.cardColor, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + children: [ + // Device Avatar + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.purple.shade400, + Colors.purple.shade700, + ], + ), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.purple.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Center( + child: Text( + deviceInfo.displayName?.substring(0, 1).toUpperCase() ?? 'M', + style: const TextStyle( + fontSize: 42, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(height: 16), + + // Device Name + Text( + deviceInfo.displayName ?? 'MeshCore Device', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + + // Public Key Chip + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: isDark + ? Colors.grey.shade800 + : Colors.grey.shade200, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.fingerprint, + size: 16, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + publicKeyShort, + style: TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: theme.textTheme.bodyMedium?.color, + ), + ), + ], + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + + // Public Info Section + _SectionHeader( + title: 'Public Info', + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _refreshDeviceInfo, + tooltip: 'Refresh', + iconSize: 20, + ), + IconButton( + icon: const Icon(Icons.check), + onPressed: _savePublicInfo, + tooltip: 'Save', + iconSize: 20, + ), + ], + ), + ), + + _SettingTile( + icon: Icons.person, + label: 'Name', + isFirst: true, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + ), + child: TextField( + controller: _nameController, + decoration: const InputDecoration( + border: InputBorder.none, + hintText: 'Device name', + isDense: true, + contentPadding: EdgeInsets.zero, + ), + textAlign: TextAlign.end, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + ), + ), + + _SettingTile( + icon: Icons.key, + label: 'Public Key', + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + publicKeyHex.substring(0, 32.clamp(0, publicKeyHex.length)), + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ), + const SizedBox(width: 4), + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () { + Clipboard.setData(ClipboardData(text: publicKeyHex)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Public key copied'), + duration: Duration(seconds: 1), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + Icons.copy, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ), + ], + ), + ), + + _SettingTile( + icon: Icons.location_on, + label: 'Latitude', + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + ), + child: IntrinsicWidth( + child: TextField( + controller: _latController, + decoration: const InputDecoration( + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 13, + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + ), + ), + ), + ), + + _SettingTile( + icon: null, + label: 'Longitude', + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + ), + child: IntrinsicWidth( + child: TextField( + controller: _lonController, + decoration: const InputDecoration( + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 13, + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + ), + ), + ), + const SizedBox(width: 8), + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: _useCurrentLocation, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.my_location, + size: 16, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ), + ), + ], + ), + ), + + _SettingTile( + icon: Icons.wifi_tethering, + label: 'Share Position', + isLast: true, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch( + value: _sharePosition, + onChanged: (value) { + setState(() { + _sharePosition = value; + }); + }, + ), + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Share Position'), + content: const Text( + 'When enabled, your device will broadcast its GPS coordinates ' + 'to other devices in the mesh network.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('GOT IT'), + ), + ], + ), + ); + }, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.primary.withOpacity(0.7), + ), + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + + // Radio Settings Section + _SectionHeader( + title: 'Radio Settings', + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + OutlinedButton( + onPressed: () { + // TODO: Show preset selection dialog + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Preset selection coming soon')), + ); + }, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + minimumSize: Size.zero, + ), + child: const Text('Choose Preset', style: TextStyle(fontSize: 12)), + ), + IconButton( + icon: const Icon(Icons.check), + onPressed: _saveRadioSettings, + tooltip: 'Save', + iconSize: 20, + ), + ], + ), + ), + + _SettingTile( + icon: Icons.radio, + label: 'Frequency (MHz)', + isFirst: true, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + ), + child: TextField( + controller: _freqController, + decoration: const InputDecoration( + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + textAlign: TextAlign.end, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + ), + ), + ), + + _SettingTile( + icon: Icons.graphic_eq, + label: 'Bandwidth', + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + ), + child: DropdownButton( + value: _selectedBandwidth, + underline: const SizedBox(), + isDense: true, + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 13, + color: Theme.of(context).textTheme.bodyLarge?.color, + ), + items: _bandwidthOptions.map((String value) { + return DropdownMenuItem( + value: value, + child: Text(value), + ); + }).toList(), + onChanged: (String? newValue) { + if (newValue != null) { + setState(() { + _selectedBandwidth = newValue; + }); + } + }, + ), + ), + ), + + _SettingTile( + icon: Icons.layers, + label: 'Spreading Factor', + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + ), + child: DropdownButton( + value: _selectedSpreadingFactor, + underline: const SizedBox(), + isDense: true, + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 13, + color: Theme.of(context).textTheme.bodyLarge?.color, + ), + items: List.generate(6, (index) => index + 7).map((int value) { + return DropdownMenuItem( + value: value, + child: Text(value.toString()), + ); + }).toList(), + onChanged: (int? newValue) { + if (newValue != null) { + setState(() { + _selectedSpreadingFactor = newValue; + }); + } + }, + ), + ), + ), + + _SettingTile( + icon: Icons.data_usage, + label: 'Coding Rate', + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + ), + child: DropdownButton( + value: _selectedCodingRate, + underline: const SizedBox(), + isDense: true, + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 13, + color: Theme.of(context).textTheme.bodyLarge?.color, + ), + items: List.generate(4, (index) => index + 5).map((int value) { + return DropdownMenuItem( + value: value, + child: Text(value.toString()), + ); + }).toList(), + onChanged: (int? newValue) { + if (newValue != null) { + setState(() { + _selectedCodingRate = newValue; + }); + } + }, + ), + ), + ), + + _SettingTile( + icon: Icons.power, + label: 'Transmit Power (dBm)', + isLast: true, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + ), + child: TextField( + controller: _txPowerController, + decoration: const InputDecoration( + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + textAlign: TextAlign.end, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + keyboardType: TextInputType.number, + ), + ), + ), + + const SizedBox(height: 32), + ], + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + final String title; + final VoidCallback? onMorePressed; + final Widget? trailing; + + const _SectionHeader({ + required this.title, + this.onMorePressed, + this.trailing, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.fromLTRB(4, 16, 4, 8), + child: Row( + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + const Spacer(), + if (trailing != null) + trailing! + else if (onMorePressed != null) + IconButton( + icon: const Icon(Icons.more_vert), + onPressed: onMorePressed, + iconSize: 20, + ), + ], + ), + ); + } +} + +class _SettingTile extends StatelessWidget { + final IconData? icon; + final String label; + final Widget? child; + final Widget? trailing; + final bool isFirst; + final bool isLast; + + const _SettingTile({ + this.icon, + required this.label, + this.child, + this.trailing, + this.isFirst = false, + this.isLast = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + margin: EdgeInsets.only( + top: isFirst ? 8 : 0, + bottom: isLast ? 0 : 1, + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: theme.cardColor, + borderRadius: BorderRadius.vertical( + top: isFirst ? const Radius.circular(12) : Radius.zero, + bottom: isLast ? const Radius.circular(12) : Radius.zero, + ), + ), + child: Row( + children: [ + if (icon != null) ...[ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + icon, + size: 18, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 12), + ] else + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + if (child != null) + Flexible( + child: child!, + ) + else if (trailing != null) + trailing!, + ], + ), + ); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 1047578..4181fdc 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -2,14 +2,13 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/connection_provider.dart'; import '../providers/app_provider.dart'; -import '../models/device_info.dart' as models; -import '../services/tile_cache_service.dart'; import '../theme/app_theme.dart'; import 'messages_tab.dart'; import 'contacts_tab.dart'; import 'map_tab.dart'; import 'map_management_screen.dart'; import 'settings_screen.dart'; +import 'device_config_screen.dart'; class HomeScreen extends StatefulWidget { final Function(AppThemeMode) onThemeChanged; @@ -352,7 +351,7 @@ class _HomeScreenState extends State with SingleTickerProviderStateM ), Text( isConnected - ? deviceInfo.deviceName ?? 'Connected' + ? deviceInfo.displayName ?? 'Connected' : 'Disconnected', style: TextStyle( fontSize: 14, @@ -420,22 +419,42 @@ class _HomeScreenState extends State with SingleTickerProviderStateM ), ), const SizedBox(width: 12), - OutlinedButton( + // Settings button + IconButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const DeviceConfigScreen(), + ), + ); + }, + icon: const Icon(Icons.settings), + iconSize: 20, + tooltip: 'Device Settings', + constraints: const BoxConstraints( + minWidth: 36, + minHeight: 36, + ), + padding: EdgeInsets.zero, + ), + const SizedBox(width: 8), + // Disconnect button (prominent, icon only) + FilledButton( onPressed: () async { await provider.disconnect(); if (context.mounted) { context.read().clearAllData(); } }, - style: OutlinedButton.styleFrom( + style: FilledButton.styleFrom( + backgroundColor: Colors.red.shade700, foregroundColor: Colors.white, - side: const BorderSide(color: Colors.white), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.all(10), + minimumSize: const Size(40, 40), + shape: const CircleBorder(), ), - child: const Text('Disconnect', style: TextStyle(fontSize: 13)), + child: const Icon(Icons.power_settings_new, size: 20), ), ], ), @@ -467,7 +486,7 @@ class _HomeScreenState extends State with SingleTickerProviderStateM Expanded( child: Text( isConnected - ? deviceInfo.deviceName ?? 'Connected' + ? deviceInfo.displayName ?? 'Connected' : 'Not Connected', style: Theme.of(context).textTheme.bodyMedium, ), diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index 0965b01..cbafd58 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -687,7 +687,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { super.build(context); // Required for AutomaticKeepAliveClientMixin return Consumer2( builder: (context, contactsProvider, messagesProvider, child) { - final contactsWithLocation = contactsProvider.chatContactsWithLocation; + final contactsWithLocation = contactsProvider.contactsWithLocation; final sarMarkers = messagesProvider.sarMarkers; final center = _calculateCenter(contactsWithLocation, sarMarkers); @@ -729,7 +729,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { onContactTap: (contact) { _showDetailedCompassWithContact( context, - contactsProvider.chatContactsWithLocation, + contactsProvider.contactsWithLocation, messagesProvider.sarMarkers, contact, ); @@ -742,7 +742,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { onSarMarkerTap: (marker) { _showDetailedCompassWithSarMarker( context, - contactsProvider.chatContactsWithLocation, + contactsProvider.contactsWithLocation, messagesProvider.sarMarkers, marker, ); @@ -807,7 +807,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { child: GestureDetector( onTap: () => _showDetailedCompass( context, - contactsProvider.chatContactsWithLocation, + contactsProvider.contactsWithLocation, messagesProvider.sarMarkers, ), child: _CompassWidget( diff --git a/lib/services/meshcore_ble_service.dart b/lib/services/meshcore_ble_service.dart index 41a525a..0cf725f 100644 --- a/lib/services/meshcore_ble_service.dart +++ b/lib/services/meshcore_ble_service.dart @@ -14,6 +14,7 @@ typedef OnContactCallback = void Function(Contact contact); typedef OnContactsCompleteCallback = void Function(List contacts); typedef OnMessageCallback = void Function(Message message); typedef OnTelemetryCallback = void Function(Uint8List publicKey, Uint8List lppData); +typedef OnSelfInfoCallback = void Function(Map selfInfo); typedef OnErrorCallback = void Function(String error); typedef OnConnectionStateCallback = void Function(bool isConnected); @@ -30,6 +31,7 @@ class MeshCoreBleService { OnContactsCompleteCallback? onContactsComplete; OnMessageCallback? onMessageReceived; OnTelemetryCallback? onTelemetryReceived; + OnSelfInfoCallback? onSelfInfoReceived; OnErrorCallback? onError; // Internal state @@ -636,11 +638,29 @@ class MeshCoreBleService { print(' Position: ${advLat / 1000000.0}, ${advLon / 1000000.0}'); print(' Radio: freq=$radioFreq, bw=$radioBw, sf=$radioSf, cr=$radioCr'); + String? selfName; if (reader.hasRemaining) { - final selfName = String.fromCharCodes(reader.readRemainingBytes().takeWhile((b) => b != 0)); + selfName = String.fromCharCodes(reader.readRemainingBytes().takeWhile((b) => b != 0)); print(' Self name: $selfName'); } + // Call callback with parsed data + onSelfInfoReceived?.call({ + 'protocolVersion': protocolVersion, + 'deviceType': deviceType, + 'txPower': txPower, + 'maxTxPower': maxTxPower, + 'publicKey': publicKey, + 'advLat': advLat, + 'advLon': advLon, + 'manualAddContacts': manualAddContacts == 1, + 'radioFreq': radioFreq, + 'radioBw': radioBw, + 'radioSf': radioSf, + 'radioCr': radioCr, + 'selfName': selfName, + }); + print(' ✅ [SelfInfo] Parsed successfully'); } catch (e) { print(' ❌ [SelfInfo] Parsing error: $e'); @@ -718,6 +738,11 @@ class MeshCoreBleService { await _sendAppStart(); } + /// Refresh device info (public method) + Future refreshDeviceInfo() async { + await _sendDeviceQuery(); + } + /// Get contacts from device Future getContacts() async { final writer = BufferWriter(); @@ -793,6 +818,50 @@ class MeshCoreBleService { await _writeData(writer.toBytes()); } + /// Set advertised name + Future setAdvertName(String name) async { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSetAdvertName); + writer.writeString(name); + await _writeData(writer.toBytes()); + } + + /// Set advertised latitude and longitude + Future setAdvertLatLon({ + required double latitude, + required double longitude, + }) async { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSetAdvertLatLon); + writer.writeInt32LE((latitude * 1000000).round()); + writer.writeInt32LE((longitude * 1000000).round()); + await _writeData(writer.toBytes()); + } + + /// Set radio parameters + Future setRadioParams({ + required int frequency, // Hz + required int bandwidth, // 0-9 (see bandwidth options) + required int spreadingFactor, // 7-12 + required int codingRate, // 5-8 + }) async { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSetRadioParams); + writer.writeUInt32LE(frequency); + writer.writeUInt16LE(bandwidth); + writer.writeByte(spreadingFactor); + writer.writeByte(codingRate); + await _writeData(writer.toBytes()); + } + + /// Set transmit power + Future setTxPower(int powerDbm) async { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSetTxPower); + writer.writeByte(powerDbm); + await _writeData(writer.toBytes()); + } + /// Reset packet counters void resetCounters() { _rxPacketCount = 0; diff --git a/lib/utils/sample_data_generator.dart b/lib/utils/sample_data_generator.dart index 61872c4..540bc67 100644 --- a/lib/utils/sample_data_generator.dart +++ b/lib/utils/sample_data_generator.dart @@ -72,8 +72,8 @@ class SampleDataGenerator { outPath: Uint8List(32), advName: teamNames[i], lastAdvert: now.millisecondsSinceEpoch ~/ 1000, - advLat: (lat * 1e7).toInt(), - advLon: (lon * 1e7).toInt(), + advLat: (lat * 1e6).toInt(), + advLon: (lon * 1e6).toInt(), lastMod: now.millisecondsSinceEpoch ~/ 1000, telemetry: telemetry, ); diff --git a/lib/widgets/map_markers.dart b/lib/widgets/map_markers.dart index 3adc1a3..b614e97 100644 --- a/lib/widgets/map_markers.dart +++ b/lib/widgets/map_markers.dart @@ -52,7 +52,7 @@ class MapMarkers { // Marker icon Container( decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, + color: _getContactTypeColor(contact, context), shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), boxShadow: [ @@ -64,8 +64,8 @@ class MapMarkers { ], ), padding: const EdgeInsets.all(6), - child: const Icon( - Icons.person, + child: Icon( + _getContactTypeIcon(contact), color: Colors.white, size: 18, ), @@ -294,6 +294,32 @@ class MapMarkers { return Colors.grey; } } + + static Color _getContactTypeColor(Contact contact, BuildContext context) { + switch (contact.type) { + case ContactType.chat: + return Theme.of(context).colorScheme.primary; // Blue for team members + case ContactType.repeater: + return Colors.deepPurple; // Purple for repeaters + case ContactType.room: + return Colors.teal; // Teal for rooms/channels + case ContactType.none: + return Colors.grey; + } + } + + static IconData _getContactTypeIcon(Contact contact) { + switch (contact.type) { + case ContactType.chat: + return Icons.person; // Person for team members + case ContactType.repeater: + return Icons.router; // Router icon for repeaters + case ContactType.room: + return Icons.forum; // Forum/chat icon for rooms + case ContactType.none: + return Icons.help_outline; + } + } } class _InfoRow extends StatelessWidget {