import 'dart:async'; import 'dart:math'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../models/channel.dart'; import '../utils/dialog_utils.dart'; import '../utils/disconnect_navigation_mixin.dart'; import '../utils/route_transitions.dart'; import '../widgets/battery_indicator.dart'; import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; import '../widgets/quick_switch_bar.dart'; import '../widgets/unread_badge.dart'; import 'channel_chat_screen.dart'; import 'contacts_screen.dart'; import 'map_screen.dart'; import 'settings_screen.dart'; enum ChannelSortOption { manual, name, latestMessages, unread, } class ChannelsScreen extends StatefulWidget { final bool hideBackButton; const ChannelsScreen({ super.key, this.hideBackButton = false, }); @override State createState() => _ChannelsScreenState(); } class _ChannelsScreenState extends State with DisconnectNavigationMixin { final TextEditingController _searchController = TextEditingController(); String _searchQuery = ''; Timer? _searchDebounce; ChannelSortOption _sortOption = ChannelSortOption.manual; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { context.read().getChannels(); }); } @override void dispose() { _searchDebounce?.cancel(); _searchController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final connector = context.watch(); // Auto-navigate back to scanner if disconnected if (!checkConnectionAndNavigate(connector)) { return const SizedBox.shrink(); } final allowBack = !connector.isConnected; return PopScope( canPop: allowBack, child: Scaffold( appBar: AppBar( leading: BatteryIndicator(connector: connector), title: const Text('Channels'), centerTitle: true, automaticallyImplyLeading: false, actions: [ IconButton( icon: const Icon(Icons.bluetooth_disabled), tooltip: 'Disconnect', onPressed: () => _disconnect(context), ), IconButton( icon: const Icon(Icons.tune), tooltip: 'Settings', onPressed: () => Navigator.push( context, MaterialPageRoute(builder: (context) => const SettingsScreen()), ), ), ], ), body: RefreshIndicator( onRefresh: () async { await context.read().getChannels(); }, child: () { if (connector.isLoadingChannels) { return const Center(child: CircularProgressIndicator()); } final channels = connector.channels; if (channels.isEmpty) { return ListView( children: [ SizedBox( height: MediaQuery.of(context).size.height - 200, child: EmptyState( icon: Icons.tag, title: 'No channels configured', action: FilledButton.icon( onPressed: () => _addPublicChannel(context, connector), icon: const Icon(Icons.public), label: const Text('Add Public Channel'), ), ), ), ], ); } final filteredChannels = _filterAndSortChannels(channels, connector); return Column( children: [ Padding( padding: const EdgeInsets.all(8.0), child: TextField( controller: _searchController, decoration: InputDecoration( hintText: 'Search channels...', prefixIcon: const Icon(Icons.search), suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ if (_searchQuery.isNotEmpty) IconButton( icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); setState(() { _searchQuery = ''; }); }, ), _buildFilterButton(), ], ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), onChanged: (value) { _searchDebounce?.cancel(); _searchDebounce = Timer(const Duration(milliseconds: 300), () { if (!mounted) return; setState(() { _searchQuery = value.toLowerCase(); }); }); }, ), ), Expanded( child: filteredChannels.isEmpty ? ListView( children: [ SizedBox( height: MediaQuery.of(context).size.height - 300, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.search_off, size: 64, color: Colors.grey[400]), const SizedBox(height: 16), Text( 'No channels found', style: TextStyle(fontSize: 16, color: Colors.grey[600]), ), ], ), ), ), ], ) : (_sortOption == ChannelSortOption.manual && _searchQuery.isEmpty) ? ReorderableListView.builder( padding: const EdgeInsets.only( left: 16, right: 16, top: 8, bottom: 88, ), buildDefaultDragHandles: false, itemCount: filteredChannels.length, onReorder: (oldIndex, newIndex) { if (newIndex > oldIndex) newIndex -= 1; final reordered = List.from(filteredChannels); final item = reordered.removeAt(oldIndex); reordered.insert(newIndex, item); unawaited( connector.setChannelOrder( reordered.map((c) => c.index).toList(), ), ); }, itemBuilder: (context, index) { final channel = filteredChannels[index]; return _buildChannelTile( context, connector, channel, showDragHandle: true, dragIndex: index, ); }, ) : ListView.builder( padding: const EdgeInsets.only( left: 16, right: 16, top: 8, bottom: 88, ), itemCount: filteredChannels.length, itemBuilder: (context, index) { final channel = filteredChannels[index]; return _buildChannelTile(context, connector, channel); }, ), ), ], ); }(), ), floatingActionButton: FloatingActionButton( onPressed: () => _showAddChannelDialog(context), child: const Icon(Icons.add), ), bottomNavigationBar: SafeArea( top: false, child: QuickSwitchBar( selectedIndex: 1, onDestinationSelected: (index) => _handleQuickSwitch(index, context), ), ), ), ); } Widget _buildChannelTile( BuildContext context, MeshCoreConnector connector, Channel channel, { bool showDragHandle = false, int? dragIndex, } ) { final unreadCount = connector.getUnreadCountForChannel(channel); return Card( key: ValueKey('channel_${channel.index}'), margin: const EdgeInsets.only(bottom: 12), child: ListTile( dense: true, minVerticalPadding: 0, contentPadding: const EdgeInsets.symmetric(horizontal: 12), visualDensity: const VisualDensity(vertical: -2), leading: CircleAvatar( backgroundColor: channel.isPublicChannel ? Colors.green.withValues(alpha: 0.2) : Colors.blue.withValues(alpha: 0.2), child: Icon( channel.isPublicChannel ? Icons.public : channel.name.startsWith('#') ? Icons.tag : Icons.lock, color: channel.isPublicChannel ? Colors.green : Colors.blue, ), ), title: Text( channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name, style: const TextStyle(fontWeight: FontWeight.w500), ), subtitle: Text( channel.name.startsWith('#') ? 'Hashtag channel' : channel.isPublicChannel ? 'Public channel' : 'Private channel', ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (unreadCount > 0) ...[ UnreadBadge(count: unreadCount), const SizedBox(width: 4), ], if (showDragHandle && dragIndex != null) ReorderableDelayedDragStartListener( index: dragIndex, child: Icon( Icons.drag_handle, color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ), onTap: () async { connector.markChannelRead(channel.index); await Future.delayed(const Duration(milliseconds: 50)); if (context.mounted) { Navigator.push( context, MaterialPageRoute( builder: (context) => ChannelChatScreen(channel: channel), ), ); } }, onLongPress: () => _showChannelActions(context, connector, channel), ), ); } void _showChannelActions( BuildContext context, MeshCoreConnector connector, Channel channel, ) { showModalBottomSheet( context: context, builder: (context) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.edit_outlined), title: const Text('Edit channel'), onTap: () async { Navigator.pop(context); await Future.delayed(const Duration(milliseconds: 100)); if (context.mounted) { _showEditChannelDialog(context, connector, channel); } }, ), ListTile( leading: const Icon(Icons.delete_outline, color: Colors.red), title: const Text('Delete channel', style: TextStyle(color: Colors.red)), onTap: () async { Navigator.pop(context); await Future.delayed(const Duration(milliseconds: 100)); if (context.mounted) { _confirmDeleteChannel(context, connector, channel); } }, ), ], ), ), ); } void _handleQuickSwitch(int index, BuildContext context) { if (index == 1) return; switch (index) { case 0: Navigator.pushReplacement( context, buildQuickSwitchRoute( const ContactsScreen(hideBackButton: true), ), ); break; case 2: Navigator.pushReplacement( context, buildQuickSwitchRoute( const MapScreen(hideBackButton: true), ), ); break; } } Future _disconnect(BuildContext context) async { final connector = context.read(); await showDisconnectDialog(context, connector); } Widget _buildFilterButton() { const actionSortManual = 0; const actionSortName = 1; const actionSortLatest = 2; const actionSortUnread = 3; return SortFilterMenu( sections: [ SortFilterMenuSection( title: 'Sort by', options: [ SortFilterMenuOption( value: actionSortManual, label: 'Manual', checked: _sortOption == ChannelSortOption.manual, ), SortFilterMenuOption( value: actionSortName, label: 'A-Z', checked: _sortOption == ChannelSortOption.name, ), SortFilterMenuOption( value: actionSortLatest, label: 'Latest messages', checked: _sortOption == ChannelSortOption.latestMessages, ), SortFilterMenuOption( value: actionSortUnread, label: 'Unread', checked: _sortOption == ChannelSortOption.unread, ), ], ), ], onSelected: (action) { setState(() { switch (action) { case actionSortManual: _sortOption = ChannelSortOption.manual; break; case actionSortLatest: _sortOption = ChannelSortOption.latestMessages; break; case actionSortUnread: _sortOption = ChannelSortOption.unread; break; case actionSortName: default: _sortOption = ChannelSortOption.name; break; } }); }, ); } List _filterAndSortChannels( List channels, MeshCoreConnector connector, ) { var filtered = channels.where((channel) { if (_searchQuery.isEmpty) return true; final label = _normalizeChannelName(channel); return label.toLowerCase().contains(_searchQuery); }).toList(); int compareByName(Channel a, Channel b) { final nameA = _normalizeChannelName(a); final nameB = _normalizeChannelName(b); return nameA.toLowerCase().compareTo(nameB.toLowerCase()); } switch (_sortOption) { case ChannelSortOption.manual: break; case ChannelSortOption.latestMessages: filtered.sort((a, b) { final aMessages = connector.getChannelMessages(a); final bMessages = connector.getChannelMessages(b); final aLast = aMessages.isEmpty ? DateTime(1970) : aMessages.last.timestamp; final bLast = bMessages.isEmpty ? DateTime(1970) : bMessages.last.timestamp; final timeCompare = bLast.compareTo(aLast); if (timeCompare != 0) return timeCompare; return compareByName(a, b); }); break; case ChannelSortOption.unread: filtered.sort((a, b) { final aUnread = connector.getUnreadCountForChannel(a); final bUnread = connector.getUnreadCountForChannel(b); final unreadCompare = bUnread.compareTo(aUnread); if (unreadCompare != 0) return unreadCompare; return compareByName(a, b); }); break; case ChannelSortOption.name: filtered.sort(compareByName); break; } return filtered; } String _normalizeChannelName(Channel channel) { if (channel.name.isEmpty) return 'Channel ${channel.index}'; final trimmed = channel.name.trim(); if (trimmed.startsWith('#') && trimmed.length > 1) { return trimmed.substring(1); } return trimmed; } void _showAddChannelDialog(BuildContext context) { final connector = context.read(); final nameController = TextEditingController(); final pskController = TextEditingController(); final maxChannels = connector.maxChannels; int selectedIndex = _findNextAvailableIndex(connector.channels, maxChannels); bool usePublicPsk = false; showDialog( context: context, builder: (context) => StatefulBuilder( builder: (context, setDialogState) => AlertDialog( title: const Text('Add Channel'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ DropdownButtonFormField( initialValue: selectedIndex, decoration: const InputDecoration( labelText: 'Channel Index', border: OutlineInputBorder(), ), items: List.generate(maxChannels, (i) => i) .map((i) => DropdownMenuItem( value: i, child: Text('Channel $i'), )) .toList(), onChanged: (value) { if (value != null) { setDialogState(() => selectedIndex = value); } }, ), const SizedBox(height: 16), TextField( controller: nameController, decoration: const InputDecoration( labelText: 'Channel Name', border: OutlineInputBorder(), ), maxLength: 31, ), const SizedBox(height: 8), CheckboxListTile( title: const Text('Use Public Channel'), subtitle: const Text('Standard public PSK'), value: usePublicPsk, onChanged: (value) { setDialogState(() { usePublicPsk = value ?? false; if (usePublicPsk) { nameController.text = 'Public'; pskController.text = Channel.publicChannelPsk; } else { pskController.clear(); } }); }, ), if (!usePublicPsk) ...[ const SizedBox(height: 8), TextField( controller: pskController, decoration: InputDecoration( labelText: 'PSK (Hex)', border: const OutlineInputBorder(), suffixIcon: IconButton( icon: const Icon(Icons.casino), tooltip: 'Generate random PSK', onPressed: () { final random = Random.secure(); final bytes = Uint8List(16); for (int i = 0; i < 16; i++) { bytes[i] = random.nextInt(256); } pskController.text = Channel.formatPskHex(bytes); }, ), ), ), ], ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), FilledButton( onPressed: () { final name = nameController.text.trim(); final pskHex = usePublicPsk ? Channel.publicChannelPsk : pskController.text.trim(); if (name.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Please enter a channel name')), ); return; } Uint8List psk; try { psk = Channel.parsePskHex(pskHex); } on FormatException { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('PSK must be 32 hex characters')), ); return; } Navigator.pop(context); connector.setChannel(selectedIndex, name, psk); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Channel "$name" added')), ); } }, child: const Text('Add'), ), ], ), ), ); } void _showEditChannelDialog( BuildContext context, MeshCoreConnector connector, Channel channel, ) { final nameController = TextEditingController(text: channel.name); final pskController = TextEditingController(text: channel.pskHex); bool smazEnabled = connector.isChannelSmazEnabled(channel.index); showDialog( context: context, builder: (context) => StatefulBuilder( builder: (context, setState) => AlertDialog( title: Text('Edit Channel ${channel.index}'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: nameController, decoration: const InputDecoration( labelText: 'Channel Name', border: OutlineInputBorder(), ), maxLength: 31, ), const SizedBox(height: 16), TextField( controller: pskController, decoration: InputDecoration( labelText: 'PSK (Hex)', border: const OutlineInputBorder(), suffixIcon: IconButton( icon: const Icon(Icons.casino), tooltip: 'Generate random PSK', onPressed: () { final random = Random.secure(); final bytes = Uint8List(16); for (int i = 0; i < 16; i++) { bytes[i] = random.nextInt(256); } pskController.text = Channel.formatPskHex(bytes); }, ), ), ), const SizedBox(height: 16), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('SMAZ compression'), value: smazEnabled, onChanged: (value) => setState(() => smazEnabled = value), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), FilledButton( onPressed: () { final name = nameController.text.trim(); final pskHex = pskController.text.trim(); Uint8List psk; try { psk = Channel.parsePskHex(pskHex); } on FormatException { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('PSK must be 32 hex characters')), ); return; } Navigator.pop(context); connector.setChannel(channel.index, name, psk); connector.setChannelSmazEnabled(channel.index, smazEnabled); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Channel "$name" updated')), ); }, child: const Text('Save'), ), ], ), ), ); } void _confirmDeleteChannel( BuildContext context, MeshCoreConnector connector, Channel channel, ) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Delete Channel'), content: Text('Delete "${channel.name}"? This cannot be undone.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), TextButton( onPressed: () { Navigator.pop(context); connector.deleteChannel(channel.index); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Channel "${channel.name}" deleted')), ); }, child: const Text('Delete', style: TextStyle(color: Colors.red)), ), ], ), ); } void _addPublicChannel(BuildContext context, MeshCoreConnector connector) { final psk = Channel.parsePskHex(Channel.publicChannelPsk); connector.setChannel(0, 'Public', psk); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Public channel added')), ); } int _findNextAvailableIndex(List channels, int maxChannels) { final usedIndices = channels.map((c) => c.index).toSet(); for (int i = 0; i < maxChannels; i++) { if (!usedIndices.contains(i)) return i; } return 0; } }