From 99aebb2c5f93dd2d4d270e9f37f59ed8da2daaf9 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Sun, 25 Jan 2026 15:52:50 +0100 Subject: [PATCH] Show recent activity under me --- .../me_activity_websocket_client.dart | 284 +++++++++++++ lib/views/devices_view.dart | 34 +- lib/views/me_view.dart | 388 ++++++++++++++---- lib/widgets/user_header.dart | 63 +++ 4 files changed, 667 insertions(+), 102 deletions(-) create mode 100644 lib/services/me_activity_websocket_client.dart create mode 100644 lib/widgets/user_header.dart diff --git a/lib/services/me_activity_websocket_client.dart b/lib/services/me_activity_websocket_client.dart new file mode 100644 index 0000000..e9b3667 --- /dev/null +++ b/lib/services/me_activity_websocket_client.dart @@ -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> _messageController = + StreamController>.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 _radioIds; + + final Completer _readyCompleter = Completer(); + + MeActivityWebSocketClient({ + required List radioIds, + }) : _radioIds = radioIds; + + static const String _wsUrl = + 'wss://api.brandmeister.network/lh/?EIO=4&transport=websocket'; + + Stream> get messageStream => _messageController.stream; + + bool get isConnected => _channel != null; + + bool get isReady => _isReady; + + Future get ready => _readyCompleter.future; + + List get radioIds => _radioIds; + + Future 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; + + 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? eventData; + + if (data is Map) { + 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(); + } +} diff --git a/lib/views/devices_view.dart b/lib/views/devices_view.dart index 427d851..9c1d15f 100644 --- a/lib/views/devices_view.dart +++ b/lib/views/devices_view.dart @@ -4,6 +4,7 @@ import '../models/device.dart'; import '../models/user_info.dart'; import '../services/authentication_manager.dart'; import '../services/brandmeister_client.dart'; +import '../widgets/user_header.dart'; import 'device_detail_view.dart'; class DevicesView extends StatefulWidget { @@ -135,34 +136,11 @@ class _DevicesViewState extends State { return ListView( children: [ - if (userInfo != null) - Container( - padding: const EdgeInsets.all(16), - color: Theme.of(context).colorScheme.primaryContainer, - child: Column( - 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), - ), - ), - ], - ), - ), + UserHeader( + userInfo: userInfo, + radioId: _devices.isNotEmpty ? _devices.first.id.toString() : null, + ), + const Divider(), ..._devices.map((device) => _DeviceRow( device: device, onTap: () { diff --git a/lib/views/me_view.dart b/lib/views/me_view.dart index 06fae7f..8111164 100644 --- a/lib/views/me_view.dart +++ b/lib/views/me_view.dart @@ -1,6 +1,12 @@ +import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../models/device.dart'; +import '../models/last_heard_item.dart'; import '../services/authentication_manager.dart'; +import '../services/me_activity_websocket_client.dart'; +import '../widgets/user_header.dart'; class MeView extends StatefulWidget { const MeView({super.key}); @@ -10,6 +16,148 @@ class MeView extends StatefulWidget { } class _MeViewState extends State { + MeActivityWebSocketClient? _wsClient; + StreamSubscription>? _wsSubscription; + List _devices = []; + final Map> _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 _loadData() async { + setState(() { + _isLoading = true; + }); + + final authManager = context.read(); + _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); + } else if (data is Map) { + // Search results may come directly without mqtt wrapper + _handleDirectResult(data as Map); + } + }); + + // Then connect + await _wsClient!.connect(); + + setState(() { + _isLoading = false; + }); + } + + void _handleDirectResult(Map 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 data) { + try { + final payloadStr = data['payload'] as String?; + if (payloadStr != null) { + final payload = jsonDecode(payloadStr) as Map; + 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 Widget build(BuildContext context) { @@ -22,105 +170,197 @@ class _MeViewState extends State { ), body: ListView( children: [ - _buildUserInfoSection(context, userInfo), + UserHeader( + userInfo: userInfo, + radioId: _devices.isNotEmpty ? _devices.first.id.toString() : null, + ), + const Divider(), + _buildActivitySection(), ], ), ); } - Widget _buildUserInfoSection(BuildContext context, userInfo) { - return Container( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + Widget _buildActivitySection() { + if (_isLoading) { + return const Padding( + padding: EdgeInsets.all(32), + child: Center( + child: Column( 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], - ), - ), - ], - ), + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading activity...'), + ], + ), + ), + ); + } + + if (_devices.isEmpty) { + return Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Column( + children: [ + Icon(Icons.radio, size: 48, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + 'No devices found', + style: TextStyle(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 { - final IconData icon; - final String label; - final String value; +class _ActivityItemTile extends StatelessWidget { + final LastHeardItem item; - const _InfoRow({ - required this.icon, - required this.label, - required this.value, - }); + const _ActivityItemTile({required this.item}); + + Color _getEventColor() { + 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 Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( + final eventColor = _getEventColor(); + + 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: [ - Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), - const SizedBox(width: 12), + Icon(Icons.arrow_forward, size: 10, color: Colors.grey[600]), + const SizedBox(width: 4), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey[600], - ), - ), - Text( - value, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - ), - ), - ], + child: Text( + item.destinationDisplayName, + style: TextStyle(fontSize: 12, color: Colors.grey[700]), + overflow: TextOverflow.ellipsis, ), ), ], ), + 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]), + ), + ], + ), ); } } diff --git a/lib/widgets/user_header.dart b/lib/widgets/user_header.dart new file mode 100644 index 0000000..dc7e9c7 --- /dev/null +++ b/lib/widgets/user_header.dart @@ -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], + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +}