diff --git a/lib/services/me_activity_websocket_client.dart b/lib/services/me_activity_websocket_client.dart deleted file mode 100644 index e9b3667..0000000 --- a/lib/services/me_activity_websocket_client.dart +++ /dev/null @@ -1,284 +0,0 @@ -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/main_view.dart b/lib/views/main_view.dart index 852530e..e5ba82c 100644 --- a/lib/views/main_view.dart +++ b/lib/views/main_view.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'me_view.dart'; import 'devices_view.dart'; import 'last_heard_view.dart'; import 'more_view.dart'; @@ -15,7 +14,6 @@ class _MainViewState extends State { int _selectedIndex = 0; final List _views = const [ - MeView(), DevicesView(), LastHeardView(), MoreView(), @@ -30,10 +28,6 @@ class _MainViewState extends State { currentIndex: _selectedIndex, onTap: (index) => setState(() => _selectedIndex = index), items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.person), - label: 'Me', - ), BottomNavigationBarItem( icon: Icon(Icons.devices), label: 'Devices', diff --git a/lib/views/me_view.dart b/lib/views/me_view.dart deleted file mode 100644 index ca3ed65..0000000 --- a/lib/views/me_view.dart +++ /dev/null @@ -1,366 +0,0 @@ -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]), - ), - ], - ), - ); - } -}