mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Fix contact add failure
This commit is contained in:
@@ -14,9 +14,6 @@ enum ConnectionMode {
|
||||
/// Direct BLE connection to MeshCore device (default)
|
||||
ble,
|
||||
|
||||
/// Act as SSE server - share BLE device with multiple clients
|
||||
sseServer,
|
||||
|
||||
/// Direct TCP/WiFi connection to MeshCore device (port 5000)
|
||||
tcp,
|
||||
}
|
||||
@@ -26,8 +23,6 @@ extension ConnectionModeExtension on ConnectionMode {
|
||||
switch (this) {
|
||||
case ConnectionMode.ble:
|
||||
return 'Direct (BLE)';
|
||||
case ConnectionMode.sseServer:
|
||||
return 'Share Device (Server)';
|
||||
case ConnectionMode.tcp:
|
||||
return 'Direct (WiFi)';
|
||||
}
|
||||
@@ -37,8 +32,6 @@ extension ConnectionModeExtension on ConnectionMode {
|
||||
switch (this) {
|
||||
case ConnectionMode.ble:
|
||||
return 'Direct BLE connection to MeshCore device';
|
||||
case ConnectionMode.sseServer:
|
||||
return 'Share BLE device with multiple clients over network';
|
||||
case ConnectionMode.tcp:
|
||||
return 'Direct WiFi/TCP connection to MeshCore device';
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
/// SSE Server Configuration Model
|
||||
///
|
||||
/// Configuration for the SSE (Server-Sent Events) web server that enables
|
||||
/// multiple app instances to share a single MeshCore BLE device.
|
||||
class SseServerConfig {
|
||||
/// Server bind address (e.g., "0.0.0.0" for all interfaces, "127.0.0.1" for localhost)
|
||||
final String host;
|
||||
|
||||
/// Server port (default: 12929)
|
||||
final int port;
|
||||
|
||||
/// Whether the SSE server is enabled
|
||||
final bool enabled;
|
||||
|
||||
/// Optional authentication token for basic security
|
||||
/// Clients must include this token in Authorization header
|
||||
final String? authToken;
|
||||
|
||||
const SseServerConfig({
|
||||
this.host = '0.0.0.0',
|
||||
this.port = 12929,
|
||||
this.enabled = false,
|
||||
this.authToken,
|
||||
});
|
||||
|
||||
/// Create a copy with updated fields
|
||||
SseServerConfig copyWith({
|
||||
String? host,
|
||||
int? port,
|
||||
bool? enabled,
|
||||
String? authToken,
|
||||
}) {
|
||||
return SseServerConfig(
|
||||
host: host ?? this.host,
|
||||
port: port ?? this.port,
|
||||
enabled: enabled ?? this.enabled,
|
||||
authToken: authToken ?? this.authToken,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get server URL for clients to connect to
|
||||
String getServerUrl({String? ipAddress}) {
|
||||
final ip = ipAddress ?? host;
|
||||
return 'http://$ip:$port';
|
||||
}
|
||||
|
||||
/// Convert to JSON for persistence
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'host': host,
|
||||
'port': port,
|
||||
'enabled': enabled,
|
||||
'authToken': authToken,
|
||||
};
|
||||
}
|
||||
|
||||
/// Create from JSON
|
||||
factory SseServerConfig.fromJson(Map<String, dynamic> json) {
|
||||
return SseServerConfig(
|
||||
host: json['host'] as String? ?? '0.0.0.0',
|
||||
port: json['port'] as int? ?? 12929,
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
authToken: json['authToken'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SseServerConfig(host: $host, port: $port, enabled: $enabled, hasAuth: ${authToken != null})';
|
||||
}
|
||||
}
|
||||
@@ -948,7 +948,7 @@ class AppProvider with ChangeNotifier {
|
||||
contactName: contact.advName.trim().isEmpty
|
||||
? null
|
||||
: contact.advName,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (contact.type == ContactType.repeater &&
|
||||
@@ -966,9 +966,6 @@ class AppProvider with ChangeNotifier {
|
||||
devicePublicKey: devicePublicKey,
|
||||
);
|
||||
unawaited(_pathHistoryService.recordLearnedPath(contact));
|
||||
|
||||
// Broadcast to SSE clients if server is running
|
||||
connectionProvider.broadcastContactToSseClients(contact);
|
||||
};
|
||||
|
||||
// When all contacts are received
|
||||
@@ -982,11 +979,6 @@ class AppProvider with ChangeNotifier {
|
||||
unawaited(_pathHistoryService.recordLearnedPath(contact));
|
||||
}
|
||||
debugPrint('Received ${contacts.length} contacts');
|
||||
|
||||
// Broadcast all contacts to SSE clients if server is running
|
||||
for (final contact in contacts) {
|
||||
connectionProvider.broadcastContactToSseClients(contact);
|
||||
}
|
||||
};
|
||||
|
||||
// Setup callback for ConnectionProvider to query channel info
|
||||
@@ -1212,9 +1204,6 @@ class AppProvider with ChangeNotifier {
|
||||
contactLocationSnapshot: contactLocationSnapshot,
|
||||
receptionDetailsSnapshot: receptionDetailsSnapshot,
|
||||
);
|
||||
|
||||
// Broadcast drawing message to SSE clients if server is running
|
||||
connectionProvider.broadcastMessageToSseClients(updatedMessage);
|
||||
} else {
|
||||
debugPrint('⚠️ [AppProvider] Failed to parse drawing message');
|
||||
}
|
||||
@@ -1255,7 +1244,6 @@ class AppProvider with ChangeNotifier {
|
||||
contactLocationSnapshot: contactLocationSnapshot,
|
||||
receptionDetailsSnapshot: receptionDetailsSnapshot,
|
||||
);
|
||||
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1289,7 +1277,6 @@ class AppProvider with ChangeNotifier {
|
||||
contactLocationSnapshot: contactLocationSnapshot,
|
||||
receptionDetailsSnapshot: receptionDetailsSnapshot,
|
||||
);
|
||||
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1314,9 +1301,6 @@ class AppProvider with ChangeNotifier {
|
||||
contactLocationSnapshot: contactLocationSnapshot,
|
||||
receptionDetailsSnapshot: receptionDetailsSnapshot,
|
||||
);
|
||||
|
||||
// Broadcast message to SSE clients if server is running
|
||||
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
||||
};
|
||||
|
||||
// Keep a compact receive-time snapshot because packet logs roll over.
|
||||
@@ -1353,20 +1337,21 @@ class AppProvider with ChangeNotifier {
|
||||
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
|
||||
final parsedAdvert = _tryParseRawAdvert(payload);
|
||||
if (parsedAdvert != null) {
|
||||
final isNewPendingAdvert = contactsProvider.addOrUpdatePendingAdvertMetadata(
|
||||
publicKey: parsedAdvert.publicKey,
|
||||
typeValue: parsedAdvert.typeValue,
|
||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||
flags: parsedAdvert.flags,
|
||||
advName: parsedAdvert.advName,
|
||||
lastAdvert: parsedAdvert.lastAdvert,
|
||||
advLat: parsedAdvert.advLat,
|
||||
advLon: parsedAdvert.advLon,
|
||||
signedEncodedPathLen: parsedAdvert.signedEncodedPathLen,
|
||||
paddedPathBytes: parsedAdvert.paddedPathBytes,
|
||||
rxRssiDbm: rssiDbm,
|
||||
rxSnrRaw: snrRaw,
|
||||
);
|
||||
final isNewPendingAdvert = contactsProvider
|
||||
.addOrUpdatePendingAdvertMetadata(
|
||||
publicKey: parsedAdvert.publicKey,
|
||||
typeValue: parsedAdvert.typeValue,
|
||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||
flags: parsedAdvert.flags,
|
||||
advName: parsedAdvert.advName,
|
||||
lastAdvert: parsedAdvert.lastAdvert,
|
||||
advLat: parsedAdvert.advLat,
|
||||
advLon: parsedAdvert.advLon,
|
||||
signedEncodedPathLen: parsedAdvert.signedEncodedPathLen,
|
||||
paddedPathBytes: parsedAdvert.paddedPathBytes,
|
||||
rxRssiDbm: rssiDbm,
|
||||
rxSnrRaw: snrRaw,
|
||||
);
|
||||
if (isNewPendingAdvert) {
|
||||
final contactKey = parsedAdvert.publicKey
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
@@ -1382,7 +1367,9 @@ class AppProvider with ChangeNotifier {
|
||||
unawaited(_requestRepeaterStatus(parsedAdvert.publicKey));
|
||||
unawaited(_maybeRequestRepeaterOwnerInfo(parsedAdvert.publicKey));
|
||||
}
|
||||
if (contactsProvider.shouldEnrichPendingAdvert(parsedAdvert.publicKey)) {
|
||||
if (contactsProvider.shouldEnrichPendingAdvert(
|
||||
parsedAdvert.publicKey,
|
||||
)) {
|
||||
unawaited(connectionProvider.previewContact(parsedAdvert.publicKey));
|
||||
}
|
||||
return;
|
||||
@@ -1669,7 +1656,9 @@ class AppProvider with ChangeNotifier {
|
||||
unawaited(
|
||||
_notificationService.showContactDiscoveredNotification(
|
||||
contactKey: keyHex,
|
||||
contactName: contactsProvider.pendingAdvertByKey(publicKey)?.advName,
|
||||
contactName: contactsProvider
|
||||
.pendingAdvertByKey(publicKey)
|
||||
?.advName,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -2248,10 +2237,9 @@ class AppProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
try {
|
||||
final payload = utf8.decode(
|
||||
responseData.sublist(4),
|
||||
allowMalformed: true,
|
||||
).trim();
|
||||
final payload = utf8
|
||||
.decode(responseData.sublist(4), allowMalformed: true)
|
||||
.trim();
|
||||
if (payload.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
@@ -2351,10 +2339,9 @@ class AppProvider with ChangeNotifier {
|
||||
|
||||
String? advName;
|
||||
if (hasName && reader.remainingBytesCount > 0) {
|
||||
final decodedName = utf8.decode(
|
||||
reader.readRemainingBytes(),
|
||||
allowMalformed: true,
|
||||
).trim();
|
||||
final decodedName = utf8
|
||||
.decode(reader.readRemainingBytes(), allowMalformed: true)
|
||||
.trim();
|
||||
if (decodedName.isNotEmpty) {
|
||||
advName = decodedName;
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@ import 'package:crypto/crypto.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/device_info.dart';
|
||||
import '../models/room_login_state.dart';
|
||||
import '../models/sse_server_config.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart' hide Contact;
|
||||
import '../services/sse_server_service.dart';
|
||||
import '../utils/sar_message_parser.dart';
|
||||
import 'helpers/room_login_manager.dart';
|
||||
import 'helpers/message_delivery_tracker.dart';
|
||||
@@ -73,7 +71,6 @@ class _PendingContactRequest {
|
||||
class ConnectionProvider with ChangeNotifier {
|
||||
static const int _controlTypeNodeDiscoverReq = 0x80;
|
||||
final MeshCoreBleService _bleService = MeshCoreBleService();
|
||||
final SseServerService _sseServer = SseServerService();
|
||||
MeshCoreTcpService? _tcpService;
|
||||
|
||||
/// Expose BLE service for background location tracking
|
||||
@@ -89,10 +86,6 @@ class ConnectionProvider with ChangeNotifier {
|
||||
ConnectionMode _connectionMode = ConnectionMode.ble;
|
||||
ConnectionMode get connectionMode => _connectionMode;
|
||||
|
||||
/// SSE server configuration
|
||||
SseServerConfig _sseServerConfig = const SseServerConfig();
|
||||
SseServerConfig get sseServerConfig => _sseServerConfig;
|
||||
|
||||
/// TCP host last connected to (for display / reconnection info)
|
||||
String? _tcpHost;
|
||||
String? get tcpHost => _tcpHost;
|
||||
@@ -471,11 +464,6 @@ class ConnectionProvider with ChangeNotifier {
|
||||
spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?,
|
||||
);
|
||||
notifyListeners();
|
||||
if (_sseServer.isRunning) {
|
||||
_sseServer.setDeviceName(
|
||||
_deviceInfo.deviceName ?? _deviceInfo.selfName,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
service.onSelfInfoReceived = (selfInfo) {
|
||||
@@ -495,11 +483,6 @@ class ConnectionProvider with ChangeNotifier {
|
||||
selfName: selfInfo['selfName'] as String?,
|
||||
);
|
||||
notifyListeners();
|
||||
if (_sseServer.isRunning) {
|
||||
_sseServer.setDeviceName(
|
||||
_deviceInfo.deviceName ?? _deviceInfo.selfName,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
service.onBatteryAndStorage = (millivolts, usedKb, totalKb) {
|
||||
@@ -857,12 +840,19 @@ class ConnectionProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
try {
|
||||
_error = null;
|
||||
_markSingleContactRequested(
|
||||
publicKey,
|
||||
source: ContactReceiveSource.requestedSingle,
|
||||
);
|
||||
await _activeService.getContactByKey(publicKey);
|
||||
} catch (e) {
|
||||
final errorText = e.toString();
|
||||
if (_error == 'Not found' || errorText.contains('Not found')) {
|
||||
_error = 'Not found';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_error = 'Failed to get contact: $e';
|
||||
debugPrint(
|
||||
'⚠️ [Provider] Failed to get contact by key, falling back to full contact sync',
|
||||
@@ -1360,6 +1350,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
try {
|
||||
_error = null;
|
||||
await _activeService.addOrUpdateContact(contact);
|
||||
} catch (e) {
|
||||
_error = 'Failed to add/update contact: $e';
|
||||
@@ -2578,110 +2569,6 @@ class ConnectionProvider with ChangeNotifier {
|
||||
return _roomLoginManager.isLoggedIntoRoom(publicKeyPrefix);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSE Server Methods
|
||||
// ============================================================================
|
||||
|
||||
/// Start SSE server to share BLE device with multiple clients
|
||||
Future<void> startSseServer(SseServerConfig config) async {
|
||||
if (_sseServer.isRunning) {
|
||||
debugPrint('⚠️ [ConnectionProvider] SSE server already running');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('🚀 [ConnectionProvider] Starting SSE server...');
|
||||
_sseServerConfig = config;
|
||||
|
||||
// Wire up callbacks
|
||||
_sseServer.onSendMessage = (recipientPublicKey, text) async {
|
||||
// Convert hex string to Uint8List
|
||||
final bytes = <int>[];
|
||||
for (int i = 0; i < recipientPublicKey.length; i += 2) {
|
||||
bytes.add(
|
||||
int.parse(recipientPublicKey.substring(i, i + 2), radix: 16),
|
||||
);
|
||||
}
|
||||
return await sendTextMessage(
|
||||
contactPublicKey: Uint8List.fromList(bytes),
|
||||
text: text,
|
||||
);
|
||||
};
|
||||
|
||||
_sseServer.onSendChannelMessage = (channelIdx, text) async {
|
||||
await sendChannelMessage(channelIdx: channelIdx, text: text);
|
||||
};
|
||||
|
||||
_sseServer.onSyncContacts = () async {
|
||||
await getContacts();
|
||||
};
|
||||
|
||||
await _sseServer.startServer(config);
|
||||
|
||||
// Set initial device name
|
||||
_sseServer.setDeviceName(_deviceInfo.deviceName ?? _deviceInfo.selfName);
|
||||
|
||||
_connectionMode = ConnectionMode.sseServer;
|
||||
notifyListeners();
|
||||
|
||||
debugPrint('✅ [ConnectionProvider] SSE server started');
|
||||
} catch (e) {
|
||||
_error = 'Failed to start SSE server: $e';
|
||||
debugPrint('❌ [ConnectionProvider] Failed to start SSE server: $e');
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop SSE server
|
||||
Future<void> stopSseServer() async {
|
||||
if (!_sseServer.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('🛑 [ConnectionProvider] Stopping SSE server...');
|
||||
await _sseServer.stopServer();
|
||||
|
||||
if (_connectionMode == ConnectionMode.sseServer) {
|
||||
_connectionMode = ConnectionMode.ble;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
debugPrint('✅ [ConnectionProvider] SSE server stopped');
|
||||
}
|
||||
|
||||
/// Broadcast message to SSE clients (call this when receiving messages from BLE)
|
||||
void broadcastMessageToSseClients(Message message) {
|
||||
if (_sseServer.isRunning) {
|
||||
_sseServer.broadcastMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
/// Broadcast contact to SSE clients (call this when receiving contacts from BLE)
|
||||
void broadcastContactToSseClients(Contact contact) {
|
||||
if (_sseServer.isRunning) {
|
||||
_sseServer.broadcastContact(contact);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get SSE server status
|
||||
bool get isSseServerRunning => _sseServer.isRunning;
|
||||
|
||||
/// Get number of connected SSE clients
|
||||
int get sseClientCount => _sseServer.connectedClients;
|
||||
|
||||
/// Set connection mode
|
||||
void setConnectionMode(ConnectionMode mode) {
|
||||
_connectionMode = mode;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Update SSE server configuration
|
||||
void updateSseServerConfig(SseServerConfig config) {
|
||||
_sseServerConfig = config;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_rxActivityTimer?.cancel();
|
||||
@@ -2689,7 +2576,6 @@ class ConnectionProvider with ChangeNotifier {
|
||||
_stopAckCleanupTimer();
|
||||
_bleService.dispose();
|
||||
_tcpService?.dispose();
|
||||
_sseServer.stopServer();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,15 +125,29 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
|
||||
await connectionProvider.getContact(advert.publicKey);
|
||||
if (connectionProvider.error == 'Not found') {
|
||||
connectionProvider.clearError();
|
||||
final fallbackContact = _contactFromPendingAdvert(advert);
|
||||
await connectionProvider.addOrUpdateContact(fallbackContact);
|
||||
contactsProvider.addOrUpdateContact(
|
||||
fallbackContact,
|
||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||
final canAddDirectly = _canAddPendingAdvertDirectly(advert);
|
||||
if (canAddDirectly) {
|
||||
final added = await _addPendingAdvertToRadio(
|
||||
advert,
|
||||
connectionProvider: connectionProvider,
|
||||
contactsProvider: contactsProvider,
|
||||
);
|
||||
if (!added) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await connectionProvider.getContact(advert.publicKey);
|
||||
if (connectionProvider.error == 'Not found') {
|
||||
connectionProvider.clearError();
|
||||
final added = await _addPendingAdvertToRadio(
|
||||
advert,
|
||||
connectionProvider: connectionProvider,
|
||||
contactsProvider: contactsProvider,
|
||||
);
|
||||
if (!added) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((advert.typeValue ?? 0) == _sensorAdvertType) {
|
||||
await connectionProvider.requestTelemetry(advert.publicKey);
|
||||
@@ -200,6 +214,37 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
bool _canAddPendingAdvertDirectly(PendingAdvert advert) {
|
||||
return advert.publicKey.length == 32 &&
|
||||
advert.typeValue != null &&
|
||||
advert.typeValue != 0;
|
||||
}
|
||||
|
||||
Future<bool> _addPendingAdvertToRadio(
|
||||
PendingAdvert advert, {
|
||||
required ConnectionProvider connectionProvider,
|
||||
required ContactsProvider contactsProvider,
|
||||
}) async {
|
||||
final contact = _contactFromPendingAdvert(advert);
|
||||
await connectionProvider.addOrUpdateContact(contact);
|
||||
|
||||
final addError = connectionProvider.error;
|
||||
if (addError != null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to add contact: $addError')),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
contactsProvider.addOrUpdateContact(
|
||||
contact,
|
||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
String _displayNameForAdvert(
|
||||
PendingAdvert advert,
|
||||
ContactsProvider contactsProvider,
|
||||
@@ -375,207 +420,203 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
),
|
||||
body: FutureBuilder<List<MeshMapNode>>(
|
||||
future: _cachedNodesFuture,
|
||||
builder: (context, nodesSnapshot) =>
|
||||
Consumer2<ContactsProvider, ConnectionProvider>(
|
||||
builder: (context, contactsProvider, connectionProvider, child) {
|
||||
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||
final isConnected = connectionProvider.deviceInfo.isConnected;
|
||||
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
|
||||
builder: (context, nodesSnapshot) => Consumer2<ContactsProvider, ConnectionProvider>(
|
||||
builder: (context, contactsProvider, connectionProvider, child) {
|
||||
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||
final isConnected = connectionProvider.deviceInfo.isConnected;
|
||||
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.person_search),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Pending discoveries (${pendingAdverts.length})',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Resolve entries manually so they do not auto-populate contacts.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: isConnected &&
|
||||
pendingAdverts.isNotEmpty &&
|
||||
!_isResolvingAll
|
||||
? () => _resolveAll(pendingAdverts)
|
||||
: null,
|
||||
icon: _isResolvingAll
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.download_for_offline_outlined,
|
||||
),
|
||||
label: const Text('Resolve all'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: pendingAdverts.isNotEmpty
|
||||
? _clearAllDiscoveries
|
||||
: null,
|
||||
icon: const Icon(Icons.clear_all_rounded),
|
||||
label: const Text('Clear all'),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Icon(Icons.person_search),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Pending discoveries (${pendingAdverts.length})',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (pendingAdverts.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person_search_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No pending discoveries',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Unknown adverts will appear here until you choose to resolve them.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Resolve entries manually so they do not auto-populate contacts.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
...pendingAdverts.map((advert) {
|
||||
final isResolving = _resolvingAdvertKeys.contains(
|
||||
advert.publicKeyHex,
|
||||
);
|
||||
final displayName = _displayNameForAdvert(
|
||||
advert,
|
||||
contactsProvider,
|
||||
cachedNodes,
|
||||
);
|
||||
final typeLabel = _resolvedTypeLabelForAdvert(
|
||||
advert,
|
||||
cachedNodes,
|
||||
);
|
||||
final downMetric = SignalMetric.fromValues(
|
||||
rssiDbm: advert.rxRssiDbm,
|
||||
snrDb: advert.rxSnr,
|
||||
);
|
||||
final upMetric = SignalMetric.fromValues(
|
||||
rssiDbm: advert.repeaterLastRssi,
|
||||
snrDb: advert.repeaterLastSnr,
|
||||
);
|
||||
final detailLines = <String>[
|
||||
'${l10n.publicKey}: ${advert.shortDisplayKey}',
|
||||
];
|
||||
final summaryParts = <String>[];
|
||||
final battery = advert.repeaterBatteryPercent;
|
||||
if (battery != null) {
|
||||
summaryParts.add('Battery ${battery.round()}%');
|
||||
}
|
||||
if (advert.repeaterQueueLen != null) {
|
||||
summaryParts.add('Queue ${advert.repeaterQueueLen}');
|
||||
}
|
||||
if (summaryParts.isNotEmpty) {
|
||||
detailLines.add(summaryParts.join(' • '));
|
||||
}
|
||||
detailLines.add(
|
||||
'${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
|
||||
);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
child: Icon(_iconForAdvert(advert)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildAdvertTitle(
|
||||
context,
|
||||
displayName: displayName,
|
||||
subtitle: typeLabel,
|
||||
),
|
||||
),
|
||||
if (downMetric != null || upMetric != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
_buildSignalSummary(
|
||||
context,
|
||||
downMetric: downMetric,
|
||||
upMetric: upMetric,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
isResolving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(
|
||||
Icons.person_add_alt_1,
|
||||
),
|
||||
tooltip: 'Resolve contact',
|
||||
onPressed: isConnected
|
||||
? () => _resolveAdvert(advert)
|
||||
: null,
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed:
|
||||
isConnected &&
|
||||
pendingAdverts.isNotEmpty &&
|
||||
!_isResolvingAll
|
||||
? () => _resolveAll(pendingAdverts)
|
||||
: null,
|
||||
icon: _isResolvingAll
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Icon(
|
||||
Icons.download_for_offline_outlined,
|
||||
),
|
||||
label: const Text('Resolve all'),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
detailLines.join('\n'),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: pendingAdverts.isNotEmpty
|
||||
? _clearAllDiscoveries
|
||||
: null,
|
||||
icon: const Icon(Icons.clear_all_rounded),
|
||||
label: const Text('Clear all'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (pendingAdverts.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person_search_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No pending discoveries',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Unknown adverts will appear here until you choose to resolve them.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
...pendingAdverts.map((advert) {
|
||||
final isResolving = _resolvingAdvertKeys.contains(
|
||||
advert.publicKeyHex,
|
||||
);
|
||||
final displayName = _displayNameForAdvert(
|
||||
advert,
|
||||
contactsProvider,
|
||||
cachedNodes,
|
||||
);
|
||||
final typeLabel = _resolvedTypeLabelForAdvert(
|
||||
advert,
|
||||
cachedNodes,
|
||||
);
|
||||
final downMetric = SignalMetric.fromValues(
|
||||
rssiDbm: advert.rxRssiDbm,
|
||||
snrDb: advert.rxSnr,
|
||||
);
|
||||
final upMetric = SignalMetric.fromValues(
|
||||
rssiDbm: advert.repeaterLastRssi,
|
||||
snrDb: advert.repeaterLastSnr,
|
||||
);
|
||||
final detailLines = <String>[
|
||||
'${l10n.publicKey}: ${advert.shortDisplayKey}',
|
||||
];
|
||||
final summaryParts = <String>[];
|
||||
final battery = advert.repeaterBatteryPercent;
|
||||
if (battery != null) {
|
||||
summaryParts.add('Battery ${battery.round()}%');
|
||||
}
|
||||
if (advert.repeaterQueueLen != null) {
|
||||
summaryParts.add('Queue ${advert.repeaterQueueLen}');
|
||||
}
|
||||
if (summaryParts.isNotEmpty) {
|
||||
detailLines.add(summaryParts.join(' • '));
|
||||
}
|
||||
detailLines.add(
|
||||
'${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
|
||||
);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
CircleAvatar(child: Icon(_iconForAdvert(advert))),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildAdvertTitle(
|
||||
context,
|
||||
displayName: displayName,
|
||||
subtitle: typeLabel,
|
||||
),
|
||||
),
|
||||
if (downMetric != null || upMetric != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
_buildSignalSummary(
|
||||
context,
|
||||
downMetric: downMetric,
|
||||
upMetric: upMetric,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
isResolving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.person_add_alt_1),
|
||||
tooltip: 'Resolve contact',
|
||||
onPressed: isConnected
|
||||
? () => _resolveAdvert(advert)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
detailLines.join('\n'),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -629,9 +670,9 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
metric.valueLabel,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -31,7 +31,6 @@ import '../utils/image_message_parser.dart';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../widgets/connection_mode_selector.dart';
|
||||
import '../widgets/update_dialog.dart';
|
||||
import 'sar_template_management_screen.dart';
|
||||
import 'welcome_wizard_screen.dart';
|
||||
@@ -1053,7 +1052,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
await NotificationService().setSarNotificationsEnabled(value);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.contact_page_outlined),
|
||||
title: const Text('Discovery notifications'),
|
||||
@@ -1070,6 +1068,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.system_update),
|
||||
title: const Text('Update notifications'),
|
||||
subtitle: const Text(
|
||||
@@ -1554,11 +1553,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
]),
|
||||
|
||||
if (!kIsWeb) ...[
|
||||
_buildSectionHeader('Network Sharing'),
|
||||
const ConnectionModeSelector(),
|
||||
],
|
||||
|
||||
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
|
||||
_buildSettingsCard([
|
||||
SwitchListTile(
|
||||
|
||||
@@ -42,89 +42,100 @@ class MapMarkerService {
|
||||
double mapRotation = 0,
|
||||
Position? userPosition,
|
||||
}) {
|
||||
return contacts.map((contact) {
|
||||
final location = contact.displayLocation;
|
||||
if (location == null) return null;
|
||||
return contacts
|
||||
.map((contact) {
|
||||
final location = contact.displayLocation;
|
||||
if (location == null) return null;
|
||||
|
||||
return Marker(
|
||||
point: location,
|
||||
width: 80,
|
||||
height: 100,
|
||||
rotate: false, // Don't rotate the entire marker with map
|
||||
child: Transform.rotate(
|
||||
angle: -mapRotation * pi / 180,
|
||||
child: GestureDetector(
|
||||
onTap: onTap != null ? () => onTap(contact) : null,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Location update time indicator
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: getLocationAgeColor(contact),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
contact.timeSinceLocationUpdate,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Marker icon
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: contact.type == ContactType.channel ||
|
||||
contact.type == ContactType.room
|
||||
? BoxShape.rectangle
|
||||
: BoxShape.circle,
|
||||
borderRadius: contact.type == ContactType.channel ||
|
||||
contact.type == ContactType.room
|
||||
? BorderRadius.circular(14)
|
||||
: null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
return Marker(
|
||||
point: location,
|
||||
width: 80,
|
||||
height: 100,
|
||||
rotate: false, // Don't rotate the entire marker with map
|
||||
child: Transform.rotate(
|
||||
angle: -mapRotation * pi / 180,
|
||||
child: GestureDetector(
|
||||
onTap: onTap != null ? () => onTap(contact) : null,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Location update time indicator
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: getLocationAgeColor(contact),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
contact.timeSinceLocationUpdate,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: ContactAvatar(contact: contact, radius: 16),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Name label (without emoji)
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 80),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
contact.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Marker icon
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape:
|
||||
contact.type == ContactType.channel ||
|
||||
contact.type == ContactType.room
|
||||
? BoxShape.rectangle
|
||||
: BoxShape.circle,
|
||||
borderRadius:
|
||||
contact.type == ContactType.channel ||
|
||||
contact.type == ContactType.room
|
||||
? BorderRadius.circular(14)
|
||||
: null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: ContactAvatar(contact: contact, radius: 16),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Name label (without emoji)
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 80),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
contact.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).whereType<Marker>().toList();
|
||||
);
|
||||
})
|
||||
.whereType<Marker>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Generate markers for SAR events.
|
||||
@@ -157,7 +168,10 @@ class MapMarkerService {
|
||||
children: [
|
||||
// Time ago label
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: getSarMarkerColor(marker.type),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
@@ -188,7 +202,7 @@ class MapMarkerService {
|
||||
),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Text(
|
||||
marker.emoji, // Use custom emoji if available
|
||||
marker.emoji, // Use custom emoji if available
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
@@ -196,13 +210,17 @@ class MapMarkerService {
|
||||
// Type label
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 90),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
marker.displayName, // Uses notes if available, otherwise type.displayName
|
||||
marker
|
||||
.displayName, // Uses notes if available, otherwise type.displayName
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
@@ -269,7 +287,8 @@ class MapMarkerService {
|
||||
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) *
|
||||
@@ -299,8 +318,8 @@ class MapMarkerService {
|
||||
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;
|
||||
@@ -368,6 +387,8 @@ class MapMarkerService {
|
||||
return Colors.deepPurple; // Purple for repeaters
|
||||
case ContactType.room:
|
||||
return Colors.teal; // Teal for rooms
|
||||
case ContactType.sensor:
|
||||
return Colors.green; // Green for sensors
|
||||
case ContactType.channel:
|
||||
return Colors.orange; // Orange for channels
|
||||
case ContactType.none:
|
||||
@@ -389,6 +410,8 @@ class MapMarkerService {
|
||||
return Icons.router; // Router icon for repeaters
|
||||
case ContactType.room:
|
||||
return Icons.forum; // Forum/chat icon for rooms
|
||||
case ContactType.sensor:
|
||||
return Icons.sensors; // Sensors icon for sensor nodes
|
||||
case ContactType.channel:
|
||||
return Icons.public; // Public icon for channels
|
||||
case ContactType.none:
|
||||
@@ -466,7 +489,8 @@ class MapMarkerService {
|
||||
}
|
||||
|
||||
if (allPoints.isEmpty) {
|
||||
return defaultCenter ?? const LatLng(46.0569, 14.5058); // Ljubljana, Slovenia
|
||||
return defaultCenter ??
|
||||
const LatLng(46.0569, 14.5058); // Ljubljana, Slovenia
|
||||
}
|
||||
|
||||
double lat = 0, lng = 0;
|
||||
|
||||
@@ -1,785 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shelf/shelf.dart' as shelf;
|
||||
import 'package:shelf/shelf_io.dart' as io;
|
||||
import 'package:nsd/nsd.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/sse_server_config.dart';
|
||||
import 'network_scanner_service.dart';
|
||||
|
||||
/// SSE Server Service
|
||||
///
|
||||
/// Provides a web server with SSE (Server-Sent Events) endpoints for
|
||||
/// real-time message and contact updates, enabling multiple app instances
|
||||
/// to share a single MeshCore BLE device.
|
||||
///
|
||||
/// Endpoints:
|
||||
/// - GET /sse/messages - SSE stream for message updates
|
||||
/// - GET /sse/contacts - SSE stream for contact updates
|
||||
/// - POST /api/messages - Send message
|
||||
/// - POST /api/messages/channel - Send channel message
|
||||
/// - POST /api/contacts/sync - Trigger contact sync
|
||||
/// - GET /api/messages/history - Get all messages
|
||||
/// - GET /api/contacts - Get all contacts
|
||||
/// - GET /api/status - Server health check
|
||||
class SseServerService {
|
||||
HttpServer? _server;
|
||||
SseServerConfig? _config;
|
||||
Registration? _bonjourRegistration;
|
||||
|
||||
/// Active SSE connections for messages
|
||||
final Set<StreamController<String>> _messageStreams = {};
|
||||
|
||||
/// Active SSE connections for contacts
|
||||
final Set<StreamController<String>> _contactStreams = {};
|
||||
|
||||
/// Message history (for new clients)
|
||||
final List<Message> _messageHistory = [];
|
||||
|
||||
/// Contact list (for new clients)
|
||||
final Map<String, Contact> _contacts = {};
|
||||
|
||||
/// Timer for cleaning up dead connections
|
||||
Timer? _cleanupTimer;
|
||||
|
||||
/// Device name (for status endpoint)
|
||||
String? _deviceName;
|
||||
|
||||
/// Set device name
|
||||
void setDeviceName(String? name) {
|
||||
_deviceName = name;
|
||||
debugPrint('📝 [SseServer] Device name set to: $name');
|
||||
}
|
||||
|
||||
/// Callback for when a client requests to send a message
|
||||
Future<bool> Function(String recipientPublicKey, String text)? onSendMessage;
|
||||
|
||||
/// Callback for when a client requests to send a channel message
|
||||
Future<void> Function(int channelIdx, String text)? onSendChannelMessage;
|
||||
|
||||
/// Callback for when a client requests contact sync
|
||||
Future<void> Function()? onSyncContacts;
|
||||
|
||||
/// Check if server is running
|
||||
bool get isRunning => _server != null;
|
||||
|
||||
/// Get current configuration
|
||||
SseServerConfig? get config => _config;
|
||||
|
||||
/// Get number of connected clients
|
||||
int get connectedClients => _messageStreams.length;
|
||||
|
||||
/// CORS middleware
|
||||
static shelf.Middleware get _corsHeaders {
|
||||
return shelf.createMiddleware(
|
||||
responseHandler: (shelf.Response response) {
|
||||
return response.change(
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers':
|
||||
'Origin, Content-Type, Authorization',
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Start the SSE server
|
||||
Future<void> startServer(SseServerConfig config) async {
|
||||
if (_server != null) {
|
||||
debugPrint('⚠️ [SseServer] Server already running');
|
||||
return;
|
||||
}
|
||||
|
||||
_config = config;
|
||||
|
||||
try {
|
||||
debugPrint(
|
||||
'🚀 [SseServer] Starting server on ${config.host}:${config.port}',
|
||||
);
|
||||
|
||||
// Create shelf handler with CORS support
|
||||
final handler = const shelf.Pipeline()
|
||||
.addMiddleware(_corsHeaders)
|
||||
.addMiddleware(shelf.logRequests())
|
||||
.addHandler(_handleRequest);
|
||||
|
||||
// Start HTTP server
|
||||
_server = await io.serve(handler, config.host, config.port);
|
||||
|
||||
debugPrint('✅ [SseServer] Server started on ${config.getServerUrl()}');
|
||||
|
||||
// Start cleanup timer for dead connections
|
||||
_startCleanupTimer();
|
||||
|
||||
// Register Bonjour/mDNS service
|
||||
await _registerBonjourService(config);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [SseServer] Failed to start server: $e');
|
||||
_server = null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Register Bonjour/mDNS service for network discovery
|
||||
Future<void> _registerBonjourService(SseServerConfig config) async {
|
||||
try {
|
||||
debugPrint(
|
||||
'📡 [SseServer] Registering Bonjour service ${NetworkScannerService.serviceType}...',
|
||||
);
|
||||
|
||||
_bonjourRegistration = await register(
|
||||
const Service(
|
||||
name: 'MeshCore SSE Server',
|
||||
type: NetworkScannerService.serviceType,
|
||||
port: 0, // Will be set dynamically
|
||||
),
|
||||
);
|
||||
|
||||
// Update with actual port
|
||||
if (_bonjourRegistration != null) {
|
||||
// Unregister and re-register with correct port
|
||||
await unregister(_bonjourRegistration!);
|
||||
_bonjourRegistration = await register(
|
||||
Service(
|
||||
name: 'MeshCore SSE Server',
|
||||
type: NetworkScannerService.serviceType,
|
||||
port: config.port,
|
||||
),
|
||||
);
|
||||
debugPrint(
|
||||
'✅ [SseServer] Bonjour service registered on port ${config.port}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [SseServer] Failed to register Bonjour service: $e');
|
||||
// Don't throw - server can still work without Bonjour
|
||||
}
|
||||
}
|
||||
|
||||
/// Start cleanup timer to remove dead connections
|
||||
void _startCleanupTimer() {
|
||||
_cleanupTimer?.cancel();
|
||||
_cleanupTimer = Timer.periodic(const Duration(seconds: 60), (timer) {
|
||||
_cleanupDeadConnections();
|
||||
});
|
||||
debugPrint('🧹 [SseServer] Cleanup timer started (60s interval)');
|
||||
}
|
||||
|
||||
/// Clean up dead/closed connections
|
||||
void _cleanupDeadConnections() {
|
||||
// Clean up message streams
|
||||
final deadMessageStreams = _messageStreams
|
||||
.where((s) => s.isClosed)
|
||||
.toList();
|
||||
for (final stream in deadMessageStreams) {
|
||||
_messageStreams.remove(stream);
|
||||
}
|
||||
|
||||
// Clean up contact streams
|
||||
final deadContactStreams = _contactStreams
|
||||
.where((s) => s.isClosed)
|
||||
.toList();
|
||||
for (final stream in deadContactStreams) {
|
||||
_contactStreams.remove(stream);
|
||||
}
|
||||
|
||||
if (deadMessageStreams.isNotEmpty || deadContactStreams.isNotEmpty) {
|
||||
debugPrint(
|
||||
'🧹 [SseServer] Cleaned up ${deadMessageStreams.length} dead message streams, ${deadContactStreams.length} dead contact streams',
|
||||
);
|
||||
debugPrint(
|
||||
' Active: ${_messageStreams.length} message clients, ${_contactStreams.length} contact clients',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the SSE server
|
||||
Future<void> stopServer() async {
|
||||
if (_server == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('🛑 [SseServer] Stopping server...');
|
||||
|
||||
// Stop cleanup timer
|
||||
_cleanupTimer?.cancel();
|
||||
_cleanupTimer = null;
|
||||
|
||||
// Close all SSE streams
|
||||
for (final stream in _messageStreams) {
|
||||
await stream.close();
|
||||
}
|
||||
_messageStreams.clear();
|
||||
|
||||
for (final stream in _contactStreams) {
|
||||
await stream.close();
|
||||
}
|
||||
_contactStreams.clear();
|
||||
|
||||
// Unregister Bonjour service
|
||||
if (_bonjourRegistration != null) {
|
||||
try {
|
||||
await unregister(_bonjourRegistration!);
|
||||
debugPrint('✅ [SseServer] Bonjour service unregistered');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [SseServer] Failed to unregister Bonjour service: $e');
|
||||
}
|
||||
_bonjourRegistration = null;
|
||||
}
|
||||
|
||||
// Close HTTP server
|
||||
await _server!.close(force: true);
|
||||
_server = null;
|
||||
_config = null;
|
||||
|
||||
debugPrint('✅ [SseServer] Server stopped');
|
||||
}
|
||||
|
||||
/// Main request handler
|
||||
Future<shelf.Response> _handleRequest(shelf.Request request) async {
|
||||
// Check authentication if token is configured
|
||||
if (_config?.authToken != null) {
|
||||
final authHeader = request.headers['authorization'];
|
||||
if (authHeader != 'Bearer ${_config!.authToken}') {
|
||||
return shelf.Response.forbidden('Invalid authentication token');
|
||||
}
|
||||
}
|
||||
|
||||
final path = request.url.path;
|
||||
final method = request.method;
|
||||
|
||||
debugPrint('📨 [SseServer] $method /$path');
|
||||
|
||||
// Route requests
|
||||
if (method == 'GET' && path == 'sse/messages') {
|
||||
return _handleSseMessages(request);
|
||||
} else if (method == 'GET' && path == 'sse/contacts') {
|
||||
return _handleSseContacts(request);
|
||||
} else if (method == 'POST' && path == 'api/messages') {
|
||||
return _handlePostMessage(request);
|
||||
} else if (method == 'POST' && path == 'api/messages/channel') {
|
||||
return _handlePostChannelMessage(request);
|
||||
} else if (method == 'POST' && path == 'api/contacts/sync') {
|
||||
return _handlePostContactsSync(request);
|
||||
} else if (method == 'GET' && path == 'api/messages/history') {
|
||||
return _handleGetMessageHistory(request);
|
||||
} else if (method == 'GET' && path == 'api/contacts') {
|
||||
return _handleGetContacts(request);
|
||||
} else if (method == 'GET' && path == 'api/status') {
|
||||
return _handleGetStatus(request);
|
||||
} else if (method == 'GET' && path == '') {
|
||||
return _handleRoot(request);
|
||||
}
|
||||
|
||||
return shelf.Response.notFound('Not found');
|
||||
}
|
||||
|
||||
/// Handle SSE messages stream
|
||||
shelf.Response _handleSseMessages(shelf.Request request) {
|
||||
return request.hijack((channel) async {
|
||||
debugPrint(
|
||||
'📥 [SseServer] New SSE client connected (messages) via hijack',
|
||||
);
|
||||
|
||||
// Set up the sink for sending data
|
||||
final sink = utf8.encoder.startChunkedConversion(channel.sink);
|
||||
|
||||
// Send SSE headers
|
||||
sink.add('HTTP/1.1 200 OK\r\n');
|
||||
sink.add('Content-Type: text/event-stream\r\n');
|
||||
sink.add('Cache-Control: no-cache\r\n');
|
||||
sink.add('Connection: keep-alive\r\n');
|
||||
sink.add('\r\n');
|
||||
|
||||
// Create controller for this connection
|
||||
final controller = StreamController<String>();
|
||||
_messageStreams.add(controller);
|
||||
|
||||
debugPrint(' Total clients: ${_messageStreams.length}');
|
||||
|
||||
// Send initial connection event
|
||||
sink.add(': connected\n\n');
|
||||
|
||||
// Send initial message history
|
||||
for (final message in _messageHistory) {
|
||||
final event = _formatSseEvent('message', _messageToJson(message));
|
||||
sink.add(event);
|
||||
}
|
||||
|
||||
// Start keep-alive timer
|
||||
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (
|
||||
timer,
|
||||
) {
|
||||
try {
|
||||
sink.add(': keepalive\n\n');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [SseServer] Keep-alive failed: $e');
|
||||
timer.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to controller for new messages to broadcast
|
||||
final subscription = controller.stream.listen(
|
||||
(data) {
|
||||
try {
|
||||
sink.add(data);
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [SseServer] Failed to send data: $e');
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
debugPrint('📤 [SseServer] Controller stream closed');
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for channel to close
|
||||
await channel.stream.drain();
|
||||
|
||||
// Cleanup
|
||||
keepAliveTimer.cancel();
|
||||
await subscription.cancel();
|
||||
_messageStreams.remove(controller);
|
||||
await controller.close();
|
||||
|
||||
debugPrint('📤 [SseServer] SSE client disconnected (messages)');
|
||||
debugPrint(' Total clients: ${_messageStreams.length}');
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle SSE contacts stream
|
||||
shelf.Response _handleSseContacts(shelf.Request request) {
|
||||
return request.hijack((channel) async {
|
||||
debugPrint(
|
||||
'📥 [SseServer] New SSE client connected (contacts) via hijack',
|
||||
);
|
||||
|
||||
// Set up the sink for sending data
|
||||
final sink = utf8.encoder.startChunkedConversion(channel.sink);
|
||||
|
||||
// Send SSE headers
|
||||
sink.add('HTTP/1.1 200 OK\r\n');
|
||||
sink.add('Content-Type: text/event-stream\r\n');
|
||||
sink.add('Cache-Control: no-cache\r\n');
|
||||
sink.add('Connection: keep-alive\r\n');
|
||||
sink.add('\r\n');
|
||||
|
||||
// Create controller for this connection
|
||||
final controller = StreamController<String>();
|
||||
_contactStreams.add(controller);
|
||||
|
||||
debugPrint(' Total clients: ${_contactStreams.length}');
|
||||
|
||||
// Send initial connection event
|
||||
sink.add(': connected\n\n');
|
||||
|
||||
// Send initial contact list
|
||||
for (final contact in _contacts.values) {
|
||||
final event = _formatSseEvent('contact', _contactToJson(contact));
|
||||
sink.add(event);
|
||||
}
|
||||
|
||||
// Start keep-alive timer
|
||||
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (
|
||||
timer,
|
||||
) {
|
||||
try {
|
||||
sink.add(': keepalive\n\n');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [SseServer] Keep-alive failed: $e');
|
||||
timer.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to controller for new messages to broadcast
|
||||
final subscription = controller.stream.listen(
|
||||
(data) {
|
||||
try {
|
||||
sink.add(data);
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [SseServer] Failed to send data: $e');
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
debugPrint('📤 [SseServer] Controller stream closed');
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for channel to close
|
||||
await channel.stream.drain();
|
||||
|
||||
// Cleanup
|
||||
keepAliveTimer.cancel();
|
||||
await subscription.cancel();
|
||||
_contactStreams.remove(controller);
|
||||
await controller.close();
|
||||
|
||||
debugPrint('📤 [SseServer] SSE client disconnected (contacts)');
|
||||
debugPrint(' Total clients: ${_contactStreams.length}');
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle POST message request
|
||||
Future<shelf.Response> _handlePostMessage(shelf.Request request) async {
|
||||
try {
|
||||
final body = await request.readAsString();
|
||||
final json = jsonDecode(body) as Map<String, dynamic>;
|
||||
|
||||
final recipientPublicKey = json['recipientPublicKey'] as String;
|
||||
final text = json['text'] as String;
|
||||
|
||||
if (onSendMessage == null) {
|
||||
return shelf.Response.internalServerError(
|
||||
body: jsonEncode({'error': 'Send message callback not configured'}),
|
||||
);
|
||||
}
|
||||
|
||||
final success = await onSendMessage!(recipientPublicKey, text);
|
||||
|
||||
return shelf.Response.ok(
|
||||
jsonEncode({'success': success}),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [SseServer] Error handling POST message: $e');
|
||||
return shelf.Response.internalServerError(
|
||||
body: jsonEncode({'error': e.toString()}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle POST channel message request
|
||||
Future<shelf.Response> _handlePostChannelMessage(
|
||||
shelf.Request request,
|
||||
) async {
|
||||
try {
|
||||
final body = await request.readAsString();
|
||||
final json = jsonDecode(body) as Map<String, dynamic>;
|
||||
|
||||
final channelIdx = json['channelIdx'] as int;
|
||||
final text = json['text'] as String;
|
||||
|
||||
if (onSendChannelMessage == null) {
|
||||
return shelf.Response.internalServerError(
|
||||
body: jsonEncode({
|
||||
'error': 'Send channel message callback not configured',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await onSendChannelMessage!(channelIdx, text);
|
||||
|
||||
return shelf.Response.ok(
|
||||
jsonEncode({'success': true}),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [SseServer] Error handling POST channel message: $e');
|
||||
return shelf.Response.internalServerError(
|
||||
body: jsonEncode({'error': e.toString()}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle POST contacts sync request
|
||||
Future<shelf.Response> _handlePostContactsSync(shelf.Request request) async {
|
||||
try {
|
||||
if (onSyncContacts == null) {
|
||||
return shelf.Response.internalServerError(
|
||||
body: jsonEncode({'error': 'Sync contacts callback not configured'}),
|
||||
);
|
||||
}
|
||||
|
||||
await onSyncContacts!();
|
||||
|
||||
return shelf.Response.ok(
|
||||
jsonEncode({'success': true}),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [SseServer] Error handling POST contacts sync: $e');
|
||||
return shelf.Response.internalServerError(
|
||||
body: jsonEncode({'error': e.toString()}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle GET message history request
|
||||
shelf.Response _handleGetMessageHistory(shelf.Request request) {
|
||||
final messages = _messageHistory.map(_messageToJson).toList();
|
||||
return shelf.Response.ok(
|
||||
jsonEncode({'messages': messages}),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle GET contacts request
|
||||
shelf.Response _handleGetContacts(shelf.Request request) {
|
||||
final contacts = _contacts.values.map(_contactToJson).toList();
|
||||
return shelf.Response.ok(
|
||||
jsonEncode({'contacts': contacts}),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle GET status request
|
||||
shelf.Response _handleGetStatus(shelf.Request request) {
|
||||
return shelf.Response.ok(
|
||||
jsonEncode({
|
||||
'status': 'running',
|
||||
'connectedClients': connectedClients,
|
||||
'messageCount': _messageHistory.length,
|
||||
'contactCount': _contacts.length,
|
||||
'deviceName': _deviceName,
|
||||
}),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle root request (landing page)
|
||||
shelf.Response _handleRoot(shelf.Request request) {
|
||||
final html = '''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>MeshCore SAR - SSE Server</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 40px; background: #f5f5f5; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
h1 { color: #333; }
|
||||
.status { background: #4CAF50; color: white; padding: 10px; border-radius: 4px; margin: 20px 0; }
|
||||
.endpoint { background: #f9f9f9; padding: 10px; margin: 10px 0; border-left: 3px solid #2196F3; font-family: monospace; }
|
||||
code { background: #eee; padding: 2px 6px; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🚀 MeshCore SAR Server</h1>
|
||||
<div class="status">✅ Server is running</div>
|
||||
<p>This server enables multiple MeshCore SAR clients to share a single BLE device.</p>
|
||||
|
||||
<h2>📡 SSE Endpoints</h2>
|
||||
<div class="endpoint">GET /sse/messages</div>
|
||||
<div class="endpoint">GET /sse/contacts</div>
|
||||
|
||||
<h2>🔧 API Endpoints</h2>
|
||||
<div class="endpoint">POST /api/messages</div>
|
||||
<div class="endpoint">POST /api/messages/channel</div>
|
||||
<div class="endpoint">POST /api/contacts/sync</div>
|
||||
<div class="endpoint">GET /api/messages/history</div>
|
||||
<div class="endpoint">GET /api/contacts</div>
|
||||
<div class="endpoint">GET /api/status</div>
|
||||
|
||||
<h2>📊 Stats</h2>
|
||||
<p>Connected clients: <strong id="clients">Loading...</strong></p>
|
||||
<p>Messages: <strong id="messages">Loading...</strong></p>
|
||||
<p>Contacts: <strong id="contacts">Loading...</strong></p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function updateStats() {
|
||||
try {
|
||||
const res = await fetch('/api/status');
|
||||
const data = await res.json();
|
||||
document.getElementById('clients').textContent = data.connectedClients;
|
||||
document.getElementById('messages').textContent = data.messageCount;
|
||||
document.getElementById('contacts').textContent = data.contactCount;
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch stats:', e);
|
||||
}
|
||||
}
|
||||
updateStats();
|
||||
setInterval(updateStats, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
''';
|
||||
return shelf.Response.ok(html, headers: {'content-type': 'text/html'});
|
||||
}
|
||||
|
||||
/// Broadcast a new message to all SSE clients
|
||||
void broadcastMessage(Message message) {
|
||||
// Add to history (limit to 1000 messages)
|
||||
_messageHistory.add(message);
|
||||
if (_messageHistory.length > 1000) {
|
||||
_messageHistory.removeAt(0);
|
||||
}
|
||||
|
||||
// Broadcast to all connected clients
|
||||
final event = _formatSseEvent('message', _messageToJson(message));
|
||||
final deadStreams = <StreamController<String>>[];
|
||||
|
||||
for (final stream in _messageStreams) {
|
||||
if (stream.isClosed) {
|
||||
deadStreams.add(stream);
|
||||
} else {
|
||||
try {
|
||||
stream.add(event);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'⚠️ [SseServer] Failed to send to stream, marking as dead: $e',
|
||||
);
|
||||
deadStreams.add(stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove dead streams
|
||||
for (final stream in deadStreams) {
|
||||
_messageStreams.remove(stream);
|
||||
stream.close().catchError(
|
||||
(e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'),
|
||||
);
|
||||
}
|
||||
|
||||
if (deadStreams.isNotEmpty) {
|
||||
debugPrint(
|
||||
'🧹 [SseServer] Removed ${deadStreams.length} dead message streams during broadcast',
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'📢 [SseServer] Broadcasted message to ${_messageStreams.length} clients',
|
||||
);
|
||||
}
|
||||
|
||||
/// Broadcast a new or updated contact to all SSE clients
|
||||
void broadcastContact(Contact contact) {
|
||||
// Update contact list
|
||||
_contacts[contact.publicKeyHex] = contact;
|
||||
|
||||
// Broadcast to all connected clients
|
||||
final event = _formatSseEvent('contact', _contactToJson(contact));
|
||||
final deadStreams = <StreamController<String>>[];
|
||||
|
||||
for (final stream in _contactStreams) {
|
||||
if (stream.isClosed) {
|
||||
deadStreams.add(stream);
|
||||
} else {
|
||||
try {
|
||||
stream.add(event);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'⚠️ [SseServer] Failed to send to stream, marking as dead: $e',
|
||||
);
|
||||
deadStreams.add(stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove dead streams
|
||||
for (final stream in deadStreams) {
|
||||
_contactStreams.remove(stream);
|
||||
stream.close().catchError(
|
||||
(e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'),
|
||||
);
|
||||
}
|
||||
|
||||
if (deadStreams.isNotEmpty) {
|
||||
debugPrint(
|
||||
'🧹 [SseServer] Removed ${deadStreams.length} dead contact streams during broadcast',
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'📢 [SseServer] Broadcasted contact to ${_contactStreams.length} clients',
|
||||
);
|
||||
}
|
||||
|
||||
/// Format SSE event
|
||||
String _formatSseEvent(String eventType, Map<String, dynamic> data) {
|
||||
final jsonData = jsonEncode(data);
|
||||
return 'event: $eventType\ndata: $jsonData\n\n';
|
||||
}
|
||||
|
||||
/// Convert Message to JSON
|
||||
Map<String, dynamic> _messageToJson(Message message) {
|
||||
return {
|
||||
'id': message.id,
|
||||
'messageType': message.messageType.name,
|
||||
'senderPublicKeyPrefix': message.senderPublicKeyPrefix?.toList(),
|
||||
'channelIdx': message.channelIdx,
|
||||
'pathLen': message.pathLen,
|
||||
'textType': message.textType.value,
|
||||
'senderTimestamp': message.senderTimestamp,
|
||||
'text': message.text,
|
||||
'isSarMarker': message.isSarMarker,
|
||||
'sarGpsCoordinates': message.sarGpsCoordinates != null
|
||||
? {
|
||||
'latitude': message.sarGpsCoordinates!.latitude,
|
||||
'longitude': message.sarGpsCoordinates!.longitude,
|
||||
}
|
||||
: null,
|
||||
'sarNotes': message.sarNotes,
|
||||
'sarCustomEmoji': message.sarCustomEmoji,
|
||||
'sarColorIndex': message.sarColorIndex,
|
||||
'receivedAt': message.receivedAt.toIso8601String(),
|
||||
'senderName': message.senderName,
|
||||
'deliveryStatus': message.deliveryStatus.name,
|
||||
'expectedAckTag': message.expectedAckTag,
|
||||
'suggestedTimeoutMs': message.suggestedTimeoutMs,
|
||||
'roundTripTimeMs': message.roundTripTimeMs,
|
||||
'deliveredAt': message.deliveredAt?.toIso8601String(),
|
||||
'recipientPublicKey': message.recipientPublicKey?.toList(),
|
||||
'retryAttempt': message.retryAttempt,
|
||||
'lastRetryAt': message.lastRetryAt?.toIso8601String(),
|
||||
'usedFloodFallback': message.usedFloodFallback,
|
||||
'isRead': message.isRead,
|
||||
'echoCount': message.echoCount,
|
||||
'firstEchoAt': message.firstEchoAt?.toIso8601String(),
|
||||
'lastEchoSnrRaw': message.lastEchoSnrRaw,
|
||||
'lastEchoRssiDbm': message.lastEchoRssiDbm,
|
||||
'lastEchoAt': message.lastEchoAt?.toIso8601String(),
|
||||
'isDrawing': message.isDrawing,
|
||||
'drawingId': message.drawingId,
|
||||
};
|
||||
}
|
||||
|
||||
/// Convert Contact to JSON
|
||||
Map<String, dynamic> _contactToJson(Contact contact) {
|
||||
return {
|
||||
'publicKey': contact.publicKey.toList(),
|
||||
'publicKeyHex': contact.publicKeyHex,
|
||||
'type': contact.type.value,
|
||||
'flags': contact.flags,
|
||||
'outPathLen': contact.outPathLen,
|
||||
'outPath': contact.outPath.toList(),
|
||||
'advName': contact.advName,
|
||||
'lastAdvert': contact.lastAdvert,
|
||||
'advLat': contact.advLat,
|
||||
'advLon': contact.advLon,
|
||||
'lastMod': contact.lastMod,
|
||||
'telemetry': contact.telemetry != null
|
||||
? {
|
||||
'batteryPercentage': contact.telemetry!.batteryPercentage,
|
||||
'batteryMilliVolts': contact.telemetry!.batteryMilliVolts,
|
||||
'temperature': contact.telemetry!.temperature,
|
||||
'humidity': contact.telemetry!.humidity,
|
||||
'pressure': contact.telemetry!.pressure,
|
||||
'gpsLocation': contact.telemetry!.gpsLocation != null
|
||||
? {
|
||||
'latitude': contact.telemetry!.gpsLocation!.latitude,
|
||||
'longitude': contact.telemetry!.gpsLocation!.longitude,
|
||||
}
|
||||
: null,
|
||||
'timestamp': contact.telemetry!.timestamp.toIso8601String(),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/// Clear message history
|
||||
void clearMessageHistory() {
|
||||
_messageHistory.clear();
|
||||
}
|
||||
|
||||
/// Clear contact list
|
||||
void clearContacts() {
|
||||
_contacts.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../models/sse_server_config.dart';
|
||||
|
||||
/// Connection Mode Selector Widget
|
||||
///
|
||||
/// Allows user to enable/disable SSE Server mode to share device with multiple clients
|
||||
class ConnectionModeSelector extends StatefulWidget {
|
||||
const ConnectionModeSelector({super.key});
|
||||
|
||||
@override
|
||||
State<ConnectionModeSelector> createState() => _ConnectionModeSelectorState();
|
||||
}
|
||||
|
||||
class _ConnectionModeSelectorState extends State<ConnectionModeSelector> {
|
||||
List<String> _localIPs = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadLocalIPs();
|
||||
}
|
||||
|
||||
Future<void> _loadLocalIPs() async {
|
||||
if (kIsWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Set<String> ipsSet = {};
|
||||
|
||||
try {
|
||||
final interfaces = await NetworkInterface.list();
|
||||
for (final interface in interfaces) {
|
||||
for (final addr in interface.addresses) {
|
||||
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
|
||||
ipsSet.add(addr.address);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error getting network interfaces: $e');
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_localIPs = ipsSet.toList();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connectionProvider = Provider.of<ConnectionProvider>(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
'Network Sharing',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// SSE Server Toggle
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.share),
|
||||
title: const Text('Share Device (Server)'),
|
||||
subtitle: Text(
|
||||
connectionProvider.isSseServerRunning
|
||||
? 'Server running on port ${connectionProvider.sseServerConfig.port} - ${connectionProvider.sseClientCount} client(s) connected'
|
||||
: 'Share BLE device with multiple clients over network',
|
||||
),
|
||||
value: connectionProvider.isSseServerRunning,
|
||||
onChanged: (enabled) async {
|
||||
if (enabled) {
|
||||
// Start server with default config (port 12929, no auth)
|
||||
final config = const SseServerConfig(port: 12929, enabled: true);
|
||||
|
||||
try {
|
||||
await connectionProvider.startSseServer(config);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('SSE server started on port 12929'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to start server: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Stop server
|
||||
await connectionProvider.stopSseServer();
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
// Show IP addresses when server is running
|
||||
if (connectionProvider.isSseServerRunning && _localIPs.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
'Connect from other devices:',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
..._localIPs.map((ip) {
|
||||
final url = 'http://$ip:${connectionProvider.sseServerConfig.port}';
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.wifi, size: 20),
|
||||
title: Text(
|
||||
url,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.copy, size: 20),
|
||||
tooltip: 'Copy URL',
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: url));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Copied $url'),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,10 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
|
||||
try {
|
||||
// Manually add the room contact to the radio's flash storage
|
||||
await connectionProvider.addOrUpdateContact(widget.contact);
|
||||
final addError = connectionProvider.error;
|
||||
if (addError != null) {
|
||||
throw Exception(addError);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'✅ [RoomLogin] Room contact added via CMD_ADD_UPDATE_CONTACT',
|
||||
|
||||
@@ -42,6 +42,7 @@ class CompassContactList extends StatelessWidget {
|
||||
// Split contacts by type
|
||||
final persons = <Map<String, dynamic>>[];
|
||||
final repeaters = <Map<String, dynamic>>[];
|
||||
final sensors = <Map<String, dynamic>>[];
|
||||
final rooms = <Map<String, dynamic>>[];
|
||||
|
||||
// Calculate bearings and distances for each contact
|
||||
@@ -70,6 +71,8 @@ class CompassContactList extends StatelessWidget {
|
||||
|
||||
if (contact.isRepeater) {
|
||||
repeaters.add(item);
|
||||
} else if (contact.isSensor) {
|
||||
sensors.add(item);
|
||||
} else if (contact.isRoom) {
|
||||
rooms.add(item);
|
||||
} else {
|
||||
@@ -78,9 +81,18 @@ class CompassContactList extends StatelessWidget {
|
||||
}
|
||||
|
||||
// Sort each list by distance
|
||||
persons.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double));
|
||||
repeaters.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double));
|
||||
rooms.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double));
|
||||
persons.sort(
|
||||
(a, b) => (a['distance'] as double).compareTo(b['distance'] as double),
|
||||
);
|
||||
repeaters.sort(
|
||||
(a, b) => (a['distance'] as double).compareTo(b['distance'] as double),
|
||||
);
|
||||
sensors.sort(
|
||||
(a, b) => (a['distance'] as double).compareTo(b['distance'] as double),
|
||||
);
|
||||
rooms.sort(
|
||||
(a, b) => (a['distance'] as double).compareTo(b['distance'] as double),
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -91,17 +103,34 @@ class CompassContactList extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 4),
|
||||
child: Text(
|
||||
l10n.teamMembers,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
...persons.map((item) => _buildContactTile(
|
||||
...persons.map(
|
||||
(item) => _buildContactTile(
|
||||
context,
|
||||
item,
|
||||
Icons.groups,
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (showContacts && sensors.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
|
||||
child: Text(
|
||||
'Sensors',
|
||||
style: Theme.of(
|
||||
context,
|
||||
item,
|
||||
Icons.groups,
|
||||
Theme.of(context).colorScheme.primary,
|
||||
)),
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
...sensors.map(
|
||||
(item) =>
|
||||
_buildContactTile(context, item, Icons.sensors, Colors.green),
|
||||
),
|
||||
],
|
||||
// Repeaters section
|
||||
if (showRepeaters && repeaters.isNotEmpty) ...[
|
||||
@@ -109,17 +138,15 @@ class CompassContactList extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
|
||||
child: Text(
|
||||
l10n.repeaters,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
...repeaters.map((item) => _buildContactTile(
|
||||
context,
|
||||
item,
|
||||
Icons.router,
|
||||
Colors.purple,
|
||||
)),
|
||||
...repeaters.map(
|
||||
(item) =>
|
||||
_buildContactTile(context, item, Icons.router, Colors.purple),
|
||||
),
|
||||
],
|
||||
// Rooms section
|
||||
if (rooms.isNotEmpty) ...[
|
||||
@@ -127,17 +154,19 @@ class CompassContactList extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
|
||||
child: Text(
|
||||
l10n.rooms,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
...rooms.map(
|
||||
(item) => _buildContactTile(
|
||||
context,
|
||||
item,
|
||||
Icons.meeting_room,
|
||||
Colors.teal,
|
||||
),
|
||||
),
|
||||
...rooms.map((item) => _buildContactTile(
|
||||
context,
|
||||
item,
|
||||
Icons.meeting_room,
|
||||
Colors.teal,
|
||||
)),
|
||||
],
|
||||
],
|
||||
);
|
||||
@@ -161,24 +190,14 @@ class CompassContactList extends StatelessWidget {
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: selectedContact == contact
|
||||
? Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
)
|
||||
? Border.all(color: Theme.of(context).colorScheme.primary, width: 2)
|
||||
: null,
|
||||
),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: contact.roleEmoji != null
|
||||
? Text(
|
||||
contact.roleEmoji!,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
)
|
||||
: Icon(
|
||||
defaultIcon,
|
||||
color: iconColor,
|
||||
size: 24,
|
||||
),
|
||||
? Text(contact.roleEmoji!, style: const TextStyle(fontSize: 24))
|
||||
: Icon(defaultIcon, color: iconColor, size: 24),
|
||||
title: Text(contact.displayName),
|
||||
subtitle: Text(
|
||||
'${_bearingToCardinal(bearing)} • ${_formatDistance(distance)}',
|
||||
@@ -190,16 +209,16 @@ class CompassContactList extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
'${bearing.round()}°',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (heading != null)
|
||||
Text(
|
||||
_formatRelativeBearing(bearing, heading!, context),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.grey,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -217,15 +236,14 @@ class CompassContactList extends StatelessWidget {
|
||||
}
|
||||
|
||||
// Calculate bearing between two points (in degrees)
|
||||
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;
|
||||
@@ -233,12 +251,17 @@ class CompassContactList extends StatelessWidget {
|
||||
|
||||
// Calculate distance between two points (in meters)
|
||||
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) *
|
||||
@@ -262,7 +285,11 @@ class CompassContactList extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
String _formatRelativeBearing(double bearing, double heading, BuildContext context) {
|
||||
String _formatRelativeBearing(
|
||||
double bearing,
|
||||
double heading,
|
||||
BuildContext context,
|
||||
) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
// Calculate relative bearing (how much to turn from current heading)
|
||||
double relative = bearing - heading;
|
||||
|
||||
@@ -12,98 +12,107 @@ class MapMarkers {
|
||||
Function(Contact)? onContactTap,
|
||||
double mapRotation = 0,
|
||||
}) {
|
||||
return contacts.map((contact) {
|
||||
final location = contact.displayLocation;
|
||||
if (location == null) return null;
|
||||
return contacts
|
||||
.map((contact) {
|
||||
final location = contact.displayLocation;
|
||||
if (location == null) return null;
|
||||
|
||||
return Marker(
|
||||
point: location,
|
||||
width: 80,
|
||||
height: 100,
|
||||
rotate: false, // Don't rotate the entire marker with map
|
||||
child: Transform.rotate(
|
||||
angle: -mapRotation * 3.14159265359 / 180,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (onContactTap != null) {
|
||||
onContactTap(contact);
|
||||
} else {
|
||||
_showContactInfo(context, contact);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Location update time indicator
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: _getLocationAgeColor(contact),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
contact.timeSinceLocationUpdate,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Marker icon or emoji
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _getContactTypeColor(contact, context),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
return Marker(
|
||||
point: location,
|
||||
width: 80,
|
||||
height: 100,
|
||||
rotate: false, // Don't rotate the entire marker with map
|
||||
child: Transform.rotate(
|
||||
angle: -mapRotation * 3.14159265359 / 180,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (onContactTap != null) {
|
||||
onContactTap(contact);
|
||||
} else {
|
||||
_showContactInfo(context, contact);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Location update time indicator
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: contact.roleEmoji != null
|
||||
? Text(
|
||||
contact.roleEmoji!,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
)
|
||||
: Icon(
|
||||
_getContactTypeIcon(contact),
|
||||
decoration: BoxDecoration(
|
||||
color: _getLocationAgeColor(contact),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
contact.timeSinceLocationUpdate,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Name label (without emoji)
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 80),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
contact.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Marker icon or emoji
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _getContactTypeColor(contact, context),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: contact.roleEmoji != null
|
||||
? Text(
|
||||
contact.roleEmoji!,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
)
|
||||
: Icon(
|
||||
_getContactTypeIcon(contact),
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Name label (without emoji)
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 80),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
contact.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).whereType<Marker>().toList();
|
||||
);
|
||||
})
|
||||
.whereType<Marker>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
static List<Marker> createSarMarkers(
|
||||
@@ -133,7 +142,10 @@ class MapMarkers {
|
||||
children: [
|
||||
// Time ago label
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getSarMarkerColor(marker),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
@@ -164,7 +176,7 @@ class MapMarkers {
|
||||
),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Text(
|
||||
marker.emoji, // Use custom emoji if available
|
||||
marker.emoji, // Use custom emoji if available
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
@@ -172,7 +184,10 @@ class MapMarkers {
|
||||
// Type label
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 90),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
@@ -183,8 +198,12 @@ class MapMarkers {
|
||||
debugPrint('🗺️ [MapMarker] Displaying SAR marker:');
|
||||
debugPrint(' marker.notes: "${marker.notes}"');
|
||||
debugPrint(' marker.type: ${marker.type}');
|
||||
debugPrint(' marker.type.displayName: ${marker.type.displayName}');
|
||||
debugPrint(' marker.displayName: ${marker.displayName}');
|
||||
debugPrint(
|
||||
' marker.type.displayName: ${marker.type.displayName}',
|
||||
);
|
||||
debugPrint(
|
||||
' marker.displayName: ${marker.displayName}',
|
||||
);
|
||||
|
||||
return Text(
|
||||
marker.displayName,
|
||||
@@ -241,17 +260,25 @@ class MapMarkers {
|
||||
_InfoRow(
|
||||
'Voltage',
|
||||
'${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V'
|
||||
'${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}',
|
||||
'${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}',
|
||||
)
|
||||
else if (contact.displayBattery != null)
|
||||
_InfoRow('Battery', '${contact.displayBattery!.round()}%'),
|
||||
if (contact.telemetry?.temperature != null)
|
||||
_InfoRow(
|
||||
'Temperature', '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C'),
|
||||
'Temperature',
|
||||
'${contact.telemetry!.temperature!.toStringAsFixed(1)}°C',
|
||||
),
|
||||
if (contact.telemetry?.humidity != null)
|
||||
_InfoRow('Humidity', '${contact.telemetry!.humidity!.toStringAsFixed(1)}%'),
|
||||
_InfoRow(
|
||||
'Humidity',
|
||||
'${contact.telemetry!.humidity!.toStringAsFixed(1)}%',
|
||||
),
|
||||
if (contact.telemetry?.pressure != null)
|
||||
_InfoRow('Pressure', '${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa'),
|
||||
_InfoRow(
|
||||
'Pressure',
|
||||
'${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa',
|
||||
),
|
||||
_InfoRow('Last Seen', contact.timeSinceLastSeen),
|
||||
_InfoRow('Public Key', contact.publicKeyShort),
|
||||
],
|
||||
@@ -272,7 +299,10 @@ class MapMarkers {
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(marker.emoji, style: const TextStyle(fontSize: 24)), // Use custom emoji if available
|
||||
Text(
|
||||
marker.emoji,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
), // Use custom emoji if available
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(marker.displayName)),
|
||||
],
|
||||
@@ -313,7 +343,9 @@ class MapMarkers {
|
||||
|
||||
static Color _getSarMarkerColor(SarMarker marker) {
|
||||
// If marker has a color index, use it (new format)
|
||||
if (marker.colorIndex != null && marker.colorIndex! >= 0 && marker.colorIndex! < 8) {
|
||||
if (marker.colorIndex != null &&
|
||||
marker.colorIndex! >= 0 &&
|
||||
marker.colorIndex! < 8) {
|
||||
final colorHex = SarTemplate.getColorFromIndex(marker.colorIndex!);
|
||||
final hexCode = colorHex.replaceAll('#', '');
|
||||
return Color(int.parse('FF$hexCode', radix: 16));
|
||||
@@ -342,6 +374,8 @@ class MapMarkers {
|
||||
return Colors.deepPurple; // Purple for repeaters
|
||||
case ContactType.room:
|
||||
return Colors.teal; // Teal for rooms
|
||||
case ContactType.sensor:
|
||||
return Colors.green; // Green for sensors
|
||||
case ContactType.channel:
|
||||
return Colors.orange; // Orange for channels
|
||||
case ContactType.none:
|
||||
@@ -357,6 +391,8 @@ class MapMarkers {
|
||||
return Icons.router; // Router icon for repeaters
|
||||
case ContactType.room:
|
||||
return Icons.forum; // Forum/chat icon for rooms
|
||||
case ContactType.sensor:
|
||||
return Icons.sensors; // Sensors icon for sensor nodes
|
||||
case ContactType.channel:
|
||||
return Icons.public; // Public icon for channels
|
||||
case ContactType.none:
|
||||
@@ -385,9 +421,7 @@ class _InfoRow extends StatelessWidget {
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value),
|
||||
),
|
||||
Expanded(child: Text(value)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
24
pubspec.lock
24
pubspec.lock
@@ -631,14 +631,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_methods:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_methods
|
||||
sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1208,22 +1200,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shelf:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shelf
|
||||
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
shelf_router:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shelf_router
|
||||
sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.4"
|
||||
simple_sparse_list:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -118,10 +118,6 @@ dependencies:
|
||||
# XML parsing for GPX import/export
|
||||
xml: ^6.5.0
|
||||
|
||||
# SSE web server for multi-user support
|
||||
shelf: ^1.4.0
|
||||
shelf_router: ^1.1.0
|
||||
|
||||
# Network Service Discovery (Bonjour/mDNS)
|
||||
nsd: ^4.0.3
|
||||
|
||||
|
||||
Reference in New Issue
Block a user