mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-19 00:45:33 +10:00
d2b693e5ce
* Refactor Cayenne LPP parsing with error handling and logging - Added error handling and logging to the Cayenne LPP parsing methods to manage malformed data gracefully. - Improved the structure of the parsing logic for better readability and maintainability. - Updated the Contact model to include error handling during frame parsing. - Refactored Channels, Contacts, Map, and Neighbours screens to utilize a new AppBarTitle widget for consistent app bar design. - Enhanced the BatteryIndicator widget to display SNR information for direct repeaters. - Introduced SNRUi class for better management of SNR icon and text representation. - Improved error handling in PathTraceMap and Neighbours screens to log errors appropriately. * Fix trace route bytes generation logic in Contact model * Ignore advertisements from self in MeshCoreConnector * Refactor PathTraceData to use List<double> for snrData and adjust data mapping in PathTraceMapScreen * Add SNRIndicator to AppBar and refactor BatteryIndicator layout * Enhance path management dialog to display direct repeaters with color coding based on signal strength * Remove unused import from SNR indicator widget * Update lib/models/contact.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update lib/connector/meshcore_connector.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update lib/connector/meshcore_connector.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update lib/screens/path_trace_map.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update lib/widgets/battery_indicator.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update lib/helpers/cayenne_lpp.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor packet handling to skip only the RSSI byte for improved reliability * Add SNR indicator localization and update UI references for nearby repeaters * Handle loading state and error parsing in PathTraceMapScreen; update SNR indicator dialog content layout * Throw an exception for unsupported LPP types in CayenneLpp class * Refactor AppBarTitle widget to remove unused style parameter; update related screens to reflect changes Improve SNR handling by adding validation for spreading factor range in snrUiFromSNR function Update contact handling in MeshCoreConnector to fix variable naming and improve readability Stop parsing unsupported LPP types in CayenneLpp to avoid misalignment * Sort direct repeaters by last updated time and SNR; limit to top three for improved path management dialog * Prevent notifications for chat and sensor adverts without a valid path * Implement ranking system for direct repeaters based on SNR and recency; update related UI components to reflect changes * Refactor localization keys for "neighbors" terminology across multiple languages - Updated localization keys from "neighbours" to "neighbors" in the following files: - app_localizations_bg.dart - app_localizations_de.dart - app_localizations_en.dart - app_localizations_es.dart - app_localizations_fr.dart - app_localizations_it.dart - app_localizations_nl.dart - app_localizations_pl.dart - app_localizations_pt.dart - app_localizations_ru.dart - app_localizations_sk.dart - app_localizations_sl.dart - app_localizations_sv.dart - app_localizations_uk.dart - app_localizations_zh.dart - Updated corresponding ARB files to reflect the changes in keys. - Renamed the NeighboursScreen to NeighborsScreen in the chat and repeater hub screens for consistency. * Adjust ranking calculation for direct repeaters by adding offset to SNR for improved accuracy * Fix typo in variable name for second direct repeater in path management dialog * Refactor ranking calculation for direct repeaters and update path handling in channel message screens * Refactor path handling in ChannelMessagePathScreen to improve logic for outgoing messages and channel messages * Fix AppBarTitle horizontal overflow with long titles (#187) * Initial plan * Wrap title Column in Expanded to prevent horizontal overflow in AppBarTitle Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com> * Refactor AppBarTitle widget to simplify Text widget initialization * Add "Show All Paths" feature to chat path management - Implemented localization for "Show All Paths" in multiple languages (DE, EN, ES, FR, IT, NL, PL, PT, RU, SK, SL, SV, UK, ZH). - Updated path management dialog to include a toggle for showing all paths. - Refactored path history display logic to conditionally show paths based on the toggle state. - Cleaned up unused print statements and improved code readability in path tracing and chat screens. * Refactor FeatureToggleRow visibility in chat and path management dialogs based on repeaters list * Remove unused import of 'dart:ffi' in path_trace_map.dart * Refactor repeater management logic and update UI state handling in chat and path management dialogs * Refactor RX data handling and improve repeater management logic in chat and path management dialogs --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com>
631 lines
20 KiB
Dart
631 lines
20 KiB
Dart
import 'dart:async';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_map/flutter_map.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
|
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
|
import 'package:meshcore_open/l10n/l10n.dart';
|
|
import 'package:meshcore_open/models/contact.dart';
|
|
import 'package:meshcore_open/services/map_tile_cache_service.dart';
|
|
import 'package:meshcore_open/utils/app_logger.dart';
|
|
import 'package:meshcore_open/widgets/snr_indicator.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
double getPathDistanceMeters(List<LatLng> points) {
|
|
if (points.length <= 1) return 0.0;
|
|
|
|
double distanceMeters = 0.0;
|
|
final distanceCalculator = Distance();
|
|
|
|
for (int i = 0; i < points.length - 1; i++) {
|
|
distanceMeters += distanceCalculator(points[i], points[i + 1]);
|
|
}
|
|
|
|
return distanceMeters;
|
|
}
|
|
|
|
String formatDistance(double distanceMeters) {
|
|
return '(${(distanceMeters / 1609.34).toStringAsFixed(2)} Miles / ${(distanceMeters / 1000).toStringAsFixed(2)} Km)';
|
|
}
|
|
|
|
class PathTraceData {
|
|
final Uint8List pathData;
|
|
final List<double> snrData;
|
|
final Map<int, Contact> pathContacts;
|
|
|
|
PathTraceData({
|
|
required this.pathData,
|
|
required this.snrData,
|
|
required this.pathContacts,
|
|
});
|
|
}
|
|
|
|
class PathTraceMapScreen extends StatefulWidget {
|
|
final String title;
|
|
final Uint8List path;
|
|
final int? repeaterId;
|
|
final bool flipPathRound;
|
|
final bool reversePathRound;
|
|
|
|
const PathTraceMapScreen({
|
|
super.key,
|
|
required this.title,
|
|
required this.path,
|
|
this.repeaterId,
|
|
this.flipPathRound = false,
|
|
this.reversePathRound = false,
|
|
});
|
|
|
|
@override
|
|
State<PathTraceMapScreen> createState() => _PathTraceMapScreenState();
|
|
}
|
|
|
|
class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|
StreamSubscription<Uint8List>? _frameSubscription;
|
|
Timer? _timeoutTimer;
|
|
|
|
bool _isLoading = false;
|
|
bool _failed2Loaded = false;
|
|
bool _hasData = false;
|
|
PathTraceData? _traceData;
|
|
List<LatLng> _points = <LatLng>[];
|
|
List<Polyline> _polylines = [];
|
|
LatLng? _initialCenter = LatLng(0, 0);
|
|
double _initialZoom = 2.0;
|
|
LatLngBounds? _bounds;
|
|
ValueKey<String> _mapKey = const ValueKey('initial');
|
|
double _pathDistanceMeters = 0.0;
|
|
|
|
String _formatPathPrefixes(Uint8List pathBytes) {
|
|
return pathBytes
|
|
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
|
.join(',');
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_setupFrameListener();
|
|
_doPathTrace();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_frameSubscription?.cancel();
|
|
_timeoutTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
Uint8List addReturnPath(Uint8List pathBytes) {
|
|
Uint8List? traceBytes;
|
|
final len = (pathBytes.length + pathBytes.length - 1);
|
|
traceBytes = Uint8List(len);
|
|
for (int i = 0; i < pathBytes.length; i++) {
|
|
traceBytes[i] = pathBytes[i];
|
|
if (i < pathBytes.length - 1) {
|
|
traceBytes[len - 1 - i] = pathBytes[i];
|
|
}
|
|
}
|
|
return traceBytes;
|
|
}
|
|
|
|
Future<void> _doPathTrace() async {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoading = true;
|
|
_failed2Loaded = false;
|
|
});
|
|
}
|
|
|
|
final Uint8List path;
|
|
|
|
Uint8List pathTmp = widget.reversePathRound
|
|
? Uint8List.fromList(widget.path.reversed.toList())
|
|
: widget.path;
|
|
|
|
if (widget.flipPathRound) {
|
|
path = addReturnPath(pathTmp);
|
|
} else {
|
|
path = pathTmp;
|
|
}
|
|
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
final frame = buildTraceReq(
|
|
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
0, //flags
|
|
0, //auth
|
|
payload: path,
|
|
);
|
|
connector.sendFrame(frame);
|
|
}
|
|
|
|
void _setupFrameListener() {
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
Uint8List tagData = Uint8List(4);
|
|
// Listen for incoming text messages from the repeater
|
|
_frameSubscription = connector.receivedFrames.listen((frame) {
|
|
if (frame.isEmpty) return;
|
|
final frameBuffer = BufferReader(frame);
|
|
try {
|
|
final code = frameBuffer.readUInt8();
|
|
|
|
if (code == respCodeSent) {
|
|
frameBuffer.skipBytes(1); //reserved
|
|
tagData = frameBuffer.readBytes(4);
|
|
final timeoutMilliseconds = frameBuffer.readUInt32LE();
|
|
|
|
// Start timeout timer for trace response
|
|
_timeoutTimer?.cancel();
|
|
_timeoutTimer = Timer(
|
|
Duration(milliseconds: timeoutMilliseconds),
|
|
() {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isLoading = false;
|
|
_failed2Loaded = true;
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
if (code == respCodeErr) {
|
|
_timeoutTimer?.cancel();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isLoading = false;
|
|
_failed2Loaded = true;
|
|
});
|
|
}
|
|
|
|
// Check if it's a binary response
|
|
if (frame.length > 8 &&
|
|
code == pushCodeTraceData &&
|
|
listEquals(frame.sublist(4, 8), tagData)) {
|
|
_timeoutTimer?.cancel();
|
|
if (!mounted) return;
|
|
frameBuffer.skipBytes(3); //reserved + path length + flag
|
|
if (listEquals(frameBuffer.readBytes(4), tagData)) {
|
|
_handleTraceResponse(frame);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
_timeoutTimer?.cancel();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isLoading = false;
|
|
_failed2Loaded = true;
|
|
});
|
|
// Handle any parsing errors gracefully
|
|
appLogger.error('Error parsing frame: $e', tag: 'PathTraceMapScreen');
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _handleTraceResponse(Uint8List frame) async {
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
|
|
final buffer = BufferReader(frame);
|
|
try {
|
|
buffer.skipBytes(2); // Skip push code and reserved byte
|
|
int pathLength = buffer.readUInt8();
|
|
buffer.skipBytes(5); // Skip Flag byte and tag data
|
|
buffer.skipBytes(4); // Skip auth code
|
|
Uint8List pathData = buffer.readBytes(pathLength);
|
|
List<double> snrData = buffer
|
|
.readRemainingBytes()
|
|
.map((snr) => snr.toSigned(8).toDouble() / 4)
|
|
.toList();
|
|
|
|
Map<int, Contact> pathContacts = {};
|
|
|
|
connector.contacts.where((c) => c.type != advTypeChat).forEach((
|
|
repeater,
|
|
) {
|
|
for (var repeaterData in pathData) {
|
|
if (listEquals(
|
|
repeater.publicKey.sublist(0, 1),
|
|
Uint8List.fromList([repeaterData]),
|
|
)) {
|
|
pathContacts[repeaterData] = repeater;
|
|
}
|
|
}
|
|
});
|
|
|
|
setState(() {
|
|
_isLoading = false;
|
|
_hasData = true;
|
|
_traceData = PathTraceData(
|
|
pathData: pathData,
|
|
snrData: snrData,
|
|
pathContacts: pathContacts,
|
|
);
|
|
_points = <LatLng>[];
|
|
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
|
|
for (final hop in _traceData!.pathData) {
|
|
final contact = _traceData!.pathContacts[hop];
|
|
if (contact != null &&
|
|
contact.hasLocation &&
|
|
contact.latitude != null &&
|
|
contact.longitude != null) {
|
|
_points.add(LatLng(contact.latitude!, contact.longitude!));
|
|
}
|
|
}
|
|
_polylines = _points.length > 1
|
|
? [
|
|
Polyline(
|
|
points: _points,
|
|
strokeWidth: 4,
|
|
color: Colors.blueAccent,
|
|
),
|
|
]
|
|
: <Polyline>[];
|
|
|
|
_initialCenter = _points.isNotEmpty
|
|
? _points.first
|
|
: const LatLng(0, 0);
|
|
_initialZoom = _points.isNotEmpty ? 13.0 : 2.0;
|
|
_bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null;
|
|
_mapKey = ValueKey(
|
|
'${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}',
|
|
);
|
|
_pathDistanceMeters = getPathDistanceMeters(_points);
|
|
});
|
|
} catch (e) {
|
|
appLogger.error(
|
|
'Error handling trace response: $e',
|
|
tag: 'PathTraceMapScreen',
|
|
);
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
_failed2Loaded = true;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Consumer<MeshCoreConnector>(
|
|
builder: (context, connector, _) {
|
|
final tileCache = context.read<MapTileCacheService>();
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Text(
|
|
widget.title,
|
|
style: const TextStyle(fontSize: 24),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
centerTitle: false,
|
|
actions: [
|
|
IconButton(
|
|
icon: _isLoading
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.refresh),
|
|
onPressed: _isLoading ? null : _doPathTrace,
|
|
tooltip: context.l10n.pathTrace_refreshTooltip,
|
|
),
|
|
],
|
|
),
|
|
body: SafeArea(
|
|
top: false,
|
|
child: Stack(
|
|
children: [
|
|
if (!_hasData)
|
|
Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (_isLoading) const CircularProgressIndicator(),
|
|
const SizedBox(height: 16),
|
|
if (!_isLoading && _failed2Loaded)
|
|
Text(context.l10n.pathTrace_notAvailable),
|
|
],
|
|
),
|
|
),
|
|
if (_hasData) _buildMapPathTrace(context, tileCache),
|
|
if (_points.isEmpty &&
|
|
!_hasData &&
|
|
!_isLoading &&
|
|
!_failed2Loaded)
|
|
Center(
|
|
child: Card(
|
|
color: Colors.white.withValues(alpha: 0.9),
|
|
child: Padding(
|
|
padding: EdgeInsets.all(12),
|
|
child: Text(
|
|
context.l10n.channelPath_noRepeaterLocations,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (_hasData) _buildLegendCard(context, _traceData!),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
List<Marker> _buildHopMarkers(List<int> pathData) {
|
|
return [
|
|
for (final hop in pathData)
|
|
if (_traceData!.pathContacts[hop] != null &&
|
|
_traceData!.pathContacts[hop]!.hasLocation)
|
|
Marker(
|
|
point: LatLng(
|
|
_traceData!.pathContacts[hop]!.latitude!,
|
|
_traceData!.pathContacts[hop]!.longitude!,
|
|
),
|
|
width: 35,
|
|
height: 35,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.green,
|
|
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(
|
|
_traceData!.pathContacts[hop]!.publicKey
|
|
.sublist(0, 1)
|
|
.map(
|
|
(b) => b.toRadixString(16).padLeft(2, '0').toUpperCase(),
|
|
)
|
|
.join(),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (context.read<MeshCoreConnector>().selfLatitude != null &&
|
|
context.read<MeshCoreConnector>().selfLongitude != null)
|
|
Marker(
|
|
point: LatLng(
|
|
context.read<MeshCoreConnector>().selfLatitude!,
|
|
context.read<MeshCoreConnector>().selfLongitude!,
|
|
),
|
|
width: 35,
|
|
height: 35,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue,
|
|
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.white,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
String formatDirectionText(PathTraceData pathTraceData, int index) {
|
|
if (index == 0 || index == pathTraceData.snrData.length - 1) {
|
|
if (index == 0) {
|
|
return context.l10n.pathTrace_you;
|
|
} else {
|
|
final contactName = pathTraceData
|
|
.pathContacts[pathTraceData.pathData[pathTraceData.pathData.length -
|
|
1]]
|
|
?.name;
|
|
final hex = pathTraceData.pathData[pathTraceData.pathData.length - 1]
|
|
.toRadixString(16)
|
|
.padLeft(2, '0')
|
|
.toUpperCase();
|
|
return contactName != null
|
|
? "$hex: $contactName"
|
|
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
|
}
|
|
} else {
|
|
final contactName =
|
|
pathTraceData.pathContacts[pathTraceData.pathData[index - 1]]?.name;
|
|
final hex = pathTraceData.pathData[index - 1]
|
|
.toRadixString(16)
|
|
.padLeft(2, '0')
|
|
.toUpperCase();
|
|
return contactName != null
|
|
? "$hex: $contactName"
|
|
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
|
}
|
|
}
|
|
|
|
String formatDirectionSubText(PathTraceData pathTraceData, int index) {
|
|
if (index == 0 || index == pathTraceData.snrData.length - 1) {
|
|
if (index == 0) {
|
|
final contactName =
|
|
pathTraceData.pathContacts[pathTraceData.pathData[0]]?.name;
|
|
final hex = pathTraceData.pathData[0]
|
|
.toRadixString(16)
|
|
.padLeft(2, '0')
|
|
.toUpperCase();
|
|
return contactName != null
|
|
? "$hex: $contactName"
|
|
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
|
} else {
|
|
return context.l10n.pathTrace_you;
|
|
}
|
|
} else {
|
|
final contactName =
|
|
pathTraceData.pathContacts[pathTraceData.pathData[index]]?.name;
|
|
final hex = pathTraceData.pathData[index]
|
|
.toRadixString(16)
|
|
.padLeft(2, '0')
|
|
.toUpperCase();
|
|
return contactName != null
|
|
? "$hex: $contactName"
|
|
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
|
}
|
|
}
|
|
|
|
Widget _buildMapPathTrace(
|
|
BuildContext context,
|
|
MapTileCacheService tileCache,
|
|
) {
|
|
return FlutterMap(
|
|
key: _mapKey,
|
|
options: MapOptions(
|
|
interactionOptions: InteractionOptions(flags: ~InteractiveFlag.rotate),
|
|
initialCenter: _initialCenter!,
|
|
initialZoom: _initialZoom,
|
|
initialCameraFit: _bounds == null
|
|
? null
|
|
: CameraFit.bounds(
|
|
bounds: _bounds!,
|
|
padding: const EdgeInsets.all(64),
|
|
maxZoom: 16,
|
|
),
|
|
minZoom: 2.0,
|
|
maxZoom: 18.0,
|
|
),
|
|
children: [
|
|
TileLayer(
|
|
urlTemplate: kMapTileUrlTemplate,
|
|
tileProvider: tileCache.tileProvider,
|
|
userAgentPackageName: MapTileCacheService.userAgentPackageName,
|
|
maxZoom: 19,
|
|
),
|
|
if (_polylines.isNotEmpty) PolylineLayer(polylines: _polylines),
|
|
if (_traceData!.pathData.isNotEmpty)
|
|
MarkerLayer(markers: _buildHopMarkers(_traceData!.pathData)),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildLegendCard(BuildContext context, PathTraceData pathTraceData) {
|
|
final l10n = context.l10n;
|
|
final maxHeight = MediaQuery.of(context).size.height * 0.35;
|
|
final estimatedHeight = 72.0 + (pathTraceData.pathData.length * 56.0);
|
|
final cardHeight = max(96.0, min(maxHeight, estimatedHeight));
|
|
|
|
return Positioned(
|
|
left: 16,
|
|
right: 16,
|
|
bottom: 16,
|
|
child: SizedBox(
|
|
height: cardHeight,
|
|
child: Card(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Text(
|
|
'${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistanceMeters)}',
|
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
const Divider(height: 1),
|
|
Expanded(
|
|
child: pathTraceData.pathData.isEmpty
|
|
? Center(
|
|
child: Text(l10n.channelPath_noHopDetailsAvailable),
|
|
)
|
|
: Scrollbar(
|
|
child: ListView.separated(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
itemCount: pathTraceData.pathData.length + 1,
|
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
|
itemBuilder: (context, index) {
|
|
final snrUi = snrUiFromSNR(
|
|
index < pathTraceData.snrData.length
|
|
? pathTraceData.snrData[index]
|
|
: null,
|
|
context.read<MeshCoreConnector>().currentSf,
|
|
);
|
|
return Column(
|
|
children: [
|
|
ListTile(
|
|
leading:
|
|
index >= pathTraceData.snrData.length / 2
|
|
? Icon(Icons.call_received)
|
|
: Icon(Icons.call_made),
|
|
title: Text(
|
|
formatDirectionText(pathTraceData, index),
|
|
style: const TextStyle(fontSize: 14),
|
|
),
|
|
subtitle: Text(
|
|
formatDirectionSubText(
|
|
pathTraceData,
|
|
index,
|
|
),
|
|
style: const TextStyle(fontSize: 14),
|
|
),
|
|
trailing: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
snrUi.icon,
|
|
color: snrUi.color,
|
|
size: 18.0,
|
|
),
|
|
Text(
|
|
snrUi.text,
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: snrUi.color,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
onTap: () {
|
|
// Handle item tap
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|