feat: Add serial compass support #0

This commit is contained in:
Janez T
2026-03-19 21:38:54 +01:00
parent 5a58e45e13
commit 721fb7e7a2
26 changed files with 1014 additions and 217 deletions

View File

@@ -29,7 +29,7 @@ extension ConnectionModeExtension on ConnectionMode {
case ConnectionMode.tcp: case ConnectionMode.tcp:
return 'Direct (WiFi)'; return 'Direct (WiFi)';
case ConnectionMode.usb: case ConnectionMode.usb:
return 'Direct (USB)'; return 'Direct (Serial)';
} }
} }
@@ -40,7 +40,7 @@ extension ConnectionModeExtension on ConnectionMode {
case ConnectionMode.tcp: case ConnectionMode.tcp:
return 'Direct WiFi/TCP connection to MeshCore device'; return 'Direct WiFi/TCP connection to MeshCore device';
case ConnectionMode.usb: case ConnectionMode.usb:
return 'USB serial connection to MeshCore device'; return 'Direct serial connection to MeshCore device';
} }
} }
} }

View File

@@ -73,6 +73,7 @@ class ConnectionProvider with ChangeNotifier {
final MeshCoreBleService _bleService = MeshCoreBleService(); final MeshCoreBleService _bleService = MeshCoreBleService();
MeshCoreTcpService? _tcpService; MeshCoreTcpService? _tcpService;
MeshCoreSerialService? _serialService; MeshCoreSerialService? _serialService;
Future<void> Function()? _serialDisconnectTransport;
/// Expose BLE service for background location tracking /// Expose BLE service for background location tracking
MeshCoreBleService get bleService => _bleService; MeshCoreBleService get bleService => _bleService;
@@ -246,7 +247,7 @@ class ConnectionProvider with ChangeNotifier {
} }
if (activeMode != ConnectionMode.usb && _serialService != null) { if (activeMode != ConnectionMode.usb && _serialService != null) {
_disposeSerialService(); await _disposeSerialService();
} }
} }
@@ -260,10 +261,20 @@ class ConnectionProvider with ChangeNotifier {
service.dispose(); service.dispose();
} }
void _disposeSerialService() { Future<void> _disposeSerialService() async {
_serialService?.markDisconnected(); final service = _serialService;
_serialService?.dispose(); final disconnectTransport = _serialDisconnectTransport;
_serialService = null; _serialService = null;
_serialDisconnectTransport = null;
if (service == null) {
return;
}
service.markDisconnected();
service.writeRaw = null;
if (disconnectTransport != null) {
await disconnectTransport();
}
service.dispose();
} }
void _beginConnectionAttempt({ void _beginConnectionAttempt({
@@ -635,6 +646,13 @@ class ConnectionProvider with ChangeNotifier {
debugPrint('✅ [Provider] Scan state initialized, notifying listeners'); debugPrint('✅ [Provider] Scan state initialized, notifying listeners');
try { 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( await for (final scanResult in _bleService.scanForDevices(
timeout: const Duration(seconds: 10), timeout: const Duration(seconds: 10),
)) { )) {
@@ -676,6 +694,53 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
Future<String?> _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 /// Stop scanning
Future<void> stopScan() async { Future<void> stopScan() async {
await FlutterBluePlus.stopScan(); await FlutterBluePlus.stopScan();
@@ -762,16 +827,22 @@ class ConnectionProvider with ChangeNotifier {
/// The caller is responsible for opening the serial port and wiring /// The caller is responsible for opening the serial port and wiring
/// [service.writeRaw] + [service.feedRawBytes] before calling this. /// [service.writeRaw] + [service.feedRawBytes] before calling this.
/// After this call succeeds, [service.markConnected()] has already run. /// After this call succeeds, [service.markConnected()] has already run.
Future<bool> connectSerial(MeshCoreSerialService service) async { Future<bool> connectSerial({
required MeshCoreSerialService service,
required Future<void> Function() disconnectTransport,
required String deviceId,
required String deviceName,
}) async {
debugPrint('🔌 [Provider] connectSerial()'); debugPrint('🔌 [Provider] connectSerial()');
await _prepareForConnectionSwitch(ConnectionMode.usb); await _prepareForConnectionSwitch(ConnectionMode.usb);
_disposeSerialService(); await _disposeSerialService();
_serialService = service; _serialService = service;
_serialDisconnectTransport = disconnectTransport;
_wireServiceCallbacks(_serialService!); _wireServiceCallbacks(_serialService!);
_beginConnectionAttempt( _beginConnectionAttempt(
mode: ConnectionMode.usb, mode: ConnectionMode.usb,
deviceId: 'usb', deviceId: deviceId,
deviceName: 'USB Companion', deviceName: deviceName,
); );
// markConnected() should already have been called by the transport. // markConnected() should already have been called by the transport.
@@ -780,6 +851,7 @@ class ConnectionProvider with ChangeNotifier {
_deviceInfo = _deviceInfo.copyWith( _deviceInfo = _deviceInfo.copyWith(
connectionState: ConnectionState.error, connectionState: ConnectionState.error,
); );
await _disposeSerialService();
notifyListeners(); notifyListeners();
return false; return false;
} }
@@ -788,7 +860,7 @@ class ConnectionProvider with ChangeNotifier {
/// Disconnect from USB serial device. /// Disconnect from USB serial device.
Future<void> disconnectSerial() async { Future<void> disconnectSerial() async {
_disposeSerialService(); await _disposeSerialService();
_resetConnectionSession(); _resetConnectionSession();
} }
@@ -2771,6 +2843,12 @@ class ConnectionProvider with ChangeNotifier {
_rxActivityTimer?.cancel(); _rxActivityTimer?.cancel();
_txActivityTimer?.cancel(); _txActivityTimer?.cancel();
_stopAckCleanupTimer(); _stopAckCleanupTimer();
final disconnectTransport = _serialDisconnectTransport;
if (disconnectTransport != null) {
unawaited(disconnectTransport());
_serialDisconnectTransport = null;
}
_serialService?.dispose();
_bleService.dispose(); _bleService.dispose();
_tcpService?.dispose(); _tcpService?.dispose();
super.dispose(); super.dispose();

View File

@@ -1551,14 +1551,12 @@ class _SectionHeader extends StatelessWidget {
final int count; final int count;
final IconData icon; final IconData icon;
final Color accentColor; final Color accentColor;
final Widget? trailing;
const _SectionHeader({ const _SectionHeader({
required this.title, required this.title,
required this.count, required this.count,
required this.icon, required this.icon,
required this.accentColor, required this.accentColor,
this.trailing,
}); });
@override @override
@@ -1596,46 +1594,25 @@ class _SectionHeader extends StatelessWidget {
], ],
); );
return LayoutBuilder( return Padding(
builder: (context, constraints) { padding: const EdgeInsets.only(bottom: 12),
final useStackedLayout = trailing != null && constraints.maxWidth < 430; child: Row(
children: [
return Padding( Container(
padding: const EdgeInsets.only(bottom: 12), width: 42,
child: Column( height: 42,
crossAxisAlignment: CrossAxisAlignment.start, decoration: BoxDecoration(
children: [ color: accentColor.withValues(alpha: 0.12),
Row( borderRadius: BorderRadius.circular(14),
children: [ border: Border.all(color: accentColor.withValues(alpha: 0.18)),
Container( ),
width: 42, alignment: Alignment.center,
height: 42, child: Icon(icon, size: 20, color: accentColor),
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!),
],
],
), ),
); const SizedBox(width: 12),
}, Expanded(child: titleBlock),
],
),
); );
} }
} }

View File

@@ -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<void> 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<List<SerialDeviceInfo>> listDevices();
Future<SerialDeviceInfo?> requestDevice();
Future<SerialConnection> connect(SerialDeviceInfo device);
void dispose();
}
SerialTransport createSerialTransport() => createSerialTransportImpl();

View File

@@ -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<List<SerialDeviceInfo>> 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<SerialDeviceInfo?> requestDevice() async => null;
@override
Future<SerialConnection> 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 = <String>[
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<List<SerialDeviceInfo>> _desktopSerialDevices() async {
final ports = <SerialDeviceInfo>[];
for (final address in SerialPort.availablePorts) {
final port = SerialPort(address);
try {
final title = _firstNonEmpty([
port.productName,
port.description,
address,
]);
final subtitleParts = <String>[
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<SerialConnection> _connectAndroid(SerialDeviceInfo device) async {
final usbDevice = device.handle as UsbDevice;
final service = MeshCoreSerialService(appName: 'MeshCore SAR');
StreamSubscription<Uint8List>? 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<SerialConnection> _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<Uint8List>? 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<String?> 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();

View File

@@ -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<List<SerialDeviceInfo>> listDevices() async => const [];
@override
Future<SerialDeviceInfo?> requestDevice() async => null;
@override
Future<SerialConnection> connect(SerialDeviceInfo device) async {
throw UnsupportedError(unsupportedMessage);
}
@override
void dispose() {}
}
SerialTransport createSerialTransportImpl() => _UnsupportedSerialTransport();

View File

@@ -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<List<SerialDeviceInfo>> listDevices() async {
if (!isSupported) {
return const [];
}
final ports = await serial.getPorts().toDart;
return ports
.toDart
.whereType<JSSerialPort>()
.map(_deviceInfoForPort)
.toList(growable: false);
}
@override
Future<SerialDeviceInfo?> requestDevice() async {
if (!isSupported) {
return null;
}
final port = await requestWebSerialPort(null);
if (port == null) {
return null;
}
return _deviceInfoForPort(port);
}
@override
Future<SerialConnection> 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<void> _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();

View File

@@ -1,13 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meshcore_client/meshcore_client.dart' hide Contact;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:usb_serial/usb_serial.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../services/network_scanner_service.dart'; import '../services/network_scanner_service.dart';
import '../services/serial/serial_transport.dart';
/// Connection Dialog with tabs for BLE devices and Network servers /// Connection Dialog with tabs for BLE devices and Network servers
class ConnectionDialog extends StatefulWidget { class ConnectionDialog extends StatefulWidget {
@@ -166,7 +164,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
'Choose Bluetooth, WiFi, or USB transport', 'Choose Bluetooth, WiFi, or Serial transport',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
@@ -192,7 +190,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
tabs: const [ tabs: const [
Tab(text: 'BLE', icon: Icon(Icons.bluetooth_rounded)), Tab(text: 'BLE', icon: Icon(Icons.bluetooth_rounded)),
Tab(text: 'Network', icon: Icon(Icons.wifi_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<ConnectionDialog>
); );
} }
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({ Widget _buildEmptyState({
required IconData icon, required IconData icon,
required String title, required String title,
@@ -371,6 +399,8 @@ class _ConnectionDialogState extends State<ConnectionDialog>
message: AppLocalizations.of(context)!.defaultPinInfo, message: AppLocalizations.of(context)!.defaultPinInfo,
onRefresh: _refreshBleDevices, onRefresh: _refreshBleDevices,
), ),
if (connectionProvider.error != null)
_buildErrorBanner(connectionProvider.error!),
Expanded( Expanded(
child: child:
connectionProvider.isScanning && connectionProvider.isScanning &&
@@ -597,7 +627,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
} }
Widget _buildUsbTab() { Widget _buildUsbTab() {
return _UsbDeviceList( return _SerialDeviceList(
buildTransportCard: buildTransportCard:
({ ({
required icon, required icon,
@@ -654,38 +684,48 @@ typedef _EmptyStateBuilder =
required VoidCallback onAction, required VoidCallback onAction,
}); });
class _UsbDeviceList extends StatefulWidget { class _SerialDeviceList extends StatefulWidget {
final VoidCallback onConnected; final VoidCallback onConnected;
final _TransportCardBuilder buildTransportCard; final _TransportCardBuilder buildTransportCard;
final _EmptyStateBuilder buildEmptyState; final _EmptyStateBuilder buildEmptyState;
const _UsbDeviceList({ const _SerialDeviceList({
required this.onConnected, required this.onConnected,
required this.buildTransportCard, required this.buildTransportCard,
required this.buildEmptyState, required this.buildEmptyState,
}); });
@override @override
State<_UsbDeviceList> createState() => _UsbDeviceListState(); State<_SerialDeviceList> createState() => _SerialDeviceListState();
} }
class _UsbDeviceListState extends State<_UsbDeviceList> { class _SerialDeviceListState extends State<_SerialDeviceList> {
List<UsbDevice> _devices = []; final SerialTransport _transport = createSerialTransport();
List<SerialDeviceInfo> _devices = [];
bool _isScanning = false; bool _isScanning = false;
bool _isConnecting = false; bool _isConnecting = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (defaultTargetPlatform == TargetPlatform.android) { if (_transport.isSupported) {
_scanDevices(); _scanDevices();
} }
} }
@override
void dispose() {
_transport.dispose();
super.dispose();
}
Future<void> _scanDevices() async { Future<void> _scanDevices() async {
if (!_transport.isSupported) {
return;
}
setState(() => _isScanning = true); setState(() => _isScanning = true);
try { try {
final devices = await UsbSerial.listDevices(); final devices = await _transport.listDevices();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_devices = devices; _devices = devices;
@@ -700,81 +740,66 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
} }
} }
Future<void> _connectToDevice(UsbDevice device) async { Future<void> _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<SerialDeviceInfo> _mergeDevices(
List<SerialDeviceInfo> devices,
SerialDeviceInfo selectedDevice,
) {
final merged = [...devices];
if (!merged.any((device) => device.id == selectedDevice.id)) {
merged.insert(0, selectedDevice);
}
return merged;
}
Future<void> _connectToDevice(SerialDeviceInfo device) async {
setState(() => _isConnecting = true); setState(() => _isConnecting = true);
try { try {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final service = MeshCoreSerialService(appName: 'MeshCore SAR'); final appProvider = context.read<AppProvider>();
final connection = await _transport.connect(device);
final port = await device.create(); final success = await connectionProvider.connectSerial(
if (port == null) { service: connection.service,
if (mounted) { disconnectTransport: connection.disconnect,
setState(() => _isConnecting = false); deviceId: connection.deviceId,
ScaffoldMessenger.of(context).showSnackBar( deviceName: connection.deviceName,
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,
); );
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 (!mounted) return;
if (success) { if (success) {
await appProvider.initialize();
widget.onConnected(); widget.onConnected();
} else { } else {
await port.close(); await connection.disconnect();
connection.service.dispose();
if (!mounted) return; if (!mounted) return;
setState(() => _isConnecting = false); setState(() => _isConnecting = false);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to connect via USB')), const SnackBar(content: Text('Failed to connect via serial')),
); );
} }
} catch (e) { } catch (e) {
@@ -782,20 +807,18 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
setState(() => _isConnecting = false); setState(() => _isConnecting = false);
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text('USB error: $e'))); ).showSnackBar(SnackBar(content: Text('Serial error: $e')));
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (defaultTargetPlatform != TargetPlatform.android) { if (!_transport.isSupported) {
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Text( child: Text(
kIsWeb _transport.unsupportedMessage,
? 'Web Serial is not yet supported.\nUse BLE or Network instead.'
: 'USB serial is available on Android only.\nConnect via OTG cable.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
@@ -811,19 +834,22 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: FilledButton.tonalIcon( child: FilledButton.tonalIcon(
onPressed: _isConnecting ? null : _scanDevices, onPressed: _isConnecting
? null
: (_transport.canRequestDevice ? _requestDevice : _scanDevices),
icon: const Icon(Icons.usb_rounded), icon: const Icon(Icons.usb_rounded),
label: const Text('Scan USB devices'), label: Text(_transport.actionLabel),
), ),
), ),
if (_devices.isEmpty) if (_devices.isEmpty)
Expanded( Expanded(
child: widget.buildEmptyState( child: widget.buildEmptyState(
icon: Icons.usb_off_rounded, icon: Icons.usb_off_rounded,
title: title: _transport.emptyStateTitle,
'No USB serial devices found.\nConnect a MeshCore device via OTG cable.', actionLabel: _transport.actionLabel,
actionLabel: 'Scan USB devices', onAction: _transport.canRequestDevice
onAction: _scanDevices, ? _requestDevice
: _scanDevices,
), ),
) )
else else
@@ -835,10 +861,8 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
return widget.buildTransportCard( return widget.buildTransportCard(
icon: Icons.usb_rounded, icon: Icons.usb_rounded,
iconColor: Theme.of(context).colorScheme.primary, iconColor: Theme.of(context).colorScheme.primary,
title: device.productName ?? 'USB Device', title: device.title,
subtitle: (device.manufacturerName?.isNotEmpty ?? false) subtitle: device.subtitle,
? device.manufacturerName!
: 'Ready over OTG serial',
trailing: _isConnecting trailing: _isConnecting
? const SizedBox( ? const SizedBox(
width: 24, width: 24,

View File

@@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../../models/contact.dart'; import '../../../models/contact.dart';
import '../../../models/sar_marker.dart'; import '../../../models/sar_marker.dart';
import 'compass_math.dart';
/// Header component for the compass dialog showing compass rose, /// Header component for the compass dialog showing compass rose,
/// heading, elevation, accuracy, and current location in multiple formats. /// 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)!; final l10n = AppLocalizations.of(context)!;
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
@@ -90,17 +95,13 @@ class CompassHeader extends StatelessWidget {
_buildInfoCard( _buildInfoCard(
context, context,
l10n.elevation, l10n.elevation,
position?.altitude != null position?.altitude != null ? '${position!.altitude.round()}m' : '--',
? '${position!.altitude.round()}m'
: '--',
Icons.terrain, Icons.terrain,
), ),
_buildInfoCard( _buildInfoCard(
context, context,
l10n.accuracy, l10n.accuracy,
position?.accuracy != null position?.accuracy != null ? '±${position!.accuracy.round()}m' : '--',
? '±${position!.accuracy.round()}m'
: '--',
Icons.gps_fixed, Icons.gps_fixed,
), ),
], ],
@@ -108,21 +109,22 @@ class CompassHeader extends StatelessWidget {
} }
Widget _buildInfoCard( Widget _buildInfoCard(
BuildContext context, String label, String value, IconData icon) { BuildContext context,
String label,
String value,
IconData icon,
) {
return Column( return Column(
children: [ children: [
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
value, value,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(
fontWeight: FontWeight.bold, context,
), ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
), ),
Text(label, style: Theme.of(context).textTheme.bodySmall),
], ],
); );
} }
@@ -193,11 +195,16 @@ class _LargeCompassPainter extends CustomPainter {
// Draw degree markers // Draw degree markers
for (int i = 0; i < 360; i += 10) { 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 isCardinal = i % 90 == 0;
final isMajor = i % 30 == 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( final start = Offset(
center.dx + startRadius * cos(angle), center.dx + startRadius * cos(angle),
center.dy + startRadius * sin(angle), center.dy + startRadius * sin(angle),
@@ -218,7 +225,10 @@ class _LargeCompassPainter extends CustomPainter {
final textPainter = TextPainter(textDirection: TextDirection.ltr); final textPainter = TextPainter(textDirection: TextDirection.ltr);
final directions = ['N', 'E', 'S', 'W']; final directions = ['N', 'E', 'S', 'W'];
for (int i = 0; i < 4; i++) { 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 x = center.dx + (radius - 35) * cos(angle);
final y = center.dy + (radius - 35) * sin(angle); final y = center.dy + (radius - 35) * sin(angle);
@@ -243,20 +253,25 @@ class _LargeCompassPainter extends CustomPainter {
final contactsWithDistance = contacts final contactsWithDistance = contacts
.where((c) => c.displayLocation != null) .where((c) => c.displayLocation != null)
.map((contact) { .map((contact) {
final bearing = _calculateBearing( final bearing = _calculateBearing(
currentPosition!.latitude, currentPosition!.latitude,
currentPosition!.longitude, currentPosition!.longitude,
contact.displayLocation!.latitude, contact.displayLocation!.latitude,
contact.displayLocation!.longitude, contact.displayLocation!.longitude,
); );
final distance = _calculateDistance( final distance = _calculateDistance(
currentPosition!.latitude, currentPosition!.latitude,
currentPosition!.longitude, currentPosition!.longitude,
contact.displayLocation!.latitude, contact.displayLocation!.latitude,
contact.displayLocation!.longitude, contact.displayLocation!.longitude,
); );
return {'contact': contact, 'bearing': bearing, 'distance': distance}; return {
}).toList(); 'contact': contact,
'bearing': bearing,
'distance': distance,
};
})
.toList();
if (contactsWithDistance.isEmpty) return; if (contactsWithDistance.isEmpty) return;
@@ -277,7 +292,8 @@ class _LargeCompassPainter extends CustomPainter {
double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0); double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0);
// Calculate contact position radius (from center to rim based on distance) // 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 // Position of contact dot
final dotX = center.dx + contactRadius * cos(angle); final dotX = center.dx + contactRadius * cos(angle);
@@ -288,11 +304,7 @@ class _LargeCompassPainter extends CustomPainter {
..color = Colors.lightBlue.withValues(alpha: 0.3) ..color = Colors.lightBlue.withValues(alpha: 0.3)
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 1.5; ..strokeWidth = 1.5;
canvas.drawLine( canvas.drawLine(center, Offset(dotX, dotY), linePaint);
center,
Offset(dotX, dotY),
linePaint,
);
// Draw contact dot (size varies with zoom) // Draw contact dot (size varies with zoom)
final dotSize = (6.0 * (1.0 + zoomLevel * 0.3)).clamp(4.0, 12.0); final dotSize = (6.0 * (1.0 + zoomLevel * 0.3)).clamp(4.0, 12.0);
@@ -341,7 +353,10 @@ class _LargeCompassPainter extends CustomPainter {
textPainter.paint( textPainter.paint(
canvas, 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) ..color = markerColor.withValues(alpha: 0.3)
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 2; ..strokeWidth = 2;
canvas.drawLine( canvas.drawLine(center, Offset(dotX, dotY), linePaint);
center,
Offset(dotX, dotY),
linePaint,
);
// Draw SAR marker dot (slightly larger than contacts) // Draw SAR marker dot (slightly larger than contacts)
final dotSize = (8.0 * (1.0 + zoomLevel * 0.3)).clamp(6.0, 14.0); final dotSize = (8.0 * (1.0 + zoomLevel * 0.3)).clamp(6.0, 14.0);
@@ -472,7 +483,10 @@ class _LargeCompassPainter extends CustomPainter {
textPainter.paint( textPainter.paint(
canvas, 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); canvas.drawPath(path, indicatorPaint);
} }
double _calculateBearing( double _calculateBearing(double lat1, double lon1, double lat2, double lon2) {
double lat1, double lon1, double lat2, double lon2) {
final dLon = (lon2 - lon1) * pi / 180; final dLon = (lon2 - lon1) * pi / 180;
final lat1Rad = lat1 * pi / 180; final lat1Rad = lat1 * pi / 180;
final lat2Rad = lat2 * pi / 180; final lat2Rad = lat2 * pi / 180;
final y = sin(dLon) * cos(lat2Rad); final y = sin(dLon) * cos(lat2Rad);
final x = cos(lat1Rad) * sin(lat2Rad) - final x =
sin(lat1Rad) * cos(lat2Rad) * cos(dLon); cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final bearing = atan2(y, x) * 180 / pi; final bearing = atan2(y, x) * 180 / pi;
return (bearing + 360) % 360; return (bearing + 360) % 360;
} }
double _calculateDistance( 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 const R = 6371000; // Earth's radius in meters
final dLat = (lat2 - lat1) * pi / 180; final dLat = (lat2 - lat1) * pi / 180;
final dLon = (lon2 - lon1) * 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(lat1 * pi / 180) *
cos(lat2 * pi / 180) * cos(lat2 * pi / 180) *
sin(dLon / 2) * sin(dLon / 2) *
@@ -572,7 +590,8 @@ class _LocationFormatToggleState extends State<_LocationFormatToggle> {
final String displayText; final String displayText;
if (_showDMS) { if (_showDMS) {
displayText = '${_formatDMS(position.latitude, true)} ${_formatDMS(position.longitude, false)}'; displayText =
'${_formatDMS(position.latitude, true)} ${_formatDMS(position.longitude, false)}';
} else { } else {
displayText = l10n.latLonFormat( displayText = l10n.latLonFormat(
position.latitude.toStringAsFixed(5), position.latitude.toStringAsFixed(5),
@@ -598,9 +617,9 @@ class _LocationFormatToggleState extends State<_LocationFormatToggle> {
displayText, displayText,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontFamily: 'monospace', fontFamily: 'monospace',
), ),
), ),
), ),
), ),

View File

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

View File

@@ -1,5 +1,6 @@
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'compass/compass_math.dart';
class CompassWidget extends StatelessWidget { class CompassWidget extends StatelessWidget {
final double heading; final double heading;
@@ -21,9 +22,9 @@ class CompassWidget extends StatelessWidget {
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
// Compass rose background - rotates to show true north at top // Rotate the rose opposite the heading under the fixed needle.
Transform.rotate( Transform.rotate(
angle: heading * pi / 180, angle: compassRoseRotationRadians(heading),
child: CustomPaint( child: CustomPaint(
size: const Size(40, 40), size: const Size(40, 40),
painter: _CompassRosePainter(), painter: _CompassRosePainter(),
@@ -76,9 +77,7 @@ class _CompassRosePainter extends CustomPainter {
canvas.drawCircle(center, radius, paint); canvas.drawCircle(center, radius, paint);
// Draw cardinal direction markers // Draw cardinal direction markers
final textPainter = TextPainter( final textPainter = TextPainter(textDirection: TextDirection.ltr);
textDirection: TextDirection.ltr,
);
final directions = ['N', 'E', 'S', 'W']; final directions = ['N', 'E', 'S', 'W'];
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {

View File

@@ -9,6 +9,7 @@
#include <audioplayers_linux/audioplayers_linux_plugin.h> #include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <file_selector_linux/file_selector_plugin.h> #include <file_selector_linux/file_selector_plugin.h>
#include <flutter_avif_linux/flutter_avif_linux_plugin.h> #include <flutter_avif_linux/flutter_avif_linux_plugin.h>
#include <flutter_libserialport/flutter_libserialport_plugin.h>
#include <record_linux/record_linux_plugin.h> #include <record_linux/record_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h> #include <url_launcher_linux/url_launcher_plugin.h>
@@ -22,6 +23,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_avif_linux_registrar = g_autoptr(FlPluginRegistrar) flutter_avif_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAvifLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAvifLinuxPlugin");
flutter_avif_linux_plugin_register_with_registrar(flutter_avif_linux_registrar); 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 = g_autoptr(FlPluginRegistrar) record_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
record_linux_plugin_register_with_registrar(record_linux_registrar); record_linux_plugin_register_with_registrar(record_linux_registrar);

View File

@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux audioplayers_linux
file_selector_linux file_selector_linux
flutter_avif_linux flutter_avif_linux
flutter_libserialport
record_linux record_linux
url_launcher_linux url_launcher_linux
) )

View File

@@ -11,6 +11,7 @@ import file_picker
import file_selector_macos import file_selector_macos
import flutter_avif_macos import flutter_avif_macos
import flutter_blue_plus_darwin import flutter_blue_plus_darwin
import flutter_libserialport
import flutter_local_notifications import flutter_local_notifications
import geolocator_apple import geolocator_apple
import nsd_macos import nsd_macos
@@ -30,6 +31,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterAvifPlugin.register(with: registry.registrar(forPlugin: "FlutterAvifPlugin")) FlutterAvifPlugin.register(with: registry.registrar(forPlugin: "FlutterAvifPlugin"))
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
FlutterLibserialportPlugin.register(with: registry.registrar(forPlugin: "FlutterLibserialportPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin")) NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))

View File

@@ -1,4 +1,4 @@
platform :osx, '10.15' platform :osx, '26.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -38,6 +38,9 @@ end
post_install do |installer| post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
flutter_additional_macos_build_settings(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 # 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 # 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. # all C source files from the macOS codec2_flutter build entirely.

View File

@@ -15,12 +15,16 @@ PODS:
- flutter_blue_plus_darwin (0.0.2): - flutter_blue_plus_darwin (0.0.2):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- flutter_libserialport (0.0.1):
- FlutterMacOS
- libserialport
- flutter_local_notifications (0.0.1): - flutter_local_notifications (0.0.1):
- FlutterMacOS - FlutterMacOS
- FlutterMacOS (1.0.0) - FlutterMacOS (1.0.0)
- geolocator_apple (1.2.0): - geolocator_apple (1.2.0):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- libserialport (0.1.1)
- nsd_macos (0.0.1): - nsd_macos (0.0.1):
- FlutterMacOS - FlutterMacOS
- package_info_plus (0.0.1): - package_info_plus (0.0.1):
@@ -40,6 +44,8 @@ PODS:
- FlutterMacOS - FlutterMacOS
- url_launcher_macos (0.0.1): - url_launcher_macos (0.0.1):
- FlutterMacOS - FlutterMacOS
- wakelock_plus (0.0.1):
- FlutterMacOS
DEPENDENCIES: DEPENDENCIES:
- audioplayers_darwin (from `Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin`) - 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`) - 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_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_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`) - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
- FlutterMacOS (from `Flutter/ephemeral`) - FlutterMacOS (from `Flutter/ephemeral`)
- geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`) - 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`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - 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: EXTERNAL SOURCES:
audioplayers_darwin: audioplayers_darwin:
@@ -76,6 +88,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos :path: Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos
flutter_blue_plus_darwin: flutter_blue_plus_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin :path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin
flutter_libserialport:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_libserialport/macos
flutter_local_notifications: flutter_local_notifications:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos
FlutterMacOS: FlutterMacOS:
@@ -98,6 +112,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
url_launcher_macos: url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
wakelock_plus:
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
SPEC CHECKSUMS: SPEC CHECKSUMS:
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
@@ -107,9 +123,11 @@ SPEC CHECKSUMS:
file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7 file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7
flutter_avif_macos: 9ed61d67adfbd6964eccb59971020fb55c31fc11 flutter_avif_macos: 9ed61d67adfbd6964eccb59971020fb55c31fc11
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
flutter_libserialport: 2c523cb8d8203fc6d1b0da88190da31ac970eb93
flutter_local_notifications: 1fc7ffb10a83d6a2eeeeddb152d43f1944b0aad0 flutter_local_notifications: 1fc7ffb10a83d6a2eeeeddb152d43f1944b0aad0
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
libserialport: 1cb25e66ef3c92a8e59c2ea3820302c3fa2268cd
nsd_macos: a472240e770b92f6c6df1022403aa29c90d012e3 nsd_macos: a472240e770b92f6c6df1022403aa29c90d012e3
package_info_plus: f0052d280d17aa382b932f399edf32507174e870 package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
@@ -118,7 +136,8 @@ SPEC CHECKSUMS:
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
PODFILE CHECKSUM: e3de8f2486e300492cc80a2fe0576e9d269ba22b PODFILE CHECKSUM: 467066e8d30d1105257e0877b8cdd20642e7b3db
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

View File

@@ -557,7 +557,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15; MACOSX_DEPLOYMENT_TARGET = 26.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx; SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
@@ -639,7 +639,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15; MACOSX_DEPLOYMENT_TARGET = 26.0;
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx; SDKROOT = macosx;
@@ -689,7 +689,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15; MACOSX_DEPLOYMENT_TARGET = 26.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx; SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;

View File

@@ -2,11 +2,7 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key> <key>com.apple.security.cs.allow-jit</key>
<true/> <true/>
<key>com.apple.security.network.server</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -24,10 +24,16 @@
<false/> <false/>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string> <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>MeshCore SAR needs Bluetooth to discover and communicate with MeshCore devices during SAR operations</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>MeshCore SAR needs Bluetooth to discover and communicate with MeshCore devices during SAR operations</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>$(PRODUCT_COPYRIGHT)</string> <string>$(PRODUCT_COPYRIGHT)</string>
<key>NSMainNibFile</key> <key>NSMainNibFile</key>
<string>MainMenu</string> <string>MainMenu</string>
<key>NSLocationUsageDescription</key>
<string>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</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>MeshCore SAR needs microphone access to send voice messages over the mesh radio network during SAR operations</string> <string>MeshCore SAR needs microphone access to send voice messages over the mesh radio network during SAR operations</string>
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>

View File

@@ -2,7 +2,5 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.security.app-sandbox</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -218,6 +218,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.3" version: "7.0.3"
dylib:
dependency: transitive
description:
name: dylib
sha256: bf609b3eb6492a3309b3d1dbe8f83a4031de5535dd7686be33487051cc760bb0
url: "https://pub.dev"
source: hosted
version: "0.3.3"
exif: exif:
dependency: transitive dependency: transitive
description: description:
@@ -479,6 +487,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.14.4" 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: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -759,6 +775,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.2" version: "3.0.2"
libserialport:
dependency: transitive
description:
name: libserialport
sha256: "392e1592def65282429832ec66fa25e9e163d3b37716b97691482e2406720727"
url: "https://pub.dev"
source: hosted
version: "0.3.0+1"
lints: lints:
dependency: transitive dependency: transitive
description: description:
@@ -1477,6 +1501,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
webserial:
dependency: "direct main"
description:
name: webserial
sha256: b39c2175190ee1c75e996bf137bfee3e3e8fd06ee0ec24f0d3c10bdf7b323786
url: "https://pub.dev"
source: hosted
version: "1.2.0"
win32: win32:
dependency: transitive dependency: transitive
description: description:

View File

@@ -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 # 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 # 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. # 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: environment:
sdk: ^3.9.2 sdk: ^3.9.2
@@ -123,6 +123,8 @@ dependencies:
nsd: ^4.0.3 nsd: ^4.0.3
usb_serial: ^0.5.2 usb_serial: ^0.5.2
web: ^1.1.1 web: ^1.1.1
flutter_libserialport: ^0.6.0
webserial: ^1.2.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -1,5 +1,3 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';

View File

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

View File

@@ -10,6 +10,7 @@
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <flutter_avif_windows/flutter_avif_windows_plugin.h> #include <flutter_avif_windows/flutter_avif_windows_plugin.h>
#include <flutter_blue_plus_winrt/flutter_blue_plus_plugin.h> #include <flutter_blue_plus_winrt/flutter_blue_plus_plugin.h>
#include <flutter_libserialport/flutter_libserialport_plugin.h>
#include <geolocator_windows/geolocator_windows.h> #include <geolocator_windows/geolocator_windows.h>
#include <nsd_windows/nsd_windows_plugin_c_api.h> #include <nsd_windows/nsd_windows_plugin_c_api.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h> #include <permission_handler_windows/permission_handler_windows_plugin.h>
@@ -26,6 +27,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FlutterAvifWindowsPlugin")); registry->GetRegistrarForPlugin("FlutterAvifWindowsPlugin"));
FlutterBluePlusPluginRegisterWithRegistrar( FlutterBluePlusPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterBluePlusPlugin")); registry->GetRegistrarForPlugin("FlutterBluePlusPlugin"));
FlutterLibserialportPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterLibserialportPlugin"));
GeolocatorWindowsRegisterWithRegistrar( GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows")); registry->GetRegistrarForPlugin("GeolocatorWindows"));
NsdWindowsPluginCApiRegisterWithRegistrar( NsdWindowsPluginCApiRegisterWithRegistrar(

View File

@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows file_selector_windows
flutter_avif_windows flutter_avif_windows
flutter_blue_plus_winrt flutter_blue_plus_winrt
flutter_libserialport
geolocator_windows geolocator_windows
nsd_windows nsd_windows
permission_handler_windows permission_handler_windows