Show recent activity under me

This commit is contained in:
2026-01-25 15:52:50 +01:00
parent 9595955f56
commit 99aebb2c5f
4 changed files with 667 additions and 102 deletions

View File

@@ -0,0 +1,284 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
class MeActivityWebSocketClient {
WebSocketChannel? _channel;
final StreamController<Map<String, dynamic>> _messageController =
StreamController<Map<String, dynamic>>.broadcast();
Timer? _heartbeatTimer;
Timer? _reconnectTimer;
bool _isConnecting = false;
bool _shouldReconnect = true;
bool _isReady = false;
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 5;
static const Duration _reconnectDelay = Duration(seconds: 5);
final List<int> _radioIds;
final Completer<void> _readyCompleter = Completer<void>();
MeActivityWebSocketClient({
required List<int> radioIds,
}) : _radioIds = radioIds;
static const String _wsUrl =
'wss://api.brandmeister.network/lh/?EIO=4&transport=websocket';
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
bool get isConnected => _channel != null;
bool get isReady => _isReady;
Future<void> get ready => _readyCompleter.future;
List<int> get radioIds => _radioIds;
Future<void> connect() async {
if (_isConnecting || isConnected) {
debugPrint('MeActivity WS: Already connected or connecting');
return;
}
if (_radioIds.isEmpty) {
debugPrint('MeActivity WS: No radio IDs provided');
if (!_readyCompleter.isCompleted) {
_readyCompleter.complete();
}
return;
}
_isConnecting = true;
_shouldReconnect = true;
try {
debugPrint('MeActivity WS: Connecting to $_wsUrl');
_channel = WebSocketChannel.connect(Uri.parse(_wsUrl));
_channel!.stream.listen(
_handleMessage,
onError: _handleError,
onDone: _handleDisconnect,
cancelOnError: false,
);
_reconnectAttempts = 0;
_isConnecting = false;
debugPrint('MeActivity WS: WebSocket connected');
} catch (e) {
_isConnecting = false;
debugPrint('MeActivity WS: Connection error: $e');
_scheduleReconnect();
}
}
void _handleMessage(dynamic message) {
try {
final messageStr = message.toString();
debugPrint('MeActivity WS: Received message: $messageStr');
if (messageStr.startsWith('0')) {
_handleOpenPacket(messageStr);
} else if (messageStr.startsWith('2')) {
_sendPong();
} else if (messageStr.startsWith('40')) {
_handleNamespaceConnect();
} else if (messageStr.startsWith('42')) {
_handleDataPacket(messageStr);
} else if (messageStr.startsWith('4')) {
debugPrint('MeActivity WS: Received other type 4 message: $messageStr');
}
} catch (e) {
debugPrint('MeActivity WS: Error handling message: $e');
}
}
void _handleOpenPacket(String message) {
try {
final jsonStr = message.substring(1);
final data = jsonDecode(jsonStr) as Map<String, dynamic>;
final pingInterval = data['pingInterval'] as int? ?? 25000;
_heartbeatTimer?.cancel();
_heartbeatTimer = Timer.periodic(
Duration(milliseconds: pingInterval),
(_) => _sendPing(),
);
debugPrint(
'MeActivity WS: Open packet received, ping interval: $pingInterval ms');
_sendNamespaceConnect();
} catch (e) {
debugPrint('MeActivity WS: Error parsing open packet: $e');
}
}
void _sendNamespaceConnect() {
if (isConnected) {
try {
_channel!.sink.add('40');
debugPrint('MeActivity WS: Sent namespace connect (40)');
} catch (e) {
debugPrint('MeActivity WS: Error sending namespace connect: $e');
}
}
}
void _handleNamespaceConnect() {
debugPrint('MeActivity WS: Namespace connected (received 40)');
_isReady = true;
if (!_readyCompleter.isCompleted) {
_readyCompleter.complete();
}
// Send search query to fetch historical activity for user's devices
_sendSearchQuery();
}
void _sendSearchQuery() {
if (!isConnected || !isReady) {
debugPrint('MeActivity WS: Cannot send search query, not ready');
return;
}
try {
// Build SQL query for multiple device IDs: ContextID = 123 OR ContextID = 456
final sqlParts = _radioIds.map((id) => 'ContextID = $id').toList();
final sql = sqlParts.join(' OR ');
final message = '42${jsonEncode([
'searchMongo',
{
'query': {'sql': sql},
'amount': 50 // Fetch up to 50 historical items
}
])}';
_channel!.sink.add(message);
debugPrint('MeActivity WS: Sent search query: $message');
} catch (e) {
debugPrint('MeActivity WS: Error sending search query: $e');
}
}
void _handleDataPacket(String message) {
try {
String jsonStr = message.substring(1);
if (jsonStr.startsWith('2')) {
jsonStr = jsonStr.substring(1);
}
final data = jsonDecode(jsonStr);
Map<String, dynamic>? eventData;
if (data is Map<String, dynamic>) {
eventData = data;
} else if (data is List && data.isNotEmpty) {
final eventName = data[0] as String;
if (data.length > 1) {
eventData = {
'event': eventName,
'data': data[1],
};
} else {
eventData = {
'event': eventName,
};
}
}
if (eventData != null && !_messageController.isClosed) {
_messageController.add(eventData);
}
} catch (e) {
debugPrint('MeActivity WS: Error parsing data packet: $e');
}
}
void _sendPing() {
if (isConnected) {
try {
_channel!.sink.add('2');
debugPrint('MeActivity WS: Sent ping');
} catch (e) {
debugPrint('MeActivity WS: Error sending ping: $e');
}
}
}
void _sendPong() {
if (isConnected) {
try {
_channel!.sink.add('3');
debugPrint('MeActivity WS: Sent pong');
} catch (e) {
debugPrint('MeActivity WS: Error sending pong: $e');
}
}
}
void _handleError(Object error) {
debugPrint('MeActivity WS: Stream error: $error');
}
void _handleDisconnect() {
debugPrint('MeActivity WS: Disconnected');
_cleanup();
if (_shouldReconnect) {
_scheduleReconnect();
}
}
void _scheduleReconnect() {
if (_reconnectAttempts >= _maxReconnectAttempts) {
debugPrint('MeActivity WS: Max reconnect attempts reached');
return;
}
_reconnectAttempts++;
debugPrint('MeActivity WS: Scheduling reconnect attempt $_reconnectAttempts');
_reconnectTimer?.cancel();
_reconnectTimer = Timer(_reconnectDelay, () {
if (_shouldReconnect) {
connect();
}
});
}
void _cleanup() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
_channel = null;
_isConnecting = false;
}
void disconnect() {
debugPrint('MeActivity WS: Disconnecting');
_shouldReconnect = false;
_reconnectTimer?.cancel();
_heartbeatTimer?.cancel();
try {
_channel?.sink.close();
} catch (e) {
debugPrint('MeActivity WS: Error closing channel: $e');
}
_cleanup();
}
void dispose() {
disconnect();
_messageController.close();
}
}

View File

@@ -4,6 +4,7 @@ import '../models/device.dart';
import '../models/user_info.dart'; import '../models/user_info.dart';
import '../services/authentication_manager.dart'; import '../services/authentication_manager.dart';
import '../services/brandmeister_client.dart'; import '../services/brandmeister_client.dart';
import '../widgets/user_header.dart';
import 'device_detail_view.dart'; import 'device_detail_view.dart';
class DevicesView extends StatefulWidget { class DevicesView extends StatefulWidget {
@@ -135,34 +136,11 @@ class _DevicesViewState extends State<DevicesView> {
return ListView( return ListView(
children: [ children: [
if (userInfo != null) UserHeader(
Container( userInfo: userInfo,
padding: const EdgeInsets.all(16), radioId: _devices.isNotEmpty ? _devices.first.id.toString() : null,
color: Theme.of(context).colorScheme.primaryContainer, ),
child: Column( const Divider(),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
userInfo.displayName,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 4),
Text(
_devices.isNotEmpty
? 'DMR ID: ${_devices.first.id}'
: 'No devices',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer
.withValues(alpha: 0.7),
),
),
],
),
),
..._devices.map((device) => _DeviceRow( ..._devices.map((device) => _DeviceRow(
device: device, device: device,
onTap: () { onTap: () {

View File

@@ -1,6 +1,12 @@
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/last_heard_item.dart';
import '../services/authentication_manager.dart'; import '../services/authentication_manager.dart';
import '../services/me_activity_websocket_client.dart';
import '../widgets/user_header.dart';
class MeView extends StatefulWidget { class MeView extends StatefulWidget {
const MeView({super.key}); const MeView({super.key});
@@ -10,6 +16,148 @@ class MeView extends StatefulWidget {
} }
class _MeViewState extends State<MeView> { class _MeViewState extends State<MeView> {
MeActivityWebSocketClient? _wsClient;
StreamSubscription<Map<String, dynamic>>? _wsSubscription;
List<Device> _devices = [];
final Map<int, List<LastHeardItem>> _itemsByDevice = {};
bool _isLoading = true;
static const int _maxItemsPerDevice = 5;
@override
void initState() {
super.initState();
_loadData();
}
@override
void dispose() {
_wsSubscription?.cancel();
_wsClient?.dispose();
super.dispose();
}
Future<void> _loadData() async {
setState(() {
_isLoading = true;
});
final authManager = context.read<AuthenticationManager>();
_devices = await authManager.getDevices();
final radioIds = _devices.map((d) => d.id).toList();
if (radioIds.isEmpty) {
setState(() {
_isLoading = false;
});
return;
}
_wsClient = MeActivityWebSocketClient(
radioIds: radioIds,
);
// Subscribe first (stream controller is created in constructor now)
_wsSubscription = _wsClient!.messageStream.listen((message) {
debugPrint('MeView: Received message: $message');
final event = message['event'];
final data = message['data'];
// Handle both 'mqtt' events and direct search results
if (event == 'mqtt' && data is Map) {
_handleMqttMessage(data as Map<String, dynamic>);
} else if (data is Map) {
// Search results may come directly without mqtt wrapper
_handleDirectResult(data as Map<String, dynamic>);
}
});
// Then connect
await _wsClient!.connect();
setState(() {
_isLoading = false;
});
}
void _handleDirectResult(Map<String, dynamic> data) {
try {
// Direct search result - data is already the LastHeardItem JSON
final item = LastHeardItem.fromJson(data);
// Filter out items without a callsign
if (item.sourceCall.isEmpty) {
return;
}
// Find which device this item belongs to (by ContextID matching device id)
final deviceId = item.contextID;
// Only process if this is one of the user's devices
final deviceIds = _devices.map((d) => d.id).toSet();
if (!deviceIds.contains(deviceId)) {
return;
}
setState(() {
if (!_itemsByDevice.containsKey(deviceId)) {
_itemsByDevice[deviceId] = [];
}
final deviceItems = _itemsByDevice[deviceId]!;
// Only add if we haven't reached max items for this device
if (deviceItems.length < _maxItemsPerDevice) {
deviceItems.add(item);
// Sort by timestamp (most recent first)
deviceItems.sort((a, b) => b.start.compareTo(a.start));
}
});
} catch (e) {
debugPrint('Error handling direct result: $e');
}
}
void _handleMqttMessage(Map<String, dynamic> data) {
try {
final payloadStr = data['payload'] as String?;
if (payloadStr != null) {
final payload = jsonDecode(payloadStr) as Map<String, dynamic>;
final item = LastHeardItem.fromJson(payload);
// Filter out items without a callsign
if (item.sourceCall.isEmpty) {
return;
}
// Find which device this item belongs to (by ContextID matching device id)
final deviceId = item.contextID;
// Only process if this is one of the user's devices
final deviceIds = _devices.map((d) => d.id).toSet();
if (!deviceIds.contains(deviceId)) {
return;
}
setState(() {
if (!_itemsByDevice.containsKey(deviceId)) {
_itemsByDevice[deviceId] = [];
}
final deviceItems = _itemsByDevice[deviceId]!;
// Add to the beginning (most recent first)
deviceItems.insert(0, item);
// Keep only the latest items per device
if (deviceItems.length > _maxItemsPerDevice) {
deviceItems.removeRange(_maxItemsPerDevice, deviceItems.length);
}
});
}
} catch (e) {
debugPrint('Error handling MQTT message: $e');
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -22,105 +170,197 @@ class _MeViewState extends State<MeView> {
), ),
body: ListView( body: ListView(
children: [ children: [
_buildUserInfoSection(context, userInfo), UserHeader(
userInfo: userInfo,
radioId: _devices.isNotEmpty ? _devices.first.id.toString() : null,
),
const Divider(),
_buildActivitySection(),
], ],
), ),
); );
} }
Widget _buildUserInfoSection(BuildContext context, userInfo) { Widget _buildActivitySection() {
return Container( if (_isLoading) {
padding: const EdgeInsets.all(16), return const Padding(
child: Column( padding: EdgeInsets.all(32),
crossAxisAlignment: CrossAxisAlignment.start, child: Center(
children: [ child: Column(
Row(
children: [ children: [
CircleAvatar( CircularProgressIndicator(),
radius: 32, SizedBox(height: 16),
backgroundColor: Theme.of(context).colorScheme.primaryContainer, Text('Loading activity...'),
child: Icon( ],
Icons.person, ),
size: 32, ),
color: Theme.of(context).colorScheme.onPrimaryContainer, );
), }
),
const SizedBox(width: 16), if (_devices.isEmpty) {
Expanded( return Padding(
child: Column( padding: const EdgeInsets.all(32),
crossAxisAlignment: CrossAxisAlignment.start, child: Center(
children: [ child: Column(
Text( children: [
userInfo?.name ?? 'N/A', Icon(Icons.radio, size: 48, color: Colors.grey[400]),
style: Theme.of(context).textTheme.headlineSmall?.copyWith( const SizedBox(height: 16),
fontWeight: FontWeight.bold, Text(
), 'No devices found',
), style: TextStyle(color: Colors.grey[600]),
const SizedBox(height: 4),
Text(
userInfo?.username ?? 'N/A',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.grey[600],
),
),
],
),
), ),
], ],
), ),
const SizedBox(height: 16), ),
_InfoRow( );
icon: Icons.numbers, }
label: 'User ID',
value: userInfo?.id.toString() ?? 'N/A', return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
'Recent Activity',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
), ),
], ),
), ..._devices.map((device) => _buildDeviceActivitySection(device)),
],
); );
} }
Widget _buildDeviceActivitySection(Device device) {
final items = _itemsByDevice[device.id] ?? [];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Row(
children: [
Icon(
Icons.radio,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
device.id.toString(),
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
if (items.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'No recent activity',
style: TextStyle(
color: Colors.grey[500],
fontStyle: FontStyle.italic,
),
),
)
else
...items.map((item) => _ActivityItemTile(item: item)),
],
);
}
} }
class _InfoRow extends StatelessWidget { class _ActivityItemTile extends StatelessWidget {
final IconData icon; final LastHeardItem item;
final String label;
final String value;
const _InfoRow({ const _ActivityItemTile({required this.item});
required this.icon,
required this.label, Color _getEventColor() {
required this.value, switch (item.event) {
}); case 'Session-Start':
return Colors.green;
case 'Session-Stop':
return Colors.red;
default:
return Colors.blue;
}
}
IconData _getEventIcon() {
switch (item.event) {
case 'Session-Start':
return Icons.phone_in_talk;
case 'Session-Stop':
return Icons.call_end;
default:
return Icons.radio;
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( final eventColor = _getEventColor();
padding: const EdgeInsets.only(bottom: 12),
child: Row( return ListTile(
dense: true,
leading: CircleAvatar(
radius: 16,
backgroundColor: eventColor.withValues(alpha: 0.2),
child: Icon(
_getEventIcon(),
color: eventColor,
size: 16,
),
),
title: Text(
item.displayName,
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14),
),
subtitle: Row(
children: [ children: [
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), Icon(Icons.arrow_forward, size: 10, color: Colors.grey[600]),
const SizedBox(width: 12), const SizedBox(width: 4),
Expanded( Expanded(
child: Column( child: Text(
crossAxisAlignment: CrossAxisAlignment.start, item.destinationDisplayName,
children: [ style: TextStyle(fontSize: 12, color: Colors.grey[700]),
Text( overflow: TextOverflow.ellipsis,
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
Text(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
), ),
), ),
], ],
), ),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: eventColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(3),
),
child: Text(
'TS${item.slot}',
style: TextStyle(
fontSize: 9,
color: eventColor,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 2),
Text(
item.timeAgo,
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
),
],
),
); );
} }
} }

View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import '../models/user_info.dart';
class UserHeader extends StatelessWidget {
final UserInfo? userInfo;
final String? radioId;
const UserHeader({
super.key,
required this.userInfo,
this.radioId,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
CircleAvatar(
radius: 32,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Icon(
Icons.person,
size: 32,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
userInfo?.name ?? 'N/A',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
userInfo?.username ?? 'N/A',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.grey[600],
),
),
if (radioId != null) ...[
const SizedBox(height: 4),
Text(
radioId!,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.grey[600],
),
),
],
],
),
),
],
),
);
}
}