Remove MeView
This commit is contained in:
@@ -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<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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'me_view.dart';
|
|
||||||
import 'devices_view.dart';
|
import 'devices_view.dart';
|
||||||
import 'last_heard_view.dart';
|
import 'last_heard_view.dart';
|
||||||
import 'more_view.dart';
|
import 'more_view.dart';
|
||||||
@@ -15,7 +14,6 @@ class _MainViewState extends State<MainView> {
|
|||||||
int _selectedIndex = 0;
|
int _selectedIndex = 0;
|
||||||
|
|
||||||
final List<Widget> _views = const [
|
final List<Widget> _views = const [
|
||||||
MeView(),
|
|
||||||
DevicesView(),
|
DevicesView(),
|
||||||
LastHeardView(),
|
LastHeardView(),
|
||||||
MoreView(),
|
MoreView(),
|
||||||
@@ -30,10 +28,6 @@ class _MainViewState extends State<MainView> {
|
|||||||
currentIndex: _selectedIndex,
|
currentIndex: _selectedIndex,
|
||||||
onTap: (index) => setState(() => _selectedIndex = index),
|
onTap: (index) => setState(() => _selectedIndex = index),
|
||||||
items: const [
|
items: const [
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.person),
|
|
||||||
label: 'Me',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.devices),
|
icon: Icon(Icons.devices),
|
||||||
label: 'Devices',
|
label: 'Devices',
|
||||||
|
|||||||
@@ -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<MeView> createState() => _MeViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final authManager = context.watch<AuthenticationManager>();
|
|
||||||
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]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user