Implement last heard display on device details
This commit is contained in:
@@ -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) ||
|
||||
|
||||
Reference in New Issue
Block a user