Files
geutebruck/geutebruck_app/lib/presentation/widgets/action_picker_dialog.dart
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

728 lines
24 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../data/models/action_template.dart';
import '../../data/models/action_output.dart';
/// Dialog for picking an action with parameters
/// Matches the native GeViSet app's action picker UI:
/// - Left pane: Category dropdown + Action list
/// - Right pane: Parameters + Caption + Delay + Description
class ActionPickerDialog extends StatefulWidget {
final Map<String, List<String>> categories;
final Map<String, ActionTemplate> templates;
final ActionOutput? existingAction;
final List<ServerInfo> gcoreServers;
final List<ServerInfo> gscServers;
const ActionPickerDialog({
Key? key,
required this.categories,
required this.templates,
this.existingAction,
this.gcoreServers = const [],
this.gscServers = const [],
}) : super(key: key);
@override
State<ActionPickerDialog> createState() => _ActionPickerDialogState();
}
class _ActionPickerDialogState extends State<ActionPickerDialog> {
String? _selectedCategory;
String? _selectedActionName;
ActionTemplate? _selectedTemplate;
String? _categoryPrefix; // 'gcore', 'gsc', or null for base
final Map<String, TextEditingController> _paramControllers = {};
final Map<String, bool> _paramEnabled = {};
late TextEditingController _captionController;
late TextEditingController _delayController;
// Categories that should have G-Core and GSC variants
// Based on native GeViSet app screenshots
static const List<String> _serverCategories = [
'Camera Control',
'Video',
'Device',
'Digital Contacts',
'Backup',
'Remote Export',
'Cash Management',
'Viewer',
'Viewer Notification',
'Point of Sale',
'Ski Data',
'License Plate System',
'Logistics', // Supply chain security
'Lenel Access Control',
'Imex',
'System',
];
@override
void initState() {
super.initState();
_captionController = TextEditingController();
_delayController = TextEditingController(text: '0');
// Initialize from existing action if provided
if (widget.existingAction != null) {
_initializeFromExisting();
} else {
// Default to first category
if (widget.categories.isNotEmpty) {
_selectedCategory = widget.categories.keys.first;
}
}
}
void _initializeFromExisting() {
final existing = widget.existingAction!;
_captionController.text = existing.action;
// Find the template that matches this action
final template = widget.templates[existing.action];
if (template != null) {
_selectedCategory = template.category;
_selectedActionName = template.actionName;
_selectedTemplate = template;
// Initialize parameter controllers with existing values
for (final param in template.parameters) {
final value = existing.parameters[param]?.toString() ?? '';
_paramControllers[param] = TextEditingController(text: value);
_paramEnabled[param] = existing.parameters.containsKey(param);
}
}
}
@override
void dispose() {
_captionController.dispose();
_delayController.dispose();
for (final controller in _paramControllers.values) {
controller.dispose();
}
super.dispose();
}
void _selectAction(String actionName) {
setState(() {
_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();
}
_paramControllers.clear();
_paramEnabled.clear();
// 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
}
// Auto-enable and set server parameter based on category prefix
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;
}
}
});
}
void _setDefaults() {
setState(() {
// Reset all parameters to disabled
for (final key in _paramEnabled.keys) {
_paramEnabled[key] = false;
_paramControllers[key]?.text = '';
}
_delayController.text = '0';
if (_selectedActionName != null) {
_captionController.text = _selectedActionName!;
}
});
}
void _onOk() {
// Validate required caption
if (_captionController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Caption is required'),
backgroundColor: Colors.red,
),
);
return;
}
if (_selectedActionName == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select an action'),
backgroundColor: Colors.red,
),
);
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 = <String, dynamic>{};
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');
}
}
}
// Add caption to parameters
final caption = _captionController.text.trim();
if (caption.isNotEmpty) {
parameters['Caption'] = caption;
}
// Add delay to parameters if non-zero
final delay = _delayController.text.trim();
if (delay.isNotEmpty && delay != '0') {
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);
}
/// Generate enhanced categories including G-Core and GSC variants
Map<String, List<String>> _getEnhancedCategories() {
final enhanced = <String, List<String>>{};
// Add base categories
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)) {
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)) {
enhanced['GSC: $category'] = widget.categories[category]!;
}
}
return enhanced;
}
/// Extract category prefix and base name from display category
void _parseCategoryName(String displayCategory) {
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');
}
}
@override
Widget build(BuildContext context) {
final enhancedCategories = _getEnhancedCategories();
return Dialog(
child: Container(
width: 800,
height: 600,
child: Column(
children: [
// Title bar
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(4),
),
),
child: Row(
children: [
const Icon(Icons.settings, color: Colors.white),
const SizedBox(width: 8),
const Text(
'Action settings...',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// Main content: Two-pane layout
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// LEFT PANE: Category + Action List
_buildLeftPane(),
const VerticalDivider(width: 1),
// RIGHT PANE: Parameters
_buildRightPane(),
],
),
),
// Bottom buttons
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: Colors.grey[300]!),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ElevatedButton(
onPressed: _setDefaults,
child: const Text('Default'),
),
Row(
children: [
ElevatedButton(
onPressed: _onOk,
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
),
child: const Text('Ok'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
],
),
],
),
),
],
),
),
);
}
Widget _buildLeftPane() {
final enhancedCategories = _getEnhancedCategories();
// Find current display category (with prefix)
String? displayCategory;
if (_selectedCategory != null && _categoryPrefix != null) {
if (_categoryPrefix == 'gcore') {
displayCategory = 'G-Core: $_selectedCategory';
} else if (_categoryPrefix == 'gsc') {
displayCategory = 'GSC: $_selectedCategory';
}
} else {
displayCategory = _selectedCategory;
}
return Container(
width: 280,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Category dropdown
const Text(
'Category:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: displayCategory,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
items: enhancedCategories.keys.map((category) {
return DropdownMenuItem(
value: category,
child: Text(category),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_parseCategoryName(value);
_selectedActionName = null;
_selectedTemplate = null;
});
}
},
),
const SizedBox(height: 16),
// Action list
const Text(
'Action:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Expanded(
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey[300]!),
borderRadius: BorderRadius.circular(4),
),
child: _selectedCategory == null
? const Center(
child: Text('Select a category'),
)
: ListView.builder(
itemCount: widget.categories[_selectedCategory]?.length ?? 0,
itemBuilder: (context, index) {
final actionName =
widget.categories[_selectedCategory]![index];
final isSelected = actionName == _selectedActionName;
return ListTile(
title: Text(
actionName,
style: TextStyle(
fontSize: 13,
color: isSelected ? Colors.white : Colors.black,
),
),
selected: isSelected,
selectedTileColor: Theme.of(context).primaryColor,
onTap: () => _selectAction(actionName),
dense: true,
);
},
),
),
),
],
),
);
}
Widget _buildRightPane() {
return Expanded(
child: Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Parameters section
const Text(
'Parameters:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
// Dynamic parameter fields
Expanded(
child: _selectedTemplate == null
? const Center(
child: Text('Select an action to view parameters'),
)
: SingleChildScrollView(
child: _buildParameterFields(),
),
),
const Divider(),
// Caption and Delay fields
Row(
children: [
Expanded(
flex: 3,
child: TextField(
controller: _captionController,
decoration: const InputDecoration(
labelText: 'Caption (required)',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 16),
if (_selectedTemplate?.supportsDelay ?? false) ...[
const Text('Delay execution:'),
const SizedBox(width: 8),
SizedBox(
width: 100,
child: TextField(
controller: _delayController,
decoration: const InputDecoration(
border: OutlineInputBorder(),
suffixText: 'ms',
),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
),
),
],
],
),
const SizedBox(height: 8),
// Description box
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
border: Border.all(color: Colors.grey[300]!),
borderRadius: BorderRadius.circular(4),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Description:',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
const SizedBox(height: 4),
Text(
_selectedTemplate?.description ?? '',
style: const TextStyle(fontSize: 12),
),
],
),
),
],
),
),
);
}
Widget _buildParameterFields() {
if (_selectedTemplate == null) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Text('Select an action to view parameters'),
);
}
// Collect all parameters including dynamically added ones
final allParams = <String>{};
allParams.addAll(_selectedTemplate!.parameters);
allParams.addAll(_paramControllers.keys);
if (allParams.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Text('No parameters required'),
);
}
return Column(
children: allParams.map((param) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Checkbox(
value: _paramEnabled[param] ?? false,
onChanged: (value) {
setState(() {
_paramEnabled[param] = value ?? false;
});
},
),
Expanded(
child: _buildParameterInput(param),
),
],
),
);
}).toList(),
);
}
/// Build appropriate input widget for parameter based on its type
Widget _buildParameterInput(String param) {
// Check if this is a server selection parameter
if (param == 'GCoreServer' || param == 'G-Core alias' || param.toLowerCase().contains('gcore')) {
return _buildServerDropdown(param, widget.gcoreServers, 'G-Core Server');
} else if (param == 'GscServer' || param == 'GeViScope alias' || param.toLowerCase().contains('geviscope')) {
return _buildServerDropdown(param, widget.gscServers, 'GeViScope Server');
}
// Default: text field
return TextField(
controller: _paramControllers[param],
enabled: _paramEnabled[param] ?? false,
decoration: InputDecoration(
labelText: param,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
);
}
/// Build dropdown for server selection
Widget _buildServerDropdown(String param, List<ServerInfo> servers, String label) {
final enabled = _paramEnabled[param] ?? false;
final currentValue = _paramControllers[param]?.text;
return DropdownButtonFormField<String>(
value: servers.any((s) => s.alias == currentValue) ? currentValue : null,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
items: servers.map((server) {
return DropdownMenuItem<String>(
value: server.alias,
child: Text(
'${server.alias} (ID: ${server.id})${server.enabled ? '' : ' [DISABLED]'}',
style: TextStyle(
color: server.enabled ? Colors.black : Colors.grey,
),
),
);
}).toList(),
onChanged: enabled
? (value) {
setState(() {
_paramControllers[param]?.text = value ?? '';
});
}
: null,
);
}
}