Implement last heard display on device details

This commit is contained in:
2026-01-20 00:36:01 +01:00
parent b96214fcba
commit 4aa5fd156f
2 changed files with 227 additions and 27 deletions

View File

@@ -1,12 +1,15 @@
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 {
@@ -30,17 +33,25 @@ class _DeviceDetailViewState extends State<DeviceDetailView> {
StreamSubscription<Map<String, dynamic>>? _wsSubscription;
int? _autoStaticTalkgroup;
LastHeardWebSocketClient? _lhWsClient;
StreamSubscription<Map<String, dynamic>>? _lhWsSubscription;
final List<LastHeardItem> _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();
}
@@ -90,6 +101,96 @@ class _DeviceDetailViewState extends State<DeviceDetailView> {
}
}
Future<void> _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<String, dynamic>);
}
});
} catch (e) {
debugPrint('DeviceDetailView: Error connecting to Last Heard WebSocket: $e');
}
}
void _handleLastHeardMessage(Map<String, dynamic> 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<String, dynamic>;
final destinationId = payload['DestinationID'] as int?;
if (destinationId == null) return;
// Get all linked talkgroup IDs (static + auto-static + dynamic)
final linkedTalkgroupIds = <int>{};
// Add static talkgroups
for (final tg in _deviceProfile?.staticSubscriptions ?? <StaticTalkgroup>[]) {
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 ?? <StaticTalkgroup>[]) {
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<void> _loadData() async {
setState(() {
_isLoadingTalkgroups = true;
@@ -275,6 +376,10 @@ class _DeviceDetailViewState extends State<DeviceDetailView> {
children: [
_buildDeviceInfoSection(),
const Divider(height: 1),
if (_lastHeardItems.isNotEmpty) ...[
_buildLastHeardSection(),
const Divider(height: 1),
],
_buildTalkgroupsSection(),
],
);
@@ -326,6 +431,83 @@ class _DeviceDetailViewState extends State<DeviceDetailView> {
);
}
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.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) ||