Implement last heard and info

This commit is contained in:
2026-01-19 12:20:27 +01:00
parent ac5bd1a211
commit b169cde70d
6 changed files with 1108 additions and 206 deletions

View File

@@ -0,0 +1,127 @@
class LastHeardItem {
final String linkName;
final String sessionID;
final int linkType;
final int contextID;
final int sessionType;
final int slot;
final int sourceID;
final int destinationID;
final String route;
final String linkCall;
final String sourceCall;
final String? sourceName;
final String destinationCall;
final String? destinationName;
final int start;
final int stop;
final int? rssi;
final int? ber;
final int reflectorID;
final String linkTypeName;
final List<String> callTypes;
final int lossCount;
final int totalCount;
final String master;
final String? talkerAlias;
final int flagSet;
final String event;
LastHeardItem({
required this.linkName,
required this.sessionID,
required this.linkType,
required this.contextID,
required this.sessionType,
required this.slot,
required this.sourceID,
required this.destinationID,
required this.route,
required this.linkCall,
required this.sourceCall,
this.sourceName,
required this.destinationCall,
this.destinationName,
required this.start,
required this.stop,
this.rssi,
this.ber,
required this.reflectorID,
required this.linkTypeName,
required this.callTypes,
required this.lossCount,
required this.totalCount,
required this.master,
this.talkerAlias,
required this.flagSet,
required this.event,
});
factory LastHeardItem.fromJson(Map<String, dynamic> json) {
return LastHeardItem(
linkName: json['LinkName'] as String? ?? '',
sessionID: json['SessionID'] as String? ?? '',
linkType: json['LinkType'] as int? ?? 0,
contextID: json['ContextID'] as int? ?? 0,
sessionType: json['SessionType'] as int? ?? 0,
slot: json['Slot'] as int? ?? 0,
sourceID: json['SourceID'] as int? ?? 0,
destinationID: json['DestinationID'] as int? ?? 0,
route: json['Route'] as String? ?? '',
linkCall: json['LinkCall'] as String? ?? '',
sourceCall: json['SourceCall'] as String? ?? '',
sourceName: json['SourceName'] as String?,
destinationCall: json['DestinationCall'] as String? ?? '',
destinationName: json['DestinationName'] as String?,
start: json['Start'] as int? ?? 0,
stop: json['Stop'] as int? ?? 0,
rssi: json['RSSI'] as int?,
ber: json['BER'] as int?,
reflectorID: json['ReflectorID'] as int? ?? 0,
linkTypeName: json['LinkTypeName'] as String? ?? '',
callTypes: (json['CallTypes'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[],
lossCount: json['LossCount'] as int? ?? 0,
totalCount: json['TotalCount'] as int? ?? 0,
master: json['Master'] as String? ?? '',
talkerAlias: json['TalkerAlias'] as String?,
flagSet: json['FlagSet'] as int? ?? 0,
event: json['Event'] as String? ?? '',
);
}
String get displayName {
if (sourceName != null && sourceName!.isNotEmpty) {
return '$sourceCall ($sourceName)';
}
return sourceCall;
}
String get destinationDisplayName {
if (destinationName != null && destinationName!.isNotEmpty) {
return '$destinationCall ($destinationName)';
}
return destinationCall;
}
DateTime get timestamp {
return DateTime.fromMillisecondsSinceEpoch(start * 1000);
}
String get timeAgo {
final now = DateTime.now();
final difference = now.difference(timestamp);
if (difference.inSeconds < 60) {
return '${difference.inSeconds}s ago';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes}m ago';
} else if (difference.inHours < 24) {
return '${difference.inHours}h ago';
} else {
return '${difference.inDays}d ago';
}
}
}

View File

@@ -0,0 +1,268 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
class LastHeardWebSocketClient {
WebSocketChannel? _channel;
StreamController<Map<String, dynamic>>? _messageController;
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 Completer<void> _readyCompleter = Completer<void>();
// Socket.IO Engine.IO v4 WebSocket URL for Last Heard
static const String _wsUrl =
'wss://api.brandmeister.network/lh/?EIO=4&transport=websocket';
Stream<Map<String, dynamic>> get messageStream =>
_messageController?.stream ?? const Stream.empty();
bool get isConnected => _channel != null;
bool get isReady => _isReady;
Future<void> get ready => _readyCompleter.future;
Future<void> connect() async {
if (_isConnecting || isConnected) {
debugPrint('LastHeard WS: Already connected or connecting');
return;
}
_isConnecting = true;
_shouldReconnect = true;
try {
debugPrint('LastHeard WS: Connecting to $_wsUrl');
_messageController ??= StreamController<Map<String, dynamic>>.broadcast();
_channel = WebSocketChannel.connect(Uri.parse(_wsUrl));
_channel!.stream.listen(
_handleMessage,
onError: _handleError,
onDone: _handleDisconnect,
cancelOnError: false,
);
_reconnectAttempts = 0;
_isConnecting = false;
debugPrint('LastHeard WS: WebSocket connected');
} catch (e) {
_isConnecting = false;
debugPrint('LastHeard WS: Connection error: $e');
_scheduleReconnect();
}
}
void _handleMessage(dynamic message) {
try {
final messageStr = message.toString();
debugPrint('LastHeard WS: Received message: $messageStr');
if (messageStr.startsWith('0')) {
_handleOpenPacket(messageStr);
} else if (messageStr.startsWith('2')) {
_sendPong();
} else if (messageStr == '40') {
_handleNamespaceConnect();
} else if (messageStr.startsWith('42')) {
_handleDataPacket(messageStr);
} else if (messageStr.startsWith('4')) {
debugPrint('LastHeard WS: Received other type 4 message: $messageStr');
}
} catch (e) {
debugPrint('LastHeard 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('LastHeard WS: Open packet received, ping interval: $pingInterval ms');
_sendNamespaceConnect();
} catch (e) {
debugPrint('LastHeard WS: Error parsing open packet: $e');
}
}
void _sendNamespaceConnect() {
if (isConnected) {
try {
_channel!.sink.add('40');
debugPrint('LastHeard WS: Sent namespace connect (40)');
} catch (e) {
debugPrint('LastHeard WS: Error sending namespace connect: $e');
}
}
}
void _handleNamespaceConnect() {
debugPrint('LastHeard WS: Namespace connected (received 40)');
_isReady = true;
if (!_readyCompleter.isCompleted) {
_readyCompleter.complete();
}
// Send search query after namespace is connected
_sendSearchQuery();
}
void _sendSearchQuery() {
if (!isConnected || !isReady) {
debugPrint('LastHeard WS: Cannot send search query, not ready');
return;
}
try {
final message = '42${jsonEncode([
'searchMongo',
{
'query': {'sql': ''},
'amount': 200
}
])}';
_channel!.sink.add(message);
debugPrint('LastHeard WS: Sent search query: $message');
} catch (e) {
debugPrint('LastHeard 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 != null &&
!_messageController!.isClosed) {
_messageController!.add(eventData);
}
} catch (e) {
debugPrint('LastHeard WS: Error parsing data packet: $e');
}
}
void _sendPing() {
if (isConnected) {
try {
_channel!.sink.add('2');
debugPrint('LastHeard WS: Sent ping');
} catch (e) {
debugPrint('LastHeard WS: Error sending ping: $e');
}
}
}
void _sendPong() {
if (isConnected) {
try {
_channel!.sink.add('3');
debugPrint('LastHeard WS: Sent pong');
} catch (e) {
debugPrint('LastHeard WS: Error sending pong: $e');
}
}
}
void _handleError(Object error) {
debugPrint('LastHeard WS: Stream error: $error');
}
void _handleDisconnect() {
debugPrint('LastHeard WS: Disconnected');
_cleanup();
if (_shouldReconnect) {
_scheduleReconnect();
}
}
void _scheduleReconnect() {
if (_reconnectAttempts >= _maxReconnectAttempts) {
debugPrint('LastHeard WS: Max reconnect attempts reached');
return;
}
_reconnectAttempts++;
debugPrint('LastHeard 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('LastHeard WS: Disconnecting');
_shouldReconnect = false;
_reconnectTimer?.cancel();
_heartbeatTimer?.cancel();
try {
_channel?.sink.close();
} catch (e) {
debugPrint('LastHeard WS: Error closing channel: $e');
}
_cleanup();
}
void dispose() {
disconnect();
_messageController?.close();
_messageController = null;
}
}

215
lib/views/devices_view.dart Normal file
View File

@@ -0,0 +1,215 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/device.dart';
import '../models/user_info.dart';
import '../services/authentication_manager.dart';
import '../services/brandmeister_client.dart';
import 'device_detail_view.dart';
class DevicesView extends StatefulWidget {
const DevicesView({super.key});
@override
State<DevicesView> createState() => _DevicesViewState();
}
class _DevicesViewState extends State<DevicesView> {
List<Device> _devices = [];
bool _isLoadingDevices = false;
String? _errorMessage;
@override
void initState() {
super.initState();
_loadDevices();
}
Future<void> _loadDevices() async {
setState(() {
_isLoadingDevices = true;
_errorMessage = null;
});
try {
final authManager = context.read<AuthenticationManager>();
final devices = await authManager.getDevices();
setState(() {
_devices = devices;
_isLoadingDevices = false;
});
} on BrandmeisterError catch (e) {
setState(() {
_errorMessage = e.message;
_isLoadingDevices = false;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoadingDevices = false;
});
}
}
@override
Widget build(BuildContext context) {
final authManager = context.watch<AuthenticationManager>();
final userInfo = authManager.userInfo;
return Scaffold(
appBar: AppBar(
title: const Text('Devices'),
),
body: RefreshIndicator(
onRefresh: _loadDevices,
child: _buildBody(userInfo),
),
);
}
Widget _buildBody(UserInfo? userInfo) {
if (_isLoadingDevices && _devices.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (_errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red[300],
),
const SizedBox(height: 16),
Text(
'Error',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey[600]),
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _loadDevices,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
);
}
if (_devices.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.devices_other,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'No devices found',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'You don\'t have any devices registered',
style: TextStyle(color: Colors.grey[600]),
),
],
),
);
}
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),
),
),
],
),
),
..._devices.map((device) => _DeviceRow(
device: device,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DeviceDetailView(device: device),
),
);
},
)),
],
);
}
}
class _DeviceRow extends StatelessWidget {
final Device device;
final VoidCallback onTap;
const _DeviceRow({
required this.device,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Icon(
Icons.radio,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
title: Text(device.callsign ?? 'Unknown'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('ID: ${device.id}'),
if (device.name != null) Text(device.name!),
Text(device.displayLocation),
],
),
isThreeLine: true,
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
);
}
}

197
lib/views/info_view.dart Normal file
View File

@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/authentication_manager.dart';
class InfoView extends StatelessWidget {
const InfoView({super.key});
@override
Widget build(BuildContext context) {
final authManager = context.watch<AuthenticationManager>();
final userInfo = authManager.userInfo;
return Scaffold(
appBar: AppBar(
title: const Text('Info'),
),
body: ListView(
children: [
_buildUserInfoSection(context, userInfo),
const Divider(height: 1),
_buildAppInfoSection(context),
const Divider(height: 1),
_buildLogoutSection(context, authManager),
],
),
);
}
Widget _buildUserInfoSection(BuildContext context, userInfo) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'User Information',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
if (userInfo != null) ...[
_InfoRow(
icon: Icons.person,
label: 'Username',
value: userInfo.username ?? 'N/A',
),
_InfoRow(
icon: Icons.badge,
label: 'Name',
value: userInfo.name ?? 'N/A',
),
_InfoRow(
icon: Icons.numbers,
label: 'User ID',
value: userInfo.id.toString(),
),
] else
const Text('User information not available'),
],
),
);
}
Widget _buildAppInfoSection(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Application',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
const _InfoRow(
icon: Icons.info,
label: 'App Name',
value: 'BM Manager',
),
const _InfoRow(
icon: Icons.analytics,
label: 'Version',
value: '1.0.0',
),
const _InfoRow(
icon: Icons.copyright,
label: 'Copyright',
value: '2026',
),
],
),
);
}
Widget _buildLogoutSection(
BuildContext context, AuthenticationManager authManager) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Account',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Logout'),
content: const Text('Are you sure you want to logout?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style:
TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Logout'),
),
],
),
);
if (confirm == true) {
await authManager.logout();
}
},
icon: const Icon(Icons.logout),
label: const Text('Logout'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
);
}
}
class _InfoRow extends StatelessWidget {
final IconData icon;
final String label;
final String value;
const _InfoRow({
required this.icon,
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
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,
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,276 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import '../models/last_heard_item.dart';
import '../services/lastheard_websocket_client.dart';
class LastHeardView extends StatefulWidget {
const LastHeardView({super.key});
@override
State<LastHeardView> createState() => _LastHeardViewState();
}
class _LastHeardViewState extends State<LastHeardView> {
LastHeardWebSocketClient? _wsClient;
StreamSubscription<Map<String, dynamic>>? _wsSubscription;
final List<LastHeardItem> _items = [];
static const int _maxItems = 200;
bool _isConnecting = true;
@override
void initState() {
super.initState();
_connectWebSocket();
}
@override
void dispose() {
_wsSubscription?.cancel();
_wsClient?.dispose();
super.dispose();
}
Future<void> _connectWebSocket() async {
setState(() {
_isConnecting = true;
});
_wsClient = LastHeardWebSocketClient();
await _wsClient!.connect();
_wsSubscription = _wsClient!.messageStream.listen((message) {
if (message['event'] == 'mqtt' && message['data'] is Map) {
_handleMqttMessage(message['data'] as Map<String, dynamic>);
}
});
setState(() {
_isConnecting = false;
});
}
void _handleMqttMessage(Map<String, dynamic> data) {
try {
final topic = data['topic'] as String?;
if (topic == 'LH') {
final payloadStr = data['payload'] as String?;
if (payloadStr != null) {
final payload = jsonDecode(payloadStr) as Map<String, dynamic>;
final item = LastHeardItem.fromJson(payload);
setState(() {
// Add to the beginning of the list
_items.insert(0, item);
// Keep only the latest 200 items
if (_items.length > _maxItems) {
_items.removeRange(_maxItems, _items.length);
}
});
}
}
} catch (e) {
debugPrint('Error handling MQTT message: $e');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Last Heard'),
actions: [
if (_items.isNotEmpty)
Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(
child: Text(
'${_items.length} ${_items.length == 1 ? "entry" : "entries"}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
),
],
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isConnecting) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Connecting to Last Heard...'),
],
),
);
}
if (_items.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.history,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'No activity yet',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Waiting for Last Heard data...',
style: TextStyle(color: Colors.grey[600]),
),
],
),
);
}
return ListView.builder(
itemCount: _items.length,
itemBuilder: (context, index) {
final item = _items[index];
return _LastHeardItemTile(item: item);
},
);
}
}
class _LastHeardItemTile extends StatelessWidget {
final LastHeardItem item;
const _LastHeardItemTile({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) {
final eventColor = _getEventColor();
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: eventColor.withValues(alpha: 0.2),
child: Icon(
_getEventIcon(),
color: eventColor,
size: 20,
),
),
title: Row(
children: [
Expanded(
child: Text(
item.displayName,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
if (item.rssi != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(4),
),
child: Text(
'${item.rssi} dBm',
style: TextStyle(
fontSize: 11,
color: Colors.grey[700],
),
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.arrow_forward, size: 12, color: Colors.grey[600]),
const SizedBox(width: 4),
Expanded(
child: Text(
item.destinationDisplayName,
style: TextStyle(color: Colors.grey[700]),
),
),
],
),
const SizedBox(height: 2),
Row(
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: 10,
color: eventColor,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 6),
Text(
item.linkTypeName,
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
const SizedBox(width: 6),
Text(
'',
style: TextStyle(color: Colors.grey[400]),
),
const SizedBox(width: 6),
Text(
item.timeAgo,
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
],
),
],
),
isThreeLine: true,
),
);
}
}

View File

@@ -1,10 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/device.dart';
import '../models/user_info.dart';
import '../services/authentication_manager.dart';
import '../services/brandmeister_client.dart';
import 'device_detail_view.dart';
import 'info_view.dart';
import 'last_heard_view.dart';
import 'devices_view.dart';
class MainView extends StatefulWidget {
const MainView({super.key});
@@ -14,214 +11,36 @@ class MainView extends StatefulWidget {
}
class _MainViewState extends State<MainView> {
List<Device> _devices = [];
bool _isLoadingDevices = false;
String? _errorMessage;
int _selectedIndex = 0;
@override
void initState() {
super.initState();
_loadDevices();
}
Future<void> _loadDevices() async {
setState(() {
_isLoadingDevices = true;
_errorMessage = null;
});
try {
final authManager = context.read<AuthenticationManager>();
final devices = await authManager.getDevices();
setState(() {
_devices = devices;
_isLoadingDevices = false;
});
} on BrandmeisterError catch (e) {
setState(() {
_errorMessage = e.message;
_isLoadingDevices = false;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoadingDevices = false;
});
}
}
Future<void> _logout() async {
final authManager = context.read<AuthenticationManager>();
await authManager.logout();
}
final List<Widget> _views = const [
DevicesView(),
LastHeardView(),
InfoView(),
];
@override
Widget build(BuildContext context) {
final authManager = context.watch<AuthenticationManager>();
final userInfo = authManager.userInfo;
return Scaffold(
appBar: AppBar(
title: const Text('BM Manager'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: _logout,
tooltip: 'Logout',
body: _views[_selectedIndex],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: (index) => setState(() => _selectedIndex = index),
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.devices),
label: 'Devices',
),
BottomNavigationBarItem(
icon: Icon(Icons.history),
label: 'Last Heard',
),
BottomNavigationBarItem(
icon: Icon(Icons.info),
label: 'Info',
),
],
),
body: RefreshIndicator(
onRefresh: _loadDevices,
child: _buildBody(userInfo),
),
);
}
Widget _buildBody(UserInfo? userInfo) {
if (_isLoadingDevices && _devices.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (_errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red[300],
),
const SizedBox(height: 16),
Text(
'Error',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey[600]),
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _loadDevices,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
);
}
if (_devices.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.devices_other,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'No devices found',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'You don\'t have any devices registered',
style: TextStyle(color: Colors.grey[600]),
),
],
),
);
}
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),
),
),
],
),
),
..._devices.map((device) => _DeviceRow(
device: device,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DeviceDetailView(device: device),
),
);
},
)),
],
);
}
}
class _DeviceRow extends StatelessWidget {
final Device device;
final VoidCallback onTap;
const _DeviceRow({
required this.device,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Icon(
Icons.radio,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
title: Text(device.callsign ?? 'Unknown'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('ID: ${device.id}'),
if (device.name != null) Text(device.name!),
Text(device.displayLocation),
],
),
isThreeLine: true,
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
);
}
}