import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/device.dart'; import '../models/static_talkgroup.dart'; import '../models/device_profile.dart'; import '../models/last_heard_item.dart'; import '../services/authentication_manager.dart'; import '../services/brandmeister_client.dart'; import '../services/brandmeister_websocket_client.dart'; import '../services/lastheard_websocket_client.dart'; import 'link_talkgroup_view.dart'; class DeviceDetailView extends StatefulWidget { final Device device; const DeviceDetailView({super.key, required this.device}); @override State createState() => _DeviceDetailViewState(); } class _DeviceDetailViewState extends State { List _talkgroups = []; Map _allTalkgroups = {}; DeviceProfile? _deviceProfile; bool _isLoadingTalkgroups = false; bool _isLoadingDeviceDetails = false; String? _errorMessage; BrandmeisterWebSocketClient? _wsClient; StreamSubscription>? _wsSubscription; int? _autoStaticTalkgroup; LastHeardWebSocketClient? _lhWsClient; StreamSubscription>? _lhWsSubscription; final List _lastHeardItems = []; static const int _maxLastHeardItems = 20; @override void initState() { super.initState(); _loadData(); _connectWebSocket(); _connectLastHeardWebSocket(); } @override void dispose() { _wsSubscription?.cancel(); _wsClient?.dispose(); _lhWsSubscription?.cancel(); _lhWsClient?.dispose(); super.dispose(); } Future _connectWebSocket() async { _wsClient = BrandmeisterWebSocketClient(); await _wsClient!.connect(); // Wait for the open packet (0{...}) to be received await _wsClient!.ready; // Request device status after connection is ready _wsClient!.getDeviceStatus(widget.device.id.toString()); // Listen for device status updates _wsSubscription = _wsClient!.messageStream.listen((message) { if (message['event'] == 'deviceStatus' && message['data'] is List) { _handleDeviceStatus(message['data'] as List); } }); } void _handleDeviceStatus(List deviceStatusList) { if (deviceStatusList.isEmpty) return; for (final status in deviceStatusList) { if (status is Map) { final number = status['number']; if (number == widget.device.id) { final values = status['values'] as List?; if (values != null && values.length >= 20) { final autoStaticValue = values[19]; if (autoStaticValue != null && autoStaticValue != 0 && autoStaticValue != 4000) { setState(() { _autoStaticTalkgroup = autoStaticValue as int; }); } else { setState(() { _autoStaticTalkgroup = null; }); } } break; } } } } Future _connectLastHeardWebSocket() async { try { _lhWsClient = LastHeardWebSocketClient(); _lhWsClient!.connect(); _lhWsSubscription = _lhWsClient!.messageStream.listen((message) { if (message['event'] == 'mqtt' && message['data'] is Map) { _handleLastHeardMessage(message['data'] as Map); } }); } catch (e) { debugPrint('DeviceDetailView: Error connecting to Last Heard WebSocket: $e'); } } void _handleLastHeardMessage(Map data) { try { final topic = data['topic'] as String?; if (topic == 'LH') { final payloadStr = data['payload'] as String?; if (payloadStr == null) return; final payload = jsonDecode(payloadStr) as Map; final destinationId = payload['DestinationID'] as int?; if (destinationId == null) return; // Get all linked talkgroup IDs (static + auto-static + dynamic) final linkedTalkgroupIds = {}; // Add static talkgroups for (final tg in _deviceProfile?.staticSubscriptions ?? []) { if (tg.talkgroup != null) { final tgId = int.tryParse(tg.talkgroup!); if (tgId != null) { linkedTalkgroupIds.add(tgId); } } } // Add dynamic/auto-static talkgroups from device profile for (final tg in _deviceProfile?.dynamicSubscriptions ?? []) { if (tg.talkgroup != null) { final tgId = int.tryParse(tg.talkgroup!); if (tgId != null) { linkedTalkgroupIds.add(tgId); } } } // Add auto-static talkgroup from WebSocket (if available) if (_autoStaticTalkgroup != null) { linkedTalkgroupIds.add(_autoStaticTalkgroup!); } debugPrint('DeviceDetailView: Linked talkgroups: $linkedTalkgroupIds, Destination: $destinationId'); // Only process if this message is for one of our linked talkgroups if (linkedTalkgroupIds.contains(destinationId)) { debugPrint('DeviceDetailView: Match found! Adding to last heard list'); final item = LastHeardItem.fromJson(payload); debugPrint('DeviceDetailView: Item - TalkerAlias: ${item.talkerAlias}, SourceCall: ${item.sourceCall}, DestName: ${item.destinationName}, DestCall: ${item.destinationCall}'); if (item.sourceCall.isEmpty) { debugPrint('DeviceDetailView: Ignoring item with empty SourceCall'); return; } if (mounted) { setState(() { // Remove any existing item with the same sourceID _lastHeardItems.removeWhere((existing) => existing.sourceID == item.sourceID); // Add the new item at the beginning _lastHeardItems.insert(0, item); // Keep only the latest 20 items if (_lastHeardItems.length > _maxLastHeardItems) { _lastHeardItems.removeRange(_maxLastHeardItems, _lastHeardItems.length); } }); } } } } catch (e) { debugPrint('DeviceDetailView: Error handling Last Heard message: $e'); } } Future _loadData() async { setState(() { _isLoadingTalkgroups = true; _isLoadingDeviceDetails = true; _errorMessage = null; }); try { final authManager = context.read(); final results = await Future.wait([ authManager.getTalkgroups(widget.device.id), authManager.getAllTalkgroups(), authManager.getDeviceProfile(widget.device.id), ]); setState(() { _talkgroups = results[0] as List; _allTalkgroups = results[1] as Map; _deviceProfile = results[2] as DeviceProfile; _isLoadingTalkgroups = false; _isLoadingDeviceDetails = false; }); } on BrandmeisterError catch (e) { setState(() { _errorMessage = e.message; _isLoadingTalkgroups = false; _isLoadingDeviceDetails = false; }); } catch (e) { setState(() { _errorMessage = e.toString(); _isLoadingTalkgroups = false; _isLoadingDeviceDetails = false; }); } } Future _unlinkTalkgroup(StaticTalkgroup talkgroup) async { if (talkgroup.talkgroup == null || talkgroup.slot == null) { return; } try { final authManager = context.read(); await authManager.unlinkTalkgroup( talkgroupId: talkgroup.talkgroup!, dmrId: widget.device.id, timeslot: talkgroup.slot!, ); await _loadData(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Talkgroup unlinked successfully')), ); } } on BrandmeisterError catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Failed to unlink: ${e.message}')), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error: ${e.toString()}')), ); } } } Future _unlinkAutoStatic() async { try { final authManager = context.read(); await authManager.dropAutoStaticGroup(widget.device.id); setState(() { _autoStaticTalkgroup = null; }); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Auto-static talkgroup unlinked successfully')), ); } } on BrandmeisterError catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Failed to unlink auto-static: ${e.message}')), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error: ${e.toString()}')), ); } } } void _showLinkTalkgroupSheet() { showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), child: LinkTalkgroupView( device: widget.device, onSuccess: () { Navigator.pop(context); _loadData(); }, ), ), ); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.device.callsign ?? 'Device Details'), ), floatingActionButton: FloatingActionButton( onPressed: _showLinkTalkgroupSheet, child: const Icon(Icons.add), ), body: RefreshIndicator( onRefresh: _loadData, child: _buildBody(), ), ); } Widget _buildBody() { if ((_isLoadingTalkgroups || _isLoadingDeviceDetails) && _talkgroups.isEmpty) { return const Center( child: CircularProgressIndicator(), ); } if (_errorMessage != null) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.error_outline, size: 64, color: Colors.red[300], ), const SizedBox(height: 16), Text( 'Error', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Text( _errorMessage!, textAlign: TextAlign.center, style: TextStyle(color: Colors.grey[600]), ), ), const SizedBox(height: 24), ElevatedButton.icon( onPressed: _loadData, icon: const Icon(Icons.refresh), label: const Text('Retry'), ), ], ), ); } return ListView( children: [ _buildDeviceInfoSection(), const Divider(height: 1), if (_lastHeardItems.isNotEmpty) ...[ _buildLastHeardSection(), const Divider(height: 1), ], _buildTalkgroupsSection(), ], ); } Widget _buildDeviceInfoSection() { return Container( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Device Information', style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 16), _InfoRow( label: 'DMR ID', value: widget.device.id.toString(), ), if (widget.device.callsign != null) _InfoRow( label: 'Callsign', value: widget.device.callsign!, ), if (widget.device.name != null) _InfoRow( label: 'Name', value: widget.device.name!, ), _InfoRow( label: 'Location', value: widget.device.displayLocation, ), if (widget.device.hardware != null) _InfoRow( label: 'Hardware', value: widget.device.hardware!, ), if (widget.device.firmware != null) _InfoRow( label: 'Firmware', value: widget.device.firmware!, ), ], ), ); } Widget _buildLastHeardSection() { return Container( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Last Heard on Talkgroup', style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), ), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12), ), child: Text( '${_lastHeardItems.length}', style: TextStyle( color: Theme.of(context).colorScheme.onPrimaryContainer, fontSize: 12, fontWeight: FontWeight.bold, ), ), ), ], ), const SizedBox(height: 16), ...(_lastHeardItems.toList() ..sort((a, b) => b.timestamp.compareTo(a.timestamp))) .map((item) => Card( margin: const EdgeInsets.only(bottom: 8), child: ListTile( leading: CircleAvatar( backgroundColor: Theme.of(context).colorScheme.secondaryContainer, child: Text( 'TS${item.slot}', style: TextStyle( fontSize: 11, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSecondaryContainer, ), ), ), title: Text(item.talkerAlias ?? item.sourceCall), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('ID: ${item.sourceID}'), Text('TG: ${item.destinationID}'), Text( '${item.timeAgo} • ${item.linkTypeName}', style: TextStyle( fontSize: 11, color: Colors.grey[600], ), ), ], ), trailing: item.event == 'Session-Start' ? Icon( Icons.mic, color: Theme.of(context).colorScheme.primary, size: 20, ) : null, isThreeLine: true, ), )), ], ), ); } Widget _buildTalkgroupsSection() { final hasAnyTalkgroups = _talkgroups.isNotEmpty || (_deviceProfile?.staticSubscriptions.isNotEmpty ?? false) || (_deviceProfile?.dynamicSubscriptions.isNotEmpty ?? false) || (_deviceProfile?.timedSubscriptions.isNotEmpty ?? false) || (_deviceProfile?.blockedGroups.isNotEmpty ?? false) || _autoStaticTalkgroup != null; return Container( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Talkgroup Subscriptions', style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), ), if (_isLoadingTalkgroups) const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), ], ), const SizedBox(height: 16), if (!hasAnyTalkgroups) Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( children: [ Icon( Icons.speaker_group, size: 48, color: Colors.grey[400], ), const SizedBox(height: 8), Text( 'No talkgroups configured', style: TextStyle(color: Colors.grey[600]), ), ], ), ), ) else ...[ // Static Subscriptions if (_deviceProfile?.staticSubscriptions.isNotEmpty ?? false) ...[ _buildTalkgroupCategory( 'Static', _deviceProfile!.staticSubscriptions, Colors.blue, Icons.link, canDelete: true, ), const SizedBox(height: 16), ], // Auto-Static Talkgroup (from WebSocket) if (_autoStaticTalkgroup != null) ...[ _buildAutoStaticSection(), const SizedBox(height: 16), ], // Dynamic Subscriptions (Autostatic) if (_deviceProfile?.dynamicSubscriptions.isNotEmpty ?? false) ...[ _buildTalkgroupCategory( 'Dynamic / Autostatic', _deviceProfile!.dynamicSubscriptions, Colors.green, Icons.autorenew, canDelete: false, ), const SizedBox(height: 16), ], // Timed Subscriptions if (_deviceProfile?.timedSubscriptions.isNotEmpty ?? false) ...[ _buildTalkgroupCategory( 'Timed', _deviceProfile!.timedSubscriptions, Colors.orange, Icons.schedule, canDelete: false, ), const SizedBox(height: 16), ], // Blocked Groups if (_deviceProfile?.blockedGroups.isNotEmpty ?? false) ...[ _buildTalkgroupCategory( 'Blocked', _deviceProfile!.blockedGroups, Colors.red, Icons.block, canDelete: false, ), ], ], ], ), ); } Widget _buildAutoStaticSection() { const color = Colors.purple; final talkgroupName = _allTalkgroups[_autoStaticTalkgroup.toString()] ?? 'Unknown'; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.flash_on, size: 20, color: color), const SizedBox(width: 8), Text( 'Auto Static', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: color, ), ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: const Text( '1', style: TextStyle( color: color, fontSize: 12, fontWeight: FontWeight.bold, ), ), ), ], ), const SizedBox(height: 8), Card( margin: const EdgeInsets.only(bottom: 8), child: ListTile( leading: CircleAvatar( backgroundColor: color.withValues(alpha: 0.2), child: const Icon( Icons.flash_on, size: 20, color: color, ), ), title: Text(talkgroupName), subtitle: Text('ID: $_autoStaticTalkgroup'), trailing: IconButton( icon: const Icon(Icons.delete_outline), color: Colors.red, onPressed: () { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Unlink Auto-Static Talkgroup'), content: Text( 'Are you sure you want to unlink auto-static talkgroup $_autoStaticTalkgroup?', ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), TextButton( onPressed: () { Navigator.pop(context); _unlinkAutoStatic(); }, style: TextButton.styleFrom(foregroundColor: Colors.red), child: const Text('Unlink'), ), ], ), ); }, ), ), ), ], ); } Widget _buildTalkgroupCategory( String title, List talkgroups, Color color, IconData icon, {bool canDelete = false} ) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(icon, size: 20, color: color), const SizedBox(width: 8), Text( title, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: color, ), ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Text( '${talkgroups.length}', style: TextStyle( color: color, fontSize: 12, fontWeight: FontWeight.bold, ), ), ), ], ), const SizedBox(height: 8), ...talkgroups.map((tg) => _TalkgroupRow( talkgroup: tg, talkgroupName: _allTalkgroups[tg.talkgroup], onDelete: canDelete ? () => _unlinkTalkgroup(tg) : null, categoryColor: color, )), ], ); } } class _InfoRow extends StatelessWidget { final String label; final String value; const _InfoRow({ required this.label, required this.value, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 100, child: Text( label, style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w500, color: Colors.grey[600], ), ), ), Expanded( child: Text( value, style: Theme.of(context).textTheme.bodyMedium, ), ), ], ), ); } } class _TalkgroupRow extends StatelessWidget { final StaticTalkgroup talkgroup; final String? talkgroupName; final VoidCallback? onDelete; final Color? categoryColor; const _TalkgroupRow({ required this.talkgroup, this.talkgroupName, this.onDelete, this.categoryColor, }); @override Widget build(BuildContext context) { final color = categoryColor ?? Theme.of(context).colorScheme.primary; return Card( margin: const EdgeInsets.only(bottom: 8), child: ListTile( leading: CircleAvatar( backgroundColor: color.withValues(alpha: 0.2), child: Text( talkgroup.displaySlot, style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: color, ), ), ), title: Text(talkgroupName ?? talkgroup.displayId), subtitle: Text('ID: ${talkgroup.displayId}'), trailing: onDelete != null ? IconButton( icon: const Icon(Icons.delete_outline), color: Colors.red, onPressed: () { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Unlink Talkgroup'), content: Text( 'Are you sure you want to unlink talkgroup ${talkgroup.displayId}?', ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), TextButton( onPressed: () { Navigator.pop(context); onDelete!(); }, style: TextButton.styleFrom(foregroundColor: Colors.red), child: const Text('Unlink'), ), ], ), ); }, ) : null, ), ); } }