import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:file_picker/file_picker.dart'; import 'dart:html' as html; import '../../../domain/entities/server.dart'; import '../../../data/services/excel_import_service.dart'; import '../../../data/data_sources/local/server_local_data_source.dart'; import '../../../injection.dart' as di; import '../../blocs/auth/auth_bloc.dart'; import '../../blocs/auth/auth_event.dart'; import '../../blocs/auth/auth_state.dart'; import '../../blocs/server/server_bloc.dart'; import '../../blocs/server/server_event.dart'; import '../../blocs/server/server_state.dart'; import '../../widgets/app_drawer.dart'; class ServersManagementScreen extends StatefulWidget { const ServersManagementScreen({super.key}); @override State createState() => _ServersManagementScreenState(); } class _ServersManagementScreenState extends State { final TextEditingController _searchController = TextEditingController(); final Set _selectedServers = {}; bool _selectAll = false; // Filter states String _filterType = 'all'; // 'all', 'gcore', 'geviscope' bool? _filterEnabled; // null = all, true = enabled only, false = disabled only String _searchQuery = ''; // Sort states int? _sortColumnIndex; bool _sortAscending = true; @override void dispose() { _searchController.dispose(); super.dispose(); } List _getFilteredServers(List allServers) { var filtered = allServers; // Apply type filter if (_filterType == 'gcore') { filtered = filtered.where((s) => s.type == ServerType.gcore).toList(); } else if (_filterType == 'geviscope') { filtered = filtered.where((s) => s.type == ServerType.geviscope).toList(); } // Apply enabled/disabled filter if (_filterEnabled != null) { filtered = filtered.where((s) => s.enabled == _filterEnabled).toList(); } // Apply search filter if (_searchQuery.isNotEmpty) { final query = _searchQuery.toLowerCase(); filtered = filtered.where((s) { return s.alias.toLowerCase().contains(query) || s.host.toLowerCase().contains(query) || s.user.toLowerCase().contains(query); }).toList(); } // Apply sorting if (_sortColumnIndex != null) { filtered.sort((a, b) { int comparison = 0; switch (_sortColumnIndex) { case 0: // Alias comparison = a.alias.toLowerCase().compareTo(b.alias.toLowerCase()); break; case 1: // Host comparison = a.host.toLowerCase().compareTo(b.host.toLowerCase()); break; case 2: // User comparison = a.user.toLowerCase().compareTo(b.user.toLowerCase()); break; case 3: // Type comparison = a.type.toString().compareTo(b.type.toString()); break; case 4: // Status comparison = a.enabled == b.enabled ? 0 : (a.enabled ? -1 : 1); break; } return _sortAscending ? comparison : -comparison; }); } return filtered; } void _toggleSelectAll(List servers) { setState(() { if (_selectAll) { _selectedServers.clear(); } else { _selectedServers.addAll(servers.map((s) => s.id)); } _selectAll = !_selectAll; }); } void _showBatchDeleteDialog(BuildContext context) { if (_selectedServers.isEmpty) return; showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text('Delete ${_selectedServers.length} Servers'), content: Text( 'Are you sure you want to delete ${_selectedServers.length} selected server${_selectedServers.length != 1 ? 's' : ''}?' ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), TextButton( onPressed: () { // Get all selected servers to determine their types final bloc = context.read(); final state = bloc.state; if (state is ServerLoaded) { for (final id in _selectedServers) { final server = state.servers.firstWhere((s) => s.id == id); bloc.add(DeleteServerEvent(id, server.type)); } } setState(() { _selectedServers.clear(); _selectAll = false; }); Navigator.of(dialogContext).pop(); }, child: const Text('Delete All', style: TextStyle(color: Colors.red)), ), ], ), ); } Future _importFromExcel() async { print('[Import] Function called'); try { print('[Import] Creating ExcelImportService...'); // Create ExcelImportService with ServerLocalDataSource from DI final localDataSource = di.sl(); final excelImportService = ExcelImportService(localDataSource: localDataSource); print('[Import] ExcelImportService created'); print('[Import] Opening file picker...'); // Pick Excel file final result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['xlsx'], withData: true, // Important for web - loads file data ); print('[Import] File picker returned'); if (result == null || result.files.isEmpty) { return; // User cancelled } final file = result.files.first; if (file.bytes == null) { throw Exception('Could not read file data'); } // Show loading dialog if (!mounted) return; showDialog( context: context, barrierDismissible: false, builder: (context) => const Center( child: Card( child: Padding( padding: EdgeInsets.all(24.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 16), Text('Importing servers from Excel...'), ], ), ), ), ), ); // Parse Excel file via backend API print('[Import] Calling backend API...'); final importedServers = await excelImportService.importServersFromExcel( file.bytes!, file.name, ); print('[Import] Received ${importedServers.length} servers from API'); // Get existing servers final bloc = context.read(); final state = bloc.state; final existingServers = state is ServerLoaded ? state.servers : []; print('[Import] Found ${existingServers.length} existing servers'); // Merge: only add new servers final newServers = excelImportService.mergeServers( existing: existingServers, imported: importedServers, ); print('[Import] ${newServers.length} new servers to add'); // Check if there are new servers print('[Import] Import summary: ${importedServers.length} total, ${newServers.length} new'); if (newServers.isEmpty) { print('[Import] No new servers to add'); // Close loading dialog if (mounted) { try { Navigator.of(context).pop(); } catch (e) { print('[Import] Error closing dialog: $e'); } } return; } print('[Import] Proceeding with import of ${newServers.length} servers'); // Save servers directly to storage, bypassing the bloc to avoid triggering rebuilds print('[Import] Saving ${newServers.length} servers directly to storage...'); try { await excelImportService.saveImportedServersToStorage(newServers); print('[Import] All servers saved to storage as unsaved changes'); } catch (e) { print('[Import] ERROR saving servers to storage: $e'); throw Exception('Failed to save servers: $e'); } // Import complete - reload page print('[Import] Successfully imported ${newServers.length} servers'); print('[Import] Servers added as unsaved changes. Use Sync button to upload to GeViServer.'); // Save redirect path so we return to servers page after reload html.window.localStorage['post_import_redirect'] = '/servers'; // Brief delay to ensure Hive writes complete await Future.delayed(const Duration(milliseconds: 300)); print('[Import] Reloading page (auth tokens now persist in localStorage)...'); // Reload page WITHOUT closing dialog to avoid crash // Auth tokens are now stored in localStorage so you'll stay logged in! html.window.location.reload(); } catch (e, stackTrace) { print('[Import] ERROR: $e'); print('[Import] Stack trace: $stackTrace'); // Close loading dialog if open if (mounted) { try { Navigator.of(context, rootNavigator: true).pop(); } catch (_) { // Dialog might already be closed } } // Show error if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Import failed: $e'), backgroundColor: Colors.red, duration: const Duration(seconds: 5), ), ); } } @override Widget build(BuildContext context) { return Scaffold( drawer: const AppDrawer(currentRoute: '/servers'), appBar: AppBar( title: Row( children: [ const Icon(Icons.dns, size: 24), const SizedBox(width: 8), const Text('Server Management'), ], ), actions: [ // Sync button with dirty count badge BlocBuilder( builder: (context, state) { final dirtyCount = state is ServerLoaded ? state.dirtyCount : 0; return Stack( children: [ IconButton( icon: const Icon(Icons.sync), onPressed: dirtyCount > 0 ? () { context.read().add(const SyncServersEvent()); } : null, tooltip: dirtyCount > 0 ? 'Sync $dirtyCount unsaved change${dirtyCount != 1 ? 's' : ''}' : 'No changes to sync', ), if (dirtyCount > 0) Positioned( right: 4, top: 4, child: Container( padding: const EdgeInsets.all(4), decoration: const BoxDecoration( color: Colors.red, shape: BoxShape.circle, ), child: Text( '$dirtyCount', style: const TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold, ), ), ), ), ], ); }, ), // Download/refresh button with confirmation if there are unsaved changes BlocBuilder( builder: (context, state) { final dirtyCount = state is ServerLoaded ? state.dirtyCount : 0; return IconButton( icon: const Icon(Icons.cloud_download), onPressed: () async { // Show confirmation if there are unsaved changes if (dirtyCount > 0) { final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Discard Changes?'), content: Text( 'You have $dirtyCount unsaved change${dirtyCount != 1 ? 's' : ''}. ' 'Downloading from server will discard all local changes.\n\n' 'Do you want to continue?' ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(dialogContext).pop(true), style: FilledButton.styleFrom( backgroundColor: Colors.orange, ), child: const Text('Discard & Download'), ), ], ), ); if (confirmed == true) { if (context.mounted) { context.read().add(const DownloadServersEvent()); } } } else { // No unsaved changes, download directly context.read().add(const DownloadServersEvent()); } }, tooltip: dirtyCount > 0 ? 'Discard $dirtyCount change${dirtyCount != 1 ? 's' : ''} & download from server' : 'Download latest from server', ); }, ), const SizedBox(width: 8), BlocBuilder( builder: (context, state) { if (state is Authenticated) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( children: [ Icon( state.user.role == 'Administrator' ? Icons.admin_panel_settings : Icons.person, size: 20, ), const SizedBox(width: 8), Text(state.user.username), const SizedBox(width: 16), IconButton( icon: const Icon(Icons.logout), onPressed: () { context.read().add(const LogoutRequested()); }, tooltip: 'Logout', ), ], ), ); } return const SizedBox.shrink(); }, ), ], ), body: Column( children: [ // Toolbar with search, filters, and batch actions Container( padding: const EdgeInsets.all(16.0), color: Colors.grey[100], child: Column( children: [ // Search and Add button row Row( children: [ Expanded( child: TextField( controller: _searchController, decoration: InputDecoration( hintText: 'Search by alias, host, or user...', prefixIcon: const Icon(Icons.search), suffixIcon: _searchQuery.isNotEmpty ? IconButton( icon: const Icon(Icons.clear), onPressed: () { setState(() { _searchController.clear(); _searchQuery = ''; }); }, ) : null, border: const OutlineInputBorder(), filled: true, fillColor: Colors.white, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), ), onChanged: (value) { setState(() { _searchQuery = value; }); }, ), ), const SizedBox(width: 16), OutlinedButton.icon( onPressed: () { print('[Import] Button clicked'); try { _importFromExcel(); } catch (e, stackTrace) { print('[Import] Button handler error: $e'); print('[Import] Button handler stack: $stackTrace'); } }, icon: const Icon(Icons.upload_file, size: 18), label: const Text('Import Excel'), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), ), ), const SizedBox(width: 12), ElevatedButton.icon( onPressed: () { _showAddServerDialog(context); }, icon: const Icon(Icons.add), label: const Text('Add Server'), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), ), ], ), const SizedBox(height: 12), // Filter chips and batch actions Row( children: [ // Type filter chips const Text('Type: ', style: TextStyle(fontWeight: FontWeight.w500)), const SizedBox(width: 8), ChoiceChip( label: const Text('All'), selected: _filterType == 'all', onSelected: (selected) { setState(() { _filterType = 'all'; }); }, ), const SizedBox(width: 8), ChoiceChip( label: const Text('G-Core'), selected: _filterType == 'gcore', onSelected: (selected) { setState(() { _filterType = selected ? 'gcore' : 'all'; }); }, ), const SizedBox(width: 8), ChoiceChip( label: const Text('GeViScope'), selected: _filterType == 'geviscope', onSelected: (selected) { setState(() { _filterType = selected ? 'geviscope' : 'all'; }); }, ), const SizedBox(width: 24), // Status filter chips const Text('Status: ', style: TextStyle(fontWeight: FontWeight.w500)), const SizedBox(width: 8), ChoiceChip( label: const Text('All'), selected: _filterEnabled == null, onSelected: (selected) { setState(() { _filterEnabled = null; }); }, ), const SizedBox(width: 8), ChoiceChip( label: const Text('Enabled'), selected: _filterEnabled == true, onSelected: (selected) { setState(() { _filterEnabled = selected ? true : null; }); }, ), const SizedBox(width: 8), ChoiceChip( label: const Text('Disabled'), selected: _filterEnabled == false, onSelected: (selected) { setState(() { _filterEnabled = selected ? false : null; }); }, ), const Spacer(), // Batch actions if (_selectedServers.isNotEmpty) ...[ Text( '${_selectedServers.length} selected', style: const TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(width: 8), TextButton( onPressed: () { setState(() { _selectedServers.clear(); _selectAll = false; }); }, child: const Text('Clear'), ), const SizedBox(width: 16), OutlinedButton.icon( onPressed: () => _showBatchDeleteDialog(context), icon: const Icon(Icons.delete, size: 18), label: const Text('Delete Selected'), style: OutlinedButton.styleFrom( foregroundColor: Colors.red, ), ), ], ], ), ], ), ), // Table Expanded( child: BlocConsumer( listener: (context, state) { if (state is ServerError) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(state.message), backgroundColor: Colors.red, ), ); } else if (state is ServerOperationSuccess) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(state.message), backgroundColor: Colors.green, ), ); } else if (state is ServerSyncSuccess) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(state.message), backgroundColor: Colors.green, ), ); } }, builder: (context, state) { if (state is ServerLoading) { return const Center(child: CircularProgressIndicator()); } else if (state is ServerSyncing) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const CircularProgressIndicator(), const SizedBox(height: 16), Text( state.message, style: Theme.of(context).textTheme.titleMedium, ), ], ), ); } else if (state is ServerDownloading) { return const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator(), SizedBox(height: 16), Text('Downloading servers...'), ], ), ); } else if (state is ServerLoaded) { final filteredServers = _getFilteredServers(state.servers); if (filteredServers.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.dns_outlined, size: 64, color: Colors.grey[400]), const SizedBox(height: 16), Text( _searchQuery.isNotEmpty || _filterEnabled != null || _filterType != 'all' ? 'No matching servers' : 'No servers found', style: Theme.of(context).textTheme.titleLarge?.copyWith( color: Colors.grey[600], ), ), const SizedBox(height: 8), Text( _searchQuery.isNotEmpty || _filterEnabled != null || _filterType != 'all' ? 'Try adjusting your search or filters' : 'Add a server to get started', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey[500], ), ), ], ), ); } return _buildCompactTable(context, filteredServers); } else if (state is ServerError) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.error_outline, size: 64, color: Colors.red[300]), const SizedBox(height: 16), Text( 'Error loading servers', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text( state.message, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey[600], ), textAlign: TextAlign.center, ), const SizedBox(height: 24), ElevatedButton.icon( onPressed: () { context.read().add(const LoadServers()); }, icon: const Icon(Icons.refresh), label: const Text('Retry'), ), ], ), ); } return const Center(child: CircularProgressIndicator()); }, ), ), ], ), ); } Widget _buildCompactTable(BuildContext context, List servers) { return SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(16.0), child: DataTable( columnSpacing: 24, horizontalMargin: 12, headingRowHeight: 48, dataRowHeight: 56, showCheckboxColumn: true, sortColumnIndex: _sortColumnIndex, sortAscending: _sortAscending, columns: [ DataColumn( label: const Text('Alias', style: TextStyle(fontWeight: FontWeight.bold)), onSort: (columnIndex, ascending) { setState(() { _sortColumnIndex = columnIndex; _sortAscending = ascending; }); }, ), DataColumn( label: const Text('Host', style: TextStyle(fontWeight: FontWeight.bold)), onSort: (columnIndex, ascending) { setState(() { _sortColumnIndex = columnIndex; _sortAscending = ascending; }); }, ), DataColumn( label: const Text('User', style: TextStyle(fontWeight: FontWeight.bold)), onSort: (columnIndex, ascending) { setState(() { _sortColumnIndex = columnIndex; _sortAscending = ascending; }); }, ), DataColumn( label: const Text('Type', style: TextStyle(fontWeight: FontWeight.bold)), onSort: (columnIndex, ascending) { setState(() { _sortColumnIndex = columnIndex; _sortAscending = ascending; }); }, ), DataColumn( label: const Text('Status', style: TextStyle(fontWeight: FontWeight.bold)), onSort: (columnIndex, ascending) { setState(() { _sortColumnIndex = columnIndex; _sortAscending = ascending; }); }, ), const DataColumn( label: Text('Actions', style: TextStyle(fontWeight: FontWeight.bold)), ), ], rows: servers.map((server) { final isSelected = _selectedServers.contains(server.id); return DataRow( selected: isSelected, onSelectChanged: (selected) { setState(() { if (selected == true) { _selectedServers.add(server.id); } else { _selectedServers.remove(server.id); } _selectAll = _selectedServers.length == servers.length; }); }, cells: [ // Alias DataCell( Text( server.alias, style: const TextStyle(fontWeight: FontWeight.w500), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), // Host DataCell( Text( server.host, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), // User DataCell( Text( server.user, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), // Type DataCell( Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( color: server.type == ServerType.gcore ? Colors.green.withOpacity(0.1) : Colors.purple.withOpacity(0.1), border: Border.all( color: server.type == ServerType.gcore ? Colors.green : Colors.purple, ), borderRadius: BorderRadius.circular(12), ), child: Text( server.type == ServerType.gcore ? 'G-Core' : 'GeViScope', style: TextStyle( color: server.type == ServerType.gcore ? Colors.green[700] : Colors.purple[700], fontSize: 12, fontWeight: FontWeight.w500, ), ), ), ), // Status DataCell( Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( color: server.enabled ? Colors.green.withOpacity(0.1) : Colors.grey.withOpacity(0.1), border: Border.all( color: server.enabled ? Colors.green : Colors.grey, ), borderRadius: BorderRadius.circular(12), ), child: Text( server.enabled ? 'Enabled' : 'Disabled', style: TextStyle( color: server.enabled ? Colors.green[700] : Colors.grey[700], fontSize: 12, fontWeight: FontWeight.w500, ), ), ), ), // Actions DataCell( Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.edit, size: 20), onPressed: () { context.push('/servers/edit/${server.id}', extra: server); }, tooltip: 'Edit', padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), const SizedBox(width: 8), IconButton( icon: const Icon(Icons.delete, size: 20, color: Colors.red), onPressed: () { _showDeleteConfirmation(context, server); }, tooltip: 'Delete', padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), ], ), ), ], ); }).toList(), ), ), ); } void _showDeleteConfirmation(BuildContext context, Server server) { showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Delete Server'), content: Text('Are you sure you want to delete "${server.alias}"?'), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), TextButton( onPressed: () { context.read().add(DeleteServerEvent(server.id, server.type)); setState(() { _selectedServers.remove(server.id); }); Navigator.of(dialogContext).pop(); }, child: const Text('Delete', style: TextStyle(color: Colors.red)), ), ], ), ); } void _showAddServerDialog(BuildContext context) { showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Add Server'), content: const Text('Choose the server type:'), actions: [ TextButton( onPressed: () { Navigator.of(dialogContext).pop(); context.push('/servers/create?type=gcore'); }, child: const Text('G-Core Server'), ), TextButton( onPressed: () { Navigator.of(dialogContext).pop(); context.push('/servers/create?type=geviscope'); }, child: const Text('GeViScope Server'), ), TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), ], ), ); } }