diff --git a/lib/models/last_heard_item.dart b/lib/models/last_heard_item.dart new file mode 100644 index 0000000..c05ef50 --- /dev/null +++ b/lib/models/last_heard_item.dart @@ -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 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 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?) + ?.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'; + } + } +} diff --git a/lib/services/lastheard_websocket_client.dart b/lib/services/lastheard_websocket_client.dart new file mode 100644 index 0000000..0cd5a4c --- /dev/null +++ b/lib/services/lastheard_websocket_client.dart @@ -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>? _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 _readyCompleter = Completer(); + + // Socket.IO Engine.IO v4 WebSocket URL for Last Heard + static const String _wsUrl = + 'wss://api.brandmeister.network/lh/?EIO=4&transport=websocket'; + + Stream> get messageStream => + _messageController?.stream ?? const Stream.empty(); + + bool get isConnected => _channel != null; + + bool get isReady => _isReady; + + Future get ready => _readyCompleter.future; + + Future 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>.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; + + 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? 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 != 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; + } +} diff --git a/lib/views/devices_view.dart b/lib/views/devices_view.dart new file mode 100644 index 0000000..427d851 --- /dev/null +++ b/lib/views/devices_view.dart @@ -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 createState() => _DevicesViewState(); +} + +class _DevicesViewState extends State { + List _devices = []; + bool _isLoadingDevices = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadDevices(); + } + + Future _loadDevices() async { + setState(() { + _isLoadingDevices = true; + _errorMessage = null; + }); + + try { + final authManager = context.read(); + 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(); + 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, + ); + } +} diff --git a/lib/views/info_view.dart b/lib/views/info_view.dart new file mode 100644 index 0000000..b19e097 --- /dev/null +++ b/lib/views/info_view.dart @@ -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(); + 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( + 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, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/last_heard_view.dart b/lib/views/last_heard_view.dart new file mode 100644 index 0000000..ca99ee2 --- /dev/null +++ b/lib/views/last_heard_view.dart @@ -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 createState() => _LastHeardViewState(); +} + +class _LastHeardViewState extends State { + LastHeardWebSocketClient? _wsClient; + StreamSubscription>? _wsSubscription; + final List _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 _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); + } + }); + + setState(() { + _isConnecting = false; + }); + } + + void _handleMqttMessage(Map 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; + 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, + ), + ); + } +} diff --git a/lib/views/main_view.dart b/lib/views/main_view.dart index 1eb3797..cd59269 100644 --- a/lib/views/main_view.dart +++ b/lib/views/main_view.dart @@ -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 { - List _devices = []; - bool _isLoadingDevices = false; - String? _errorMessage; + int _selectedIndex = 0; - @override - void initState() { - super.initState(); - _loadDevices(); - } - - Future _loadDevices() async { - setState(() { - _isLoadingDevices = true; - _errorMessage = null; - }); - - try { - final authManager = context.read(); - 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 _logout() async { - final authManager = context.read(); - await authManager.logout(); - } + final List _views = const [ + DevicesView(), + LastHeardView(), + InfoView(), + ]; @override Widget build(BuildContext context) { - final authManager = context.watch(); - 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, ); } }