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) {
|
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(
|
return LastHeardItem(
|
||||||
linkName: json['LinkName'] as String? ?? '',
|
linkName: _toString(json['LinkName']),
|
||||||
sessionID: json['SessionID'] as String? ?? '',
|
sessionID: _toString(json['SessionID']),
|
||||||
linkType: json['LinkType'] as int? ?? 0,
|
linkType: _toInt(json['LinkType']) ?? 0,
|
||||||
contextID: json['ContextID'] as int? ?? 0,
|
contextID: _toInt(json['ContextID']) ?? 0,
|
||||||
sessionType: json['SessionType'] as int? ?? 0,
|
sessionType: _toInt(json['SessionType']) ?? 0,
|
||||||
slot: json['Slot'] as int? ?? 0,
|
slot: _toInt(json['Slot']) ?? 0,
|
||||||
sourceID: json['SourceID'] as int? ?? 0,
|
sourceID: _toInt(json['SourceID']) ?? 0,
|
||||||
destinationID: json['DestinationID'] as int? ?? 0,
|
destinationID: _toInt(json['DestinationID']) ?? 0,
|
||||||
route: json['Route'] as String? ?? '',
|
route: _toString(json['Route']),
|
||||||
linkCall: json['LinkCall'] as String? ?? '',
|
linkCall: _toString(json['LinkCall']),
|
||||||
sourceCall: json['SourceCall'] as String? ?? '',
|
sourceCall: _toString(json['SourceCall']),
|
||||||
sourceName: json['SourceName'] as String?,
|
sourceName: _toStringOrNull(json['SourceName']),
|
||||||
destinationCall: json['DestinationCall'] as String? ?? '',
|
destinationCall: _toString(json['DestinationCall']),
|
||||||
destinationName: json['DestinationName'] as String?,
|
destinationName: _toStringOrNull(json['DestinationName']),
|
||||||
start: json['Start'] as int? ?? 0,
|
start: _toInt(json['Start']) ?? 0,
|
||||||
stop: json['Stop'] as int? ?? 0,
|
stop: _toInt(json['Stop']) ?? 0,
|
||||||
rssi: json['RSSI'] as int?,
|
rssi: _toInt(json['RSSI']),
|
||||||
ber: json['BER'] as int?,
|
ber: _toInt(json['BER']),
|
||||||
reflectorID: json['ReflectorID'] as int? ?? 0,
|
reflectorID: _toInt(json['ReflectorID']) ?? 0,
|
||||||
linkTypeName: json['LinkTypeName'] as String? ?? '',
|
linkTypeName: _toString(json['LinkTypeName']),
|
||||||
callTypes: (json['CallTypes'] as List<dynamic>?)
|
callTypes: (json['CallTypes'] as List<dynamic>?)
|
||||||
?.map((e) => e as String)
|
?.map((e) => e.toString())
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
lossCount: json['LossCount'] as int? ?? 0,
|
lossCount: _toInt(json['LossCount']) ?? 0,
|
||||||
totalCount: json['TotalCount'] as int? ?? 0,
|
totalCount: _toInt(json['TotalCount']) ?? 0,
|
||||||
master: json['Master'] as String? ?? '',
|
master: _toString(json['Master']),
|
||||||
talkerAlias: json['TalkerAlias'] as String?,
|
talkerAlias: _toStringOrNull(json['TalkerAlias']),
|
||||||
flagSet: json['FlagSet'] as int? ?? 0,
|
flagSet: _toInt(json['FlagSet']) ?? 0,
|
||||||
event: json['Event'] as String? ?? '',
|
event: _toString(json['Event']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/device.dart';
|
import '../models/device.dart';
|
||||||
import '../models/static_talkgroup.dart';
|
import '../models/static_talkgroup.dart';
|
||||||
import '../models/device_profile.dart';
|
import '../models/device_profile.dart';
|
||||||
|
import '../models/last_heard_item.dart';
|
||||||
import '../services/authentication_manager.dart';
|
import '../services/authentication_manager.dart';
|
||||||
import '../services/brandmeister_client.dart';
|
import '../services/brandmeister_client.dart';
|
||||||
import '../services/brandmeister_websocket_client.dart';
|
import '../services/brandmeister_websocket_client.dart';
|
||||||
|
import '../services/lastheard_websocket_client.dart';
|
||||||
import 'link_talkgroup_view.dart';
|
import 'link_talkgroup_view.dart';
|
||||||
|
|
||||||
class DeviceDetailView extends StatefulWidget {
|
class DeviceDetailView extends StatefulWidget {
|
||||||
@@ -30,17 +33,25 @@ class _DeviceDetailViewState extends State<DeviceDetailView> {
|
|||||||
StreamSubscription<Map<String, dynamic>>? _wsSubscription;
|
StreamSubscription<Map<String, dynamic>>? _wsSubscription;
|
||||||
int? _autoStaticTalkgroup;
|
int? _autoStaticTalkgroup;
|
||||||
|
|
||||||
|
LastHeardWebSocketClient? _lhWsClient;
|
||||||
|
StreamSubscription<Map<String, dynamic>>? _lhWsSubscription;
|
||||||
|
final List<LastHeardItem> _lastHeardItems = [];
|
||||||
|
static const int _maxLastHeardItems = 20;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadData();
|
_loadData();
|
||||||
_connectWebSocket();
|
_connectWebSocket();
|
||||||
|
_connectLastHeardWebSocket();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_wsSubscription?.cancel();
|
_wsSubscription?.cancel();
|
||||||
_wsClient?.dispose();
|
_wsClient?.dispose();
|
||||||
|
_lhWsSubscription?.cancel();
|
||||||
|
_lhWsClient?.dispose();
|
||||||
super.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 {
|
Future<void> _loadData() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoadingTalkgroups = true;
|
_isLoadingTalkgroups = true;
|
||||||
@@ -275,6 +376,10 @@ class _DeviceDetailViewState extends State<DeviceDetailView> {
|
|||||||
children: [
|
children: [
|
||||||
_buildDeviceInfoSection(),
|
_buildDeviceInfoSection(),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
|
if (_lastHeardItems.isNotEmpty) ...[
|
||||||
|
_buildLastHeardSection(),
|
||||||
|
const Divider(height: 1),
|
||||||
|
],
|
||||||
_buildTalkgroupsSection(),
|
_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() {
|
Widget _buildTalkgroupsSection() {
|
||||||
final hasAnyTalkgroups = _talkgroups.isNotEmpty ||
|
final hasAnyTalkgroups = _talkgroups.isNotEmpty ||
|
||||||
(_deviceProfile?.staticSubscriptions.isNotEmpty ?? false) ||
|
(_deviceProfile?.staticSubscriptions.isNotEmpty ?? false) ||
|
||||||
|
|||||||
Reference in New Issue
Block a user