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}); @override State createState() => _MeViewState(); } 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) { final authManager = context.watch(); final userInfo = authManager.userInfo; return Scaffold( appBar: AppBar( title: const Text('Me'), ), body: ListView( children: [ UserHeader( userInfo: userInfo, radioId: _devices.isNotEmpty ? _devices.first.id.toString() : null, ), const Divider(), _buildActivitySection(), ], ), ); } Widget _buildActivitySection() { if (_isLoading) { return const Padding( padding: EdgeInsets.all(32), child: Center( child: Column( children: [ CircularProgressIndicator(), SizedBox(height: 16), Text('Loading activity...'), ], ), ), ); } if (_devices.isEmpty) { return Padding( padding: const EdgeInsets.all(32), child: Center( child: Column( children: [ Icon(Icons.settings_input_antenna, size: 48, color: Colors.grey[400]), const SizedBox(height: 16), Text( 'No devices found', style: TextStyle(color: Colors.grey[600]), ), ], ), ), ); } 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.settings_input_antenna, 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 _ActivityItemTile extends StatelessWidget { final LastHeardItem item; const _ActivityItemTile({required this.item}); Color _getEventColor(BuildContext context) { switch (item.event) { case 'Session-Start': return Colors.green; case 'Session-Stop': return Colors.red; default: return Theme.of(context).colorScheme.primary; } } IconData _getEventIcon() { switch (item.event) { case 'Session-Start': return Icons.phone_in_talk; case 'Session-Stop': return Icons.call_end; default: return Icons.settings_input_antenna; } } @override Widget build(BuildContext context) { final eventColor = _getEventColor(context); 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(Icons.arrow_forward, size: 10, color: Colors.grey[600]), const SizedBox(width: 4), Expanded( 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]), ), ], ), ); } }