From 353c08b385fae58d5fa176b94e164b0ff9c466d8 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Mon, 19 Jan 2026 12:32:55 +0100 Subject: [PATCH] Show hoseline --- lib/models/hose_item.dart | 46 ++++++ lib/views/hose_view.dart | 314 ++++++++++++++++++++++++++++++++++++++ lib/views/main_view.dart | 7 + 3 files changed, 367 insertions(+) create mode 100644 lib/models/hose_item.dart create mode 100644 lib/views/hose_view.dart diff --git a/lib/models/hose_item.dart b/lib/models/hose_item.dart new file mode 100644 index 0000000..0e4baf7 --- /dev/null +++ b/lib/models/hose_item.dart @@ -0,0 +1,46 @@ +class HoseItem { + final int talkgroupId; + final String talkgroupName; + final String sourceCall; + final String? sourceName; + final int slot; + final DateTime lastActivity; + final bool isActive; + + HoseItem({ + required this.talkgroupId, + required this.talkgroupName, + required this.sourceCall, + this.sourceName, + required this.slot, + required this.lastActivity, + required this.isActive, + }); + + String get displayName { + if (sourceName != null && sourceName!.isNotEmpty) { + return '$sourceCall ($sourceName)'; + } + return sourceCall; + } + + HoseItem copyWith({ + int? talkgroupId, + String? talkgroupName, + String? sourceCall, + String? sourceName, + int? slot, + DateTime? lastActivity, + bool? isActive, + }) { + return HoseItem( + talkgroupId: talkgroupId ?? this.talkgroupId, + talkgroupName: talkgroupName ?? this.talkgroupName, + sourceCall: sourceCall ?? this.sourceCall, + sourceName: sourceName ?? this.sourceName, + slot: slot ?? this.slot, + lastActivity: lastActivity ?? this.lastActivity, + isActive: isActive ?? this.isActive, + ); + } +} diff --git a/lib/views/hose_view.dart b/lib/views/hose_view.dart new file mode 100644 index 0000000..27860ec --- /dev/null +++ b/lib/views/hose_view.dart @@ -0,0 +1,314 @@ +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); + }, + ); + } + + 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 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, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/main_view.dart b/lib/views/main_view.dart index cd59269..6bdb759 100644 --- a/lib/views/main_view.dart +++ b/lib/views/main_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'info_view.dart'; import 'last_heard_view.dart'; import 'devices_view.dart'; +import 'hose_view.dart'; class MainView extends StatefulWidget { const MainView({super.key}); @@ -15,6 +16,7 @@ class _MainViewState extends State { final List _views = const [ DevicesView(), + HoseView(), LastHeardView(), InfoView(), ]; @@ -24,6 +26,7 @@ class _MainViewState extends State { return Scaffold( body: _views[_selectedIndex], bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, currentIndex: _selectedIndex, onTap: (index) => setState(() => _selectedIndex = index), items: const [ @@ -31,6 +34,10 @@ class _MainViewState extends State { icon: Icon(Icons.devices), label: 'Devices', ), + BottomNavigationBarItem( + icon: Icon(Icons.water_drop), + label: 'Hose', + ), BottomNavigationBarItem( icon: Icon(Icons.history), label: 'Last Heard',