mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-27 14:06:38 +10:00
updated ui added new features
This commit is contained in:
@@ -7,6 +7,7 @@ import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../services/map_tile_cache_service.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../models/channel_message.dart';
|
||||
import '../models/contact.dart';
|
||||
@@ -23,8 +24,14 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final hops = _buildPathHops(message.pathBytes, connector.contacts);
|
||||
final hasHopDetails = message.pathBytes.isNotEmpty;
|
||||
final primaryPath = _selectPrimaryPath(message.pathBytes, message.pathVariants);
|
||||
final hops = _buildPathHops(primaryPath, connector.contacts);
|
||||
final hasHopDetails = primaryPath.isNotEmpty;
|
||||
final observedLabel = _formatObservedHops(
|
||||
primaryPath.length,
|
||||
message.pathLength,
|
||||
);
|
||||
final extraPaths = _otherPaths(primaryPath, message.pathVariants);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -35,13 +42,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
tooltip: 'View map',
|
||||
onPressed: hasHopDetails
|
||||
? () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
ChannelMessagePathMapScreen(message: message),
|
||||
),
|
||||
);
|
||||
_openPathMap(context);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
@@ -50,8 +51,17 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildSummaryCard(context),
|
||||
_buildSummaryCard(context, observedLabel: observedLabel),
|
||||
const SizedBox(height: 16),
|
||||
if (extraPaths.isNotEmpty) ...[
|
||||
Text(
|
||||
'Other Observed Paths',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildPathVariants(context, extraPaths),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Text(
|
||||
'Repeater Hops',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
@@ -71,7 +81,10 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCard(BuildContext context) {
|
||||
Widget _buildSummaryCard(
|
||||
BuildContext context, {
|
||||
String? observedLabel,
|
||||
}) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
@@ -88,12 +101,37 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
if (message.repeatCount > 0)
|
||||
_buildDetailRow('Repeats', message.repeatCount.toString()),
|
||||
_buildDetailRow('Path', _formatPathLabel(message.pathLength)),
|
||||
if (observedLabel != null) _buildDetailRow('Observed', observedLabel),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPathVariants(
|
||||
BuildContext context,
|
||||
List<Uint8List> variants,
|
||||
) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < variants.length; i++)
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
title: Text(
|
||||
'Observed path ${i + 1} • ${_formatHopCount(variants[i].length)}',
|
||||
),
|
||||
subtitle: Text(_formatPathPrefixes(variants[i])),
|
||||
trailing: const Icon(Icons.map_outlined, size: 20),
|
||||
onTap: () => _openPathMap(context, initialPath: variants[i]),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildHopTiles(List<_PathHop> hops) {
|
||||
return [
|
||||
for (final hop in hops)
|
||||
@@ -138,6 +176,22 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
return '$pathLength hops';
|
||||
}
|
||||
|
||||
String? _formatObservedHops(int observedCount, int? pathLength) {
|
||||
if (observedCount <= 0 && (pathLength == null || pathLength <= 0)) {
|
||||
return null;
|
||||
}
|
||||
if (pathLength == null || pathLength < 0) {
|
||||
return observedCount > 0 ? '$observedCount hops' : null;
|
||||
}
|
||||
if (observedCount == 0) {
|
||||
return '0 of $pathLength hops';
|
||||
}
|
||||
if (observedCount == pathLength) {
|
||||
return '$observedCount hops';
|
||||
}
|
||||
return '$observedCount of $pathLength hops';
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
@@ -153,21 +207,71 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openPathMap(BuildContext context, {Uint8List? initialPath}) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChannelMessagePathMapScreen(
|
||||
message: message,
|
||||
initialPath: initialPath,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ChannelMessagePathMapScreen extends StatelessWidget {
|
||||
class ChannelMessagePathMapScreen extends StatefulWidget {
|
||||
final ChannelMessage message;
|
||||
final Uint8List? initialPath;
|
||||
|
||||
const ChannelMessagePathMapScreen({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.initialPath,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChannelMessagePathMapScreen> createState() =>
|
||||
_ChannelMessagePathMapScreenState();
|
||||
}
|
||||
|
||||
class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScreen> {
|
||||
Uint8List? _selectedPath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedPath = widget.initialPath;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ChannelMessagePathMapScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.message != widget.message ||
|
||||
!_pathsEqual(oldWidget.initialPath ?? Uint8List(0),
|
||||
widget.initialPath ?? Uint8List(0))) {
|
||||
_selectedPath = widget.initialPath;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final hops = _buildPathHops(message.pathBytes, connector.contacts);
|
||||
final tileCache = context.read<MapTileCacheService>();
|
||||
final primaryPath =
|
||||
_selectPrimaryPath(widget.message.pathBytes, widget.message.pathVariants);
|
||||
final observedPaths =
|
||||
_buildObservedPaths(primaryPath, widget.message.pathVariants);
|
||||
final selectedPath = _resolveSelectedPath(
|
||||
_selectedPath,
|
||||
observedPaths,
|
||||
primaryPath,
|
||||
);
|
||||
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
||||
final hops = _buildPathHops(selectedPath, connector.contacts);
|
||||
final points = hops
|
||||
.where((hop) => hop.hasLocation)
|
||||
.map((hop) => hop.position!)
|
||||
@@ -186,6 +290,7 @@ class ChannelMessagePathMapScreen extends StatelessWidget {
|
||||
points.isNotEmpty ? points.first : const LatLng(0, 0);
|
||||
final initialZoom = points.isNotEmpty ? 13.0 : 2.0;
|
||||
final bounds = points.length > 1 ? LatLngBounds.fromPoints(points) : null;
|
||||
final mapKey = ValueKey(_formatPathPrefixes(selectedPath));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -194,6 +299,7 @@ class ChannelMessagePathMapScreen extends StatelessWidget {
|
||||
body: Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
key: mapKey,
|
||||
options: MapOptions(
|
||||
initialCenter: initialCenter,
|
||||
initialZoom: initialZoom,
|
||||
@@ -209,9 +315,10 @@ class ChannelMessagePathMapScreen extends StatelessWidget {
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate:
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.meshcore.open',
|
||||
urlTemplate: kMapTileUrlTemplate,
|
||||
tileProvider: tileCache.tileProvider,
|
||||
userAgentPackageName:
|
||||
MapTileCacheService.userAgentPackageName,
|
||||
maxZoom: 19,
|
||||
),
|
||||
if (polylines.isNotEmpty) PolylineLayer(polylines: polylines),
|
||||
@@ -220,6 +327,17 @@ class ChannelMessagePathMapScreen extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (observedPaths.length > 1)
|
||||
_buildPathSelector(
|
||||
context,
|
||||
observedPaths,
|
||||
selectedIndex,
|
||||
(index) {
|
||||
setState(() {
|
||||
_selectedPath = observedPaths[index].pathBytes;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (points.isEmpty)
|
||||
Center(
|
||||
child: Card(
|
||||
@@ -238,6 +356,65 @@ class ChannelMessagePathMapScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPathSelector(
|
||||
BuildContext context,
|
||||
List<_ObservedPath> paths,
|
||||
int selectedIndex,
|
||||
ValueChanged<int> onSelected,
|
||||
) {
|
||||
final selectedPath = paths[selectedIndex];
|
||||
final label = selectedPath.isPrimary
|
||||
? 'Path ${selectedIndex + 1} (Primary)'
|
||||
: 'Path ${selectedIndex + 1}';
|
||||
return Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
child: SafeArea(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Observed Path',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
isExpanded: true,
|
||||
value: selectedIndex,
|
||||
items: [
|
||||
for (int i = 0; i < paths.length; i++)
|
||||
DropdownMenuItem(
|
||||
value: i,
|
||||
child: Text(
|
||||
'${paths[i].isPrimary ? 'Path ${i + 1} (Primary)' : 'Path ${i + 1}'}'
|
||||
' • ${_formatHopCount(paths[i].pathBytes.length)}',
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
onSelected(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$label • ${_formatPathPrefixes(selectedPath.pathBytes)}',
|
||||
style: TextStyle(color: Colors.grey[700], fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Marker> _buildHopMarkers(List<_PathHop> hops) {
|
||||
return [
|
||||
for (final hop in hops)
|
||||
@@ -356,6 +533,16 @@ class _PathHop {
|
||||
}
|
||||
}
|
||||
|
||||
class _ObservedPath {
|
||||
final Uint8List pathBytes;
|
||||
final bool isPrimary;
|
||||
|
||||
const _ObservedPath({
|
||||
required this.pathBytes,
|
||||
required this.isPrimary,
|
||||
});
|
||||
}
|
||||
|
||||
List<_PathHop> _buildPathHops(Uint8List pathBytes, List<Contact> contacts) {
|
||||
final hops = <_PathHop>[];
|
||||
for (var i = 0; i < pathBytes.length; i++) {
|
||||
@@ -375,7 +562,10 @@ List<_PathHop> _buildPathHops(Uint8List pathBytes, List<Contact> contacts) {
|
||||
|
||||
Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) {
|
||||
final matches = contacts
|
||||
.where((contact) => contact.publicKey.isNotEmpty && contact.publicKey[0] == prefix)
|
||||
.where((contact) =>
|
||||
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
|
||||
contact.publicKey.isNotEmpty &&
|
||||
contact.publicKey[0] == prefix)
|
||||
.toList();
|
||||
if (matches.isEmpty) return null;
|
||||
|
||||
@@ -410,6 +600,16 @@ String _formatPrefix(int prefix) {
|
||||
return prefix.toRadixString(16).padLeft(2, '0').toUpperCase();
|
||||
}
|
||||
|
||||
String _formatPathPrefixes(Uint8List pathBytes) {
|
||||
return pathBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join(',');
|
||||
}
|
||||
|
||||
String _formatHopCount(int count) {
|
||||
return '$count ${count == 1 ? 'hop' : 'hops'}';
|
||||
}
|
||||
|
||||
String _resolveName(Contact? contact) {
|
||||
if (contact == null) return 'Unknown Repeater';
|
||||
final name = contact.name.trim();
|
||||
@@ -418,3 +618,81 @@ String _resolveName(Contact? contact) {
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
Uint8List _selectPrimaryPath(Uint8List pathBytes, List<Uint8List> variants) {
|
||||
Uint8List primary = pathBytes;
|
||||
for (final variant in variants) {
|
||||
if (variant.length > primary.length) {
|
||||
primary = variant;
|
||||
}
|
||||
}
|
||||
return primary;
|
||||
}
|
||||
|
||||
List<Uint8List> _otherPaths(Uint8List primary, List<Uint8List> variants) {
|
||||
final others = <Uint8List>[];
|
||||
for (final variant in variants) {
|
||||
if (variant.isEmpty) continue;
|
||||
if (!_pathsEqual(primary, variant)) {
|
||||
others.add(variant);
|
||||
}
|
||||
}
|
||||
return others;
|
||||
}
|
||||
|
||||
List<_ObservedPath> _buildObservedPaths(
|
||||
Uint8List primary,
|
||||
List<Uint8List> variants,
|
||||
) {
|
||||
final observed = <_ObservedPath>[];
|
||||
|
||||
void addPath(Uint8List pathBytes, bool isPrimary) {
|
||||
if (pathBytes.isEmpty) return;
|
||||
for (final existing in observed) {
|
||||
if (_pathsEqual(existing.pathBytes, pathBytes)) return;
|
||||
}
|
||||
observed.add(_ObservedPath(pathBytes: pathBytes, isPrimary: isPrimary));
|
||||
}
|
||||
|
||||
addPath(primary, true);
|
||||
for (final variant in variants) {
|
||||
addPath(variant, false);
|
||||
}
|
||||
|
||||
return observed;
|
||||
}
|
||||
|
||||
Uint8List _resolveSelectedPath(
|
||||
Uint8List? selected,
|
||||
List<_ObservedPath> observedPaths,
|
||||
Uint8List fallback,
|
||||
) {
|
||||
if (selected != null) {
|
||||
for (final path in observedPaths) {
|
||||
if (_pathsEqual(path.pathBytes, selected)) {
|
||||
return path.pathBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (observedPaths.isNotEmpty) {
|
||||
return observedPaths.first.pathBytes;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
int _indexForPath(Uint8List selected, List<_ObservedPath> paths) {
|
||||
for (int i = 0; i < paths.length; i++) {
|
||||
if (_pathsEqual(paths[i].pathBytes, selected)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool _pathsEqual(Uint8List a, Uint8List b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user