Implement last heard display on device details
This commit is contained in:
@@ -58,37 +58,55 @@ class LastHeardItem {
|
||||
});
|
||||
|
||||
factory LastHeardItem.fromJson(Map<String, dynamic> json) {
|
||||
int? _toInt(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is int) return value;
|
||||
if (value is double) return value.toInt();
|
||||
if (value is String) return int.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
String _toString(dynamic value) {
|
||||
if (value == null) return '';
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
String? _toStringOrNull(dynamic value) {
|
||||
if (value == null) return null;
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
return LastHeardItem(
|
||||
linkName: json['LinkName'] as String? ?? '',
|
||||
sessionID: json['SessionID'] as String? ?? '',
|
||||
linkType: json['LinkType'] as int? ?? 0,
|
||||
contextID: json['ContextID'] as int? ?? 0,
|
||||
sessionType: json['SessionType'] as int? ?? 0,
|
||||
slot: json['Slot'] as int? ?? 0,
|
||||
sourceID: json['SourceID'] as int? ?? 0,
|
||||
destinationID: json['DestinationID'] as int? ?? 0,
|
||||
route: json['Route'] as String? ?? '',
|
||||
linkCall: json['LinkCall'] as String? ?? '',
|
||||
sourceCall: json['SourceCall'] as String? ?? '',
|
||||
sourceName: json['SourceName'] as String?,
|
||||
destinationCall: json['DestinationCall'] as String? ?? '',
|
||||
destinationName: json['DestinationName'] as String?,
|
||||
start: json['Start'] as int? ?? 0,
|
||||
stop: json['Stop'] as int? ?? 0,
|
||||
rssi: json['RSSI'] as int?,
|
||||
ber: json['BER'] as int?,
|
||||
reflectorID: json['ReflectorID'] as int? ?? 0,
|
||||
linkTypeName: json['LinkTypeName'] as String? ?? '',
|
||||
linkName: _toString(json['LinkName']),
|
||||
sessionID: _toString(json['SessionID']),
|
||||
linkType: _toInt(json['LinkType']) ?? 0,
|
||||
contextID: _toInt(json['ContextID']) ?? 0,
|
||||
sessionType: _toInt(json['SessionType']) ?? 0,
|
||||
slot: _toInt(json['Slot']) ?? 0,
|
||||
sourceID: _toInt(json['SourceID']) ?? 0,
|
||||
destinationID: _toInt(json['DestinationID']) ?? 0,
|
||||
route: _toString(json['Route']),
|
||||
linkCall: _toString(json['LinkCall']),
|
||||
sourceCall: _toString(json['SourceCall']),
|
||||
sourceName: _toStringOrNull(json['SourceName']),
|
||||
destinationCall: _toString(json['DestinationCall']),
|
||||
destinationName: _toStringOrNull(json['DestinationName']),
|
||||
start: _toInt(json['Start']) ?? 0,
|
||||
stop: _toInt(json['Stop']) ?? 0,
|
||||
rssi: _toInt(json['RSSI']),
|
||||
ber: _toInt(json['BER']),
|
||||
reflectorID: _toInt(json['ReflectorID']) ?? 0,
|
||||
linkTypeName: _toString(json['LinkTypeName']),
|
||||
callTypes: (json['CallTypes'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
?.map((e) => e.toString())
|
||||
.toList() ??
|
||||
[],
|
||||
lossCount: json['LossCount'] as int? ?? 0,
|
||||
totalCount: json['TotalCount'] as int? ?? 0,
|
||||
master: json['Master'] as String? ?? '',
|
||||
talkerAlias: json['TalkerAlias'] as String?,
|
||||
flagSet: json['FlagSet'] as int? ?? 0,
|
||||
event: json['Event'] as String? ?? '',
|
||||
lossCount: _toInt(json['LossCount']) ?? 0,
|
||||
totalCount: _toInt(json['TotalCount']) ?? 0,
|
||||
master: _toString(json['Master']),
|
||||
talkerAlias: _toStringOrNull(json['TalkerAlias']),
|
||||
flagSet: _toInt(json['FlagSet']) ?? 0,
|
||||
event: _toString(json['Event']),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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