import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../helpers/snack_bar_builder.dart'; import '../models/contact.dart'; import '../services/mesh_topology_service.dart'; import '../widgets/adaptive_app_bar_title.dart'; /// Read-only diagnostic view of the passively-learned mesh topology (#196 / /// epic #186). It answers one question on real hardware: is /// [MeshTopologyService] actually ingesting? Node/edge counts climb as routed /// packets arrive; stuck at zero while traffic flows means the RX ingestion /// wiring is dead. Strings are hardcoded EN on purpose — this is a dev-facing /// diagnostic, deliberately not part of the localized UX surface. class TopologyDebugScreen extends StatelessWidget { const TopologyDebugScreen({super.key}); @override Widget build(BuildContext context) { final contacts = context.read().contacts; return Consumer( builder: (context, topo, _) { final now = topo.clockSeconds; final self = topo.self; final nodes = topo.nodes.toList() ..sort((a, b) => b.lastSeen.compareTo(a.lastSeen)); final edges = topo.edges.toList() ..sort((a, b) => b.lastSeen.compareTo(a.lastSeen)); final hasData = nodes.isNotEmpty || edges.isNotEmpty; return Scaffold( appBar: AppBar( title: const AdaptiveAppBarTitle('Mesh topology'), centerTitle: true, actions: [ IconButton( tooltip: 'Copy dump', icon: const Icon(Icons.copy), onPressed: !hasData ? null : () async { await Clipboard.setData( ClipboardData(text: _dump(topo, contacts, now)), ); if (!context.mounted) return; showDismissibleSnackBar( context, content: const Text('Topology copied'), ); }, ), ], ), body: SafeArea( top: false, child: ListView( children: [ _summary(context, topo, self, contacts), const Divider(height: 1), if (nodes.isEmpty) _empty(context) else ...[ _sectionHeader(context, 'Nodes (${nodes.length})'), for (final n in nodes) _nodeTile(context, n, self, contacts, now), const Divider(height: 1), _sectionHeader(context, 'Edges (${edges.length})'), for (final e in edges) _edgeTile(context, e, contacts, now), ], ], ), ), ); }, ); } Widget _summary( BuildContext context, MeshTopologyService topo, Uint8List? self, List contacts, ) { final selfLabel = self == null ? 'not set yet — waiting for first packet' : (_nameOf(self, contacts) == null ? _hex(self) : '${_nameOf(self, contacts)} (${_hex(self)})'); return Padding( padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ _stat(context, '${topo.nodeCount}', 'nodes'), _stat(context, '${topo.edgeCount}', 'edges'), _stat(context, '${topo.freshnessSeconds ~/ 86400}d', 'freshness'), ], ), const SizedBox(height: 10), Text( 'You: $selfLabel', style: TextStyle( fontSize: 12, fontFamily: 'monospace', color: Colors.grey[700], ), ), const SizedBox(height: 6), Text( 'Passive map of routed-packet paths. Counts climb as traffic ' 'arrives; stuck at 0 while messages flow means ingestion is dead.', style: TextStyle(fontSize: 11, color: Colors.grey[600]), ), ], ), ); } Widget _stat(BuildContext context, String value, String label) { return Expanded( child: Column( children: [ Text( value, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold), ), Text(label, style: TextStyle(fontSize: 11, color: Colors.grey[600])), ], ), ); } Widget _sectionHeader(BuildContext context, String text) { return Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Text( text, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold), ), ); } Widget _nodeTile( BuildContext context, TopoNode n, Uint8List? self, List contacts, int now, ) { final hex = _hex(n.prefix); final isSelf = self != null && hex == _hex(self); final name = isSelf ? 'You' : _nameOf(n.prefix, contacts); final snr = n.neighbourSnr; final meta = [ 'seen ×${n.seenCount}', _age(n.lastSeen, now), if (snr != null) 'SNR ${snr.toStringAsFixed(1)}', ].join(' · '); return ListTile( dense: true, leading: Icon( isSelf ? Icons.my_location : (snr != null ? Icons.cell_tower : Icons.hub_outlined), size: 18, color: isSelf ? Colors.blue : (snr != null ? Colors.green : Colors.grey), ), title: Text( name ?? hex, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), ), subtitle: Text( name != null ? '$hex · $meta' : meta, style: TextStyle( fontSize: 11, fontFamily: 'monospace', color: Colors.grey[600], ), ), ); } Widget _edgeTile( BuildContext context, TopoEdge e, List contacts, int now, ) { final a = _nameOf(e.a, contacts) ?? _hex(e.a); final b = _nameOf(e.b, contacts) ?? _hex(e.b); final failed = e.lastFailed != null; final meta = [ '×${e.count}', _age(e.lastSeen, now), if (failed) 'failed ${_age(e.lastFailed!, now)}', ].join(' · '); return ListTile( dense: true, leading: Icon( failed ? Icons.link_off : Icons.link, size: 18, color: failed ? Colors.orange : Colors.grey, ), title: Text('$a ~ $b', style: const TextStyle(fontSize: 13)), subtitle: Text( meta, style: TextStyle(fontSize: 11, color: Colors.grey[600]), ), ); } Widget _empty(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 24), child: Column( children: [ Icon(Icons.hub_outlined, size: 56, color: Colors.grey[400]), const SizedBox(height: 12), Text( 'No topology learned yet', style: TextStyle(fontSize: 15, color: Colors.grey[600]), ), const SizedBox(height: 6), Text( 'Listening — this fills as routed packets arrive from the mesh. ' 'Leave it connected a few minutes.', textAlign: TextAlign.center, style: TextStyle(fontSize: 12, color: Colors.grey[500]), ), ], ), ); } // ── pure helpers (also used by the clipboard dump) ────────────────────── static String? _nameOf(List prefix, List contacts) { final matches = contacts .where( (c) => c.publicKey.length >= prefix.length && _startsWith(c.publicKey, prefix) && (c.type == advTypeRepeater || c.type == advTypeRoom), ) .toList(); if (matches.isEmpty) return null; if (matches.length == 1) return matches.first.name; return matches.map((c) => c.name).join(' | '); } static bool _startsWith(List pubkey, List prefix) { for (var i = 0; i < prefix.length; i++) { if (pubkey[i] != prefix[i]) return false; } return true; } static String _hex(List b) => b.map((x) => x.toRadixString(16).padLeft(2, '0').toUpperCase()).join(); static String _age(int lastSeen, int now) { final s = now - lastSeen; if (s < 0) return 'now'; if (s < 60) return '${s}s ago'; if (s < 3600) return '${s ~/ 60}m ago'; if (s < 86400) return '${s ~/ 3600}h ago'; return '${s ~/ 86400}d ago'; } static String _dump( MeshTopologyService topo, List contacts, int now, ) { final self = topo.self; final buf = StringBuffer() ..writeln( 'Mesh topology — ${topo.nodeCount} nodes, ${topo.edgeCount} edges', ) ..writeln('self: ${self == null ? "(unset)" : _hex(self)}') ..writeln('freshness: ${topo.freshnessSeconds ~/ 86400}d') ..writeln('') ..writeln('NODES:'); for (final n in topo.nodes.toList() ..sort((a, b) => b.lastSeen.compareTo(a.lastSeen))) { final nm = _nameOf(n.prefix, contacts); final snr = n.neighbourSnr; buf.writeln( ' ${_hex(n.prefix)}${nm != null ? " ($nm)" : ""} ' 'seen×${n.seenCount} ${_age(n.lastSeen, now)}' '${snr != null ? " SNR ${snr.toStringAsFixed(1)}" : ""}', ); } buf.writeln('EDGES:'); for (final e in topo.edges.toList() ..sort((a, b) => b.lastSeen.compareTo(a.lastSeen))) { buf.writeln( ' ${_hex(e.a)} ~ ${_hex(e.b)} ×${e.count} ' '${_age(e.lastSeen, now)}${e.lastFailed != null ? " FAILED" : ""}', ); } return buf.toString(); } }