Fix contact add failure

This commit is contained in:
Janez T
2026-03-15 09:19:59 +01:00
parent 4557fb4b9e
commit c2e0fa952e
14 changed files with 608 additions and 1662 deletions

View File

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

View File

@@ -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',

View File

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

View File

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