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

@@ -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<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) {
@@ -22,105 +170,197 @@ class _MeViewState extends State<MeView> {
),
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]),
),
],
),
);
}
}