From c9e83e427778ba91ef33995db9cf2acb23e1f73c Mon Sep 17 00:00:00 2001 From: Administrator Date: Fri, 2 Jan 2026 22:52:51 +0100 Subject: [PATCH] feat: Add compact table views with advanced filtering and batch operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enhanced Flutter web app management in PowerShell scripts - Added Flutter web server to start-services.ps1 as 4th service - Updated stop-services.ps1 to stop Flutter web server - Improved service orchestration and startup sequence - Implemented server caching for improved resilience - Added ServerCacheService for browser localStorage caching - Server lists persist across service restarts - Automatic fallback to cached data when API unavailable - Action picker categories always visible regardless of server status - Redesigned Action Mappings view with compact table layout - Replaced card-based ListView with DataTable for higher density - Added real-time search across name, input, output, description - Implemented multi-filter support (status: enabled/disabled) - Added column sorting (name, input, output, status, executions) - Batch operations: select all/multiple, batch delete - Reduced row height from ~120px to 56px for better overview - Redesigned Servers Management view with compact table layout - Replaced card-based ListView with DataTable - Added search by alias, host, user - Multi-filter support (type: all/G-Core/GeViScope, status: all/enabled/disabled) - Column sorting (alias, host, user, type, status) - Batch operations: select all/multiple, batch delete - Color-coded type and status badges - Improved action picker dialog for GSC/G-Core actions - GSC and G-Core categories always visible - Server validation with clear error messages - Fixed duplicate checkbox issue in table headers - Debug logging for troubleshooting server parameter issues 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- geutebruck-api/start-services.ps1 | 37 +- geutebruck-api/stop-services.ps1 | 29 +- .../data/services/server_cache_service.dart | 95 ++ .../action_mapping_form_screen.dart | 52 +- .../action_mappings_list_screen.dart | 698 ++++++++----- .../servers/servers_management_screen.dart | 987 ++++++++++++------ .../widgets/action_picker_dialog.dart | 66 +- 7 files changed, 1343 insertions(+), 621 deletions(-) create mode 100644 geutebruck_app/lib/data/services/server_cache_service.dart diff --git a/geutebruck-api/start-services.ps1 b/geutebruck-api/start-services.ps1 index 3d45519..e86b596 100644 --- a/geutebruck-api/start-services.ps1 +++ b/geutebruck-api/start-services.ps1 @@ -1,5 +1,5 @@ # Start Geutebruck API Services -# This script starts GeViServer, SDK Bridge, and Python API +# This script starts GeViServer, SDK Bridge, Python API, and Flutter Web App $ErrorActionPreference = "Stop" @@ -15,6 +15,7 @@ $sdkBridgeExe = "$sdkBridgePath\GeViScopeBridge.exe" $apiPath = "C:\DEV\COPILOT\geutebruck-api\src\api" $venvPython = "C:\DEV\COPILOT\geutebruck-api\.venv\Scripts\python.exe" $uvicorn = "C:\DEV\COPILOT\geutebruck-api\.venv\Scripts\uvicorn.exe" +$flutterWebPath = "C:\DEV\COPILOT\geutebruck_app\build\web" # Function to wait for port to be listening function Wait-ForPort { @@ -40,12 +41,13 @@ function Wait-ForPort { $geviServerRunning = Get-Process -Name "GeViServer" -ErrorAction SilentlyContinue $sdkBridgeRunning = Get-Process -Name "GeViScopeBridge" -ErrorAction SilentlyContinue $uvicornRunning = Get-Process -Name "uvicorn" -ErrorAction SilentlyContinue +$flutterRunning = Get-NetTCPConnection -LocalPort 8081 -State Listen -ErrorAction SilentlyContinue # Start GeViServer if ($geviServerRunning) { Write-Host "[SKIP] GeViServer is already running (PID: $($geviServerRunning.Id))" -ForegroundColor Yellow } else { - Write-Host "[1/3] Starting GeViServer..." -ForegroundColor Green + Write-Host "[1/4] Starting GeViServer..." -ForegroundColor Green Start-Process -FilePath $geviServerExe -ArgumentList "console" -WorkingDirectory "C:\GEVISOFT" -WindowStyle Hidden # Wait for GeViServer to start listening on port 7700 @@ -65,7 +67,7 @@ if ($geviServerRunning) { if ($sdkBridgeRunning) { Write-Host "[SKIP] SDK Bridge is already running (PID: $($sdkBridgeRunning.Id))" -ForegroundColor Yellow } else { - Write-Host "[2/3] Starting SDK Bridge..." -ForegroundColor Green + Write-Host "[2/4] Starting SDK Bridge..." -ForegroundColor Green Start-Process -FilePath $sdkBridgeExe -WorkingDirectory $sdkBridgePath -WindowStyle Hidden # Wait for SDK Bridge to start listening on port 50051 @@ -85,7 +87,7 @@ if ($sdkBridgeRunning) { if ($uvicornRunning) { Write-Host "[SKIP] Python API is already running (PID: $($uvicornRunning.Id))" -ForegroundColor Yellow } else { - Write-Host "[3/3] Starting Python API..." -ForegroundColor Green + Write-Host "[3/4] Starting Python API..." -ForegroundColor Green Start-Process -FilePath $uvicorn ` -ArgumentList "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload" ` -WorkingDirectory $apiPath ` @@ -104,6 +106,32 @@ if ($uvicornRunning) { } } +# Start Flutter Web Server +if ($flutterRunning) { + $flutterProcess = Get-NetTCPConnection -LocalPort 8081 -State Listen -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty OwningProcess + Write-Host "[SKIP] Flutter Web is already running (PID: $flutterProcess)" -ForegroundColor Yellow +} else { + Write-Host "[4/4] Starting Flutter Web Server..." -ForegroundColor Green + Start-Process -FilePath "python" ` + -ArgumentList "-m", "http.server", "8081", "--bind", "0.0.0.0" ` + -WorkingDirectory $flutterWebPath ` + -WindowStyle Hidden + + # Wait for Flutter Web to start listening on port 8081 + Write-Host " Waiting for Flutter Web to initialize" -NoNewline -ForegroundColor Gray + if (Wait-ForPort -Port 8081 -TimeoutSeconds 10) { + Write-Host "" + $flutterProcess = Get-NetTCPConnection -LocalPort 8081 -State Listen -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty OwningProcess + Write-Host " [OK] Flutter Web started (PID: $flutterProcess)" -ForegroundColor Green + } else { + Write-Host "" + Write-Host " [ERROR] Flutter Web failed to start listening on port 8081" -ForegroundColor Red + exit 1 + } +} + Write-Host "" Write-Host "========================================" -ForegroundColor Green Write-Host "Services Started Successfully!" -ForegroundColor Green @@ -113,6 +141,7 @@ Write-Host "GeViServer: Running on ports 7700-7703" -ForegroundColor Cyan Write-Host "SDK Bridge: Running on port 50051 (gRPC)" -ForegroundColor Cyan Write-Host "Python API: http://localhost:8000" -ForegroundColor Cyan Write-Host "Swagger UI: http://localhost:8000/docs" -ForegroundColor Cyan +Write-Host "Flutter Web: http://localhost:8081" -ForegroundColor Cyan Write-Host "" Write-Host "To check status, run: .\status-services.ps1" -ForegroundColor Yellow Write-Host "To stop services, run: .\stop-services.ps1" -ForegroundColor Yellow diff --git a/geutebruck-api/stop-services.ps1 b/geutebruck-api/stop-services.ps1 index 2984134..d6d09de 100644 --- a/geutebruck-api/stop-services.ps1 +++ b/geutebruck-api/stop-services.ps1 @@ -1,5 +1,5 @@ # Stop Geutebruck API Services -# This script stops Python API, SDK Bridge, and GeViServer +# This script stops Flutter Web, Python API, SDK Bridge, and GeViServer # # Usage: # .\stop-services.ps1 # Stop all services @@ -20,42 +20,53 @@ if ($KeepGeViServer) { Write-Host "========================================" -ForegroundColor Cyan Write-Host "" +# Stop Flutter Web Server +$flutterPort = Get-NetTCPConnection -LocalPort 8081 -State Listen -ErrorAction SilentlyContinue +if ($flutterPort) { + $flutterPid = $flutterPort | Select-Object -First 1 -ExpandProperty OwningProcess + Write-Host "[1/4] Stopping Flutter Web (PID: $flutterPid)..." -ForegroundColor Yellow + Stop-Process -Id $flutterPid -Force + Write-Host " [OK] Flutter Web stopped" -ForegroundColor Green +} else { + Write-Host "[1/4] Flutter Web is not running" -ForegroundColor Gray +} + # Stop Python API $uvicorn = Get-Process -Name "uvicorn" if ($uvicorn) { - Write-Host "[1/3] Stopping Python API (PID: $($uvicorn.Id))..." -ForegroundColor Yellow + Write-Host "[2/4] Stopping Python API (PID: $($uvicorn.Id))..." -ForegroundColor Yellow Stop-Process -Name "uvicorn" -Force Write-Host " [OK] Python API stopped" -ForegroundColor Green } else { - Write-Host "[1/3] Python API is not running" -ForegroundColor Gray + Write-Host "[2/4] Python API is not running" -ForegroundColor Gray } # Stop SDK Bridge $sdkBridge = Get-Process -Name "GeViScopeBridge" if ($sdkBridge) { - Write-Host "[2/3] Stopping SDK Bridge (PID: $($sdkBridge.Id))..." -ForegroundColor Yellow + Write-Host "[3/4] Stopping SDK Bridge (PID: $($sdkBridge.Id))..." -ForegroundColor Yellow Stop-Process -Name "GeViScopeBridge" -Force Write-Host " [OK] SDK Bridge stopped" -ForegroundColor Green } else { - Write-Host "[2/3] SDK Bridge is not running" -ForegroundColor Gray + Write-Host "[3/4] SDK Bridge is not running" -ForegroundColor Gray } # Stop GeViServer (unless -KeepGeViServer flag is set) if (-not $KeepGeViServer) { $geviServer = Get-Process -Name "GeViServer" if ($geviServer) { - Write-Host "[3/3] Stopping GeViServer (PID: $($geviServer.Id))..." -ForegroundColor Yellow + Write-Host "[4/4] Stopping GeViServer (PID: $($geviServer.Id))..." -ForegroundColor Yellow Stop-Process -Name "GeViServer" -Force Write-Host " [OK] GeViServer stopped" -ForegroundColor Green } else { - Write-Host "[3/3] GeViServer is not running" -ForegroundColor Gray + Write-Host "[4/4] GeViServer is not running" -ForegroundColor Gray } } else { $geviServer = Get-Process -Name "GeViServer" if ($geviServer) { - Write-Host "[3/3] Keeping GeViServer running (PID: $($geviServer.Id))" -ForegroundColor Green + Write-Host "[4/4] Keeping GeViServer running (PID: $($geviServer.Id))" -ForegroundColor Green } else { - Write-Host "[3/3] GeViServer is not running (cannot keep)" -ForegroundColor Yellow + Write-Host "[4/4] GeViServer is not running (cannot keep)" -ForegroundColor Yellow } } diff --git a/geutebruck_app/lib/data/services/server_cache_service.dart b/geutebruck_app/lib/data/services/server_cache_service.dart new file mode 100644 index 0000000..ca66783 --- /dev/null +++ b/geutebruck_app/lib/data/services/server_cache_service.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/action_template.dart'; + +/// Service for caching server lists to prevent categories from disappearing +/// when SDK Bridge is temporarily unavailable during service restarts +class ServerCacheService { + static const String _gcoreServersKey = 'cached_gcore_servers'; + static const String _gscServersKey = 'cached_gsc_servers'; + static const String _cacheTimestampKey = 'server_cache_timestamp'; + + /// Save G-Core servers to cache + static Future cacheGCoreServers(List servers) async { + final prefs = await SharedPreferences.getInstance(); + final json = jsonEncode(servers.map((s) => s.toJson()).toList()); + await prefs.setString(_gcoreServersKey, json); + await prefs.setInt(_cacheTimestampKey, DateTime.now().millisecondsSinceEpoch); + print('[ServerCache] Cached ${servers.length} G-Core servers'); + } + + /// Save GSC servers to cache + static Future cacheGSCServers(List servers) async { + final prefs = await SharedPreferences.getInstance(); + final json = jsonEncode(servers.map((s) => s.toJson()).toList()); + await prefs.setString(_gscServersKey, json); + await prefs.setInt(_cacheTimestampKey, DateTime.now().millisecondsSinceEpoch); + print('[ServerCache] Cached ${servers.length} GSC servers'); + } + + /// Save both server lists + static Future cacheServers({ + required List gcoreServers, + required List gscServers, + }) async { + await cacheGCoreServers(gcoreServers); + await cacheGSCServers(gscServers); + } + + /// Load cached G-Core servers + static Future> getCachedGCoreServers() async { + final prefs = await SharedPreferences.getInstance(); + final json = prefs.getString(_gcoreServersKey); + if (json == null) { + print('[ServerCache] No cached G-Core servers found'); + return []; + } + + try { + final list = jsonDecode(json) as List; + final servers = list.map((item) => ServerInfo.fromJson(item as Map)).toList(); + print('[ServerCache] Loaded ${servers.length} cached G-Core servers'); + return servers; + } catch (e) { + print('[ServerCache] Error loading cached G-Core servers: $e'); + return []; + } + } + + /// Load cached GSC servers + static Future> getCachedGSCServers() async { + final prefs = await SharedPreferences.getInstance(); + final json = prefs.getString(_gscServersKey); + if (json == null) { + print('[ServerCache] No cached GSC servers found'); + return []; + } + + try { + final list = jsonDecode(json) as List; + final servers = list.map((item) => ServerInfo.fromJson(item as Map)).toList(); + print('[ServerCache] Loaded ${servers.length} cached GSC servers'); + return servers; + } catch (e) { + print('[ServerCache] Error loading cached GSC servers: $e'); + return []; + } + } + + /// Get cache age in milliseconds + static Future getCacheAge() async { + final prefs = await SharedPreferences.getInstance(); + final timestamp = prefs.getInt(_cacheTimestampKey); + if (timestamp == null) return null; + return DateTime.now().millisecondsSinceEpoch - timestamp; + } + + /// Clear all cached server data + static Future clearCache() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_gcoreServersKey); + await prefs.remove(_gscServersKey); + await prefs.remove(_cacheTimestampKey); + print('[ServerCache] Cache cleared'); + } +} diff --git a/geutebruck_app/lib/presentation/screens/action_mappings/action_mapping_form_screen.dart b/geutebruck_app/lib/presentation/screens/action_mappings/action_mapping_form_screen.dart index 6e88996..b102ce4 100644 --- a/geutebruck_app/lib/presentation/screens/action_mappings/action_mapping_form_screen.dart +++ b/geutebruck_app/lib/presentation/screens/action_mappings/action_mapping_form_screen.dart @@ -6,6 +6,7 @@ import '../../../domain/entities/action_mapping.dart'; import '../../../data/models/action_output.dart'; import '../../../data/models/action_template.dart'; import '../../../data/services/action_template_service.dart'; +import '../../../data/services/server_cache_service.dart'; import '../../../core/constants/api_constants.dart'; import '../../../core/storage/token_manager.dart'; import '../../blocs/action_mapping/action_mapping_bloc.dart'; @@ -73,6 +74,13 @@ class _ActionMappingFormScreenState extends State { } Future _loadActionTemplates() async { + print('[ActionTemplates] Starting to load action templates...'); + + // Load cached servers first as fallback + final cachedGCoreServers = await ServerCacheService.getCachedGCoreServers(); + final cachedGSCServers = await ServerCacheService.getCachedGSCServers(); + print('[ActionTemplates] Cached servers - G-Core: ${cachedGCoreServers.length}, GSC: ${cachedGSCServers.length}'); + try { // Get auth token from TokenManager final token = TokenManager().accessToken; @@ -85,22 +93,58 @@ class _ActionMappingFormScreenState extends State { final categoriesResponse = await service.getActionCategories(); final templates = await service.getActionTemplates(); + // Use API data if available, otherwise use cached data + final gcoreServers = categoriesResponse.servers.gcoreServers.isNotEmpty + ? categoriesResponse.servers.gcoreServers + : cachedGCoreServers; + + final gscServers = categoriesResponse.servers.gscServers.isNotEmpty + ? categoriesResponse.servers.gscServers + : cachedGSCServers; + + print('[ActionTemplates] API returned - G-Core: ${categoriesResponse.servers.gcoreServers.length}, GSC: ${categoriesResponse.servers.gscServers.length}'); + print('[ActionTemplates] Using - G-Core: ${gcoreServers.length}, GSC: ${gscServers.length}'); + + // Cache the server lists if we got them from API + if (categoriesResponse.servers.gcoreServers.isNotEmpty || categoriesResponse.servers.gscServers.isNotEmpty) { + await ServerCacheService.cacheServers( + gcoreServers: categoriesResponse.servers.gcoreServers, + gscServers: categoriesResponse.servers.gscServers, + ); + print('[ActionTemplates] Cached server lists to local storage'); + } + setState(() { _categories = categoriesResponse.categories; _templates = templates; - _gcoreServers = categoriesResponse.servers.gcoreServers; - _gscServers = categoriesResponse.servers.gscServers; + _gcoreServers = gcoreServers; + _gscServers = gscServers; _isLoadingTemplates = false; }); + + print('[ActionTemplates] Successfully loaded templates and ${gcoreServers.length + gscServers.length} servers'); } catch (e) { + print('[ActionTemplates] Error loading from API: $e'); + + // Fall back to cached data setState(() { + _gcoreServers = cachedGCoreServers; + _gscServers = cachedGSCServers; _isLoadingTemplates = false; }); + + print('[ActionTemplates] Fell back to cached servers - G-Core: ${cachedGCoreServers.length}, GSC: ${cachedGSCServers.length}'); + if (mounted) { + final hasCache = cachedGCoreServers.isNotEmpty || cachedGSCServers.isNotEmpty; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to load action templates: $e'), - backgroundColor: Colors.orange, + content: Text( + hasCache + ? 'Using cached server data (API unavailable)' + : 'Failed to load action templates: $e', + ), + backgroundColor: hasCache ? Colors.orange : Colors.red, ), ); } diff --git a/geutebruck_app/lib/presentation/screens/action_mappings/action_mappings_list_screen.dart b/geutebruck_app/lib/presentation/screens/action_mappings/action_mappings_list_screen.dart index a2a7418..08a75d5 100644 --- a/geutebruck_app/lib/presentation/screens/action_mappings/action_mappings_list_screen.dart +++ b/geutebruck_app/lib/presentation/screens/action_mappings/action_mappings_list_screen.dart @@ -18,8 +18,17 @@ class ActionMappingsListScreen extends StatefulWidget { } class _ActionMappingsListScreenState extends State { - bool _showSearch = false; final TextEditingController _searchController = TextEditingController(); + final Set _selectedMappings = {}; + bool _selectAll = false; + + // Filter states + bool? _filterEnabled; // null = all, true = enabled only, false = disabled only + String _searchQuery = ''; + + // Sort states + int? _sortColumnIndex; + bool _sortAscending = true; @override void dispose() { @@ -27,51 +36,113 @@ class _ActionMappingsListScreenState extends State { super.dispose(); } + List _getFilteredMappings(List allMappings) { + var filtered = allMappings; + + // Apply enabled/disabled filter + if (_filterEnabled != null) { + filtered = filtered.where((m) => m.enabled == _filterEnabled).toList(); + } + + // Apply search filter + if (_searchQuery.isNotEmpty) { + final query = _searchQuery.toLowerCase(); + filtered = filtered.where((m) { + return m.name.toLowerCase().contains(query) || + m.inputAction.toLowerCase().contains(query) || + m.outputActions.any((o) => o.action.toLowerCase().contains(query)) || + (m.description?.toLowerCase().contains(query) ?? false); + }).toList(); + } + + // Apply sorting + if (_sortColumnIndex != null) { + filtered.sort((a, b) { + int comparison = 0; + switch (_sortColumnIndex) { + case 0: // Name + comparison = a.name.toLowerCase().compareTo(b.name.toLowerCase()); + break; + case 1: // Input Action + comparison = a.inputAction.toLowerCase().compareTo(b.inputAction.toLowerCase()); + break; + case 2: // Output Actions + final aOutputs = a.outputActions.map((o) => o.action).join(', ').toLowerCase(); + final bOutputs = b.outputActions.map((o) => o.action).join(', ').toLowerCase(); + comparison = aOutputs.compareTo(bOutputs); + break; + case 3: // Status + comparison = a.enabled == b.enabled ? 0 : (a.enabled ? -1 : 1); + break; + case 4: // Executions + comparison = a.executionCount.compareTo(b.executionCount); + break; + } + return _sortAscending ? comparison : -comparison; + }); + } + + return filtered; + } + + void _toggleSelectAll(List mappings) { + setState(() { + if (_selectAll) { + _selectedMappings.clear(); + } else { + _selectedMappings.addAll(mappings.map((m) => m.id)); + } + _selectAll = !_selectAll; + }); + } + + void _showBatchDeleteDialog(BuildContext context) { + if (_selectedMappings.isEmpty) return; + + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text('Delete ${_selectedMappings.length} Action Mappings'), + content: Text( + 'Are you sure you want to delete ${_selectedMappings.length} selected action mapping${_selectedMappings.length != 1 ? 's' : ''}?' + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + for (final id in _selectedMappings) { + context.read().add(DeleteActionMappingEvent(id)); + } + setState(() { + _selectedMappings.clear(); + _selectAll = false; + }); + Navigator.of(dialogContext).pop(); + }, + child: const Text('Delete All', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } + + @override Widget build(BuildContext context) { return Scaffold( drawer: const AppDrawer(currentRoute: '/action-mappings'), appBar: AppBar( - title: _showSearch - ? TextField( - controller: _searchController, - autofocus: true, - decoration: const InputDecoration( - hintText: 'Search action mappings...', - border: InputBorder.none, - hintStyle: TextStyle(color: Colors.white70), - ), - style: const TextStyle(color: Colors.white), - onChanged: (query) { - if (query.isEmpty) { - context.read().add(const LoadActionMappings()); - } else { - context.read().add(SearchActionMappings(query)); - } - }, - ) - : Row( - children: [ - const Icon(Icons.link, size: 24), - const SizedBox(width: 8), - const Text('Action Mappings'), - ], - ), + title: Row( + children: [ + const Icon(Icons.link, size: 24), + const SizedBox(width: 8), + const Text('Action Mappings'), + ], + ), actions: [ - // Search toggle button - IconButton( - icon: Icon(_showSearch ? Icons.close : Icons.search), - onPressed: () { - setState(() { - _showSearch = !_showSearch; - if (!_showSearch) { - _searchController.clear(); - context.read().add(const LoadActionMappings()); - } - }); - }, - tooltip: _showSearch ? 'Close search' : 'Search', - ), // Sync button with dirty count badge BlocBuilder( builder: (context, state) { @@ -158,24 +229,136 @@ class _ActionMappingsListScreenState extends State { ), body: Column( children: [ - // Add Action Mapping button + // Toolbar with search, filters, and batch actions Container( padding: const EdgeInsets.all(16.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, + color: Colors.grey[100], + child: Column( children: [ - ElevatedButton.icon( - onPressed: () { - context.push('/action-mappings/create'); + // Search and Add button row + Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search by name, input, output, or description...', + 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), + ElevatedButton.icon( + onPressed: () { + context.push('/action-mappings/create'); + }, + icon: const Icon(Icons.add), + label: const Text('Add Mapping'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Filter chips and batch actions + BlocBuilder( + builder: (context, state) { + final allMappings = state is ActionMappingLoaded ? state.mappings : []; + final filteredMappings = _getFilteredMappings(allMappings); + + return Row( + children: [ + // Filter chips + const Text('Filters: ', 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 (_selectedMappings.isNotEmpty) ...[ + Text( + '${_selectedMappings.length} selected', + style: const TextStyle(fontWeight: FontWeight.w500), + ), + const SizedBox(width: 8), + TextButton( + onPressed: () { + setState(() { + _selectedMappings.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, + ), + ), + ], + ], + ); }, - icon: const Icon(Icons.add), - label: const Text('Add Action Mapping'), ), ], ), ), - // Action Mapping list + // Table Expanded( child: BlocConsumer( listener: (context, state) { @@ -231,9 +414,9 @@ class _ActionMappingsListScreenState extends State { ), ); } else if (state is ActionMappingLoaded) { - final mappings = state.mappings; + final filteredMappings = _getFilteredMappings(state.mappings); - if (mappings.isEmpty) { + if (filteredMappings.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -241,14 +424,18 @@ class _ActionMappingsListScreenState extends State { Icon(Icons.link_off, size: 64, color: Colors.grey[400]), const SizedBox(height: 16), Text( - 'No action mappings found', + _searchQuery.isNotEmpty || _filterEnabled != null + ? 'No matching action mappings' + : 'No action mappings found', style: Theme.of(context).textTheme.titleLarge?.copyWith( color: Colors.grey[600], ), ), const SizedBox(height: 8), Text( - 'Add an action mapping to get started', + _searchQuery.isNotEmpty || _filterEnabled != null + ? 'Try adjusting your search or filters' + : 'Add an action mapping to get started', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey[500], ), @@ -258,14 +445,7 @@ class _ActionMappingsListScreenState extends State { ); } - return ListView.builder( - padding: const EdgeInsets.all(16.0), - itemCount: mappings.length, - itemBuilder: (context, index) { - final mapping = mappings[index]; - return _buildActionMappingCard(context, mapping); - }, - ); + return _buildCompactTable(context, filteredMappings); } else if (state is ActionMappingError) { return Center( child: Column( @@ -298,7 +478,6 @@ class _ActionMappingsListScreenState extends State { ); } - // Handle ActionMappingInitial or any other unknown states with a loading indicator return const Center(child: CircularProgressIndicator()); }, ), @@ -308,241 +487,193 @@ class _ActionMappingsListScreenState extends State { ); } - Widget _buildActionMappingCard(BuildContext context, ActionMapping mapping) { - final hasParameters = mapping.inputParameters.isNotEmpty || - mapping.outputActions.any((o) => o.parameters.isNotEmpty); - - return Card( - margin: const EdgeInsets.only(bottom: 12.0), - child: ExpansionTile( - leading: CircleAvatar( - backgroundColor: mapping.enabled - ? Colors.green.withOpacity(0.2) - : Colors.grey.withOpacity(0.2), - child: Icon( - Icons.link, - color: mapping.enabled ? Colors.green : Colors.grey, - ), - ), - title: Row( - children: [ - Expanded( - child: Text( - mapping.name, - style: const TextStyle(fontWeight: FontWeight.bold), - ), + Widget _buildCompactTable(BuildContext context, List mappings) { + 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('Name', style: TextStyle(fontWeight: FontWeight.bold)), + onSort: (columnIndex, ascending) { + setState(() { + _sortColumnIndex = columnIndex; + _sortAscending = ascending; + }); + }, ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: mapping.enabled ? Colors.green : Colors.grey, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - mapping.enabled ? 'Enabled' : 'Disabled', - style: const TextStyle( - color: Colors.white, - fontSize: 12, - ), - ), + DataColumn( + label: const Text('Input Action', style: TextStyle(fontWeight: FontWeight.bold)), + onSort: (columnIndex, ascending) { + setState(() { + _sortColumnIndex = columnIndex; + _sortAscending = ascending; + }); + }, + ), + DataColumn( + label: const Text('Output Actions', 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; + }); + }, + ), + DataColumn( + label: const Text('Executions', style: TextStyle(fontWeight: FontWeight.bold)), + numeric: true, + onSort: (columnIndex, ascending) { + setState(() { + _sortColumnIndex = columnIndex; + _sortAscending = ascending; + }); + }, + ), + const DataColumn( + label: Text('Actions', style: TextStyle(fontWeight: FontWeight.bold)), ), ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - if (mapping.description != null && mapping.description!.isNotEmpty) - Text( - mapping.description!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Row( - children: [ - const Text('Input: ', style: TextStyle(fontWeight: FontWeight.w500)), - Expanded( - child: Text( + rows: mappings.map((mapping) { + final isSelected = _selectedMappings.contains(mapping.id); + + return DataRow( + selected: isSelected, + onSelectChanged: (selected) { + setState(() { + if (selected == true) { + _selectedMappings.add(mapping.id); + } else { + _selectedMappings.remove(mapping.id); + } + _selectAll = _selectedMappings.length == mappings.length; + }); + }, + cells: [ + // Name + DataCell( + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + mapping.name, + style: const TextStyle(fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (mapping.description != null && mapping.description!.isNotEmpty) + Text( + mapping.description!, + style: TextStyle(fontSize: 11, color: Colors.grey[600]), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + + // Input Action + DataCell( + Text( mapping.inputAction, + maxLines: 1, overflow: TextOverflow.ellipsis, ), ), - if (mapping.inputParameters.isNotEmpty) + + // Output Actions + DataCell( + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + mapping.outputActions.map((o) => o.action).join(', '), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ), + ], + ), + ), + + // Status + DataCell( Container( - margin: const EdgeInsets.only(left: 4), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( - color: Colors.blue.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.blue.withOpacity(0.3)), + color: mapping.enabled ? Colors.green.withOpacity(0.1) : Colors.grey.withOpacity(0.1), + border: Border.all( + color: mapping.enabled ? Colors.green : Colors.grey, + ), + borderRadius: BorderRadius.circular(12), ), child: Text( - '${mapping.inputParameters.length} param${mapping.inputParameters.length != 1 ? 's' : ''}', - style: TextStyle(fontSize: 10, color: Colors.blue[700]), + mapping.enabled ? 'Enabled' : 'Disabled', + style: TextStyle( + color: mapping.enabled ? Colors.green[700] : Colors.grey[700], + fontSize: 12, + fontWeight: FontWeight.w500, + ), ), ), - ], - ), - const SizedBox(height: 2), - ...mapping.outputActions.asMap().entries.map((entry) { - final index = entry.key; - final output = entry.value; - return Padding( - padding: const EdgeInsets.only(top: 2), - child: Row( - children: [ - Text( - index == 0 ? 'Output: ' : ' ', - style: const TextStyle(fontWeight: FontWeight.w500), - ), - Expanded( - child: Text( - output.action, - overflow: TextOverflow.ellipsis, - ), - ), - if (output.parameters.isNotEmpty) - Container( - margin: const EdgeInsets.only(left: 4), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.orange.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.orange.withOpacity(0.3)), - ), - child: Text( - '${output.parameters.length} param${output.parameters.length != 1 ? 's' : ''}', - style: TextStyle(fontSize: 10, color: Colors.orange[700]), - ), - ), - ], ), - ); - }), - if (mapping.executionCount > 0) ...[ - const SizedBox(height: 2), - Text('Executions: ${mapping.executionCount}'), - ], - ], - ), - children: [ - if (hasParameters) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Input Parameters - if (mapping.inputParameters.isNotEmpty) ...[ - const Divider(), - const SizedBox(height: 8), - Text( - 'Input Parameters', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - color: Colors.blue[700], - ), - ), - const SizedBox(height: 8), - ...mapping.inputParameters.entries.map((entry) { - return Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 120, - child: Text( - '${entry.key}:', - style: const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 12, - ), - ), - ), - Expanded( - child: Text( - entry.value.toString(), - style: const TextStyle(fontSize: 12), - ), - ), - ], - ), - ); - }), - ], - // Output Action Parameters - ...mapping.outputActions.asMap().entries.map((entry) { - final index = entry.key; - final output = entry.value; - if (output.parameters.isEmpty) return const SizedBox.shrink(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Divider(), - const SizedBox(height: 8), - Text( - 'Output Action ${index + 1}: ${output.action}', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - color: Colors.orange[700], - ), - ), - const SizedBox(height: 8), - ...output.parameters.entries.map((paramEntry) { - return Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 120, - child: Text( - '${paramEntry.key}:', - style: const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 12, - ), - ), - ), - Expanded( - child: Text( - paramEntry.value.toString(), - style: const TextStyle(fontSize: 12), - ), - ), - ], - ), - ); - }), - ], - ); - }), - ], - ), - ), - ], - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit), - onPressed: () { - context.push('/action-mappings/edit/${mapping.id}', extra: mapping); - }, - tooltip: 'Edit', - ), - IconButton( - icon: const Icon(Icons.delete, color: Colors.red), - onPressed: () { - _showDeleteConfirmation(context, mapping); - }, - tooltip: 'Delete', - ), - ], + // Executions + DataCell( + Text( + '${mapping.executionCount}', + style: const TextStyle(fontSize: 12), + ), + ), + + // Actions + DataCell( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit, size: 20), + onPressed: () { + context.push('/action-mappings/edit/${mapping.id}', extra: mapping); + }, + 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, mapping); + }, + tooltip: 'Delete', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ), + ], + ); + }).toList(), ), ), ); @@ -562,6 +693,9 @@ class _ActionMappingsListScreenState extends State { TextButton( onPressed: () { context.read().add(DeleteActionMappingEvent(mapping.id)); + setState(() { + _selectedMappings.remove(mapping.id); + }); Navigator.of(dialogContext).pop(); }, child: const Text('Delete', style: TextStyle(color: Colors.red)), diff --git a/geutebruck_app/lib/presentation/screens/servers/servers_management_screen.dart b/geutebruck_app/lib/presentation/screens/servers/servers_management_screen.dart index 62068d5..abce2ca 100644 --- a/geutebruck_app/lib/presentation/screens/servers/servers_management_screen.dart +++ b/geutebruck_app/lib/presentation/screens/servers/servers_management_screen.dart @@ -18,350 +18,694 @@ class ServersManagementScreen extends StatefulWidget { } 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)), + ), + ], + ), + ); + } @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, - ), + 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 + Builder( + builder: (context) => IconButton( + icon: const Icon(Icons.cloud_download), + onPressed: () { + context.read().add(const DownloadServersEvent()); }, + tooltip: 'Download latest from server', ), - // Download/refresh button - Builder( - builder: (context) => IconButton( - icon: const Icon(Icons.cloud_download), - onPressed: () { - context.read().add(const DownloadServersEvent()); - }, - tooltip: '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), + 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), ), - const SizedBox(width: 8), - Text(state.user.username), - const SizedBox(width: 16), - IconButton( - icon: const Icon(Icons.logout), + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + ), + ), + const SizedBox(width: 16), + 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 LogoutRequested()); + context.read().add(const LoadServers()); }, - tooltip: 'Logout', + icon: const Icon(Icons.refresh), + label: const Text('Retry'), ), ], ), ); } - return const SizedBox.shrink(); + + return const Center(child: CircularProgressIndicator()); }, ), - ], - ), - body: Column( - children: [ - // Filter tabs - Container( - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - _buildFilterChip('All Servers', 'all'), - const SizedBox(width: 8), - _buildFilterChip('G-Core', 'gcore'), - const SizedBox(width: 8), - _buildFilterChip('GeViScope', 'geviscope'), - const Spacer(), - ElevatedButton.icon( - onPressed: () { - _showAddServerDialog(context); - }, - icon: const Icon(Icons.add), - label: const Text('Add Server'), - ), - ], - ), - ), - - // Server list - 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 = _filterServers(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( - 'No servers found', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Colors.grey[600], - ), - ), - const SizedBox(height: 8), - Text( - 'Add a server to get started', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.grey[500], - ), - ), - ], - ), - ); - } - - return ListView.builder( - padding: const EdgeInsets.all(16.0), - itemCount: filteredServers.length, - itemBuilder: (context, index) { - final server = filteredServers[index]; - return _buildServerCard(context, server); - }, - ); - } 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'), - ), - ], - ), - ); - } - - // Handle ServerInitial or any other unknown states with a loading indicator - // instead of "No data" to prevent confusion during state transitions - return const Center(child: CircularProgressIndicator()); - }, - ), - ), - ], - ), - ); - } - - List _filterServers(List servers) { - if (_filterType == 'all') { - return servers; - } else if (_filterType == 'gcore') { - return servers.where((s) => s.type == ServerType.gcore).toList(); - } else { - return servers.where((s) => s.type == ServerType.geviscope).toList(); - } - } - - Widget _buildFilterChip(String label, String value) { - final isSelected = _filterType == value; - return FilterChip( - label: Text(label), - selected: isSelected, - onSelected: (selected) { - setState(() { - _filterType = value; - }); - }, - ); - } - - Widget _buildServerCard(BuildContext context, Server server) { - return Card( - margin: const EdgeInsets.only(bottom: 12.0), - child: ListTile( - leading: CircleAvatar( - backgroundColor: server.type == ServerType.gcore - ? Colors.green.withOpacity(0.2) - : Colors.purple.withOpacity(0.2), - child: Icon( - Icons.dns, - color: server.type == ServerType.gcore ? Colors.green : Colors.purple, ), - ), - title: Row( - children: [ - Text( - server.alias, - style: const TextStyle(fontWeight: FontWeight.bold), + ], + ), + ); + } + + 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; + }); + }, ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: server.enabled ? Colors.green : Colors.grey, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - server.enabled ? 'Enabled' : 'Disabled', - style: const TextStyle( - color: Colors.white, - fontSize: 12, + 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, + ), ), - ), - ), - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - Text('Host: ${server.host}'), - Text('User: ${server.user}'), - Text('Type: ${server.type == ServerType.gcore ? "G-Core" : "GeViScope"}'), - ], - ), - isThreeLine: true, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit), - onPressed: () { - context.push('/servers/edit/${server.id}', extra: server); - }, - tooltip: 'Edit', - ), - IconButton( - icon: const Icon(Icons.delete, color: Colors.red), - onPressed: () { - _showDeleteConfirmation(context, server); - }, - tooltip: 'Delete', - ), - ], + + // 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(), ), ), ); @@ -381,6 +725,9 @@ class _ServersManagementScreenState extends State { 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)), diff --git a/geutebruck_app/lib/presentation/widgets/action_picker_dialog.dart b/geutebruck_app/lib/presentation/widgets/action_picker_dialog.dart index 41c8c01..b74ccba 100644 --- a/geutebruck_app/lib/presentation/widgets/action_picker_dialog.dart +++ b/geutebruck_app/lib/presentation/widgets/action_picker_dialog.dart @@ -112,6 +112,10 @@ class _ActionPickerDialogState extends State { _selectedActionName = actionName; _selectedTemplate = widget.templates[actionName]; + print('[DEBUG] _selectAction: action=$actionName, categoryPrefix=$_categoryPrefix'); + print('[DEBUG] _selectAction: gscServers count=${widget.gscServers.length}'); + print('[DEBUG] _selectAction: gcoreServers count=${widget.gcoreServers.length}'); + // Clear previous parameter controllers for (final controller in _paramControllers.values) { controller.dispose(); @@ -121,6 +125,7 @@ class _ActionPickerDialogState extends State { // Initialize new parameter controllers if (_selectedTemplate != null) { + print('[DEBUG] _selectAction: template parameters=${_selectedTemplate!.parameters}'); for (final param in _selectedTemplate!.parameters) { _paramControllers[param] = TextEditingController(); _paramEnabled[param] = false; // Start with parameters disabled @@ -130,29 +135,41 @@ class _ActionPickerDialogState extends State { if (_categoryPrefix == 'gcore') { // Add G-Core server parameter const serverParam = 'GCoreServer'; + print('[DEBUG] Adding G-Core server parameter'); if (!_paramControllers.containsKey(serverParam)) { _paramControllers[serverParam] = TextEditingController(); + print('[DEBUG] Created new controller for $serverParam'); } _paramEnabled[serverParam] = true; // Auto-select first enabled server if available final enabledServers = widget.gcoreServers.where((s) => s.enabled).toList(); if (enabledServers.isNotEmpty) { _paramControllers[serverParam]?.text = enabledServers.first.alias; + print('[DEBUG] Set $serverParam to ${enabledServers.first.alias}'); } } else if (_categoryPrefix == 'gsc') { // Add GSC server parameter const serverParam = 'GscServer'; + print('[DEBUG] Adding GSC server parameter'); if (!_paramControllers.containsKey(serverParam)) { _paramControllers[serverParam] = TextEditingController(); + print('[DEBUG] Created new controller for $serverParam'); } _paramEnabled[serverParam] = true; // Auto-select first enabled server if available final enabledServers = widget.gscServers.where((s) => s.enabled).toList(); + print('[DEBUG] GSC enabled servers: ${enabledServers.map((s) => s.alias).toList()}'); if (enabledServers.isNotEmpty) { _paramControllers[serverParam]?.text = enabledServers.first.alias; + print('[DEBUG] Set $serverParam to ${enabledServers.first.alias}'); + } else { + print('[DEBUG] WARNING: No enabled GSC servers found!'); } } + print('[DEBUG] Final _paramEnabled keys: ${_paramEnabled.keys.toList()}'); + print('[DEBUG] Final _paramControllers keys: ${_paramControllers.keys.toList()}'); + // Auto-fill caption if empty if (_captionController.text.isEmpty) { _captionController.text = actionName; @@ -197,13 +214,47 @@ class _ActionPickerDialogState extends State { return; } + // Validate server parameter for GSC/G-Core actions + if (_categoryPrefix == 'gsc') { + final gscServerValue = _paramControllers['GscServer']?.text.trim() ?? ''; + if (gscServerValue.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('GSC server is required. Please configure a GeViScope server first.'), + backgroundColor: Colors.red, + duration: Duration(seconds: 4), + ), + ); + return; + } + } else if (_categoryPrefix == 'gcore') { + final gcoreServerValue = _paramControllers['GCoreServer']?.text.trim() ?? ''; + if (gcoreServerValue.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('G-Core server is required. Please configure a G-Core server first.'), + backgroundColor: Colors.red, + duration: Duration(seconds: 4), + ), + ); + return; + } + } + + print('[DEBUG] _onOk: Building parameters...'); + print('[DEBUG] _onOk: _categoryPrefix=$_categoryPrefix'); + print('[DEBUG] _onOk: _paramEnabled=${_paramEnabled}'); + // Build parameters map from enabled parameters final parameters = {}; for (final entry in _paramEnabled.entries) { + print('[DEBUG] _onOk: Checking param ${entry.key}, enabled=${entry.value}'); if (entry.value) { final value = _paramControllers[entry.key]?.text.trim() ?? ''; + print('[DEBUG] _onOk: Param ${entry.key} value="$value"'); if (value.isNotEmpty) { parameters[entry.key] = value; + print('[DEBUG] _onOk: Added param ${entry.key}=$value'); } } } @@ -220,12 +271,16 @@ class _ActionPickerDialogState extends State { parameters['Delay'] = delay; } + print('[DEBUG] _onOk: Final parameters=$parameters'); + // Create ActionOutput with the actual action name (NOT the caption!) final result = ActionOutput( action: _selectedActionName!, parameters: parameters, ); + print('[DEBUG] _onOk: Created ActionOutput: action=$_selectedActionName, parameters=$parameters'); + Navigator.of(context).pop(result); } @@ -237,15 +292,19 @@ class _ActionPickerDialogState extends State { enhanced.addAll(widget.categories); // Add G-Core variants for applicable categories + // ALWAYS show these categories - even if no servers are configured + // User can see the category but won't be able to select a server if none exist for (final category in _serverCategories) { - if (widget.categories.containsKey(category) && widget.gcoreServers.isNotEmpty) { + if (widget.categories.containsKey(category)) { enhanced['G-Core: $category'] = widget.categories[category]!; } } // Add GSC variants for applicable categories + // ALWAYS show these categories - even if no servers are configured + // User can see the category but won't be able to select a server if none exist for (final category in _serverCategories) { - if (widget.categories.containsKey(category) && widget.gscServers.isNotEmpty) { + if (widget.categories.containsKey(category)) { enhanced['GSC: $category'] = widget.categories[category]!; } } @@ -258,12 +317,15 @@ class _ActionPickerDialogState extends State { if (displayCategory.startsWith('G-Core: ')) { _categoryPrefix = 'gcore'; _selectedCategory = displayCategory.substring(8); // Remove "G-Core: " + print('[DEBUG] Parsed category: prefix=gcore, base=$_selectedCategory'); } else if (displayCategory.startsWith('GSC: ')) { _categoryPrefix = 'gsc'; _selectedCategory = displayCategory.substring(5); // Remove "GSC: " + print('[DEBUG] Parsed category: prefix=gsc, base=$_selectedCategory'); } else { _categoryPrefix = null; _selectedCategory = displayCategory; + print('[DEBUG] Parsed category: prefix=null, base=$_selectedCategory'); } }