mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
Core Features Unread Message Tracking: Added persistent unread counts for contacts and channels with visual badges Message Deletion: Users can now long-press to delete individual messages in chats and channels SMAZ Compression: Added per-contact compression settings (previously only channels) UTF-8 Length Limiting: Text inputs now enforce protocol byte limits correctly Channel Message Paths: New screen to visualize packet routing through repeater network with map view Protocol Updates Added maxContactMessageBytes() and maxChannelMessageBytes() helpers for message length validation Changed channel PSK format from Base64 to Hexadecimal (breaking change) Added app version field to connection handshake frame UI Improvements Unread badges on all contact and channel list items Enhanced message bubbles with path visualization for channel messages Character count displays in message input fields Improved repeater CLI screen functionality New Files lib/storage/unread_store.dart - Unread tracking persistence lib/storage/contact_settings_store.dart - Per-contact SMAZ settings lib/widgets/unread_badge.dart - Unread count indicator lib/helpers/utf8_length_limiter.dart - Byte-aware text input formatter lib/screens/channel_message_path_screen.dart - Packet path visualization
1109 lines
36 KiB
Dart
1109 lines
36 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_map/flutter_map.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../connector/meshcore_connector.dart';
|
|
import '../connector/meshcore_protocol.dart';
|
|
import '../models/channel.dart';
|
|
import '../models/contact.dart';
|
|
import '../services/app_settings_service.dart';
|
|
import '../services/map_marker_service.dart';
|
|
import 'chat_screen.dart';
|
|
|
|
class MapScreen extends StatefulWidget {
|
|
final LatLng? highlightPosition;
|
|
final String? highlightLabel;
|
|
final double highlightZoom;
|
|
|
|
const MapScreen({
|
|
super.key,
|
|
this.highlightPosition,
|
|
this.highlightLabel,
|
|
this.highlightZoom = 15.0,
|
|
});
|
|
|
|
@override
|
|
State<MapScreen> createState() => _MapScreenState();
|
|
}
|
|
|
|
class _MapScreenState extends State<MapScreen> {
|
|
final MapController _mapController = MapController();
|
|
final MapMarkerService _markerService = MapMarkerService();
|
|
final Set<String> _hiddenMarkerIds = {};
|
|
Set<String> _removedMarkerIds = {};
|
|
bool _isSelectingPoi = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadRemovedMarkers();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
context.read<MeshCoreConnector>().getChannels();
|
|
if (widget.highlightPosition != null) {
|
|
_mapController.move(widget.highlightPosition!, widget.highlightZoom);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _loadRemovedMarkers() async {
|
|
final ids = await _markerService.loadRemovedIds();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_removedMarkerIds = ids;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Consumer2<MeshCoreConnector, AppSettingsService>(
|
|
builder: (context, connector, settingsService, child) {
|
|
final settings = settingsService.settings;
|
|
final contacts = connector.contacts;
|
|
final highlightPosition = widget.highlightPosition;
|
|
final sharedMarkers = settings.mapShowMarkers
|
|
? _collectSharedMarkers(connector)
|
|
.where((marker) =>
|
|
!_hiddenMarkerIds.contains(marker.id) &&
|
|
!_removedMarkerIds.contains(marker.id))
|
|
.toList()
|
|
: <_SharedMarker>[];
|
|
|
|
// Filter by time
|
|
final now = DateTime.now();
|
|
final filteredByTime = settings.mapTimeFilterHours == 0
|
|
? contacts
|
|
: contacts.where((c) {
|
|
final hoursSinceLastSeen =
|
|
now.difference(c.lastSeen).inHours;
|
|
return hoursSinceLastSeen <= settings.mapTimeFilterHours;
|
|
}).toList();
|
|
|
|
// Filter by key prefix
|
|
final keyPrefix = settings.mapKeyPrefix.trim();
|
|
final filteredByKeyPrefix = (settings.mapKeyPrefixEnabled && keyPrefix.isNotEmpty)
|
|
? filteredByTime.where((c) {
|
|
return c.publicKeyHex.toLowerCase().startsWith(keyPrefix.toLowerCase());
|
|
}).toList()
|
|
: filteredByTime;
|
|
|
|
// Filter by location
|
|
final contactsWithLocation = filteredByKeyPrefix
|
|
.where((c) => c.hasLocation)
|
|
.toList();
|
|
|
|
// Calculate center of all nodes, or default to (0, 0)
|
|
LatLng center = const LatLng(0, 0);
|
|
final hasMapContent = contactsWithLocation.isNotEmpty ||
|
|
sharedMarkers.isNotEmpty ||
|
|
_isSelectingPoi ||
|
|
highlightPosition != null;
|
|
if (contactsWithLocation.isNotEmpty || sharedMarkers.isNotEmpty) {
|
|
double avgLat = contactsWithLocation
|
|
.map((c) => c.latitude!)
|
|
.fold<double>(0, (sum, lat) => sum + lat);
|
|
double avgLon = contactsWithLocation
|
|
.map((c) => c.longitude!)
|
|
.fold<double>(0, (sum, lon) => sum + lon);
|
|
for (final marker in sharedMarkers) {
|
|
avgLat += marker.position.latitude;
|
|
avgLon += marker.position.longitude;
|
|
}
|
|
final total = contactsWithLocation.length + sharedMarkers.length;
|
|
if (total > 0) {
|
|
center = LatLng(avgLat / total, avgLon / total);
|
|
}
|
|
}
|
|
if (highlightPosition != null) {
|
|
center = highlightPosition;
|
|
}
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Node Map'),
|
|
centerTitle: true,
|
|
),
|
|
body: !hasMapContent
|
|
? _buildEmptyState()
|
|
: Stack(
|
|
children: [
|
|
FlutterMap(
|
|
mapController: _mapController,
|
|
options: MapOptions(
|
|
initialCenter: center,
|
|
initialZoom: 13.0,
|
|
minZoom: 2.0,
|
|
maxZoom: 18.0,
|
|
onTap: (_, latLng) {
|
|
if (_isSelectingPoi) {
|
|
setState(() {
|
|
_isSelectingPoi = false;
|
|
});
|
|
_shareMarker(
|
|
context: context,
|
|
connector: connector,
|
|
position: latLng,
|
|
defaultLabel: 'Point of interest',
|
|
flags: 'poi',
|
|
);
|
|
}
|
|
},
|
|
onLongPress: (_, latLng) {
|
|
if (_isSelectingPoi) {
|
|
setState(() {
|
|
_isSelectingPoi = false;
|
|
});
|
|
_shareMarker(
|
|
context: context,
|
|
connector: connector,
|
|
position: latLng,
|
|
defaultLabel: 'Point of interest',
|
|
flags: 'poi',
|
|
);
|
|
return;
|
|
}
|
|
_showShareMarkerAtPositionSheet(
|
|
context: context,
|
|
connector: connector,
|
|
position: latLng,
|
|
);
|
|
},
|
|
),
|
|
children: [
|
|
TileLayer(
|
|
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
userAgentPackageName: 'com.meshcore.open',
|
|
maxZoom: 19,
|
|
),
|
|
MarkerLayer(
|
|
markers: [
|
|
if (highlightPosition != null)
|
|
Marker(
|
|
point: highlightPosition,
|
|
width: 40,
|
|
height: 40,
|
|
child: Icon(
|
|
Icons.location_on_outlined,
|
|
color: Colors.red[600],
|
|
size: 34,
|
|
),
|
|
),
|
|
..._buildMarkers(contactsWithLocation, settings),
|
|
...sharedMarkers.map(_buildSharedMarker),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
_buildLegend(contactsWithLocation.length, sharedMarkers.length),
|
|
],
|
|
),
|
|
floatingActionButton: FloatingActionButton(
|
|
onPressed: () => _showFilterDialog(context, settingsService),
|
|
child: const Icon(Icons.filter_list),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildEmptyState() {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.location_off,
|
|
size: 64,
|
|
color: Colors.grey[400],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'No nodes with location data',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Nodes need to share their GPS coordinates\nto appear on the map',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: Colors.grey[500],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Marker> _buildMarkers(List<Contact> contacts, settings) {
|
|
final markers = <Marker>[];
|
|
|
|
for (final contact in contacts) {
|
|
if (!contact.hasLocation) continue;
|
|
|
|
// Apply node type filters
|
|
if (contact.type == advTypeRepeater && !settings.mapShowRepeaters) continue;
|
|
if (contact.type == advTypeChat && !settings.mapShowChatNodes) continue;
|
|
if (contact.type != advTypeChat &&
|
|
contact.type != advTypeRepeater &&
|
|
!settings.mapShowOtherNodes) {
|
|
continue;
|
|
}
|
|
|
|
final marker = Marker(
|
|
point: LatLng(contact.latitude!, contact.longitude!),
|
|
width: 80,
|
|
height: 80,
|
|
child: GestureDetector(
|
|
onTap: () => _showNodeInfo(context, contact),
|
|
child: Column(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: _getNodeColor(contact.type),
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
child: Icon(
|
|
_getNodeIcon(contact.type),
|
|
color: Colors.white,
|
|
size: 24,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
markers.add(marker);
|
|
}
|
|
|
|
return markers;
|
|
}
|
|
|
|
Color _getNodeColor(int type) {
|
|
switch (type) {
|
|
case advTypeChat:
|
|
return Colors.blue;
|
|
case advTypeRepeater:
|
|
return Colors.green;
|
|
case advTypeRoom:
|
|
return Colors.purple;
|
|
case advTypeSensor:
|
|
return Colors.orange;
|
|
default:
|
|
return Colors.grey;
|
|
}
|
|
}
|
|
|
|
IconData _getNodeIcon(int type) {
|
|
switch (type) {
|
|
case advTypeChat:
|
|
return Icons.person;
|
|
case advTypeRepeater:
|
|
return Icons.router;
|
|
case advTypeRoom:
|
|
return Icons.meeting_room;
|
|
case advTypeSensor:
|
|
return Icons.sensors;
|
|
default:
|
|
return Icons.device_unknown;
|
|
}
|
|
}
|
|
|
|
Widget _buildLegend(int nodeCount, int markerCount) {
|
|
return Positioned(
|
|
top: 16,
|
|
right: 16,
|
|
child: Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'Nodes: $nodeCount',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
Text(
|
|
'Pins: $markerCount',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
_buildLegendItem(Icons.person, 'Chat', Colors.blue),
|
|
_buildLegendItem(Icons.router, 'Repeater', Colors.green),
|
|
_buildLegendItem(Icons.meeting_room, 'Room', Colors.purple),
|
|
_buildLegendItem(Icons.sensors, 'Sensor', Colors.orange),
|
|
_buildLegendItem(Icons.flag, 'Pin (DM)', Colors.blue),
|
|
_buildLegendItem(Icons.flag, 'Pin (Private)', Colors.purple),
|
|
_buildLegendItem(Icons.flag, 'Pin (Public)', Colors.orange),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLegendItem(IconData icon, String label, Color color) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 16, color: color),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
label,
|
|
style: const TextStyle(fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
List<_SharedMarker> _collectSharedMarkers(MeshCoreConnector connector) {
|
|
final markers = <_SharedMarker>[];
|
|
final selfName = connector.selfName ?? 'Me';
|
|
|
|
for (final contact in connector.contacts) {
|
|
final messages = connector.getMessages(contact);
|
|
for (final message in messages) {
|
|
final payload = _parseMarkerText(message.text);
|
|
if (payload == null) continue;
|
|
final fromName = message.isOutgoing ? selfName : contact.name;
|
|
final id = _buildMarkerId(
|
|
sourceId: contact.publicKeyHex,
|
|
timestamp: message.timestamp,
|
|
text: message.text,
|
|
);
|
|
markers.add(
|
|
_SharedMarker(
|
|
id: id,
|
|
position: payload.position,
|
|
label: payload.label,
|
|
flags: payload.flags,
|
|
fromName: fromName,
|
|
sourceLabel: contact.name,
|
|
isChannel: false,
|
|
isPublicChannel: false,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
for (final channel in connector.channels.where((c) => !c.isEmpty)) {
|
|
final isPublic = _isPublicChannel(channel);
|
|
final messages = connector.getChannelMessages(channel);
|
|
for (final message in messages) {
|
|
final payload = _parseMarkerText(message.text);
|
|
if (payload == null) continue;
|
|
final id = _buildMarkerId(
|
|
sourceId: 'channel:${channel.index}',
|
|
timestamp: message.timestamp,
|
|
text: message.text,
|
|
);
|
|
markers.add(
|
|
_SharedMarker(
|
|
id: id,
|
|
position: payload.position,
|
|
label: payload.label,
|
|
flags: payload.flags,
|
|
fromName: message.senderName,
|
|
sourceLabel: channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name,
|
|
isChannel: true,
|
|
isPublicChannel: isPublic,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
return markers;
|
|
}
|
|
|
|
_MarkerPayload? _parseMarkerText(String text) {
|
|
final trimmed = text.trim();
|
|
if (!trimmed.startsWith('m:')) return null;
|
|
|
|
final parts = trimmed.substring(2).split('|');
|
|
if (parts.isEmpty) return null;
|
|
final coords = parts[0].split(',');
|
|
if (coords.length != 2) return null;
|
|
final lat = double.tryParse(coords[0].trim());
|
|
final lon = double.tryParse(coords[1].trim());
|
|
if (lat == null || lon == null) return null;
|
|
|
|
final label = parts.length > 1 ? parts[1].trim() : '';
|
|
final flags = parts.length > 2 ? parts[2].trim() : '';
|
|
return _MarkerPayload(
|
|
position: LatLng(lat, lon),
|
|
label: label.isEmpty ? 'Shared pin' : label,
|
|
flags: flags,
|
|
);
|
|
}
|
|
|
|
String _buildMarkerId({
|
|
required String sourceId,
|
|
required DateTime timestamp,
|
|
required String text,
|
|
}) {
|
|
return '$sourceId|${timestamp.millisecondsSinceEpoch}|$text';
|
|
}
|
|
|
|
Marker _buildSharedMarker(_SharedMarker marker) {
|
|
final markerColor = marker.isChannel
|
|
? (marker.isPublicChannel ? Colors.orange : Colors.purple)
|
|
: Colors.blue;
|
|
return Marker(
|
|
point: marker.position,
|
|
width: 60,
|
|
height: 60,
|
|
child: GestureDetector(
|
|
onTap: () => _showMarkerInfo(marker),
|
|
child: Column(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(6),
|
|
decoration: BoxDecoration(
|
|
color: markerColor,
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
child: const Icon(
|
|
Icons.flag,
|
|
color: Colors.white,
|
|
size: 20,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showNodeInfo(BuildContext context, Contact contact) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Row(
|
|
children: [
|
|
Icon(
|
|
_getNodeIcon(contact.type),
|
|
color: _getNodeColor(contact.type),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: Text(contact.name)),
|
|
],
|
|
),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildInfoRow('Type', contact.typeLabel),
|
|
_buildInfoRow('Path', contact.pathLabel),
|
|
_buildInfoRow('Location',
|
|
'${contact.latitude!.toStringAsFixed(6)}, ${contact.longitude!.toStringAsFixed(6)}'),
|
|
_buildInfoRow('Last Seen', _formatLastSeen(contact.lastSeen)),
|
|
_buildInfoRow('Public Key', contact.publicKeyHex),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Close'),
|
|
),
|
|
if (contact.type == advTypeChat) // Only show chat button for chat nodes
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => ChatScreen(contact: contact),
|
|
),
|
|
);
|
|
},
|
|
child: const Text('Open Chat'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showMarkerInfo(_SharedMarker marker) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(marker.label),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildInfoRow('From', marker.fromName),
|
|
_buildInfoRow('Source', marker.sourceLabel),
|
|
_buildInfoRow(
|
|
'Location',
|
|
'${marker.position.latitude.toStringAsFixed(6)}, ${marker.position.longitude.toStringAsFixed(6)}',
|
|
),
|
|
if (marker.flags.isNotEmpty) _buildInfoRow('Flags', marker.flags),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
setState(() {
|
|
_hiddenMarkerIds.add(marker.id);
|
|
});
|
|
Navigator.pop(context);
|
|
},
|
|
child: const Text('Hide'),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
setState(() {
|
|
_hiddenMarkerIds.add(marker.id);
|
|
_removedMarkerIds.add(marker.id);
|
|
});
|
|
await _markerService.saveRemovedIds(_removedMarkerIds);
|
|
if (context.mounted) {
|
|
Navigator.pop(context);
|
|
}
|
|
},
|
|
child: const Text('Remove'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Close'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildInfoRow(String label, String value) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[600],
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
value,
|
|
style: const TextStyle(fontSize: 14),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatLastSeen(DateTime lastSeen) {
|
|
final now = DateTime.now();
|
|
final difference = now.difference(lastSeen);
|
|
|
|
if (difference.inSeconds < 60) {
|
|
return 'Just now';
|
|
} else if (difference.inMinutes < 60) {
|
|
return '${difference.inMinutes}m ago';
|
|
} else if (difference.inHours < 24) {
|
|
return '${difference.inHours}h ago';
|
|
} else {
|
|
return '${difference.inDays}d ago';
|
|
}
|
|
}
|
|
|
|
void _showShareMarkerAtPositionSheet({
|
|
required BuildContext context,
|
|
required MeshCoreConnector connector,
|
|
required LatLng position,
|
|
}) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
builder: (sheetContext) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.place),
|
|
title: const Text('Share marker here'),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
_shareMarker(
|
|
context: context,
|
|
connector: connector,
|
|
position: position,
|
|
defaultLabel: 'Point of interest',
|
|
flags: 'poi',
|
|
);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.close),
|
|
title: const Text('Cancel'),
|
|
onTap: () => Navigator.pop(sheetContext),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _shareMarker({
|
|
required BuildContext context,
|
|
required MeshCoreConnector connector,
|
|
required LatLng position,
|
|
required String defaultLabel,
|
|
required String flags,
|
|
}) async {
|
|
if (!connector.isConnected) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Connect to a device to share markers')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final label = await _promptForLabel(context, defaultLabel);
|
|
if (label == null) return;
|
|
|
|
final markerText = _formatMarkerMessage(position, label, flags);
|
|
await _showRecipientSheet(
|
|
context: context,
|
|
connector: connector,
|
|
markerText: markerText,
|
|
);
|
|
}
|
|
|
|
Future<String?> _promptForLabel(BuildContext context, String defaultLabel) async {
|
|
final controller = TextEditingController(text: defaultLabel);
|
|
return showDialog<String>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: const Text('Pin label'),
|
|
content: TextField(
|
|
controller: controller,
|
|
decoration: const InputDecoration(
|
|
hintText: 'Label',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
final label = controller.text.trim().replaceAll('|', '/');
|
|
Navigator.pop(dialogContext, label.isEmpty ? defaultLabel : label);
|
|
},
|
|
child: const Text('Continue'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatMarkerMessage(LatLng position, String label, String flags) {
|
|
final lat = position.latitude.toStringAsFixed(6);
|
|
final lon = position.longitude.toStringAsFixed(6);
|
|
return 'm:$lat,$lon|$label|$flags';
|
|
}
|
|
|
|
Future<void> _showRecipientSheet({
|
|
required BuildContext context,
|
|
required MeshCoreConnector connector,
|
|
required String markerText,
|
|
}) async {
|
|
if (!connector.isLoadingChannels && connector.channels.isEmpty) {
|
|
connector.getChannels();
|
|
}
|
|
String query = '';
|
|
|
|
await showModalBottomSheet(
|
|
context: context,
|
|
builder: (sheetContext) => StatefulBuilder(
|
|
builder: (sheetContext, setSheetState) {
|
|
return Consumer<MeshCoreConnector>(
|
|
builder: (context, liveConnector, child) {
|
|
final allContacts = liveConnector.contacts
|
|
.where((contact) =>
|
|
contact.type != advTypeRepeater && contact.type != advTypeRoom)
|
|
.toList();
|
|
return SafeArea(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Padding(
|
|
padding: EdgeInsets.fromLTRB(16, 12, 16, 4),
|
|
child: Text('Send to contact', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
|
child: TextField(
|
|
decoration: InputDecoration(
|
|
hintText: 'Search contacts...',
|
|
prefixIcon: const Icon(Icons.search),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
),
|
|
onChanged: (value) {
|
|
setSheetState(() {
|
|
query = value.toLowerCase();
|
|
});
|
|
},
|
|
),
|
|
),
|
|
...allContacts
|
|
.where((contact) =>
|
|
query.isEmpty ||
|
|
contact.name.toLowerCase().contains(query))
|
|
.map((contact) {
|
|
return ListTile(
|
|
leading: const Icon(Icons.person),
|
|
title: Text(contact.name),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
liveConnector.sendMessage(contact, markerText);
|
|
},
|
|
);
|
|
}),
|
|
const Padding(
|
|
padding: EdgeInsets.fromLTRB(16, 12, 16, 4),
|
|
child: Text('Send to channel', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
),
|
|
if (liveConnector.isLoadingChannels)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: LinearProgressIndicator(),
|
|
)
|
|
else if (liveConnector.channels.where((c) => !c.isEmpty).isEmpty)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Text('No channels available'),
|
|
)
|
|
else
|
|
...liveConnector.channels.where((c) => !c.isEmpty).map((channel) {
|
|
final isPublic = _isPublicChannel(channel);
|
|
final label = channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name;
|
|
return ListTile(
|
|
leading: Icon(
|
|
isPublic ? Icons.public : Icons.tag,
|
|
color: isPublic ? Colors.orange : Colors.blue,
|
|
),
|
|
title: Text(label),
|
|
subtitle: isPublic ? const Text('Public channel') : null,
|
|
onTap: () async {
|
|
Navigator.pop(sheetContext);
|
|
final canSend = isPublic
|
|
? await _confirmPublicShare(context, label)
|
|
: true;
|
|
if (canSend) {
|
|
liveConnector.sendChannelMessage(channel, markerText);
|
|
}
|
|
},
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
bool _isPublicChannel(Channel channel) {
|
|
return channel.isPublicChannel;
|
|
}
|
|
|
|
Future<bool> _confirmPublicShare(BuildContext context, String channelLabel) async {
|
|
final result = await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: const Text('Public location share'),
|
|
content: Text(
|
|
'You are about to share a location in $channelLabel. '
|
|
'This channel is public and anyone with the PSK can see it.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, true),
|
|
child: const Text('Share'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
return result ?? false;
|
|
}
|
|
|
|
void _showFilterDialog(BuildContext context, AppSettingsService settingsService) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: const Text('Filter Nodes'),
|
|
contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
|
|
content: SingleChildScrollView(
|
|
child: Consumer<AppSettingsService>(
|
|
builder: (context, service, child) {
|
|
final settings = service.settings;
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Node Types',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
CheckboxListTile(
|
|
title: const Text('Chat Nodes'),
|
|
value: settings.mapShowChatNodes,
|
|
onChanged: (value) {
|
|
service.setMapShowChatNodes(value ?? true);
|
|
},
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
CheckboxListTile(
|
|
title: const Text('Repeaters'),
|
|
value: settings.mapShowRepeaters,
|
|
onChanged: (value) {
|
|
service.setMapShowRepeaters(value ?? true);
|
|
},
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
CheckboxListTile(
|
|
title: const Text('Other Nodes'),
|
|
value: settings.mapShowOtherNodes,
|
|
onChanged: (value) {
|
|
service.setMapShowOtherNodes(value ?? true);
|
|
},
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
const SizedBox(height: 16),
|
|
const Text(
|
|
'Key Prefix',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
CheckboxListTile(
|
|
title: const Text('Filter by key prefix'),
|
|
value: settings.mapKeyPrefixEnabled,
|
|
onChanged: (value) {
|
|
service.setMapKeyPrefixEnabled(value ?? false);
|
|
},
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
TextFormField(
|
|
initialValue: settings.mapKeyPrefix,
|
|
enabled: settings.mapKeyPrefixEnabled,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Public key prefix',
|
|
hintText: 'e.g. ab12',
|
|
border: OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
onChanged: (value) {
|
|
service.setMapKeyPrefix(value);
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
const Text(
|
|
'Markers',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
CheckboxListTile(
|
|
title: const Text('Show shared markers'),
|
|
value: settings.mapShowMarkers,
|
|
onChanged: (value) {
|
|
service.setMapShowMarkers(value ?? true);
|
|
},
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
const SizedBox(height: 16),
|
|
const Text(
|
|
'Last Seen Time',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_getTimeFilterLabel(settings.mapTimeFilterHours),
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: Colors.grey[700],
|
|
),
|
|
),
|
|
Slider(
|
|
value: _hoursToSliderValue(settings.mapTimeFilterHours),
|
|
min: 0,
|
|
max: 100,
|
|
divisions: 100,
|
|
onChanged: (value) {
|
|
final hours = _sliderValueToHours(value);
|
|
service.setMapTimeFilterHours(hours);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: const Text('Close'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// Convert hours to slider value (0-100) with exponential scaling
|
|
double _hoursToSliderValue(double hours) {
|
|
if (hours == 0) return 100; // All time
|
|
|
|
// Map hours exponentially
|
|
// 0-24h: 0-40
|
|
// 24h-7d: 40-60
|
|
// 7d-30d: 60-80
|
|
// 30d-6mo: 80-99
|
|
// All time: 100
|
|
|
|
if (hours <= 24) {
|
|
return (hours / 24) * 40;
|
|
} else if (hours <= 168) { // 7 days
|
|
return 40 + ((hours - 24) / (168 - 24)) * 20;
|
|
} else if (hours <= 720) { // 30 days
|
|
return 60 + ((hours - 168) / (720 - 168)) * 20;
|
|
} else if (hours <= 4380) { // 6 months
|
|
return 80 + ((hours - 720) / (4380 - 720)) * 19;
|
|
} else {
|
|
return 100;
|
|
}
|
|
}
|
|
|
|
// Convert slider value (0-100) to hours with exponential scaling
|
|
double _sliderValueToHours(double value) {
|
|
if (value >= 99.5) return 0; // All time
|
|
|
|
if (value <= 40) {
|
|
return (value / 40) * 24; // 0-24 hours
|
|
} else if (value <= 60) {
|
|
return 24 + ((value - 40) / 20) * (168 - 24); // 1-7 days
|
|
} else if (value <= 80) {
|
|
return 168 + ((value - 60) / 20) * (720 - 168); // 7-30 days
|
|
} else {
|
|
return 720 + ((value - 80) / 19) * (4380 - 720); // 30 days - 6 months
|
|
}
|
|
}
|
|
|
|
String _getTimeFilterLabel(double hours) {
|
|
if (hours == 0) return 'All Time';
|
|
|
|
if (hours < 1) {
|
|
return '${(hours * 60).round()} minutes';
|
|
} else if (hours < 24) {
|
|
return '${hours.round()} ${hours.round() == 1 ? 'hour' : 'hours'}';
|
|
} else if (hours < 168) {
|
|
final days = (hours / 24).round();
|
|
return '$days ${days == 1 ? 'day' : 'days'}';
|
|
} else if (hours < 720) {
|
|
final weeks = (hours / 168).round();
|
|
return '$weeks ${weeks == 1 ? 'week' : 'weeks'}';
|
|
} else if (hours < 4380) {
|
|
final months = (hours / 730).round();
|
|
return '$months ${months == 1 ? 'month' : 'months'}';
|
|
} else {
|
|
return 'All Time';
|
|
}
|
|
}
|
|
}
|
|
|
|
class _MarkerPayload {
|
|
final LatLng position;
|
|
final String label;
|
|
final String flags;
|
|
|
|
_MarkerPayload({
|
|
required this.position,
|
|
required this.label,
|
|
required this.flags,
|
|
});
|
|
}
|
|
|
|
class _SharedMarker {
|
|
final String id;
|
|
final LatLng position;
|
|
final String label;
|
|
final String flags;
|
|
final String fromName;
|
|
final String sourceLabel;
|
|
final bool isChannel;
|
|
final bool isPublicChannel;
|
|
|
|
_SharedMarker({
|
|
required this.id,
|
|
required this.position,
|
|
required this.label,
|
|
required this.flags,
|
|
required this.fromName,
|
|
required this.sourceLabel,
|
|
required this.isChannel,
|
|
required this.isPublicChannel,
|
|
});
|
|
}
|