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,36 @@
class ApiConstants {
// Base URL - change this to your API server address
static const String baseUrl = 'http://100.81.138.77:8000';
// API version
static const String apiVersion = 'v1';
// Full base path
static const String basePath = '$baseUrl/api/$apiVersion';
// Timeout durations
static const Duration connectTimeout = Duration(seconds: 30);
static const Duration receiveTimeout = Duration(seconds: 30);
static const Duration sendTimeout = Duration(seconds: 30);
// Endpoints
static const String loginEndpoint = '/auth/login';
static const String refreshEndpoint = '/auth/refresh';
// Server endpoints
static const String serversEndpoint = '/configuration/servers';
static const String gcoreServersEndpoint = '/configuration/servers/gcore';
static const String geviscopeServersEndpoint = '/configuration/servers/geviscope';
// Action mapping endpoints
static const String actionMappingsEndpoint = '/configuration/action-mappings';
// Camera endpoints
static const String camerasEndpoint = '/cameras';
// Monitor endpoints
static const String monitorsEndpoint = '/monitors';
// Cross-switching endpoints
static const String crossSwitchEndpoint = '/crossswitch';
}

View File

@@ -0,0 +1,44 @@
class ServerException implements Exception {
final String message;
const ServerException(this.message);
@override
String toString() => 'ServerException: $message';
}
class NetworkException implements Exception {
final String message;
const NetworkException([this.message = 'No internet connection']);
@override
String toString() => 'NetworkException: $message';
}
class AuthenticationException implements Exception {
final String message;
const AuthenticationException([this.message = 'Authentication failed']);
@override
String toString() => 'AuthenticationException: $message';
}
class CacheException implements Exception {
final String message;
const CacheException([this.message = 'Cache error']);
@override
String toString() => 'CacheException: $message';
}
class ActionMappingException implements Exception {
final String message;
const ActionMappingException(this.message);
@override
String toString() => 'ActionMappingException: $message';
}

View File

@@ -0,0 +1,34 @@
import 'package:equatable/equatable.dart';
abstract class Failure extends Equatable {
final String message;
const Failure(this.message);
@override
List<Object?> get props => [message];
}
class ServerFailure extends Failure {
const ServerFailure(super.message);
}
class NetworkFailure extends Failure {
const NetworkFailure([super.message = 'No internet connection']);
}
class AuthenticationFailure extends Failure {
const AuthenticationFailure([super.message = 'Authentication failed']);
}
class ValidationFailure extends Failure {
const ValidationFailure(super.message);
}
class CacheFailure extends Failure {
const CacheFailure([super.message = 'Cache error']);
}
class ActionMappingFailure extends Failure {
const ActionMappingFailure(super.message);
}

View File

@@ -0,0 +1,159 @@
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../constants/api_constants.dart';
import '../storage/token_manager.dart';
class DioClient {
late final Dio _dio;
final FlutterSecureStorage _secureStorage;
DioClient(this._secureStorage) {
_dio = Dio(
BaseOptions(
baseUrl: ApiConstants.basePath,
connectTimeout: ApiConstants.connectTimeout,
receiveTimeout: ApiConstants.receiveTimeout,
sendTimeout: ApiConstants.sendTimeout,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
);
// Add interceptors
_dio.interceptors.add(_authInterceptor());
_dio.interceptors.add(_loggingInterceptor());
}
Dio get dio => _dio;
// Auth interceptor - adds token to requests
Interceptor _authInterceptor() {
return InterceptorsWrapper(
onRequest: (options, handler) async {
// Skip token for login endpoint
if (options.path.contains(ApiConstants.loginEndpoint)) {
return handler.next(options);
}
// Add access token to other requests
String? token;
try {
token = await _secureStorage.read(key: 'access_token');
} catch (e) {
// Fallback to memory storage on web
print('Reading token from memory storage');
}
// Fallback to TokenManager if secure storage fails
token ??= TokenManager().accessToken;
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
return handler.next(options);
},
onError: (error, handler) async {
// Handle 401 errors (token expired)
if (error.response?.statusCode == 401) {
// Try to refresh token
final refreshed = await _refreshToken();
if (refreshed) {
// Retry the original request
try {
final response = await _retry(error.requestOptions);
return handler.resolve(response);
} catch (e) {
return handler.next(error);
}
}
}
return handler.next(error);
},
);
}
// Logging interceptor - logs requests and responses
Interceptor _loggingInterceptor() {
return InterceptorsWrapper(
onRequest: (options, handler) {
print('🔵 REQUEST[${options.method}] => ${options.uri}');
print('Headers: ${options.headers}');
if (options.data != null) {
print('Body: ${options.data}');
}
return handler.next(options);
},
onResponse: (response, handler) {
print('🟢 RESPONSE[${response.statusCode}] => ${response.requestOptions.uri}');
print('Data: ${response.data}');
return handler.next(response);
},
onError: (error, handler) {
print('🔴 ERROR[${error.response?.statusCode}] => ${error.requestOptions.uri}');
print('Message: ${error.message}');
if (error.response?.data != null) {
print('Error Data: ${error.response?.data}');
}
return handler.next(error);
},
);
}
// Refresh token logic
Future<bool> _refreshToken() async {
try {
String? refreshToken;
try {
refreshToken = await _secureStorage.read(key: 'refresh_token');
} catch (e) {
// Fallback to memory storage on web
refreshToken = TokenManager().refreshToken;
}
refreshToken ??= TokenManager().refreshToken;
if (refreshToken == null) return false;
final response = await _dio.post(
ApiConstants.refreshEndpoint,
data: {'refresh_token': refreshToken},
);
if (response.statusCode == 200) {
final newAccessToken = response.data['access_token'];
// Save to memory first
TokenManager().saveTokens(accessToken: newAccessToken);
try {
await _secureStorage.write(key: 'access_token', value: newAccessToken);
} catch (e) {
// Ignore secure storage errors on web
}
return true;
}
return false;
} catch (e) {
print('Failed to refresh token: $e');
return false;
}
}
// Retry a request
Future<Response> _retry(RequestOptions requestOptions) async {
final options = Options(
method: requestOptions.method,
headers: requestOptions.headers,
);
return _dio.request(
requestOptions.path,
data: requestOptions.data,
queryParameters: requestOptions.queryParameters,
options: options,
);
}
}

View File

@@ -0,0 +1,35 @@
/// Simple in-memory token storage for web (when secure storage fails)
class TokenManager {
static final TokenManager _instance = TokenManager._internal();
factory TokenManager() => _instance;
TokenManager._internal();
String? _accessToken;
String? _refreshToken;
String? _username;
String? _userRole;
void saveTokens({
String? accessToken,
String? refreshToken,
String? username,
String? userRole,
}) {
if (accessToken != null) _accessToken = accessToken;
if (refreshToken != null) _refreshToken = refreshToken;
if (username != null) _username = username;
if (userRole != null) _userRole = userRole;
}
String? get accessToken => _accessToken;
String? get refreshToken => _refreshToken;
String? get username => _username;
String? get userRole => _userRole;
void clear() {
_accessToken = null;
_refreshToken = null;
_username = null;
_userRole = null;
}
}

View File

@@ -0,0 +1,141 @@
import 'package:hive_flutter/hive_flutter.dart';
import '../../models/action_mapping_hive_model.dart';
import '../../models/action_mapping_model.dart';
/// Abstract interface for local action mapping data operations
abstract class ActionMappingLocalDataSource {
Future<List<ActionMappingHiveModel>> getAllActionMappings();
Future<ActionMappingHiveModel?> getActionMappingById(String id);
Future<void> saveActionMapping(ActionMappingHiveModel mapping);
Future<void> deleteActionMapping(String id);
Future<List<ActionMappingHiveModel>> getDirtyActionMappings();
Future<void> markActionMappingAsSynced(String id);
Future<void> replaceAllActionMappings(List<ActionMappingModel> mappings, {bool force = false});
Future<List<ActionMappingHiveModel>> searchActionMappings(String query);
Future<void> clearAll();
}
/// Implementation of local action mapping data source using Hive
class ActionMappingLocalDataSourceImpl implements ActionMappingLocalDataSource {
static const String _boxName = 'action_mappings';
Future<Box<ActionMappingHiveModel>> get _box async =>
await Hive.openBox<ActionMappingHiveModel>(_boxName);
@override
Future<List<ActionMappingHiveModel>> getAllActionMappings() async {
final box = await _box;
return box.values
.where((mapping) => mapping.syncOperation != 'delete')
.toList();
}
@override
Future<ActionMappingHiveModel?> getActionMappingById(String id) async {
final box = await _box;
final mapping = box.get(id);
// Don't return deleted items
if (mapping?.syncOperation == 'delete') {
return null;
}
return mapping;
}
@override
Future<void> saveActionMapping(ActionMappingHiveModel mapping) async {
final box = await _box;
await box.put(mapping.id, mapping);
}
@override
Future<void> deleteActionMapping(String id) async {
final box = await _box;
final existing = box.get(id);
if (existing != null) {
// Soft delete: mark as dirty with delete operation
final deleted = existing.copyWith(
isDirty: true,
syncOperation: 'delete',
lastModified: DateTime.now(),
);
await box.put(id, deleted);
}
}
@override
Future<List<ActionMappingHiveModel>> getDirtyActionMappings() async {
final box = await _box;
return box.values.where((mapping) => mapping.isDirty).toList();
}
@override
Future<void> markActionMappingAsSynced(String id) async {
final box = await _box;
final mapping = box.get(id);
if (mapping != null) {
if (mapping.syncOperation == 'delete') {
// Actually delete after successful sync
await box.delete(id);
} else {
// Clear dirty flag and sync operation
final synced = mapping.copyWith(
isDirty: false,
syncOperation: null,
);
await box.put(id, synced);
}
}
}
@override
Future<void> replaceAllActionMappings(List<ActionMappingModel> mappings, {bool force = false}) async {
final box = await _box;
if (force) {
// Force mode: Clear ALL items (including dirty) to get fresh server data
await box.clear();
} else {
// Normal mode: Clear existing non-dirty items
final keysToDelete = box.values
.where((mapping) => !mapping.isDirty)
.map((mapping) => mapping.id)
.toList();
for (final key in keysToDelete) {
await box.delete(key);
}
}
// Add new items from server
for (final model in mappings) {
final hiveModel = ActionMappingHiveModel.fromActionMappingModel(
model,
isDirty: false,
);
await box.put(hiveModel.id, hiveModel);
}
}
@override
Future<List<ActionMappingHiveModel>> searchActionMappings(String query) async {
final box = await _box;
final lowerQuery = query.toLowerCase();
return box.values
.where((mapping) =>
mapping.syncOperation != 'delete' &&
(mapping.name.toLowerCase().contains(lowerQuery) ||
(mapping.description?.toLowerCase().contains(lowerQuery) ?? false)))
.toList();
}
@override
Future<void> clearAll() async {
final box = await _box;
await box.clear();
}
}

View File

@@ -0,0 +1,110 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../../core/errors/exceptions.dart';
import '../../../core/storage/token_manager.dart';
class SecureStorageManager {
final FlutterSecureStorage storage;
SecureStorageManager({required this.storage});
// Token storage
Future<void> saveAccessToken(String token) async {
// Save to memory for immediate use
TokenManager().saveTokens(accessToken: token);
try {
await storage.write(key: 'access_token', value: token);
} catch (e) {
// Silently fail on web when storage is not available (HTTP context)
print('Warning: Failed to save access token to secure storage (using memory): $e');
}
}
Future<void> saveRefreshToken(String token) async {
// Save to memory for immediate use
TokenManager().saveTokens(refreshToken: token);
try {
await storage.write(key: 'refresh_token', value: token);
} catch (e) {
// Silently fail on web when storage is not available (HTTP context)
print('Warning: Failed to save refresh token to secure storage (using memory): $e');
}
}
Future<String?> getAccessToken() async {
try {
final token = await storage.read(key: 'access_token');
if (token != null) return token;
} catch (e) {
print('Warning: Failed to read access token from secure storage, using memory');
}
// Fallback to memory storage
return TokenManager().accessToken;
}
Future<String?> getRefreshToken() async {
try {
final token = await storage.read(key: 'refresh_token');
if (token != null) return token;
} catch (e) {
print('Warning: Failed to read refresh token from secure storage, using memory');
}
// Fallback to memory storage
return TokenManager().refreshToken;
}
// User data storage
Future<void> saveUsername(String username) async {
// Save to memory for immediate use
TokenManager().saveTokens(username: username);
try {
await storage.write(key: 'username', value: username);
} catch (e) {
// Silently fail on web when storage is not available (HTTP context)
print('Warning: Failed to save username to secure storage (using memory): $e');
}
}
Future<void> saveUserRole(String role) async {
// Save to memory for immediate use
TokenManager().saveTokens(userRole: role);
try {
await storage.write(key: 'user_role', value: role);
} catch (e) {
// Silently fail on web when storage is not available (HTTP context)
print('Warning: Failed to save user role to secure storage (using memory): $e');
}
}
Future<String?> getUsername() async {
try {
return await storage.read(key: 'username');
} catch (e) {
throw CacheException('Failed to read username');
}
}
Future<String?> getUserRole() async {
try {
return await storage.read(key: 'user_role');
} catch (e) {
throw CacheException('Failed to read user role');
}
}
// Clear all data
Future<void> clearAll() async {
// Clear memory storage
TokenManager().clear();
try {
await storage.deleteAll();
} catch (e) {
print('Warning: Failed to clear secure storage: $e');
// Not throwing exception since memory is already cleared
}
}
}

View File

@@ -0,0 +1,159 @@
import 'package:hive/hive.dart';
import '../../models/server_hive_model.dart';
import '../../models/server_model.dart';
abstract class ServerLocalDataSource {
/// Get all servers from local storage
Future<List<ServerHiveModel>> getAllServers();
/// Get servers by type
Future<List<ServerHiveModel>> getServersByType(String type);
/// Get server by ID and type
Future<ServerHiveModel?> getServerById(String id, String type);
/// Save server to local storage (create or update)
Future<void> saveServer(ServerHiveModel server);
/// Delete server from local storage
Future<void> deleteServer(String id, String type);
/// Get all servers with pending sync operations
Future<List<ServerHiveModel>> getDirtyServers();
/// Clear dirty flag after successful sync
Future<void> markServerAsSynced(String id, String type);
/// Replace all servers (used after fetching from API)
Future<void> replaceAllServers(List<ServerModel> servers);
/// Clear all local data
Future<void> clearAll();
}
class ServerLocalDataSourceImpl implements ServerLocalDataSource {
static const String _boxName = 'servers';
Box<ServerHiveModel>? _box;
Future<Box<ServerHiveModel>> get box async {
if (_box != null && _box!.isOpen) {
return _box!;
}
_box = await Hive.openBox<ServerHiveModel>(_boxName);
return _box!;
}
String _getKey(String id, String type) => '${type}_$id';
@override
Future<List<ServerHiveModel>> getAllServers() async {
final b = await box;
return b.values.where((s) => s.syncOperation != 'delete').toList();
}
@override
Future<List<ServerHiveModel>> getServersByType(String type) async {
final b = await box;
return b.values
.where((s) => s.serverType == type && s.syncOperation != 'delete')
.toList();
}
@override
Future<ServerHiveModel?> getServerById(String id, String type) async {
final b = await box;
final key = _getKey(id, type);
final server = b.get(key);
// Don't return deleted servers
if (server?.syncOperation == 'delete') {
return null;
}
return server;
}
@override
Future<void> saveServer(ServerHiveModel server) async {
final b = await box;
final key = _getKey(server.id, server.serverType);
await b.put(key, server);
}
@override
Future<void> deleteServer(String id, String type) async {
final b = await box;
final key = _getKey(id, type);
final existing = b.get(key);
if (existing != null) {
// Mark as deleted instead of actually deleting
// This allows us to sync the deletion to the server
final deleted = existing.copyWith(
isDirty: true,
syncOperation: 'delete',
lastModified: DateTime.now(),
);
await b.put(key, deleted);
}
}
@override
Future<List<ServerHiveModel>> getDirtyServers() async {
final b = await box;
return b.values.where((s) => s.isDirty).toList();
}
@override
Future<void> markServerAsSynced(String id, String type) async {
final b = await box;
final key = _getKey(id, type);
final server = b.get(key);
if (server != null) {
if (server.syncOperation == 'delete') {
// Actually delete after successful sync
await b.delete(key);
} else {
// Clear dirty flag
final synced = server.copyWith(
isDirty: false,
syncOperation: null,
);
await b.put(key, synced);
}
}
}
@override
Future<void> replaceAllServers(List<ServerModel> servers) async {
final b = await box;
// Don't clear dirty servers - keep them for sync
final dirtyServers = await getDirtyServers();
final dirtyKeys = dirtyServers.map((s) => _getKey(s.id, s.serverType)).toSet();
// Clear only non-dirty servers
await b.clear();
// Re-add dirty servers
for (final dirty in dirtyServers) {
await b.put(_getKey(dirty.id, dirty.serverType), dirty);
}
// Add all fetched servers (but don't overwrite dirty ones)
for (final server in servers) {
final key = _getKey(server.id, server.serverType);
if (!dirtyKeys.contains(key)) {
await b.put(key, ServerHiveModel.fromServerModel(server));
}
}
}
@override
Future<void> clearAll() async {
final b = await box;
await b.clear();
}
}

View File

@@ -0,0 +1,110 @@
import 'package:dio/dio.dart';
import '../../../core/constants/api_constants.dart';
import '../../../core/errors/exceptions.dart';
import '../../models/action_mapping_model.dart';
/// Abstract interface for remote action mapping data operations
abstract class ActionMappingRemoteDataSource {
Future<List<ActionMappingModel>> getAllActionMappings();
Future<ActionMappingModel> getActionMappingById(String id);
Future<void> createActionMapping(ActionMappingModel mapping);
Future<void> updateActionMapping(String id, ActionMappingModel mapping);
Future<void> deleteActionMapping(String id);
}
/// Implementation of remote action mapping data source using Dio
class ActionMappingRemoteDataSourceImpl implements ActionMappingRemoteDataSource {
final Dio dio;
ActionMappingRemoteDataSourceImpl({required this.dio});
@override
Future<List<ActionMappingModel>> getAllActionMappings() async {
try {
final response = await dio.get(ApiConstants.actionMappingsEndpoint);
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
final List<dynamic> mappingsList = data['mappings'] as List<dynamic>;
return mappingsList.map((json) => ActionMappingModel.fromJson(json)).toList();
} else {
throw ActionMappingException('Failed to fetch action mappings');
}
} on DioException catch (e) {
throw _handleDioException(e);
}
}
@override
Future<ActionMappingModel> getActionMappingById(String id) async {
try {
final response = await dio.get('${ApiConstants.actionMappingsEndpoint}/$id');
if (response.statusCode == 200) {
return ActionMappingModel.fromJson(response.data);
} else {
throw ActionMappingException('Failed to fetch action mapping');
}
} on DioException catch (e) {
throw _handleDioException(e);
}
}
@override
Future<void> createActionMapping(ActionMappingModel mapping) async {
try {
final response = await dio.post(
ApiConstants.actionMappingsEndpoint,
data: mapping.toJson(),
);
if (response.statusCode != 200 && response.statusCode != 201) {
throw ActionMappingException('Failed to create action mapping');
}
} on DioException catch (e) {
throw _handleDioException(e);
}
}
@override
Future<void> updateActionMapping(String id, ActionMappingModel mapping) async {
try {
final response = await dio.put(
'${ApiConstants.actionMappingsEndpoint}/$id',
data: mapping.toJson(),
);
if (response.statusCode != 200) {
throw ActionMappingException('Failed to update action mapping');
}
} on DioException catch (e) {
throw _handleDioException(e);
}
}
@override
Future<void> deleteActionMapping(String id) async {
try {
final response = await dio.delete('${ApiConstants.actionMappingsEndpoint}/$id');
if (response.statusCode != 200 && response.statusCode != 204) {
throw ActionMappingException('Failed to delete action mapping');
}
} on DioException catch (e) {
throw _handleDioException(e);
}
}
Exception _handleDioException(DioException e) {
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout) {
return NetworkException('Connection timeout');
} else if (e.type == DioExceptionType.connectionError) {
return NetworkException('No internet connection');
} else if (e.response?.statusCode == 401) {
return AuthenticationException('Unauthorized');
} else {
return ActionMappingException('Server error: ${e.message}');
}
}
}

View File

@@ -0,0 +1,54 @@
import 'package:dio/dio.dart';
import '../../../core/constants/api_constants.dart';
import '../../../core/errors/exceptions.dart';
import '../../models/auth_model.dart';
abstract class AuthRemoteDataSource {
Future<AuthResponseModel> login(String username, String password);
Future<void> logout();
}
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
final Dio dio;
AuthRemoteDataSourceImpl({required this.dio});
@override
Future<AuthResponseModel> login(String username, String password) async {
try {
final response = await dio.post(
ApiConstants.loginEndpoint,
data: {
'username': username,
'password': password,
},
);
if (response.statusCode == 200) {
return AuthResponseModel.fromJson(response.data);
} else {
throw ServerException('Login failed: ${response.statusMessage}');
}
} on DioException catch (e) {
if (e.response?.statusCode == 401) {
throw AuthenticationException('Invalid credentials');
} else if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout) {
throw NetworkException('Connection timeout');
} else if (e.type == DioExceptionType.connectionError) {
throw NetworkException('No internet connection');
} else {
throw ServerException('Server error: ${e.message}');
}
} catch (e) {
throw ServerException('Unexpected error: $e');
}
}
@override
Future<void> logout() async {
// In this implementation, logout is handled locally (clearing tokens)
// If the API has a logout endpoint, implement it here
return;
}
}

View File

@@ -0,0 +1,33 @@
import '../../../core/errors/exceptions.dart';
import '../../models/auth_model.dart';
import 'auth_remote_data_source.dart';
/// Mock implementation of AuthRemoteDataSource for development/testing
/// Accepts hardcoded credentials: admin/admin123
class AuthRemoteDataSourceMock implements AuthRemoteDataSource {
@override
Future<AuthResponseModel> login(String username, String password) async {
// Simulate network delay
await Future.delayed(const Duration(milliseconds: 500));
// Check credentials
if (username == 'admin' && password == 'admin123') {
// Return mock successful auth response
return const AuthResponseModel(
accessToken: 'mock_access_token_12345',
refreshToken: 'mock_refresh_token_67890',
username: 'admin',
role: 'Administrator',
);
} else {
// Invalid credentials
throw AuthenticationException('Invalid username or password');
}
}
@override
Future<void> logout() async {
// Mock logout - nothing to do
await Future.delayed(const Duration(milliseconds: 200));
}
}

View File

@@ -0,0 +1,177 @@
import 'package:dio/dio.dart';
import '../../../core/constants/api_constants.dart';
import '../../../core/errors/exceptions.dart';
import '../../models/server_model.dart';
abstract class ServerRemoteDataSource {
Future<List<ServerModel>> getAllServers();
Future<List<ServerModel>> getGCoreServers();
Future<List<ServerModel>> getGeViScopeServers();
Future<ServerModel> getServerById(String id, String type);
Future<void> createServer(ServerModel server);
Future<void> updateServer(ServerModel server);
Future<void> deleteServer(String id, String type);
}
class ServerRemoteDataSourceImpl implements ServerRemoteDataSource {
final Dio dio;
ServerRemoteDataSourceImpl({required this.dio});
@override
Future<List<ServerModel>> getAllServers() async {
try {
final response = await dio.get(ApiConstants.serversEndpoint);
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
final List<ServerModel> servers = [];
// Parse gcore servers
if (data['gcore_servers'] != null) {
final gcoreList = data['gcore_servers'] as List<dynamic>;
servers.addAll(
gcoreList.map((json) => ServerModel.fromJson(json, 'gcore')),
);
}
// Parse geviscope servers
if (data['geviscope_servers'] != null) {
final geviscopeList = data['geviscope_servers'] as List<dynamic>;
servers.addAll(
geviscopeList.map((json) => ServerModel.fromJson(json, 'geviscope')),
);
}
return servers;
} else {
throw ServerException('Failed to fetch servers');
}
} on DioException catch (e) {
throw _handleDioException(e);
}
}
@override
Future<List<ServerModel>> getGCoreServers() async {
try {
final response = await dio.get(ApiConstants.gcoreServersEndpoint);
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
final List<dynamic> serversList = data['servers'] as List<dynamic>;
return serversList.map((json) => ServerModel.fromJson(json, 'gcore')).toList();
} else {
throw ServerException('Failed to fetch G-Core servers');
}
} on DioException catch (e) {
throw _handleDioException(e);
}
}
@override
Future<List<ServerModel>> getGeViScopeServers() async {
try {
final response = await dio.get(ApiConstants.geviscopeServersEndpoint);
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
final List<dynamic> serversList = data['servers'] as List<dynamic>;
return serversList.map((json) => ServerModel.fromJson(json, 'geviscope')).toList();
} else {
throw ServerException('Failed to fetch GeViScope servers');
}
} on DioException catch (e) {
throw _handleDioException(e);
}
}
@override
Future<ServerModel> getServerById(String id, String type) async {
try {
final endpoint = type == 'gcore'
? '${ApiConstants.gcoreServersEndpoint}/$id'
: '${ApiConstants.geviscopeServersEndpoint}/$id';
final response = await dio.get(endpoint);
if (response.statusCode == 200) {
return ServerModel.fromJson(response.data, type);
} else {
throw ServerException('Failed to fetch server');
}
} on DioException catch (e) {
throw _handleDioException(e);
}
}
@override
Future<void> createServer(ServerModel server) async {
try {
final endpoint = server.serverType == 'gcore'
? ApiConstants.gcoreServersEndpoint
: ApiConstants.geviscopeServersEndpoint;
final response = await dio.post(
endpoint,
data: server.toJson(),
);
if (response.statusCode != 200 && response.statusCode != 201) {
throw ServerException('Failed to create server');
}
} on DioException catch (e) {
throw _handleDioException(e);
}
}
@override
Future<void> updateServer(ServerModel server) async {
try {
final endpoint = server.serverType == 'gcore'
? '${ApiConstants.gcoreServersEndpoint}/${server.id}'
: '${ApiConstants.geviscopeServersEndpoint}/${server.id}';
final response = await dio.put(
endpoint,
data: server.toJson(),
);
if (response.statusCode != 200) {
throw ServerException('Failed to update server');
}
} on DioException catch (e) {
throw _handleDioException(e);
}
}
@override
Future<void> deleteServer(String id, String type) async {
try {
final endpoint = type == 'gcore'
? '${ApiConstants.gcoreServersEndpoint}/$id'
: '${ApiConstants.geviscopeServersEndpoint}/$id';
final response = await dio.delete(endpoint);
if (response.statusCode != 200 && response.statusCode != 204) {
throw ServerException('Failed to delete server');
}
} on DioException catch (e) {
throw _handleDioException(e);
}
}
Exception _handleDioException(DioException e) {
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout) {
return NetworkException('Connection timeout');
} else if (e.type == DioExceptionType.connectionError) {
return NetworkException('No internet connection');
} else if (e.response?.statusCode == 401) {
return AuthenticationException('Unauthorized');
} else {
return ServerException('Server error: ${e.message}');
}
}
}

View File

@@ -0,0 +1,229 @@
import 'dart:convert';
import 'package:hive/hive.dart';
import '../../domain/entities/action_mapping.dart';
import 'action_mapping_model.dart';
import 'action_output.dart';
part 'action_mapping_hive_model.g.dart';
/// Hive model for local storage of action mappings with sync tracking
@HiveType(typeId: 1)
class ActionMappingHiveModel extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String name;
@HiveField(2)
final String? description;
@HiveField(3)
final String inputAction;
@HiveField(4)
final List<String> outputActions; // Deprecated - kept for compatibility
@HiveField(5)
final String? geviscopeInstanceScope;
@HiveField(6)
final bool enabled;
@HiveField(7)
final int executionCount;
@HiveField(8)
final DateTime? lastExecuted;
@HiveField(9)
final DateTime createdAt;
@HiveField(10)
final DateTime updatedAt;
@HiveField(11)
final String createdBy;
// Sync tracking fields
@HiveField(12)
final bool isDirty;
@HiveField(13)
final DateTime lastModified;
@HiveField(14)
final String? syncOperation; // 'create', 'update', 'delete'
// New fields for parameters (stored as JSON strings)
@HiveField(15)
final String? inputParametersJson; // JSON-encoded Map<String, dynamic>
@HiveField(16)
final String? outputActionsJson; // JSON-encoded List<ActionOutput>
ActionMappingHiveModel({
required this.id,
required this.name,
this.description,
required this.inputAction,
required this.outputActions,
this.geviscopeInstanceScope,
this.enabled = true,
this.executionCount = 0,
this.lastExecuted,
required this.createdAt,
required this.updatedAt,
required this.createdBy,
this.isDirty = false,
required this.lastModified,
this.syncOperation,
this.inputParametersJson,
this.outputActionsJson,
});
/// Create Hive model from domain entity
factory ActionMappingHiveModel.fromEntity(
ActionMapping mapping, {
bool isDirty = false,
String? syncOperation,
}) {
return ActionMappingHiveModel(
id: mapping.id,
name: mapping.name,
description: mapping.description,
inputAction: mapping.inputAction,
outputActions: mapping.outputActions.map((o) => o.action).toList(), // For compatibility
geviscopeInstanceScope: mapping.geviscopeInstanceScope,
enabled: mapping.enabled,
executionCount: mapping.executionCount,
lastExecuted: mapping.lastExecuted,
createdAt: mapping.createdAt,
updatedAt: mapping.updatedAt,
createdBy: mapping.createdBy,
isDirty: isDirty,
lastModified: DateTime.now(),
syncOperation: syncOperation,
inputParametersJson: jsonEncode(mapping.inputParameters),
outputActionsJson: jsonEncode(mapping.outputActions.map((o) => o.toJson()).toList()),
);
}
/// Create Hive model from API model
factory ActionMappingHiveModel.fromActionMappingModel(
ActionMappingModel model, {
bool isDirty = false,
String? syncOperation,
}) {
return ActionMappingHiveModel(
id: model.id,
name: model.name,
description: model.description,
inputAction: model.inputAction,
outputActions: model.outputActions.map((o) => o.action).toList(), // For compatibility
geviscopeInstanceScope: model.geviscopeInstanceScope,
enabled: model.enabled,
executionCount: model.executionCount,
lastExecuted: model.lastExecuted,
createdAt: model.createdAt,
updatedAt: model.updatedAt,
createdBy: model.createdBy,
isDirty: isDirty,
lastModified: DateTime.now(),
syncOperation: syncOperation,
inputParametersJson: jsonEncode(model.inputParameters),
outputActionsJson: jsonEncode(model.outputActions.map((o) => o.toJson()).toList()),
);
}
/// Convert to API model
ActionMappingModel toActionMappingModel() {
// Decode parameters from JSON
Map<String, dynamic> inputParams = {};
List<ActionOutput> outputs = [];
if (inputParametersJson != null && inputParametersJson!.isNotEmpty) {
try {
inputParams = Map<String, dynamic>.from(jsonDecode(inputParametersJson!));
} catch (e) {
print('Error decoding inputParametersJson: $e');
}
}
if (outputActionsJson != null && outputActionsJson!.isNotEmpty) {
try {
final decoded = jsonDecode(outputActionsJson!) as List<dynamic>;
outputs = decoded.map((e) => ActionOutput.fromJson(e as Map<String, dynamic>)).toList();
} catch (e) {
print('Error decoding outputActionsJson: $e');
// Fallback to legacy outputActions
outputs = outputActions.map((action) => ActionOutput(action: action, parameters: {})).toList();
}
} else {
// Fallback to legacy outputActions
outputs = outputActions.map((action) => ActionOutput(action: action, parameters: {})).toList();
}
return ActionMappingModel(
id: id,
name: name,
description: description,
inputAction: inputAction,
inputParameters: inputParams,
outputActions: outputs,
geviscopeInstanceScope: geviscopeInstanceScope,
enabled: enabled,
executionCount: executionCount,
lastExecuted: lastExecuted,
createdAt: createdAt,
updatedAt: updatedAt,
createdBy: createdBy,
);
}
/// Convert to domain entity
ActionMapping toEntity() {
return toActionMappingModel().toEntity();
}
/// Create a copy with modified fields
ActionMappingHiveModel copyWith({
String? id,
String? name,
String? description,
String? inputAction,
List<String>? outputActions,
String? geviscopeInstanceScope,
bool? enabled,
int? executionCount,
DateTime? lastExecuted,
DateTime? createdAt,
DateTime? updatedAt,
String? createdBy,
bool? isDirty,
DateTime? lastModified,
String? syncOperation,
String? inputParametersJson,
String? outputActionsJson,
}) {
return ActionMappingHiveModel(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
inputAction: inputAction ?? this.inputAction,
outputActions: outputActions ?? this.outputActions,
geviscopeInstanceScope: geviscopeInstanceScope ?? this.geviscopeInstanceScope,
enabled: enabled ?? this.enabled,
executionCount: executionCount ?? this.executionCount,
lastExecuted: lastExecuted ?? this.lastExecuted,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
createdBy: createdBy ?? this.createdBy,
isDirty: isDirty ?? this.isDirty,
lastModified: lastModified ?? this.lastModified,
syncOperation: syncOperation ?? this.syncOperation,
inputParametersJson: inputParametersJson ?? this.inputParametersJson,
outputActionsJson: outputActionsJson ?? this.outputActionsJson,
);
}
}

View File

@@ -0,0 +1,90 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'action_mapping_hive_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ActionMappingHiveModelAdapter
extends TypeAdapter<ActionMappingHiveModel> {
@override
final int typeId = 1;
@override
ActionMappingHiveModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ActionMappingHiveModel(
id: fields[0] as String,
name: fields[1] as String,
description: fields[2] as String?,
inputAction: fields[3] as String,
outputActions: (fields[4] as List).cast<String>(),
geviscopeInstanceScope: fields[5] as String?,
enabled: fields[6] as bool,
executionCount: fields[7] as int,
lastExecuted: fields[8] as DateTime?,
createdAt: fields[9] as DateTime,
updatedAt: fields[10] as DateTime,
createdBy: fields[11] as String,
isDirty: fields[12] as bool,
lastModified: fields[13] as DateTime,
syncOperation: fields[14] as String?,
inputParametersJson: fields[15] as String?,
outputActionsJson: fields[16] as String?,
);
}
@override
void write(BinaryWriter writer, ActionMappingHiveModel obj) {
writer
..writeByte(17)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.name)
..writeByte(2)
..write(obj.description)
..writeByte(3)
..write(obj.inputAction)
..writeByte(4)
..write(obj.outputActions)
..writeByte(5)
..write(obj.geviscopeInstanceScope)
..writeByte(6)
..write(obj.enabled)
..writeByte(7)
..write(obj.executionCount)
..writeByte(8)
..write(obj.lastExecuted)
..writeByte(9)
..write(obj.createdAt)
..writeByte(10)
..write(obj.updatedAt)
..writeByte(11)
..write(obj.createdBy)
..writeByte(12)
..write(obj.isDirty)
..writeByte(13)
..write(obj.lastModified)
..writeByte(14)
..write(obj.syncOperation)
..writeByte(15)
..write(obj.inputParametersJson)
..writeByte(16)
..write(obj.outputActionsJson);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ActionMappingHiveModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,145 @@
import '../../domain/entities/action_mapping.dart';
import 'action_output.dart';
/// Data model for action mapping with JSON serialization
/// Handles conversion between API (snake_case) and domain entities
class ActionMappingModel {
final String id;
final String name;
final String? description;
final String inputAction;
final Map<String, dynamic> inputParameters;
final List<ActionOutput> outputActions;
final String? geviscopeInstanceScope;
final bool enabled;
final int executionCount;
final DateTime? lastExecuted;
final DateTime createdAt;
final DateTime updatedAt;
final String createdBy;
const ActionMappingModel({
required this.id,
required this.name,
this.description,
required this.inputAction,
this.inputParameters = const {},
required this.outputActions,
this.geviscopeInstanceScope,
this.enabled = true,
this.executionCount = 0,
this.lastExecuted,
required this.createdAt,
required this.updatedAt,
required this.createdBy,
});
/// Create model from JSON (API response with snake_case keys)
factory ActionMappingModel.fromJson(Map<String, dynamic> json) {
// Convert ID from int to string if needed
final id = json['id'].toString();
// Extract input action and parameters from array of objects
final inputActions = json['input_actions'] as List<dynamic>?;
String inputAction;
Map<String, dynamic> inputParameters;
if (inputActions != null && inputActions.isNotEmpty) {
final firstInput = inputActions[0] as Map<String, dynamic>;
inputAction = firstInput['action'] as String;
inputParameters = Map<String, dynamic>.from(firstInput['parameters'] as Map? ?? {});
} else {
inputAction = json['name'] as String; // Fallback to name
inputParameters = {};
}
// Extract output actions and parameters from array of objects
final outputActionsRaw = json['output_actions'] as List<dynamic>?;
final outputActions = outputActionsRaw != null
? outputActionsRaw.map((e) => ActionOutput.fromJson(e as Map<String, dynamic>)).toList()
: <ActionOutput>[];
return ActionMappingModel(
id: id,
name: json['name'] as String,
description: json['description'] as String?,
inputAction: inputAction,
inputParameters: inputParameters,
outputActions: outputActions,
geviscopeInstanceScope: json['geviscope_instance_scope'] as String?,
enabled: json['enabled'] as bool? ?? true,
executionCount: json['execution_count'] as int? ?? 0,
lastExecuted: json['last_executed'] != null
? DateTime.parse(json['last_executed'] as String)
: null,
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'] as String)
: DateTime.now(),
updatedAt: json['updated_at'] != null
? DateTime.parse(json['updated_at'] as String)
: DateTime.now(),
createdBy: json['created_by'] as String? ?? 'system',
);
}
/// Convert model to JSON (for API requests with snake_case keys)
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
if (description != null) 'description': description,
// API expects input_actions as array of objects with parameters
'input_actions': [
{'action': inputAction, 'parameters': inputParameters}
],
// API expects output_actions as array of objects with parameters
'output_actions': outputActions.map((output) => output.toJson()).toList(),
if (geviscopeInstanceScope != null)
'geviscope_instance_scope': geviscopeInstanceScope,
'enabled': enabled,
'execution_count': executionCount,
if (lastExecuted != null) 'last_executed': lastExecuted!.toIso8601String(),
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'created_by': createdBy,
};
}
/// Convert to domain entity
ActionMapping toEntity() {
return ActionMapping(
id: id,
name: name,
description: description,
inputAction: inputAction,
inputParameters: inputParameters,
outputActions: outputActions,
geviscopeInstanceScope: geviscopeInstanceScope,
enabled: enabled,
executionCount: executionCount,
lastExecuted: lastExecuted,
createdAt: createdAt,
updatedAt: updatedAt,
createdBy: createdBy,
);
}
/// Create model from domain entity
factory ActionMappingModel.fromEntity(ActionMapping mapping) {
return ActionMappingModel(
id: mapping.id,
name: mapping.name,
description: mapping.description,
inputAction: mapping.inputAction,
inputParameters: mapping.inputParameters,
outputActions: mapping.outputActions,
geviscopeInstanceScope: mapping.geviscopeInstanceScope,
enabled: mapping.enabled,
executionCount: mapping.executionCount,
lastExecuted: mapping.lastExecuted,
createdAt: mapping.createdAt,
updatedAt: mapping.updatedAt,
createdBy: mapping.createdBy,
);
}
}

View File

@@ -0,0 +1,56 @@
/// Represents an output action with its parameters
class ActionOutput {
final String action;
final Map<String, dynamic> parameters;
const ActionOutput({
required this.action,
required this.parameters,
});
/// Create from JSON
factory ActionOutput.fromJson(Map<String, dynamic> json) {
return ActionOutput(
action: json['action'] as String,
parameters: Map<String, dynamic>.from(json['parameters'] as Map? ?? {}),
);
}
/// Convert to JSON
Map<String, dynamic> toJson() {
return {
'action': action,
'parameters': parameters,
};
}
/// Create a copy with updated values
ActionOutput copyWith({
String? action,
Map<String, dynamic>? parameters,
}) {
return ActionOutput(
action: action ?? this.action,
parameters: parameters ?? this.parameters,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ActionOutput &&
other.action == action &&
_mapsEqual(other.parameters, parameters);
}
@override
int get hashCode => action.hashCode ^ parameters.hashCode;
bool _mapsEqual(Map<String, dynamic> a, Map<String, dynamic> b) {
if (a.length != b.length) return false;
for (var key in a.keys) {
if (!b.containsKey(key) || a[key] != b[key]) return false;
}
return true;
}
}

View File

@@ -0,0 +1,174 @@
import 'package:equatable/equatable.dart';
/// Model representing an action template from the API
/// Used for dynamic form generation when selecting action types
class ActionTemplate extends Equatable {
final String actionName;
final List<String> parameters;
final String description;
final String category;
final bool requiredCaption;
final bool supportsDelay;
final Map<String, String>? parameterTypes;
const ActionTemplate({
required this.actionName,
required this.parameters,
required this.description,
required this.category,
this.requiredCaption = true,
this.supportsDelay = true,
this.parameterTypes,
});
factory ActionTemplate.fromJson(Map<String, dynamic> json) {
return ActionTemplate(
actionName: json['action_name'] as String,
parameters: (json['parameters'] as List<dynamic>)
.map((e) => e.toString())
.toList(),
description: json['description'] as String,
category: json['category'] as String,
requiredCaption: json['required_caption'] as bool? ?? true,
supportsDelay: json['supports_delay'] as bool? ?? true,
parameterTypes: json['parameter_types'] != null
? Map<String, String>.from(json['parameter_types'] as Map)
: null,
);
}
Map<String, dynamic> toJson() {
return {
'action_name': actionName,
'parameters': parameters,
'description': description,
'category': category,
'required_caption': requiredCaption,
'supports_delay': supportsDelay,
if (parameterTypes != null) 'parameter_types': parameterTypes,
};
}
@override
List<Object?> get props => [
actionName,
parameters,
description,
category,
requiredCaption,
supportsDelay,
parameterTypes,
];
}
/// Model representing a server (G-Core or GeViScope)
class ServerInfo extends Equatable {
final String id;
final String alias;
final bool enabled;
const ServerInfo({
required this.id,
required this.alias,
required this.enabled,
});
factory ServerInfo.fromJson(Map<String, dynamic> json) {
return ServerInfo(
id: json['id'].toString(),
alias: json['alias'] as String,
enabled: json['enabled'] as bool,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'alias': alias,
'enabled': enabled,
};
}
@override
List<Object?> get props => [id, alias, enabled];
}
/// Model for servers information
class ServersInfo extends Equatable {
final List<ServerInfo> gcoreServers;
final List<ServerInfo> gscServers;
const ServersInfo({
required this.gcoreServers,
required this.gscServers,
});
factory ServersInfo.fromJson(Map<String, dynamic> json) {
return ServersInfo(
gcoreServers: (json['gcore_servers'] as List<dynamic>?)
?.map((e) => ServerInfo.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
gscServers: (json['gsc_servers'] as List<dynamic>?)
?.map((e) => ServerInfo.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
);
}
@override
List<Object?> get props => [gcoreServers, gscServers];
}
/// Model for action categories response
class ActionCategoriesResponse extends Equatable {
final Map<String, List<String>> categories;
final int totalCategories;
final int totalActions;
final ServersInfo servers;
final List<String> gscSpecificCategories;
const ActionCategoriesResponse({
required this.categories,
required this.totalCategories,
required this.totalActions,
required this.servers,
this.gscSpecificCategories = const [],
});
factory ActionCategoriesResponse.fromJson(Map<String, dynamic> json) {
final categoriesMap = json['categories'] as Map<String, dynamic>;
final categories = categoriesMap.map(
(key, value) => MapEntry(
key,
(value as List<dynamic>).map((e) => e.toString()).toList(),
),
);
return ActionCategoriesResponse(
categories: categories,
totalCategories: json['total_categories'] as int,
totalActions: json['total_actions'] as int,
servers: ServersInfo.fromJson(json['servers'] as Map<String, dynamic>? ?? {}),
gscSpecificCategories: (json['gsc_specific_categories'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ??
[],
);
}
/// Get all action names sorted by category
List<String> getActionsForCategory(String category) {
return categories[category] ?? [];
}
/// Get list of all category names sorted
List<String> get categoryNames {
final names = categories.keys.toList();
names.sort();
return names;
}
@override
List<Object?> get props => [categories, totalCategories, totalActions, servers, gscSpecificCategories];
}

View File

@@ -0,0 +1,43 @@
import '../../domain/entities/user.dart';
class AuthResponseModel {
final String accessToken;
final String refreshToken;
final String username;
final String role;
AuthResponseModel({
required this.accessToken,
required this.refreshToken,
required this.username,
required this.role,
});
factory AuthResponseModel.fromJson(Map<String, dynamic> json) {
final user = json['user'] as Map<String, dynamic>;
return AuthResponseModel(
accessToken: json['access_token'] as String,
refreshToken: json['refresh_token'] as String,
username: user['username'] as String,
role: user['role'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'access_token': accessToken,
'refresh_token': refreshToken,
'username': username,
'role': role,
};
}
User toEntity() {
return User(
username: username,
role: role,
accessToken: accessToken,
refreshToken: refreshToken,
);
}
}

View File

@@ -0,0 +1,154 @@
import 'package:hive/hive.dart';
import '../../domain/entities/server.dart';
import 'server_model.dart';
part 'server_hive_model.g.dart';
@HiveType(typeId: 0)
class ServerHiveModel extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String alias;
@HiveField(2)
final String host;
@HiveField(3)
final String user;
@HiveField(4)
final String password;
@HiveField(5)
final bool enabled;
@HiveField(6)
final bool deactivateEcho;
@HiveField(7)
final bool deactivateLiveCheck;
@HiveField(8)
final String serverType; // 'gcore' or 'geviscope'
@HiveField(9)
final bool isDirty; // Has unsaved changes
@HiveField(10)
final DateTime lastModified;
@HiveField(11)
final String? syncOperation; // 'create', 'update', 'delete', null
ServerHiveModel({
required this.id,
required this.alias,
required this.host,
required this.user,
required this.password,
required this.enabled,
required this.deactivateEcho,
required this.deactivateLiveCheck,
required this.serverType,
this.isDirty = false,
DateTime? lastModified,
this.syncOperation,
}) : lastModified = lastModified ?? DateTime.now();
// Convert from domain entity to Hive model
factory ServerHiveModel.fromEntity(Server server, {bool isDirty = false, String? syncOperation}) {
return ServerHiveModel(
id: server.id,
alias: server.alias,
host: server.host,
user: server.user,
password: server.password,
enabled: server.enabled,
deactivateEcho: server.deactivateEcho,
deactivateLiveCheck: server.deactivateLiveCheck,
serverType: server.type == ServerType.gcore ? 'gcore' : 'geviscope',
isDirty: isDirty,
syncOperation: syncOperation,
);
}
// Convert from ServerModel (API) to Hive model
factory ServerHiveModel.fromServerModel(ServerModel serverModel) {
return ServerHiveModel(
id: serverModel.id,
alias: serverModel.alias,
host: serverModel.host,
user: serverModel.user,
password: serverModel.password,
enabled: serverModel.enabled,
deactivateEcho: serverModel.deactivateEcho,
deactivateLiveCheck: serverModel.deactivateLiveCheck,
serverType: serverModel.serverType,
isDirty: false,
syncOperation: null,
);
}
// Convert to domain entity
Server toEntity() {
return Server(
id: id,
alias: alias,
host: host,
user: user,
password: password,
enabled: enabled,
deactivateEcho: deactivateEcho,
deactivateLiveCheck: deactivateLiveCheck,
type: serverType == 'gcore' ? ServerType.gcore : ServerType.geviscope,
);
}
// Convert to ServerModel for API calls
ServerModel toServerModel() {
return ServerModel(
id: id,
alias: alias,
host: host,
user: user,
password: password,
enabled: enabled,
deactivateEcho: deactivateEcho,
deactivateLiveCheck: deactivateLiveCheck,
serverType: serverType,
);
}
// Create a copy with modified fields
ServerHiveModel copyWith({
String? id,
String? alias,
String? host,
String? user,
String? password,
bool? enabled,
bool? deactivateEcho,
bool? deactivateLiveCheck,
String? serverType,
bool? isDirty,
DateTime? lastModified,
String? syncOperation,
}) {
return ServerHiveModel(
id: id ?? this.id,
alias: alias ?? this.alias,
host: host ?? this.host,
user: user ?? this.user,
password: password ?? this.password,
enabled: enabled ?? this.enabled,
deactivateEcho: deactivateEcho ?? this.deactivateEcho,
deactivateLiveCheck: deactivateLiveCheck ?? this.deactivateLiveCheck,
serverType: serverType ?? this.serverType,
isDirty: isDirty ?? this.isDirty,
lastModified: lastModified ?? this.lastModified,
syncOperation: syncOperation ?? this.syncOperation,
);
}
}

View File

@@ -0,0 +1,74 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'server_hive_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ServerHiveModelAdapter extends TypeAdapter<ServerHiveModel> {
@override
final int typeId = 0;
@override
ServerHiveModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ServerHiveModel(
id: fields[0] as String,
alias: fields[1] as String,
host: fields[2] as String,
user: fields[3] as String,
password: fields[4] as String,
enabled: fields[5] as bool,
deactivateEcho: fields[6] as bool,
deactivateLiveCheck: fields[7] as bool,
serverType: fields[8] as String,
isDirty: fields[9] as bool,
lastModified: fields[10] as DateTime?,
syncOperation: fields[11] as String?,
);
}
@override
void write(BinaryWriter writer, ServerHiveModel obj) {
writer
..writeByte(12)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.alias)
..writeByte(2)
..write(obj.host)
..writeByte(3)
..write(obj.user)
..writeByte(4)
..write(obj.password)
..writeByte(5)
..write(obj.enabled)
..writeByte(6)
..write(obj.deactivateEcho)
..writeByte(7)
..write(obj.deactivateLiveCheck)
..writeByte(8)
..write(obj.serverType)
..writeByte(9)
..write(obj.isDirty)
..writeByte(10)
..write(obj.lastModified)
..writeByte(11)
..write(obj.syncOperation);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ServerHiveModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,80 @@
import '../../domain/entities/server.dart';
class ServerModel {
final String id;
final String alias;
final String host;
final String user;
final String password;
final bool enabled;
final bool deactivateEcho;
final bool deactivateLiveCheck;
final String serverType; // "gcore" or "geviscope"
ServerModel({
required this.id,
required this.alias,
required this.host,
required this.user,
required this.password,
required this.enabled,
required this.deactivateEcho,
required this.deactivateLiveCheck,
required this.serverType,
});
factory ServerModel.fromJson(Map<String, dynamic> json, String type) {
return ServerModel(
id: json['id'].toString(),
alias: json['alias'] as String,
host: json['host'] as String,
user: json['user'] as String,
password: json['password'] as String,
enabled: json['enabled'] as bool,
deactivateEcho: json['deactivate_echo'] as bool,
deactivateLiveCheck: json['deactivate_live_check'] as bool,
serverType: type,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'alias': alias,
'host': host,
'user': user,
'password': password,
'enabled': enabled,
'deactivate_echo': deactivateEcho,
'deactivate_live_check': deactivateLiveCheck,
};
}
Server toEntity() {
return Server(
id: id,
alias: alias,
host: host,
user: user,
password: password,
enabled: enabled,
deactivateEcho: deactivateEcho,
deactivateLiveCheck: deactivateLiveCheck,
type: serverType == 'gcore' ? ServerType.gcore : ServerType.geviscope,
);
}
factory ServerModel.fromEntity(Server server) {
return ServerModel(
id: server.id,
alias: server.alias,
host: server.host,
user: server.user,
password: server.password,
enabled: server.enabled,
deactivateEcho: server.deactivateEcho,
deactivateLiveCheck: server.deactivateLiveCheck,
serverType: server.type == ServerType.gcore ? 'gcore' : 'geviscope',
);
}
}

View File

@@ -0,0 +1,119 @@
import 'package:dartz/dartz.dart';
import '../../domain/entities/action_mapping.dart';
import '../../domain/repositories/action_mapping_repository.dart';
import '../../core/errors/failures.dart';
import '../data_sources/local/action_mapping_local_data_source.dart';
import '../data_sources/remote/action_mapping_remote_data_source.dart';
import '../models/action_mapping_hive_model.dart';
import '../services/sync_service.dart';
class ActionMappingRepositoryImpl implements ActionMappingRepository {
final ActionMappingLocalDataSource localDataSource;
final ActionMappingRemoteDataSource remoteDataSource;
final SyncService syncService;
ActionMappingRepositoryImpl({
required this.localDataSource,
required this.remoteDataSource,
required this.syncService,
});
@override
Future<Either<Failure, List<ActionMapping>>> getAllActionMappings() async {
try {
// Read from local storage
final mappings = await localDataSource.getAllActionMappings();
return Right(mappings.map((model) => model.toEntity()).toList());
} catch (e) {
return Left(ActionMappingFailure('Failed to load action mappings: $e'));
}
}
@override
Future<Either<Failure, ActionMapping>> getActionMappingById(String id) async {
try {
final mapping = await localDataSource.getActionMappingById(id);
if (mapping == null) {
return Left(ActionMappingFailure('Action mapping not found'));
}
return Right(mapping.toEntity());
} catch (e) {
return Left(ActionMappingFailure('Failed to load action mapping: $e'));
}
}
@override
Future<Either<Failure, void>> createActionMapping(ActionMapping mapping) async {
try {
// Save to local storage with dirty flag and 'create' operation
final hiveModel = ActionMappingHiveModel.fromEntity(
mapping,
isDirty: true,
syncOperation: 'create',
);
await localDataSource.saveActionMapping(hiveModel);
return const Right(null);
} catch (e) {
return Left(ActionMappingFailure('Failed to create action mapping: $e'));
}
}
@override
Future<Either<Failure, void>> updateActionMapping(ActionMapping mapping) async {
try {
// Save to local storage with dirty flag and 'update' operation
final hiveModel = ActionMappingHiveModel.fromEntity(
mapping,
isDirty: true,
syncOperation: 'update',
);
await localDataSource.saveActionMapping(hiveModel);
return const Right(null);
} catch (e) {
return Left(ActionMappingFailure('Failed to update action mapping: $e'));
}
}
@override
Future<Either<Failure, void>> deleteActionMapping(String id) async {
try {
// Mark as deleted in local storage (soft delete with sync flag)
await localDataSource.deleteActionMapping(id);
return const Right(null);
} catch (e) {
return Left(ActionMappingFailure('Failed to delete action mapping: $e'));
}
}
@override
Future<Either<Failure, List<ActionMapping>>> searchActionMappings(String query) async {
try {
final mappings = await localDataSource.searchActionMappings(query);
return Right(mappings.map((model) => model.toEntity()).toList());
} catch (e) {
return Left(ActionMappingFailure('Failed to search action mappings: $e'));
}
}
@override
Future<Either<Failure, SyncResult>> syncToServer() async {
return await syncService.syncActionMappings();
}
@override
Future<Either<Failure, int>> downloadFromServer() async {
return await syncService.downloadActionMappings();
}
@override
Future<Either<Failure, int>> getDirtyCount() async {
try {
final dirtyMappings = await localDataSource.getDirtyActionMappings();
return Right(dirtyMappings.length);
} catch (e) {
return Left(ActionMappingFailure('Failed to get dirty count: $e'));
}
}
}

View File

@@ -0,0 +1,94 @@
import 'package:dartz/dartz.dart';
import '../../domain/entities/user.dart';
import '../../domain/repositories/auth_repository.dart';
import '../../core/errors/failures.dart';
import '../../core/errors/exceptions.dart';
import '../data_sources/remote/auth_remote_data_source.dart';
import '../data_sources/local/secure_storage_manager.dart';
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource remoteDataSource;
final SecureStorageManager storageManager;
AuthRepositoryImpl({
required this.remoteDataSource,
required this.storageManager,
});
@override
Future<Either<Failure, User>> login(String username, String password) async {
try {
final authResponse = await remoteDataSource.login(username, password);
// Save tokens and user data
await storageManager.saveAccessToken(authResponse.accessToken);
await storageManager.saveRefreshToken(authResponse.refreshToken);
await storageManager.saveUsername(authResponse.username);
await storageManager.saveUserRole(authResponse.role);
return Right(authResponse.toEntity());
} on AuthenticationException catch (e) {
return Left(AuthenticationFailure(e.message));
} on NetworkException catch (e) {
return Left(NetworkFailure(e.message));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(ServerFailure('Unexpected error: $e'));
}
}
@override
Future<Either<Failure, void>> logout() async {
try {
await remoteDataSource.logout();
await storageManager.clearAll();
return const Right(null);
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(ServerFailure('Unexpected error during logout: $e'));
}
}
@override
Future<Either<Failure, User>> refreshToken() async {
try {
final refreshToken = await storageManager.getRefreshToken();
if (refreshToken == null) {
return const Left(AuthenticationFailure('No refresh token available'));
}
// In a real implementation, you'd call an API endpoint to refresh the token
// For now, we'll just return an error
return const Left(AuthenticationFailure('Token refresh not implemented'));
} catch (e) {
return Left(ServerFailure('Failed to refresh token: $e'));
}
}
@override
Future<Either<Failure, User?>> getCurrentUser() async {
try {
final username = await storageManager.getUsername();
final role = await storageManager.getUserRole();
final accessToken = await storageManager.getAccessToken();
final refreshToken = await storageManager.getRefreshToken();
if (username == null || role == null || accessToken == null || refreshToken == null) {
return const Right(null);
}
return Right(User(
username: username,
role: role,
accessToken: accessToken,
refreshToken: refreshToken,
));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(ServerFailure('Failed to get current user: $e'));
}
}
}

View File

@@ -0,0 +1,133 @@
import 'package:dartz/dartz.dart';
import '../../domain/entities/server.dart';
import '../../domain/repositories/server_repository.dart';
import '../../core/errors/failures.dart';
import '../data_sources/local/server_local_data_source.dart';
import '../data_sources/remote/server_remote_data_source.dart';
import '../models/server_hive_model.dart';
import '../services/sync_service.dart';
class ServerRepositoryImpl implements ServerRepository {
final ServerLocalDataSource localDataSource;
final ServerRemoteDataSource remoteDataSource;
final SyncService syncService;
ServerRepositoryImpl({
required this.localDataSource,
required this.remoteDataSource,
required this.syncService,
});
@override
Future<Either<Failure, List<Server>>> getAllServers() async {
try {
// Read from local storage
final servers = await localDataSource.getAllServers();
return Right(servers.map((model) => model.toEntity()).toList());
} catch (e) {
return Left(ServerFailure('Failed to load servers: $e'));
}
}
@override
Future<Either<Failure, List<Server>>> getGCoreServers() async {
try {
// Read from local storage
final servers = await localDataSource.getServersByType('gcore');
return Right(servers.map((model) => model.toEntity()).toList());
} catch (e) {
return Left(ServerFailure('Failed to load G-Core servers: $e'));
}
}
@override
Future<Either<Failure, List<Server>>> getGeViScopeServers() async {
try {
// Read from local storage
final servers = await localDataSource.getServersByType('geviscope');
return Right(servers.map((model) => model.toEntity()).toList());
} catch (e) {
return Left(ServerFailure('Failed to load GeViScope servers: $e'));
}
}
@override
Future<Either<Failure, Server>> getServerById(String id, ServerType type) async {
try {
final typeStr = type == ServerType.gcore ? 'gcore' : 'geviscope';
final server = await localDataSource.getServerById(id, typeStr);
if (server == null) {
return Left(ServerFailure('Server not found'));
}
return Right(server.toEntity());
} catch (e) {
return Left(ServerFailure('Failed to load server: $e'));
}
}
@override
Future<Either<Failure, void>> createServer(Server server) async {
try {
// Save to local storage with dirty flag and 'create' operation
final hiveModel = ServerHiveModel.fromEntity(
server,
isDirty: true,
syncOperation: 'create',
);
await localDataSource.saveServer(hiveModel);
return const Right(null);
} catch (e) {
return Left(ServerFailure('Failed to create server: $e'));
}
}
@override
Future<Either<Failure, void>> updateServer(Server server) async {
try {
// Save to local storage with dirty flag and 'update' operation
final hiveModel = ServerHiveModel.fromEntity(
server,
isDirty: true,
syncOperation: 'update',
);
await localDataSource.saveServer(hiveModel);
return const Right(null);
} catch (e) {
return Left(ServerFailure('Failed to update server: $e'));
}
}
@override
Future<Either<Failure, void>> deleteServer(String id, ServerType type) async {
try {
final typeStr = type == ServerType.gcore ? 'gcore' : 'geviscope';
// Mark as deleted in local storage (soft delete with sync flag)
await localDataSource.deleteServer(id, typeStr);
return const Right(null);
} catch (e) {
return Left(ServerFailure('Failed to delete server: $e'));
}
}
@override
Future<Either<Failure, SyncResult>> syncToServer() async {
return await syncService.syncServers();
}
@override
Future<Either<Failure, int>> downloadFromServer() async {
return await syncService.downloadServers();
}
@override
Future<Either<Failure, int>> getDirtyCount() async {
try {
final dirtyServers = await localDataSource.getDirtyServers();
return Right(dirtyServers.length);
} catch (e) {
return Left(ServerFailure('Failed to get dirty count: $e'));
}
}
}

View File

@@ -0,0 +1,77 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/action_template.dart';
import '../../core/constants/api_constants.dart';
/// Service for fetching action templates and categories
/// Used by ActionPickerDialog to show available actions
class ActionTemplateService {
final String baseUrl;
final String? authToken;
ActionTemplateService({
required this.baseUrl,
this.authToken,
});
Map<String, String> get _headers => {
'Content-Type': 'application/json',
if (authToken != null) 'Authorization': 'Bearer $authToken',
};
/// Fetch all action categories
Future<ActionCategoriesResponse> getActionCategories() async {
final url = '$baseUrl/api/v1/configuration/action-categories';
final response = await http.get(
Uri.parse(url),
headers: _headers,
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
return ActionCategoriesResponse.fromJson(json);
} else {
throw Exception('Failed to load action categories: ${response.statusCode}');
}
}
/// Fetch all action templates
Future<Map<String, ActionTemplate>> getActionTemplates() async {
final url = '$baseUrl/api/v1/configuration/action-types';
final response = await http.get(
Uri.parse(url),
headers: _headers,
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
final actionTypesMap = json['action_types'] as Map<String, dynamic>;
return actionTypesMap.map((key, value) {
final templateJson = value as Map<String, dynamic>;
templateJson['action_name'] = key; // Add action name to the template
return MapEntry(key, ActionTemplate.fromJson(templateJson));
});
} else {
throw Exception('Failed to load action templates: ${response.statusCode}');
}
}
/// Fetch a specific action template by name
Future<ActionTemplate> getActionTemplate(String actionName) async {
final url = '$baseUrl/api/v1/configuration/action-types/$actionName';
final response = await http.get(
Uri.parse(url),
headers: _headers,
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
return ActionTemplate.fromJson(json);
} else if (response.statusCode == 404) {
throw Exception('Action template "$actionName" not found');
} else {
throw Exception('Failed to load action template: ${response.statusCode}');
}
}
}

View File

@@ -0,0 +1,233 @@
import 'package:dartz/dartz.dart';
import '../../core/errors/exceptions.dart';
import '../../core/errors/failures.dart';
import '../data_sources/local/server_local_data_source.dart';
import '../data_sources/remote/server_remote_data_source.dart';
import '../data_sources/local/action_mapping_local_data_source.dart';
import '../data_sources/remote/action_mapping_remote_data_source.dart';
enum SyncStatus {
idle,
syncing,
success,
error,
}
class SyncResult {
final SyncStatus status;
final String? message;
final int? syncedCount;
final List<String>? errors;
SyncResult({
required this.status,
this.message,
this.syncedCount,
this.errors,
});
factory SyncResult.idle() => SyncResult(status: SyncStatus.idle);
factory SyncResult.syncing() => SyncResult(
status: SyncStatus.syncing,
message: 'Syncing changes...',
);
factory SyncResult.success(int count) => SyncResult(
status: SyncStatus.success,
message: 'Successfully synced $count change${count != 1 ? 's' : ''}',
syncedCount: count,
);
factory SyncResult.error(List<String> errors) => SyncResult(
status: SyncStatus.error,
message: 'Sync failed: ${errors.length} error${errors.length != 1 ? 's' : ''}',
errors: errors,
);
}
abstract class SyncService {
/// Sync all dirty servers with the remote API
Future<Either<Failure, SyncResult>> syncServers();
/// Download fresh data from server and update local storage
Future<Either<Failure, int>> downloadServers();
/// Sync all dirty action mappings with the remote API
Future<Either<Failure, SyncResult>> syncActionMappings();
/// Download fresh action mappings from server and update local storage
Future<Either<Failure, int>> downloadActionMappings();
}
class SyncServiceImpl implements SyncService {
final ServerLocalDataSource localDataSource;
final ServerRemoteDataSource remoteDataSource;
final ActionMappingLocalDataSource actionMappingLocalDataSource;
final ActionMappingRemoteDataSource actionMappingRemoteDataSource;
SyncServiceImpl({
required this.localDataSource,
required this.remoteDataSource,
required this.actionMappingLocalDataSource,
required this.actionMappingRemoteDataSource,
});
@override
Future<Either<Failure, SyncResult>> syncServers() async {
try {
// Get all dirty servers
final dirtyServers = await localDataSource.getDirtyServers();
if (dirtyServers.isEmpty) {
return Right(SyncResult.success(0));
}
final errors = <String>[];
int successCount = 0;
// Process each dirty server
for (final server in dirtyServers) {
try {
final operation = server.syncOperation;
final serverModel = server.toServerModel();
if (operation == 'create') {
await remoteDataSource.createServer(serverModel);
await localDataSource.markServerAsSynced(server.id, server.serverType);
successCount++;
} else if (operation == 'update') {
await remoteDataSource.updateServer(serverModel);
await localDataSource.markServerAsSynced(server.id, server.serverType);
successCount++;
} else if (operation == 'delete') {
await remoteDataSource.deleteServer(server.id, server.serverType);
await localDataSource.markServerAsSynced(server.id, server.serverType);
successCount++;
}
} catch (e) {
errors.add('Failed to sync ${server.alias}: ${e.toString()}');
}
}
if (errors.isEmpty) {
return Right(SyncResult.success(successCount));
} else if (successCount > 0) {
// Partial success
return Right(SyncResult(
status: SyncStatus.success,
message: 'Synced $successCount/${dirtyServers.length} changes',
syncedCount: successCount,
errors: errors,
));
} else {
// Complete failure
return Right(SyncResult.error(errors));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on NetworkException catch (e) {
return Left(NetworkFailure(e.message));
} catch (e) {
return Left(ServerFailure('Unexpected error during sync: ${e.toString()}'));
}
}
@override
Future<Either<Failure, int>> downloadServers() async {
try {
// Fetch all servers from API
final servers = await remoteDataSource.getAllServers();
// Replace local storage (preserving dirty servers)
await localDataSource.replaceAllServers(servers);
return Right(servers.length);
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on NetworkException catch (e) {
return Left(NetworkFailure(e.message));
} catch (e) {
return Left(ServerFailure('Unexpected error during download: ${e.toString()}'));
}
}
@override
Future<Either<Failure, SyncResult>> syncActionMappings() async {
try {
// Get all dirty action mappings
final dirtyMappings = await actionMappingLocalDataSource.getDirtyActionMappings();
if (dirtyMappings.isEmpty) {
return Right(SyncResult.success(0));
}
final errors = <String>[];
int successCount = 0;
// Process each dirty action mapping
for (final mapping in dirtyMappings) {
try {
final operation = mapping.syncOperation;
final mappingModel = mapping.toActionMappingModel();
if (operation == 'create') {
await actionMappingRemoteDataSource.createActionMapping(mappingModel);
await actionMappingLocalDataSource.markActionMappingAsSynced(mapping.id);
successCount++;
} else if (operation == 'update') {
await actionMappingRemoteDataSource.updateActionMapping(mapping.id, mappingModel);
await actionMappingLocalDataSource.markActionMappingAsSynced(mapping.id);
successCount++;
} else if (operation == 'delete') {
await actionMappingRemoteDataSource.deleteActionMapping(mapping.id);
await actionMappingLocalDataSource.markActionMappingAsSynced(mapping.id);
successCount++;
}
} catch (e) {
errors.add('Failed to sync ${mapping.name}: ${e.toString()}');
}
}
if (errors.isEmpty) {
return Right(SyncResult.success(successCount));
} else if (successCount > 0) {
// Partial success
return Right(SyncResult(
status: SyncStatus.success,
message: 'Synced $successCount/${dirtyMappings.length} changes',
syncedCount: successCount,
errors: errors,
));
} else {
// Complete failure
return Right(SyncResult.error(errors));
}
} on ActionMappingException catch (e) {
return Left(ActionMappingFailure(e.message));
} on NetworkException catch (e) {
return Left(NetworkFailure(e.message));
} catch (e) {
return Left(ActionMappingFailure('Unexpected error during sync: ${e.toString()}'));
}
}
@override
Future<Either<Failure, int>> downloadActionMappings() async {
try {
// Fetch all action mappings from API
final mappings = await actionMappingRemoteDataSource.getAllActionMappings();
// Replace local storage with force=true to discard local changes and get fresh server data
await actionMappingLocalDataSource.replaceAllActionMappings(mappings, force: true);
return Right(mappings.length);
} on ActionMappingException catch (e) {
return Left(ActionMappingFailure(e.message));
} on NetworkException catch (e) {
return Left(NetworkFailure(e.message));
} catch (e) {
return Left(ActionMappingFailure('Unexpected error during download: ${e.toString()}'));
}
}
}

View File

@@ -0,0 +1,92 @@
import 'package:equatable/equatable.dart';
import '../../data/models/action_output.dart';
/// Domain entity representing an action mapping configuration
/// Maps input actions (events) to output actions (responses)
class ActionMapping extends Equatable {
final String id;
final String name;
final String? description;
final String inputAction;
final Map<String, dynamic> inputParameters;
final List<ActionOutput> outputActions;
final String? geviscopeInstanceScope;
final bool enabled;
final int executionCount;
final DateTime? lastExecuted;
final DateTime createdAt;
final DateTime updatedAt;
final String createdBy;
const ActionMapping({
required this.id,
required this.name,
this.description,
required this.inputAction,
this.inputParameters = const {},
required this.outputActions,
this.geviscopeInstanceScope,
this.enabled = true,
this.executionCount = 0,
this.lastExecuted,
required this.createdAt,
required this.updatedAt,
required this.createdBy,
});
/// Create a copy of this action mapping with some fields replaced
ActionMapping copyWith({
String? id,
String? name,
String? description,
String? inputAction,
Map<String, dynamic>? inputParameters,
List<ActionOutput>? outputActions,
String? geviscopeInstanceScope,
bool? enabled,
int? executionCount,
DateTime? lastExecuted,
DateTime? createdAt,
DateTime? updatedAt,
String? createdBy,
}) {
return ActionMapping(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
inputAction: inputAction ?? this.inputAction,
inputParameters: inputParameters ?? this.inputParameters,
outputActions: outputActions ?? this.outputActions,
geviscopeInstanceScope: geviscopeInstanceScope ?? this.geviscopeInstanceScope,
enabled: enabled ?? this.enabled,
executionCount: executionCount ?? this.executionCount,
lastExecuted: lastExecuted ?? this.lastExecuted,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
createdBy: createdBy ?? this.createdBy,
);
}
@override
List<Object?> get props => [
id,
name,
description,
inputAction,
inputParameters,
outputActions,
geviscopeInstanceScope,
enabled,
executionCount,
lastExecuted,
createdAt,
updatedAt,
createdBy,
];
@override
String toString() {
return 'ActionMapping(id: $id, name: $name, inputAction: $inputAction, '
'outputActions: ${outputActions.length} actions, enabled: $enabled)';
}
}

View File

@@ -0,0 +1,64 @@
import 'package:equatable/equatable.dart';
enum ServerType { gcore, geviscope }
class Server extends Equatable {
final String id;
final String alias;
final String host;
final String user;
final String password;
final bool enabled;
final bool deactivateEcho;
final bool deactivateLiveCheck;
final ServerType type;
const Server({
required this.id,
required this.alias,
required this.host,
required this.user,
required this.password,
required this.enabled,
required this.deactivateEcho,
required this.deactivateLiveCheck,
required this.type,
});
@override
List<Object?> get props => [
id,
alias,
host,
user,
password,
enabled,
deactivateEcho,
deactivateLiveCheck,
type,
];
Server copyWith({
String? id,
String? alias,
String? host,
String? user,
String? password,
bool? enabled,
bool? deactivateEcho,
bool? deactivateLiveCheck,
ServerType? type,
}) {
return Server(
id: id ?? this.id,
alias: alias ?? this.alias,
host: host ?? this.host,
user: user ?? this.user,
password: password ?? this.password,
enabled: enabled ?? this.enabled,
deactivateEcho: deactivateEcho ?? this.deactivateEcho,
deactivateLiveCheck: deactivateLiveCheck ?? this.deactivateLiveCheck,
type: type ?? this.type,
);
}
}

View File

@@ -0,0 +1,18 @@
import 'package:equatable/equatable.dart';
class User extends Equatable {
final String username;
final String role;
final String accessToken;
final String refreshToken;
const User({
required this.username,
required this.role,
required this.accessToken,
required this.refreshToken,
});
@override
List<Object?> get props => [username, role, accessToken, refreshToken];
}

View File

@@ -0,0 +1,35 @@
import 'package:dartz/dartz.dart';
import '../../core/errors/failures.dart';
import '../../data/services/sync_service.dart';
import '../entities/action_mapping.dart';
/// Repository interface for action mapping operations
/// Defines the contract for data operations with functional error handling
abstract class ActionMappingRepository {
/// Get all action mappings from local storage
Future<Either<Failure, List<ActionMapping>>> getAllActionMappings();
/// Get a specific action mapping by ID
Future<Either<Failure, ActionMapping>> getActionMappingById(String id);
/// Create a new action mapping (saved locally, marked as dirty)
Future<Either<Failure, void>> createActionMapping(ActionMapping mapping);
/// Update an existing action mapping (saved locally, marked as dirty)
Future<Either<Failure, void>> updateActionMapping(ActionMapping mapping);
/// Delete an action mapping (soft delete, marked as dirty)
Future<Either<Failure, void>> deleteActionMapping(String id);
/// Search action mappings by name or description
Future<Either<Failure, List<ActionMapping>>> searchActionMappings(String query);
/// Sync dirty (unsaved) action mappings to the server
Future<Either<Failure, SyncResult>> syncToServer();
/// Download latest action mappings from server and replace local data
Future<Either<Failure, int>> downloadFromServer();
/// Get count of dirty (unsaved) action mappings
Future<Either<Failure, int>> getDirtyCount();
}

View File

@@ -0,0 +1,10 @@
import 'package:dartz/dartz.dart';
import '../entities/user.dart';
import '../../core/errors/failures.dart';
abstract class AuthRepository {
Future<Either<Failure, User>> login(String username, String password);
Future<Either<Failure, void>> logout();
Future<Either<Failure, User>> refreshToken();
Future<Either<Failure, User?>> getCurrentUser();
}

View File

@@ -0,0 +1,22 @@
import 'package:dartz/dartz.dart';
import '../entities/server.dart';
import '../../core/errors/failures.dart';
import '../../data/services/sync_service.dart';
abstract class ServerRepository {
// Local-first operations (read from local storage)
Future<Either<Failure, List<Server>>> getAllServers();
Future<Either<Failure, List<Server>>> getGCoreServers();
Future<Either<Failure, List<Server>>> getGeViScopeServers();
Future<Either<Failure, Server>> getServerById(String id, ServerType type);
// Local-first CRUD (writes to local storage, marks as dirty)
Future<Either<Failure, void>> createServer(Server server);
Future<Either<Failure, void>> updateServer(Server server);
Future<Either<Failure, void>> deleteServer(String id, ServerType type);
// Sync operations
Future<Either<Failure, SyncResult>> syncToServer();
Future<Either<Failure, int>> downloadFromServer();
Future<Either<Failure, int>> getDirtyCount();
}

View File

@@ -0,0 +1,14 @@
import 'package:dartz/dartz.dart';
import '../../entities/user.dart';
import '../../repositories/auth_repository.dart';
import '../../../core/errors/failures.dart';
class Login {
final AuthRepository repository;
Login(this.repository);
Future<Either<Failure, User>> call(String username, String password) async {
return await repository.login(username, password);
}
}

View File

@@ -0,0 +1,14 @@
import 'package:dartz/dartz.dart';
import '../../entities/server.dart';
import '../../repositories/server_repository.dart';
import '../../../core/errors/failures.dart';
class GetServers {
final ServerRepository repository;
GetServers(this.repository);
Future<Either<Failure, List<Server>>> call() async {
return await repository.getAllServers();
}
}

View File

@@ -0,0 +1,126 @@
import 'package:get_it/get_it.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
// Core
import 'core/network/dio_client.dart';
// Data sources
import 'data/data_sources/remote/auth_remote_data_source.dart';
import 'data/data_sources/remote/server_remote_data_source.dart';
import 'data/data_sources/remote/action_mapping_remote_data_source.dart';
import 'data/data_sources/local/secure_storage_manager.dart';
import 'data/data_sources/local/server_local_data_source.dart';
import 'data/data_sources/local/action_mapping_local_data_source.dart';
// Services
import 'data/services/sync_service.dart';
// Repositories
import 'data/repositories/auth_repository_impl.dart';
import 'data/repositories/server_repository_impl.dart';
import 'data/repositories/action_mapping_repository_impl.dart';
import 'domain/repositories/auth_repository.dart';
import 'domain/repositories/server_repository.dart';
import 'domain/repositories/action_mapping_repository.dart';
// Use cases
import 'domain/use_cases/auth/login.dart';
import 'domain/use_cases/servers/get_servers.dart';
// BLoCs
import 'presentation/blocs/auth/auth_bloc.dart';
import 'presentation/blocs/server/server_bloc.dart';
import 'presentation/blocs/action_mapping/action_mapping_bloc.dart';
final sl = GetIt.instance;
Future<void> init() async {
// BLoCs
sl.registerFactory(
() => AuthBloc(
loginUseCase: sl(),
authRepository: sl(),
),
);
sl.registerFactory(
() => ServerBloc(
serverRepository: sl(),
),
);
sl.registerFactory(
() => ActionMappingBloc(
actionMappingRepository: sl(),
),
);
// Use cases
sl.registerLazySingleton(() => Login(sl()));
sl.registerLazySingleton(() => GetServers(sl()));
// Repositories
sl.registerLazySingleton<AuthRepository>(
() => AuthRepositoryImpl(
remoteDataSource: sl(),
storageManager: sl(),
),
);
sl.registerLazySingleton<ServerRepository>(
() => ServerRepositoryImpl(
localDataSource: sl(),
remoteDataSource: sl(),
syncService: sl(),
),
);
sl.registerLazySingleton<ActionMappingRepository>(
() => ActionMappingRepositoryImpl(
localDataSource: sl(),
remoteDataSource: sl(),
syncService: sl(),
),
);
// Services
sl.registerLazySingleton<SyncService>(
() => SyncServiceImpl(
localDataSource: sl(),
remoteDataSource: sl(),
actionMappingLocalDataSource: sl(),
actionMappingRemoteDataSource: sl(),
),
);
// Data sources
sl.registerLazySingleton<AuthRemoteDataSource>(
() => AuthRemoteDataSourceImpl(dio: sl<DioClient>().dio),
);
sl.registerLazySingleton<ServerRemoteDataSource>(
() => ServerRemoteDataSourceImpl(dio: sl<DioClient>().dio),
);
sl.registerLazySingleton<ServerLocalDataSource>(
() => ServerLocalDataSourceImpl(),
);
sl.registerLazySingleton<ActionMappingRemoteDataSource>(
() => ActionMappingRemoteDataSourceImpl(dio: sl<DioClient>().dio),
);
sl.registerLazySingleton<ActionMappingLocalDataSource>(
() => ActionMappingLocalDataSourceImpl(),
);
sl.registerLazySingleton(
() => SecureStorageManager(storage: sl()),
);
// Core
sl.registerLazySingleton(() => DioClient(sl()));
// External
sl.registerLazySingleton(() => const FlutterSecureStorage());
}

View File

@@ -0,0 +1,197 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'injection.dart' as di;
import 'presentation/blocs/auth/auth_bloc.dart';
import 'presentation/blocs/auth/auth_event.dart';
import 'presentation/blocs/auth/auth_state.dart';
import 'presentation/blocs/server/server_bloc.dart';
import 'presentation/blocs/server/server_event.dart';
import 'presentation/blocs/action_mapping/action_mapping_bloc.dart';
import 'presentation/blocs/action_mapping/action_mapping_event.dart';
import 'presentation/screens/auth/login_screen.dart';
import 'presentation/screens/servers/server_list_screen.dart';
import 'presentation/screens/servers/servers_management_screen.dart';
import 'presentation/screens/servers/server_form_screen.dart';
import 'presentation/screens/action_mappings/action_mappings_list_screen.dart';
import 'presentation/screens/action_mappings/action_mapping_form_screen.dart';
import 'domain/entities/server.dart';
import 'domain/entities/action_mapping.dart';
import 'data/models/server_hive_model.dart';
import 'data/models/action_mapping_hive_model.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Hive for local storage
await Hive.initFlutter();
// Register Hive adapters
Hive.registerAdapter(ServerHiveModelAdapter());
Hive.registerAdapter(ActionMappingHiveModelAdapter());
// Initialize dependency injection
await di.init();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => di.sl<AuthBloc>()..add(const CheckAuthStatus()),
child: MaterialApp.router(
title: 'GeViAPI - Video Management System',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
useMaterial3: true,
appBarTheme: const AppBarTheme(
centerTitle: false,
elevation: 2,
),
cardTheme: CardThemeData(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
routerConfig: _router,
),
);
}
}
final _router = GoRouter(
routes: [
GoRoute(
path: '/login',
builder: (context, state) => BlocProvider.value(
value: context.read<AuthBloc>(),
child: const LoginScreen(),
),
),
GoRoute(
path: '/',
builder: (context, state) => BlocProvider.value(
value: context.read<AuthBloc>(),
child: const ServerListScreen(),
),
),
ShellRoute(
builder: (context, state, child) => BlocProvider(
create: (_) => di.sl<ServerBloc>()..add(const LoadServers()),
child: child,
),
routes: [
GoRoute(
path: '/servers',
builder: (context, state) => const ServersManagementScreen(),
),
GoRoute(
path: '/servers/create',
builder: (context, state) {
final serverType = state.uri.queryParameters['type'] == 'geviscope'
? ServerType.geviscope
: ServerType.gcore;
return ServerFormScreen(serverType: serverType);
},
),
GoRoute(
path: '/servers/edit/:id',
builder: (context, state) {
final server = state.extra as Server;
return ServerFormScreen(server: server, serverType: server.type);
},
),
],
),
ShellRoute(
builder: (context, state, child) => BlocProvider(
create: (_) => di.sl<ActionMappingBloc>()..add(const LoadActionMappings()),
child: child,
),
routes: [
GoRoute(
path: '/action-mappings',
builder: (context, state) => const ActionMappingsListScreen(),
),
GoRoute(
path: '/action-mappings/create',
builder: (context, state) => const ActionMappingFormScreen(),
),
GoRoute(
path: '/action-mappings/edit/:id',
builder: (context, state) {
final mapping = state.extra as ActionMapping;
return ActionMappingFormScreen(mapping: mapping);
},
),
],
),
],
redirect: (context, state) {
final authBloc = context.read<AuthBloc>();
final authState = authBloc.state;
final isLoginRoute = state.matchedLocation == '/login';
if (authState is Authenticated) {
// If authenticated and trying to access login, redirect to home
if (isLoginRoute) {
return '/';
}
} else {
// If not authenticated and not on login page, redirect to login
if (!isLoginRoute) {
return '/login';
}
}
// No redirect needed
return null;
},
refreshListenable: GoRouterRefreshStream(
di.sl<AuthBloc>().stream,
),
);
class GoRouterRefreshStream extends ChangeNotifier {
GoRouterRefreshStream(Stream<dynamic> stream) {
notifyListeners();
_subscription = stream.asBroadcastStream().listen(
(dynamic _) => notifyListeners(),
);
}
late final StreamSubscription<dynamic> _subscription;
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
}

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