Files
meshcore-sar_android/lib/screens/live_traffic_screen.dart
2026-03-12 20:54:04 +01:00

1014 lines
30 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart';
import '../models/ble_packet_log.dart';
import '../models/contact.dart';
import '../providers/contacts_provider.dart';
import '../providers/connection_provider.dart';
import '../services/live_traffic_summary.dart';
import '../services/location_tracking_service.dart';
import '../services/route_hash_preferences.dart';
import '../utils/log_rx_route_decoder.dart';
import 'packet_log_screen.dart';
T? _maybeProvider<T>(BuildContext context) {
try {
return Provider.of<T>(context, listen: false);
} catch (_) {
return null;
}
}
class LiveTrafficScreen extends StatefulWidget {
final List<BlePacketLog> Function() logReader;
final int Function()? rxCountReader;
final Listenable? refreshListenable;
final DateTime Function() now;
final VoidCallback? openPacketLogs;
const LiveTrafficScreen({
super.key,
required this.logReader,
this.rxCountReader,
this.refreshListenable,
DateTime Function()? now,
this.openPacketLogs,
}) : now = now ?? DateTime.now;
factory LiveTrafficScreen.fromProvider(
ConnectionProvider provider, {
Key? key,
VoidCallback? openPacketLogs,
}) {
return LiveTrafficScreen(
key: key,
logReader: () => provider.bleService.packetLogs,
rxCountReader: () => provider.rxPacketCount,
refreshListenable: provider,
openPacketLogs: openPacketLogs,
);
}
@override
State<LiveTrafficScreen> createState() => _LiveTrafficScreenState();
}
class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
static const List<Duration> _windowOptions = [
Duration(minutes: 1),
Duration(minutes: 5),
Duration(minutes: 10),
Duration(minutes: 15),
Duration(minutes: 30),
Duration(minutes: 60),
];
Timer? _ticker;
DateTime? _clearedAt;
int _preferredHashSize = RouteHashPreferences.defaultHashSize;
Duration _selectedWindow = const Duration(minutes: 1);
String? _selectedPacketType;
@override
void initState() {
super.initState();
_loadPreferredHashSize();
widget.refreshListenable?.addListener(_handleRefresh);
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) {
setState(() {});
}
});
}
@override
void didUpdateWidget(covariant LiveTrafficScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.refreshListenable != widget.refreshListenable) {
oldWidget.refreshListenable?.removeListener(_handleRefresh);
widget.refreshListenable?.addListener(_handleRefresh);
}
}
@override
void dispose() {
widget.refreshListenable?.removeListener(_handleRefresh);
_ticker?.cancel();
super.dispose();
}
void _handleRefresh() {
if (!mounted) return;
setState(() {});
}
Future<void> _loadPreferredHashSize() async {
final hashSize = await RouteHashPreferences.getHashSize();
if (!mounted) return;
setState(() {
_preferredHashSize = hashSize;
});
}
@override
Widget build(BuildContext context) {
final unfilteredSnapshot = LiveTrafficSummary.fromLogs(
widget.logReader(),
now: widget.now(),
clearedAt: _clearedAt,
preferredHashSize: _preferredHashSize,
window: _selectedWindow,
);
final snapshot = LiveTrafficSummary.fromLogs(
widget.logReader(),
now: widget.now(),
clearedAt: _clearedAt,
preferredHashSize: _preferredHashSize,
window: _selectedWindow,
packetTypeFilter: _selectedPacketType,
);
final theme = Theme.of(context);
final contactsProvider = _maybeProvider<ContactsProvider>(context);
final routeHashCounts = _RouteHashCounts.fromContacts(
contactsProvider?.contacts ?? const <Contact>[],
);
final packetTypes = unfilteredSnapshot.visibleEntries
.map((entry) => entry.payloadLabel)
.toSet()
.toList()
..sort();
final filteredEntries = snapshot.visibleEntries;
return Scaffold(
appBar: AppBar(
title: const Text('Live Traffic'),
actions: [
if (widget.openPacketLogs != null)
IconButton(
onPressed: widget.openPacketLogs,
tooltip: 'Open packet logs',
icon: const Icon(Icons.list_alt_rounded),
),
IconButton(
onPressed: () {
setState(() {
_clearedAt = widget.now();
});
},
tooltip: 'Clear live view',
icon: const Icon(Icons.cleaning_services_outlined),
),
],
),
body: CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
sliver: SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SummaryPanel(
snapshot: snapshot,
totalRxCount: widget.rxCountReader?.call(),
routeHashCounts: routeHashCounts,
onWindowTap: () => _showWindowPicker(context),
),
const SizedBox(height: 10),
_PacketTypeFilterBar(
packetTypes: packetTypes,
selectedType: _selectedPacketType,
onSelected: (value) {
setState(() {
_selectedPacketType = value;
});
},
),
],
),
),
),
if (filteredEntries.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.radar_rounded,
size: 64,
color: theme.colorScheme.primary.withValues(alpha: 0.45),
),
const SizedBox(height: 16),
const Text(
'No packets for this filter',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
unfilteredSnapshot.visibleEntries.isEmpty
? 'This view only shows in-memory traffic while it is active.'
: 'Try a different packet type or switch back to All.',
textAlign: TextAlign.center,
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
),
],
),
),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
sliver: SliverList.separated(
itemBuilder: (context, index) {
final entry = filteredEntries[index];
return _LiveTrafficCard(entry: entry, now: widget.now());
},
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemCount: filteredEntries.length,
),
),
],
),
);
}
Future<void> _showWindowPicker(BuildContext context) async {
final selected = await showModalBottomSheet<Duration>(
context: context,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: ListView(
shrinkWrap: true,
children: [
for (final option in _windowOptions)
ListTile(
title: Text(_windowLabel(option)),
trailing: option == _selectedWindow
? const Icon(Icons.check)
: null,
onTap: () => Navigator.of(context).pop(option),
),
],
),
);
},
);
if (selected == null || !mounted) return;
setState(() {
_selectedWindow = selected;
});
}
}
class _SummaryPanel extends StatelessWidget {
final LiveTrafficSnapshot snapshot;
final int? totalRxCount;
final _RouteHashCounts routeHashCounts;
final VoidCallback onWindowTap;
const _SummaryPanel({
required this.snapshot,
required this.totalRxCount,
required this.routeHashCounts,
required this.onWindowTap,
});
@override
Widget build(BuildContext context) {
final busynessColor = switch (snapshot.busyness) {
LiveTrafficBusyness.quiet => Colors.blueGrey,
LiveTrafficBusyness.active => Colors.orange,
LiveTrafficBusyness.busy => Colors.redAccent,
};
final busynessLabel = switch (snapshot.busyness) {
LiveTrafficBusyness.quiet => 'Quiet',
LiveTrafficBusyness.active => 'Active',
LiveTrafficBusyness.busy => 'Busy',
};
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
busynessColor.withValues(alpha: 0.18),
Theme.of(context).colorScheme.surfaceContainerHigh,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 6,
runSpacing: 6,
children: [
_SummaryBadge(
label: 'Mesh',
value: busynessLabel,
color: busynessColor,
),
_SummaryBadge(
label: 'Rate',
value: '${snapshot.packetsPerMinute} pkt/min',
color: Theme.of(context).colorScheme.primary,
),
_SummaryBadge(
label: 'Window',
value: _windowLabel(snapshot.windowDuration),
color: Theme.of(context).colorScheme.secondary,
onTap: onWindowTap,
),
],
),
const SizedBox(height: 14),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
_MetricTile(
label: 'RX packets',
value: '${snapshot.rxCount}',
subtitle: totalRxCount == null
? 'Last 60 sec'
: 'Device total $totalRxCount',
),
_MetricTile(
label: 'RSSI',
value: snapshot.latestRssiDbm == null
? 'No RX data'
: '${snapshot.latestRssiDbm} dBm',
subtitle: snapshot.avgRssiDbm == null
? 'No average yet'
: 'Avg ${snapshot.avgRssiDbm!.toStringAsFixed(1)} dBm',
),
_MetricTile(
label: 'SNR',
value: snapshot.latestSnrDb == null
? 'No RX data'
: '${snapshot.latestSnrDb!.toStringAsFixed(1)} dB',
subtitle: snapshot.avgSnrDb == null
? 'No average yet'
: 'Avg ${snapshot.avgSnrDb!.toStringAsFixed(1)} dB',
),
_MetricTile(
label: 'Multi-hop',
value: '${snapshot.multiHopCount}',
subtitle: routeHashCounts.summaryLabel,
footer: snapshot.avgHopCount == null
? 'No routes yet'
: 'Avg ${snapshot.avgHopCount!.toStringAsFixed(1)} hops',
),
],
),
],
),
);
}
}
class _SummaryBadge extends StatelessWidget {
final String label;
final String value;
final Color color;
final VoidCallback? onTap;
const _SummaryBadge({
required this.label,
required this.value,
required this.color,
this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 6),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.35)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$label ',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.w800, fontSize: 12),
),
],
),
),
);
}
}
class _PacketTypeFilterBar extends StatelessWidget {
final List<String> packetTypes;
final String? selectedType;
final ValueChanged<String?> onSelected;
const _PacketTypeFilterBar({
required this.packetTypes,
required this.selectedType,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_FilterChip(
label: 'All',
selected: selectedType == null,
onTap: () => onSelected(null),
),
for (final type in packetTypes) ...[
const SizedBox(width: 8),
_FilterChip(
label: type,
selected: selectedType == type,
onTap: () => onSelected(type),
),
],
],
),
);
}
}
class _FilterChip extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onTap;
const _FilterChip({
required this.label,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final color = selected ? scheme.primary : scheme.outline;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: selected ? 0.14 : 0.08),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: selected ? 0.45 : 0.22)),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: color,
),
),
),
);
}
}
String _windowLabel(Duration duration) {
if (duration.inMinutes >= 60) {
return '${duration.inMinutes} min';
}
return '${duration.inMinutes} min';
}
class _MetricTile extends StatelessWidget {
final String label;
final String value;
final String subtitle;
final String? footer;
const _MetricTile({
required this.label,
required this.value,
required this.subtitle,
this.footer,
});
@override
Widget build(BuildContext context) {
return Container(
width: 160,
height: 128,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 6),
Text(
value,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
),
const SizedBox(height: 4),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
if (footer != null) ...[
const SizedBox(height: 2),
Text(
footer!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
),
),
],
),
);
}
}
class _RouteHashCounts {
final int oneByte;
final int twoByte;
final int threeByte;
const _RouteHashCounts({
required this.oneByte,
required this.twoByte,
required this.threeByte,
});
factory _RouteHashCounts.fromContacts(List<Contact> contacts) {
var oneByte = 0;
var twoByte = 0;
var threeByte = 0;
for (final contact in contacts) {
if (!contact.routeHasPath || contact.routeHopCount <= 0) continue;
switch (contact.routeHashSize) {
case 1:
oneByte += 1;
break;
case 2:
twoByte += 1;
break;
case 3:
threeByte += 1;
break;
}
}
return _RouteHashCounts(
oneByte: oneByte,
twoByte: twoByte,
threeByte: threeByte,
);
}
String get summaryLabel => '1b:$oneByte 2b:$twoByte 3b:$threeByte';
}
class _LiveTrafficCard extends StatelessWidget {
final LiveTrafficEntry entry;
final DateTime now;
const _LiveTrafficCard({required this.entry, required this.now});
@override
Widget build(BuildContext context) {
final log = entry.log;
final isRx = log.direction == PacketDirection.rx;
final accent = isRx ? Colors.green : Colors.blue;
final rxInfo = log.logRxDataInfo;
final routePreview = _resolvedRoutePreview(context, entry);
final originDistance = _originDistanceLabel(context, entry);
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: accent.withValues(alpha: 0.25)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(999),
),
child: Text(
isRx ? 'RX' : 'TX',
style: TextStyle(
color: accent,
fontWeight: FontWeight.w800,
fontSize: 12,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.payloadLabel,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
if (entry.payloadMeaning != null)
Text(
entry.payloadMeaning!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
Text(
_timeAgo(log.timestamp, now),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_PacketMetaChip(
label: '${log.rawData.length} bytes',
onTap: () => _showPacketBytesSheet(context, log.rawData),
),
if (rxInfo?.rssiDbm != null)
_PacketMetaChip(label: 'RSSI ${rxInfo!.rssiDbm} dBm'),
if (rxInfo?.snrDb != null)
_PacketMetaChip(
label: 'SNR ${rxInfo!.snrDb!.toStringAsFixed(1)} dB',
),
if (entry.hopCount != null)
_PacketMetaChip(label: '${entry.hopCount} hops'),
if (entry.isMultiHop)
const _PacketMetaChip(label: 'MULTI-HOP', emphasized: true),
if (originDistance != null)
_PacketMetaChip(label: 'Origin $originDistance'),
],
),
const SizedBox(height: 10),
Text(
routePreview,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
static String _timeAgo(DateTime timestamp, DateTime now) {
final diff = now.difference(timestamp);
if (diff.inSeconds < 60) return '${diff.inSeconds}s ago';
return '${diff.inMinutes}m ago';
}
static String _resolvedRoutePreview(
BuildContext context,
LiveTrafficEntry entry,
) {
final route = entry.route;
if (route == null || route.hopHashes.isEmpty) {
return entry.routePreview;
}
final contactsProvider = _maybeProvider<ContactsProvider>(context);
final connectionProvider = _maybeProvider<ConnectionProvider>(context);
if (contactsProvider == null && connectionProvider == null) {
return entry.routePreview;
}
final ownLatLng = _ownLatLng(connectionProvider);
final resolvedLabels = route.hopHashes.map((hashHex) {
final resolved = LogRxRouteDecoder.resolveHash(
hashHex,
contacts: contactsProvider?.contacts ?? const <Contact>[],
ownPublicKey: connectionProvider?.deviceInfo.publicKey,
ownName:
connectionProvider?.deviceInfo.selfName ??
connectionProvider?.deviceInfo.displayName,
ownLatitude: ownLatLng?.latitude,
ownLongitude: ownLatLng?.longitude,
);
return _compactNodeLabel(resolved);
}).toList();
return resolvedLabels.join(' -> ');
}
static String? _originDistanceLabel(
BuildContext context,
LiveTrafficEntry entry,
) {
final route = entry.route;
if (route == null || route.hopHashes.isEmpty) {
return null;
}
final contactsProvider = _maybeProvider<ContactsProvider>(context);
final connectionProvider = _maybeProvider<ConnectionProvider>(context);
final ownLatLng = _ownLatLng(connectionProvider);
final resolved = LogRxRouteDecoder.resolveHash(
route.hopHashes.first,
contacts: contactsProvider?.contacts ?? const <Contact>[],
ownPublicKey: connectionProvider?.deviceInfo.publicKey,
ownName:
connectionProvider?.deviceInfo.selfName ??
connectionProvider?.deviceInfo.displayName,
ownLatitude: ownLatLng?.latitude,
ownLongitude: ownLatLng?.longitude,
);
if (resolved.latitude == null || resolved.longitude == null) {
return null;
}
final currentPosition = LocationTrackingService().currentPosition;
final originDistanceMeters = currentPosition != null
? Geolocator.distanceBetween(
currentPosition.latitude,
currentPosition.longitude,
resolved.latitude!,
resolved.longitude!,
)
: ownLatLng != null
? Geolocator.distanceBetween(
ownLatLng.latitude,
ownLatLng.longitude,
resolved.latitude!,
resolved.longitude!,
)
: null;
if (originDistanceMeters == null) {
return null;
}
return _formatDistance(originDistanceMeters);
}
static _GeoPoint? _ownLatLng(ConnectionProvider? connectionProvider) {
final advLat = connectionProvider?.deviceInfo.advLat;
final advLon = connectionProvider?.deviceInfo.advLon;
if (advLat == null || advLon == null || (advLat == 0 && advLon == 0)) {
return null;
}
return _GeoPoint(advLat / 1e6, advLon / 1e6);
}
static String _formatDistance(double meters) {
if (meters < 1000) {
return '${meters.round()}m';
}
return '${(meters / 1000).toStringAsFixed(1)}km';
}
static Future<void> _showPacketBytesSheet(
BuildContext context,
List<int> bytes,
) {
final data = bytes.toList(growable: false);
final hexLines = <String>[];
const bytesPerLine = 16;
for (var offset = 0; offset < data.length; offset += bytesPerLine) {
final chunk = data.skip(offset).take(bytesPerLine).toList();
final hex = chunk
.map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(' ');
hexLines.add('${offset.toRadixString(16).padLeft(4, '0').toUpperCase()}: $hex');
}
final ascii = data
.map((byte) => (byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.')
.join();
return showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (context) {
final scheme = Theme.of(context).colorScheme;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Packet Bytes',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 6),
Text(
'${data.length} bytes',
style: TextStyle(color: scheme.onSurfaceVariant),
),
const SizedBox(height: 16),
Text(
'Hex',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(14),
),
child: SelectableText(
hexLines.join('\n'),
style: TextStyle(
fontSize: 12,
height: 1.45,
fontFamily: 'monospace',
color: scheme.onSurface,
),
),
),
const SizedBox(height: 16),
Text(
'ASCII',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(14),
),
child: SelectableText(
ascii.isEmpty ? '(empty)' : ascii,
style: TextStyle(
fontSize: 12,
height: 1.45,
fontFamily: 'monospace',
color: scheme.onSurface,
),
),
),
],
),
),
),
);
},
);
}
static String _compactNodeLabel(ResolvedNodeHash node) {
if (node.isOwnNode) {
return node.label;
}
if (node.matchCount > 0) {
return node.label;
}
return node.hexLabel;
}
}
class _GeoPoint {
final double latitude;
final double longitude;
const _GeoPoint(this.latitude, this.longitude);
}
class _PacketMetaChip extends StatelessWidget {
final String label;
final bool emphasized;
final VoidCallback? onTap;
const _PacketMetaChip({
required this.label,
this.emphasized = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final color = emphasized ? scheme.primary : scheme.outline;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: color.withValues(alpha: emphasized ? 0.12 : 0.08),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.22)),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
),
);
}
}
void openLiveTrafficScreen(BuildContext context, ConnectionProvider provider) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => LiveTrafficScreen.fromProvider(
provider,
openPacketLogs: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PacketLogScreen(bleService: provider.bleService),
),
);
},
),
),
);
}