diff --git a/lib/models/hose_item.dart b/lib/models/hose_item.dart deleted file mode 100644 index 0e4baf7..0000000 --- a/lib/models/hose_item.dart +++ /dev/null @@ -1,46 +0,0 @@ -class HoseItem { - final int talkgroupId; - final String talkgroupName; - final String sourceCall; - final String? sourceName; - final int slot; - final DateTime lastActivity; - final bool isActive; - - HoseItem({ - required this.talkgroupId, - required this.talkgroupName, - required this.sourceCall, - this.sourceName, - required this.slot, - required this.lastActivity, - required this.isActive, - }); - - String get displayName { - if (sourceName != null && sourceName!.isNotEmpty) { - return '$sourceCall ($sourceName)'; - } - return sourceCall; - } - - HoseItem copyWith({ - int? talkgroupId, - String? talkgroupName, - String? sourceCall, - String? sourceName, - int? slot, - DateTime? lastActivity, - bool? isActive, - }) { - return HoseItem( - talkgroupId: talkgroupId ?? this.talkgroupId, - talkgroupName: talkgroupName ?? this.talkgroupName, - sourceCall: sourceCall ?? this.sourceCall, - sourceName: sourceName ?? this.sourceName, - slot: slot ?? this.slot, - lastActivity: lastActivity ?? this.lastActivity, - isActive: isActive ?? this.isActive, - ); - } -} diff --git a/lib/views/hose_view.dart b/lib/views/hose_view.dart deleted file mode 100644 index 794a88e..0000000 --- a/lib/views/hose_view.dart +++ /dev/null @@ -1,326 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'package:flutter/material.dart'; -import '../models/hose_item.dart'; -import '../services/lastheard_websocket_client.dart'; - -class HoseView extends StatefulWidget { - const HoseView({super.key}); - - @override - State createState() => _HoseViewState(); -} - -class _HoseViewState extends State { - final Map _hoseItems = {}; - LastHeardWebSocketClient? _wsClient; - StreamSubscription>? _wsSubscription; - Timer? _activityTimer; - bool _isConnecting = true; - - // Time threshold to consider a talkgroup as "active" (5 seconds) - static const Duration _activityThreshold = Duration(seconds: 5); - - @override - void initState() { - super.initState(); - _connectWebSocket(); - _startActivityTimer(); - } - - @override - void dispose() { - _wsSubscription?.cancel(); - _wsClient?.dispose(); - _activityTimer?.cancel(); - super.dispose(); - } - - Future _connectWebSocket() async { - if (!mounted) return; - - try { - _wsClient = LastHeardWebSocketClient(); - _wsClient!.connect(); - - _wsSubscription = _wsClient!.messageStream.listen((message) { - _handleMqttMessage(message); - }); - - // Give it a short moment to connect, then hide loading - await Future.delayed(const Duration(milliseconds: 500)); - - if (mounted) { - setState(() { - _isConnecting = false; - }); - } - } catch (e) { - debugPrint('HoseView: Error connecting to WebSocket: $e'); - if (mounted) { - setState(() { - _isConnecting = false; - }); - } - } - } - - void _handleMqttMessage(Map message) { - try { - // Check if this is an MQTT event message - if (message['event'] == 'mqtt' && message['data'] is Map) { - final data = message['data'] as Map; - final topic = data['topic'] as String?; - - if (topic == 'LH') { - final payloadStr = data['payload'] as String?; - if (payloadStr == null) return; - - final payload = jsonDecode(payloadStr) as Map; - - final talkgroupId = payload['DestinationID'] as int?; - final talkgroupName = payload['DestinationName'] as String?; - final sourceCall = payload['SourceCall'] as String?; - final sourceName = payload['SourceName'] as String?; - final slot = payload['Slot'] as int?; - final event = payload['Event'] as String?; - final start = payload['Start'] as int?; - - if (talkgroupId == null || sourceCall == null || slot == null || start == null) { - return; - } - - final lastActivity = DateTime.fromMillisecondsSinceEpoch(start * 1000); - final isActive = event == 'Session-Start'; - - if (mounted) { - setState(() { - _hoseItems[talkgroupId] = HoseItem( - talkgroupId: talkgroupId, - talkgroupName: talkgroupName ?? 'TG $talkgroupId', - sourceCall: sourceCall, - sourceName: sourceName, - slot: slot, - lastActivity: lastActivity, - isActive: isActive, - ); - }); - } - } - } - } catch (e) { - debugPrint('HoseView: Error handling MQTT message: $e'); - } - } - - void _startActivityTimer() { - // Update activity status every second - _activityTimer = Timer.periodic(const Duration(seconds: 1), (_) { - final now = DateTime.now(); - bool needsUpdate = false; - - for (final item in _hoseItems.values) { - if (item.isActive && now.difference(item.lastActivity) > _activityThreshold) { - needsUpdate = true; - break; - } - } - - if (needsUpdate) { - setState(() { - final now = DateTime.now(); - _hoseItems.updateAll((key, item) { - if (item.isActive && now.difference(item.lastActivity) > _activityThreshold) { - return item.copyWith(isActive: false); - } - return item; - }); - }); - } - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Hose'), - ), - body: _buildBody(), - ); - } - - Widget _buildBody() { - if (_isConnecting) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - if (_hoseItems.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.water_drop_outlined, - size: 64, - color: Colors.grey[400], - ), - const SizedBox(height: 16), - Text( - 'No active talkgroups', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text( - 'Waiting for activity...', - style: TextStyle(color: Colors.grey[600]), - ), - ], - ), - ); - } - - final items = _hoseItems.values.toList() - ..sort((a, b) => a.talkgroupId.compareTo(b.talkgroupId)); - - return GridView.builder( - padding: const EdgeInsets.all(8), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 1.0, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - return _buildHoseCard(item); - }, - ); - } - - void _showNotImplementedSnackbar() { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Listening to live QSOs has not been implemented yet'), - duration: Duration(seconds: 2), - ), - ); - } - - Widget _buildHoseCard(HoseItem item) { - final now = DateTime.now(); - final timeSinceActivity = now.difference(item.lastActivity); - - String timeAgo; - if (timeSinceActivity.inSeconds < 60) { - timeAgo = '${timeSinceActivity.inSeconds}s ago'; - } else if (timeSinceActivity.inMinutes < 60) { - timeAgo = '${timeSinceActivity.inMinutes}m ago'; - } else { - timeAgo = '${timeSinceActivity.inHours}h ago'; - } - - return GestureDetector( - onTap: _showNotImplementedSnackbar, - child: Card( - elevation: item.isActive ? 4 : 1, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: item.isActive - ? BorderSide( - color: Theme.of(context).colorScheme.primary, - width: 3, - ) - : BorderSide.none, - ), - child: Container( - padding: const EdgeInsets.all(12), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Talkgroup header - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - 'TS${item.slot}', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - ), - const SizedBox(width: 4), - if (item.isActive) - Icon( - Icons.mic, - size: 16, - color: Theme.of(context).colorScheme.primary, - ), - ], - ), - // Talkgroup name - Expanded( - child: Center( - child: Text( - item.talkgroupName, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ), - // Last talker info - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.displayName, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'TG ${item.talkgroupId}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey[600], - fontSize: 10, - ), - ), - Text( - timeAgo, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey[600], - fontSize: 10, - ), - ), - ], - ), - ], - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/views/more_view.dart b/lib/views/more_view.dart index a9046f6..828c8e2 100644 --- a/lib/views/more_view.dart +++ b/lib/views/more_view.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/authentication_manager.dart'; -import 'hose_view.dart'; class MoreView extends StatelessWidget { const MoreView({super.key}); @@ -16,8 +15,6 @@ class MoreView extends StatelessWidget { ), body: ListView( children: [ - _buildFeaturesSection(context), - const Divider(height: 1), _buildAppInfoSection(context), const Divider(height: 1), _buildLogoutSection(context, authManager), @@ -26,46 +23,6 @@ class MoreView extends StatelessWidget { ); } - Widget _buildFeaturesSection(BuildContext context) { - return Container( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Features', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 16), - Card( - child: ListTile( - leading: CircleAvatar( - backgroundColor: Theme.of(context).colorScheme.primaryContainer, - child: Icon( - Icons.water_drop, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - title: const Text('Hose'), - subtitle: const Text('Live talkgroup activity monitor'), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const HoseView(), - ), - ); - }, - ), - ), - ], - ), - ); - } - Widget _buildAppInfoSection(BuildContext context) { return Container( padding: const EdgeInsets.all(16),