import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:share_plus/share_plus.dart'; import 'dart:io'; import 'package:path_provider/path_provider.dart'; import '../models/ble_packet_log.dart'; import '../services/meshcore_ble_service.dart'; class PacketLogScreen extends StatefulWidget { final MeshCoreBleService bleService; const PacketLogScreen({ super.key, required this.bleService, }); @override State createState() => _PacketLogScreenState(); } class _PacketLogScreenState extends State { bool _autoScroll = true; final ScrollController _scrollController = ScrollController(); String _searchQuery = ''; PacketDirection? _filterDirection; @override void dispose() { _scrollController.dispose(); super.dispose(); } List get _filteredLogs { var logs = widget.bleService.packetLogs; // Filter by direction if (_filterDirection != null) { logs = logs.where((log) => log.direction == _filterDirection).toList(); } // Filter by search query if (_searchQuery.isNotEmpty) { final query = _searchQuery.toLowerCase(); logs = logs.where((log) { return log.hexData.toLowerCase().contains(query) || (log.description?.toLowerCase().contains(query) ?? false) || log.summary.toLowerCase().contains(query); }).toList(); } return logs; } Future _exportLogs(BuildContext context) async { try { final logs = _filteredLogs; if (logs.isEmpty) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('No logs to export')), ); } return; } // Create CSV content final buffer = StringBuffer(); buffer.writeln('Timestamp,Direction,Size (bytes),Opcode Name,Code,Hex Data,Description'); for (final log in logs) { buffer.writeln(log.toCsvRow()); } // Save to temporary file final tempDir = await getTemporaryDirectory(); final file = File('${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.csv'); await file.writeAsString(buffer.toString()); // Share the file await Share.shareXFiles( [XFile(file.path)], subject: 'MeshCore BLE Packet Logs', text: 'Exported ${logs.length} BLE packets from MeshCore SAR app', ); } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Export failed: $e')), ); } } } Future _exportAsText(BuildContext context) async { try { final logs = _filteredLogs; if (logs.isEmpty) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('No logs to export')), ); } return; } // Create text content final buffer = StringBuffer(); buffer.writeln('MeshCore BLE Packet Logs'); buffer.writeln('=' * 80); buffer.writeln('Exported: ${DateTime.now().toIso8601String()}'); buffer.writeln('Total packets: ${logs.length}'); buffer.writeln('=' * 80); buffer.writeln(); for (final log in logs) { buffer.writeln(log.toLogString()); } // Save to temporary file final tempDir = await getTemporaryDirectory(); final file = File('${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.txt'); await file.writeAsString(buffer.toString()); // Share the file await Share.shareXFiles( [XFile(file.path)], subject: 'MeshCore BLE Packet Logs', text: 'Exported ${logs.length} BLE packets from MeshCore SAR app', ); } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Export failed: $e')), ); } } } void _copyToClipboard(BuildContext context, BlePacketLog log) { Clipboard.setData(ClipboardData(text: log.hexData)); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Hex data copied to clipboard'), duration: Duration(seconds: 1), ), ); } void _clearLogs(BuildContext context) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Clear Packet Logs'), content: const Text('Are you sure you want to clear all packet logs? This cannot be undone.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), TextButton( onPressed: () { widget.bleService.clearPacketLogs(); Navigator.pop(context); setState(() {}); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Packet logs cleared')), ); }, style: TextButton.styleFrom(foregroundColor: Colors.red), child: const Text('Clear'), ), ], ), ); } @override Widget build(BuildContext context) { final logs = _filteredLogs; return Scaffold( appBar: AppBar( title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('BLE Packet Logs'), Text( '${logs.length} packets', style: Theme.of(context).textTheme.bodySmall, ), ], ), actions: [ // Direction filter PopupMenuButton( icon: Icon(_filterDirection == null ? Icons.filter_list : _filterDirection == PacketDirection.rx ? Icons.arrow_downward : Icons.arrow_upward), tooltip: 'Filter by direction', onSelected: (direction) { setState(() { _filterDirection = direction; }); }, itemBuilder: (context) => [ PopupMenuItem( value: null, child: Row( children: [ Icon(Icons.filter_list, color: _filterDirection == null ? Theme.of(context).colorScheme.primary : null), const SizedBox(width: 8), Text('All', style: TextStyle( fontWeight: _filterDirection == null ? FontWeight.bold : FontWeight.normal)), ], ), ), PopupMenuItem( value: PacketDirection.rx, child: Row( children: [ Icon(Icons.arrow_downward, color: _filterDirection == PacketDirection.rx ? Theme.of(context).colorScheme.primary : null), const SizedBox(width: 8), Text('RX (Received)', style: TextStyle( fontWeight: _filterDirection == PacketDirection.rx ? FontWeight.bold : FontWeight.normal)), ], ), ), PopupMenuItem( value: PacketDirection.tx, child: Row( children: [ Icon(Icons.arrow_upward, color: _filterDirection == PacketDirection.tx ? Theme.of(context).colorScheme.primary : null), const SizedBox(width: 8), Text('TX (Sent)', style: TextStyle( fontWeight: _filterDirection == PacketDirection.tx ? FontWeight.bold : FontWeight.normal)), ], ), ), ], ), // Auto-scroll toggle IconButton( icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center), tooltip: _autoScroll ? 'Disable auto-scroll' : 'Enable auto-scroll', onPressed: () { setState(() { _autoScroll = !_autoScroll; }); }, ), // Export menu PopupMenuButton( icon: const Icon(Icons.share), tooltip: 'Export logs', itemBuilder: (context) => [ const PopupMenuItem( value: 'csv', child: Row( children: [ Icon(Icons.table_chart), SizedBox(width: 8), Text('Export as CSV'), ], ), ), const PopupMenuItem( value: 'txt', child: Row( children: [ Icon(Icons.text_snippet), SizedBox(width: 8), Text('Export as Text'), ], ), ), ], onSelected: (value) { if (value == 'csv') { _exportLogs(context); } else if (value == 'txt') { _exportAsText(context); } }, ), // Clear logs IconButton( icon: const Icon(Icons.delete_outline), tooltip: 'Clear logs', onPressed: () => _clearLogs(context), ), ], ), body: Column( children: [ // Search bar Padding( padding: const EdgeInsets.all(8.0), child: TextField( decoration: InputDecoration( hintText: 'Search logs...', prefixIcon: const Icon(Icons.search), suffixIcon: _searchQuery.isNotEmpty ? IconButton( icon: const Icon(Icons.clear), onPressed: () { setState(() { _searchQuery = ''; }); }, ) : null, border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), ), onChanged: (value) { setState(() { _searchQuery = value; }); }, ), ), // Logs list Expanded( child: logs.isEmpty ? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.list_alt, size: 64, color: Colors.grey[400], ), const SizedBox(height: 16), Text( _searchQuery.isNotEmpty || _filterDirection != null ? 'No matching packets found' : 'No packets logged yet', style: TextStyle( fontSize: 16, color: Colors.grey[600], ), ), if (_searchQuery.isNotEmpty || _filterDirection != null) ...[ const SizedBox(height: 8), TextButton.icon( onPressed: () { setState(() { _searchQuery = ''; _filterDirection = null; }); }, icon: const Icon(Icons.clear_all), label: const Text('Clear filters'), ), ], ], ), ) : ListView.builder( controller: _scrollController, itemCount: logs.length, itemBuilder: (context, index) { final log = logs[index]; // Auto-scroll to bottom if (_autoScroll && index == logs.length - 1) { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); } }); } return _PacketLogCard( log: log, onCopy: () => _copyToClipboard(context, log), ); }, ), ), ], ), ); } } class _PacketLogCard extends StatelessWidget { final BlePacketLog log; final VoidCallback onCopy; const _PacketLogCard({ required this.log, required this.onCopy, }); @override Widget build(BuildContext context) { final isRx = log.direction == PacketDirection.rx; final directionColor = isRx ? Colors.green : Colors.blue; return Card( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: ExpansionTile( leading: CircleAvatar( backgroundColor: directionColor.withOpacity(0.2), child: Icon( isRx ? Icons.arrow_downward : Icons.arrow_upward, color: directionColor, size: 20, ), ), title: Row( children: [ Text( isRx ? 'RX' : 'TX', style: TextStyle( fontWeight: FontWeight.bold, color: directionColor, fontSize: 12, ), ), const SizedBox(width: 8), Flexible( child: Text( log.responseCode != null ? log.opcodeName : 'N/A', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, ), overflow: TextOverflow.ellipsis, ), ), ], ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 4), Text( '${log.rawData.length} bytes • ${_formatTimestamp(log.timestamp)}', style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), ], ), children: [ Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Hex data Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Hex: ', style: TextStyle( fontWeight: FontWeight.bold, color: Colors.grey[700], ), ), Expanded( child: SelectableText( log.hexData, style: const TextStyle( fontFamily: 'monospace', fontSize: 12, ), ), ), IconButton( icon: const Icon(Icons.copy, size: 18), tooltip: 'Copy hex data', onPressed: onCopy, padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), ], ), const SizedBox(height: 8), // Metadata Wrap( spacing: 16, runSpacing: 8, children: [ _InfoChip( icon: Icons.schedule, label: log.timestamp.toIso8601String(), ), _InfoChip( icon: Icons.data_usage, label: '${log.rawData.length} bytes', ), if (log.responseCode != null) _InfoChip( icon: Icons.tag, label: log.opcodeDescription, ), // Show RSSI and SNR for LOG_RX_DATA packets if (log.logRxDataInfo?.rssiDbm != null) _InfoChip( icon: Icons.signal_cellular_alt, label: 'RSSI: ${log.logRxDataInfo!.rssiDbm} dBm', ), if (log.logRxDataInfo?.snrDb != null) _InfoChip( icon: Icons.waves, label: 'SNR: ${log.logRxDataInfo!.snrDb!.toStringAsFixed(1)} dB', ), ], ), ], ), ), ], ), ); } String _formatTimestamp(DateTime timestamp) { final now = DateTime.now(); final diff = now.difference(timestamp); if (diff.inSeconds < 60) { return '${diff.inSeconds}s ago'; } else if (diff.inMinutes < 60) { return '${diff.inMinutes}m ago'; } else if (diff.inHours < 24) { return '${diff.inHours}h ago'; } else { return '${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}:${timestamp.second.toString().padLeft(2, '0')}'; } } } class _InfoChip extends StatelessWidget { final IconData icon; final String label; const _InfoChip({ required this.icon, required this.label, }); @override Widget build(BuildContext context) { return Chip( avatar: Icon(icon, size: 16), label: Text( label, style: const TextStyle(fontSize: 11), ), padding: const EdgeInsets.all(4), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ); } }