Compare commits

1 Commits

Author SHA1 Message Date
Administrator
c9e83e4277 feat: Add compact table views with advanced filtering and batch operations
- 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 <noreply@anthropic.com>
2026-01-02 22:52:51 +01:00
7 changed files with 1343 additions and 621 deletions

View File

@@ -1,5 +1,5 @@
# Start Geutebruck API Services # 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" $ErrorActionPreference = "Stop"
@@ -15,6 +15,7 @@ $sdkBridgeExe = "$sdkBridgePath\GeViScopeBridge.exe"
$apiPath = "C:\DEV\COPILOT\geutebruck-api\src\api" $apiPath = "C:\DEV\COPILOT\geutebruck-api\src\api"
$venvPython = "C:\DEV\COPILOT\geutebruck-api\.venv\Scripts\python.exe" $venvPython = "C:\DEV\COPILOT\geutebruck-api\.venv\Scripts\python.exe"
$uvicorn = "C:\DEV\COPILOT\geutebruck-api\.venv\Scripts\uvicorn.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 to wait for port to be listening
function Wait-ForPort { function Wait-ForPort {
@@ -40,12 +41,13 @@ function Wait-ForPort {
$geviServerRunning = Get-Process -Name "GeViServer" -ErrorAction SilentlyContinue $geviServerRunning = Get-Process -Name "GeViServer" -ErrorAction SilentlyContinue
$sdkBridgeRunning = Get-Process -Name "GeViScopeBridge" -ErrorAction SilentlyContinue $sdkBridgeRunning = Get-Process -Name "GeViScopeBridge" -ErrorAction SilentlyContinue
$uvicornRunning = Get-Process -Name "uvicorn" -ErrorAction SilentlyContinue $uvicornRunning = Get-Process -Name "uvicorn" -ErrorAction SilentlyContinue
$flutterRunning = Get-NetTCPConnection -LocalPort 8081 -State Listen -ErrorAction SilentlyContinue
# Start GeViServer # Start GeViServer
if ($geviServerRunning) { if ($geviServerRunning) {
Write-Host "[SKIP] GeViServer is already running (PID: $($geviServerRunning.Id))" -ForegroundColor Yellow Write-Host "[SKIP] GeViServer is already running (PID: $($geviServerRunning.Id))" -ForegroundColor Yellow
} else { } 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 Start-Process -FilePath $geviServerExe -ArgumentList "console" -WorkingDirectory "C:\GEVISOFT" -WindowStyle Hidden
# Wait for GeViServer to start listening on port 7700 # Wait for GeViServer to start listening on port 7700
@@ -65,7 +67,7 @@ if ($geviServerRunning) {
if ($sdkBridgeRunning) { if ($sdkBridgeRunning) {
Write-Host "[SKIP] SDK Bridge is already running (PID: $($sdkBridgeRunning.Id))" -ForegroundColor Yellow Write-Host "[SKIP] SDK Bridge is already running (PID: $($sdkBridgeRunning.Id))" -ForegroundColor Yellow
} else { } 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 Start-Process -FilePath $sdkBridgeExe -WorkingDirectory $sdkBridgePath -WindowStyle Hidden
# Wait for SDK Bridge to start listening on port 50051 # Wait for SDK Bridge to start listening on port 50051
@@ -85,7 +87,7 @@ if ($sdkBridgeRunning) {
if ($uvicornRunning) { if ($uvicornRunning) {
Write-Host "[SKIP] Python API is already running (PID: $($uvicornRunning.Id))" -ForegroundColor Yellow Write-Host "[SKIP] Python API is already running (PID: $($uvicornRunning.Id))" -ForegroundColor Yellow
} else { } else {
Write-Host "[3/3] Starting Python API..." -ForegroundColor Green Write-Host "[3/4] Starting Python API..." -ForegroundColor Green
Start-Process -FilePath $uvicorn ` Start-Process -FilePath $uvicorn `
-ArgumentList "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload" ` -ArgumentList "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload" `
-WorkingDirectory $apiPath ` -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 ""
Write-Host "========================================" -ForegroundColor Green Write-Host "========================================" -ForegroundColor Green
Write-Host "Services Started Successfully!" -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 "SDK Bridge: Running on port 50051 (gRPC)" -ForegroundColor Cyan
Write-Host "Python API: http://localhost:8000" -ForegroundColor Cyan Write-Host "Python API: http://localhost:8000" -ForegroundColor Cyan
Write-Host "Swagger UI: http://localhost:8000/docs" -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 ""
Write-Host "To check status, run: .\status-services.ps1" -ForegroundColor Yellow Write-Host "To check status, run: .\status-services.ps1" -ForegroundColor Yellow
Write-Host "To stop services, run: .\stop-services.ps1" -ForegroundColor Yellow Write-Host "To stop services, run: .\stop-services.ps1" -ForegroundColor Yellow

View File

@@ -1,5 +1,5 @@
# Stop Geutebruck API Services # 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: # Usage:
# .\stop-services.ps1 # Stop all services # .\stop-services.ps1 # Stop all services
@@ -20,42 +20,53 @@ if ($KeepGeViServer) {
Write-Host "========================================" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan
Write-Host "" 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 # Stop Python API
$uvicorn = Get-Process -Name "uvicorn" $uvicorn = Get-Process -Name "uvicorn"
if ($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 Stop-Process -Name "uvicorn" -Force
Write-Host " [OK] Python API stopped" -ForegroundColor Green Write-Host " [OK] Python API stopped" -ForegroundColor Green
} else { } 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 # Stop SDK Bridge
$sdkBridge = Get-Process -Name "GeViScopeBridge" $sdkBridge = Get-Process -Name "GeViScopeBridge"
if ($sdkBridge) { 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 Stop-Process -Name "GeViScopeBridge" -Force
Write-Host " [OK] SDK Bridge stopped" -ForegroundColor Green Write-Host " [OK] SDK Bridge stopped" -ForegroundColor Green
} else { } 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) # Stop GeViServer (unless -KeepGeViServer flag is set)
if (-not $KeepGeViServer) { if (-not $KeepGeViServer) {
$geviServer = Get-Process -Name "GeViServer" $geviServer = Get-Process -Name "GeViServer"
if ($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 Stop-Process -Name "GeViServer" -Force
Write-Host " [OK] GeViServer stopped" -ForegroundColor Green Write-Host " [OK] GeViServer stopped" -ForegroundColor Green
} else { } else {
Write-Host "[3/3] GeViServer is not running" -ForegroundColor Gray Write-Host "[4/4] GeViServer is not running" -ForegroundColor Gray
} }
} else { } else {
$geviServer = Get-Process -Name "GeViServer" $geviServer = Get-Process -Name "GeViServer"
if ($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 { } 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
} }
} }

View File

@@ -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<void> cacheGCoreServers(List<ServerInfo> 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<void> cacheGSCServers(List<ServerInfo> 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<void> cacheServers({
required List<ServerInfo> gcoreServers,
required List<ServerInfo> gscServers,
}) async {
await cacheGCoreServers(gcoreServers);
await cacheGSCServers(gscServers);
}
/// Load cached G-Core servers
static Future<List<ServerInfo>> 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<dynamic>;
final servers = list.map((item) => ServerInfo.fromJson(item as Map<String, dynamic>)).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<List<ServerInfo>> 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<dynamic>;
final servers = list.map((item) => ServerInfo.fromJson(item as Map<String, dynamic>)).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<int?> 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<void> clearCache() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_gcoreServersKey);
await prefs.remove(_gscServersKey);
await prefs.remove(_cacheTimestampKey);
print('[ServerCache] Cache cleared');
}
}

View File

@@ -6,6 +6,7 @@ import '../../../domain/entities/action_mapping.dart';
import '../../../data/models/action_output.dart'; import '../../../data/models/action_output.dart';
import '../../../data/models/action_template.dart'; import '../../../data/models/action_template.dart';
import '../../../data/services/action_template_service.dart'; import '../../../data/services/action_template_service.dart';
import '../../../data/services/server_cache_service.dart';
import '../../../core/constants/api_constants.dart'; import '../../../core/constants/api_constants.dart';
import '../../../core/storage/token_manager.dart'; import '../../../core/storage/token_manager.dart';
import '../../blocs/action_mapping/action_mapping_bloc.dart'; import '../../blocs/action_mapping/action_mapping_bloc.dart';
@@ -73,6 +74,13 @@ class _ActionMappingFormScreenState extends State<ActionMappingFormScreen> {
} }
Future<void> _loadActionTemplates() async { Future<void> _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 { try {
// Get auth token from TokenManager // Get auth token from TokenManager
final token = TokenManager().accessToken; final token = TokenManager().accessToken;
@@ -85,22 +93,58 @@ class _ActionMappingFormScreenState extends State<ActionMappingFormScreen> {
final categoriesResponse = await service.getActionCategories(); final categoriesResponse = await service.getActionCategories();
final templates = await service.getActionTemplates(); 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(() { setState(() {
_categories = categoriesResponse.categories; _categories = categoriesResponse.categories;
_templates = templates; _templates = templates;
_gcoreServers = categoriesResponse.servers.gcoreServers; _gcoreServers = gcoreServers;
_gscServers = categoriesResponse.servers.gscServers; _gscServers = gscServers;
_isLoadingTemplates = false; _isLoadingTemplates = false;
}); });
print('[ActionTemplates] Successfully loaded templates and ${gcoreServers.length + gscServers.length} servers');
} catch (e) { } catch (e) {
print('[ActionTemplates] Error loading from API: $e');
// Fall back to cached data
setState(() { setState(() {
_gcoreServers = cachedGCoreServers;
_gscServers = cachedGSCServers;
_isLoadingTemplates = false; _isLoadingTemplates = false;
}); });
print('[ActionTemplates] Fell back to cached servers - G-Core: ${cachedGCoreServers.length}, GSC: ${cachedGSCServers.length}');
if (mounted) { if (mounted) {
final hasCache = cachedGCoreServers.isNotEmpty || cachedGSCServers.isNotEmpty;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Failed to load action templates: $e'), content: Text(
backgroundColor: Colors.orange, hasCache
? 'Using cached server data (API unavailable)'
: 'Failed to load action templates: $e',
),
backgroundColor: hasCache ? Colors.orange : Colors.red,
), ),
); );
} }

View File

@@ -18,8 +18,17 @@ class ActionMappingsListScreen extends StatefulWidget {
} }
class _ActionMappingsListScreenState extends State<ActionMappingsListScreen> { class _ActionMappingsListScreenState extends State<ActionMappingsListScreen> {
bool _showSearch = false;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final Set<String> _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 @override
void dispose() { void dispose() {
@@ -27,51 +36,113 @@ class _ActionMappingsListScreenState extends State<ActionMappingsListScreen> {
super.dispose(); super.dispose();
} }
List<ActionMapping> _getFilteredMappings(List<ActionMapping> 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<ActionMapping> 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<ActionMappingBloc>().add(DeleteActionMappingEvent(id));
}
setState(() {
_selectedMappings.clear();
_selectAll = false;
});
Navigator.of(dialogContext).pop();
},
child: const Text('Delete All', style: TextStyle(color: Colors.red)),
),
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
drawer: const AppDrawer(currentRoute: '/action-mappings'), drawer: const AppDrawer(currentRoute: '/action-mappings'),
appBar: AppBar( appBar: AppBar(
title: _showSearch title: Row(
? TextField( children: [
controller: _searchController, const Icon(Icons.link, size: 24),
autofocus: true, const SizedBox(width: 8),
decoration: const InputDecoration( const Text('Action Mappings'),
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<ActionMappingBloc>().add(const LoadActionMappings());
} else {
context.read<ActionMappingBloc>().add(SearchActionMappings(query));
}
},
)
: Row(
children: [
const Icon(Icons.link, size: 24),
const SizedBox(width: 8),
const Text('Action Mappings'),
],
),
actions: [ actions: [
// Search toggle button
IconButton(
icon: Icon(_showSearch ? Icons.close : Icons.search),
onPressed: () {
setState(() {
_showSearch = !_showSearch;
if (!_showSearch) {
_searchController.clear();
context.read<ActionMappingBloc>().add(const LoadActionMappings());
}
});
},
tooltip: _showSearch ? 'Close search' : 'Search',
),
// Sync button with dirty count badge // Sync button with dirty count badge
BlocBuilder<ActionMappingBloc, ActionMappingState>( BlocBuilder<ActionMappingBloc, ActionMappingState>(
builder: (context, state) { builder: (context, state) {
@@ -158,24 +229,136 @@ class _ActionMappingsListScreenState extends State<ActionMappingsListScreen> {
), ),
body: Column( body: Column(
children: [ children: [
// Add Action Mapping button // Toolbar with search, filters, and batch actions
Container( Container(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Row( color: Colors.grey[100],
mainAxisAlignment: MainAxisAlignment.end, child: Column(
children: [ children: [
ElevatedButton.icon( // Search and Add button row
onPressed: () { Row(
context.push('/action-mappings/create'); 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<ActionMappingBloc, ActionMappingState>(
builder: (context, state) {
final allMappings = state is ActionMappingLoaded ? state.mappings : <ActionMapping>[];
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( Expanded(
child: BlocConsumer<ActionMappingBloc, ActionMappingState>( child: BlocConsumer<ActionMappingBloc, ActionMappingState>(
listener: (context, state) { listener: (context, state) {
@@ -231,9 +414,9 @@ class _ActionMappingsListScreenState extends State<ActionMappingsListScreen> {
), ),
); );
} else if (state is ActionMappingLoaded) { } else if (state is ActionMappingLoaded) {
final mappings = state.mappings; final filteredMappings = _getFilteredMappings(state.mappings);
if (mappings.isEmpty) { if (filteredMappings.isEmpty) {
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -241,14 +424,18 @@ class _ActionMappingsListScreenState extends State<ActionMappingsListScreen> {
Icon(Icons.link_off, size: 64, color: Colors.grey[400]), Icon(Icons.link_off, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No action mappings found', _searchQuery.isNotEmpty || _filterEnabled != null
? 'No matching action mappings'
: 'No action mappings found',
style: Theme.of(context).textTheme.titleLarge?.copyWith( style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.grey[600], color: Colors.grey[600],
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( 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( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[500], color: Colors.grey[500],
), ),
@@ -258,14 +445,7 @@ class _ActionMappingsListScreenState extends State<ActionMappingsListScreen> {
); );
} }
return ListView.builder( return _buildCompactTable(context, filteredMappings);
padding: const EdgeInsets.all(16.0),
itemCount: mappings.length,
itemBuilder: (context, index) {
final mapping = mappings[index];
return _buildActionMappingCard(context, mapping);
},
);
} else if (state is ActionMappingError) { } else if (state is ActionMappingError) {
return Center( return Center(
child: Column( child: Column(
@@ -298,7 +478,6 @@ class _ActionMappingsListScreenState extends State<ActionMappingsListScreen> {
); );
} }
// Handle ActionMappingInitial or any other unknown states with a loading indicator
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
}, },
), ),
@@ -308,241 +487,193 @@ class _ActionMappingsListScreenState extends State<ActionMappingsListScreen> {
); );
} }
Widget _buildActionMappingCard(BuildContext context, ActionMapping mapping) { Widget _buildCompactTable(BuildContext context, List<ActionMapping> mappings) {
final hasParameters = mapping.inputParameters.isNotEmpty || return SingleChildScrollView(
mapping.outputActions.any((o) => o.parameters.isNotEmpty); child: Padding(
padding: const EdgeInsets.all(16.0),
return Card( child: DataTable(
margin: const EdgeInsets.only(bottom: 12.0), columnSpacing: 24,
child: ExpansionTile( horizontalMargin: 12,
leading: CircleAvatar( headingRowHeight: 48,
backgroundColor: mapping.enabled dataRowHeight: 56,
? Colors.green.withOpacity(0.2) showCheckboxColumn: true,
: Colors.grey.withOpacity(0.2), sortColumnIndex: _sortColumnIndex,
child: Icon( sortAscending: _sortAscending,
Icons.link, columns: [
color: mapping.enabled ? Colors.green : Colors.grey, DataColumn(
), label: const Text('Name', style: TextStyle(fontWeight: FontWeight.bold)),
), onSort: (columnIndex, ascending) {
title: Row( setState(() {
children: [ _sortColumnIndex = columnIndex;
Expanded( _sortAscending = ascending;
child: Text( });
mapping.name, },
style: const TextStyle(fontWeight: FontWeight.bold),
),
), ),
const SizedBox(width: 8), DataColumn(
Container( label: const Text('Input Action', style: TextStyle(fontWeight: FontWeight.bold)),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), onSort: (columnIndex, ascending) {
decoration: BoxDecoration( setState(() {
color: mapping.enabled ? Colors.green : Colors.grey, _sortColumnIndex = columnIndex;
borderRadius: BorderRadius.circular(12), _sortAscending = ascending;
), });
child: Text( },
mapping.enabled ? 'Enabled' : 'Disabled', ),
style: const TextStyle( DataColumn(
color: Colors.white, label: const Text('Output Actions', style: TextStyle(fontWeight: FontWeight.bold)),
fontSize: 12, 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)),
), ),
], ],
), rows: mappings.map((mapping) {
subtitle: Column( final isSelected = _selectedMappings.contains(mapping.id);
crossAxisAlignment: CrossAxisAlignment.start,
children: [ return DataRow(
const SizedBox(height: 4), selected: isSelected,
if (mapping.description != null && mapping.description!.isNotEmpty) onSelectChanged: (selected) {
Text( setState(() {
mapping.description!, if (selected == true) {
maxLines: 2, _selectedMappings.add(mapping.id);
overflow: TextOverflow.ellipsis, } else {
), _selectedMappings.remove(mapping.id);
const SizedBox(height: 4), }
Row( _selectAll = _selectedMappings.length == mappings.length;
children: [ });
const Text('Input: ', style: TextStyle(fontWeight: FontWeight.w500)), },
Expanded( cells: [
child: Text( // 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, mapping.inputAction,
maxLines: 1,
overflow: TextOverflow.ellipsis, 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( Container(
margin: const EdgeInsets.only(left: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.2), color: mapping.enabled ? Colors.green.withOpacity(0.1) : Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(8), border: Border.all(
border: Border.all(color: Colors.blue.withOpacity(0.3)), color: mapping.enabled ? Colors.green : Colors.grey,
),
borderRadius: BorderRadius.circular(12),
), ),
child: Text( child: Text(
'${mapping.inputParameters.length} param${mapping.inputParameters.length != 1 ? 's' : ''}', mapping.enabled ? 'Enabled' : 'Disabled',
style: TextStyle(fontSize: 10, color: Colors.blue[700]), 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( // Executions
crossAxisAlignment: CrossAxisAlignment.start, DataCell(
children: [ Text(
const Divider(), '${mapping.executionCount}',
const SizedBox(height: 8), style: const TextStyle(fontSize: 12),
Text( ),
'Output Action ${index + 1}: ${output.action}', ),
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold, // Actions
color: Colors.orange[700], DataCell(
), Row(
), mainAxisSize: MainAxisSize.min,
const SizedBox(height: 8), children: [
...output.parameters.entries.map((paramEntry) { IconButton(
return Padding( icon: const Icon(Icons.edit, size: 20),
padding: const EdgeInsets.only(bottom: 4), onPressed: () {
child: Row( context.push('/action-mappings/edit/${mapping.id}', extra: mapping);
crossAxisAlignment: CrossAxisAlignment.start, },
children: [ tooltip: 'Edit',
SizedBox( padding: EdgeInsets.zero,
width: 120, constraints: const BoxConstraints(),
child: Text( ),
'${paramEntry.key}:', const SizedBox(width: 8),
style: const TextStyle( IconButton(
fontWeight: FontWeight.w500, icon: const Icon(Icons.delete, size: 20, color: Colors.red),
fontSize: 12, onPressed: () {
), _showDeleteConfirmation(context, mapping);
), },
), tooltip: 'Delete',
Expanded( padding: EdgeInsets.zero,
child: Text( constraints: const BoxConstraints(),
paramEntry.value.toString(), ),
style: const TextStyle(fontSize: 12), ],
), ),
), ),
], ],
), );
); }).toList(),
}),
],
);
}),
],
),
),
],
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',
),
],
), ),
), ),
); );
@@ -562,6 +693,9 @@ class _ActionMappingsListScreenState extends State<ActionMappingsListScreen> {
TextButton( TextButton(
onPressed: () { onPressed: () {
context.read<ActionMappingBloc>().add(DeleteActionMappingEvent(mapping.id)); context.read<ActionMappingBloc>().add(DeleteActionMappingEvent(mapping.id));
setState(() {
_selectedMappings.remove(mapping.id);
});
Navigator.of(dialogContext).pop(); Navigator.of(dialogContext).pop();
}, },
child: const Text('Delete', style: TextStyle(color: Colors.red)), child: const Text('Delete', style: TextStyle(color: Colors.red)),

View File

@@ -112,6 +112,10 @@ class _ActionPickerDialogState extends State<ActionPickerDialog> {
_selectedActionName = actionName; _selectedActionName = actionName;
_selectedTemplate = widget.templates[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 // Clear previous parameter controllers
for (final controller in _paramControllers.values) { for (final controller in _paramControllers.values) {
controller.dispose(); controller.dispose();
@@ -121,6 +125,7 @@ class _ActionPickerDialogState extends State<ActionPickerDialog> {
// Initialize new parameter controllers // Initialize new parameter controllers
if (_selectedTemplate != null) { if (_selectedTemplate != null) {
print('[DEBUG] _selectAction: template parameters=${_selectedTemplate!.parameters}');
for (final param in _selectedTemplate!.parameters) { for (final param in _selectedTemplate!.parameters) {
_paramControllers[param] = TextEditingController(); _paramControllers[param] = TextEditingController();
_paramEnabled[param] = false; // Start with parameters disabled _paramEnabled[param] = false; // Start with parameters disabled
@@ -130,29 +135,41 @@ class _ActionPickerDialogState extends State<ActionPickerDialog> {
if (_categoryPrefix == 'gcore') { if (_categoryPrefix == 'gcore') {
// Add G-Core server parameter // Add G-Core server parameter
const serverParam = 'GCoreServer'; const serverParam = 'GCoreServer';
print('[DEBUG] Adding G-Core server parameter');
if (!_paramControllers.containsKey(serverParam)) { if (!_paramControllers.containsKey(serverParam)) {
_paramControllers[serverParam] = TextEditingController(); _paramControllers[serverParam] = TextEditingController();
print('[DEBUG] Created new controller for $serverParam');
} }
_paramEnabled[serverParam] = true; _paramEnabled[serverParam] = true;
// Auto-select first enabled server if available // Auto-select first enabled server if available
final enabledServers = widget.gcoreServers.where((s) => s.enabled).toList(); final enabledServers = widget.gcoreServers.where((s) => s.enabled).toList();
if (enabledServers.isNotEmpty) { if (enabledServers.isNotEmpty) {
_paramControllers[serverParam]?.text = enabledServers.first.alias; _paramControllers[serverParam]?.text = enabledServers.first.alias;
print('[DEBUG] Set $serverParam to ${enabledServers.first.alias}');
} }
} else if (_categoryPrefix == 'gsc') { } else if (_categoryPrefix == 'gsc') {
// Add GSC server parameter // Add GSC server parameter
const serverParam = 'GscServer'; const serverParam = 'GscServer';
print('[DEBUG] Adding GSC server parameter');
if (!_paramControllers.containsKey(serverParam)) { if (!_paramControllers.containsKey(serverParam)) {
_paramControllers[serverParam] = TextEditingController(); _paramControllers[serverParam] = TextEditingController();
print('[DEBUG] Created new controller for $serverParam');
} }
_paramEnabled[serverParam] = true; _paramEnabled[serverParam] = true;
// Auto-select first enabled server if available // Auto-select first enabled server if available
final enabledServers = widget.gscServers.where((s) => s.enabled).toList(); final enabledServers = widget.gscServers.where((s) => s.enabled).toList();
print('[DEBUG] GSC enabled servers: ${enabledServers.map((s) => s.alias).toList()}');
if (enabledServers.isNotEmpty) { if (enabledServers.isNotEmpty) {
_paramControllers[serverParam]?.text = enabledServers.first.alias; _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 // Auto-fill caption if empty
if (_captionController.text.isEmpty) { if (_captionController.text.isEmpty) {
_captionController.text = actionName; _captionController.text = actionName;
@@ -197,13 +214,47 @@ class _ActionPickerDialogState extends State<ActionPickerDialog> {
return; 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 // Build parameters map from enabled parameters
final parameters = <String, dynamic>{}; final parameters = <String, dynamic>{};
for (final entry in _paramEnabled.entries) { for (final entry in _paramEnabled.entries) {
print('[DEBUG] _onOk: Checking param ${entry.key}, enabled=${entry.value}');
if (entry.value) { if (entry.value) {
final value = _paramControllers[entry.key]?.text.trim() ?? ''; final value = _paramControllers[entry.key]?.text.trim() ?? '';
print('[DEBUG] _onOk: Param ${entry.key} value="$value"');
if (value.isNotEmpty) { if (value.isNotEmpty) {
parameters[entry.key] = value; parameters[entry.key] = value;
print('[DEBUG] _onOk: Added param ${entry.key}=$value');
} }
} }
} }
@@ -220,12 +271,16 @@ class _ActionPickerDialogState extends State<ActionPickerDialog> {
parameters['Delay'] = delay; parameters['Delay'] = delay;
} }
print('[DEBUG] _onOk: Final parameters=$parameters');
// Create ActionOutput with the actual action name (NOT the caption!) // Create ActionOutput with the actual action name (NOT the caption!)
final result = ActionOutput( final result = ActionOutput(
action: _selectedActionName!, action: _selectedActionName!,
parameters: parameters, parameters: parameters,
); );
print('[DEBUG] _onOk: Created ActionOutput: action=$_selectedActionName, parameters=$parameters');
Navigator.of(context).pop(result); Navigator.of(context).pop(result);
} }
@@ -237,15 +292,19 @@ class _ActionPickerDialogState extends State<ActionPickerDialog> {
enhanced.addAll(widget.categories); enhanced.addAll(widget.categories);
// Add G-Core variants for applicable 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) { 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]!; enhanced['G-Core: $category'] = widget.categories[category]!;
} }
} }
// Add GSC variants for applicable categories // 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) { for (final category in _serverCategories) {
if (widget.categories.containsKey(category) && widget.gscServers.isNotEmpty) { if (widget.categories.containsKey(category)) {
enhanced['GSC: $category'] = widget.categories[category]!; enhanced['GSC: $category'] = widget.categories[category]!;
} }
} }
@@ -258,12 +317,15 @@ class _ActionPickerDialogState extends State<ActionPickerDialog> {
if (displayCategory.startsWith('G-Core: ')) { if (displayCategory.startsWith('G-Core: ')) {
_categoryPrefix = 'gcore'; _categoryPrefix = 'gcore';
_selectedCategory = displayCategory.substring(8); // Remove "G-Core: " _selectedCategory = displayCategory.substring(8); // Remove "G-Core: "
print('[DEBUG] Parsed category: prefix=gcore, base=$_selectedCategory');
} else if (displayCategory.startsWith('GSC: ')) { } else if (displayCategory.startsWith('GSC: ')) {
_categoryPrefix = 'gsc'; _categoryPrefix = 'gsc';
_selectedCategory = displayCategory.substring(5); // Remove "GSC: " _selectedCategory = displayCategory.substring(5); // Remove "GSC: "
print('[DEBUG] Parsed category: prefix=gsc, base=$_selectedCategory');
} else { } else {
_categoryPrefix = null; _categoryPrefix = null;
_selectedCategory = displayCategory; _selectedCategory = displayCategory;
print('[DEBUG] Parsed category: prefix=null, base=$_selectedCategory');
} }
} }