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,193 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'action_mapping_event.dart';
import 'action_mapping_state.dart';
import '../../../domain/repositories/action_mapping_repository.dart';
import '../../../data/services/sync_service.dart';
class ActionMappingBloc extends Bloc<ActionMappingEvent, ActionMappingState> {
final ActionMappingRepository actionMappingRepository;
ActionMappingBloc({required this.actionMappingRepository})
: super(const ActionMappingInitial()) {
on<LoadActionMappings>(_onLoadActionMappings);
on<SearchActionMappings>(_onSearchActionMappings);
on<CreateActionMappingEvent>(_onCreateActionMapping);
on<UpdateActionMappingEvent>(_onUpdateActionMapping);
on<DeleteActionMappingEvent>(_onDeleteActionMapping);
on<SyncActionMappingsEvent>(_onSyncActionMappings);
on<DownloadActionMappingsEvent>(_onDownloadActionMappings);
on<CheckDirtyCountEvent>(_onCheckDirtyCount);
}
Future<void> _onLoadActionMappings(
LoadActionMappings event,
Emitter<ActionMappingState> emit,
) async {
emit(const ActionMappingLoading());
final result = await actionMappingRepository.getAllActionMappings();
final dirtyCountResult = await actionMappingRepository.getDirtyCount();
result.fold(
(failure) => emit(ActionMappingError(failure.message)),
(mappings) {
final dirtyCount = dirtyCountResult.fold((_) => 0, (count) => count);
emit(ActionMappingLoaded(mappings, dirtyCount: dirtyCount));
},
);
}
Future<void> _onSearchActionMappings(
SearchActionMappings event,
Emitter<ActionMappingState> emit,
) async {
emit(const ActionMappingLoading());
final result = await actionMappingRepository.searchActionMappings(event.query);
final dirtyCountResult = await actionMappingRepository.getDirtyCount();
result.fold(
(failure) => emit(ActionMappingError(failure.message)),
(mappings) {
final dirtyCount = dirtyCountResult.fold((_) => 0, (count) => count);
emit(ActionMappingLoaded(mappings, dirtyCount: dirtyCount));
},
);
}
Future<void> _onCreateActionMapping(
CreateActionMappingEvent event,
Emitter<ActionMappingState> emit,
) async {
emit(const ActionMappingLoading());
final result = await actionMappingRepository.createActionMapping(event.mapping);
await result.fold(
(failure) async => emit(ActionMappingError(failure.message)),
(_) async {
// Reload action mappings first
final mappingsResult = await actionMappingRepository.getAllActionMappings();
final dirtyCountResult = await actionMappingRepository.getDirtyCount();
mappingsResult.fold(
(failure) => emit(ActionMappingError(failure.message)),
(mappings) {
final dirtyCount = dirtyCountResult.fold((_) => 0, (count) => count);
emit(ActionMappingLoaded(mappings, dirtyCount: dirtyCount));
},
);
},
);
}
Future<void> _onUpdateActionMapping(
UpdateActionMappingEvent event,
Emitter<ActionMappingState> emit,
) async {
emit(const ActionMappingLoading());
final result = await actionMappingRepository.updateActionMapping(event.mapping);
await result.fold(
(failure) async => emit(ActionMappingError(failure.message)),
(_) async {
// Reload action mappings first
final mappingsResult = await actionMappingRepository.getAllActionMappings();
final dirtyCountResult = await actionMappingRepository.getDirtyCount();
mappingsResult.fold(
(failure) => emit(ActionMappingError(failure.message)),
(mappings) {
final dirtyCount = dirtyCountResult.fold((_) => 0, (count) => count);
emit(ActionMappingLoaded(mappings, dirtyCount: dirtyCount));
},
);
},
);
}
Future<void> _onDeleteActionMapping(
DeleteActionMappingEvent event,
Emitter<ActionMappingState> emit,
) async {
emit(const ActionMappingLoading());
final result = await actionMappingRepository.deleteActionMapping(event.id);
await result.fold(
(failure) async => emit(ActionMappingError(failure.message)),
(_) async {
// Reload action mappings first
final mappingsResult = await actionMappingRepository.getAllActionMappings();
final dirtyCountResult = await actionMappingRepository.getDirtyCount();
mappingsResult.fold(
(failure) => emit(ActionMappingError(failure.message)),
(mappings) {
final dirtyCount = dirtyCountResult.fold((_) => 0, (count) => count);
emit(ActionMappingLoaded(mappings, dirtyCount: dirtyCount));
},
);
},
);
}
Future<void> _onSyncActionMappings(
SyncActionMappingsEvent event,
Emitter<ActionMappingState> emit,
) async {
emit(const ActionMappingSyncing());
final result = await actionMappingRepository.syncToServer();
result.fold(
(failure) => emit(ActionMappingError(failure.message)),
(syncResult) {
if (syncResult.status == SyncStatus.success) {
emit(ActionMappingSyncSuccess(
syncResult.message ?? 'Sync completed',
syncResult.syncedCount ?? 0,
));
// Reload action mappings after sync
add(const LoadActionMappings());
} else if (syncResult.status == SyncStatus.error) {
emit(ActionMappingError(syncResult.message ?? 'Sync failed'));
}
},
);
}
Future<void> _onDownloadActionMappings(
DownloadActionMappingsEvent event,
Emitter<ActionMappingState> emit,
) async {
emit(const ActionMappingDownloading());
final result = await actionMappingRepository.downloadFromServer();
result.fold(
(failure) => emit(ActionMappingError(failure.message)),
(count) {
emit(ActionMappingOperationSuccess('Downloaded $count action mappings from server'));
// Reload action mappings after download
add(const LoadActionMappings());
},
);
}
Future<void> _onCheckDirtyCount(
CheckDirtyCountEvent event,
Emitter<ActionMappingState> emit,
) async {
final result = await actionMappingRepository.getDirtyCount();
result.fold(
(failure) => emit(ActionMappingError(failure.message)),
(count) {
// Just trigger a reload which will include the dirty count
add(const LoadActionMappings());
},
);
}
}

View File

@@ -0,0 +1,61 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/action_mapping.dart';
abstract class ActionMappingEvent extends Equatable {
const ActionMappingEvent();
@override
List<Object?> get props => [];
}
class LoadActionMappings extends ActionMappingEvent {
const LoadActionMappings();
}
class SearchActionMappings extends ActionMappingEvent {
final String query;
const SearchActionMappings(this.query);
@override
List<Object?> get props => [query];
}
class CreateActionMappingEvent extends ActionMappingEvent {
final ActionMapping mapping;
const CreateActionMappingEvent(this.mapping);
@override
List<Object?> get props => [mapping];
}
class UpdateActionMappingEvent extends ActionMappingEvent {
final ActionMapping mapping;
const UpdateActionMappingEvent(this.mapping);
@override
List<Object?> get props => [mapping];
}
class DeleteActionMappingEvent extends ActionMappingEvent {
final String id;
const DeleteActionMappingEvent(this.id);
@override
List<Object?> get props => [id];
}
class SyncActionMappingsEvent extends ActionMappingEvent {
const SyncActionMappingsEvent();
}
class DownloadActionMappingsEvent extends ActionMappingEvent {
const DownloadActionMappingsEvent();
}
class CheckDirtyCountEvent extends ActionMappingEvent {
const CheckDirtyCountEvent();
}

View File

@@ -0,0 +1,68 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/action_mapping.dart';
abstract class ActionMappingState extends Equatable {
const ActionMappingState();
@override
List<Object?> get props => [];
}
class ActionMappingInitial extends ActionMappingState {
const ActionMappingInitial();
}
class ActionMappingLoading extends ActionMappingState {
const ActionMappingLoading();
}
class ActionMappingLoaded extends ActionMappingState {
final List<ActionMapping> mappings;
final int dirtyCount;
const ActionMappingLoaded(this.mappings, {this.dirtyCount = 0});
@override
List<Object?> get props => [mappings, dirtyCount];
}
class ActionMappingOperationSuccess extends ActionMappingState {
final String message;
const ActionMappingOperationSuccess(this.message);
@override
List<Object?> get props => [message];
}
class ActionMappingError extends ActionMappingState {
final String message;
const ActionMappingError(this.message);
@override
List<Object?> get props => [message];
}
class ActionMappingSyncing extends ActionMappingState {
final String message;
const ActionMappingSyncing({this.message = 'Syncing changes...'});
@override
List<Object?> get props => [message];
}
class ActionMappingSyncSuccess extends ActionMappingState {
final String message;
final int syncedCount;
const ActionMappingSyncSuccess(this.message, this.syncedCount);
@override
List<Object?> get props => [message, syncedCount];
}
class ActionMappingDownloading extends ActionMappingState {
const ActionMappingDownloading();
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'auth_event.dart';
import 'auth_state.dart';
import '../../../domain/use_cases/auth/login.dart';
import '../../../domain/repositories/auth_repository.dart';
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final Login loginUseCase;
final AuthRepository authRepository;
AuthBloc({
required this.loginUseCase,
required this.authRepository,
}) : super(const AuthInitial()) {
on<LoginRequested>(_onLoginRequested);
on<LogoutRequested>(_onLogoutRequested);
on<CheckAuthStatus>(_onCheckAuthStatus);
}
Future<void> _onLoginRequested(
LoginRequested event,
Emitter<AuthState> emit,
) async {
emit(const AuthLoading());
final result = await loginUseCase(event.username, event.password);
result.fold(
(failure) => emit(AuthError(failure.message)),
(user) => emit(Authenticated(user)),
);
}
Future<void> _onLogoutRequested(
LogoutRequested event,
Emitter<AuthState> emit,
) async {
emit(const AuthLoading());
final result = await authRepository.logout();
result.fold(
(failure) => emit(AuthError(failure.message)),
(_) => emit(const Unauthenticated()),
);
}
Future<void> _onCheckAuthStatus(
CheckAuthStatus event,
Emitter<AuthState> emit,
) async {
emit(const AuthLoading());
final result = await authRepository.getCurrentUser();
result.fold(
(failure) => emit(const Unauthenticated()),
(user) {
if (user != null) {
emit(Authenticated(user));
} else {
emit(const Unauthenticated());
}
},
);
}
}

View File

@@ -0,0 +1,29 @@
import 'package:equatable/equatable.dart';
abstract class AuthEvent extends Equatable {
const AuthEvent();
@override
List<Object?> get props => [];
}
class LoginRequested extends AuthEvent {
final String username;
final String password;
const LoginRequested({
required this.username,
required this.password,
});
@override
List<Object?> get props => [username, password];
}
class LogoutRequested extends AuthEvent {
const LogoutRequested();
}
class CheckAuthStatus extends AuthEvent {
const CheckAuthStatus();
}

View File

@@ -0,0 +1,39 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/user.dart';
abstract class AuthState extends Equatable {
const AuthState();
@override
List<Object?> get props => [];
}
class AuthInitial extends AuthState {
const AuthInitial();
}
class AuthLoading extends AuthState {
const AuthLoading();
}
class Authenticated extends AuthState {
final User user;
const Authenticated(this.user);
@override
List<Object?> get props => [user];
}
class Unauthenticated extends AuthState {
const Unauthenticated();
}
class AuthError extends AuthState {
final String message;
const AuthError(this.message);
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,211 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'server_event.dart';
import 'server_state.dart';
import '../../../domain/repositories/server_repository.dart';
import '../../../data/services/sync_service.dart';
class ServerBloc extends Bloc<ServerEvent, ServerState> {
final ServerRepository serverRepository;
ServerBloc({required this.serverRepository}) : super(const ServerInitial()) {
on<LoadServers>(_onLoadServers);
on<LoadGCoreServers>(_onLoadGCoreServers);
on<LoadGeViScopeServers>(_onLoadGeViScopeServers);
on<CreateServerEvent>(_onCreateServer);
on<UpdateServerEvent>(_onUpdateServer);
on<DeleteServerEvent>(_onDeleteServer);
on<SyncServersEvent>(_onSyncServers);
on<DownloadServersEvent>(_onDownloadServers);
on<CheckDirtyCountEvent>(_onCheckDirtyCount);
}
Future<void> _onLoadServers(
LoadServers event,
Emitter<ServerState> emit,
) async {
emit(const ServerLoading());
final result = await serverRepository.getAllServers();
final dirtyCountResult = await serverRepository.getDirtyCount();
result.fold(
(failure) => emit(ServerError(failure.message)),
(servers) {
final dirtyCount = dirtyCountResult.fold((_) => 0, (count) => count);
emit(ServerLoaded(servers, dirtyCount: dirtyCount));
},
);
}
Future<void> _onLoadGCoreServers(
LoadGCoreServers event,
Emitter<ServerState> emit,
) async {
emit(const ServerLoading());
final result = await serverRepository.getGCoreServers();
final dirtyCountResult = await serverRepository.getDirtyCount();
result.fold(
(failure) => emit(ServerError(failure.message)),
(servers) {
final dirtyCount = dirtyCountResult.fold((_) => 0, (count) => count);
emit(ServerLoaded(servers, dirtyCount: dirtyCount));
},
);
}
Future<void> _onLoadGeViScopeServers(
LoadGeViScopeServers event,
Emitter<ServerState> emit,
) async {
emit(const ServerLoading());
final result = await serverRepository.getGeViScopeServers();
final dirtyCountResult = await serverRepository.getDirtyCount();
result.fold(
(failure) => emit(ServerError(failure.message)),
(servers) {
final dirtyCount = dirtyCountResult.fold((_) => 0, (count) => count);
emit(ServerLoaded(servers, dirtyCount: dirtyCount));
},
);
}
Future<void> _onCreateServer(
CreateServerEvent event,
Emitter<ServerState> emit,
) async {
emit(const ServerLoading());
final result = await serverRepository.createServer(event.server);
await result.fold(
(failure) async => emit(ServerError(failure.message)),
(_) async {
// Reload servers first
final serversResult = await serverRepository.getAllServers();
final dirtyCountResult = await serverRepository.getDirtyCount();
serversResult.fold(
(failure) => emit(ServerError(failure.message)),
(servers) {
final dirtyCount = dirtyCountResult.fold((_) => 0, (count) => count);
emit(ServerLoaded(servers, dirtyCount: dirtyCount));
},
);
},
);
}
Future<void> _onUpdateServer(
UpdateServerEvent event,
Emitter<ServerState> emit,
) async {
emit(const ServerLoading());
final result = await serverRepository.updateServer(event.server);
await result.fold(
(failure) async => emit(ServerError(failure.message)),
(_) async {
// Reload servers first
final serversResult = await serverRepository.getAllServers();
final dirtyCountResult = await serverRepository.getDirtyCount();
serversResult.fold(
(failure) => emit(ServerError(failure.message)),
(servers) {
final dirtyCount = dirtyCountResult.fold((_) => 0, (count) => count);
emit(ServerLoaded(servers, dirtyCount: dirtyCount));
},
);
},
);
}
Future<void> _onDeleteServer(
DeleteServerEvent event,
Emitter<ServerState> emit,
) async {
emit(const ServerLoading());
final result = await serverRepository.deleteServer(event.id, event.type);
await result.fold(
(failure) async => emit(ServerError(failure.message)),
(_) async {
// Reload servers first
final serversResult = await serverRepository.getAllServers();
final dirtyCountResult = await serverRepository.getDirtyCount();
serversResult.fold(
(failure) => emit(ServerError(failure.message)),
(servers) {
final dirtyCount = dirtyCountResult.fold((_) => 0, (count) => count);
emit(ServerLoaded(servers, dirtyCount: dirtyCount));
},
);
},
);
}
Future<void> _onSyncServers(
SyncServersEvent event,
Emitter<ServerState> emit,
) async {
emit(const ServerSyncing());
final result = await serverRepository.syncToServer();
result.fold(
(failure) => emit(ServerError(failure.message)),
(syncResult) {
if (syncResult.status == SyncStatus.success) {
emit(ServerSyncSuccess(
syncResult.message ?? 'Sync completed',
syncResult.syncedCount ?? 0,
));
// Reload servers after sync
add(const LoadServers());
} else if (syncResult.status == SyncStatus.error) {
emit(ServerError(syncResult.message ?? 'Sync failed'));
}
},
);
}
Future<void> _onDownloadServers(
DownloadServersEvent event,
Emitter<ServerState> emit,
) async {
emit(const ServerDownloading());
final result = await serverRepository.downloadFromServer();
result.fold(
(failure) => emit(ServerError(failure.message)),
(count) {
emit(ServerOperationSuccess('Downloaded $count servers from server'));
// Reload servers after download
add(const LoadServers());
},
);
}
Future<void> _onCheckDirtyCount(
CheckDirtyCountEvent event,
Emitter<ServerState> emit,
) async {
final result = await serverRepository.getDirtyCount();
result.fold(
(failure) => emit(ServerError(failure.message)),
(count) {
// Just trigger a reload which will include the dirty count
add(const LoadServers());
},
);
}
}

View File

@@ -0,0 +1,61 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/server.dart';
abstract class ServerEvent extends Equatable {
const ServerEvent();
@override
List<Object?> get props => [];
}
class LoadServers extends ServerEvent {
const LoadServers();
}
class LoadGCoreServers extends ServerEvent {
const LoadGCoreServers();
}
class LoadGeViScopeServers extends ServerEvent {
const LoadGeViScopeServers();
}
class CreateServerEvent extends ServerEvent {
final Server server;
const CreateServerEvent(this.server);
@override
List<Object?> get props => [server];
}
class UpdateServerEvent extends ServerEvent {
final Server server;
const UpdateServerEvent(this.server);
@override
List<Object?> get props => [server];
}
class DeleteServerEvent extends ServerEvent {
final String id;
final ServerType type;
const DeleteServerEvent(this.id, this.type);
@override
List<Object?> get props => [id, type];
}
class SyncServersEvent extends ServerEvent {
const SyncServersEvent();
}
class DownloadServersEvent extends ServerEvent {
const DownloadServersEvent();
}
class CheckDirtyCountEvent extends ServerEvent {
const CheckDirtyCountEvent();
}

View File

@@ -0,0 +1,68 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/server.dart';
abstract class ServerState extends Equatable {
const ServerState();
@override
List<Object?> get props => [];
}
class ServerInitial extends ServerState {
const ServerInitial();
}
class ServerLoading extends ServerState {
const ServerLoading();
}
class ServerLoaded extends ServerState {
final List<Server> servers;
final int dirtyCount;
const ServerLoaded(this.servers, {this.dirtyCount = 0});
@override
List<Object?> get props => [servers, dirtyCount];
}
class ServerOperationSuccess extends ServerState {
final String message;
const ServerOperationSuccess(this.message);
@override
List<Object?> get props => [message];
}
class ServerError extends ServerState {
final String message;
const ServerError(this.message);
@override
List<Object?> get props => [message];
}
class ServerSyncing extends ServerState {
final String message;
const ServerSyncing({this.message = 'Syncing changes...'});
@override
List<Object?> get props => [message];
}
class ServerSyncSuccess extends ServerState {
final String message;
final int syncedCount;
const ServerSyncSuccess(this.message, this.syncedCount);
@override
List<Object?> get props => [message, syncedCount];
}
class ServerDownloading extends ServerState {
const ServerDownloading();
}

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)),
),
],
),
);
}
}

View File

@@ -0,0 +1,182 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../blocs/auth/auth_bloc.dart';
import '../../blocs/auth/auth_event.dart';
import '../../blocs/auth/auth_state.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
void _handleLogin() {
if (_formKey.currentState!.validate()) {
context.read<AuthBloc>().add(
LoginRequested(
username: _usernameController.text.trim(),
password: _passwordController.text,
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: BlocConsumer<AuthBloc, AuthState>(
listener: (context, state) {
if (state is Authenticated) {
// Navigate to home screen
context.go('/');
} else if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.red,
),
);
}
},
builder: (context, state) {
final isLoading = state is AuthLoading;
return Card(
elevation: 8,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Logo/Title
const Icon(
Icons.videocam,
size: 64,
color: Colors.blue,
),
const SizedBox(height: 16),
Text(
'GeViAPI',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Video Management System',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// Username field
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
),
enabled: !isLoading,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your username';
}
return null;
},
onFieldSubmitted: (_) => _handleLogin(),
),
const SizedBox(height: 16),
// Password field
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility
: Icons.visibility_off,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
enabled: !isLoading,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password';
}
return null;
},
onFieldSubmitted: (_) => _handleLogin(),
),
const SizedBox(height: 24),
// Login button
ElevatedButton(
onPressed: isLoading ? null : _handleLogin,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text(
'Login',
style: TextStyle(fontSize: 16),
),
),
],
),
),
),
);
},
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,321 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../domain/entities/server.dart';
import '../../blocs/server/server_bloc.dart';
import '../../blocs/server/server_event.dart';
import '../../blocs/server/server_state.dart';
class ServerFormScreen extends StatefulWidget {
final Server? server; // null for create, non-null for edit
final ServerType serverType;
const ServerFormScreen({
super.key,
this.server,
this.serverType = ServerType.gcore,
});
@override
State<ServerFormScreen> createState() => _ServerFormScreenState();
}
class _ServerFormScreenState extends State<ServerFormScreen> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _idController;
late TextEditingController _aliasController;
late TextEditingController _hostController;
late TextEditingController _userController;
late TextEditingController _passwordController;
late bool _enabled;
late bool _deactivateEcho;
late bool _deactivateLiveCheck;
bool _obscurePassword = true;
bool _isSaving = false; // Track if we're in a save operation
@override
void initState() {
super.initState();
final server = widget.server;
_idController = TextEditingController(text: server?.id ?? '');
_aliasController = TextEditingController(text: server?.alias ?? '');
_hostController = TextEditingController(text: server?.host ?? '');
_userController = TextEditingController(text: server?.user ?? '');
_passwordController = TextEditingController(text: server?.password ?? '');
_enabled = server?.enabled ?? true;
_deactivateEcho = server?.deactivateEcho ?? false;
_deactivateLiveCheck = server?.deactivateLiveCheck ?? false;
}
@override
void dispose() {
_idController.dispose();
_aliasController.dispose();
_hostController.dispose();
_userController.dispose();
_passwordController.dispose();
super.dispose();
}
bool get isEditMode => widget.server != null;
String get screenTitle => isEditMode
? 'Edit ${widget.serverType == ServerType.gcore ? "G-Core" : "GeViScope"} Server'
: 'Add ${widget.serverType == ServerType.gcore ? "G-Core" : "GeViScope"} Server';
void _submitForm() {
if (_formKey.currentState!.validate()) {
setState(() {
_isSaving = true; // Mark that we're saving
});
final server = Server(
id: _idController.text.trim(),
alias: _aliasController.text.trim(),
host: _hostController.text.trim(),
user: _userController.text.trim(),
password: _passwordController.text,
enabled: _enabled,
deactivateEcho: _deactivateEcho,
deactivateLiveCheck: _deactivateLiveCheck,
type: widget.serverType,
);
if (isEditMode) {
context.read<ServerBloc>().add(UpdateServerEvent(server));
} else {
context.read<ServerBloc>().add(CreateServerEvent(server));
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(screenTitle),
),
body: BlocListener<ServerBloc, ServerState>(
listener: (context, state) {
if (state is ServerLoaded && _isSaving) {
// Success! Show message and go back
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isEditMode ? 'Server updated successfully' : 'Server created successfully'),
backgroundColor: Colors.green,
),
);
context.pop(); // Go back to server list
} else if (state is ServerError) {
setState(() {
_isSaving = false; // Reset on error
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.red,
),
);
}
},
child: BlocBuilder<ServerBloc, ServerState>(
builder: (context, state) {
final isLoading = state is ServerLoading;
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ID Field
TextFormField(
controller: _idController,
decoration: const InputDecoration(
labelText: 'Server ID *',
hintText: 'e.g., 1, 2, 3',
border: OutlineInputBorder(),
helperText: 'Unique identifier for the server',
),
enabled: !isEditMode, // Can't change ID in edit mode
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Server ID is required';
}
return null;
},
),
const SizedBox(height: 16),
// Alias Field
TextFormField(
controller: _aliasController,
decoration: const InputDecoration(
labelText: 'Alias *',
hintText: 'e.g., Main Server, Backup Server',
border: OutlineInputBorder(),
helperText: 'Friendly name for the server',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Alias is required';
}
return null;
},
),
const SizedBox(height: 16),
// Host Field
TextFormField(
controller: _hostController,
decoration: const InputDecoration(
labelText: 'Host *',
hintText: 'e.g., 192.168.1.100 or server.example.com',
border: OutlineInputBorder(),
helperText: 'IP address or hostname',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Host is required';
}
// Basic validation for IP or hostname
final trimmed = value.trim();
if (!RegExp(r'^[\w\.\-]+$').hasMatch(trimmed)) {
return 'Invalid host format';
}
return null;
},
),
const SizedBox(height: 16),
// User Field
TextFormField(
controller: _userController,
decoration: const InputDecoration(
labelText: 'Username *',
hintText: 'e.g., admin',
border: OutlineInputBorder(),
helperText: 'Username for server authentication',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Username is required';
}
return null;
},
),
const SizedBox(height: 16),
// Password Field
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password *',
hintText: 'Enter server password',
border: const OutlineInputBorder(),
helperText: 'Password for server authentication',
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility
: Icons.visibility_off,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
obscureText: _obscurePassword,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
return null;
},
),
const SizedBox(height: 24),
// Options Section
const Text(
'Server 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 server connection'),
value: _enabled,
onChanged: (value) {
setState(() {
_enabled = value;
});
},
),
// Deactivate Echo Switch
SwitchListTile(
title: const Text('Deactivate Echo'),
subtitle: const Text(
'Disable echo mode for this server'),
value: _deactivateEcho,
onChanged: (value) {
setState(() {
_deactivateEcho = value;
});
},
),
// Deactivate Live Check Switch
SwitchListTile(
title: const Text('Deactivate Live Check'),
subtitle: const Text(
'Disable live connection checks'),
value: _deactivateLiveCheck,
onChanged: (value) {
setState(() {
_deactivateLiveCheck = value;
});
},
),
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 Server' : 'Create Server',
style: const TextStyle(fontSize: 16),
),
),
],
),
),
);
},
),
),
);
}
}

View File

@@ -0,0 +1,250 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../blocs/auth/auth_bloc.dart';
import '../../blocs/auth/auth_event.dart';
import '../../blocs/auth/auth_state.dart';
import '../../widgets/app_drawer.dart';
class ServerListScreen extends StatelessWidget {
const ServerListScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
drawer: const AppDrawer(currentRoute: '/'),
appBar: AppBar(
title: Row(
children: [
const Icon(Icons.videocam, size: 24),
const SizedBox(width: 8),
const Text('GeViAPI'),
],
),
actions: [
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: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Welcome header
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is Authenticated) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(
state.user.role == 'Administrator'
? Icons.admin_panel_settings
: Icons.person,
size: 40,
color: Colors.blue,
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Welcome, ${state.user.username}',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
state.user.role,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
),
],
),
],
),
),
);
}
return const SizedBox.shrink();
},
),
const SizedBox(height: 24),
// Dashboard title
Text(
'Video Management Dashboard',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// Feature grid
Expanded(
child: GridView.count(
crossAxisCount: 3,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
children: [
_buildFeatureCard(
context,
icon: Icons.video_camera_back,
title: 'Cameras',
subtitle: 'View and control cameras',
color: Colors.blue,
onTap: () {
// TODO: Navigate to cameras
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cameras - Coming soon')),
);
},
),
_buildFeatureCard(
context,
icon: Icons.monitor,
title: 'Monitors',
subtitle: 'Manage video monitors',
color: Colors.purple,
onTap: () {
// TODO: Navigate to monitors
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Monitors - Coming soon')),
);
},
),
_buildFeatureCard(
context,
icon: Icons.swap_horiz,
title: 'Cross-Switch',
subtitle: 'Switch camera feeds',
color: Colors.orange,
onTap: () {
// TODO: Navigate to cross-switching
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cross-Switching - Coming soon')),
);
},
),
_buildFeatureCard(
context,
icon: Icons.dns,
title: 'Servers',
subtitle: 'Manage G-Core & GeViScope',
color: Colors.green,
onTap: () {
context.go('/servers');
},
),
_buildFeatureCard(
context,
icon: Icons.settings_input_component,
title: 'Action Mappings',
subtitle: 'Configure event actions',
color: Colors.teal,
onTap: () {
// TODO: Navigate to action mappings
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Action Mappings - Coming soon')),
);
},
),
_buildFeatureCard(
context,
icon: Icons.settings,
title: 'Configuration',
subtitle: 'Export/Import settings',
color: Colors.grey,
onTap: () {
// TODO: Navigate to configuration
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Configuration - Coming soon')),
);
},
),
],
),
),
],
),
),
);
}
Widget _buildFeatureCard(
BuildContext context, {
required IconData icon,
required String title,
required String subtitle,
required Color color,
required VoidCallback onTap,
}) {
return Card(
elevation: 2,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 48,
color: color,
),
const SizedBox(height: 12),
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,422 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../domain/entities/server.dart';
import '../../blocs/auth/auth_bloc.dart';
import '../../blocs/auth/auth_event.dart';
import '../../blocs/auth/auth_state.dart';
import '../../blocs/server/server_bloc.dart';
import '../../blocs/server/server_event.dart';
import '../../blocs/server/server_state.dart';
import '../../widgets/app_drawer.dart';
class ServersManagementScreen extends StatefulWidget {
const ServersManagementScreen({super.key});
@override
State<ServersManagementScreen> createState() => _ServersManagementScreenState();
}
class _ServersManagementScreenState extends State<ServersManagementScreen> {
String _filterType = 'all'; // 'all', 'gcore', 'geviscope'
@override
Widget build(BuildContext context) {
return Scaffold(
drawer: const AppDrawer(currentRoute: '/servers'),
appBar: AppBar(
title: Row(
children: [
const Icon(Icons.dns, size: 24),
const SizedBox(width: 8),
const Text('Server Management'),
],
),
actions: [
// Sync button with dirty count badge
BlocBuilder<ServerBloc, ServerState>(
builder: (context, state) {
final dirtyCount = state is ServerLoaded ? state.dirtyCount : 0;
return Stack(
children: [
IconButton(
icon: const Icon(Icons.sync),
onPressed: dirtyCount > 0
? () {
context.read<ServerBloc>().add(const SyncServersEvent());
}
: null,
tooltip: dirtyCount > 0
? 'Sync $dirtyCount unsaved change${dirtyCount != 1 ? 's' : ''}'
: 'No changes to sync',
),
if (dirtyCount > 0)
Positioned(
right: 4,
top: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: Text(
'$dirtyCount',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
],
);
},
),
// Download/refresh button
Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.cloud_download),
onPressed: () {
context.read<ServerBloc>().add(const DownloadServersEvent());
},
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: [
// Filter tabs
Container(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
_buildFilterChip('All Servers', 'all'),
const SizedBox(width: 8),
_buildFilterChip('G-Core', 'gcore'),
const SizedBox(width: 8),
_buildFilterChip('GeViScope', 'geviscope'),
const Spacer(),
ElevatedButton.icon(
onPressed: () {
_showAddServerDialog(context);
},
icon: const Icon(Icons.add),
label: const Text('Add Server'),
),
],
),
),
// Server list
Expanded(
child: BlocConsumer<ServerBloc, ServerState>(
listener: (context, state) {
if (state is ServerError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.red,
),
);
} else if (state is ServerOperationSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.green,
),
);
} else if (state is ServerSyncSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.green,
),
);
}
},
builder: (context, state) {
if (state is ServerLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is ServerSyncing) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
state.message,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
);
} else if (state is ServerDownloading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Downloading servers...'),
],
),
);
} else if (state is ServerLoaded) {
final filteredServers = _filterServers(state.servers);
if (filteredServers.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.dns_outlined, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No servers found',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
'Add a server to get started',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[500],
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: filteredServers.length,
itemBuilder: (context, index) {
final server = filteredServers[index];
return _buildServerCard(context, server);
},
);
} else if (state is ServerError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.red[300]),
const SizedBox(height: 16),
Text(
'Error loading servers',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
state.message,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () {
context.read<ServerBloc>().add(const LoadServers());
},
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
);
}
// Handle ServerInitial or any other unknown states with a loading indicator
// instead of "No data" to prevent confusion during state transitions
return const Center(child: CircularProgressIndicator());
},
),
),
],
),
);
}
List<Server> _filterServers(List<Server> servers) {
if (_filterType == 'all') {
return servers;
} else if (_filterType == 'gcore') {
return servers.where((s) => s.type == ServerType.gcore).toList();
} else {
return servers.where((s) => s.type == ServerType.geviscope).toList();
}
}
Widget _buildFilterChip(String label, String value) {
final isSelected = _filterType == value;
return FilterChip(
label: Text(label),
selected: isSelected,
onSelected: (selected) {
setState(() {
_filterType = value;
});
},
);
}
Widget _buildServerCard(BuildContext context, Server server) {
return Card(
margin: const EdgeInsets.only(bottom: 12.0),
child: ListTile(
leading: CircleAvatar(
backgroundColor: server.type == ServerType.gcore
? Colors.green.withOpacity(0.2)
: Colors.purple.withOpacity(0.2),
child: Icon(
Icons.dns,
color: server.type == ServerType.gcore ? Colors.green : Colors.purple,
),
),
title: Row(
children: [
Text(
server.alias,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: server.enabled ? Colors.green : Colors.grey,
borderRadius: BorderRadius.circular(12),
),
child: Text(
server.enabled ? 'Enabled' : 'Disabled',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text('Host: ${server.host}'),
Text('User: ${server.user}'),
Text('Type: ${server.type == ServerType.gcore ? "G-Core" : "GeViScope"}'),
],
),
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
context.push('/servers/edit/${server.id}', extra: server);
},
tooltip: 'Edit',
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () {
_showDeleteConfirmation(context, server);
},
tooltip: 'Delete',
),
],
),
),
);
}
void _showDeleteConfirmation(BuildContext context, Server server) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete Server'),
content: Text('Are you sure you want to delete "${server.alias}"?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
context.read<ServerBloc>().add(DeleteServerEvent(server.id, server.type));
Navigator.of(dialogContext).pop();
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
}
void _showAddServerDialog(BuildContext context) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Add Server'),
content: const Text('Choose the server type:'),
actions: [
TextButton(
onPressed: () {
Navigator.of(dialogContext).pop();
context.push('/servers/create?type=gcore');
},
child: const Text('G-Core Server'),
),
TextButton(
onPressed: () {
Navigator.of(dialogContext).pop();
context.push('/servers/create?type=geviscope');
},
child: const Text('GeViScope Server'),
),
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
],
),
);
}
}

View File

@@ -0,0 +1,665 @@
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];
// Clear previous parameter controllers
for (final controller in _paramControllers.values) {
controller.dispose();
}
_paramControllers.clear();
_paramEnabled.clear();
// Initialize new parameter controllers
if (_selectedTemplate != null) {
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';
if (!_paramControllers.containsKey(serverParam)) {
_paramControllers[serverParam] = TextEditingController();
}
_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;
}
} else if (_categoryPrefix == 'gsc') {
// Add GSC server parameter
const serverParam = 'GscServer';
if (!_paramControllers.containsKey(serverParam)) {
_paramControllers[serverParam] = TextEditingController();
}
_paramEnabled[serverParam] = true;
// Auto-select first enabled server if available
final enabledServers = widget.gscServers.where((s) => s.enabled).toList();
if (enabledServers.isNotEmpty) {
_paramControllers[serverParam]?.text = enabledServers.first.alias;
}
}
// 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;
}
// Build parameters map from enabled parameters
final parameters = <String, dynamic>{};
for (final entry in _paramEnabled.entries) {
if (entry.value) {
final value = _paramControllers[entry.key]?.text.trim() ?? '';
if (value.isNotEmpty) {
parameters[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;
}
// Create ActionOutput with the actual action name (NOT the caption!)
final result = 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
for (final category in _serverCategories) {
if (widget.categories.containsKey(category) && widget.gcoreServers.isNotEmpty) {
enhanced['G-Core: $category'] = widget.categories[category]!;
}
}
// Add GSC variants for applicable categories
for (final category in _serverCategories) {
if (widget.categories.containsKey(category) && widget.gscServers.isNotEmpty) {
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: "
} else if (displayCategory.startsWith('GSC: ')) {
_categoryPrefix = 'gsc';
_selectedCategory = displayCategory.substring(5); // Remove "GSC: "
} else {
_categoryPrefix = null;
_selectedCategory = displayCategory;
}
}
@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,
);
}
}

View File

@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../blocs/auth/auth_bloc.dart';
import '../blocs/auth/auth_event.dart';
import '../blocs/auth/auth_state.dart';
class AppDrawer extends StatelessWidget {
final String currentRoute;
const AppDrawer({
super.key,
required this.currentRoute,
});
@override
Widget build(BuildContext context) {
return Drawer(
child: Column(
children: [
// Drawer Header
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is Authenticated) {
return DrawerHeader(
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Icon(
state.user.role == 'administrator'
? Icons.admin_panel_settings
: Icons.person,
size: 48,
color: Colors.white,
),
const SizedBox(height: 12),
Text(
state.user.username,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
Text(
state.user.role,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
],
),
);
}
return const SizedBox.shrink();
},
),
// Navigation Items
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
_buildNavItem(
context,
icon: Icons.dashboard,
title: 'Dashboard',
route: '/',
),
const Divider(),
_buildNavItem(
context,
icon: Icons.video_camera_back,
title: 'Cameras',
route: '/cameras',
comingSoon: true,
),
_buildNavItem(
context,
icon: Icons.monitor,
title: 'Monitors',
route: '/monitors',
comingSoon: true,
),
_buildNavItem(
context,
icon: Icons.swap_horiz,
title: 'Cross-Switch',
route: '/crossswitch',
comingSoon: true,
),
_buildNavItem(
context,
icon: Icons.dns,
title: 'Servers',
route: '/servers',
),
_buildNavItem(
context,
icon: Icons.link,
title: 'Action Mappings',
route: '/action-mappings',
),
_buildNavItem(
context,
icon: Icons.settings,
title: 'Configuration',
route: '/configuration',
comingSoon: true,
),
],
),
),
// Logout at bottom
const Divider(),
ListTile(
leading: const Icon(Icons.logout),
title: const Text('Logout'),
onTap: () {
Navigator.pop(context);
context.read<AuthBloc>().add(const LogoutRequested());
},
),
],
),
);
}
Widget _buildNavItem(
BuildContext context, {
required IconData icon,
required String title,
required String route,
bool comingSoon = false,
}) {
final isSelected = currentRoute == route;
return ListTile(
leading: Icon(icon),
title: Row(
children: [
Text(title),
if (comingSoon) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Soon',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
selected: isSelected,
onTap: () {
Navigator.pop(context);
if (comingSoon) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$title - Coming soon')),
);
} else {
context.go(route);
}
},
);
}
}