feat: Geutebruck GeViScope/GeViSoft Action Mapping System - MVP

This MVP release provides a complete full-stack solution for managing action mappings
in Geutebruck's GeViScope and GeViSoft video surveillance systems.

## Features

### Flutter Web Application (Port 8081)
- Modern, responsive UI for managing action mappings
- Action picker dialog with full parameter configuration
- Support for both GSC (GeViScope) and G-Core server actions
- Consistent UI for input and output actions with edit/delete capabilities
- Real-time action mapping creation, editing, and deletion
- Server categorization (GSC: prefix for GeViScope, G-Core: prefix for G-Core servers)

### FastAPI REST Backend (Port 8000)
- RESTful API for action mapping CRUD operations
- Action template service with comprehensive action catalog (247 actions)
- Server management (G-Core and GeViScope servers)
- Configuration tree reading and writing
- JWT authentication with role-based access control
- PostgreSQL database integration

### C# SDK Bridge (gRPC, Port 50051)
- Native integration with GeViSoft SDK (GeViProcAPINET_4_0.dll)
- Action mapping creation with correct binary format
- Support for GSC and G-Core action types
- Proper Camera parameter inclusion in action strings (fixes CrossSwitch bug)
- Action ID lookup table with server-specific action IDs
- Configuration reading/writing via SetupClient

## Bug Fixes
- **CrossSwitch Bug**: GSC and G-Core actions now correctly display camera/PTZ head parameters in GeViSet
- Action strings now include Camera parameter: `@ PanLeft (Comment: "", Camera: 101028)`
- Proper filter flags and VideoInput=0 for action mappings
- Correct action ID assignment (4198 for GSC, 9294 for G-Core PanLeft)

## Technical Stack
- **Frontend**: Flutter Web, Dart, Dio HTTP client
- **Backend**: Python FastAPI, PostgreSQL, Redis
- **SDK Bridge**: C# .NET 8.0, gRPC, GeViSoft SDK
- **Authentication**: JWT tokens
- **Configuration**: GeViSoft .set files (binary format)

## Credentials
- GeViSoft/GeViScope: username=sysadmin, password=masterkey
- Default admin: username=admin, password=admin123

## Deployment
All services run on localhost:
- Flutter Web: http://localhost:8081
- FastAPI: http://localhost:8000
- SDK Bridge gRPC: localhost:50051
- GeViServer: localhost (default port)

Generated with Claude Code (https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Administrator
2025-12-31 18:10:54 +01:00
commit 14893e62a5
4189 changed files with 1395076 additions and 0 deletions

View File

@@ -0,0 +1,863 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:uuid/uuid.dart';
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 '../../../core/constants/api_constants.dart';
import '../../../core/storage/token_manager.dart';
import '../../blocs/action_mapping/action_mapping_bloc.dart';
import '../../blocs/action_mapping/action_mapping_event.dart';
import '../../blocs/action_mapping/action_mapping_state.dart';
import '../../widgets/action_picker_dialog.dart';
class ActionMappingFormScreen extends StatefulWidget {
final ActionMapping? mapping; // null for create, non-null for edit
const ActionMappingFormScreen({
super.key,
this.mapping,
});
@override
State<ActionMappingFormScreen> createState() => _ActionMappingFormScreenState();
}
class _ActionMappingFormScreenState extends State<ActionMappingFormScreen> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _nameController;
late TextEditingController _descriptionController;
late TextEditingController _geviscopeInstanceScopeController;
late bool _enabled;
bool _isSaving = false;
bool _isLoadingTemplates = true;
// Action templates and categories
Map<String, List<String>>? _categories;
Map<String, ActionTemplate>? _templates;
List<ServerInfo> _gcoreServers = [];
List<ServerInfo> _gscServers = [];
// Input and output actions (now using ActionOutput objects directly)
ActionOutput? _inputAction;
List<ActionOutput> _outputActions = [];
@override
void initState() {
super.initState();
final mapping = widget.mapping;
_nameController = TextEditingController(text: mapping?.name ?? '');
_descriptionController = TextEditingController(text: mapping?.description ?? '');
_geviscopeInstanceScopeController = TextEditingController(text: mapping?.geviscopeInstanceScope ?? '');
_enabled = mapping?.enabled ?? true;
// Initialize input action (parse from string if needed)
if (mapping != null && mapping.inputAction != null && mapping.inputAction!.isNotEmpty) {
// For backward compatibility, create a simple ActionOutput from the string
_inputAction = ActionOutput(
action: mapping.inputAction!,
parameters: mapping.inputParameters ?? {},
);
}
// Initialize output actions
if (mapping != null && mapping.outputActions.isNotEmpty) {
_outputActions = List.from(mapping.outputActions);
}
// Load action templates and categories
_loadActionTemplates();
}
Future<void> _loadActionTemplates() async {
try {
// Get auth token from TokenManager
final token = TokenManager().accessToken;
final service = ActionTemplateService(
baseUrl: ApiConstants.baseUrl,
authToken: token,
);
final categoriesResponse = await service.getActionCategories();
final templates = await service.getActionTemplates();
setState(() {
_categories = categoriesResponse.categories;
_templates = templates;
_gcoreServers = categoriesResponse.servers.gcoreServers;
_gscServers = categoriesResponse.servers.gscServers;
_isLoadingTemplates = false;
});
} catch (e) {
setState(() {
_isLoadingTemplates = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to load action templates: $e'),
backgroundColor: Colors.orange,
),
);
}
}
}
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
_geviscopeInstanceScopeController.dispose();
super.dispose();
}
bool get isEditMode => widget.mapping != null;
String get screenTitle => isEditMode ? 'Edit Action Mapping' : 'Add Action Mapping';
Future<void> _addOutputAction() async {
if (_categories == null || _templates == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Action templates are still loading...'),
backgroundColor: Colors.orange,
),
);
return;
}
final result = await showDialog<ActionOutput>(
context: context,
builder: (context) => ActionPickerDialog(
categories: _categories!,
templates: _templates!,
gcoreServers: _gcoreServers,
gscServers: _gscServers,
),
);
if (result != null) {
setState(() {
_outputActions.add(result);
});
}
}
Future<void> _selectInputAction() async {
if (_categories == null || _templates == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Action templates are still loading...'),
backgroundColor: Colors.orange,
),
);
return;
}
final result = await showDialog<ActionOutput>(
context: context,
builder: (context) => ActionPickerDialog(
categories: _categories!,
templates: _templates!,
existingAction: _inputAction,
gcoreServers: _gcoreServers,
gscServers: _gscServers,
),
);
if (result != null) {
setState(() {
_inputAction = result;
});
}
}
Future<void> _editInputAction() async {
if (_categories == null || _templates == null) return;
final result = await showDialog<ActionOutput>(
context: context,
builder: (context) => ActionPickerDialog(
categories: _categories!,
templates: _templates!,
existingAction: _inputAction,
gcoreServers: _gcoreServers,
gscServers: _gscServers,
),
);
if (result != null) {
setState(() {
_inputAction = result;
});
}
}
void _removeInputAction() {
setState(() {
_inputAction = null;
});
}
Future<void> _editOutputAction(int index) async {
if (_categories == null || _templates == null) return;
final result = await showDialog<ActionOutput>(
context: context,
builder: (context) => ActionPickerDialog(
categories: _categories!,
templates: _templates!,
existingAction: _outputActions[index],
gcoreServers: _gcoreServers,
gscServers: _gscServers,
),
);
if (result != null) {
setState(() {
_outputActions[index] = result;
});
}
}
void _removeOutputAction(int index) {
setState(() {
_outputActions.removeAt(index);
});
}
void _moveOutputActionUp(int index) {
if (index > 0) {
setState(() {
final action = _outputActions.removeAt(index);
_outputActions.insert(index - 1, action);
});
}
}
void _moveOutputActionDown(int index) {
if (index < _outputActions.length - 1) {
setState(() {
final action = _outputActions.removeAt(index);
_outputActions.insert(index + 1, action);
});
}
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
setState(() {
_isSaving = true;
});
// Validate that input action is provided
if (_inputAction == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Input action is required'),
backgroundColor: Colors.red,
),
);
setState(() {
_isSaving = false;
});
return;
}
// Validate that at least one output action is provided
if (_outputActions.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('At least one output action is required'),
backgroundColor: Colors.red,
),
);
setState(() {
_isSaving = false;
});
return;
}
final now = DateTime.now();
final mapping = ActionMapping(
id: widget.mapping?.id ?? const Uuid().v4(),
name: _nameController.text.trim(),
description: _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim(),
inputAction: _inputAction!.action,
inputParameters: _inputAction!.parameters,
outputActions: _outputActions,
geviscopeInstanceScope: _geviscopeInstanceScopeController.text.trim().isEmpty
? null
: _geviscopeInstanceScopeController.text.trim(),
enabled: _enabled,
executionCount: widget.mapping?.executionCount ?? 0,
lastExecuted: widget.mapping?.lastExecuted,
createdAt: widget.mapping?.createdAt ?? now,
updatedAt: now,
createdBy: widget.mapping?.createdBy ?? 'current-user',
);
if (isEditMode) {
context.read<ActionMappingBloc>().add(UpdateActionMappingEvent(mapping));
} else {
context.read<ActionMappingBloc>().add(CreateActionMappingEvent(mapping));
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(screenTitle),
),
body: BlocListener<ActionMappingBloc, ActionMappingState>(
listener: (context, state) {
if (state is ActionMappingLoaded && _isSaving) {
// Success! Show message and go back
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isEditMode
? 'Action mapping updated successfully'
: 'Action mapping created successfully'),
backgroundColor: Colors.green,
),
);
context.pop();
} else if (state is ActionMappingError) {
setState(() {
_isSaving = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.red,
),
);
}
},
child: BlocBuilder<ActionMappingBloc, ActionMappingState>(
builder: (context, state) {
final isLoading = state is ActionMappingLoading;
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Name Field
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name *',
hintText: 'e.g., Motion Detection Alert',
border: OutlineInputBorder(),
helperText: 'Descriptive name for this action mapping (1-100 characters)',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Name is required';
}
if (value.trim().length > 100) {
return 'Name must be 100 characters or less';
}
return null;
},
),
const SizedBox(height: 16),
// Description Field
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description',
hintText: 'Optional description of what this mapping does',
border: OutlineInputBorder(),
helperText: 'Optional (max 500 characters)',
),
maxLines: 3,
validator: (value) {
if (value != null && value.trim().length > 500) {
return 'Description must be 500 characters or less';
}
return null;
},
),
const SizedBox(height: 16),
// Input Action Section
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Input Action *',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
if (_inputAction == null)
ElevatedButton.icon(
onPressed: _isLoadingTemplates ? null : _selectInputAction,
icon: const Icon(Icons.add, size: 18),
label: const Text('Pick Input Action'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
),
],
),
const SizedBox(height: 8),
const Text(
'The triggering action/event that will execute the output actions',
style: TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(height: 12),
// Input Action Display
if (_inputAction == null)
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey[300]!),
borderRadius: BorderRadius.circular(8),
color: Colors.grey[50],
),
child: Center(
child: Column(
children: [
Icon(Icons.input, size: 48, color: Colors.grey[400]),
const SizedBox(height: 8),
Text(
'No input action selected',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
'Click "Pick Input Action" to select the triggering action',
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
],
),
),
),
// Display input action
if (_inputAction != null)
Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'INPUT',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
),
],
),
const SizedBox(height: 8),
Text(
_inputAction!.action,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit, size: 20),
onPressed: () => _editInputAction(),
tooltip: 'Edit',
color: Colors.blue,
),
IconButton(
icon: const Icon(Icons.delete, size: 20),
onPressed: () => _removeInputAction(),
tooltip: 'Remove',
color: Colors.red,
),
],
),
],
),
if (_inputAction!.parameters.isNotEmpty) ...[
const Divider(height: 20),
Wrap(
spacing: 8,
runSpacing: 8,
children: _inputAction!.parameters.entries
.map((param) => Chip(
label: Text(
'${param.key}: ${param.value}',
style: const TextStyle(fontSize: 12),
),
backgroundColor: Colors.blue[50],
))
.toList(),
),
],
],
),
),
),
const SizedBox(height: 24),
// Output Actions Section
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Output Actions *',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
ElevatedButton.icon(
onPressed: _isLoadingTemplates ? null : _addOutputAction,
icon: const Icon(Icons.add, size: 18),
label: const Text('Add Output Action'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
),
],
),
const SizedBox(height: 8),
const Text(
'Actions to execute when the input action is triggered (at least one required)',
style: TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(height: 12),
// Loading indicator for templates
if (_isLoadingTemplates)
const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
),
// Output Actions List
if (!_isLoadingTemplates && _outputActions.isEmpty)
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey[300]!),
borderRadius: BorderRadius.circular(8),
color: Colors.grey[50],
),
child: Center(
child: Column(
children: [
Icon(Icons.play_arrow, size: 48, color: Colors.grey[400]),
const SizedBox(height: 8),
Text(
'No output actions yet',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
'Click "Add Output Action" to add actions using the action picker',
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
],
),
),
),
// Display output actions
if (!_isLoadingTemplates)
..._outputActions.asMap().entries.map((entry) {
final index = entry.key;
final action = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with action name and controls
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Output ${index + 1}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
),
],
),
const SizedBox(height: 8),
Text(
action.action,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
// Show category if available
if (_templates != null && _templates![action.action] != null)
Text(
'Category: ${_templates![action.action]!.category}',
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
),
],
),
),
// Action buttons
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.arrow_upward, size: 20),
onPressed: index > 0 ? () => _moveOutputActionUp(index) : null,
tooltip: 'Move up',
),
IconButton(
icon: const Icon(Icons.arrow_downward, size: 20),
onPressed: index < _outputActions.length - 1
? () => _moveOutputActionDown(index)
: null,
tooltip: 'Move down',
),
IconButton(
icon: const Icon(Icons.edit, size: 20),
onPressed: () => _editOutputAction(index),
tooltip: 'Edit',
color: Colors.blue,
),
IconButton(
icon: const Icon(Icons.delete, size: 20),
onPressed: () => _removeOutputAction(index),
tooltip: 'Remove',
color: Colors.red,
),
],
),
],
),
// Parameters
if (action.parameters.isNotEmpty) ...[
const SizedBox(height: 12),
const Divider(),
const SizedBox(height: 8),
Text(
'Parameters (${action.parameters.length})',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.orange[700],
),
),
const SizedBox(height: 8),
...action.parameters.entries.map((param) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
SizedBox(
width: 120,
child: Text(
'${param.key}:',
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 12,
),
),
),
Expanded(
child: Text(
param.value.toString(),
style: const TextStyle(fontSize: 12),
),
),
],
),
);
}),
],
],
),
),
),
);
}),
const SizedBox(height: 16),
// GeViScope Instance Scope Field
TextFormField(
controller: _geviscopeInstanceScopeController,
decoration: const InputDecoration(
labelText: 'GeViScope Instance Scope',
hintText: 'Optional scope identifier',
border: OutlineInputBorder(),
helperText: 'Optional instance scope for this mapping',
),
),
const SizedBox(height: 24),
// Options Section
const Text(
'Options',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
// Enabled Switch
SwitchListTile(
title: const Text('Enabled'),
subtitle: const Text('Enable or disable this action mapping'),
value: _enabled,
onChanged: (value) {
setState(() {
_enabled = value;
});
},
),
if (isEditMode && widget.mapping != null) ...[
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
// Read-only info for edit mode
ListTile(
title: const Text('Execution Count'),
subtitle: Text('${widget.mapping!.executionCount}'),
leading: const Icon(Icons.play_circle_outline),
),
if (widget.mapping!.lastExecuted != null)
ListTile(
title: const Text('Last Executed'),
subtitle: Text(widget.mapping!.lastExecuted.toString()),
leading: const Icon(Icons.access_time),
),
],
const SizedBox(height: 32),
// Submit Button
ElevatedButton(
onPressed: isLoading ? null : _submitForm,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Text(
isEditMode ? 'Update Action Mapping' : 'Create Action Mapping',
style: const TextStyle(fontSize: 16),
),
),
],
),
),
);
},
),
),
);
}
}

View File

@@ -0,0 +1,573 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../domain/entities/action_mapping.dart';
import '../../blocs/auth/auth_bloc.dart';
import '../../blocs/auth/auth_event.dart';
import '../../blocs/auth/auth_state.dart';
import '../../blocs/action_mapping/action_mapping_bloc.dart';
import '../../blocs/action_mapping/action_mapping_event.dart';
import '../../blocs/action_mapping/action_mapping_state.dart';
import '../../widgets/app_drawer.dart';
class ActionMappingsListScreen extends StatefulWidget {
const ActionMappingsListScreen({super.key});
@override
State<ActionMappingsListScreen> createState() => _ActionMappingsListScreenState();
}
class _ActionMappingsListScreenState extends State<ActionMappingsListScreen> {
bool _showSearch = false;
final TextEditingController _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@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<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: [
// 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
BlocBuilder<ActionMappingBloc, ActionMappingState>(
builder: (context, state) {
final dirtyCount = state is ActionMappingLoaded ? state.dirtyCount : 0;
return Stack(
children: [
IconButton(
icon: const Icon(Icons.sync),
onPressed: dirtyCount > 0
? () {
context.read<ActionMappingBloc>().add(const SyncActionMappingsEvent());
}
: 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<ActionMappingBloc>().add(const DownloadActionMappingsEvent());
},
tooltip: 'Download latest from server',
),
),
const SizedBox(width: 8),
BlocBuilder<AuthBloc, AuthState>(
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<AuthBloc>().add(const LogoutRequested());
},
tooltip: 'Logout',
),
],
),
);
}
return const SizedBox.shrink();
},
),
],
),
body: Column(
children: [
// Add Action Mapping button
Container(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ElevatedButton.icon(
onPressed: () {
context.push('/action-mappings/create');
},
icon: const Icon(Icons.add),
label: const Text('Add Action Mapping'),
),
],
),
),
// Action Mapping list
Expanded(
child: BlocConsumer<ActionMappingBloc, ActionMappingState>(
listener: (context, state) {
if (state is ActionMappingError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.red,
),
);
} else if (state is ActionMappingOperationSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.green,
),
);
} else if (state is ActionMappingSyncSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.green,
),
);
}
},
builder: (context, state) {
if (state is ActionMappingLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is ActionMappingSyncing) {
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 ActionMappingDownloading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Downloading action mappings...'),
],
),
);
} else if (state is ActionMappingLoaded) {
final mappings = state.mappings;
if (mappings.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.link_off, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'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',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[500],
),
),
],
),
);
}
return ListView.builder(
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) {
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 action mappings',
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<ActionMappingBloc>().add(const LoadActionMappings());
},
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
);
}
// Handle ActionMappingInitial or any other unknown states with a loading indicator
return const Center(child: CircularProgressIndicator());
},
),
),
],
),
);
}
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),
),
),
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,
),
),
),
],
),
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(
mapping.inputAction,
overflow: TextOverflow.ellipsis,
),
),
if (mapping.inputParameters.isNotEmpty)
Container(
margin: const EdgeInsets.only(left: 4),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withOpacity(0.3)),
),
child: Text(
'${mapping.inputParameters.length} param${mapping.inputParameters.length != 1 ? 's' : ''}',
style: TextStyle(fontSize: 10, color: Colors.blue[700]),
),
),
],
),
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',
),
],
),
),
);
}
void _showDeleteConfirmation(BuildContext context, ActionMapping mapping) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete Action Mapping'),
content: Text('Are you sure you want to delete "${mapping.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
context.read<ActionMappingBloc>().add(DeleteActionMappingEvent(mapping.id));
Navigator.of(dialogContext).pop();
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
}
}