import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import '../models/hose_item.dart'; import '../services/lastheard_websocket_client.dart'; class HoseView extends StatefulWidget { const HoseView({super.key}); @override State createState() => _HoseViewState(); } class _HoseViewState extends State { final Map _hoseItems = {}; LastHeardWebSocketClient? _wsClient; StreamSubscription>? _wsSubscription; Timer? _activityTimer; bool _isConnecting = true; // Time threshold to consider a talkgroup as "active" (5 seconds) static const Duration _activityThreshold = Duration(seconds: 5); @override void initState() { super.initState(); _connectWebSocket(); _startActivityTimer(); } @override void dispose() { _wsSubscription?.cancel(); _wsClient?.dispose(); _activityTimer?.cancel(); super.dispose(); } Future _connectWebSocket() async { if (!mounted) return; try { _wsClient = LastHeardWebSocketClient(); _wsClient!.connect(); _wsSubscription = _wsClient!.messageStream.listen((message) { _handleMqttMessage(message); }); // Give it a short moment to connect, then hide loading await Future.delayed(const Duration(milliseconds: 500)); if (mounted) { setState(() { _isConnecting = false; }); } } catch (e) { debugPrint('HoseView: Error connecting to WebSocket: $e'); if (mounted) { setState(() { _isConnecting = false; }); } } } void _handleMqttMessage(Map message) { try { // Check if this is an MQTT event message if (message['event'] == 'mqtt' && message['data'] is Map) { final data = message['data'] as Map; 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 talkgroupId = payload['DestinationID'] as int?; final talkgroupName = payload['DestinationName'] as String?; final sourceCall = payload['SourceCall'] as String?; final sourceName = payload['SourceName'] as String?; final slot = payload['Slot'] as int?; final event = payload['Event'] as String?; final start = payload['Start'] as int?; if (talkgroupId == null || sourceCall == null || slot == null || start == null) { return; } final lastActivity = DateTime.fromMillisecondsSinceEpoch(start * 1000); final isActive = event == 'Session-Start'; if (mounted) { setState(() { _hoseItems[talkgroupId] = HoseItem( talkgroupId: talkgroupId, talkgroupName: talkgroupName ?? 'TG $talkgroupId', sourceCall: sourceCall, sourceName: sourceName, slot: slot, lastActivity: lastActivity, isActive: isActive, ); }); } } } } catch (e) { debugPrint('HoseView: Error handling MQTT message: $e'); } } void _startActivityTimer() { // Update activity status every second _activityTimer = Timer.periodic(const Duration(seconds: 1), (_) { final now = DateTime.now(); bool needsUpdate = false; for (final item in _hoseItems.values) { if (item.isActive && now.difference(item.lastActivity) > _activityThreshold) { needsUpdate = true; break; } } if (needsUpdate) { setState(() { final now = DateTime.now(); _hoseItems.updateAll((key, item) { if (item.isActive && now.difference(item.lastActivity) > _activityThreshold) { return item.copyWith(isActive: false); } return item; }); }); } }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Hose'), ), body: _buildBody(), ); } Widget _buildBody() { if (_isConnecting) { return const Center( child: CircularProgressIndicator(), ); } if (_hoseItems.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.water_drop_outlined, size: 64, color: Colors.grey[400], ), const SizedBox(height: 16), Text( 'No active talkgroups', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text( 'Waiting for activity...', style: TextStyle(color: Colors.grey[600]), ), ], ), ); } final items = _hoseItems.values.toList() ..sort((a, b) => a.talkgroupId.compareTo(b.talkgroupId)); return GridView.builder( padding: const EdgeInsets.all(8), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, childAspectRatio: 1.0, crossAxisSpacing: 8, mainAxisSpacing: 8, ), itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; return _buildHoseCard(item); }, ); } void _showNotImplementedSnackbar() { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Listening to live QSOs has not been implemented yet'), duration: Duration(seconds: 2), ), ); } Widget _buildHoseCard(HoseItem item) { final now = DateTime.now(); final timeSinceActivity = now.difference(item.lastActivity); String timeAgo; if (timeSinceActivity.inSeconds < 60) { timeAgo = '${timeSinceActivity.inSeconds}s ago'; } else if (timeSinceActivity.inMinutes < 60) { timeAgo = '${timeSinceActivity.inMinutes}m ago'; } else { timeAgo = '${timeSinceActivity.inHours}h ago'; } return GestureDetector( onTap: _showNotImplementedSnackbar, child: Card( elevation: item.isActive ? 4 : 1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: item.isActive ? BorderSide( color: Theme.of(context).colorScheme.primary, width: 3, ) : BorderSide.none, ), child: Container( padding: const EdgeInsets.all(12), child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Talkgroup header Row( children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular(4), ), child: Text( 'TS${item.slot}', style: TextStyle( fontSize: 10, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onPrimaryContainer, ), ), ), const SizedBox(width: 4), if (item.isActive) Icon( Icons.mic, size: 16, color: Theme.of(context).colorScheme.primary, ), ], ), // Talkgroup name Expanded( child: Center( child: Text( item.talkgroupName, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, ), ), ), // Last talker info Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.displayName, style: Theme.of(context).textTheme.bodySmall?.copyWith( fontWeight: FontWeight.w600, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'TG ${item.talkgroupId}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey[600], fontSize: 10, ), ), Text( timeAgo, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey[600], fontSize: 10, ), ), ], ), ], ), ], ), ), ), ); } }