mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
- Refactor location permission handling in SparseLocationLogger - Add updateMyLocation method to SparseLocationLogger - Introduce new contact handling in MeshCoreConnector - Update map screen with a new option to refresh location - Add localization for "Update Location" in app_en.arb - Adjust payload types in meshcore_protocol.dart
1503 lines
50 KiB
Dart
1503 lines
50 KiB
Dart
import 'dart:math';
|
|
|
|
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 '../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/battery_indicator.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 _isSelectingPoi = false;
|
|
bool _hasInitializedMap = false;
|
|
bool _removedMarkersLoaded = false;
|
|
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();
|
|
|
|
// 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(
|
|
leading: BatteryIndicator(connector: connector),
|
|
title: Text(context.l10n.map_title),
|
|
centerTitle: true,
|
|
automaticallyImplyLeading: false,
|
|
actions: [
|
|
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(),
|
|
),
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.refresh),
|
|
const SizedBox(width: 8),
|
|
Text(context.l10n.map_updateMyLocation),
|
|
],
|
|
),
|
|
onTap: () =>
|
|
connector.sparseLocationLogger?.updateMyLocation(),
|
|
),
|
|
],
|
|
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,
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
_buildLegend(contactsWithLocation.length, sharedMarkers.length),
|
|
],
|
|
),
|
|
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(
|
|
onTap: () => _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: 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 ? 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|