mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: Add serial compass support #0
This commit is contained in:
@@ -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<ConnectionDialog>
|
||||
),
|
||||
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<ConnectionDialog>
|
||||
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<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({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
@@ -371,6 +399,8 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
||||
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<ConnectionDialog>
|
||||
}
|
||||
|
||||
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<UsbDevice> _devices = [];
|
||||
class _SerialDeviceListState extends State<_SerialDeviceList> {
|
||||
final SerialTransport _transport = createSerialTransport();
|
||||
List<SerialDeviceInfo> _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<void> _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<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);
|
||||
try {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
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<AppProvider>();
|
||||
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,
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
22
lib/widgets/map/compass/compass_math.dart
Normal file
22
lib/widgets/map/compass/compass_math.dart
Normal 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));
|
||||
@@ -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++) {
|
||||
|
||||
Reference in New Issue
Block a user