updated ui added new features

This commit is contained in:
zach
2025-12-27 15:32:32 -07:00
parent 02ca7801ea
commit a2cfae3a22
589 changed files with 181780 additions and 569 deletions
+295 -17
View File
@@ -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;
}