mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-26 20:27:30 +10:00
Merge branch 'main' into dev-neighbours
This commit is contained in:
+297
-111
@@ -515,132 +515,318 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
|
||||
void _showAddChannelDialog(BuildContext context) {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final nextIndex = _findNextAvailableIndex(connector.channels, connector.maxChannels);
|
||||
final hasPublicChannel = connector.channels.any((c) => c.isPublicChannel);
|
||||
int? selectedOption;
|
||||
final nameController = TextEditingController();
|
||||
final pskController = TextEditingController();
|
||||
final maxChannels = connector.maxChannels;
|
||||
int selectedIndex = _findNextAvailableIndex(connector.channels, maxChannels);
|
||||
bool usePublicPsk = false;
|
||||
final hashtagController = TextEditingController();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (dialogContext, setDialogState) => AlertDialog(
|
||||
title: Text(dialogContext.l10n.channels_addChannel),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: selectedIndex,
|
||||
decoration: InputDecoration(
|
||||
labelText: dialogContext.l10n.channels_channelIndexLabel,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: List.generate(maxChannels, (i) => i)
|
||||
.map((i) => DropdownMenuItem(
|
||||
value: i,
|
||||
child: Text(dialogContext.l10n.channels_channelIndex(i)),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setDialogState(() => selectedIndex = value);
|
||||
}
|
||||
},
|
||||
builder: (dialogContext, setDialogState) {
|
||||
Widget buildOptionTile({
|
||||
required int optionIndex,
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
bool enabled = true,
|
||||
}) {
|
||||
final isSelected = selectedOption == optionIndex;
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: enabled
|
||||
? (isSelected ? Theme.of(dialogContext).colorScheme.primaryContainer : null)
|
||||
: Colors.grey.withValues(alpha: 0.2),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: enabled
|
||||
? (isSelected ? Theme.of(dialogContext).colorScheme.primary : null)
|
||||
: Colors.grey,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: dialogContext.l10n.channels_channelName,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
maxLength: 31,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CheckboxListTile(
|
||||
title: Text(dialogContext.l10n.channels_usePublicChannel),
|
||||
subtitle: Text(dialogContext.l10n.channels_standardPublicPsk),
|
||||
value: usePublicPsk,
|
||||
onChanged: (value) {
|
||||
setDialogState(() {
|
||||
usePublicPsk = value ?? false;
|
||||
if (usePublicPsk) {
|
||||
nameController.text = 'Public';
|
||||
pskController.text = Channel.publicChannelPsk;
|
||||
} else {
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(color: enabled ? null : Colors.grey),
|
||||
),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: TextStyle(color: enabled ? null : Colors.grey),
|
||||
),
|
||||
trailing: enabled ? const Icon(Icons.chevron_right) : null,
|
||||
selected: isSelected,
|
||||
onTap: enabled
|
||||
? () {
|
||||
setDialogState(() {
|
||||
selectedOption = optionIndex;
|
||||
nameController.clear();
|
||||
pskController.clear();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!usePublicPsk) ...[
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: pskController,
|
||||
decoration: InputDecoration(
|
||||
labelText: dialogContext.l10n.channels_pskHex,
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.casino),
|
||||
tooltip: dialogContext.l10n.channels_generateRandomPsk,
|
||||
onPressed: () {
|
||||
final random = Random.secure();
|
||||
final bytes = Uint8List(16);
|
||||
for (int i = 0; i < 16; i++) {
|
||||
bytes[i] = random.nextInt(256);
|
||||
}
|
||||
pskController.text = Channel.formatPskHex(bytes);
|
||||
},
|
||||
hashtagController.clear();
|
||||
});
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget? buildExpandedContent() {
|
||||
switch (selectedOption) {
|
||||
case 0: // Create Private Channel
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: dialogContext.l10n.channels_channelName,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
maxLength: 31,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
final name = nameController.text.trim();
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(dialogContext).showSnackBar(
|
||||
SnackBar(content: Text(dialogContext.l10n.channels_enterChannelName)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final random = Random.secure();
|
||||
final psk = Uint8List(16);
|
||||
for (int i = 0; i < 16; i++) {
|
||||
psk[i] = random.nextInt(256);
|
||||
}
|
||||
Navigator.pop(dialogContext);
|
||||
connector.setChannel(nextIndex, name, psk);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.channels_channelAdded(name))),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(dialogContext.l10n.common_create),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
case 1: // Join Private Channel
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: dialogContext.l10n.channels_channelName,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
maxLength: 31,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: TextField(
|
||||
controller: pskController,
|
||||
decoration: InputDecoration(
|
||||
labelText: dialogContext.l10n.channels_pskHex,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
final name = nameController.text.trim();
|
||||
final pskHex = pskController.text.trim();
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(dialogContext).showSnackBar(
|
||||
SnackBar(content: Text(dialogContext.l10n.channels_enterChannelName)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Uint8List psk;
|
||||
try {
|
||||
psk = Channel.parsePskHex(pskHex);
|
||||
} on FormatException {
|
||||
ScaffoldMessenger.of(dialogContext).showSnackBar(
|
||||
SnackBar(content: Text(dialogContext.l10n.channels_pskMustBe32Hex)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Navigator.pop(dialogContext);
|
||||
connector.setChannel(nextIndex, name, psk);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.channels_channelAdded(name))),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(dialogContext.l10n.common_add),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
case 2: // Join Public Channel
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
final psk = Channel.parsePskHex(Channel.publicChannelPsk);
|
||||
Navigator.pop(dialogContext);
|
||||
connector.setChannel(nextIndex, 'Public', psk);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.channels_publicChannelAdded)),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(dialogContext.l10n.common_add),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(dialogContext.l10n.common_cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final name = nameController.text.trim();
|
||||
final pskHex = usePublicPsk
|
||||
? Channel.publicChannelPsk
|
||||
: pskController.text.trim();
|
||||
);
|
||||
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(dialogContext).showSnackBar(
|
||||
SnackBar(content: Text(dialogContext.l10n.channels_enterChannelName)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 3: // Join Hashtag Channel
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: TextField(
|
||||
controller: hashtagController,
|
||||
decoration: InputDecoration(
|
||||
labelText: dialogContext.l10n.channels_enterHashtag,
|
||||
hintText: dialogContext.l10n.channels_hashtagHint,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.tag),
|
||||
),
|
||||
maxLength: 31,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
var hashtag = hashtagController.text.trim();
|
||||
if (hashtag.isEmpty) {
|
||||
ScaffoldMessenger.of(dialogContext).showSnackBar(
|
||||
SnackBar(content: Text(dialogContext.l10n.channels_enterChannelName)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Normalize hashtag name
|
||||
final name = hashtag.startsWith('#') ? hashtag : '#$hashtag';
|
||||
final psk = Channel.derivePskFromHashtag(hashtag);
|
||||
Navigator.pop(dialogContext);
|
||||
connector.setChannel(nextIndex, name, psk);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.channels_channelAdded(name))),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(dialogContext.l10n.common_add),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Uint8List psk;
|
||||
try {
|
||||
psk = Channel.parsePskHex(pskHex);
|
||||
} on FormatException {
|
||||
ScaffoldMessenger.of(dialogContext).showSnackBar(
|
||||
SnackBar(content: Text(dialogContext.l10n.channels_pskMustBe32Hex)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Navigator.pop(dialogContext);
|
||||
connector.setChannel(selectedIndex, name, psk);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.channels_channelAdded(name))),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(dialogContext.l10n.common_add),
|
||||
return AlertDialog(
|
||||
title: Text(dialogContext.l10n.channels_addChannel),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 16),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildOptionTile(
|
||||
optionIndex: 0,
|
||||
icon: Icons.add,
|
||||
title: dialogContext.l10n.channels_createPrivateChannel,
|
||||
subtitle: dialogContext.l10n.channels_createPrivateChannelDesc,
|
||||
),
|
||||
if (selectedOption == 0) buildExpandedContent()!,
|
||||
const Divider(height: 1),
|
||||
buildOptionTile(
|
||||
optionIndex: 1,
|
||||
icon: Icons.lock,
|
||||
title: dialogContext.l10n.channels_joinPrivateChannel,
|
||||
subtitle: dialogContext.l10n.channels_joinPrivateChannelDesc,
|
||||
),
|
||||
if (selectedOption == 1) buildExpandedContent()!,
|
||||
if (!hasPublicChannel) ...[
|
||||
const Divider(height: 1),
|
||||
buildOptionTile(
|
||||
optionIndex: 2,
|
||||
icon: Icons.public,
|
||||
title: dialogContext.l10n.channels_joinPublicChannel,
|
||||
subtitle: dialogContext.l10n.channels_joinPublicChannelDesc,
|
||||
),
|
||||
if (selectedOption == 2) buildExpandedContent()!,
|
||||
],
|
||||
const Divider(height: 1),
|
||||
buildOptionTile(
|
||||
optionIndex: 3,
|
||||
icon: Icons.tag,
|
||||
title: dialogContext.l10n.channels_joinHashtagChannel,
|
||||
subtitle: dialogContext.l10n.channels_joinHashtagChannelDesc,
|
||||
),
|
||||
if (selectedOption == 3) buildExpandedContent()!,
|
||||
const Divider(height: 1),
|
||||
buildOptionTile(
|
||||
optionIndex: 4,
|
||||
icon: Icons.qr_code,
|
||||
title: dialogContext.l10n.channels_scanQrCode,
|
||||
subtitle: dialogContext.l10n.channels_scanQrCodeComingSoon,
|
||||
enabled: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(dialogContext.l10n.common_close),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -759,13 +759,14 @@ class _ContactTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shotPublicKey = "<${contact.publicKeyHex.substring(0, 8)}...${contact.publicKeyHex.substring(contact.publicKeyHex.length - 8)}>";
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _getTypeColor(contact.type),
|
||||
child: _buildContactAvatar(contact),
|
||||
),
|
||||
title: Text(contact.name),
|
||||
subtitle: Text('${contact.typeLabel} • ${contact.pathLabel}'),
|
||||
subtitle: Text('${contact.typeLabel} • ${contact.pathLabel} $shotPublicKey'),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
|
||||
+288
-130
@@ -1,3 +1,5 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
@@ -47,6 +49,8 @@ class _MapScreenState extends State<MapScreen> {
|
||||
final Set<String> _hiddenMarkerIds = {};
|
||||
Set<String> _removedMarkerIds = {};
|
||||
bool _isSelectingPoi = false;
|
||||
bool _hasInitializedMap = false;
|
||||
bool _removedMarkersLoaded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -67,9 +71,39 @@ class _MapScreenState extends State<MapScreen> {
|
||||
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>(
|
||||
@@ -80,10 +114,12 @@ class _MapScreenState extends State<MapScreen> {
|
||||
final highlightPosition = widget.highlightPosition;
|
||||
final sharedMarkers = settings.mapShowMarkers
|
||||
? _collectSharedMarkers(connector)
|
||||
.where((marker) =>
|
||||
!_hiddenMarkerIds.contains(marker.id) &&
|
||||
!_removedMarkerIds.contains(marker.id))
|
||||
.toList()
|
||||
.where(
|
||||
(marker) =>
|
||||
!_hiddenMarkerIds.contains(marker.id) &&
|
||||
!_removedMarkerIds.contains(marker.id),
|
||||
)
|
||||
.toList()
|
||||
: <_SharedMarker>[];
|
||||
|
||||
// Filter by time
|
||||
@@ -91,16 +127,18 @@ class _MapScreenState extends State<MapScreen> {
|
||||
final filteredByTime = settings.mapTimeFilterHours == 0
|
||||
? contacts
|
||||
: contacts.where((c) {
|
||||
final hoursSinceLastSeen =
|
||||
now.difference(c.lastSeen).inHours;
|
||||
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)
|
||||
final filteredByKeyPrefix =
|
||||
(settings.mapKeyPrefixEnabled && keyPrefix.isNotEmpty)
|
||||
? filteredByTime.where((c) {
|
||||
return c.publicKeyHex.toLowerCase().startsWith(keyPrefix.toLowerCase());
|
||||
return c.publicKeyHex.toLowerCase().startsWith(
|
||||
keyPrefix.toLowerCase(),
|
||||
);
|
||||
}).toList()
|
||||
: filteredByTime;
|
||||
|
||||
@@ -109,30 +147,91 @@ class _MapScreenState extends State<MapScreen> {
|
||||
.where((c) => c.hasLocation)
|
||||
.toList();
|
||||
|
||||
// Calculate center of all nodes, or default to (0, 0)
|
||||
// Calculate center and zoom of all nodes, or default to (0, 0)
|
||||
LatLng center = const LatLng(0, 0);
|
||||
final hasMapContent = contactsWithLocation.isNotEmpty ||
|
||||
double initialZoom = 10.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);
|
||||
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 && hasMapContent) {
|
||||
_hasInitializedMap = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_mapController.move(center, initialZoom);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final allowBack = !connector.isConnected;
|
||||
@@ -156,7 +255,9 @@ class _MapScreenState extends State<MapScreen> {
|
||||
tooltip: context.l10n.common_settings,
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SettingsScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -169,7 +270,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: center,
|
||||
initialZoom: 13.0,
|
||||
initialZoom: initialZoom,
|
||||
minZoom: 2.0,
|
||||
maxZoom: 18.0,
|
||||
onTap: (_, latLng) {
|
||||
@@ -234,14 +335,18 @@ class _MapScreenState extends State<MapScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
_buildLegend(contactsWithLocation.length, sharedMarkers.length),
|
||||
_buildLegend(
|
||||
contactsWithLocation.length,
|
||||
sharedMarkers.length,
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
child: QuickSwitchBar(
|
||||
selectedIndex: 2,
|
||||
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
|
||||
onDestinationSelected: (index) =>
|
||||
_handleQuickSwitch(index, context),
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
@@ -259,27 +364,17 @@ class _MapScreenState extends State<MapScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_off,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
Icon(Icons.location_off, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.map_noNodesWithLocation,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
style: TextStyle(fontSize: 18, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.map_nodesNeedGps,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -293,7 +388,9 @@ class _MapScreenState extends State<MapScreen> {
|
||||
if (!contact.hasLocation) continue;
|
||||
|
||||
// Apply node type filters
|
||||
if (contact.type == advTypeRepeater && !settings.mapShowRepeaters) continue;
|
||||
if (contact.type == advTypeRepeater && !settings.mapShowRepeaters) {
|
||||
continue;
|
||||
}
|
||||
if (contact.type == advTypeChat && !settings.mapShowChatNodes) continue;
|
||||
if (contact.type != advTypeChat &&
|
||||
contact.type != advTypeRepeater &&
|
||||
@@ -396,13 +493,37 @@ class _MapScreenState extends State<MapScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_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.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),
|
||||
_buildLegendItem(
|
||||
Icons.flag,
|
||||
context.l10n.map_pinPrivate,
|
||||
Colors.purple,
|
||||
),
|
||||
_buildLegendItem(
|
||||
Icons.flag,
|
||||
context.l10n.map_pinPublic,
|
||||
Colors.orange,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -418,10 +539,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
Text(label, style: const TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -475,7 +593,9 @@ class _MapScreenState extends State<MapScreen> {
|
||||
label: payload.label,
|
||||
flags: payload.flags,
|
||||
fromName: message.senderName,
|
||||
sourceLabel: channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name,
|
||||
sourceLabel: channel.name.isEmpty
|
||||
? 'Channel ${channel.index}'
|
||||
: channel.name,
|
||||
isChannel: true,
|
||||
isPublicChannel: isPublic,
|
||||
),
|
||||
@@ -541,11 +661,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.flag,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
child: const Icon(Icons.flag, color: Colors.white, size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -563,10 +679,8 @@ class _MapScreenState extends State<MapScreen> {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RepeaterHubScreen(
|
||||
repeater: repeater,
|
||||
password: password,
|
||||
),
|
||||
builder: (context) =>
|
||||
RepeaterHubScreen(repeater: repeater, password: password),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -584,9 +698,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChatScreen(contact: room),
|
||||
),
|
||||
MaterialPageRoute(builder: (context) => ChatScreen(contact: room)),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -613,9 +725,14 @@ class _MapScreenState extends State<MapScreen> {
|
||||
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(
|
||||
'Location',
|
||||
'${contact.latitude!.toStringAsFixed(6)}, ${contact.longitude!.toStringAsFixed(6)}',
|
||||
),
|
||||
_buildInfoRow(
|
||||
context.l10n.map_lastSeen,
|
||||
_formatLastSeen(contact.lastSeen),
|
||||
),
|
||||
_buildInfoRow('Public Key', contact.publicKeyHex),
|
||||
],
|
||||
),
|
||||
@@ -624,7 +741,8 @@ class _MapScreenState extends State<MapScreen> {
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(context.l10n.common_close),
|
||||
),
|
||||
if (contact.type == advTypeChat) // Only show chat button for chat nodes
|
||||
if (contact.type ==
|
||||
advTypeChat) // Only show chat button for chat nodes
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(dialogContext);
|
||||
@@ -637,22 +755,22 @@ class _MapScreenState extends State<MapScreen> {
|
||||
},
|
||||
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),
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -664,17 +782,13 @@ class _MapScreenState extends State<MapScreen> {
|
||||
case 0:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(
|
||||
const ContactsScreen(hideBackButton: true),
|
||||
),
|
||||
buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)),
|
||||
);
|
||||
break;
|
||||
case 1:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(
|
||||
const ChannelsScreen(hideBackButton: true),
|
||||
),
|
||||
buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)),
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -722,7 +836,8 @@ class _MapScreenState extends State<MapScreen> {
|
||||
'Location',
|
||||
'${marker.position.latitude.toStringAsFixed(6)}, ${marker.position.longitude.toStringAsFixed(6)}',
|
||||
),
|
||||
if (marker.flags.isNotEmpty) _buildInfoRow(context.l10n.map_flags, marker.flags),
|
||||
if (marker.flags.isNotEmpty)
|
||||
_buildInfoRow(context.l10n.map_flags, marker.flags),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
@@ -772,10 +887,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
Text(value, style: const TextStyle(fontSize: 14)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -860,7 +972,10 @@ class _MapScreenState extends State<MapScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _promptForLabel(BuildContext context, String defaultLabel) async {
|
||||
Future<String?> _promptForLabel(
|
||||
BuildContext context,
|
||||
String defaultLabel,
|
||||
) async {
|
||||
final controller = TextEditingController(text: defaultLabel);
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
@@ -881,7 +996,10 @@ class _MapScreenState extends State<MapScreen> {
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final label = controller.text.trim().replaceAll('|', '/');
|
||||
Navigator.pop(dialogContext, label.isEmpty ? defaultLabel : label);
|
||||
Navigator.pop(
|
||||
dialogContext,
|
||||
label.isEmpty ? defaultLabel : label,
|
||||
);
|
||||
},
|
||||
child: Text(context.l10n.common_continue),
|
||||
),
|
||||
@@ -913,8 +1031,11 @@ class _MapScreenState extends State<MapScreen> {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (consumerContext, liveConnector, child) {
|
||||
final allContacts = liveConnector.contacts
|
||||
.where((contact) =>
|
||||
contact.type != advTypeRepeater && contact.type != advTypeRoom)
|
||||
.where(
|
||||
(contact) =>
|
||||
contact.type != advTypeRepeater &&
|
||||
contact.type != advTypeRoom,
|
||||
)
|
||||
.toList();
|
||||
return SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
@@ -924,7 +1045,10 @@ class _MapScreenState extends State<MapScreen> {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(context.l10n.map_sendToContact, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
child: Text(
|
||||
context.l10n.map_sendToContact,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
@@ -935,7 +1059,10 @@ class _MapScreenState extends State<MapScreen> {
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setSheetState(() {
|
||||
@@ -945,50 +1072,73 @@ class _MapScreenState extends State<MapScreen> {
|
||||
),
|
||||
),
|
||||
...allContacts
|
||||
.where((contact) =>
|
||||
query.isEmpty || matchesContactQuery(contact, query))
|
||||
.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);
|
||||
},
|
||||
);
|
||||
}),
|
||||
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)),
|
||||
child: Text(
|
||||
context.l10n.map_sendToChannel,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
if (liveConnector.isLoadingChannels)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: LinearProgressIndicator(),
|
||||
)
|
||||
else if (liveConnector.channels.where((c) => !c.isEmpty).isEmpty)
|
||||
else if (liveConnector.channels
|
||||
.where((c) => !c.isEmpty)
|
||||
.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(context.l10n.map_noChannelsAvailable),
|
||||
)
|
||||
else
|
||||
...liveConnector.channels.where((c) => !c.isEmpty).map((channel) {
|
||||
...liveConnector.channels.where((c) => !c.isEmpty).map((
|
||||
channel,
|
||||
) {
|
||||
final isPublic = _isPublicChannel(channel);
|
||||
final label = channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name;
|
||||
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,
|
||||
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);
|
||||
liveConnector.sendChannelMessage(
|
||||
channel,
|
||||
markerText,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1008,12 +1158,17 @@ class _MapScreenState extends State<MapScreen> {
|
||||
return channel.isPublicChannel;
|
||||
}
|
||||
|
||||
Future<bool> _confirmPublicShare(BuildContext context, String channelLabel) async {
|
||||
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)),
|
||||
content: Text(
|
||||
context.l10n.map_publicLocationShareConfirm(channelLabel),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
@@ -1029,7 +1184,10 @@ class _MapScreenState extends State<MapScreen> {
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
void _showFilterDialog(BuildContext context, AppSettingsService settingsService) {
|
||||
void _showFilterDialog(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
@@ -1133,10 +1291,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_getTimeFilterLabel(settings.mapTimeFilterHours),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
|
||||
),
|
||||
Slider(
|
||||
value: _hoursToSliderValue(settings.mapTimeFilterHours),
|
||||
@@ -1176,11 +1331,14 @@ class _MapScreenState extends State<MapScreen> {
|
||||
|
||||
if (hours <= 24) {
|
||||
return (hours / 24) * 40;
|
||||
} else if (hours <= 168) { // 7 days
|
||||
} else if (hours <= 168) {
|
||||
// 7 days
|
||||
return 40 + ((hours - 24) / (168 - 24)) * 20;
|
||||
} else if (hours <= 720) { // 30 days
|
||||
} else if (hours <= 720) {
|
||||
// 30 days
|
||||
return 60 + ((hours - 168) / (720 - 168)) * 20;
|
||||
} else if (hours <= 4380) { // 6 months
|
||||
} else if (hours <= 4380) {
|
||||
// 6 months
|
||||
return 80 + ((hours - 720) / (4380 - 720)) * 19;
|
||||
} else {
|
||||
return 100;
|
||||
|
||||
@@ -57,6 +57,11 @@ class RepeaterHubScreen extends StatelessWidget {
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'<${repeater.publicKeyHex.substring(0, 8)}...${repeater.publicKeyHex.substring(repeater.publicKeyHex.length - 8)}>',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
repeater.pathLabel,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||
|
||||
@@ -895,7 +895,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<int>(
|
||||
value: _bandwidth,
|
||||
initialValue: _bandwidth,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.repeater_bandwidth,
|
||||
border: const OutlineInputBorder(),
|
||||
@@ -917,7 +917,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<int>(
|
||||
value: _spreadingFactor,
|
||||
initialValue: _spreadingFactor,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.repeater_spreadingFactor,
|
||||
border: const OutlineInputBorder(),
|
||||
@@ -939,7 +939,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<int>(
|
||||
value: _codingRate,
|
||||
initialValue: _codingRate,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.repeater_codingRate,
|
||||
border: const OutlineInputBorder(),
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -34,7 +31,6 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
static const int _statusResponseBytes =
|
||||
_statusPayloadOffset + _statusStatsSize;
|
||||
Uint8List _tagData = Uint8List(4);
|
||||
int _timeEstment = 0;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _isLoaded = false;
|
||||
@@ -64,18 +60,19 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
|
||||
if (frame[0] == respCodeSent) {
|
||||
_tagData = frame.sublist(2, 6);
|
||||
_timeEstment = frame.buffer.asByteData().getUint32(6, Endian.little);
|
||||
}
|
||||
|
||||
// Check if it's a binary response
|
||||
if (frame[0] == pushCodeBinaryResponse &&
|
||||
listEquals(frame.sublist(2, 6), _tagData)) {
|
||||
_handleStatusResponse(context, frame.sublist(6));
|
||||
if (!mounted) return;
|
||||
_handleStatusResponse(frame.sublist(6));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleStatusResponse(BuildContext context, Uint8List frame) {
|
||||
void _handleStatusResponse(Uint8List frame) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_parsedTelemetry = CayenneLpp.parseByChannel(frame);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user