mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
Improve SNR handling by adding validation for spreading factor range in snrUiFromSNR function Update contact handling in MeshCoreConnector to fix variable naming and improve readability Stop parsing unsupported LPP types in CayenneLpp to avoid misalignment
1635 lines
55 KiB
Dart
1635 lines
55 KiB
Dart
import 'dart:math';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_map/flutter_map.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
import 'package:meshcore_open/screens/path_trace_map.dart';
|
|
import 'package:meshcore_open/widgets/app_bar.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../connector/meshcore_connector.dart';
|
|
import '../l10n/l10n.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 '../services/map_tile_cache_service.dart';
|
|
import '../utils/contact_search.dart';
|
|
import '../utils/route_transitions.dart';
|
|
import '../widgets/quick_switch_bar.dart';
|
|
import 'channels_screen.dart';
|
|
import 'chat_screen.dart';
|
|
import 'contacts_screen.dart';
|
|
import '../widgets/repeater_login_dialog.dart';
|
|
import '../widgets/room_login_dialog.dart';
|
|
import 'repeater_hub_screen.dart';
|
|
import 'settings_screen.dart';
|
|
|
|
class MapScreen extends StatefulWidget {
|
|
final LatLng? highlightPosition;
|
|
final String? highlightLabel;
|
|
final double highlightZoom;
|
|
final bool hideBackButton;
|
|
|
|
const MapScreen({
|
|
super.key,
|
|
this.highlightPosition,
|
|
this.highlightLabel,
|
|
this.highlightZoom = 15.0,
|
|
this.hideBackButton = false,
|
|
});
|
|
|
|
@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 _isBuildingPathTrace = false;
|
|
bool _isSelectingPoi = false;
|
|
bool _hasInitializedMap = false;
|
|
bool _removedMarkersLoaded = false;
|
|
final List<int> _pathTrace = [];
|
|
final List<LatLng> _points = [];
|
|
final List<Polyline> _polylines = [];
|
|
bool _legendExpanded = 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;
|
|
_removedMarkersLoaded = true;
|
|
});
|
|
}
|
|
|
|
double _standardDeviation(List<double> values) {
|
|
if (values.length <= 1) {
|
|
return 0.0;
|
|
}
|
|
|
|
final mean = values.reduce((a, b) => a + b) / values.length;
|
|
|
|
double sumSquaredDiff = 0.0;
|
|
for (final value in values) {
|
|
final diff = value - mean;
|
|
sumSquaredDiff += diff * diff;
|
|
}
|
|
|
|
// Sample standard deviation (n-1) — most appropriate here
|
|
final variance = sumSquaredDiff / (values.length - 1);
|
|
|
|
return sqrt(variance);
|
|
}
|
|
|
|
// Calculate zoom level based on the spread of points (std deviation in degrees)
|
|
double _zoomFromStdDev(double latStdDev, double lonStdDev) {
|
|
final maxSpread = max(latStdDev, lonStdDev);
|
|
if (maxSpread <= 0) return 13.0;
|
|
// Approzimate: each zoom level halves the visible area
|
|
// ~0.01 degrees spread -> zoom 13, ~0.1 -> zoom 10, ~1.0 -> zoom 7
|
|
final zoom = 10.0 - log(maxSpread * 10 + 1) / ln10 * 3;
|
|
return zoom.clamp(4.0, 15.0);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Consumer2<MeshCoreConnector, AppSettingsService>(
|
|
builder: (context, connector, settingsService, child) {
|
|
final tileCache = context.read<MapTileCacheService>();
|
|
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();
|
|
|
|
_polylines.clear();
|
|
_polylines.addAll(
|
|
_points.length > 1
|
|
? [
|
|
Polyline(
|
|
points: _points,
|
|
strokeWidth: 4,
|
|
color: Colors.blueAccent,
|
|
),
|
|
]
|
|
: <Polyline>[],
|
|
);
|
|
|
|
// Calculate center and zoom of all nodes, or default to (0, 0)
|
|
LatLng center = const LatLng(0, 0);
|
|
double initialZoom = 10.0;
|
|
final hasMapContent =
|
|
contactsWithLocation.isNotEmpty ||
|
|
sharedMarkers.isNotEmpty ||
|
|
_isSelectingPoi ||
|
|
highlightPosition != null;
|
|
if (contactsWithLocation.isNotEmpty || sharedMarkers.isNotEmpty) {
|
|
final allPoints = [
|
|
...contactsWithLocation.map(
|
|
(c) => LatLng(c.latitude!, c.longitude!),
|
|
),
|
|
...sharedMarkers.map((m) => m.position),
|
|
];
|
|
if (allPoints.length >= 3) {
|
|
final latValues = allPoints.map((p) => p.latitude).toList();
|
|
final lonValues = allPoints.map((p) => p.longitude).toList();
|
|
|
|
final meanLat =
|
|
latValues.reduce((a, b) => a + b) / latValues.length;
|
|
final meanLon =
|
|
lonValues.reduce((a, b) => a + b) / lonValues.length;
|
|
final latStdDev = _standardDeviation(latValues);
|
|
final lonStdDev = _standardDeviation(lonValues);
|
|
|
|
final filteredPoints = allPoints
|
|
.where(
|
|
(p) =>
|
|
(p.latitude - meanLat).abs() <= latStdDev * 2 &&
|
|
(p.longitude - meanLon).abs() <= lonStdDev * 2,
|
|
)
|
|
.toList();
|
|
|
|
if (filteredPoints.isNotEmpty) {
|
|
final filteredLatValues = filteredPoints
|
|
.map((p) => p.latitude)
|
|
.toList();
|
|
final filteredLonValues = filteredPoints
|
|
.map((p) => p.longitude)
|
|
.toList();
|
|
final avgLat = filteredLatValues.reduce((a, b) => a + b);
|
|
final avgLon = filteredLonValues.reduce((a, b) => a + b);
|
|
center = LatLng(
|
|
avgLat / filteredPoints.length,
|
|
avgLon / filteredPoints.length,
|
|
);
|
|
// Use std deviation of filtered points for zoom
|
|
final filteredLatStdDev = _standardDeviation(filteredLatValues);
|
|
final filteredLonStdDev = _standardDeviation(filteredLonValues);
|
|
initialZoom = _zoomFromStdDev(
|
|
filteredLatStdDev,
|
|
filteredLonStdDev,
|
|
);
|
|
} else {
|
|
center = LatLng(meanLat, meanLon);
|
|
initialZoom = _zoomFromStdDev(latStdDev, lonStdDev);
|
|
}
|
|
} else {
|
|
double avgLat = 0.0;
|
|
double avgLon = 0.0;
|
|
for (final point in allPoints) {
|
|
avgLat += point.latitude;
|
|
avgLon += point.longitude;
|
|
}
|
|
center = LatLng(
|
|
avgLat / allPoints.length,
|
|
avgLon / allPoints.length,
|
|
);
|
|
initialZoom = 12.0;
|
|
}
|
|
}
|
|
if (highlightPosition != null) {
|
|
center = highlightPosition;
|
|
initialZoom = widget.highlightZoom;
|
|
}
|
|
|
|
// Re center map after removed markers have loaded
|
|
if (!_hasInitializedMap && _removedMarkersLoaded) {
|
|
_hasInitializedMap = true;
|
|
if (hasMapContent) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
_mapController.move(center, initialZoom);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
final allowBack = !connector.isConnected;
|
|
|
|
return PopScope(
|
|
canPop: allowBack,
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
title: AppBarTitle(context.l10n.map_title),
|
|
centerTitle: true,
|
|
automaticallyImplyLeading: false,
|
|
actions: [
|
|
if (!_isBuildingPathTrace)
|
|
IconButton(
|
|
icon: const Icon(Icons.radar),
|
|
onPressed: () => _startPath(),
|
|
tooltip: context.l10n.contacts_pathTrace,
|
|
),
|
|
PopupMenuButton(
|
|
itemBuilder: (context) => [
|
|
PopupMenuItem(
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.logout, color: Colors.red),
|
|
const SizedBox(width: 8),
|
|
Text(context.l10n.common_disconnect),
|
|
],
|
|
),
|
|
onTap: () => _disconnect(context, connector),
|
|
),
|
|
PopupMenuItem(
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.settings),
|
|
const SizedBox(width: 8),
|
|
Text(context.l10n.settings_title),
|
|
],
|
|
),
|
|
onTap: () => Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => const SettingsScreen(),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
icon: const Icon(Icons.more_vert),
|
|
),
|
|
],
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
FlutterMap(
|
|
mapController: _mapController,
|
|
options: MapOptions(
|
|
initialCenter: center,
|
|
initialZoom: initialZoom,
|
|
minZoom: 2.0,
|
|
maxZoom: 18.0,
|
|
interactionOptions: InteractionOptions(
|
|
flags: ~InteractiveFlag.rotate,
|
|
),
|
|
onTap: (_, latLng) {
|
|
if (_isSelectingPoi) {
|
|
setState(() {
|
|
_isSelectingPoi = false;
|
|
});
|
|
_shareMarker(
|
|
context: context,
|
|
connector: connector,
|
|
position: latLng,
|
|
defaultLabel: context.l10n.map_pointOfInterest,
|
|
flags: 'poi',
|
|
);
|
|
}
|
|
},
|
|
onLongPress: (_, latLng) {
|
|
if (_isSelectingPoi) {
|
|
setState(() {
|
|
_isSelectingPoi = false;
|
|
});
|
|
_shareMarker(
|
|
context: context,
|
|
connector: connector,
|
|
position: latLng,
|
|
defaultLabel: context.l10n.map_pointOfInterest,
|
|
flags: 'poi',
|
|
);
|
|
return;
|
|
}
|
|
_showShareMarkerAtPositionSheet(
|
|
context: context,
|
|
connector: connector,
|
|
position: latLng,
|
|
);
|
|
},
|
|
),
|
|
children: [
|
|
TileLayer(
|
|
urlTemplate: kMapTileUrlTemplate,
|
|
tileProvider: tileCache.tileProvider,
|
|
userAgentPackageName:
|
|
MapTileCacheService.userAgentPackageName,
|
|
maxZoom: 19,
|
|
),
|
|
if (_polylines.isNotEmpty && _isBuildingPathTrace)
|
|
PolylineLayer(polylines: _polylines),
|
|
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),
|
|
if (connector.selfLatitude != null &&
|
|
connector.selfLongitude != null)
|
|
Marker(
|
|
point: LatLng(
|
|
connector.selfLatitude!,
|
|
connector.selfLongitude!,
|
|
),
|
|
width: 40,
|
|
height: 40,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.teal,
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
context.l10n.pathTrace_you,
|
|
style: const TextStyle(
|
|
color: Colors.black,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
if (!_isBuildingPathTrace)
|
|
_buildLegend(
|
|
contactsWithLocation.length,
|
|
sharedMarkers.length,
|
|
),
|
|
if (_isBuildingPathTrace) _buildPathTraceOverlay(),
|
|
],
|
|
),
|
|
bottomNavigationBar: SafeArea(
|
|
top: false,
|
|
child: QuickSwitchBar(
|
|
selectedIndex: 2,
|
|
onDestinationSelected: (index) =>
|
|
_handleQuickSwitch(index, context),
|
|
),
|
|
),
|
|
floatingActionButton: FloatingActionButton(
|
|
onPressed: () => _showFilterDialog(context, settingsService),
|
|
tooltip: context.l10n.map_filterNodes,
|
|
child: const Icon(Icons.filter_list),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
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: 35,
|
|
height: 35,
|
|
child: GestureDetector(
|
|
onLongPress: () =>
|
|
_isBuildingPathTrace ? _showNodeInfo(context, contact) : null,
|
|
onTap: () => _isBuildingPathTrace
|
|
? _addToPath(context, contact)
|
|
: _showNodeInfo(context, contact),
|
|
child: Column(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(4),
|
|
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: 20,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
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: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
InkWell(
|
|
borderRadius: BorderRadius.circular(12),
|
|
onTap: () {
|
|
setState(() {
|
|
_legendExpanded = !_legendExpanded;
|
|
});
|
|
},
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
context.l10n.map_nodesCount(nodeCount),
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
Text(
|
|
context.l10n.map_pinsCount(markerCount),
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(width: 8),
|
|
AnimatedRotation(
|
|
turns: _legendExpanded ? 0.5 : 0,
|
|
duration: const Duration(milliseconds: 200),
|
|
child: const Icon(Icons.expand_more, size: 20),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
AnimatedCrossFade(
|
|
firstChild: const SizedBox.shrink(),
|
|
secondChild: Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(height: 6),
|
|
_buildLegendItem(
|
|
Icons.person,
|
|
context.l10n.map_chat,
|
|
Colors.blue,
|
|
),
|
|
_buildLegendItem(
|
|
Icons.router,
|
|
context.l10n.map_repeater,
|
|
Colors.green,
|
|
),
|
|
_buildLegendItem(
|
|
Icons.meeting_room,
|
|
context.l10n.map_room,
|
|
Colors.purple,
|
|
),
|
|
_buildLegendItem(
|
|
Icons.sensors,
|
|
context.l10n.map_sensor,
|
|
Colors.orange,
|
|
),
|
|
_buildLegendItem(
|
|
Icons.flag,
|
|
context.l10n.map_pinDm,
|
|
Colors.blue,
|
|
),
|
|
_buildLegendItem(
|
|
Icons.flag,
|
|
context.l10n.map_pinPrivate,
|
|
Colors.purple,
|
|
),
|
|
_buildLegendItem(
|
|
Icons.flag,
|
|
context.l10n.map_pinPublic,
|
|
Colors.orange,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
crossFadeState: _legendExpanded
|
|
? CrossFadeState.showSecond
|
|
: CrossFadeState.showFirst,
|
|
duration: const Duration(milliseconds: 200),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLegendItem(IconData icon, String label, Color color) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 1.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 ? context.l10n.map_sharedPin : 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 _showRepeaterLogin(BuildContext context, Contact repeater) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => RepeaterLoginDialog(
|
|
repeater: repeater,
|
|
onLogin: (password) {
|
|
// Navigate to repeater hub screen after successful login
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) =>
|
|
RepeaterHubScreen(repeater: repeater, password: password),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showRoomLogin(BuildContext context, Contact room) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => RoomLoginDialog(
|
|
room: room,
|
|
onLogin: (password) {
|
|
// Navigate to chat screen after successful login
|
|
context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex);
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => ChatScreen(contact: room)),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showNodeInfo(BuildContext context, Contact contact) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (dialogContext) => 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(
|
|
context.l10n.map_lastSeen,
|
|
_formatLastSeen(contact.lastSeen),
|
|
),
|
|
_buildInfoRow('Public Key', contact.publicKeyHex),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: Text(context.l10n.common_close),
|
|
),
|
|
if (contact.type ==
|
|
advTypeChat) // Only show chat button for chat nodes
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(dialogContext);
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => ChatScreen(contact: contact),
|
|
),
|
|
);
|
|
},
|
|
child: Text(context.l10n.contacts_openChat),
|
|
),
|
|
if (contact.type == advTypeRepeater)
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(dialogContext);
|
|
_showRepeaterLogin(context, contact);
|
|
},
|
|
child: Text(context.l10n.map_manageRepeater),
|
|
),
|
|
if (contact.type == advTypeRoom)
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(dialogContext);
|
|
_showRoomLogin(context, contact);
|
|
},
|
|
child: Text(context.l10n.map_joinRoom),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _handleQuickSwitch(int index, BuildContext context) {
|
|
if (index == 2) return;
|
|
switch (index) {
|
|
case 0:
|
|
Navigator.pushReplacement(
|
|
context,
|
|
buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)),
|
|
);
|
|
break;
|
|
case 1:
|
|
Navigator.pushReplacement(
|
|
context,
|
|
buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)),
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
Future<void> _disconnect(
|
|
BuildContext context,
|
|
MeshCoreConnector connector,
|
|
) async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(context.l10n.common_disconnect),
|
|
content: Text(context.l10n.map_disconnectConfirm),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, false),
|
|
child: Text(context.l10n.common_cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, true),
|
|
child: Text(context.l10n.common_disconnect),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed == true) {
|
|
await connector.disconnect();
|
|
}
|
|
}
|
|
|
|
void _showMarkerInfo(_SharedMarker marker) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(marker.label),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildInfoRow(context.l10n.map_from, marker.fromName),
|
|
_buildInfoRow(context.l10n.map_source, marker.sourceLabel),
|
|
_buildInfoRow(
|
|
'Location',
|
|
'${marker.position.latitude.toStringAsFixed(6)}, ${marker.position.longitude.toStringAsFixed(6)}',
|
|
),
|
|
if (marker.flags.isNotEmpty)
|
|
_buildInfoRow(context.l10n.map_flags, marker.flags),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
setState(() {
|
|
_hiddenMarkerIds.add(marker.id);
|
|
});
|
|
Navigator.pop(dialogContext);
|
|
},
|
|
child: Text(context.l10n.common_hide),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
setState(() {
|
|
_hiddenMarkerIds.add(marker.id);
|
|
_removedMarkerIds.add(marker.id);
|
|
});
|
|
await _markerService.saveRemovedIds(_removedMarkerIds);
|
|
if (dialogContext.mounted) {
|
|
Navigator.pop(dialogContext);
|
|
}
|
|
},
|
|
child: Text(context.l10n.common_remove),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: Text(context.l10n.common_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 context.l10n.time_justNow;
|
|
} else if (difference.inMinutes < 60) {
|
|
return context.l10n.time_minutesAgo(difference.inMinutes);
|
|
} else if (difference.inHours < 24) {
|
|
return context.l10n.time_hoursAgo(difference.inHours);
|
|
} else {
|
|
return context.l10n.time_daysAgo(difference.inDays);
|
|
}
|
|
}
|
|
|
|
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: Text(context.l10n.map_shareMarkerHere),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
_shareMarker(
|
|
context: context,
|
|
connector: connector,
|
|
position: position,
|
|
defaultLabel: context.l10n.map_pointOfInterest,
|
|
flags: 'poi',
|
|
);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.close),
|
|
title: Text(context.l10n.common_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(
|
|
SnackBar(content: Text(context.l10n.map_connectToShareMarkers)),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final label = await _promptForLabel(context, defaultLabel);
|
|
if (label == null || !mounted) return;
|
|
|
|
final markerText = _formatMarkerMessage(position, label, flags);
|
|
if (!mounted) return;
|
|
|
|
await _showRecipientSheet(
|
|
// ignore: use_build_context_synchronously
|
|
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: Text(context.l10n.map_pinLabel),
|
|
content: TextField(
|
|
controller: controller,
|
|
decoration: InputDecoration(
|
|
hintText: context.l10n.map_label,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: Text(context.l10n.common_cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
final label = controller.text.trim().replaceAll('|', '/');
|
|
Navigator.pop(
|
|
dialogContext,
|
|
label.isEmpty ? defaultLabel : label,
|
|
);
|
|
},
|
|
child: Text(context.l10n.common_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: (consumerContext, 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: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
|
child: Text(
|
|
context.l10n.map_sendToContact,
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
|
child: TextField(
|
|
decoration: InputDecoration(
|
|
hintText: context.l10n.contacts_searchContacts,
|
|
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 ||
|
|
matchesContactQuery(contact, query),
|
|
)
|
|
.map((contact) {
|
|
return ListTile(
|
|
leading: const Icon(Icons.person),
|
|
title: Text(contact.name),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
liveConnector.sendMessage(contact, markerText);
|
|
},
|
|
);
|
|
}),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
|
child: Text(
|
|
context.l10n.map_sendToChannel,
|
|
style: const 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)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 8,
|
|
),
|
|
child: Text(context.l10n.map_noChannelsAvailable),
|
|
)
|
|
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
|
|
? Text(context.l10n.channels_publicChannel)
|
|
: 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: Text(context.l10n.map_publicLocationShare),
|
|
content: Text(
|
|
context.l10n.map_publicLocationShareConfirm(channelLabel),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, false),
|
|
child: Text(context.l10n.common_cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, true),
|
|
child: Text(context.l10n.common_share),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
return result ?? false;
|
|
}
|
|
|
|
void _showFilterDialog(
|
|
BuildContext context,
|
|
AppSettingsService settingsService,
|
|
) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(context.l10n.map_filterNodes),
|
|
contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
|
|
content: SingleChildScrollView(
|
|
child: Consumer<AppSettingsService>(
|
|
builder: (consumerContext, service, child) {
|
|
final settings = service.settings;
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
context.l10n.map_nodeTypes,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
CheckboxListTile(
|
|
title: Text(context.l10n.map_chatNodes),
|
|
value: settings.mapShowChatNodes,
|
|
onChanged: (value) {
|
|
service.setMapShowChatNodes(value ?? true);
|
|
},
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
CheckboxListTile(
|
|
title: Text(context.l10n.map_repeaters),
|
|
value: settings.mapShowRepeaters,
|
|
onChanged: (value) {
|
|
service.setMapShowRepeaters(value ?? true);
|
|
},
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
CheckboxListTile(
|
|
title: Text(context.l10n.map_otherNodes),
|
|
value: settings.mapShowOtherNodes,
|
|
onChanged: (value) {
|
|
service.setMapShowOtherNodes(value ?? true);
|
|
},
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
context.l10n.map_keyPrefix,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
CheckboxListTile(
|
|
title: Text(context.l10n.map_filterByKeyPrefix),
|
|
value: settings.mapKeyPrefixEnabled,
|
|
onChanged: (value) {
|
|
service.setMapKeyPrefixEnabled(value ?? false);
|
|
},
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
TextFormField(
|
|
initialValue: settings.mapKeyPrefix,
|
|
enabled: settings.mapKeyPrefixEnabled,
|
|
decoration: InputDecoration(
|
|
labelText: context.l10n.map_publicKeyPrefix,
|
|
hintText: 'e.g. ab12',
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
onChanged: (value) {
|
|
service.setMapKeyPrefix(value);
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
context.l10n.map_markers,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
CheckboxListTile(
|
|
title: Text(context.l10n.map_showSharedMarkers),
|
|
value: settings.mapShowMarkers,
|
|
onChanged: (value) {
|
|
service.setMapShowMarkers(value ?? true);
|
|
},
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
context.l10n.map_lastSeenTime,
|
|
style: const 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: Text(context.l10n.common_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 context.l10n.time_allTime;
|
|
|
|
if (hours < 1) {
|
|
return '${(hours * 60).round()} ${context.l10n.time_minutes}';
|
|
} else if (hours < 24) {
|
|
final h = hours.round();
|
|
return '$h ${h == 1 ? context.l10n.time_hour : context.l10n.time_hours}';
|
|
} else if (hours < 168) {
|
|
final days = (hours / 24).round();
|
|
return '$days ${days == 1 ? context.l10n.time_day : context.l10n.time_days}';
|
|
} else if (hours < 720) {
|
|
final weeks = (hours / 168).round();
|
|
return '$weeks ${weeks == 1 ? context.l10n.time_week : context.l10n.time_weeks}';
|
|
} else if (hours < 4380) {
|
|
final months = (hours / 730).round();
|
|
return '$months ${months == 1 ? context.l10n.time_month : context.l10n.time_months}';
|
|
} else {
|
|
return context.l10n.time_allTime;
|
|
}
|
|
}
|
|
|
|
void _addToPath(BuildContext context, Contact contact) {
|
|
setState(() {
|
|
_pathTrace.add(
|
|
contact.publicKey[0],
|
|
); // Add first 16 bytes of public key to path trace
|
|
_points.add(LatLng(contact.latitude!, contact.longitude!));
|
|
});
|
|
}
|
|
|
|
void _startPath() {
|
|
setState(() {
|
|
_isBuildingPathTrace = true;
|
|
_pathTrace.clear();
|
|
_points.clear();
|
|
_polylines.clear();
|
|
});
|
|
}
|
|
|
|
void _removePath() {
|
|
setState(() {
|
|
_pathTrace.removeLast(); // Remove last node from path trace
|
|
_points.removeLast(); // Remove last point from points list
|
|
_polylines.clear(); // Clear polylines
|
|
});
|
|
}
|
|
|
|
Widget _buildPathTraceOverlay() {
|
|
final l10n = context.l10n;
|
|
return Positioned(
|
|
top: 16,
|
|
left: 16,
|
|
right: 16,
|
|
child: Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12.0),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
l10n.contacts_pathTrace,
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
if (_pathTrace.isEmpty) const SizedBox(height: 8),
|
|
if (_pathTrace.isEmpty)
|
|
Text(l10n.map_tapToAdd, style: TextStyle(fontSize: 12)),
|
|
const SizedBox(height: 6),
|
|
if (_pathTrace.isNotEmpty)
|
|
Text(
|
|
"${l10n.path_currentPathLabel} ${formatDistance(getPathDistanceMeters(_points))}",
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
|
|
),
|
|
SelectableText(
|
|
_pathTrace
|
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
|
.join(','),
|
|
style: TextStyle(fontSize: 18),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (_pathTrace.isNotEmpty)
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => PathTraceMapScreen(
|
|
title: l10n.contacts_pathTrace,
|
|
path: Uint8List.fromList(_pathTrace),
|
|
),
|
|
),
|
|
);
|
|
setState(() {
|
|
_isBuildingPathTrace = false;
|
|
});
|
|
},
|
|
child: Text(l10n.map_runTrace),
|
|
),
|
|
if (_pathTrace.isNotEmpty)
|
|
ElevatedButton(
|
|
onPressed: _removePath,
|
|
child: Text(l10n.map_removeLast),
|
|
),
|
|
if (_pathTrace.isEmpty)
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
setState(() {
|
|
_isBuildingPathTrace = false;
|
|
_pathTrace.clear();
|
|
_points.clear();
|
|
_polylines.clear();
|
|
});
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l10n.map_pathTraceCancelled)),
|
|
);
|
|
},
|
|
child: Text(l10n.common_cancel),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|