From 721fb7e7a29a2a72bf0cc7e042fd289007369aa7 Mon Sep 17 00:00:00 2001 From: Janez T Date: Thu, 19 Mar 2026 21:38:54 +0100 Subject: [PATCH] feat: Add serial compass support #0 --- lib/models/device_info.dart | 4 +- lib/providers/connection_provider.dart | 96 +++++- lib/screens/contacts_tab.dart | 59 ++-- lib/services/serial/serial_transport.dart | 50 ++++ lib/services/serial/serial_transport_io.dart | 283 ++++++++++++++++++ .../serial/serial_transport_stub.dart | 35 +++ lib/services/serial/serial_transport_web.dart | 209 +++++++++++++ lib/widgets/connection_dialog.dart | 206 +++++++------ lib/widgets/map/compass/compass_header.dart | 129 ++++---- lib/widgets/map/compass/compass_math.dart | 22 ++ lib/widgets/map/compass_widget.dart | 9 +- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Podfile | 5 +- macos/Podfile.lock | 21 +- macos/Runner.xcodeproj/project.pbxproj | 6 +- macos/Runner/DebugProfile.entitlements | 4 - macos/Runner/Info.plist | 6 + macos/Runner/Release.entitlements | 2 - pubspec.lock | 32 ++ pubspec.yaml | 4 +- test/screens/contacts_tab_test.dart | 2 - test/widgets/compass_math_test.dart | 36 +++ .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 26 files changed, 1014 insertions(+), 217 deletions(-) create mode 100644 lib/services/serial/serial_transport.dart create mode 100644 lib/services/serial/serial_transport_io.dart create mode 100644 lib/services/serial/serial_transport_stub.dart create mode 100644 lib/services/serial/serial_transport_web.dart create mode 100644 lib/widgets/map/compass/compass_math.dart create mode 100644 test/widgets/compass_math_test.dart diff --git a/lib/models/device_info.dart b/lib/models/device_info.dart index d576cb3..d4d6588 100644 --- a/lib/models/device_info.dart +++ b/lib/models/device_info.dart @@ -29,7 +29,7 @@ extension ConnectionModeExtension on ConnectionMode { case ConnectionMode.tcp: return 'Direct (WiFi)'; case ConnectionMode.usb: - return 'Direct (USB)'; + return 'Direct (Serial)'; } } @@ -40,7 +40,7 @@ extension ConnectionModeExtension on ConnectionMode { case ConnectionMode.tcp: return 'Direct WiFi/TCP connection to MeshCore device'; case ConnectionMode.usb: - return 'USB serial connection to MeshCore device'; + return 'Direct serial connection to MeshCore device'; } } } diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index ea6a5fd..1741f15 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -73,6 +73,7 @@ class ConnectionProvider with ChangeNotifier { final MeshCoreBleService _bleService = MeshCoreBleService(); MeshCoreTcpService? _tcpService; MeshCoreSerialService? _serialService; + Future Function()? _serialDisconnectTransport; /// Expose BLE service for background location tracking MeshCoreBleService get bleService => _bleService; @@ -246,7 +247,7 @@ class ConnectionProvider with ChangeNotifier { } if (activeMode != ConnectionMode.usb && _serialService != null) { - _disposeSerialService(); + await _disposeSerialService(); } } @@ -260,10 +261,20 @@ class ConnectionProvider with ChangeNotifier { service.dispose(); } - void _disposeSerialService() { - _serialService?.markDisconnected(); - _serialService?.dispose(); + Future _disposeSerialService() async { + final service = _serialService; + final disconnectTransport = _serialDisconnectTransport; _serialService = null; + _serialDisconnectTransport = null; + if (service == null) { + return; + } + service.markDisconnected(); + service.writeRaw = null; + if (disconnectTransport != null) { + await disconnectTransport(); + } + service.dispose(); } void _beginConnectionAttempt({ @@ -635,6 +646,13 @@ class ConnectionProvider with ChangeNotifier { debugPrint('✅ [Provider] Scan state initialized, notifying listeners'); try { + final adapterError = await _awaitBleAdapterReady(); + if (adapterError != null) { + debugPrint('⚠️ [Provider] BLE adapter not ready: $adapterError'); + _error = adapterError; + return; + } + await for (final scanResult in _bleService.scanForDevices( timeout: const Duration(seconds: 10), )) { @@ -676,6 +694,53 @@ class ConnectionProvider with ChangeNotifier { } } + Future _awaitBleAdapterReady({ + Duration timeout = const Duration(seconds: 4), + }) async { + if (!await FlutterBluePlus.isSupported) { + return 'Bluetooth is not supported on this device.'; + } + + var state = await FlutterBluePlus.adapterState.first; + debugPrint('🔵 [Provider] Initial BLE adapter state: $state'); + + if (state == BluetoothAdapterState.unknown || + state == BluetoothAdapterState.turningOn) { + debugPrint('⏳ [Provider] Waiting for BLE adapter to finish initializing'); + try { + state = await FlutterBluePlus.adapterState + .where( + (candidate) => + candidate != BluetoothAdapterState.unknown && + candidate != BluetoothAdapterState.turningOn, + ) + .first + .timeout(timeout); + } on TimeoutException { + state = FlutterBluePlus.adapterStateNow; + } + debugPrint('🔵 [Provider] BLE adapter state after wait: $state'); + } + + if (state == BluetoothAdapterState.on) { + return null; + } + + return switch (state) { + BluetoothAdapterState.off => + 'Bluetooth is turned off. Turn it on and try again.', + BluetoothAdapterState.turningOff => + 'Bluetooth is turning off. Turn it back on and try again.', + BluetoothAdapterState.unauthorized => + 'Bluetooth access is not allowed. Check app permissions in System Settings and try again.', + BluetoothAdapterState.unavailable => + 'Bluetooth hardware is unavailable. On macOS, verify Bluetooth access is enabled for this app target.', + BluetoothAdapterState.unknown || BluetoothAdapterState.turningOn => + 'Bluetooth is still initializing. Please try again in a moment.', + BluetoothAdapterState.on => null, + }; + } + /// Stop scanning Future stopScan() async { await FlutterBluePlus.stopScan(); @@ -762,16 +827,22 @@ class ConnectionProvider with ChangeNotifier { /// The caller is responsible for opening the serial port and wiring /// [service.writeRaw] + [service.feedRawBytes] before calling this. /// After this call succeeds, [service.markConnected()] has already run. - Future connectSerial(MeshCoreSerialService service) async { + Future connectSerial({ + required MeshCoreSerialService service, + required Future Function() disconnectTransport, + required String deviceId, + required String deviceName, + }) async { debugPrint('🔌 [Provider] connectSerial()'); await _prepareForConnectionSwitch(ConnectionMode.usb); - _disposeSerialService(); + await _disposeSerialService(); _serialService = service; + _serialDisconnectTransport = disconnectTransport; _wireServiceCallbacks(_serialService!); _beginConnectionAttempt( mode: ConnectionMode.usb, - deviceId: 'usb', - deviceName: 'USB Companion', + deviceId: deviceId, + deviceName: deviceName, ); // markConnected() should already have been called by the transport. @@ -780,6 +851,7 @@ class ConnectionProvider with ChangeNotifier { _deviceInfo = _deviceInfo.copyWith( connectionState: ConnectionState.error, ); + await _disposeSerialService(); notifyListeners(); return false; } @@ -788,7 +860,7 @@ class ConnectionProvider with ChangeNotifier { /// Disconnect from USB serial device. Future disconnectSerial() async { - _disposeSerialService(); + await _disposeSerialService(); _resetConnectionSession(); } @@ -2771,6 +2843,12 @@ class ConnectionProvider with ChangeNotifier { _rxActivityTimer?.cancel(); _txActivityTimer?.cancel(); _stopAckCleanupTimer(); + final disconnectTransport = _serialDisconnectTransport; + if (disconnectTransport != null) { + unawaited(disconnectTransport()); + _serialDisconnectTransport = null; + } + _serialService?.dispose(); _bleService.dispose(); _tcpService?.dispose(); super.dispose(); diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index e4df9e4..d8a17c9 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -1551,14 +1551,12 @@ class _SectionHeader extends StatelessWidget { final int count; final IconData icon; final Color accentColor; - final Widget? trailing; const _SectionHeader({ required this.title, required this.count, required this.icon, required this.accentColor, - this.trailing, }); @override @@ -1596,46 +1594,25 @@ class _SectionHeader extends StatelessWidget { ], ); - return LayoutBuilder( - builder: (context, constraints) { - final useStackedLayout = trailing != null && constraints.maxWidth < 430; - - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 42, - height: 42, - decoration: BoxDecoration( - color: accentColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: accentColor.withValues(alpha: 0.18), - ), - ), - alignment: Alignment.center, - child: Icon(icon, size: 20, color: accentColor), - ), - const SizedBox(width: 12), - Expanded(child: titleBlock), - if (!useStackedLayout && trailing != null) ...[ - const SizedBox(width: 12), - Flexible(child: trailing!), - ], - ], - ), - if (useStackedLayout) ...[ - const SizedBox(height: 10), - Align(alignment: Alignment.centerRight, child: trailing!), - ], - ], + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: accentColor.withValues(alpha: 0.18)), + ), + alignment: Alignment.center, + child: Icon(icon, size: 20, color: accentColor), ), - ); - }, + const SizedBox(width: 12), + Expanded(child: titleBlock), + ], + ), ); } } diff --git a/lib/services/serial/serial_transport.dart b/lib/services/serial/serial_transport.dart new file mode 100644 index 0000000..75764b9 --- /dev/null +++ b/lib/services/serial/serial_transport.dart @@ -0,0 +1,50 @@ +import 'package:meshcore_client/meshcore_client.dart'; + +import 'serial_transport_stub.dart' + if (dart.library.io) 'serial_transport_io.dart' + if (dart.library.js_interop) 'serial_transport_web.dart'; + +const int kSerialBaudRate = 115200; + +class SerialDeviceInfo { + final String id; + final String title; + final String subtitle; + final Object handle; + + const SerialDeviceInfo({ + required this.id, + required this.title, + required this.subtitle, + required this.handle, + }); +} + +class SerialConnection { + final MeshCoreSerialService service; + final String deviceId; + final String deviceName; + final Future Function() disconnect; + + const SerialConnection({ + required this.service, + required this.deviceId, + required this.deviceName, + required this.disconnect, + }); +} + +abstract class SerialTransport { + bool get isSupported; + bool get canRequestDevice; + String get actionLabel; + String get emptyStateTitle; + String get unsupportedMessage; + + Future> listDevices(); + Future requestDevice(); + Future connect(SerialDeviceInfo device); + void dispose(); +} + +SerialTransport createSerialTransport() => createSerialTransportImpl(); diff --git a/lib/services/serial/serial_transport_io.dart b/lib/services/serial/serial_transport_io.dart new file mode 100644 index 0000000..985dff3 --- /dev/null +++ b/lib/services/serial/serial_transport_io.dart @@ -0,0 +1,283 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_libserialport/flutter_libserialport.dart'; +import 'package:meshcore_client/meshcore_client.dart'; +import 'package:usb_serial/usb_serial.dart'; + +import 'serial_transport.dart'; + +class _IoSerialTransport implements SerialTransport { + @override + bool get isSupported => + Platform.isAndroid || Platform.isMacOS || Platform.isWindows; + + @override + bool get canRequestDevice => false; + + @override + String get actionLabel => + Platform.isAndroid ? 'Scan USB devices' : 'Scan serial ports'; + + @override + String get emptyStateTitle => Platform.isAndroid + ? 'No USB serial devices found.\nConnect a MeshCore device via OTG cable.' + : 'No serial ports found.\nConnect a MeshCore device over USB serial.'; + + @override + String get unsupportedMessage => + 'Serial is supported on Android, macOS, Windows, and compatible web browsers.'; + + @override + Future> listDevices() async { + if (Platform.isAndroid) { + final devices = await UsbSerial.listDevices(); + return devices.map(_androidDeviceInfo).toList(growable: false); + } + if (Platform.isMacOS || Platform.isWindows) { + return _desktopSerialDevices(); + } + return const []; + } + + @override + Future requestDevice() async => null; + + @override + Future connect(SerialDeviceInfo device) async { + if (Platform.isAndroid) { + return _connectAndroid(device); + } + if (Platform.isMacOS || Platform.isWindows) { + return _connectDesktop(device); + } + throw UnsupportedError(unsupportedMessage); + } + + SerialDeviceInfo _androidDeviceInfo(UsbDevice device) { + final title = _firstNonEmpty([ + device.productName, + device.manufacturerName, + 'USB Serial Device', + ]); + final subtitleParts = [ + if ((device.manufacturerName ?? '').trim().isNotEmpty) + device.manufacturerName!.trim(), + if (device.vid != null && device.pid != null) + 'VID:${_hex(device.vid!)} PID:${_hex(device.pid!)}', + ]; + + return SerialDeviceInfo( + id: '${device.deviceId}:${device.vid ?? 'na'}:${device.pid ?? 'na'}:${device.productName ?? 'usb'}', + title: title, + subtitle: subtitleParts.isEmpty + ? 'Ready over OTG serial' + : subtitleParts.join(' • '), + handle: device, + ); + } + + Future> _desktopSerialDevices() async { + final ports = []; + for (final address in SerialPort.availablePorts) { + final port = SerialPort(address); + try { + final title = _firstNonEmpty([ + port.productName, + port.description, + address, + ]); + final subtitleParts = [ + address, + if ((port.manufacturer ?? '').trim().isNotEmpty) + port.manufacturer!.trim(), + if (port.vendorId != null && port.productId != null) + 'VID:${_hex(port.vendorId!)} PID:${_hex(port.productId!)}', + ]; + ports.add( + SerialDeviceInfo( + id: address, + title: title, + subtitle: subtitleParts.join(' • '), + handle: address, + ), + ); + } finally { + port.dispose(); + } + } + return ports; + } + + Future _connectAndroid(SerialDeviceInfo device) async { + final usbDevice = device.handle as UsbDevice; + final service = MeshCoreSerialService(appName: 'MeshCore SAR'); + StreamSubscription? inputSubscription; + UsbPort? port; + + try { + port = await usbDevice.create(); + if (port == null) { + throw Exception('Failed to create USB port'); + } + final opened = await port.open(); + if (!opened) { + throw Exception('Failed to open USB port'); + } + + await port.setDTR(true); + await port.setRTS(true); + await port.setPortParameters( + kSerialBaudRate, + UsbPort.DATABITS_8, + UsbPort.STOPBITS_1, + UsbPort.PARITY_NONE, + ); + + service.writeRaw = (data) async { + await port!.write(data); + }; + + inputSubscription = port.inputStream?.listen( + (data) => service.feedRawBytes(data), + onError: (error) { + debugPrint('❌ [Serial/Android] Read error: $error'); + service.markDisconnected(); + }, + onDone: () { + debugPrint('⚠️ [Serial/Android] Port closed'); + service.markDisconnected(); + }, + ); + + final connected = await service.markConnected(); + if (!connected) { + throw Exception('Serial session initialization failed'); + } + + return SerialConnection( + service: service, + deviceId: + '${usbDevice.deviceId}:${usbDevice.vid ?? 'na'}:${usbDevice.pid ?? 'na'}', + deviceName: device.title, + disconnect: () async { + await inputSubscription?.cancel(); + inputSubscription = null; + service.writeRaw = null; + await port?.close(); + port = null; + }, + ); + } catch (_) { + await inputSubscription?.cancel(); + service.writeRaw = null; + await port?.close(); + service.dispose(); + rethrow; + } + } + + Future _connectDesktop(SerialDeviceInfo device) async { + final portName = device.handle as String; + final port = SerialPort(portName); + final config = SerialPortConfig(); + final service = MeshCoreSerialService(appName: 'MeshCore SAR'); + SerialPortReader? reader; + StreamSubscription? inputSubscription; + + try { + if (!port.openReadWrite()) { + throw Exception( + SerialPort.lastError?.message ?? 'Failed to open serial port', + ); + } + + config.baudRate = kSerialBaudRate; + config.bits = 8; + config.stopBits = 1; + config.parity = SerialPortParity.none; + config.setFlowControl(SerialPortFlowControl.none); + config.dtr = SerialPortDtr.on; + config.rts = SerialPortRts.on; + port.config = config; + + service.writeRaw = (data) async { + var offset = 0; + while (offset < data.length) { + final written = port.write(data.sublist(offset)); + if (written <= 0) { + throw Exception( + SerialPort.lastError?.message ?? 'Failed to write to serial port', + ); + } + offset += written; + } + }; + + reader = SerialPortReader(port); + inputSubscription = reader.stream.listen( + (data) => service.feedRawBytes(data), + onError: (error) { + debugPrint('❌ [Serial/Desktop] Read error: $error'); + service.markDisconnected(); + }, + onDone: () { + debugPrint('⚠️ [Serial/Desktop] Reader closed'); + service.markDisconnected(); + }, + ); + + final connected = await service.markConnected(); + if (!connected) { + throw Exception('Serial session initialization failed'); + } + + return SerialConnection( + service: service, + deviceId: portName, + deviceName: device.title, + disconnect: () async { + await inputSubscription?.cancel(); + inputSubscription = null; + reader?.close(); + reader = null; + service.writeRaw = null; + if (port.isOpen) { + port.close(); + } + port.dispose(); + config.dispose(); + }, + ); + } catch (_) { + await inputSubscription?.cancel(); + reader?.close(); + service.writeRaw = null; + if (port.isOpen) { + port.close(); + } + port.dispose(); + config.dispose(); + service.dispose(); + rethrow; + } + } + + @override + void dispose() {} +} + +String _firstNonEmpty(List values) { + for (final value in values) { + final trimmed = value?.trim(); + if (trimmed != null && trimmed.isNotEmpty) { + return trimmed; + } + } + return 'Serial Device'; +} + +String _hex(int value) => value.toRadixString(16).padLeft(4, '0'); + +SerialTransport createSerialTransportImpl() => _IoSerialTransport(); diff --git a/lib/services/serial/serial_transport_stub.dart b/lib/services/serial/serial_transport_stub.dart new file mode 100644 index 0000000..5c30f4d --- /dev/null +++ b/lib/services/serial/serial_transport_stub.dart @@ -0,0 +1,35 @@ +import 'serial_transport.dart'; + +class _UnsupportedSerialTransport implements SerialTransport { + @override + bool get isSupported => false; + + @override + bool get canRequestDevice => false; + + @override + String get actionLabel => 'Serial unsupported'; + + @override + String get emptyStateTitle => 'Serial is unavailable on this platform.'; + + @override + String get unsupportedMessage => + 'Serial is supported on Android, macOS, Windows, and compatible web browsers.'; + + @override + Future> listDevices() async => const []; + + @override + Future requestDevice() async => null; + + @override + Future connect(SerialDeviceInfo device) async { + throw UnsupportedError(unsupportedMessage); + } + + @override + void dispose() {} +} + +SerialTransport createSerialTransportImpl() => _UnsupportedSerialTransport(); diff --git a/lib/services/serial/serial_transport_web.dart b/lib/services/serial/serial_transport_web.dart new file mode 100644 index 0000000..cbff9a8 --- /dev/null +++ b/lib/services/serial/serial_transport_web.dart @@ -0,0 +1,209 @@ +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +import 'package:meshcore_client/meshcore_client.dart'; +import 'package:web/web.dart' as web; +import 'package:webserial/webserial.dart'; + +import 'serial_transport.dart'; + +class _WebSerialTransport implements SerialTransport { + @override + bool get isSupported => + web.window.navigator.hasProperty('serial'.toJS).toDart; + + @override + bool get canRequestDevice => true; + + @override + String get actionLabel => 'Choose serial device'; + + @override + String get emptyStateTitle => + 'No serial devices granted yet.\nChoose a device and approve access in your browser.'; + + @override + String get unsupportedMessage => + 'Web Serial requires a compatible browser such as Chrome or Edge and a secure context (HTTPS or localhost).'; + + @override + Future> listDevices() async { + if (!isSupported) { + return const []; + } + final ports = await serial.getPorts().toDart; + return ports + .toDart + .whereType() + .map(_deviceInfoForPort) + .toList(growable: false); + } + + @override + Future requestDevice() async { + if (!isSupported) { + return null; + } + final port = await requestWebSerialPort(null); + if (port == null) { + return null; + } + return _deviceInfoForPort(port); + } + + @override + Future connect(SerialDeviceInfo device) async { + final port = device.handle as JSSerialPort; + final service = MeshCoreSerialService(appName: 'MeshCore SAR'); + web.ReadableStreamDefaultReader? reader; + bool keepReading = false; + + try { + await port + .open( + JSSerialOptions( + baudRate: kSerialBaudRate, + dataBits: 8, + stopBits: 1, + parity: 'none', + bufferSize: 1024, + flowControl: 'none', + ), + ) + .toDart; + + await port + .setSignals( + JSSerialOutputSignals( + dataTerminalReady: true, + requestToSend: true, + ), + ) + .toDart; + + service.writeRaw = (data) async { + final writable = port.writable; + if (writable == null) { + throw Exception('Serial port is not writable'); + } + final writer = + writable.getWriter() as web.WritableStreamDefaultWriter?; + if (writer == null) { + throw Exception('Failed to acquire serial writer'); + } + try { + await writer.write(data.toJS).toDart; + } finally { + writer.releaseLock(); + } + }; + + final readable = port.readable; + if (readable == null) { + throw Exception('Serial port is not readable'); + } + + reader = readable.getReader() as web.ReadableStreamDefaultReader?; + if (reader == null) { + throw Exception('Failed to acquire serial reader'); + } + + keepReading = true; + unawaited(_readLoop(reader, service, () => keepReading)); + + final connected = await service.markConnected(); + if (!connected) { + throw Exception('Serial session initialization failed'); + } + + return SerialConnection( + service: service, + deviceId: device.id, + deviceName: device.title, + disconnect: () async { + keepReading = false; + try { + await reader?.cancel().toDart; + } catch (_) {} + try { + reader?.releaseLock(); + } catch (_) {} + reader = null; + service.writeRaw = null; + try { + await port.close().toDart; + } catch (_) {} + }, + ); + } catch (_) { + keepReading = false; + try { + await reader?.cancel().toDart; + } catch (_) {} + try { + reader?.releaseLock(); + } catch (_) {} + service.writeRaw = null; + try { + await port.close().toDart; + } catch (_) {} + service.dispose(); + rethrow; + } + } + + Future _readLoop( + web.ReadableStreamDefaultReader reader, + MeshCoreSerialService service, + bool Function() shouldContinue, + ) async { + while (shouldContinue()) { + try { + final result = await reader.read().toDart; + if (result.done) { + break; + } + final value = result.value; + if (value == null) { + continue; + } + service.feedRawBytes((value as JSUint8Array).toDart); + } catch (_) { + break; + } + } + service.markDisconnected(); + try { + reader.releaseLock(); + } catch (_) {} + } + + SerialDeviceInfo _deviceInfoForPort(JSSerialPort port) { + final info = port.getInfo(); + final vendorId = info.usbVendorId; + final productId = info.usbProductId; + final title = (vendorId != 0 || productId != 0) + ? 'USB ${_hex(vendorId)}:${_hex(productId)}' + : 'Granted serial device'; + final subtitle = [ + if (vendorId != 0 || productId != 0) + 'VID:${_hex(vendorId)} PID:${_hex(productId)}', + 'Web Serial', + ].join(' • '); + + return SerialDeviceInfo( + id: '$title::$subtitle', + title: title, + subtitle: subtitle, + handle: port, + ); + } + + @override + void dispose() {} +} + +String _hex(int value) => value.toRadixString(16).padLeft(4, '0'); + +SerialTransport createSerialTransportImpl() => _WebSerialTransport(); diff --git a/lib/widgets/connection_dialog.dart b/lib/widgets/connection_dialog.dart index e1acd98..914d97a 100644 --- a/lib/widgets/connection_dialog.dart +++ b/lib/widgets/connection_dialog.dart @@ -1,13 +1,11 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:meshcore_client/meshcore_client.dart' hide Contact; import 'package:provider/provider.dart'; -import 'package:usb_serial/usb_serial.dart'; import '../l10n/app_localizations.dart'; import '../providers/app_provider.dart'; import '../providers/connection_provider.dart'; import '../services/network_scanner_service.dart'; +import '../services/serial/serial_transport.dart'; /// Connection Dialog with tabs for BLE devices and Network servers class ConnectionDialog extends StatefulWidget { @@ -166,7 +164,7 @@ class _ConnectionDialogState extends State ), const SizedBox(height: 2), Text( - 'Choose Bluetooth, WiFi, or USB transport', + 'Choose Bluetooth, WiFi, or Serial transport', textAlign: TextAlign.center, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, @@ -192,7 +190,7 @@ class _ConnectionDialogState extends State tabs: const [ Tab(text: 'BLE', icon: Icon(Icons.bluetooth_rounded)), Tab(text: 'Network', icon: Icon(Icons.wifi_rounded)), - Tab(text: 'USB', icon: Icon(Icons.usb_rounded)), + Tab(text: 'Serial', icon: Icon(Icons.usb_rounded)), ], ), ], @@ -250,6 +248,36 @@ class _ConnectionDialogState extends State ); } + Widget _buildErrorBanner(String message) { + final theme = Theme.of(context); + return Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 12), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.error_outline_rounded, + color: theme.colorScheme.onErrorContainer, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ); + } + Widget _buildEmptyState({ required IconData icon, required String title, @@ -371,6 +399,8 @@ class _ConnectionDialogState extends State message: AppLocalizations.of(context)!.defaultPinInfo, onRefresh: _refreshBleDevices, ), + if (connectionProvider.error != null) + _buildErrorBanner(connectionProvider.error!), Expanded( child: connectionProvider.isScanning && @@ -597,7 +627,7 @@ class _ConnectionDialogState extends State } Widget _buildUsbTab() { - return _UsbDeviceList( + return _SerialDeviceList( buildTransportCard: ({ required icon, @@ -654,38 +684,48 @@ typedef _EmptyStateBuilder = required VoidCallback onAction, }); -class _UsbDeviceList extends StatefulWidget { +class _SerialDeviceList extends StatefulWidget { final VoidCallback onConnected; final _TransportCardBuilder buildTransportCard; final _EmptyStateBuilder buildEmptyState; - const _UsbDeviceList({ + const _SerialDeviceList({ required this.onConnected, required this.buildTransportCard, required this.buildEmptyState, }); @override - State<_UsbDeviceList> createState() => _UsbDeviceListState(); + State<_SerialDeviceList> createState() => _SerialDeviceListState(); } -class _UsbDeviceListState extends State<_UsbDeviceList> { - List _devices = []; +class _SerialDeviceListState extends State<_SerialDeviceList> { + final SerialTransport _transport = createSerialTransport(); + List _devices = []; bool _isScanning = false; bool _isConnecting = false; @override void initState() { super.initState(); - if (defaultTargetPlatform == TargetPlatform.android) { + if (_transport.isSupported) { _scanDevices(); } } + @override + void dispose() { + _transport.dispose(); + super.dispose(); + } + Future _scanDevices() async { + if (!_transport.isSupported) { + return; + } setState(() => _isScanning = true); try { - final devices = await UsbSerial.listDevices(); + final devices = await _transport.listDevices(); if (!mounted) return; setState(() { _devices = devices; @@ -700,81 +740,66 @@ class _UsbDeviceListState extends State<_UsbDeviceList> { } } - Future _connectToDevice(UsbDevice device) async { + Future _requestDevice() async { + if (!_transport.canRequestDevice) { + await _scanDevices(); + return; + } + + setState(() => _isScanning = true); + try { + final selectedDevice = await _transport.requestDevice(); + final devices = await _transport.listDevices(); + if (!mounted) return; + setState(() { + _devices = selectedDevice == null + ? devices + : _mergeDevices(devices, selectedDevice); + _isScanning = false; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _isScanning = false; + }); + } + } + + List _mergeDevices( + List devices, + SerialDeviceInfo selectedDevice, + ) { + final merged = [...devices]; + if (!merged.any((device) => device.id == selectedDevice.id)) { + merged.insert(0, selectedDevice); + } + return merged; + } + + Future _connectToDevice(SerialDeviceInfo device) async { setState(() => _isConnecting = true); try { final connectionProvider = context.read(); - final service = MeshCoreSerialService(appName: 'MeshCore SAR'); - - final port = await device.create(); - if (port == null) { - if (mounted) { - setState(() => _isConnecting = false); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to create USB port')), - ); - } - return; - } - - final opened = await port.open(); - if (!opened) { - if (mounted) { - setState(() => _isConnecting = false); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to open USB port')), - ); - } - return; - } - - await port.setDTR(true); - await port.setRTS(true); - await port.setPortParameters( - 115200, - UsbPort.DATABITS_8, - UsbPort.STOPBITS_1, - UsbPort.PARITY_NONE, + final appProvider = context.read(); + final connection = await _transport.connect(device); + final success = await connectionProvider.connectSerial( + service: connection.service, + disconnectTransport: connection.disconnect, + deviceId: connection.deviceId, + deviceName: connection.deviceName, ); - - service.writeRaw = (data) async { - await port.write(data); - }; - - port.inputStream?.listen( - (data) => service.feedRawBytes(data), - onError: (_) { - service.markDisconnected(); - port.close(); - }, - onDone: () { - service.markDisconnected(); - }, - ); - - final sessionOk = await service.markConnected(); - if (!sessionOk) { - await port.close(); - if (mounted) { - setState(() => _isConnecting = false); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('USB session initialization failed')), - ); - } - return; - } - - final success = await connectionProvider.connectSerial(service); if (!mounted) return; if (success) { + await appProvider.initialize(); widget.onConnected(); } else { - await port.close(); + await connection.disconnect(); + connection.service.dispose(); if (!mounted) return; setState(() => _isConnecting = false); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to connect via USB')), + const SnackBar(content: Text('Failed to connect via serial')), ); } } catch (e) { @@ -782,20 +807,18 @@ class _UsbDeviceListState extends State<_UsbDeviceList> { setState(() => _isConnecting = false); ScaffoldMessenger.of( context, - ).showSnackBar(SnackBar(content: Text('USB error: $e'))); + ).showSnackBar(SnackBar(content: Text('Serial error: $e'))); } } @override Widget build(BuildContext context) { - if (defaultTargetPlatform != TargetPlatform.android) { + if (!_transport.isSupported) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Text( - kIsWeb - ? 'Web Serial is not yet supported.\nUse BLE or Network instead.' - : 'USB serial is available on Android only.\nConnect via OTG cable.', + _transport.unsupportedMessage, textAlign: TextAlign.center, ), ), @@ -811,19 +834,22 @@ class _UsbDeviceListState extends State<_UsbDeviceList> { Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: FilledButton.tonalIcon( - onPressed: _isConnecting ? null : _scanDevices, + onPressed: _isConnecting + ? null + : (_transport.canRequestDevice ? _requestDevice : _scanDevices), icon: const Icon(Icons.usb_rounded), - label: const Text('Scan USB devices'), + label: Text(_transport.actionLabel), ), ), if (_devices.isEmpty) Expanded( child: widget.buildEmptyState( icon: Icons.usb_off_rounded, - title: - 'No USB serial devices found.\nConnect a MeshCore device via OTG cable.', - actionLabel: 'Scan USB devices', - onAction: _scanDevices, + title: _transport.emptyStateTitle, + actionLabel: _transport.actionLabel, + onAction: _transport.canRequestDevice + ? _requestDevice + : _scanDevices, ), ) else @@ -835,10 +861,8 @@ class _UsbDeviceListState extends State<_UsbDeviceList> { return widget.buildTransportCard( icon: Icons.usb_rounded, iconColor: Theme.of(context).colorScheme.primary, - title: device.productName ?? 'USB Device', - subtitle: (device.manufacturerName?.isNotEmpty ?? false) - ? device.manufacturerName! - : 'Ready over OTG serial', + title: device.title, + subtitle: device.subtitle, trailing: _isConnecting ? const SizedBox( width: 24, diff --git a/lib/widgets/map/compass/compass_header.dart b/lib/widgets/map/compass/compass_header.dart index 094ed81..780bd50 100644 --- a/lib/widgets/map/compass/compass_header.dart +++ b/lib/widgets/map/compass/compass_header.dart @@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart'; import '../../../l10n/app_localizations.dart'; import '../../../models/contact.dart'; import '../../../models/sar_marker.dart'; +import 'compass_math.dart'; /// Header component for the compass dialog showing compass rose, /// heading, elevation, accuracy, and current location in multiple formats. @@ -76,7 +77,11 @@ class CompassHeader extends StatelessWidget { ); } - Widget _buildInfoRow(BuildContext context, double? heading, Position? position) { + Widget _buildInfoRow( + BuildContext context, + double? heading, + Position? position, + ) { final l10n = AppLocalizations.of(context)!; return Row( mainAxisAlignment: MainAxisAlignment.spaceAround, @@ -90,17 +95,13 @@ class CompassHeader extends StatelessWidget { _buildInfoCard( context, l10n.elevation, - position?.altitude != null - ? '${position!.altitude.round()}m' - : '--', + position?.altitude != null ? '${position!.altitude.round()}m' : '--', Icons.terrain, ), _buildInfoCard( context, l10n.accuracy, - position?.accuracy != null - ? '±${position!.accuracy.round()}m' - : '--', + position?.accuracy != null ? '±${position!.accuracy.round()}m' : '--', Icons.gps_fixed, ), ], @@ -108,21 +109,22 @@ class CompassHeader extends StatelessWidget { } Widget _buildInfoCard( - BuildContext context, String label, String value, IconData icon) { + BuildContext context, + String label, + String value, + IconData icon, + ) { return Column( children: [ Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), const SizedBox(height: 4), Text( value, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Text( - label, - style: Theme.of(context).textTheme.bodySmall, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), + Text(label, style: Theme.of(context).textTheme.bodySmall), ], ); } @@ -193,11 +195,16 @@ class _LargeCompassPainter extends CustomPainter { // Draw degree markers for (int i = 0; i < 360; i += 10) { - final angle = i * pi / 180 - pi / 2 + heading * pi / 180; + final angle = compassDialAngleRadians( + markerDegrees: i.toDouble(), + headingDegrees: heading, + ); final isCardinal = i % 90 == 0; final isMajor = i % 30 == 0; - final startRadius = isCardinal ? radius - 25 : (isMajor ? radius - 15 : radius - 10); + final startRadius = isCardinal + ? radius - 25 + : (isMajor ? radius - 15 : radius - 10); final start = Offset( center.dx + startRadius * cos(angle), center.dy + startRadius * sin(angle), @@ -218,7 +225,10 @@ class _LargeCompassPainter extends CustomPainter { final textPainter = TextPainter(textDirection: TextDirection.ltr); final directions = ['N', 'E', 'S', 'W']; for (int i = 0; i < 4; i++) { - final angle = i * pi / 2 - pi / 2 + heading * pi / 180; + final angle = compassDialAngleRadians( + markerDegrees: (i * 90).toDouble(), + headingDegrees: heading, + ); final x = center.dx + (radius - 35) * cos(angle); final y = center.dy + (radius - 35) * sin(angle); @@ -243,20 +253,25 @@ class _LargeCompassPainter extends CustomPainter { final contactsWithDistance = contacts .where((c) => c.displayLocation != null) .map((contact) { - final bearing = _calculateBearing( - currentPosition!.latitude, - currentPosition!.longitude, - contact.displayLocation!.latitude, - contact.displayLocation!.longitude, - ); - final distance = _calculateDistance( - currentPosition!.latitude, - currentPosition!.longitude, - contact.displayLocation!.latitude, - contact.displayLocation!.longitude, - ); - return {'contact': contact, 'bearing': bearing, 'distance': distance}; - }).toList(); + final bearing = _calculateBearing( + currentPosition!.latitude, + currentPosition!.longitude, + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ); + final distance = _calculateDistance( + currentPosition!.latitude, + currentPosition!.longitude, + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ); + return { + 'contact': contact, + 'bearing': bearing, + 'distance': distance, + }; + }) + .toList(); if (contactsWithDistance.isEmpty) return; @@ -277,7 +292,8 @@ class _LargeCompassPainter extends CustomPainter { double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0); // Calculate contact position radius (from center to rim based on distance) - final contactRadius = radius * normalizedDistance * 0.85; // 0.85 to keep inside rim + final contactRadius = + radius * normalizedDistance * 0.85; // 0.85 to keep inside rim // Position of contact dot final dotX = center.dx + contactRadius * cos(angle); @@ -288,11 +304,7 @@ class _LargeCompassPainter extends CustomPainter { ..color = Colors.lightBlue.withValues(alpha: 0.3) ..style = PaintingStyle.stroke ..strokeWidth = 1.5; - canvas.drawLine( - center, - Offset(dotX, dotY), - linePaint, - ); + canvas.drawLine(center, Offset(dotX, dotY), linePaint); // Draw contact dot (size varies with zoom) final dotSize = (6.0 * (1.0 + zoomLevel * 0.3)).clamp(4.0, 12.0); @@ -341,7 +353,10 @@ class _LargeCompassPainter extends CustomPainter { textPainter.paint( canvas, - Offset(labelX - textPainter.width / 2, labelY - textPainter.height / 2), + Offset( + labelX - textPainter.width / 2, + labelY - textPainter.height / 2, + ), ); } } @@ -419,11 +434,7 @@ class _LargeCompassPainter extends CustomPainter { ..color = markerColor.withValues(alpha: 0.3) ..style = PaintingStyle.stroke ..strokeWidth = 2; - canvas.drawLine( - center, - Offset(dotX, dotY), - linePaint, - ); + canvas.drawLine(center, Offset(dotX, dotY), linePaint); // Draw SAR marker dot (slightly larger than contacts) final dotSize = (8.0 * (1.0 + zoomLevel * 0.3)).clamp(6.0, 14.0); @@ -472,7 +483,10 @@ class _LargeCompassPainter extends CustomPainter { textPainter.paint( canvas, - Offset(labelX - textPainter.width / 2, labelY - textPainter.height / 2), + Offset( + labelX - textPainter.width / 2, + labelY - textPainter.height / 2, + ), ); } } @@ -492,27 +506,31 @@ class _LargeCompassPainter extends CustomPainter { canvas.drawPath(path, indicatorPaint); } - double _calculateBearing( - double lat1, double lon1, double lat2, double lon2) { + double _calculateBearing(double lat1, double lon1, double lat2, double lon2) { final dLon = (lon2 - lon1) * pi / 180; final lat1Rad = lat1 * pi / 180; final lat2Rad = lat2 * pi / 180; final y = sin(dLon) * cos(lat2Rad); - final x = cos(lat1Rad) * sin(lat2Rad) - - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); + final x = + cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); final bearing = atan2(y, x) * 180 / pi; return (bearing + 360) % 360; } double _calculateDistance( - double lat1, double lon1, double lat2, double lon2) { + double lat1, + double lon1, + double lat2, + double lon2, + ) { const R = 6371000; // Earth's radius in meters final dLat = (lat2 - lat1) * pi / 180; final dLon = (lon2 - lon1) * pi / 180; - final a = sin(dLat / 2) * sin(dLat / 2) + + final a = + sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * pi / 180) * cos(lat2 * pi / 180) * sin(dLon / 2) * @@ -572,7 +590,8 @@ class _LocationFormatToggleState extends State<_LocationFormatToggle> { final String displayText; if (_showDMS) { - displayText = '${_formatDMS(position.latitude, true)} ${_formatDMS(position.longitude, false)}'; + displayText = + '${_formatDMS(position.latitude, true)} ${_formatDMS(position.longitude, false)}'; } else { displayText = l10n.latLonFormat( position.latitude.toStringAsFixed(5), @@ -598,9 +617,9 @@ class _LocationFormatToggleState extends State<_LocationFormatToggle> { displayText, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - ), + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + ), ), ), ), diff --git a/lib/widgets/map/compass/compass_math.dart b/lib/widgets/map/compass/compass_math.dart new file mode 100644 index 0000000..590dc4a --- /dev/null +++ b/lib/widgets/map/compass/compass_math.dart @@ -0,0 +1,22 @@ +import 'dart:math'; + +/// Rotate the compass rose opposite the device heading so the current heading +/// stays under the fixed indicator at the top of the dial. +double compassRoseRotationRadians(double headingDegrees) { + final normalizedHeading = headingDegrees % 360; + return _normalizeRadians(-normalizedHeading * pi / 180); +} + +/// Convert a compass marker bearing into an on-screen angle for the dial. +double compassDialAngleRadians({ + required double markerDegrees, + required double headingDegrees, +}) { + return _normalizeRadians( + markerDegrees * pi / 180 - + pi / 2 + + compassRoseRotationRadians(headingDegrees), + ); +} + +double _normalizeRadians(double angle) => atan2(sin(angle), cos(angle)); diff --git a/lib/widgets/map/compass_widget.dart b/lib/widgets/map/compass_widget.dart index 2616404..0dc5607 100644 --- a/lib/widgets/map/compass_widget.dart +++ b/lib/widgets/map/compass_widget.dart @@ -1,5 +1,6 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import 'compass/compass_math.dart'; class CompassWidget extends StatelessWidget { final double heading; @@ -21,9 +22,9 @@ class CompassWidget extends StatelessWidget { child: Stack( alignment: Alignment.center, children: [ - // Compass rose background - rotates to show true north at top + // Rotate the rose opposite the heading under the fixed needle. Transform.rotate( - angle: heading * pi / 180, + angle: compassRoseRotationRadians(heading), child: CustomPaint( size: const Size(40, 40), painter: _CompassRosePainter(), @@ -76,9 +77,7 @@ class _CompassRosePainter extends CustomPainter { canvas.drawCircle(center, radius, paint); // Draw cardinal direction markers - final textPainter = TextPainter( - textDirection: TextDirection.ltr, - ); + final textPainter = TextPainter(textDirection: TextDirection.ltr); final directions = ['N', 'E', 'S', 'W']; for (int i = 0; i < 4; i++) { diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 73a17a5..da7fc38 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -22,6 +23,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_avif_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAvifLinuxPlugin"); flutter_avif_linux_plugin_register_with_registrar(flutter_avif_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_libserialport_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterLibserialportPlugin"); + flutter_libserialport_plugin_register_with_registrar(flutter_libserialport_registrar); g_autoptr(FlPluginRegistrar) record_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); record_linux_plugin_register_with_registrar(record_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index d97f3af..aa14568 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux file_selector_linux flutter_avif_linux + flutter_libserialport record_linux url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 9e5eda2..b82cef6 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,6 +11,7 @@ import file_picker import file_selector_macos import flutter_avif_macos import flutter_blue_plus_darwin +import flutter_libserialport import flutter_local_notifications import geolocator_apple import nsd_macos @@ -30,6 +31,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterAvifPlugin.register(with: registry.registrar(forPlugin: "FlutterAvifPlugin")) FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) + FlutterLibserialportPlugin.register(with: registry.registrar(forPlugin: "FlutterLibserialportPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin")) diff --git a/macos/Podfile b/macos/Podfile index 51f3332..b5f3fe2 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.15' +platform :osx, '26.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -38,6 +38,9 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_macos_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '26.0' + end # codec2_flutter uses ARM-specific register asm("sp") in debug_alloc.h that # does not compile on x86_64 macOS. Voice messages are iOS-only, so exclude # all C source files from the macOS codec2_flutter build entirely. diff --git a/macos/Podfile.lock b/macos/Podfile.lock index ccb71c5..5127158 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -15,12 +15,16 @@ PODS: - flutter_blue_plus_darwin (0.0.2): - Flutter - FlutterMacOS + - flutter_libserialport (0.0.1): + - FlutterMacOS + - libserialport - flutter_local_notifications (0.0.1): - FlutterMacOS - FlutterMacOS (1.0.0) - geolocator_apple (1.2.0): - Flutter - FlutterMacOS + - libserialport (0.1.1) - nsd_macos (0.0.1): - FlutterMacOS - package_info_plus (0.0.1): @@ -40,6 +44,8 @@ PODS: - FlutterMacOS - url_launcher_macos (0.0.1): - FlutterMacOS + - wakelock_plus (0.0.1): + - FlutterMacOS DEPENDENCIES: - audioplayers_darwin (from `Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin`) @@ -49,6 +55,7 @@ DEPENDENCIES: - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - flutter_avif_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos`) - flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`) + - flutter_libserialport (from `Flutter/ephemeral/.symlinks/plugins/flutter_libserialport/macos`) - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`) @@ -60,6 +67,11 @@ DEPENDENCIES: - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`) + +SPEC REPOS: + trunk: + - libserialport EXTERNAL SOURCES: audioplayers_darwin: @@ -76,6 +88,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos flutter_blue_plus_darwin: :path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin + flutter_libserialport: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_libserialport/macos flutter_local_notifications: :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos FlutterMacOS: @@ -98,6 +112,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + wakelock_plus: + :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos SPEC CHECKSUMS: audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 @@ -107,9 +123,11 @@ SPEC CHECKSUMS: file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7 flutter_avif_macos: 9ed61d67adfbd6964eccb59971020fb55c31fc11 flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 + flutter_libserialport: 2c523cb8d8203fc6d1b0da88190da31ac970eb93 flutter_local_notifications: 1fc7ffb10a83d6a2eeeeddb152d43f1944b0aad0 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e + libserialport: 1cb25e66ef3c92a8e59c2ea3820302c3fa2268cd nsd_macos: a472240e770b92f6c6df1022403aa29c90d012e3 package_info_plus: f0052d280d17aa382b932f399edf32507174e870 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 @@ -118,7 +136,8 @@ SPEC CHECKSUMS: shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd + wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b -PODFILE CHECKSUM: e3de8f2486e300492cc80a2fe0576e9d269ba22b +PODFILE CHECKSUM: 467066e8d30d1105257e0877b8cdd20642e7b3db COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 3c4fb9d..f0c63c5 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -557,7 +557,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 26.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -639,7 +639,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 26.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -689,7 +689,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 26.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index dddb8a3..d35e43a 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -2,11 +2,7 @@ - com.apple.security.app-sandbox - com.apple.security.cs.allow-jit - com.apple.security.network.server - diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index f445a62..47e8855 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -24,10 +24,16 @@ LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) + NSBluetoothAlwaysUsageDescription + MeshCore SAR needs Bluetooth to discover and communicate with MeshCore devices during SAR operations + NSBluetoothPeripheralUsageDescription + MeshCore SAR needs Bluetooth to discover and communicate with MeshCore devices during SAR operations NSHumanReadableCopyright $(PRODUCT_COPYRIGHT) NSMainNibFile MainMenu + NSLocationUsageDescription + MeshCore SAR needs your location to track your team, share your position over the mesh network, and show your location on the map during SAR operations NSMicrophoneUsageDescription MeshCore SAR needs microphone access to send voice messages over the mesh radio network during SAR operations NSPrincipalClass diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 852fa1a..6631ffa 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -2,7 +2,5 @@ - com.apple.security.app-sandbox - diff --git a/pubspec.lock b/pubspec.lock index cd77622..048bd44 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -218,6 +218,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.3" + dylib: + dependency: transitive + description: + name: dylib + sha256: bf609b3eb6492a3309b3d1dbe8f83a4031de5535dd7686be33487051cc760bb0 + url: "https://pub.dev" + source: hosted + version: "0.3.3" exif: dependency: transitive description: @@ -479,6 +487,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.14.4" + flutter_libserialport: + dependency: "direct main" + description: + name: flutter_libserialport + sha256: f24b5fe6f1821d1c1b3bf2be737ca53f6b0ae78657032a790aa00abb810c8759 + url: "https://pub.dev" + source: hosted + version: "0.6.0" flutter_lints: dependency: "direct dev" description: @@ -759,6 +775,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + libserialport: + dependency: transitive + description: + name: libserialport + sha256: "392e1592def65282429832ec66fa25e9e163d3b37716b97691482e2406720727" + url: "https://pub.dev" + source: hosted + version: "0.3.0+1" lints: dependency: transitive description: @@ -1477,6 +1501,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webserial: + dependency: "direct main" + description: + name: webserial + sha256: b39c2175190ee1c75e996bf137bfee3e3e8fd06ee0ec24f0d3c10bdf7b323786 + url: "https://pub.dev" + source: hosted + version: "1.2.0" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 529885a..0de8403 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2026.0318.1+34 +version: 2026.0319.1+35 environment: sdk: ^3.9.2 @@ -123,6 +123,8 @@ dependencies: nsd: ^4.0.3 usb_serial: ^0.5.2 web: ^1.1.1 + flutter_libserialport: ^0.6.0 + webserial: ^1.2.0 dev_dependencies: flutter_test: diff --git a/test/screens/contacts_tab_test.dart b/test/screens/contacts_tab_test.dart index 135c49e..55e09ca 100644 --- a/test/screens/contacts_tab_test.dart +++ b/test/screens/contacts_tab_test.dart @@ -1,5 +1,3 @@ -import 'dart:typed_data'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/test/widgets/compass_math_test.dart b/test/widgets/compass_math_test.dart new file mode 100644 index 0000000..61d1c98 --- /dev/null +++ b/test/widgets/compass_math_test.dart @@ -0,0 +1,36 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/widgets/map/compass/compass_math.dart'; + +void main() { + group('compass dial math', () { + test('rotates the rose opposite the heading', () { + expect(compassRoseRotationRadians(0), closeTo(0, 1e-10)); + expect(compassRoseRotationRadians(90), closeTo(-pi / 2, 1e-10)); + expect(compassRoseRotationRadians(450), closeTo(-pi / 2, 1e-10)); + }); + + test('places east at the top when heading east', () { + expect( + compassDialAngleRadians(markerDegrees: 90, headingDegrees: 90), + closeTo(-pi / 2, 1e-10), + ); + expect( + compassDialAngleRadians(markerDegrees: 270, headingDegrees: 90), + closeTo(pi / 2, 1e-10), + ); + }); + + test('places west at the top when heading west', () { + expect( + compassDialAngleRadians(markerDegrees: 270, headingDegrees: 270), + closeTo(-pi / 2, 1e-10), + ); + expect( + compassDialAngleRadians(markerDegrees: 90, headingDegrees: 270), + closeTo(pi / 2, 1e-10), + ); + }); + }); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 713c73c..fbe3b11 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -26,6 +27,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterAvifWindowsPlugin")); FlutterBluePlusPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterBluePlusPlugin")); + FlutterLibserialportPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterLibserialportPlugin")); GeolocatorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("GeolocatorWindows")); NsdWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index afa41cb..0ed801f 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows flutter_avif_windows flutter_blue_plus_winrt + flutter_libserialport geolocator_windows nsd_windows permission_handler_windows