Initial commit: COPILOT D6 Flutter keyboard controller

Flutter web app replacing legacy WPF CCTV surveillance keyboard controller.
Includes wall overview, section view with monitor grid, camera input,
PTZ control, alarm/lock/sequence BLoCs, and legacy-matching UI styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
klas
2026-02-12 14:57:38 +01:00
commit 40143734fc
125 changed files with 65073 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'injection_container.dart';
import 'presentation/blocs/wall/wall_bloc.dart';
import 'presentation/blocs/wall/wall_event.dart';
import 'presentation/screens/main_screen.dart';
class CopilotKeyboardApp extends StatelessWidget {
const CopilotKeyboardApp({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider<WallBloc>(
create: (_) => sl<WallBloc>()..add(const LoadWallConfig()),
child: MaterialApp(
title: 'COPILOT Keyboard',
theme: _buildDarkTheme(),
darkTheme: _buildDarkTheme(),
themeMode: ThemeMode.dark,
debugShowCheckedModeBanner: false,
home: const MainScreen(),
),
);
}
ThemeData _buildDarkTheme() {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF00D4FF),
brightness: Brightness.dark,
),
scaffoldBackgroundColor: const Color(0xFF0A0E14),
cardTheme: CardTheme(
elevation: 0,
color: const Color(0xFF151A22),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
minimumSize: const Size(36, 36),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
),
);
}
}

View File

@@ -0,0 +1,179 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:logger/logger.dart';
import '../domain/entities/function_button_config.dart';
import '../domain/entities/server_config.dart';
/// Application configuration loaded from servers.json
class AppConfig {
final List<ServerConfig> servers;
final String coordinatorUrl;
final String keyboardId;
final FunctionButtonConfig functionButtons;
final int alarmSyncIntervalSeconds;
final int connectionRetrySeconds;
final int commandTimeoutSeconds;
const AppConfig({
required this.servers,
this.coordinatorUrl = 'http://localhost:8090',
this.keyboardId = 'keyboard-1',
this.functionButtons = const FunctionButtonConfig(),
this.alarmSyncIntervalSeconds = 30,
this.connectionRetrySeconds = 5,
this.commandTimeoutSeconds = 10,
});
/// Load configuration from file or assets
static Future<AppConfig> load({String? configPath}) async {
final logger = Logger();
Map<String, dynamic> configJson;
// Try loading from file path first
if (configPath != null) {
try {
final file = File(configPath);
if (await file.exists()) {
final contents = await file.readAsString();
configJson = jsonDecode(contents) as Map<String, dynamic>;
logger.i('Loaded config from: $configPath');
return _parseConfig(configJson);
}
} catch (e) {
logger.w('Failed to load config from file: $e');
}
}
// Try common file locations
final commonPaths = [
'servers.json',
'../servers.json',
'config/servers.json',
r'C:\DEV\COPILOT_D6\servers.json',
];
for (final path in commonPaths) {
try {
final file = File(path);
if (await file.exists()) {
final contents = await file.readAsString();
configJson = jsonDecode(contents) as Map<String, dynamic>;
logger.i('Loaded config from: $path');
return _parseConfig(configJson);
}
} catch (e) {
// Continue to next path
}
}
// Try loading from assets
try {
final contents = await rootBundle.loadString('assets/config/servers.json');
configJson = jsonDecode(contents) as Map<String, dynamic>;
logger.i('Loaded config from assets');
return _parseConfig(configJson);
} catch (e) {
logger.w('Failed to load config from assets: $e');
}
// Return default empty config
logger.w('No config file found, using defaults');
return const AppConfig(servers: []);
}
static AppConfig _parseConfig(Map<String, dynamic> json) {
final serversJson = json['servers'] as List<dynamic>? ?? [];
final servers = serversJson
.map((s) => ServerConfig.fromJson(s as Map<String, dynamic>))
.where((s) => s.enabled)
.toList();
final settings = json['settings'] as Map<String, dynamic>? ?? {};
// Parse function button config
final fbJson = json['functionButtons'] as Map<String, dynamic>?;
final functionButtons = fbJson != null
? FunctionButtonConfig.fromJson(fbJson)
: const FunctionButtonConfig();
return AppConfig(
servers: servers,
coordinatorUrl: settings['coordinatorUrl'] as String? ?? 'http://localhost:8090',
keyboardId: settings['keyboardId'] as String? ?? 'keyboard-1',
functionButtons: functionButtons,
alarmSyncIntervalSeconds: settings['alarmSyncIntervalSeconds'] as int? ?? 30,
connectionRetrySeconds: settings['connectionRetrySeconds'] as int? ?? 5,
commandTimeoutSeconds: settings['commandTimeoutSeconds'] as int? ?? 10,
);
}
/// Get servers by type
List<ServerConfig> getServersByType(ServerType type) {
return servers.where((s) => s.type == type).toList();
}
/// Get server that owns a camera ID
ServerConfig? getServerForCamera(int cameraId) {
for (final server in servers) {
if (server.ownsCamera(cameraId)) {
return server;
}
}
return null;
}
/// Get server that owns a monitor ID
ServerConfig? getServerForMonitor(int monitorId) {
for (final server in servers) {
if (server.ownsMonitor(monitorId)) {
return server;
}
}
return null;
}
/// Get total camera count across all servers
int get totalCameras {
int count = 0;
for (final server in servers) {
count += (server.cameraRangeEnd - server.cameraRangeStart + 1);
}
return count;
}
/// Get total monitor count across all servers
int get totalMonitors {
int count = 0;
for (final server in servers) {
count += (server.monitorRangeEnd - server.monitorRangeStart + 1);
}
return count;
}
/// Get all camera IDs
List<int> get allCameraIds {
final ids = <int>[];
for (final server in servers) {
for (int i = server.cameraRangeStart; i <= server.cameraRangeEnd; i++) {
ids.add(i);
}
}
ids.sort();
return ids;
}
/// Get all monitor IDs
List<int> get allMonitorIds {
final ids = <int>[];
for (final server in servers) {
for (int i = server.monitorRangeStart; i <= server.monitorRangeEnd; i++) {
ids.add(i);
}
}
ids.sort();
return ids;
}
}

View File

@@ -0,0 +1,64 @@
/// Event received from bridge WebSocket
class BridgeEvent {
final String serverId;
final DateTime timestamp;
final String action;
final Map<String, dynamic> params;
BridgeEvent({
required this.serverId,
required this.timestamp,
required this.action,
required this.params,
});
factory BridgeEvent.fromJson(Map<String, dynamic> json, String serverId) {
return BridgeEvent(
serverId: serverId,
timestamp: json['timestamp'] != null
? DateTime.parse(json['timestamp'] as String)
: DateTime.now(),
action: json['action'] as String? ?? '',
params: json['params'] as Map<String, dynamic>? ?? {},
);
}
// Specific event type checks
bool get isViewerConnected => action == 'ViewerConnected';
bool get isViewerCleared => action == 'ViewerCleared';
bool get isViewerSelectionChanged => action == 'ViewerSelectionChanged';
bool get isEventStarted => action == 'EventStarted';
bool get isEventStopped => action == 'EventStopped';
bool get isDigitalInput => action == 'DigitalInput';
bool get isAlarmQueueNotification => action == 'VCAlarmQueueNotification';
bool get isConnectionLost => action == 'ConnectionLost';
// Viewer events
int? get viewer => params['Viewer'] as int?;
int? get channel => params['Channel'] as int?;
int? get playMode => params['PlayMode'] as int?;
// Alarm events
int? get eventId => params['EventID'] as int?;
String? get eventName => params['EventName'] as String?;
int? get typeId => params['TypeID'] as int?;
int? get foreignKey {
final fk = params['ForeignKey'];
if (fk is int) return fk;
if (fk is String) return int.tryParse(fk);
return null;
}
// Digital input
int? get contact => params['Contact'] as int?;
int? get state => params['State'] as int?;
// Alarm queue notification
int? get notification => params['Notification'] as int?;
int? get alarmId => params['AlarmID'] as int?;
@override
String toString() {
return 'BridgeEvent($action from $serverId at $timestamp)';
}
}

View File

@@ -0,0 +1,265 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:logger/logger.dart';
import 'package:rxdart/rxdart.dart';
import '../../domain/entities/alarm_state.dart';
import '../../domain/entities/server_config.dart';
import '../models/bridge_event.dart';
/// Service for querying and tracking alarm state from GeViServer
class AlarmService {
final Logger _logger = Logger();
final Map<String, Dio> _httpClients = {};
final Map<String, ServerConfig> _servers = {};
// Alarm state
final _alarmsController = BehaviorSubject<List<AlarmState>>.seeded([]);
Timer? _syncTimer;
/// Stream of active alarms
Stream<List<AlarmState>> get alarms => _alarmsController.stream;
/// Current active alarms
List<AlarmState> get currentAlarms => _alarmsController.value;
/// Initialize with GeViServer configurations
Future<void> initialize(List<ServerConfig> servers) async {
// Only use GeViServer type servers for alarm queries
final geviServers =
servers.where((s) => s.type == ServerType.geviserver && s.enabled);
for (final server in geviServers) {
_servers[server.id] = server;
_httpClients[server.id] = Dio(BaseOptions(
baseUrl: server.bridgeUrl,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 10),
));
}
_logger.i('AlarmService initialized with ${_servers.length} GeViServers');
}
/// Query all active alarms from all GeViServers
Future<List<AlarmState>> queryAllAlarms() async {
final allAlarms = <AlarmState>[];
for (final entry in _servers.entries) {
final serverId = entry.key;
try {
final alarms = await _queryAlarmsFromServer(serverId);
allAlarms.addAll(alarms);
_logger.i('Queried ${alarms.length} alarms from $serverId');
} catch (e) {
_logger.e('Failed to query alarms from $serverId: $e');
}
}
_alarmsController.add(allAlarms);
return allAlarms;
}
/// Query alarms from a specific server using GetFirstAlarm/GetNextAlarm pattern
Future<List<AlarmState>> _queryAlarmsFromServer(String serverId) async {
final client = _httpClients[serverId];
if (client == null) return [];
final alarms = <AlarmState>[];
try {
// Try the bulk endpoint first (preferred)
final response = await client.get('/alarms/active');
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
final alarmList = data['alarms'] as List<dynamic>? ?? [];
for (final alarmJson in alarmList) {
final alarm = _parseAlarmFromJson(
alarmJson as Map<String, dynamic>,
serverId,
);
if (alarm != null) {
alarms.add(alarm);
}
}
return alarms;
}
} catch (e) {
_logger.d('Bulk alarm query failed, trying iteration: $e');
}
// Fallback to GetFirstAlarm/GetNextAlarm iteration
try {
// Get first alarm
var response = await client.get('/alarms/first');
if (response.statusCode != 200) return alarms;
var data = response.data as Map<String, dynamic>;
if (data['alarm'] == null) return alarms; // No alarms
var alarm = _parseAlarmFromJson(
data['alarm'] as Map<String, dynamic>,
serverId,
);
if (alarm != null) alarms.add(alarm);
// Iterate through remaining alarms
while (true) {
response = await client.get('/alarms/next');
if (response.statusCode != 200) break;
data = response.data as Map<String, dynamic>;
if (data['alarm'] == null) break; // No more alarms
alarm = _parseAlarmFromJson(
data['alarm'] as Map<String, dynamic>,
serverId,
);
if (alarm != null) alarms.add(alarm);
// Safety limit
if (alarms.length > 1000) {
_logger.w('Alarm query hit safety limit of 1000');
break;
}
}
} catch (e) {
_logger.e('Alarm iteration failed: $e');
}
return alarms;
}
/// Parse alarm from JSON response
AlarmState? _parseAlarmFromJson(Map<String, dynamic> json, String serverId) {
try {
return AlarmState(
eventId: json['EventID'] as int? ?? json['event_id'] as int? ?? 0,
eventName:
json['EventName'] as String? ?? json['event_name'] as String? ?? '',
typeId: json['TypeID'] as int? ?? json['type_id'] as int? ?? 0,
foreignKey: _parseForeignKey(json['ForeignKey'] ?? json['foreign_key']),
serverId: serverId,
startedAt: _parseDateTime(json['StartedAt'] ?? json['started_at']),
stoppedAt: _parseNullableDateTime(json['StoppedAt'] ?? json['stopped_at']),
isActive: json['IsActive'] as bool? ?? json['is_active'] as bool? ?? true,
status: AlarmStatus.fromValue(
json['Status'] as int? ?? json['status'] as int? ?? 0,
),
associatedMonitor: json['AssociatedMonitor'] as int? ??
json['associated_monitor'] as int?,
);
} catch (e) {
_logger.e('Failed to parse alarm: $e');
return null;
}
}
int _parseForeignKey(dynamic value) {
if (value is int) return value;
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}
DateTime _parseDateTime(dynamic value) {
if (value is DateTime) return value;
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
return DateTime.now();
}
DateTime? _parseNullableDateTime(dynamic value) {
if (value == null) return null;
if (value is DateTime) return value;
if (value is String) return DateTime.tryParse(value);
return null;
}
/// Start periodic alarm sync
void startPeriodicSync(Duration interval) {
stopPeriodicSync();
_syncTimer = Timer.periodic(interval, (_) => queryAllAlarms());
_logger.i('Started periodic alarm sync every ${interval.inSeconds}s');
}
/// Stop periodic alarm sync
void stopPeriodicSync() {
_syncTimer?.cancel();
_syncTimer = null;
}
/// Handle alarm event from WebSocket
void handleAlarmEvent(BridgeEvent event) {
final currentAlarms = List<AlarmState>.from(_alarmsController.value);
if (event.isEventStarted) {
// New alarm started
final newAlarm = AlarmState(
eventId: event.eventId ?? 0,
eventName: event.eventName ?? '',
typeId: event.typeId ?? 0,
foreignKey: event.foreignKey ?? 0,
serverId: event.serverId,
startedAt: event.timestamp,
isActive: true,
status: AlarmStatus.newAlarm,
);
// Remove existing alarm with same ID if any
currentAlarms.removeWhere((a) =>
a.eventId == newAlarm.eventId && a.serverId == newAlarm.serverId);
currentAlarms.add(newAlarm);
_logger.d('Alarm started: ${newAlarm.eventName} (ID: ${newAlarm.eventId})');
} else if (event.isEventStopped) {
// Alarm stopped
final index = currentAlarms.indexWhere((a) =>
a.eventId == event.eventId && a.serverId == event.serverId);
if (index >= 0) {
currentAlarms[index] = currentAlarms[index].stopped();
_logger.d('Alarm stopped: ${currentAlarms[index].eventName}');
}
} else if (event.isAlarmQueueNotification) {
// Alarm status changed (presented, confirmed, etc.)
final alarmId = event.alarmId;
final notification = event.notification;
if (alarmId != null && notification != null) {
final index = currentAlarms.indexWhere((a) =>
a.eventId == alarmId && a.serverId == event.serverId);
if (index >= 0) {
final newStatus = AlarmStatus.fromValue(notification);
currentAlarms[index] = currentAlarms[index].withStatus(newStatus);
_logger.d(
'Alarm status changed: ${currentAlarms[index].eventName} -> ${newStatus.name}');
}
}
}
_alarmsController.add(currentAlarms);
}
/// Get alarms blocking a specific monitor
List<AlarmState> getAlarmsForMonitor(int monitorId) {
return _alarmsController.value
.where((a) => a.associatedMonitor == monitorId && a.blocksMonitor)
.toList();
}
/// Check if any alarm blocks a monitor
bool isMonitorBlocked(int monitorId) {
return _alarmsController.value
.any((a) => a.associatedMonitor == monitorId && a.blocksMonitor);
}
/// Dispose resources
void dispose() {
stopPeriodicSync();
_alarmsController.close();
_httpClients.clear();
_servers.clear();
}
}

View File

@@ -0,0 +1,487 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:logger/logger.dart';
import 'package:rxdart/rxdart.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../../domain/entities/server_config.dart';
import '../models/bridge_event.dart';
/// Service for communicating with all bridges.
/// Includes auto-reconnection with exponential backoff and health polling.
class BridgeService {
final Logger _logger = Logger();
final Map<String, Dio> _httpClients = {};
final Map<String, WebSocketChannel> _wsChannels = {};
final Map<String, StreamSubscription> _wsSubscriptions = {};
final Map<String, ServerConfig> _servers = {};
final Map<String, int> _reconnectAttempts = {};
final Map<String, Timer> _reconnectTimers = {};
Timer? _healthCheckTimer;
bool _disposed = false;
// Reconnection config
static const _initialReconnectDelay = Duration(seconds: 1);
static const _maxReconnectDelay = Duration(seconds: 30);
static const _healthCheckInterval = Duration(seconds: 10);
static const _commandRetryCount = 3;
static const _commandRetryDelay = Duration(milliseconds: 200);
// Event streams
final _eventController = BehaviorSubject<BridgeEvent>();
final _connectionStatusController =
BehaviorSubject<Map<String, bool>>.seeded({});
// Callback for state resync after reconnection
void Function(String serverId)? onReconnected;
/// Stream of all events from all bridges
Stream<BridgeEvent> get eventStream => _eventController.stream;
/// Stream of connection status per server
Stream<Map<String, bool>> get connectionStatus =>
_connectionStatusController.stream;
/// Get current connection status
Map<String, bool> get currentConnectionStatus =>
_connectionStatusController.value;
/// Initialize the service with server configurations
Future<void> initialize(List<ServerConfig> servers) async {
_logger.i('Initializing BridgeService with ${servers.length} servers');
for (final server in servers) {
if (!server.enabled) {
_logger.d('Skipping disabled server: ${server.id}');
continue;
}
_servers[server.id] = server;
// Create HTTP client
_httpClients[server.id] = Dio(BaseOptions(
baseUrl: server.bridgeUrl,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 10),
));
_updateConnectionStatus(server.id, false);
}
}
/// Connect to a specific server's bridge
Future<bool> connect(String serverId) async {
final server = _servers[serverId];
if (server == null) {
_logger.w('Unknown server: $serverId');
return false;
}
try {
// Check bridge health
final response =
await _httpClients[serverId]!.get('/health');
if (response.statusCode == 200) {
_updateConnectionStatus(serverId, true);
_logger.i('Connected to bridge: $serverId');
// Connect WebSocket for events if available
if (server.websocketUrl != null) {
await _connectWebSocket(serverId, server.websocketUrl!);
}
return true;
}
} catch (e) {
_logger.e('Failed to connect to $serverId: $e');
}
_updateConnectionStatus(serverId, false);
return false;
}
/// Connect to all configured servers and start health monitoring
Future<void> connectAll() async {
for (final serverId in _servers.keys) {
await connect(serverId);
}
_startHealthChecks();
}
/// Disconnect from a specific server
Future<void> disconnect(String serverId) async {
await _disconnectWebSocket(serverId);
_updateConnectionStatus(serverId, false);
_logger.i('Disconnected from bridge: $serverId');
}
/// Disconnect from all servers
Future<void> disconnectAll() async {
for (final serverId in _servers.keys) {
await disconnect(serverId);
}
}
// ============================================================
// Command Methods
// ============================================================
/// Switch camera to monitor (ViewerConnectLive).
/// Critical command — retries up to 3 times with backoff.
Future<bool> viewerConnectLive(int viewer, int channel) async {
final serverId = _findServerForMonitor(viewer);
if (serverId == null) {
_logger.w('No server found for monitor $viewer');
return false;
}
return _retryCommand('viewerConnectLive', () async {
final response = await _httpClients[serverId]!.post(
'/viewer/connect-live',
data: {'Viewer': viewer, 'Channel': channel},
);
return response.statusCode == 200;
});
}
/// Clear monitor (ViewerClear)
Future<bool> viewerClear(int viewer) async {
final serverId = _findServerForMonitor(viewer);
if (serverId == null) return false;
try {
final response = await _httpClients[serverId]!.post(
'/viewer/clear',
data: {'Viewer': viewer},
);
return response.statusCode == 200;
} catch (e) {
_logger.e('viewerClear failed: $e');
return false;
}
}
/// PTZ Pan control
Future<bool> ptzPan(int camera, String direction, int speed) async {
final serverId = _findServerForCamera(camera);
if (serverId == null) return false;
try {
final response = await _httpClients[serverId]!.post(
'/camera/pan',
data: {'Camera': camera, 'Direction': direction, 'Speed': speed},
);
return response.statusCode == 200;
} catch (e) {
_logger.e('ptzPan failed: $e');
return false;
}
}
/// PTZ Tilt control
Future<bool> ptzTilt(int camera, String direction, int speed) async {
final serverId = _findServerForCamera(camera);
if (serverId == null) return false;
try {
final response = await _httpClients[serverId]!.post(
'/camera/tilt',
data: {'Camera': camera, 'Direction': direction, 'Speed': speed},
);
return response.statusCode == 200;
} catch (e) {
_logger.e('ptzTilt failed: $e');
return false;
}
}
/// PTZ Zoom control
Future<bool> ptzZoom(int camera, String direction, int speed) async {
final serverId = _findServerForCamera(camera);
if (serverId == null) return false;
try {
final response = await _httpClients[serverId]!.post(
'/camera/zoom',
data: {'Camera': camera, 'Direction': direction, 'Speed': speed},
);
return response.statusCode == 200;
} catch (e) {
_logger.e('ptzZoom failed: $e');
return false;
}
}
/// PTZ Stop all movement
Future<bool> ptzStop(int camera) async {
final serverId = _findServerForCamera(camera);
if (serverId == null) return false;
try {
final response = await _httpClients[serverId]!.post(
'/camera/stop',
data: {'Camera': camera},
);
return response.statusCode == 200;
} catch (e) {
_logger.e('ptzStop failed: $e');
return false;
}
}
/// PTZ Go to preset
Future<bool> ptzPreset(int camera, int preset) async {
final serverId = _findServerForCamera(camera);
if (serverId == null) return false;
try {
final response = await _httpClients[serverId]!.post(
'/camera/preset',
data: {'Camera': camera, 'Preset': preset},
);
return response.statusCode == 200;
} catch (e) {
_logger.e('ptzPreset failed: $e');
return false;
}
}
// ============================================================
// Query Methods
// ============================================================
/// Get current monitor states from a bridge
Future<List<Map<String, dynamic>>> getMonitorStates(String serverId) async {
try {
final response = await _httpClients[serverId]!.get('/monitors');
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
final monitors = data['monitors'] as List<dynamic>? ?? [];
return monitors.cast<Map<String, dynamic>>();
}
} catch (e) {
_logger.e('getMonitorStates failed for $serverId: $e');
}
return [];
}
/// Get active alarms from a bridge
Future<List<Map<String, dynamic>>> getActiveAlarms(String serverId) async {
try {
final response = await _httpClients[serverId]!.get('/alarms/active');
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
final alarms = data['alarms'] as List<dynamic>? ?? [];
return alarms.cast<Map<String, dynamic>>();
}
} catch (e) {
_logger.e('getActiveAlarms failed for $serverId: $e');
}
return [];
}
/// Get bridge status
Future<Map<String, dynamic>?> getBridgeStatus(String serverId) async {
try {
final response = await _httpClients[serverId]!.get('/status');
if (response.statusCode == 200) {
return response.data as Map<String, dynamic>;
}
} catch (e) {
_logger.e('getBridgeStatus failed for $serverId: $e');
}
return null;
}
// ============================================================
// Private Methods
// ============================================================
String? _findServerForCamera(int cameraId) {
for (final entry in _servers.entries) {
if (entry.value.ownsCamera(cameraId)) {
return entry.key;
}
}
return null;
}
String? _findServerForMonitor(int monitorId) {
for (final entry in _servers.entries) {
if (entry.value.ownsMonitor(monitorId)) {
return entry.key;
}
}
return null;
}
void _updateConnectionStatus(String serverId, bool connected) {
final current = Map<String, bool>.from(_connectionStatusController.value);
current[serverId] = connected;
_connectionStatusController.add(current);
}
Future<void> _connectWebSocket(String serverId, String url) async {
try {
await _disconnectWebSocket(serverId);
_logger.d('Connecting WebSocket to $url');
final channel = WebSocketChannel.connect(Uri.parse(url));
_wsChannels[serverId] = channel;
_reconnectAttempts[serverId] = 0; // Reset on successful connection
_wsSubscriptions[serverId] = channel.stream.listen(
(message) {
try {
final json = jsonDecode(message as String) as Map<String, dynamic>;
if (json['type'] == 'connected') {
_logger.d('WebSocket connected to $serverId');
return;
}
final event = BridgeEvent.fromJson(json, serverId);
_eventController.add(event);
} catch (e) {
_logger.e('Failed to parse WebSocket message: $e');
}
},
onError: (error) {
_logger.e('WebSocket error for $serverId: $error');
_updateConnectionStatus(serverId, false);
_scheduleReconnect(serverId);
},
onDone: () {
_logger.w('WebSocket closed for $serverId');
_updateConnectionStatus(serverId, false);
_scheduleReconnect(serverId);
},
);
} catch (e) {
_logger.e('Failed to connect WebSocket to $serverId: $e');
_scheduleReconnect(serverId);
}
}
Future<void> _disconnectWebSocket(String serverId) async {
await _wsSubscriptions[serverId]?.cancel();
_wsSubscriptions.remove(serverId);
await _wsChannels[serverId]?.sink.close();
_wsChannels.remove(serverId);
}
/// Retry a critical command with exponential backoff.
/// Used for CrossSwitch and other critical operations.
Future<bool> _retryCommand(String name, Future<bool> Function() command) async {
for (int attempt = 1; attempt <= _commandRetryCount; attempt++) {
try {
final result = await command();
if (result) return true;
} catch (e) {
if (attempt == _commandRetryCount) {
_logger.e('$name failed after $attempt attempts: $e');
return false;
}
_logger.w('$name attempt $attempt failed, retrying: $e');
await Future.delayed(_commandRetryDelay * attempt);
}
}
return false;
}
/// Schedule WebSocket reconnection with exponential backoff.
void _scheduleReconnect(String serverId) {
if (_disposed) return;
_reconnectTimers[serverId]?.cancel();
final attempts = _reconnectAttempts[serverId] ?? 0;
final delay = Duration(
milliseconds: (_initialReconnectDelay.inMilliseconds *
(1 << attempts.clamp(0, 5))) // 1s, 2s, 4s, 8s, 16s, 32s
.clamp(0, _maxReconnectDelay.inMilliseconds),
);
_logger.i('Scheduling reconnect for $serverId in ${delay.inSeconds}s (attempt ${attempts + 1})');
_reconnectTimers[serverId] = Timer(delay, () async {
if (_disposed) return;
_reconnectAttempts[serverId] = attempts + 1;
final server = _servers[serverId];
if (server == null) return;
// Check if bridge is healthy before reconnecting WebSocket
try {
final response = await _httpClients[serverId]!.get('/health');
if (response.statusCode == 200) {
_updateConnectionStatus(serverId, true);
if (server.websocketUrl != null) {
await _connectWebSocket(serverId, server.websocketUrl!);
}
_logger.i('Reconnected to $serverId');
onReconnected?.call(serverId);
}
} catch (e) {
_logger.d('Reconnect health check failed for $serverId: $e');
_scheduleReconnect(serverId); // Try again
}
});
}
/// Start periodic health checks for all bridges.
/// Detects when a bridge comes back online after failure.
void _startHealthChecks() {
_healthCheckTimer?.cancel();
_healthCheckTimer = Timer.periodic(_healthCheckInterval, (_) async {
if (_disposed) return;
for (final serverId in _servers.keys) {
final isConnected = currentConnectionStatus[serverId] ?? false;
if (!isConnected) {
// Bridge is down — try to reconnect
try {
final response = await _httpClients[serverId]!.get('/health');
if (response.statusCode == 200) {
_logger.i('Bridge $serverId is back online');
_updateConnectionStatus(serverId, true);
_reconnectAttempts[serverId] = 0;
final server = _servers[serverId];
if (server?.websocketUrl != null) {
await _connectWebSocket(serverId, server!.websocketUrl!);
}
onReconnected?.call(serverId);
}
} catch (_) {
// Still down, will check again next cycle
}
}
}
});
}
/// Dispose of all resources
void dispose() {
_disposed = true;
_healthCheckTimer?.cancel();
for (final timer in _reconnectTimers.values) {
timer.cancel();
}
_reconnectTimers.clear();
_eventController.close();
_connectionStatusController.close();
for (final sub in _wsSubscriptions.values) {
sub.cancel();
}
_wsSubscriptions.clear();
for (final channel in _wsChannels.values) {
channel.sink.close();
}
_wsChannels.clear();
_httpClients.clear();
_servers.clear();
}
}

View File

@@ -0,0 +1,464 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:logger/logger.dart';
import 'package:rxdart/rxdart.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../../domain/entities/camera_lock.dart';
/// Client for the COPILOT Coordinator service (:8090).
/// Handles camera locks, sequences, and keyboard coordination.
class CoordinationService {
final Logger _logger = Logger();
late final Dio _http;
WebSocketChannel? _ws;
StreamSubscription? _wsSubscription;
Timer? _reconnectTimer;
Timer? _lockResetTimer;
bool _disposed = false;
String _coordinatorUrl = '';
String _keyboardId = '';
// Reconnection config
static const _initialReconnectDelay = Duration(seconds: 1);
static const _maxReconnectDelay = Duration(seconds: 30);
int _reconnectAttempts = 0;
// Connection status
final _connectedController = BehaviorSubject<bool>.seeded(false);
// Lock state
final _locksController = BehaviorSubject<Map<int, CameraLock>>.seeded({});
final _notificationController =
BehaviorSubject<CameraLockNotification?>.seeded(null);
/// Whether connected to the coordinator
Stream<bool> get connected => _connectedController.stream;
bool get isConnected => _connectedController.value;
/// Current camera locks (all keyboards)
Stream<Map<int, CameraLock>> get locks => _locksController.stream;
Map<int, CameraLock> get currentLocks => _locksController.value;
/// Lock notifications targeted at this keyboard
Stream<CameraLockNotification?> get notifications =>
_notificationController.stream;
/// Initialize with coordinator URL and keyboard identity
Future<void> initialize(String coordinatorUrl, String keyboardId) async {
_coordinatorUrl = coordinatorUrl;
_keyboardId = keyboardId;
_http = Dio(BaseOptions(
baseUrl: coordinatorUrl,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 10),
));
_logger.i(
'CoordinationService initialized: $coordinatorUrl (keyboard: $keyboardId)');
}
/// Connect to the coordinator (HTTP health check + WebSocket)
Future<bool> connect() async {
try {
final response = await _http.get('/health');
if (response.statusCode == 200) {
_connectedController.add(true);
_reconnectAttempts = 0;
await _connectWebSocket();
// Sync current lock state
await syncLocks();
_logger.i('Connected to coordinator');
return true;
}
} catch (e) {
_logger.e('Failed to connect to coordinator: $e');
}
_connectedController.add(false);
_scheduleReconnect();
return false;
}
/// Disconnect from the coordinator
Future<void> disconnect() async {
_reconnectTimer?.cancel();
_lockResetTimer?.cancel();
await _disconnectWebSocket();
_connectedController.add(false);
}
// ============================================================
// Lock Operations
// ============================================================
/// Try to acquire a lock on a camera
Future<({bool acquired, CameraLock? lock})> tryLock(
int cameraId, {
CameraLockPriority priority = CameraLockPriority.low,
}) async {
try {
final response = await _http.post('/locks/try', data: {
'CameraId': cameraId,
'KeyboardId': _keyboardId,
'Priority': priority.name,
});
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
final acquired = data['acquired'] as bool? ?? false;
final lockJson = data['lock'] as Map<String, dynamic>?;
final lock = lockJson != null ? CameraLock.fromJson(lockJson) : null;
if (acquired && lock != null) {
_updateLockState(lock);
_startLockResetTimer(cameraId);
}
return (acquired: acquired, lock: lock);
}
} catch (e) {
_logger.e('tryLock failed for camera $cameraId: $e');
}
return (acquired: false, lock: null);
}
/// Release a camera lock
Future<bool> releaseLock(int cameraId) async {
try {
final response = await _http.post('/locks/release', data: {
'CameraId': cameraId,
'KeyboardId': _keyboardId,
});
if (response.statusCode == 200) {
_removeLockState(cameraId);
return true;
}
} catch (e) {
_logger.e('releaseLock failed for camera $cameraId: $e');
}
return false;
}
/// Request takeover of a camera locked by another keyboard
Future<bool> requestTakeover(
int cameraId, {
CameraLockPriority priority = CameraLockPriority.low,
}) async {
try {
final response = await _http.post('/locks/takeover', data: {
'CameraId': cameraId,
'KeyboardId': _keyboardId,
'Priority': priority.name,
});
return response.statusCode == 200;
} catch (e) {
_logger.e('requestTakeover failed for camera $cameraId: $e');
return false;
}
}
/// Confirm or reject a takeover request
Future<bool> confirmTakeover(int cameraId, bool confirm) async {
try {
final response = await _http.post('/locks/confirm', data: {
'CameraId': cameraId,
'KeyboardId': _keyboardId,
'Confirm': confirm,
});
return response.statusCode == 200;
} catch (e) {
_logger.e('confirmTakeover failed for camera $cameraId: $e');
return false;
}
}
/// Reset lock expiration timer (called periodically while PTZ is active)
Future<bool> resetExpiration(int cameraId) async {
try {
final response = await _http.post('/locks/reset', data: {
'CameraId': cameraId,
'KeyboardId': _keyboardId,
});
return response.statusCode == 200;
} catch (e) {
_logger.e('resetExpiration failed for camera $cameraId: $e');
return false;
}
}
/// Get all current locks from the coordinator
Future<void> syncLocks() async {
try {
final response = await _http.get('/locks');
if (response.statusCode == 200) {
final lockList = response.data as List<dynamic>;
final locks = <int, CameraLock>{};
for (final lockJson in lockList) {
final lock = CameraLock.fromJson(lockJson as Map<String, dynamic>);
locks[lock.cameraId] = lock;
}
_locksController.add(locks);
}
} catch (e) {
_logger.e('syncLocks failed: $e');
}
}
/// Get camera IDs locked by this keyboard
Future<List<int>> getMyLockedCameras() async {
try {
final response = await _http.get('/locks/$_keyboardId');
if (response.statusCode == 200) {
final ids = response.data as List<dynamic>;
return ids.cast<int>();
}
} catch (e) {
_logger.e('getMyLockedCameras failed: $e');
}
return [];
}
/// Check if a camera is locked by this keyboard
bool isCameraLockedByMe(int cameraId) {
final lock = currentLocks[cameraId];
return lock != null &&
lock.ownerName.toLowerCase() == _keyboardId.toLowerCase();
}
/// Check if a camera is locked by another keyboard
bool isCameraLockedByOther(int cameraId) {
final lock = currentLocks[cameraId];
return lock != null &&
lock.ownerName.toLowerCase() != _keyboardId.toLowerCase();
}
// ============================================================
// Sequence Operations
// ============================================================
/// Start a sequence on a viewer
Future<Map<String, dynamic>?> startSequence(
int viewerId, int sequenceId) async {
try {
final response = await _http.post('/sequences/start', data: {
'ViewerId': viewerId,
'SequenceId': sequenceId,
});
if (response.statusCode == 200) {
return response.data as Map<String, dynamic>;
}
} catch (e) {
_logger.e('startSequence failed: $e');
}
return null;
}
/// Stop a sequence on a viewer
Future<bool> stopSequence(int viewerId) async {
try {
final response = await _http.post('/sequences/stop', data: {
'ViewerId': viewerId,
});
return response.statusCode == 200;
} catch (e) {
_logger.e('stopSequence failed: $e');
return false;
}
}
/// Get running sequences
Future<List<Map<String, dynamic>>> getRunningSequences() async {
try {
final response = await _http.get('/sequences/running');
if (response.statusCode == 200) {
final list = response.data as List<dynamic>;
return list.cast<Map<String, dynamic>>();
}
} catch (e) {
_logger.e('getRunningSequences failed: $e');
}
return [];
}
/// Get available sequences, optionally filtered by category
Future<List<Map<String, dynamic>>> getSequences({int? categoryId}) async {
try {
final queryParams = categoryId != null ? {'categoryId': categoryId} : null;
final response =
await _http.get('/sequences', queryParameters: queryParams);
if (response.statusCode == 200) {
final list = response.data as List<dynamic>;
return list.cast<Map<String, dynamic>>();
}
} catch (e) {
_logger.e('getSequences failed: $e');
}
return [];
}
/// Get sequence categories
Future<List<Map<String, dynamic>>> getSequenceCategories() async {
try {
final response = await _http.get('/sequences/categories');
if (response.statusCode == 200) {
final list = response.data as List<dynamic>;
return list.cast<Map<String, dynamic>>();
}
} catch (e) {
_logger.e('getSequenceCategories failed: $e');
}
return [];
}
// ============================================================
// Private Methods
// ============================================================
Future<void> _connectWebSocket() async {
await _disconnectWebSocket();
try {
final wsUrl =
'${_coordinatorUrl.replaceFirst('http', 'ws')}/ws?keyboard=$_keyboardId';
_logger.d('Connecting coordinator WebSocket: $wsUrl');
_ws = WebSocketChannel.connect(Uri.parse(wsUrl));
_wsSubscription = _ws!.stream.listen(
(message) => _handleWsMessage(message as String),
onError: (error) {
_logger.e('Coordinator WebSocket error: $error');
_connectedController.add(false);
_scheduleReconnect();
},
onDone: () {
_logger.w('Coordinator WebSocket closed');
_connectedController.add(false);
_scheduleReconnect();
},
);
} catch (e) {
_logger.e('Failed to connect coordinator WebSocket: $e');
_scheduleReconnect();
}
}
Future<void> _disconnectWebSocket() async {
await _wsSubscription?.cancel();
_wsSubscription = null;
await _ws?.sink.close();
_ws = null;
}
void _handleWsMessage(String message) {
try {
final json = jsonDecode(message) as Map<String, dynamic>;
final type = json['type'] as String?;
final data = json['data'];
switch (type) {
case 'lock_acquired':
if (data is Map<String, dynamic>) {
final lock = CameraLock.fromJson(data);
_updateLockState(lock);
}
break;
case 'lock_released':
if (data is Map<String, dynamic>) {
final cameraId =
data['cameraId'] as int? ?? data['CameraId'] as int? ?? 0;
_removeLockState(cameraId);
}
break;
case 'lock_notification':
if (data is Map<String, dynamic>) {
final notification = CameraLockNotification.fromJson(data);
_notificationController.add(notification);
_logger.i(
'Lock notification: ${notification.type.name} camera ${notification.cameraId}');
}
break;
case 'sequence_started':
case 'sequence_stopped':
_logger.d('Sequence event: $type');
break;
case 'keyboard_online':
case 'keyboard_offline':
_logger.d('Keyboard event: $type');
break;
default:
_logger.d('Unknown coordinator event: $type');
}
} catch (e) {
_logger.e('Failed to parse coordinator message: $e');
}
}
void _updateLockState(CameraLock lock) {
final locks = Map<int, CameraLock>.from(_locksController.value);
locks[lock.cameraId] = lock;
_locksController.add(locks);
}
void _removeLockState(int cameraId) {
final locks = Map<int, CameraLock>.from(_locksController.value);
locks.remove(cameraId);
_locksController.add(locks);
}
/// Auto-reset lock expiration every 2 minutes while a lock is held
void _startLockResetTimer(int cameraId) {
_lockResetTimer?.cancel();
_lockResetTimer = Timer.periodic(const Duration(minutes: 2), (_) {
if (isCameraLockedByMe(cameraId)) {
resetExpiration(cameraId);
} else {
_lockResetTimer?.cancel();
}
});
}
void _scheduleReconnect() {
if (_disposed) return;
_reconnectTimer?.cancel();
final delay = Duration(
milliseconds: (_initialReconnectDelay.inMilliseconds *
(1 << _reconnectAttempts.clamp(0, 5)))
.clamp(0, _maxReconnectDelay.inMilliseconds),
);
_logger.i(
'Scheduling coordinator reconnect in ${delay.inSeconds}s (attempt ${_reconnectAttempts + 1})');
_reconnectTimer = Timer(delay, () async {
if (_disposed) return;
_reconnectAttempts++;
await connect();
});
}
/// Dispose all resources
void dispose() {
_disposed = true;
_reconnectTimer?.cancel();
_lockResetTimer?.cancel();
_wsSubscription?.cancel();
_ws?.sink.close();
_connectedController.close();
_locksController.close();
_notificationController.close();
}
}

View File

@@ -0,0 +1,67 @@
import 'package:logger/logger.dart';
import '../../domain/entities/function_button_config.dart';
import 'bridge_service.dart';
import 'coordination_service.dart';
/// Executes function button actions (F1-F7).
/// Ported from legacy FunctionButtonsService.cs.
class FunctionButtonService {
final BridgeService _bridgeService;
final CoordinationService _coordinationService;
final Logger _logger = Logger();
FunctionButtonConfig _config = const FunctionButtonConfig();
FunctionButtonService({
required BridgeService bridgeService,
required CoordinationService coordinationService,
}) : _bridgeService = bridgeService,
_coordinationService = coordinationService;
/// Load function button configuration.
void loadConfig(FunctionButtonConfig config) {
_config = config;
_logger.i('Loaded function button config: ${config.walls.length} walls');
}
/// Execute all actions for a button on a specific wall.
/// Actions are executed sequentially (like legacy).
Future<bool> execute(String wallId, String buttonKey) async {
final actions = _config.getActions(wallId, buttonKey);
if (actions.isEmpty) {
_logger.d('No actions for $buttonKey on wall $wallId');
return false;
}
_logger.i('Executing $buttonKey on wall $wallId (${actions.length} actions)');
for (final action in actions) {
try {
switch (action.type) {
case FunctionButtonActionType.crossSwitch:
await _bridgeService.viewerConnectLive(
action.viewerId, action.sourceId);
_logger.d(
'CrossSwitch: viewer ${action.viewerId} -> camera ${action.sourceId}');
case FunctionButtonActionType.sequenceStart:
await _coordinationService.startSequence(
action.viewerId, action.sourceId);
_logger.d(
'SequenceStart: viewer ${action.viewerId} -> sequence ${action.sourceId}');
}
} catch (e) {
_logger.e(
'Function button action failed: ${action.type.name} - $e');
}
}
return true;
}
/// Check if a button has actions for a given wall.
bool hasActions(String wallId, String buttonKey) {
return _config.hasActions(wallId, buttonKey);
}
}

View File

@@ -0,0 +1,189 @@
import 'dart:async';
import 'package:logger/logger.dart';
import 'package:rxdart/rxdart.dart';
import '../../domain/entities/monitor_state.dart';
import '../../domain/entities/alarm_state.dart';
import '../models/bridge_event.dart';
import 'bridge_service.dart';
import 'alarm_service.dart';
/// Service for tracking overall system state (monitors + alarms)
class StateService {
final BridgeService _bridgeService;
final AlarmService _alarmService;
final Logger _logger = Logger();
StreamSubscription? _eventSubscription;
// Monitor state
final _monitorStatesController =
BehaviorSubject<Map<int, MonitorState>>.seeded({});
// Combined state stream (monitors with alarm flags)
final _combinedMonitorStatesController =
BehaviorSubject<Map<int, MonitorState>>.seeded({});
StateService({
required BridgeService bridgeService,
required AlarmService alarmService,
}) : _bridgeService = bridgeService,
_alarmService = alarmService;
/// Stream of monitor states (without alarm info)
Stream<Map<int, MonitorState>> get monitorStates =>
_monitorStatesController.stream;
/// Stream of monitor states with alarm flags
Stream<Map<int, MonitorState>> get combinedMonitorStates =>
_combinedMonitorStatesController.stream;
/// Stream of active alarms (delegated to AlarmService)
Stream<List<AlarmState>> get activeAlarms => _alarmService.alarms;
/// Current monitor states
Map<int, MonitorState> get currentMonitorStates =>
_monitorStatesController.value;
/// Get state for a specific monitor
MonitorState? getMonitorState(int viewerId) {
return _combinedMonitorStatesController.value[viewerId];
}
/// Initialize state tracking
Future<void> initialize() async {
// Subscribe to bridge events
_eventSubscription = _bridgeService.eventStream.listen(_handleBridgeEvent);
// Subscribe to alarm changes to update monitor flags
_alarmService.alarms.listen((_) => _updateCombinedStates());
_logger.i('StateService initialized');
}
/// Sync initial state from all bridges
Future<void> syncFromBridges() async {
_logger.i('Syncing state from bridges...');
final connectionStatus = _bridgeService.currentConnectionStatus;
final monitors = <int, MonitorState>{};
for (final entry in connectionStatus.entries) {
if (!entry.value) continue; // Skip disconnected servers
try {
final serverMonitors =
await _bridgeService.getMonitorStates(entry.key);
for (final monitorJson in serverMonitors) {
final state = MonitorState.fromJson(monitorJson);
monitors[state.viewerId] = state;
}
_logger.d(
'Synced ${serverMonitors.length} monitors from ${entry.key}');
} catch (e) {
_logger.e('Failed to sync monitors from ${entry.key}: $e');
}
}
_monitorStatesController.add(monitors);
_updateCombinedStates();
// Also sync alarms
await _alarmService.queryAllAlarms();
_logger.i('State sync complete: ${monitors.length} monitors');
}
/// Handle incoming bridge event
void _handleBridgeEvent(BridgeEvent event) {
if (event.isViewerConnected || event.isViewerSelectionChanged) {
_handleViewerConnected(event);
} else if (event.isViewerCleared) {
_handleViewerCleared(event);
} else if (event.isEventStarted ||
event.isEventStopped ||
event.isAlarmQueueNotification) {
// Delegate alarm events to AlarmService
_alarmService.handleAlarmEvent(event);
}
}
/// Handle viewer connected event
void _handleViewerConnected(BridgeEvent event) {
final viewer = event.viewer;
final channel = event.channel;
final playMode = event.playMode;
if (viewer == null) return;
final monitors = Map<int, MonitorState>.from(_monitorStatesController.value);
final existing = monitors[viewer];
monitors[viewer] = MonitorState(
viewerId: viewer,
currentChannel: channel ?? existing?.currentChannel ?? 0,
playMode: PlayMode.fromValue(playMode ?? existing?.playMode.value ?? 0),
serverId: event.serverId,
lastUpdated: event.timestamp,
);
_monitorStatesController.add(monitors);
_updateCombinedStates();
_logger.d('Monitor $viewer connected to channel $channel');
}
/// Handle viewer cleared event
void _handleViewerCleared(BridgeEvent event) {
final viewer = event.viewer;
if (viewer == null) return;
final monitors = Map<int, MonitorState>.from(_monitorStatesController.value);
final existing = monitors[viewer];
if (existing != null) {
monitors[viewer] = existing.cleared();
} else {
monitors[viewer] = MonitorState(
viewerId: viewer,
currentChannel: 0,
playMode: PlayMode.unknown,
serverId: event.serverId,
lastUpdated: event.timestamp,
);
}
_monitorStatesController.add(monitors);
_updateCombinedStates();
_logger.d('Monitor $viewer cleared');
}
/// Update combined states with alarm flags
void _updateCombinedStates() {
final monitors = _monitorStatesController.value;
final combined = <int, MonitorState>{};
for (final entry in monitors.entries) {
final hasAlarm = _alarmService.isMonitorBlocked(entry.key);
combined[entry.key] = entry.value.withAlarm(hasAlarm);
}
_combinedMonitorStatesController.add(combined);
}
/// Check if a monitor is blocked by an alarm
bool isMonitorBlocked(int viewerId) {
return _alarmService.isMonitorBlocked(viewerId);
}
/// Dispose resources
void dispose() {
_eventSubscription?.cancel();
_monitorStatesController.close();
_combinedMonitorStatesController.close();
}
}

View File

@@ -0,0 +1,137 @@
import 'package:equatable/equatable.dart';
/// Alarm state enumeration matching SDK PlcViewerAlarmState
enum AlarmStatus {
newAlarm(0, 'vasNewAlarm'),
presented(1, 'vasPresented'),
stacked(2, 'vasStacked'),
confirmed(3, 'vasConfirmed'),
removed(4, 'vasRemoved'),
lastConfirmed(5, 'vasLastConfirmed'),
lastRemoved(6, 'vasLastRemoved');
final int value;
final String name;
const AlarmStatus(this.value, this.name);
static AlarmStatus fromValue(int value) {
return AlarmStatus.values.firstWhere(
(s) => s.value == value,
orElse: () => AlarmStatus.newAlarm,
);
}
/// Check if this status blocks the monitor
bool get blocksMonitor =>
this == AlarmStatus.newAlarm || this == AlarmStatus.presented;
}
/// State of a single alarm/event
class AlarmState extends Equatable {
final int eventId;
final String eventName;
final int typeId;
final int foreignKey; // Camera or contact ID
final String? serverId;
final DateTime startedAt;
final DateTime? stoppedAt;
final bool isActive;
final AlarmStatus status;
final int? associatedMonitor;
const AlarmState({
required this.eventId,
required this.eventName,
required this.typeId,
required this.foreignKey,
this.serverId,
required this.startedAt,
this.stoppedAt,
required this.isActive,
this.status = AlarmStatus.newAlarm,
this.associatedMonitor,
});
/// Check if this alarm blocks a monitor
bool get blocksMonitor => isActive && status.blocksMonitor;
/// Create a stopped alarm
AlarmState stopped() {
return AlarmState(
eventId: eventId,
eventName: eventName,
typeId: typeId,
foreignKey: foreignKey,
serverId: serverId,
startedAt: startedAt,
stoppedAt: DateTime.now(),
isActive: false,
status: AlarmStatus.removed,
associatedMonitor: associatedMonitor,
);
}
/// Create alarm with updated status
AlarmState withStatus(AlarmStatus newStatus) {
return AlarmState(
eventId: eventId,
eventName: eventName,
typeId: typeId,
foreignKey: foreignKey,
serverId: serverId,
startedAt: startedAt,
stoppedAt: stoppedAt,
isActive: isActive,
status: newStatus,
associatedMonitor: associatedMonitor,
);
}
factory AlarmState.fromJson(Map<String, dynamic> json) {
return AlarmState(
eventId: json['event_id'] as int? ?? 0,
eventName: json['event_name'] as String? ?? '',
typeId: json['type_id'] as int? ?? 0,
foreignKey: json['foreign_key'] as int? ?? 0,
serverId: json['server_id'] as String?,
startedAt: json['started_at'] != null
? DateTime.parse(json['started_at'] as String)
: DateTime.now(),
stoppedAt: json['stopped_at'] != null
? DateTime.parse(json['stopped_at'] as String)
: null,
isActive: json['is_active'] as bool? ?? true,
status: AlarmStatus.fromValue(json['status'] as int? ?? 0),
associatedMonitor: json['associated_monitor'] as int?,
);
}
Map<String, dynamic> toJson() {
return {
'event_id': eventId,
'event_name': eventName,
'type_id': typeId,
'foreign_key': foreignKey,
'server_id': serverId,
'started_at': startedAt.toIso8601String(),
'stopped_at': stoppedAt?.toIso8601String(),
'is_active': isActive,
'status': status.value,
'associated_monitor': associatedMonitor,
};
}
@override
List<Object?> get props => [
eventId,
eventName,
typeId,
foreignKey,
serverId,
startedAt,
stoppedAt,
isActive,
status,
associatedMonitor,
];
}

View File

@@ -0,0 +1,123 @@
/// Camera lock entity matching the coordinator's CameraLock model.
class CameraLock {
final int cameraId;
final CameraLockPriority priority;
final String ownerName;
final DateTime ownedSince;
final DateTime expiresAt;
const CameraLock({
required this.cameraId,
required this.priority,
required this.ownerName,
required this.ownedSince,
required this.expiresAt,
});
factory CameraLock.fromJson(Map<String, dynamic> json) {
return CameraLock(
cameraId: json['cameraId'] as int? ?? json['CameraId'] as int? ?? 0,
priority: CameraLockPriority.fromString(
json['priority'] as String? ?? json['Priority'] as String? ?? 'Low'),
ownerName:
json['ownerName'] as String? ?? json['OwnerName'] as String? ?? '',
ownedSince: DateTime.parse(
json['ownedSince'] as String? ?? json['OwnedSince'] as String? ?? ''),
expiresAt: DateTime.parse(
json['expiresAt'] as String? ?? json['ExpiresAt'] as String? ?? ''),
);
}
Duration get timeRemaining {
final remaining = expiresAt.difference(DateTime.now().toUtc());
return remaining.isNegative ? Duration.zero : remaining;
}
bool get isExpiringSoon => timeRemaining.inSeconds <= 60;
bool get isExpired => timeRemaining == Duration.zero;
bool isOwnedBy(String keyboardId) =>
ownerName.toLowerCase() == keyboardId.toLowerCase();
}
enum CameraLockPriority {
none,
high,
low;
static CameraLockPriority fromString(String value) {
switch (value.toLowerCase()) {
case 'high':
return CameraLockPriority.high;
case 'low':
return CameraLockPriority.low;
default:
return CameraLockPriority.none;
}
}
String get name {
switch (this) {
case CameraLockPriority.high:
return 'High';
case CameraLockPriority.low:
return 'Low';
case CameraLockPriority.none:
return 'None';
}
}
}
enum CameraLockNotificationType {
acquired,
takenOver,
confirmTakeOver,
confirmed,
rejected,
expireSoon,
unlocked;
static CameraLockNotificationType fromString(String value) {
switch (value) {
case 'Acquired':
return CameraLockNotificationType.acquired;
case 'TakenOver':
return CameraLockNotificationType.takenOver;
case 'ConfirmTakeOver':
return CameraLockNotificationType.confirmTakeOver;
case 'Confirmed':
return CameraLockNotificationType.confirmed;
case 'Rejected':
return CameraLockNotificationType.rejected;
case 'ExpireSoon':
return CameraLockNotificationType.expireSoon;
case 'Unlocked':
return CameraLockNotificationType.unlocked;
default:
return CameraLockNotificationType.acquired;
}
}
}
/// Lock notification from the coordinator (sent via WebSocket)
class CameraLockNotification {
final CameraLockNotificationType type;
final int cameraId;
final String copilotName;
const CameraLockNotification({
required this.type,
required this.cameraId,
required this.copilotName,
});
factory CameraLockNotification.fromJson(Map<String, dynamic> json) {
return CameraLockNotification(
type: CameraLockNotificationType.fromString(
json['type'] as String? ?? json['Type'] as String? ?? ''),
cameraId: json['cameraId'] as int? ?? json['CameraId'] as int? ?? 0,
copilotName: json['copilotName'] as String? ??
json['CopilotName'] as String? ??
'',
);
}
}

View File

@@ -0,0 +1,77 @@
/// Configuration for function buttons (F1-F7) per wall.
/// Loaded from the "functionButtons" section of the config file.
class FunctionButtonConfig {
final Map<String, Map<String, List<FunctionButtonAction>>> walls;
const FunctionButtonConfig({this.walls = const {}});
/// Get actions for a specific wall and button.
List<FunctionButtonAction> getActions(String wallId, String buttonKey) {
return walls[wallId]?[buttonKey] ?? [];
}
/// Check if a button has any actions configured for this wall.
bool hasActions(String wallId, String buttonKey) {
return getActions(wallId, buttonKey).isNotEmpty;
}
factory FunctionButtonConfig.fromJson(Map<String, dynamic> json) {
final wallsJson = json['walls'] as Map<String, dynamic>? ?? {};
final walls = <String, Map<String, List<FunctionButtonAction>>>{};
for (final wallEntry in wallsJson.entries) {
final buttonsJson = wallEntry.value as Map<String, dynamic>? ?? {};
final buttons = <String, List<FunctionButtonAction>>{};
for (final buttonEntry in buttonsJson.entries) {
final actionsJson = buttonEntry.value as List<dynamic>? ?? [];
buttons[buttonEntry.key] = actionsJson
.map((a) =>
FunctionButtonAction.fromJson(a as Map<String, dynamic>))
.toList();
}
walls[wallEntry.key] = buttons;
}
return FunctionButtonConfig(walls: walls);
}
}
/// A single action triggered by a function button press.
class FunctionButtonAction {
final FunctionButtonActionType type;
final int viewerId;
final int sourceId;
const FunctionButtonAction({
required this.type,
required this.viewerId,
required this.sourceId,
});
factory FunctionButtonAction.fromJson(Map<String, dynamic> json) {
return FunctionButtonAction(
type: FunctionButtonActionType.fromString(
json['actionType'] as String? ?? ''),
viewerId: json['viewerId'] as int? ?? 0,
sourceId: json['sourceId'] as int? ?? 0,
);
}
}
enum FunctionButtonActionType {
crossSwitch,
sequenceStart;
static FunctionButtonActionType fromString(String value) {
switch (value.toLowerCase()) {
case 'crossswitch':
return FunctionButtonActionType.crossSwitch;
case 'sequencestart':
return FunctionButtonActionType.sequenceStart;
default:
return FunctionButtonActionType.crossSwitch;
}
}
}

View File

@@ -0,0 +1,127 @@
import 'package:equatable/equatable.dart';
/// Play mode enumeration matching SDK values
enum PlayMode {
unknown(0),
playStop(1),
playForward(2),
playBackward(3),
fastForward(4),
fastBackward(5),
stepForward(6),
stepBackward(7),
playBOD(8),
playEOD(9),
quasiLive(10),
live(11),
nextEvent(12),
prevEvent(13),
peekLivePicture(14),
nextDetectedMotion(17),
prevDetectedMotion(18);
final int value;
const PlayMode(this.value);
static PlayMode fromValue(int value) {
return PlayMode.values.firstWhere(
(m) => m.value == value,
orElse: () => PlayMode.unknown,
);
}
}
/// State of a single monitor/viewer
class MonitorState extends Equatable {
final int viewerId;
final int currentChannel;
final PlayMode playMode;
final String? serverId;
final DateTime lastUpdated;
final bool hasAlarm;
const MonitorState({
required this.viewerId,
required this.currentChannel,
required this.playMode,
this.serverId,
required this.lastUpdated,
this.hasAlarm = false,
});
/// Check if monitor is currently displaying a camera
bool get isActive => currentChannel > 0;
/// Check if monitor is in live mode
bool get isLive => playMode == PlayMode.live || playMode == PlayMode.quasiLive;
/// Create a cleared state
MonitorState cleared() {
return MonitorState(
viewerId: viewerId,
currentChannel: 0,
playMode: PlayMode.unknown,
serverId: serverId,
lastUpdated: DateTime.now(),
hasAlarm: hasAlarm,
);
}
/// Create updated state with new camera
MonitorState withCamera(int channel, PlayMode mode) {
return MonitorState(
viewerId: viewerId,
currentChannel: channel,
playMode: mode,
serverId: serverId,
lastUpdated: DateTime.now(),
hasAlarm: hasAlarm,
);
}
/// Create state with alarm flag updated
MonitorState withAlarm(bool alarm) {
return MonitorState(
viewerId: viewerId,
currentChannel: currentChannel,
playMode: playMode,
serverId: serverId,
lastUpdated: lastUpdated,
hasAlarm: alarm,
);
}
factory MonitorState.fromJson(Map<String, dynamic> json) {
return MonitorState(
viewerId: json['viewer_id'] as int? ?? 0,
currentChannel: json['current_channel'] as int? ?? 0,
playMode: PlayMode.fromValue(json['play_mode'] as int? ?? 0),
serverId: json['server_id'] as String?,
lastUpdated: json['last_updated'] != null
? DateTime.parse(json['last_updated'] as String)
: DateTime.now(),
hasAlarm: json['has_alarm'] as bool? ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'viewer_id': viewerId,
'current_channel': currentChannel,
'play_mode': playMode.value,
'server_id': serverId,
'last_updated': lastUpdated.toIso8601String(),
'has_alarm': hasAlarm,
};
}
@override
List<Object?> get props => [
viewerId,
currentChannel,
playMode,
serverId,
lastUpdated,
hasAlarm,
];
}

View File

@@ -0,0 +1,72 @@
/// Sequence definition loaded from the coordinator.
class SequenceDefinition {
final int id;
final String name;
final int categoryId;
final List<int> cameras;
final int intervalSeconds;
const SequenceDefinition({
required this.id,
required this.name,
required this.categoryId,
required this.cameras,
required this.intervalSeconds,
});
factory SequenceDefinition.fromJson(Map<String, dynamic> json) {
return SequenceDefinition(
id: json['id'] as int? ?? json['Id'] as int? ?? 0,
name: json['name'] as String? ?? json['Name'] as String? ?? '',
categoryId:
json['categoryId'] as int? ?? json['CategoryId'] as int? ?? 0,
cameras: (json['cameras'] as List<dynamic>? ??
json['Cameras'] as List<dynamic>? ??
[])
.cast<int>(),
intervalSeconds: json['intervalSeconds'] as int? ??
json['IntervalSeconds'] as int? ??
5,
);
}
}
/// Sequence category for grouping sequences.
class SequenceCategory {
final int id;
final String name;
const SequenceCategory({required this.id, required this.name});
factory SequenceCategory.fromJson(Map<String, dynamic> json) {
return SequenceCategory(
id: json['id'] as int? ?? json['Id'] as int? ?? 0,
name: json['name'] as String? ?? json['Name'] as String? ?? '',
);
}
}
/// A sequence currently running on a viewer.
class RunningSequence {
final int viewerId;
final int sequenceId;
final DateTime startedAt;
const RunningSequence({
required this.viewerId,
required this.sequenceId,
required this.startedAt,
});
factory RunningSequence.fromJson(Map<String, dynamic> json) {
return RunningSequence(
viewerId: json['viewerId'] as int? ?? json['ViewerId'] as int? ?? 0,
sequenceId:
json['sequenceId'] as int? ?? json['SequenceId'] as int? ?? 0,
startedAt: DateTime.tryParse(json['startedAt'] as String? ??
json['StartedAt'] as String? ??
'') ??
DateTime.now(),
);
}
}

View File

@@ -0,0 +1,94 @@
import 'package:equatable/equatable.dart';
/// Server type enumeration
enum ServerType { geviscope, gcore, geviserver }
/// Configuration for a single recording server
class ServerConfig extends Equatable {
final String id;
final String name;
final ServerType type;
final bool enabled;
final String address;
final int port;
final String username;
final String bridgeUrl;
final String? websocketUrl;
final int cameraRangeStart;
final int cameraRangeEnd;
final int monitorRangeStart;
final int monitorRangeEnd;
const ServerConfig({
required this.id,
required this.name,
required this.type,
required this.enabled,
required this.address,
required this.port,
required this.username,
required this.bridgeUrl,
this.websocketUrl,
required this.cameraRangeStart,
required this.cameraRangeEnd,
required this.monitorRangeStart,
required this.monitorRangeEnd,
});
/// Check if this server owns a camera ID
bool ownsCamera(int cameraId) {
return cameraId >= cameraRangeStart && cameraId <= cameraRangeEnd;
}
/// Check if this server owns a monitor ID
bool ownsMonitor(int monitorId) {
return monitorId >= monitorRangeStart && monitorId <= monitorRangeEnd;
}
factory ServerConfig.fromJson(Map<String, dynamic> json) {
final typeStr = json['type'] as String;
final type = ServerType.values.firstWhere(
(t) => t.name == typeStr,
orElse: () => ServerType.geviscope,
);
final connection = json['connection'] as Map<String, dynamic>? ?? {};
final bridge = json['bridge'] as Map<String, dynamic>? ?? {};
final resources = json['resources'] as Map<String, dynamic>? ?? {};
final cameraRange = resources['cameraRange'] as Map<String, dynamic>? ?? {};
final monitorRange = resources['monitorRange'] as Map<String, dynamic>? ?? {};
return ServerConfig(
id: json['id'] as String,
name: json['name'] as String? ?? json['id'] as String,
type: type,
enabled: json['enabled'] as bool? ?? true,
address: connection['address'] as String? ?? '',
port: connection['port'] as int? ?? 7700,
username: connection['username'] as String? ?? '',
bridgeUrl: bridge['url'] as String? ?? '',
websocketUrl: bridge['websocket'] as String?,
cameraRangeStart: cameraRange['start'] as int? ?? 0,
cameraRangeEnd: cameraRange['end'] as int? ?? 0,
monitorRangeStart: monitorRange['start'] as int? ?? 0,
monitorRangeEnd: monitorRange['end'] as int? ?? 0,
);
}
@override
List<Object?> get props => [
id,
name,
type,
enabled,
address,
port,
username,
bridgeUrl,
websocketUrl,
cameraRangeStart,
cameraRangeEnd,
monitorRangeStart,
monitorRangeEnd,
];
}

View File

@@ -0,0 +1,439 @@
import 'package:equatable/equatable.dart';
/// Configuration for a physical monitor that can display 1-4 viewers
class PhysicalMonitor extends Equatable {
final int id;
final String? name;
final List<int> viewerIds; // 1-4 viewer IDs in this physical monitor
final bool isQuadView;
final int row; // Grid row position (1-based)
final int col; // Grid column position (1-based)
final int rowSpan; // How many rows this monitor spans
final int colSpan; // How many columns this monitor spans
const PhysicalMonitor({
required this.id,
this.name,
required this.viewerIds,
this.isQuadView = false,
this.row = 1,
this.col = 1,
this.rowSpan = 1,
this.colSpan = 1,
});
/// Whether this monitor has multiple viewers (quad view)
bool get hasMultipleViewers => viewerIds.length > 1;
/// Get the primary viewer ID (first one)
int get primaryViewerId => viewerIds.isNotEmpty ? viewerIds.first : 0;
factory PhysicalMonitor.fromJson(Map<String, dynamic> json) {
final viewers = json['viewer_ids'] as List<dynamic>?;
return PhysicalMonitor(
id: json['id'] as int,
name: json['name'] as String?,
viewerIds: viewers?.map((v) => v as int).toList() ?? [],
isQuadView: json['is_quad_view'] as bool? ?? false,
row: json['row'] as int? ?? 1,
col: json['col'] as int? ?? 1,
rowSpan: json['row_span'] as int? ?? 1,
colSpan: json['col_span'] as int? ?? 1,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'viewer_ids': viewerIds,
'is_quad_view': isQuadView,
'row': row,
'col': col,
'row_span': rowSpan,
'col_span': colSpan,
};
@override
List<Object?> get props => [id, name, viewerIds, isQuadView, row, col, rowSpan, colSpan];
}
/// A section of the video wall (e.g., "Vrchní část", "Pravá část")
class WallSection extends Equatable {
final String id;
final String name;
final List<PhysicalMonitor> monitors;
final int columns; // Grid layout columns for this section
final int rows; // Grid layout rows for this section
const WallSection({
required this.id,
required this.name,
required this.monitors,
this.columns = 8,
this.rows = 4,
});
factory WallSection.fromJson(Map<String, dynamic> json) {
final monitors = json['monitors'] as List<dynamic>?;
return WallSection(
id: json['id'] as String,
name: json['name'] as String,
monitors: monitors
?.map((m) => PhysicalMonitor.fromJson(m as Map<String, dynamic>))
.toList() ??
[],
columns: json['columns'] as int? ?? 8,
rows: json['rows'] as int? ?? 4,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'monitors': monitors.map((m) => m.toJson()).toList(),
'columns': columns,
'rows': rows,
};
@override
List<Object?> get props => [id, name, monitors, columns, rows];
}
/// Complete wall configuration with all sections
class WallConfig extends Equatable {
final String id;
final String name;
final List<WallSection> sections;
final List<int> alarmMonitorIds; // Monitor IDs designated for alarms
const WallConfig({
required this.id,
required this.name,
required this.sections,
this.alarmMonitorIds = const [],
});
/// Get all viewer IDs across all sections
List<int> get allViewerIds {
final ids = <int>[];
for (final section in sections) {
for (final monitor in section.monitors) {
ids.addAll(monitor.viewerIds);
}
}
return ids;
}
/// Get all physical monitors across all sections
List<PhysicalMonitor> get allMonitors {
final monitors = <PhysicalMonitor>[];
for (final section in sections) {
monitors.addAll(section.monitors);
}
return monitors;
}
/// Find physical monitor containing a viewer ID
PhysicalMonitor? findMonitorByViewerId(int viewerId) {
for (final section in sections) {
for (final monitor in section.monitors) {
if (monitor.viewerIds.contains(viewerId)) {
return monitor;
}
}
}
return null;
}
/// Find section containing a viewer ID
WallSection? findSectionByViewerId(int viewerId) {
for (final section in sections) {
for (final monitor in section.monitors) {
if (monitor.viewerIds.contains(viewerId)) {
return section;
}
}
}
return null;
}
factory WallConfig.fromJson(Map<String, dynamic> json) {
final sections = json['sections'] as List<dynamic>?;
final alarmIds = json['alarm_monitor_ids'] as List<dynamic>?;
return WallConfig(
id: json['id'] as String,
name: json['name'] as String,
sections: sections
?.map((s) => WallSection.fromJson(s as Map<String, dynamic>))
.toList() ??
[],
alarmMonitorIds: alarmIds?.map((i) => i as int).toList() ?? [],
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'sections': sections.map((s) => s.toJson()).toList(),
'alarm_monitor_ids': alarmMonitorIds,
};
/// Create sample wall config matching D6 app structure
factory WallConfig.sample() {
return WallConfig(
id: 'wall_1',
name: 'Hlavní videostěna',
sections: [
// Vrchní část - 8 columns x 4 rows irregular grid
WallSection(
id: 'top',
name: 'Vrchní část',
columns: 8,
rows: 4,
monitors: [
// Row 1-2: Three quad monitors
PhysicalMonitor(
id: 1,
viewerIds: [210, 211, 212, 213],
isQuadView: true,
row: 1, col: 1, rowSpan: 2, colSpan: 2,
),
PhysicalMonitor(
id: 2,
viewerIds: [214, 215, 216, 217],
isQuadView: true,
row: 1, col: 3, rowSpan: 2, colSpan: 2,
),
PhysicalMonitor(
id: 3,
viewerIds: [1001, 1002, 1003, 1004],
isQuadView: true,
row: 1, col: 5, rowSpan: 2, colSpan: 2,
),
// Row 4: Single monitor
PhysicalMonitor(
id: 4,
viewerIds: [222],
row: 4, col: 2, rowSpan: 1, colSpan: 1,
),
// Row 3-4: Three quad monitors
PhysicalMonitor(
id: 5,
viewerIds: [223, 224, 225, 226],
isQuadView: true,
row: 3, col: 3, rowSpan: 2, colSpan: 2,
),
PhysicalMonitor(
id: 6,
viewerIds: [227, 228, 229, 230],
isQuadView: true,
row: 3, col: 5, rowSpan: 2, colSpan: 2,
),
PhysicalMonitor(
id: 7,
viewerIds: [231, 232, 233, 234],
isQuadView: true,
row: 3, col: 7, rowSpan: 2, colSpan: 2,
),
],
),
// Levá část - 7 columns x 6 rows
WallSection(
id: 'left',
name: 'Levá část',
columns: 7,
rows: 6,
monitors: [
// Row 1-2: 3x2 monitor + two 2x2 quads
PhysicalMonitor(
id: 8,
viewerIds: [88, 89, 90, 91, 92, 93],
row: 1, col: 1, rowSpan: 2, colSpan: 3,
),
PhysicalMonitor(
id: 9,
viewerIds: [40, 41, 42, 43],
isQuadView: true,
row: 1, col: 4, rowSpan: 2, colSpan: 2,
),
PhysicalMonitor(
id: 10,
viewerIds: [44, 45, 46, 47],
isQuadView: true,
row: 1, col: 6, rowSpan: 2, colSpan: 2,
),
// Row 3-4: Two 2x2 quads
PhysicalMonitor(
id: 11,
viewerIds: [48, 49, 50, 51],
isQuadView: true,
row: 3, col: 4, rowSpan: 2, colSpan: 2,
),
PhysicalMonitor(
id: 12,
viewerIds: [52, 53, 54, 55],
isQuadView: true,
row: 3, col: 6, rowSpan: 2, colSpan: 2,
),
// Row 5-6: Two 2x2 quads
PhysicalMonitor(
id: 13,
viewerIds: [56, 57, 58, 59],
isQuadView: true,
row: 5, col: 4, rowSpan: 2, colSpan: 2,
),
PhysicalMonitor(
id: 14,
viewerIds: [60, 61, 62, 63],
isQuadView: true,
row: 5, col: 6, rowSpan: 2, colSpan: 2,
),
],
),
// Střed stěny - 8 columns x 4 rows
WallSection(
id: 'center',
name: 'Střed stěny',
columns: 8,
rows: 4,
monitors: [
// Row 1-2: Quad + 4 tall single monitors
PhysicalMonitor(
id: 15,
viewerIds: [14, 15, 16, 17],
isQuadView: true,
row: 1, col: 2, rowSpan: 2, colSpan: 2,
),
PhysicalMonitor(id: 16, viewerIds: [18], row: 1, col: 4, rowSpan: 2, colSpan: 1),
PhysicalMonitor(id: 17, viewerIds: [19], row: 1, col: 5, rowSpan: 2, colSpan: 1),
PhysicalMonitor(id: 18, viewerIds: [20], row: 1, col: 6, rowSpan: 2, colSpan: 1),
PhysicalMonitor(id: 19, viewerIds: [21], row: 1, col: 7, rowSpan: 2, colSpan: 1),
// Row 3-4: Four 2x2 quads
PhysicalMonitor(
id: 20,
viewerIds: [24, 25, 32, 33],
isQuadView: true,
row: 3, col: 1, rowSpan: 2, colSpan: 2,
),
PhysicalMonitor(
id: 21,
viewerIds: [26, 27, 34, 35],
isQuadView: true,
row: 3, col: 3, rowSpan: 2, colSpan: 2,
),
PhysicalMonitor(
id: 22,
viewerIds: [28, 29, 36, 37],
isQuadView: true,
row: 3, col: 5, rowSpan: 2, colSpan: 2,
),
PhysicalMonitor(
id: 23,
viewerIds: [30, 31, 38, 39],
isQuadView: true,
row: 3, col: 7, rowSpan: 2, colSpan: 2,
),
],
),
// Pravá část - 7 columns x 6 rows
WallSection(
id: 'right',
name: 'Pravá část',
columns: 7,
rows: 6,
monitors: [
// Row 1-2: Two 2x2 quads + 3x2 monitor
PhysicalMonitor(
id: 24,
viewerIds: [64, 65, 66, 67],
isQuadView: true,
row: 1, col: 1, rowSpan: 2, colSpan: 2,
),
PhysicalMonitor(
id: 25,
viewerIds: [68, 69, 70, 71],
isQuadView: true,
row: 1, col: 3, rowSpan: 2, colSpan: 2,
),
PhysicalMonitor(
id: 26,
viewerIds: [94, 95, 96, 97, 98, 99],
row: 1, col: 5, rowSpan: 2, colSpan: 3,
),
// Row 3-4: Two 2x2 quads
PhysicalMonitor(
id: 27,
viewerIds: [72, 73, 74, 75],
isQuadView: true,
row: 3, col: 1, rowSpan: 2, colSpan: 2,
),
PhysicalMonitor(
id: 28,
viewerIds: [76, 77, 78, 79],
isQuadView: true,
row: 3, col: 3, rowSpan: 2, colSpan: 2,
),
// Row 5-6: Two 2x2 quads
PhysicalMonitor(
id: 29,
viewerIds: [80, 81, 82, 83],
isQuadView: true,
row: 5, col: 1, rowSpan: 2, colSpan: 2,
),
PhysicalMonitor(
id: 30,
viewerIds: [84, 85, 86, 87],
isQuadView: true,
row: 5, col: 3, rowSpan: 2, colSpan: 2,
),
],
),
// Stupínek - 8 columns x 4 rows
WallSection(
id: 'bottom',
name: 'Stupínek',
columns: 8,
rows: 4,
monitors: [
// Row 1-2: Three 2x2 quads
PhysicalMonitor(
id: 31,
viewerIds: [183, 184, 185, 186],
isQuadView: true,
row: 1, col: 1, rowSpan: 2, colSpan: 2,
),
PhysicalMonitor(
id: 32,
viewerIds: [187, 188, 189, 190],
isQuadView: true,
row: 1, col: 3, rowSpan: 2, colSpan: 2,
),
PhysicalMonitor(
id: 33,
viewerIds: [191, 192, 193, 194],
isQuadView: true,
row: 1, col: 5, rowSpan: 2, colSpan: 2,
),
// Row 3-4: Two 2x2 quads
PhysicalMonitor(
id: 34,
viewerIds: [195, 196, 197, 198],
isQuadView: true,
row: 3, col: 5, rowSpan: 2, colSpan: 2,
),
PhysicalMonitor(
id: 35,
viewerIds: [199, 200, 201, 202],
isQuadView: true,
row: 3, col: 7, rowSpan: 2, colSpan: 2,
),
],
),
],
alarmMonitorIds: [222, 223, 224], // Example alarm monitors
);
}
@override
List<Object?> get props => [id, name, sections, alarmMonitorIds];
}

View File

@@ -0,0 +1,123 @@
import 'package:get_it/get_it.dart';
import 'config/app_config.dart';
import 'data/services/bridge_service.dart';
import 'data/services/alarm_service.dart';
import 'data/services/coordination_service.dart';
import 'data/services/function_button_service.dart';
import 'data/services/state_service.dart';
import 'presentation/blocs/connection/connection_bloc.dart';
import 'presentation/blocs/camera/camera_bloc.dart';
import 'presentation/blocs/monitor/monitor_bloc.dart';
import 'presentation/blocs/ptz/ptz_bloc.dart';
import 'presentation/blocs/alarm/alarm_bloc.dart';
import 'presentation/blocs/lock/lock_bloc.dart';
import 'presentation/blocs/sequence/sequence_bloc.dart';
import 'presentation/blocs/wall/wall_bloc.dart';
final sl = GetIt.instance;
/// Initialize all dependencies
Future<void> initializeDependencies() async {
// Config
final config = await AppConfig.load();
sl.registerSingleton<AppConfig>(config);
// Services
sl.registerLazySingleton<BridgeService>(() => BridgeService());
sl.registerLazySingleton<AlarmService>(() => AlarmService());
sl.registerLazySingleton<StateService>(() => StateService(
bridgeService: sl<BridgeService>(),
alarmService: sl<AlarmService>(),
));
sl.registerLazySingleton<CoordinationService>(() => CoordinationService());
sl.registerLazySingleton<FunctionButtonService>(() => FunctionButtonService(
bridgeService: sl<BridgeService>(),
coordinationService: sl<CoordinationService>(),
));
// BLoCs
sl.registerFactory<ConnectionBloc>(() => ConnectionBloc(
bridgeService: sl<BridgeService>(),
config: sl<AppConfig>(),
));
sl.registerFactory<CameraBloc>(() => CameraBloc(
bridgeService: sl<BridgeService>(),
config: sl<AppConfig>(),
));
sl.registerFactory<MonitorBloc>(() => MonitorBloc(
stateService: sl<StateService>(),
config: sl<AppConfig>(),
));
sl.registerFactory<PtzBloc>(() => PtzBloc(
bridgeService: sl<BridgeService>(),
coordinationService: sl<CoordinationService>(),
));
sl.registerFactory<AlarmBloc>(() => AlarmBloc(
alarmService: sl<AlarmService>(),
stateService: sl<StateService>(),
));
sl.registerFactory<LockBloc>(() => LockBloc(
coordinationService: sl<CoordinationService>(),
keyboardId: sl<AppConfig>().keyboardId,
));
sl.registerFactory<SequenceBloc>(() => SequenceBloc(
coordinationService: sl<CoordinationService>(),
));
sl.registerFactory<WallBloc>(() => WallBloc(
bridgeService: sl<BridgeService>(),
));
}
/// Initialize services (call after dependencies are set up)
Future<void> initializeServices() async {
final config = sl<AppConfig>();
final bridgeService = sl<BridgeService>();
final alarmService = sl<AlarmService>();
final stateService = sl<StateService>();
final coordinationService = sl<CoordinationService>();
// Initialize services with config
await bridgeService.initialize(config.servers);
await alarmService.initialize(config.servers);
await stateService.initialize();
await coordinationService.initialize(config.coordinatorUrl, config.keyboardId);
// Load function button config
sl<FunctionButtonService>().loadConfig(config.functionButtons);
// Wire reconnection callback: resync state when a bridge comes back online
bridgeService.onReconnected = (serverId) {
stateService.syncFromBridges();
};
// Connect to all bridges
await bridgeService.connectAll();
// Sync initial state
await stateService.syncFromBridges();
// Start periodic alarm sync
alarmService.startPeriodicSync(
Duration(seconds: config.alarmSyncIntervalSeconds),
);
// Connect to coordinator (non-blocking, auto-reconnects)
coordinationService.connect();
}
/// Dispose all services
void disposeServices() {
sl<CoordinationService>().dispose();
sl<AlarmService>().dispose();
sl<StateService>().dispose();
sl<BridgeService>().dispose();
}

View File

@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:logger/logger.dart';
import 'app.dart';
import 'injection_container.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final logger = Logger();
try {
// Initialize dependencies
logger.i('Initializing dependencies...');
await initializeDependencies();
// Initialize services and connect to bridges
logger.i('Initializing services...');
await initializeServices();
logger.i('Starting COPILOT Keyboard app...');
} catch (e, stackTrace) {
logger.e('Initialization failed', error: e, stackTrace: stackTrace);
// Continue anyway - app will show disconnected state
}
runApp(const CopilotKeyboardApp());
}

View File

@@ -0,0 +1,78 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../data/services/alarm_service.dart';
import '../../../data/services/state_service.dart';
import 'alarm_event.dart';
import 'alarm_state.dart';
class AlarmBloc extends Bloc<AlarmEvent, AlarmBlocState> {
final AlarmService _alarmService;
final StateService _stateService;
StreamSubscription? _alarmSubscription;
AlarmBloc({
required AlarmService alarmService,
required StateService stateService,
}) : _alarmService = alarmService,
_stateService = stateService,
super(const AlarmBlocState()) {
on<RefreshAlarms>(_onRefreshAlarms);
on<AlarmsUpdated>(_onAlarmsUpdated);
on<AcknowledgeAlarm>(_onAcknowledgeAlarm);
// Subscribe to alarm changes
_alarmSubscription = _alarmService.alarms.listen((alarms) {
add(AlarmsUpdated(alarms));
});
}
Future<void> _onRefreshAlarms(
RefreshAlarms event,
Emitter<AlarmBlocState> emit,
) async {
emit(state.copyWith(isLoading: true, error: null));
try {
await _alarmService.queryAllAlarms();
emit(state.copyWith(isLoading: false, lastSync: DateTime.now()));
} catch (e) {
emit(state.copyWith(
isLoading: false,
error: 'Failed to refresh alarms: $e',
));
}
}
void _onAlarmsUpdated(
AlarmsUpdated event,
Emitter<AlarmBlocState> emit,
) {
// Filter to only active alarms for display
final activeAlarms = event.alarms.where((a) => a.isActive).toList();
// Sort by start time (newest first)
activeAlarms.sort((a, b) => b.startedAt.compareTo(a.startedAt));
emit(state.copyWith(
activeAlarms: activeAlarms,
lastSync: DateTime.now(),
));
}
Future<void> _onAcknowledgeAlarm(
AcknowledgeAlarm event,
Emitter<AlarmBlocState> emit,
) async {
// Alarm acknowledgment would be implemented here
// This would call the bridge to acknowledge the alarm
// For now, just log that we received the event
}
@override
Future<void> close() {
_alarmSubscription?.cancel();
return super.close();
}
}

View File

@@ -0,0 +1,39 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/alarm_state.dart';
abstract class AlarmEvent extends Equatable {
const AlarmEvent();
@override
List<Object?> get props => [];
}
/// Refresh alarms from server
class RefreshAlarms extends AlarmEvent {
const RefreshAlarms();
}
/// Alarms updated (internal)
class AlarmsUpdated extends AlarmEvent {
final List<AlarmState> alarms;
const AlarmsUpdated(this.alarms);
@override
List<Object?> get props => [alarms];
}
/// Acknowledge an alarm
class AcknowledgeAlarm extends AlarmEvent {
final int alarmId;
final String serverId;
const AcknowledgeAlarm({
required this.alarmId,
required this.serverId,
});
@override
List<Object?> get props => [alarmId, serverId];
}

View File

@@ -0,0 +1,51 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/alarm_state.dart' as domain;
class AlarmBlocState extends Equatable {
final List<domain.AlarmState> activeAlarms;
final bool isLoading;
final String? error;
final DateTime? lastSync;
const AlarmBlocState({
this.activeAlarms = const [],
this.isLoading = false,
this.error,
this.lastSync,
});
/// Get count of active blocking alarms
int get blockingAlarmCount =>
activeAlarms.where((a) => a.blocksMonitor).length;
/// Get alarms for a specific monitor
List<domain.AlarmState> alarmsForMonitor(int monitorId) {
return activeAlarms
.where((a) => a.associatedMonitor == monitorId)
.toList();
}
/// Check if monitor has blocking alarm
bool monitorHasBlockingAlarm(int monitorId) {
return activeAlarms.any(
(a) => a.associatedMonitor == monitorId && a.blocksMonitor);
}
AlarmBlocState copyWith({
List<domain.AlarmState>? activeAlarms,
bool? isLoading,
String? error,
DateTime? lastSync,
}) {
return AlarmBlocState(
activeAlarms: activeAlarms ?? this.activeAlarms,
isLoading: isLoading ?? this.isLoading,
error: error,
lastSync: lastSync ?? this.lastSync,
);
}
@override
List<Object?> get props => [activeAlarms, isLoading, error, lastSync];
}

View File

@@ -0,0 +1,64 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../config/app_config.dart';
import '../../../data/services/bridge_service.dart';
import 'camera_event.dart';
import 'camera_state.dart';
class CameraBloc extends Bloc<CameraEvent, CameraState> {
final BridgeService _bridgeService;
final AppConfig _config;
CameraBloc({
required BridgeService bridgeService,
required AppConfig config,
}) : _bridgeService = bridgeService,
_config = config,
super(CameraState(availableCameras: config.allCameraIds)) {
on<SelectCamera>(_onSelectCamera);
on<ConnectCameraToMonitor>(_onConnectCameraToMonitor);
on<ClearCameraSelection>(_onClearCameraSelection);
}
void _onSelectCamera(
SelectCamera event,
Emitter<CameraState> emit,
) {
emit(state.copyWith(selectedCameraId: event.cameraId, error: null));
}
Future<void> _onConnectCameraToMonitor(
ConnectCameraToMonitor event,
Emitter<CameraState> emit,
) async {
emit(state.copyWith(isConnecting: true, error: null));
try {
final success = await _bridgeService.viewerConnectLive(
event.monitorId,
event.cameraId,
);
if (success) {
emit(state.copyWith(isConnecting: false));
} else {
emit(state.copyWith(
isConnecting: false,
error: 'Failed to connect camera ${event.cameraId} to monitor ${event.monitorId}',
));
}
} catch (e) {
emit(state.copyWith(
isConnecting: false,
error: e.toString(),
));
}
}
void _onClearCameraSelection(
ClearCameraSelection event,
Emitter<CameraState> emit,
) {
emit(state.copyWith(clearSelection: true, error: null));
}
}

View File

@@ -0,0 +1,37 @@
import 'package:equatable/equatable.dart';
abstract class CameraEvent extends Equatable {
const CameraEvent();
@override
List<Object?> get props => [];
}
/// Select a camera for viewing/control
class SelectCamera extends CameraEvent {
final int cameraId;
const SelectCamera(this.cameraId);
@override
List<Object?> get props => [cameraId];
}
/// Connect selected camera to a monitor
class ConnectCameraToMonitor extends CameraEvent {
final int cameraId;
final int monitorId;
const ConnectCameraToMonitor({
required this.cameraId,
required this.monitorId,
});
@override
List<Object?> get props => [cameraId, monitorId];
}
/// Clear selection
class ClearCameraSelection extends CameraEvent {
const ClearCameraSelection();
}

View File

@@ -0,0 +1,37 @@
import 'package:equatable/equatable.dart';
class CameraState extends Equatable {
final int? selectedCameraId;
final bool isConnecting;
final String? error;
final List<int> availableCameras;
const CameraState({
this.selectedCameraId,
this.isConnecting = false,
this.error,
this.availableCameras = const [],
});
bool get hasSelection => selectedCameraId != null;
CameraState copyWith({
int? selectedCameraId,
bool? isConnecting,
String? error,
List<int>? availableCameras,
bool clearSelection = false,
}) {
return CameraState(
selectedCameraId:
clearSelection ? null : (selectedCameraId ?? this.selectedCameraId),
isConnecting: isConnecting ?? this.isConnecting,
error: error,
availableCameras: availableCameras ?? this.availableCameras,
);
}
@override
List<Object?> get props =>
[selectedCameraId, isConnecting, error, availableCameras];
}

View File

@@ -0,0 +1,120 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../config/app_config.dart';
import '../../../data/services/bridge_service.dart';
import 'connection_event.dart';
import 'connection_state.dart';
class ConnectionBloc extends Bloc<ConnectionEvent, ConnectionState> {
final BridgeService _bridgeService;
final AppConfig _config;
StreamSubscription? _statusSubscription;
ConnectionBloc({
required BridgeService bridgeService,
required AppConfig config,
}) : _bridgeService = bridgeService,
_config = config,
super(const ConnectionState()) {
on<ConnectAll>(_onConnectAll);
on<ConnectServer>(_onConnectServer);
on<DisconnectServer>(_onDisconnectServer);
on<DisconnectAll>(_onDisconnectAll);
on<RetryConnections>(_onRetryConnections);
on<ConnectionStatusUpdated>(_onConnectionStatusUpdated);
// Subscribe to connection status changes
_statusSubscription = _bridgeService.connectionStatus.listen((status) {
add(ConnectionStatusUpdated(status));
});
}
Future<void> _onConnectAll(
ConnectAll event,
Emitter<ConnectionState> emit,
) async {
emit(state.copyWith(overallStatus: ConnectionOverallStatus.connecting));
try {
await _bridgeService.connectAll();
} catch (e) {
emit(state.copyWith(
overallStatus: ConnectionOverallStatus.disconnected,
error: e.toString(),
));
}
}
Future<void> _onConnectServer(
ConnectServer event,
Emitter<ConnectionState> emit,
) async {
try {
await _bridgeService.connect(event.serverId);
} catch (e) {
emit(state.copyWith(error: 'Failed to connect to ${event.serverId}: $e'));
}
}
Future<void> _onDisconnectServer(
DisconnectServer event,
Emitter<ConnectionState> emit,
) async {
await _bridgeService.disconnect(event.serverId);
}
Future<void> _onDisconnectAll(
DisconnectAll event,
Emitter<ConnectionState> emit,
) async {
await _bridgeService.disconnectAll();
emit(state.copyWith(overallStatus: ConnectionOverallStatus.disconnected));
}
Future<void> _onRetryConnections(
RetryConnections event,
Emitter<ConnectionState> emit,
) async {
// Retry only disconnected servers
final disconnected = state.serverStatus.entries
.where((e) => !e.value)
.map((e) => e.key)
.toList();
for (final serverId in disconnected) {
await _bridgeService.connect(serverId);
}
}
void _onConnectionStatusUpdated(
ConnectionStatusUpdated event,
Emitter<ConnectionState> emit,
) {
final status = event.status;
ConnectionOverallStatus overall;
if (status.isEmpty) {
overall = ConnectionOverallStatus.disconnected;
} else if (status.values.every((v) => v)) {
overall = ConnectionOverallStatus.connected;
} else if (status.values.any((v) => v)) {
overall = ConnectionOverallStatus.partial;
} else {
overall = ConnectionOverallStatus.disconnected;
}
emit(state.copyWith(
overallStatus: overall,
serverStatus: status,
error: null,
));
}
@override
Future<void> close() {
_statusSubscription?.cancel();
return super.close();
}
}

View File

@@ -0,0 +1,53 @@
import 'package:equatable/equatable.dart';
abstract class ConnectionEvent extends Equatable {
const ConnectionEvent();
@override
List<Object?> get props => [];
}
/// Connect to all servers
class ConnectAll extends ConnectionEvent {
const ConnectAll();
}
/// Connect to a specific server
class ConnectServer extends ConnectionEvent {
final String serverId;
const ConnectServer(this.serverId);
@override
List<Object?> get props => [serverId];
}
/// Disconnect from a specific server
class DisconnectServer extends ConnectionEvent {
final String serverId;
const DisconnectServer(this.serverId);
@override
List<Object?> get props => [serverId];
}
/// Disconnect from all servers
class DisconnectAll extends ConnectionEvent {
const DisconnectAll();
}
/// Retry failed connections
class RetryConnections extends ConnectionEvent {
const RetryConnections();
}
/// Connection status updated (internal)
class ConnectionStatusUpdated extends ConnectionEvent {
final Map<String, bool> status;
const ConnectionStatusUpdated(this.status);
@override
List<Object?> get props => [status];
}

View File

@@ -0,0 +1,43 @@
import 'package:equatable/equatable.dart';
enum ConnectionOverallStatus { disconnected, connecting, connected, partial }
class ConnectionState extends Equatable {
final ConnectionOverallStatus overallStatus;
final Map<String, bool> serverStatus;
final String? error;
const ConnectionState({
this.overallStatus = ConnectionOverallStatus.disconnected,
this.serverStatus = const {},
this.error,
});
/// Check if all servers are connected
bool get allConnected =>
serverStatus.isNotEmpty && serverStatus.values.every((v) => v);
/// Check if any server is connected
bool get anyConnected => serverStatus.values.any((v) => v);
/// Get count of connected servers
int get connectedCount => serverStatus.values.where((v) => v).length;
/// Get count of total servers
int get totalCount => serverStatus.length;
ConnectionState copyWith({
ConnectionOverallStatus? overallStatus,
Map<String, bool>? serverStatus,
String? error,
}) {
return ConnectionState(
overallStatus: overallStatus ?? this.overallStatus,
serverStatus: serverStatus ?? this.serverStatus,
error: error,
);
}
@override
List<Object?> get props => [overallStatus, serverStatus, error];
}

View File

@@ -0,0 +1,168 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../data/services/coordination_service.dart';
import '../../../domain/entities/camera_lock.dart';
import 'lock_event.dart';
import 'lock_state.dart';
class LockBloc extends Bloc<LockEvent, LockState> {
final CoordinationService _coordinationService;
StreamSubscription? _locksSub;
StreamSubscription? _notifSub;
StreamSubscription? _connSub;
LockBloc({
required CoordinationService coordinationService,
required String keyboardId,
}) : _coordinationService = coordinationService,
super(const LockState()) {
on<TryLock>(_onTryLock);
on<ReleaseLock>(_onReleaseLock);
on<ReleaseAllLocks>(_onReleaseAllLocks);
on<RequestTakeover>(_onRequestTakeover);
on<ConfirmTakeover>(_onConfirmTakeover);
on<ResetLockExpiration>(_onResetLockExpiration);
on<LocksUpdated>(_onLocksUpdated);
on<LockNotificationReceived>(_onLockNotificationReceived);
on<CoordinatorConnectionChanged>(_onCoordinatorConnectionChanged);
// Subscribe to coordinator streams
_locksSub = _coordinationService.locks.listen((locks) {
add(LocksUpdated(locks));
});
_notifSub = _coordinationService.notifications.listen((notification) {
if (notification != null) {
add(LockNotificationReceived(notification));
}
});
_connSub = _coordinationService.connected.listen((connected) {
add(CoordinatorConnectionChanged(connected));
});
}
Future<void> _onTryLock(TryLock event, Emitter<LockState> emit) async {
final result = await _coordinationService.tryLock(
event.cameraId,
priority: event.priority,
);
if (!result.acquired) {
final lock = result.lock;
final owner = lock?.ownerName ?? 'unknown';
emit(state.copyWith(
lastNotification: 'Camera ${event.cameraId} locked by $owner',
));
}
}
Future<void> _onReleaseLock(
ReleaseLock event, Emitter<LockState> emit) async {
await _coordinationService.releaseLock(event.cameraId);
}
Future<void> _onReleaseAllLocks(
ReleaseAllLocks event, Emitter<LockState> emit) async {
final myLocks = await _coordinationService.getMyLockedCameras();
for (final cameraId in myLocks) {
await _coordinationService.releaseLock(cameraId);
}
}
Future<void> _onRequestTakeover(
RequestTakeover event, Emitter<LockState> emit) async {
final success = await _coordinationService.requestTakeover(
event.cameraId,
priority: event.priority,
);
if (success) {
emit(state.copyWith(
lastNotification: 'Takeover requested for camera ${event.cameraId}',
));
}
}
Future<void> _onConfirmTakeover(
ConfirmTakeover event, Emitter<LockState> emit) async {
await _coordinationService.confirmTakeover(
event.cameraId, event.confirm);
emit(state.copyWith(clearPendingTakeover: true));
}
Future<void> _onResetLockExpiration(
ResetLockExpiration event, Emitter<LockState> emit) async {
await _coordinationService.resetExpiration(event.cameraId);
}
void _onLocksUpdated(LocksUpdated event, Emitter<LockState> emit) {
emit(state.copyWith(locks: event.locks));
}
void _onLockNotificationReceived(
LockNotificationReceived event, Emitter<LockState> emit) {
final notification = event.notification;
switch (notification.type) {
case CameraLockNotificationType.confirmTakeOver:
// Show takeover confirmation dialog
emit(state.copyWith(
pendingTakeover: TakeoverRequest(
cameraId: notification.cameraId,
requestingKeyboard: notification.copilotName,
),
));
break;
case CameraLockNotificationType.takenOver:
emit(state.copyWith(
lastNotification:
'Camera ${notification.cameraId} taken over by ${notification.copilotName}',
));
break;
case CameraLockNotificationType.expireSoon:
emit(state.copyWith(
lastNotification:
'Lock on camera ${notification.cameraId} expiring soon',
));
break;
case CameraLockNotificationType.confirmed:
emit(state.copyWith(
lastNotification:
'Takeover confirmed for camera ${notification.cameraId}',
));
break;
case CameraLockNotificationType.rejected:
emit(state.copyWith(
lastNotification:
'Takeover rejected for camera ${notification.cameraId}',
));
break;
case CameraLockNotificationType.unlocked:
case CameraLockNotificationType.acquired:
// Handled by lock state updates
break;
}
}
void _onCoordinatorConnectionChanged(
CoordinatorConnectionChanged event, Emitter<LockState> emit) {
emit(state.copyWith(coordinatorConnected: event.connected));
}
@override
Future<void> close() {
_locksSub?.cancel();
_notifSub?.cancel();
_connSub?.cancel();
return super.close();
}
}

View File

@@ -0,0 +1,60 @@
import '../../../domain/entities/camera_lock.dart';
abstract class LockEvent {}
/// Try to acquire a lock on a camera
class TryLock extends LockEvent {
final int cameraId;
final CameraLockPriority priority;
TryLock(this.cameraId, {this.priority = CameraLockPriority.low});
}
/// Release a camera lock
class ReleaseLock extends LockEvent {
final int cameraId;
ReleaseLock(this.cameraId);
}
/// Release all locks held by this keyboard
class ReleaseAllLocks extends LockEvent {}
/// Request takeover of a camera locked by another keyboard
class RequestTakeover extends LockEvent {
final int cameraId;
final CameraLockPriority priority;
RequestTakeover(this.cameraId, {this.priority = CameraLockPriority.low});
}
/// Confirm or reject a takeover request from another keyboard
class ConfirmTakeover extends LockEvent {
final int cameraId;
final bool confirm;
ConfirmTakeover(this.cameraId, {required this.confirm});
}
/// Reset lock expiration (keep-alive during PTZ)
class ResetLockExpiration extends LockEvent {
final int cameraId;
ResetLockExpiration(this.cameraId);
}
/// Internal: lock state updated from coordinator WebSocket
class LocksUpdated extends LockEvent {
final Map<int, CameraLock> locks;
LocksUpdated(this.locks);
}
/// Internal: lock notification received from coordinator
class LockNotificationReceived extends LockEvent {
final CameraLockNotification notification;
LockNotificationReceived(this.notification);
}
/// Internal: coordinator connection status changed
class CoordinatorConnectionChanged extends LockEvent {
final bool connected;
CoordinatorConnectionChanged(this.connected);
}

View File

@@ -0,0 +1,75 @@
import '../../../domain/entities/camera_lock.dart';
class LockState {
/// All known camera locks
final Map<int, CameraLock> locks;
/// Whether the coordinator is connected
final bool coordinatorConnected;
/// Pending takeover confirmation request (show dialog to user)
final TakeoverRequest? pendingTakeover;
/// Last notification message (for snackbar/toast)
final String? lastNotification;
/// Error message
final String? error;
const LockState({
this.locks = const {},
this.coordinatorConnected = false,
this.pendingTakeover,
this.lastNotification,
this.error,
});
LockState copyWith({
Map<int, CameraLock>? locks,
bool? coordinatorConnected,
TakeoverRequest? pendingTakeover,
bool clearPendingTakeover = false,
String? lastNotification,
bool clearNotification = false,
String? error,
bool clearError = false,
}) {
return LockState(
locks: locks ?? this.locks,
coordinatorConnected: coordinatorConnected ?? this.coordinatorConnected,
pendingTakeover:
clearPendingTakeover ? null : (pendingTakeover ?? this.pendingTakeover),
lastNotification:
clearNotification ? null : (lastNotification ?? this.lastNotification),
error: clearError ? null : (error ?? this.error),
);
}
/// Check if a camera is locked by this keyboard
bool isCameraLockedByMe(int cameraId, String keyboardId) {
final lock = locks[cameraId];
return lock != null &&
lock.ownerName.toLowerCase() == keyboardId.toLowerCase();
}
/// Check if a camera is locked by another keyboard
bool isCameraLockedByOther(int cameraId, String keyboardId) {
final lock = locks[cameraId];
return lock != null &&
lock.ownerName.toLowerCase() != keyboardId.toLowerCase();
}
/// Get the lock for a camera, if any
CameraLock? getLock(int cameraId) => locks[cameraId];
}
/// Pending takeover request shown as a dialog
class TakeoverRequest {
final int cameraId;
final String requestingKeyboard;
const TakeoverRequest({
required this.cameraId,
required this.requestingKeyboard,
});
}

View File

@@ -0,0 +1,72 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../config/app_config.dart';
import '../../../data/services/state_service.dart';
import '../../../data/services/bridge_service.dart';
import '../../../injection_container.dart';
import 'monitor_event.dart';
import 'monitor_state.dart';
class MonitorBloc extends Bloc<MonitorEvent, MonitorBlocState> {
final StateService _stateService;
final AppConfig _config;
StreamSubscription? _stateSubscription;
MonitorBloc({
required StateService stateService,
required AppConfig config,
}) : _stateService = stateService,
_config = config,
super(MonitorBlocState(availableMonitors: config.allMonitorIds)) {
on<SelectMonitor>(_onSelectMonitor);
on<ClearMonitor>(_onClearMonitor);
on<ClearMonitorSelection>(_onClearMonitorSelection);
on<MonitorStatesUpdated>(_onMonitorStatesUpdated);
// Subscribe to monitor state changes
_stateSubscription = _stateService.combinedMonitorStates.listen((states) {
add(MonitorStatesUpdated(states));
});
}
void _onSelectMonitor(
SelectMonitor event,
Emitter<MonitorBlocState> emit,
) {
emit(state.copyWith(selectedMonitorId: event.monitorId, error: null));
}
Future<void> _onClearMonitor(
ClearMonitor event,
Emitter<MonitorBlocState> emit,
) async {
try {
final bridgeService = sl<BridgeService>();
await bridgeService.viewerClear(event.monitorId);
} catch (e) {
emit(state.copyWith(error: 'Failed to clear monitor: $e'));
}
}
void _onClearMonitorSelection(
ClearMonitorSelection event,
Emitter<MonitorBlocState> emit,
) {
emit(state.copyWith(clearSelection: true, error: null));
}
void _onMonitorStatesUpdated(
MonitorStatesUpdated event,
Emitter<MonitorBlocState> emit,
) {
emit(state.copyWith(monitorStates: event.states));
}
@override
Future<void> close() {
_stateSubscription?.cancel();
return super.close();
}
}

View File

@@ -0,0 +1,45 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/monitor_state.dart';
abstract class MonitorEvent extends Equatable {
const MonitorEvent();
@override
List<Object?> get props => [];
}
/// Select a monitor for camera switching
class SelectMonitor extends MonitorEvent {
final int monitorId;
const SelectMonitor(this.monitorId);
@override
List<Object?> get props => [monitorId];
}
/// Clear the selected monitor
class ClearMonitor extends MonitorEvent {
final int monitorId;
const ClearMonitor(this.monitorId);
@override
List<Object?> get props => [monitorId];
}
/// Clear selection
class ClearMonitorSelection extends MonitorEvent {
const ClearMonitorSelection();
}
/// Monitor states updated (internal)
class MonitorStatesUpdated extends MonitorEvent {
final Map<int, MonitorState> states;
const MonitorStatesUpdated(this.states);
@override
List<Object?> get props => [states];
}

View File

@@ -0,0 +1,50 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/monitor_state.dart' as domain;
class MonitorBlocState extends Equatable {
final int? selectedMonitorId;
final Map<int, domain.MonitorState> monitorStates;
final List<int> availableMonitors;
final String? error;
const MonitorBlocState({
this.selectedMonitorId,
this.monitorStates = const {},
this.availableMonitors = const [],
this.error,
});
bool get hasSelection => selectedMonitorId != null;
/// Get the currently selected monitor's state
domain.MonitorState? get selectedMonitorState {
if (selectedMonitorId == null) return null;
return monitorStates[selectedMonitorId];
}
/// Get the camera currently on the selected monitor
int? get selectedMonitorCamera {
return selectedMonitorState?.currentChannel;
}
MonitorBlocState copyWith({
int? selectedMonitorId,
Map<int, domain.MonitorState>? monitorStates,
List<int>? availableMonitors,
String? error,
bool clearSelection = false,
}) {
return MonitorBlocState(
selectedMonitorId:
clearSelection ? null : (selectedMonitorId ?? this.selectedMonitorId),
monitorStates: monitorStates ?? this.monitorStates,
availableMonitors: availableMonitors ?? this.availableMonitors,
error: error,
);
}
@override
List<Object?> get props =>
[selectedMonitorId, monitorStates, availableMonitors, error];
}

View File

@@ -0,0 +1,189 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../data/services/bridge_service.dart';
import '../../../data/services/coordination_service.dart';
import 'ptz_event.dart';
import 'ptz_state.dart';
class PtzBloc extends Bloc<PtzEvent, PtzState> {
final BridgeService _bridgeService;
final CoordinationService _coordinationService;
Timer? _lockResetTimer;
PtzBloc({
required BridgeService bridgeService,
required CoordinationService coordinationService,
}) : _bridgeService = bridgeService,
_coordinationService = coordinationService,
super(const PtzState()) {
on<PtzPanStart>(_onPanStart);
on<PtzTiltStart>(_onTiltStart);
on<PtzZoomStart>(_onZoomStart);
on<PtzStop>(_onStop);
on<PtzGoToPreset>(_onGoToPreset);
on<PtzSetCamera>(_onSetCamera);
}
/// Ensure we have a lock on the camera before PTZ movement.
/// Returns true if lock was acquired or already held.
Future<bool> _ensureLock(int cameraId, Emitter<PtzState> emit) async {
// Already locked by us
if (_coordinationService.isCameraLockedByMe(cameraId)) {
emit(state.copyWith(lockStatus: PtzLockStatus.locked));
return true;
}
emit(state.copyWith(lockStatus: PtzLockStatus.acquiring));
final result = await _coordinationService.tryLock(cameraId);
if (result.acquired) {
emit(state.copyWith(lockStatus: PtzLockStatus.locked));
_startLockResetTimer(cameraId);
return true;
}
// Lock denied — someone else has it
emit(state.copyWith(
lockStatus: PtzLockStatus.denied,
lockedBy: result.lock?.ownerName,
error: 'Camera locked by ${result.lock?.ownerName ?? "another keyboard"}',
));
return false;
}
void _startLockResetTimer(int cameraId) {
_lockResetTimer?.cancel();
_lockResetTimer = Timer.periodic(const Duration(minutes: 2), (_) {
if (_coordinationService.isCameraLockedByMe(cameraId)) {
_coordinationService.resetExpiration(cameraId);
} else {
_lockResetTimer?.cancel();
}
});
}
Future<void> _onPanStart(
PtzPanStart event,
Emitter<PtzState> emit,
) async {
emit(state.copyWith(activeCameraId: event.cameraId));
if (!await _ensureLock(event.cameraId, emit)) return;
final direction =
event.direction == 'left' ? PtzDirection.left : PtzDirection.right;
emit(state.copyWith(
currentDirection: direction,
isMoving: true,
error: null,
));
try {
await _bridgeService.ptzPan(event.cameraId, event.direction, event.speed);
} catch (e) {
emit(state.copyWith(error: e.toString(), isMoving: false));
}
}
Future<void> _onTiltStart(
PtzTiltStart event,
Emitter<PtzState> emit,
) async {
emit(state.copyWith(activeCameraId: event.cameraId));
if (!await _ensureLock(event.cameraId, emit)) return;
final direction =
event.direction == 'up' ? PtzDirection.up : PtzDirection.down;
emit(state.copyWith(
currentDirection: direction,
isMoving: true,
error: null,
));
try {
await _bridgeService.ptzTilt(
event.cameraId, event.direction, event.speed);
} catch (e) {
emit(state.copyWith(error: e.toString(), isMoving: false));
}
}
Future<void> _onZoomStart(
PtzZoomStart event,
Emitter<PtzState> emit,
) async {
emit(state.copyWith(activeCameraId: event.cameraId));
if (!await _ensureLock(event.cameraId, emit)) return;
final direction =
event.direction == 'in' ? PtzDirection.zoomIn : PtzDirection.zoomOut;
emit(state.copyWith(
currentDirection: direction,
isMoving: true,
error: null,
));
try {
await _bridgeService.ptzZoom(
event.cameraId, event.direction, event.speed);
} catch (e) {
emit(state.copyWith(error: e.toString(), isMoving: false));
}
}
Future<void> _onStop(
PtzStop event,
Emitter<PtzState> emit,
) async {
emit(state.copyWith(
currentDirection: PtzDirection.none,
isMoving: false,
error: null,
));
try {
await _bridgeService.ptzStop(event.cameraId);
} catch (e) {
emit(state.copyWith(error: e.toString()));
}
}
Future<void> _onGoToPreset(
PtzGoToPreset event,
Emitter<PtzState> emit,
) async {
if (!await _ensureLock(event.cameraId, emit)) return;
emit(state.copyWith(error: null));
try {
await _bridgeService.ptzPreset(event.cameraId, event.preset);
} catch (e) {
emit(state.copyWith(error: e.toString()));
}
}
void _onSetCamera(
PtzSetCamera event,
Emitter<PtzState> emit,
) {
if (event.cameraId == null) {
_lockResetTimer?.cancel();
emit(state.copyWith(
clearCamera: true, lockStatus: PtzLockStatus.none));
} else {
emit(state.copyWith(activeCameraId: event.cameraId));
}
}
@override
Future<void> close() {
_lockResetTimer?.cancel();
return super.close();
}
}

View File

@@ -0,0 +1,90 @@
import 'package:equatable/equatable.dart';
abstract class PtzEvent extends Equatable {
const PtzEvent();
@override
List<Object?> get props => [];
}
/// Start panning
class PtzPanStart extends PtzEvent {
final int cameraId;
final String direction; // 'left' or 'right'
final int speed;
const PtzPanStart({
required this.cameraId,
required this.direction,
this.speed = 50,
});
@override
List<Object?> get props => [cameraId, direction, speed];
}
/// Start tilting
class PtzTiltStart extends PtzEvent {
final int cameraId;
final String direction; // 'up' or 'down'
final int speed;
const PtzTiltStart({
required this.cameraId,
required this.direction,
this.speed = 50,
});
@override
List<Object?> get props => [cameraId, direction, speed];
}
/// Start zooming
class PtzZoomStart extends PtzEvent {
final int cameraId;
final String direction; // 'in' or 'out'
final int speed;
const PtzZoomStart({
required this.cameraId,
required this.direction,
this.speed = 50,
});
@override
List<Object?> get props => [cameraId, direction, speed];
}
/// Stop all PTZ movement
class PtzStop extends PtzEvent {
final int cameraId;
const PtzStop(this.cameraId);
@override
List<Object?> get props => [cameraId];
}
/// Go to preset
class PtzGoToPreset extends PtzEvent {
final int cameraId;
final int preset;
const PtzGoToPreset({
required this.cameraId,
required this.preset,
});
@override
List<Object?> get props => [cameraId, preset];
}
/// Set camera for PTZ control
class PtzSetCamera extends PtzEvent {
final int? cameraId;
const PtzSetCamera(this.cameraId);
@override
List<Object?> get props => [cameraId];
}

View File

@@ -0,0 +1,50 @@
import 'package:equatable/equatable.dart';
enum PtzDirection { none, left, right, up, down, zoomIn, zoomOut }
enum PtzLockStatus { none, acquiring, locked, denied }
class PtzState extends Equatable {
final int? activeCameraId;
final PtzDirection currentDirection;
final bool isMoving;
final PtzLockStatus lockStatus;
final String? lockedBy;
final String? error;
const PtzState({
this.activeCameraId,
this.currentDirection = PtzDirection.none,
this.isMoving = false,
this.lockStatus = PtzLockStatus.none,
this.lockedBy,
this.error,
});
bool get hasActiveCamera => activeCameraId != null;
bool get hasLock => lockStatus == PtzLockStatus.locked;
PtzState copyWith({
int? activeCameraId,
PtzDirection? currentDirection,
bool? isMoving,
PtzLockStatus? lockStatus,
String? lockedBy,
String? error,
bool clearCamera = false,
}) {
return PtzState(
activeCameraId:
clearCamera ? null : (activeCameraId ?? this.activeCameraId),
currentDirection: currentDirection ?? this.currentDirection,
isMoving: isMoving ?? this.isMoving,
lockStatus: lockStatus ?? this.lockStatus,
lockedBy: lockedBy ?? this.lockedBy,
error: error,
);
}
@override
List<Object?> get props =>
[activeCameraId, currentDirection, isMoving, lockStatus, lockedBy, error];
}

View File

@@ -0,0 +1,90 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../data/services/coordination_service.dart';
import '../../../domain/entities/sequence.dart';
import 'sequence_event.dart';
import 'sequence_state.dart';
class SequenceBloc extends Bloc<SequenceEvent, SequenceState> {
final CoordinationService _coordinationService;
SequenceBloc({required CoordinationService coordinationService})
: _coordinationService = coordinationService,
super(const SequenceState()) {
on<LoadSequences>(_onLoadSequences);
on<StartSequence>(_onStartSequence);
on<StopSequence>(_onStopSequence);
on<SelectCategory>(_onSelectCategory);
}
Future<void> _onLoadSequences(
LoadSequences event, Emitter<SequenceState> emit) async {
emit(state.copyWith(isLoading: true, clearError: true));
try {
final sequencesJson = await _coordinationService.getSequences();
final categoriesJson = await _coordinationService.getSequenceCategories();
final runningJson = await _coordinationService.getRunningSequences();
final sequences = sequencesJson
.map((j) => SequenceDefinition.fromJson(j))
.toList();
final categories =
categoriesJson.map((j) => SequenceCategory.fromJson(j)).toList();
final running = <int, RunningSequence>{};
for (final j in runningJson) {
final rs = RunningSequence.fromJson(j);
running[rs.viewerId] = rs;
}
emit(state.copyWith(
sequences: sequences,
categories: categories,
running: running,
isLoading: false,
));
} catch (e) {
emit(state.copyWith(isLoading: false, error: e.toString()));
}
}
Future<void> _onStartSequence(
StartSequence event, Emitter<SequenceState> emit) async {
try {
final result = await _coordinationService.startSequence(
event.viewerId, event.sequenceId);
if (result != null) {
final rs = RunningSequence.fromJson(result);
final running = Map<int, RunningSequence>.from(state.running);
running[rs.viewerId] = rs;
emit(state.copyWith(running: running));
}
} catch (e) {
emit(state.copyWith(error: e.toString()));
}
}
Future<void> _onStopSequence(
StopSequence event, Emitter<SequenceState> emit) async {
try {
await _coordinationService.stopSequence(event.viewerId);
final running = Map<int, RunningSequence>.from(state.running);
running.remove(event.viewerId);
emit(state.copyWith(running: running));
} catch (e) {
emit(state.copyWith(error: e.toString()));
}
}
void _onSelectCategory(
SelectCategory event, Emitter<SequenceState> emit) {
if (event.categoryId == null) {
emit(state.copyWith(clearCategory: true));
} else {
emit(state.copyWith(selectedCategoryId: event.categoryId));
}
}
}

View File

@@ -0,0 +1,26 @@
abstract class SequenceEvent {}
/// Load available sequences and categories from coordinator
class LoadSequences extends SequenceEvent {}
/// Start a sequence on a viewer
class StartSequence extends SequenceEvent {
final int viewerId;
final int sequenceId;
StartSequence({required this.viewerId, required this.sequenceId});
}
/// Stop a sequence on a viewer
class StopSequence extends SequenceEvent {
final int viewerId;
StopSequence(this.viewerId);
}
/// Filter sequences by category
class SelectCategory extends SequenceEvent {
final int? categoryId;
SelectCategory(this.categoryId);
}

View File

@@ -0,0 +1,55 @@
import '../../../domain/entities/sequence.dart';
class SequenceState {
final List<SequenceDefinition> sequences;
final List<SequenceCategory> categories;
final Map<int, RunningSequence> running; // viewerId -> RunningSequence
final int? selectedCategoryId;
final bool isLoading;
final String? error;
const SequenceState({
this.sequences = const [],
this.categories = const [],
this.running = const {},
this.selectedCategoryId,
this.isLoading = false,
this.error,
});
/// Sequences filtered by selected category
List<SequenceDefinition> get filteredSequences {
if (selectedCategoryId == null) return sequences;
return sequences
.where((s) => s.categoryId == selectedCategoryId)
.toList();
}
/// Check if a sequence is running on a viewer
bool isRunningOnViewer(int viewerId) => running.containsKey(viewerId);
/// Get running sequence for a viewer
RunningSequence? getRunning(int viewerId) => running[viewerId];
SequenceState copyWith({
List<SequenceDefinition>? sequences,
List<SequenceCategory>? categories,
Map<int, RunningSequence>? running,
int? selectedCategoryId,
bool clearCategory = false,
bool? isLoading,
String? error,
bool clearError = false,
}) {
return SequenceState(
sequences: sequences ?? this.sequences,
categories: categories ?? this.categories,
running: running ?? this.running,
selectedCategoryId: clearCategory
? null
: (selectedCategoryId ?? this.selectedCategoryId),
isLoading: isLoading ?? this.isLoading,
error: clearError ? null : (error ?? this.error),
);
}
}

View File

@@ -0,0 +1,237 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../data/services/bridge_service.dart';
import '../../../domain/entities/wall_config.dart';
import 'wall_event.dart';
import 'wall_state.dart';
class WallBloc extends Bloc<WallEvent, WallState> {
final BridgeService _bridgeService;
Timer? _editTimeoutTimer;
/// Legacy cancel timeout: 5 seconds of inactivity cancels edit mode.
static const _editTimeout = Duration(seconds: 5);
WallBloc({required BridgeService bridgeService})
: _bridgeService = bridgeService,
super(const WallState()) {
on<LoadWallConfig>(_onLoadWallConfig);
on<SelectViewer>(_onSelectViewer);
on<DeselectViewer>(_onDeselectViewer);
on<SetCameraPrefix>(_onSetCameraPrefix);
on<AddCameraDigit>(_onAddCameraDigit);
on<BackspaceCameraDigit>(_onBackspaceCameraDigit);
on<CancelCameraEdit>(_onCancelCameraEdit);
on<CycleCameraPrefix>(_onCycleCameraPrefix);
on<ExecuteCrossSwitch>(_onExecuteCrossSwitch);
on<UpdateViewerCamera>(_onUpdateViewerCamera);
on<SetViewerAlarm>(_onSetViewerAlarm);
on<SetViewerLock>(_onSetViewerLock);
on<ToggleSectionExpanded>(_onToggleSectionExpanded);
}
@override
Future<void> close() {
_editTimeoutTimer?.cancel();
return super.close();
}
void _restartEditTimeout() {
_editTimeoutTimer?.cancel();
_editTimeoutTimer = Timer(_editTimeout, () {
add(const CancelCameraEdit());
});
}
void _cancelEditTimeout() {
_editTimeoutTimer?.cancel();
}
void _onLoadWallConfig(LoadWallConfig event, Emitter<WallState> emit) {
emit(state.copyWith(isLoading: true, clearError: true));
try {
// Use provided config or load sample
final config = event.config ?? WallConfig.sample();
// Initialize viewer states for all viewers
final viewerStates = <int, ViewerState>{};
for (final viewerId in config.allViewerIds) {
viewerStates[viewerId] = ViewerState(viewerId: viewerId);
}
// Expand all sections by default
final expandedSections = config.sections.map((s) => s.id).toSet();
emit(state.copyWith(
config: config,
isLoading: false,
viewerStates: viewerStates,
expandedSections: expandedSections,
));
} catch (e) {
emit(state.copyWith(
isLoading: false,
error: 'Failed to load wall config: $e',
));
}
}
void _onSelectViewer(SelectViewer event, Emitter<WallState> emit) {
if (state.config == null) return;
// Find the physical monitor containing this viewer
final monitor = state.config!.findMonitorByViewerId(event.viewerId);
_cancelEditTimeout();
emit(state.copyWith(
selectedViewerId: event.viewerId,
selectedPhysicalMonitorId: monitor?.id,
cameraNumberInput: '',
isEditing: false,
));
}
void _onDeselectViewer(DeselectViewer event, Emitter<WallState> emit) {
_cancelEditTimeout();
emit(state.copyWith(
clearSelection: true,
cameraNumberInput: '',
isEditing: false,
));
}
void _onSetCameraPrefix(SetCameraPrefix event, Emitter<WallState> emit) {
if (event.prefix != 500 && event.prefix != 501 && event.prefix != 502) {
return;
}
emit(state.copyWith(cameraPrefix: event.prefix));
}
void _onAddCameraDigit(AddCameraDigit event, Emitter<WallState> emit) {
if (event.digit < 0 || event.digit > 9) return;
if (state.selectedViewerId == null) return;
if (state.cameraNumberInput.length >= 6) return; // Max 6 digits (legacy)
_restartEditTimeout();
emit(state.copyWith(
cameraNumberInput: state.cameraNumberInput + event.digit.toString(),
isEditing: true,
));
}
void _onBackspaceCameraDigit(
BackspaceCameraDigit event, Emitter<WallState> emit) {
if (state.cameraNumberInput.isEmpty) return;
final newInput = state.cameraNumberInput
.substring(0, state.cameraNumberInput.length - 1);
if (newInput.isEmpty) {
_cancelEditTimeout();
emit(state.copyWith(cameraNumberInput: '', isEditing: false));
} else {
_restartEditTimeout();
emit(state.copyWith(cameraNumberInput: newInput));
}
}
void _onCancelCameraEdit(CancelCameraEdit event, Emitter<WallState> emit) {
_cancelEditTimeout();
emit(state.copyWith(cameraNumberInput: '', isEditing: false));
}
void _onCycleCameraPrefix(
CycleCameraPrefix event, Emitter<WallState> emit) {
const prefixes = [500, 501, 502];
final idx = prefixes.indexOf(state.cameraPrefix);
final next = prefixes[(idx + 1) % prefixes.length];
emit(state.copyWith(cameraPrefix: next));
}
Future<void> _onExecuteCrossSwitch(
ExecuteCrossSwitch event, Emitter<WallState> emit) async {
print('CrossSwitch: canExecute=${state.canExecuteCrossSwitch}, selectedViewer=${state.selectedViewerId}, cameraInput=${state.cameraNumberInput}, fullCamera=${state.fullCameraNumber}');
if (!state.canExecuteCrossSwitch) {
print('CrossSwitch: Cannot execute - returning early');
return;
}
final viewerId = state.selectedViewerId!;
final cameraId = state.fullCameraNumber!;
try {
print('CrossSwitch: Calling viewerConnectLive(viewer=$viewerId, camera=$cameraId)');
// Execute CrossSwitch via bridge service
final result = await _bridgeService.viewerConnectLive(viewerId, cameraId);
print('CrossSwitch: Result = $result');
// Update local state
final viewerStates = Map<int, ViewerState>.from(state.viewerStates);
viewerStates[viewerId] = state.getViewerState(viewerId).copyWith(
currentCameraId: cameraId,
isLive: true,
);
_cancelEditTimeout();
emit(state.copyWith(
viewerStates: viewerStates,
cameraNumberInput: '',
isEditing: false,
));
} catch (e) {
emit(state.copyWith(error: 'CrossSwitch failed: $e'));
}
}
void _onUpdateViewerCamera(
UpdateViewerCamera event, Emitter<WallState> emit) {
final viewerStates = Map<int, ViewerState>.from(state.viewerStates);
viewerStates[event.viewerId] =
(viewerStates[event.viewerId] ?? ViewerState(viewerId: event.viewerId))
.copyWith(
currentCameraId: event.cameraId,
isLive: event.isLive,
);
emit(state.copyWith(viewerStates: viewerStates));
}
void _onSetViewerAlarm(SetViewerAlarm event, Emitter<WallState> emit) {
final viewerStates = Map<int, ViewerState>.from(state.viewerStates);
viewerStates[event.viewerId] =
(viewerStates[event.viewerId] ?? ViewerState(viewerId: event.viewerId))
.copyWith(hasAlarm: event.hasAlarm);
emit(state.copyWith(viewerStates: viewerStates));
}
void _onSetViewerLock(SetViewerLock event, Emitter<WallState> emit) {
final viewerStates = Map<int, ViewerState>.from(state.viewerStates);
viewerStates[event.viewerId] =
(viewerStates[event.viewerId] ?? ViewerState(viewerId: event.viewerId))
.copyWith(
isLocked: event.isLocked,
lockedBy: event.lockedBy,
);
emit(state.copyWith(viewerStates: viewerStates));
}
void _onToggleSectionExpanded(
ToggleSectionExpanded event, Emitter<WallState> emit) {
final expandedSections = Set<String>.from(state.expandedSections);
if (expandedSections.contains(event.sectionId)) {
expandedSections.remove(event.sectionId);
} else {
expandedSections.add(event.sectionId);
}
emit(state.copyWith(expandedSections: expandedSections));
}
}

View File

@@ -0,0 +1,131 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/wall_config.dart';
abstract class WallEvent extends Equatable {
const WallEvent();
@override
List<Object?> get props => [];
}
/// Load wall configuration
class LoadWallConfig extends WallEvent {
final WallConfig? config;
const LoadWallConfig([this.config]);
@override
List<Object?> get props => [config];
}
/// Select a viewer by tapping on it
class SelectViewer extends WallEvent {
final int viewerId;
const SelectViewer(this.viewerId);
@override
List<Object?> get props => [viewerId];
}
/// Deselect current viewer
class DeselectViewer extends WallEvent {
const DeselectViewer();
}
/// Update camera prefix for input
class SetCameraPrefix extends WallEvent {
final int prefix; // 500, 501, 502
const SetCameraPrefix(this.prefix);
@override
List<Object?> get props => [prefix];
}
/// Add digit to camera number input
class AddCameraDigit extends WallEvent {
final int digit;
const AddCameraDigit(this.digit);
@override
List<Object?> get props => [digit];
}
/// Remove last digit from camera input (Backspace)
class BackspaceCameraDigit extends WallEvent {
const BackspaceCameraDigit();
}
/// Cancel camera edit (Escape or timeout)
class CancelCameraEdit extends WallEvent {
const CancelCameraEdit();
}
/// Cycle to next camera prefix (Prefix key)
class CycleCameraPrefix extends WallEvent {
const CycleCameraPrefix();
}
/// Execute CrossSwitch with current camera input
class ExecuteCrossSwitch extends WallEvent {
const ExecuteCrossSwitch();
}
/// Update viewer state (from WebSocket events)
class UpdateViewerCamera extends WallEvent {
final int viewerId;
final int cameraId;
final bool isLive;
const UpdateViewerCamera({
required this.viewerId,
required this.cameraId,
this.isLive = true,
});
@override
List<Object?> get props => [viewerId, cameraId, isLive];
}
/// Set alarm state on a viewer
class SetViewerAlarm extends WallEvent {
final int viewerId;
final bool hasAlarm;
const SetViewerAlarm({
required this.viewerId,
required this.hasAlarm,
});
@override
List<Object?> get props => [viewerId, hasAlarm];
}
/// Set lock state on a viewer's camera
class SetViewerLock extends WallEvent {
final int viewerId;
final bool isLocked;
final String? lockedBy;
const SetViewerLock({
required this.viewerId,
required this.isLocked,
this.lockedBy,
});
@override
List<Object?> get props => [viewerId, isLocked, lockedBy];
}
/// Toggle expanded section
class ToggleSectionExpanded extends WallEvent {
final String sectionId;
const ToggleSectionExpanded(this.sectionId);
@override
List<Object?> get props => [sectionId];
}

View File

@@ -0,0 +1,180 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/wall_config.dart';
/// State for a single viewer
class ViewerState extends Equatable {
final int viewerId;
final int currentCameraId;
final bool isLive;
final bool hasAlarm;
final bool isLocked;
final String? lockedBy;
const ViewerState({
required this.viewerId,
this.currentCameraId = 0,
this.isLive = true,
this.hasAlarm = false,
this.isLocked = false,
this.lockedBy,
});
bool get hasCamera => currentCameraId > 0;
bool get isLockedByOther => isLocked && lockedBy != null;
ViewerState copyWith({
int? currentCameraId,
bool? isLive,
bool? hasAlarm,
bool? isLocked,
String? lockedBy,
}) {
return ViewerState(
viewerId: viewerId,
currentCameraId: currentCameraId ?? this.currentCameraId,
isLive: isLive ?? this.isLive,
hasAlarm: hasAlarm ?? this.hasAlarm,
isLocked: isLocked ?? this.isLocked,
lockedBy: lockedBy ?? this.lockedBy,
);
}
@override
List<Object?> get props =>
[viewerId, currentCameraId, isLive, hasAlarm, isLocked, lockedBy];
}
/// Main wall bloc state
class WallState extends Equatable {
final WallConfig? config;
final bool isLoading;
final String? error;
// Selection state
final int? selectedViewerId;
final int? selectedPhysicalMonitorId;
// Camera input state
final int cameraPrefix; // 500, 501, 502
final String cameraNumberInput; // Up to 6 digits typed by user
final bool isEditing; // Whether camera input is active
// Viewer states (keyed by viewer ID)
final Map<int, ViewerState> viewerStates;
// Expanded sections
final Set<String> expandedSections;
const WallState({
this.config,
this.isLoading = false,
this.error,
this.selectedViewerId,
this.selectedPhysicalMonitorId,
this.cameraPrefix = 500,
this.cameraNumberInput = '',
this.isEditing = false,
this.viewerStates = const {},
this.expandedSections = const {},
});
static const int _maxLength = 6;
static const List<int> _prefixes = [500, 501, 502];
/// Compose camera number with prefix (legacy CameraNumber.GetCameraNumberWithPrefix).
/// If digits > prefix length: use digits as-is, right-pad with zeros.
/// If digits <= prefix length: prefix + left-padded digits.
int? get fullCameraNumber {
if (cameraNumberInput.isEmpty) return null;
final prefix = cameraPrefix.toString();
final String composed;
if (cameraNumberInput.length > prefix.length) {
composed = cameraNumberInput.padRight(_maxLength, '0');
} else {
composed = prefix +
cameraNumberInput.padLeft(_maxLength - prefix.length, '0');
}
return int.tryParse(composed);
}
/// Display string: typed digits only (no prefix shown in field).
String get cameraInputDisplay {
if (!isEditing || cameraNumberInput.isEmpty) return '';
return cameraNumberInput;
}
/// Check if a viewer is selected
bool isViewerSelected(int viewerId) => selectedViewerId == viewerId;
/// Check if a physical monitor is selected (any of its viewers)
bool isPhysicalMonitorSelected(PhysicalMonitor monitor) =>
selectedPhysicalMonitorId == monitor.id;
/// Get viewer state
ViewerState getViewerState(int viewerId) {
return viewerStates[viewerId] ?? ViewerState(viewerId: viewerId);
}
/// Check if section is expanded
bool isSectionExpanded(String sectionId) =>
expandedSections.contains(sectionId);
/// Check if CrossSwitch can be executed
bool get canExecuteCrossSwitch {
if (selectedViewerId == null) return false;
if (!isEditing || cameraNumberInput.isEmpty) return false;
if (fullCameraNumber == null) return false;
final viewerState = getViewerState(selectedViewerId!);
if (viewerState.hasAlarm) return false;
return true;
}
WallState copyWith({
WallConfig? config,
bool? isLoading,
String? error,
int? selectedViewerId,
int? selectedPhysicalMonitorId,
int? cameraPrefix,
String? cameraNumberInput,
bool? isEditing,
Map<int, ViewerState>? viewerStates,
Set<String>? expandedSections,
bool clearSelection = false,
bool clearError = false,
}) {
return WallState(
config: config ?? this.config,
isLoading: isLoading ?? this.isLoading,
error: clearError ? null : (error ?? this.error),
selectedViewerId:
clearSelection ? null : (selectedViewerId ?? this.selectedViewerId),
selectedPhysicalMonitorId: clearSelection
? null
: (selectedPhysicalMonitorId ?? this.selectedPhysicalMonitorId),
cameraPrefix: cameraPrefix ?? this.cameraPrefix,
cameraNumberInput: cameraNumberInput ?? this.cameraNumberInput,
isEditing: isEditing ?? this.isEditing,
viewerStates: viewerStates ?? this.viewerStates,
expandedSections: expandedSections ?? this.expandedSections,
);
}
@override
List<Object?> get props => [
config,
isLoading,
error,
selectedViewerId,
selectedPhysicalMonitorId,
cameraPrefix,
cameraNumberInput,
isEditing,
viewerStates,
expandedSections,
];
}

View File

@@ -0,0 +1,349 @@
import 'package:flutter/material.dart' hide LockState;
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../config/app_config.dart';
import '../../data/services/function_button_service.dart';
import '../../injection_container.dart';
import '../blocs/connection/connection_bloc.dart';
import '../blocs/camera/camera_bloc.dart';
import '../blocs/monitor/monitor_bloc.dart';
import '../blocs/ptz/ptz_bloc.dart';
import '../blocs/alarm/alarm_bloc.dart';
import '../blocs/lock/lock_bloc.dart';
import '../blocs/lock/lock_event.dart';
import '../blocs/lock/lock_state.dart';
import '../blocs/sequence/sequence_bloc.dart';
import '../blocs/sequence/sequence_event.dart';
import '../blocs/wall/wall_bloc.dart';
import '../blocs/wall/wall_event.dart';
import '../widgets/wall_grid/wall_grid.dart';
import '../widgets/toolbar/bottom_toolbar.dart';
import '../widgets/connection_status_bar.dart';
import '../widgets/ptz_control.dart';
import '../widgets/sequence_panel.dart';
import '../widgets/takeover_dialog.dart';
class KeyboardScreen extends StatelessWidget {
const KeyboardScreen({super.key});
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<ConnectionBloc>(create: (_) => sl<ConnectionBloc>()),
BlocProvider<CameraBloc>(create: (_) => sl<CameraBloc>()),
BlocProvider<MonitorBloc>(create: (_) => sl<MonitorBloc>()),
BlocProvider<PtzBloc>(create: (_) => sl<PtzBloc>()),
BlocProvider<AlarmBloc>(create: (_) => sl<AlarmBloc>()),
BlocProvider<LockBloc>(create: (_) => sl<LockBloc>()),
BlocProvider<SequenceBloc>(
create: (_) => sl<SequenceBloc>()..add(LoadSequences()),
),
BlocProvider<WallBloc>(
create: (_) => sl<WallBloc>()..add(const LoadWallConfig()),
),
],
child: BlocListener<LockBloc, LockState>(
listenWhen: (prev, curr) =>
prev.pendingTakeover != curr.pendingTakeover &&
curr.pendingTakeover != null,
listener: (context, state) {
if (state.pendingTakeover != null) {
showTakeoverDialog(context, state.pendingTakeover!);
}
},
child: const _KeyboardScreenContent(),
),
);
}
}
class _KeyboardScreenContent extends StatelessWidget {
const _KeyboardScreenContent();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0D1117),
body: Column(
children: [
// Top bar with connection status
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: const BoxDecoration(
color: Color(0xFF161B22),
border: Border(
bottom: BorderSide(color: Color(0xFF30363D), width: 1),
),
),
child: Row(
children: [
const Text(
'COPILOT Keyboard',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 12),
const Text(
'v0.3.0-build5',
style: TextStyle(
color: Color(0xFF7F7F7F),
fontSize: 11,
),
),
const Spacer(),
const ConnectionStatusBar(),
],
),
),
// Main content area
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
// Wide layout with PTZ on the side
if (constraints.maxWidth > 1200) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Wall grid
Expanded(
flex: 4,
child: Padding(
padding: const EdgeInsets.all(16),
child: const WallGrid(),
),
),
// PTZ controls sidebar
Container(
width: 220,
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Color(0xFF161B22),
border: Border(
left: BorderSide(color: Color(0xFF30363D), width: 1),
),
),
child: const SingleChildScrollView(
child: PtzControl(),
),
),
],
);
}
// Narrow layout - PTZ in bottom sheet or collapsed
return Padding(
padding: const EdgeInsets.all(16),
child: const WallGrid(),
);
},
),
),
// Bottom toolbar
BottomToolbar(
onSearchPressed: () => _showSearchDialog(context),
onPrepositionPressed: () => _showPrepositionDialog(context),
onPlaybackPressed: () => _showPlaybackOverlay(context),
onAlarmListPressed: () => _showAlarmListDialog(context),
onSequencePressed: () => _showSequenceDialog(context),
onLockPressed: () => _toggleLock(context),
onFunctionButtonPressed: (buttonId) =>
_executeFunctionButton(context, buttonId),
),
],
),
);
}
void _showSearchDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1A202C),
title: const Text(
'Hledat kameru',
style: TextStyle(color: Colors.white),
),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
hintText: 'Číslo nebo název kamery',
hintStyle: TextStyle(color: Colors.white54),
prefixIcon: Icon(Icons.search, color: Colors.white54),
border: OutlineInputBorder(),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.white24),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Color(0xFF00D4FF)),
),
),
autofocus: true,
),
const SizedBox(height: 16),
const Text(
'Funkce bude implementována v další fázi.',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Zavřít'),
),
],
),
);
}
void _showPrepositionDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1A202C),
title: const Text(
'Prepozice',
style: TextStyle(color: Colors.white),
),
content: const SizedBox(
width: 400,
height: 300,
child: Center(
child: Text(
'Seznam prepozic bude implementován v další fázi.',
style: TextStyle(color: Colors.white54),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Zavřít'),
),
],
),
);
}
void _showPlaybackOverlay(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Režim přehrávání (PvZ) bude implementován v další fázi.'),
backgroundColor: Color(0xFF2D3748),
),
);
}
void _showSequenceDialog(BuildContext context) {
final wallState = context.read<WallBloc>().state;
final viewerId = wallState.selectedViewerId;
if (viewerId == null) return;
// Refresh sequence list
context.read<SequenceBloc>().add(LoadSequences());
showDialog(
context: context,
builder: (_) => BlocProvider.value(
value: context.read<SequenceBloc>(),
child: AlertDialog(
backgroundColor: const Color(0xFF1A202C),
title: Text(
'Sekvence — Monitor $viewerId',
style: const TextStyle(color: Colors.white),
),
content: SizedBox(
width: 500,
height: 400,
child: SingleChildScrollView(
child: SequencePanel(viewerId: viewerId),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Zavřít'),
),
],
),
),
);
}
void _showAlarmListDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1A202C),
title: const Text(
'Historie alarmů',
style: TextStyle(color: Colors.white),
),
content: const SizedBox(
width: 500,
height: 400,
child: Center(
child: Text(
'Seznam alarmů bude implementován v další fázi.',
style: TextStyle(color: Colors.white54),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Zavřít'),
),
],
),
);
}
void _toggleLock(BuildContext context) {
final wallState = context.read<WallBloc>().state;
final viewerId = wallState.selectedViewerId;
if (viewerId == null) return;
final viewerState = wallState.getViewerState(viewerId);
final cameraId = viewerState.currentCameraId;
if (cameraId <= 0) return;
final lockBloc = context.read<LockBloc>();
final lockState = lockBloc.state;
if (lockState.isCameraLockedByMe(
cameraId, sl<AppConfig>().keyboardId)) {
lockBloc.add(ReleaseLock(cameraId));
} else {
lockBloc.add(TryLock(cameraId));
}
}
void _executeFunctionButton(BuildContext context, String buttonId) {
final wallBloc = context.read<WallBloc>();
final wallId = wallBloc.state.config?.id ?? '1';
final service = sl<FunctionButtonService>();
if (!service.hasActions(wallId, buttonId)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$buttonId — žádná akce pro tuto stěnu'),
backgroundColor: const Color(0xFF2D3748),
duration: const Duration(seconds: 1),
),
);
return;
}
service.execute(wallId, buttonId);
}
}

View File

@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/wall/wall_bloc.dart';
import '../blocs/wall/wall_event.dart';
import '../blocs/wall/wall_state.dart';
import '../widgets/overview/wall_overview.dart';
import '../widgets/section/section_view.dart';
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
String? _selectedSectionId;
final FocusNode _focusNode = FocusNode();
WallBloc? _wallBloc;
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<WallBloc, WallState>(
builder: (blocContext, state) {
// Store bloc reference for keyboard handler
_wallBloc = BlocProvider.of<WallBloc>(blocContext, listen: false);
return KeyboardListener(
focusNode: _focusNode,
autofocus: true,
onKeyEvent: _handleKeyEvent,
child: Scaffold(
backgroundColor: const Color(0xFF0A0E14),
body: _buildBody(blocContext, state),
),
);
},
);
}
Widget _buildBody(BuildContext context, WallState state) {
if (state.isLoading || state.config == null) {
return const Center(
child: CircularProgressIndicator(
color: Color(0xFF00D4FF),
),
);
}
// Show section view or wall overview
if (_selectedSectionId != null) {
final section = state.config!.sections.firstWhere(
(s) => s.id == _selectedSectionId,
orElse: () => state.config!.sections.first,
);
return SectionView(
section: section,
wallState: state,
onBack: () => setState(() => _selectedSectionId = null),
onViewerTap: (viewerId) {
context.read<WallBloc>().add(SelectViewer(viewerId));
// Re-request focus for keyboard input after tile tap
_focusNode.requestFocus();
},
);
}
return WallOverview(
config: state.config!,
wallState: state,
onSectionTap: (sectionId) {
setState(() => _selectedSectionId = sectionId);
},
);
}
void _handleKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent) return;
final bloc = _wallBloc;
if (bloc == null) return;
final state = bloc.state;
final key = event.logicalKey;
// Escape - go back or deselect
if (key == LogicalKeyboardKey.escape) {
if (state.selectedViewerId != null) {
bloc.add(const DeselectViewer());
} else if (_selectedSectionId != null) {
setState(() => _selectedSectionId = null);
}
return;
}
// Only handle camera input when a viewer is selected
if (state.selectedViewerId == null) return;
// Digit keys 0-9
if (key.keyId >= LogicalKeyboardKey.digit0.keyId &&
key.keyId <= LogicalKeyboardKey.digit9.keyId) {
final digit = key.keyId - LogicalKeyboardKey.digit0.keyId;
bloc.add(AddCameraDigit(digit));
return;
}
// Numpad digits
if (key.keyId >= LogicalKeyboardKey.numpad0.keyId &&
key.keyId <= LogicalKeyboardKey.numpad9.keyId) {
final digit = key.keyId - LogicalKeyboardKey.numpad0.keyId;
bloc.add(AddCameraDigit(digit));
return;
}
// Enter - execute CrossSwitch
if (key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.numpadEnter) {
if (state.canExecuteCrossSwitch) {
bloc.add(const ExecuteCrossSwitch());
}
return;
}
// Backspace - remove last digit
if (key == LogicalKeyboardKey.backspace) {
bloc.add(const BackspaceCameraDigit());
return;
}
// Delete - cancel edit
if (key == LogicalKeyboardKey.delete) {
bloc.add(const CancelCameraEdit());
return;
}
// Escape - cancel edit or deselect
if (key == LogicalKeyboardKey.escape) {
if (state.isEditing) {
bloc.add(const CancelCameraEdit());
} else {
bloc.add(const DeselectViewer());
}
return;
}
// F1-F3 for prefix selection
if (key == LogicalKeyboardKey.f1) {
bloc.add(const SetCameraPrefix(500));
return;
}
if (key == LogicalKeyboardKey.f2) {
bloc.add(const SetCameraPrefix(501));
return;
}
if (key == LogicalKeyboardKey.f3) {
bloc.add(const SetCameraPrefix(502));
return;
}
}
}

View File

@@ -0,0 +1,253 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/entities/alarm_state.dart';
import '../blocs/alarm/alarm_bloc.dart';
import '../blocs/alarm/alarm_event.dart';
import '../blocs/alarm/alarm_state.dart';
class AlarmPanel extends StatelessWidget {
final int maxDisplayed;
const AlarmPanel({super.key, this.maxDisplayed = 5});
@override
Widget build(BuildContext context) {
return BlocBuilder<AlarmBloc, AlarmBlocState>(
builder: (context, state) {
final alarms = state.activeAlarms.take(maxDisplayed).toList();
final hasMore = state.activeAlarms.length > maxDisplayed;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'ACTIVE ALARMS',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
if (state.blockingAlarmCount > 0)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${state.blockingAlarmCount}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
const Spacer(),
if (state.isLoading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: () =>
context.read<AlarmBloc>().add(const RefreshAlarms()),
tooltip: 'Refresh alarms',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: 8),
if (alarms.isEmpty)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.check_circle,
color: Colors.green.shade600,
),
const SizedBox(width: 8),
Text(
'No active alarms',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
)
else
Container(
decoration: BoxDecoration(
border: Border.all(
color: Colors.red.shade200,
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
...alarms.asMap().entries.map((entry) {
final index = entry.key;
final alarm = entry.value;
return _AlarmTile(
alarm: alarm,
isLast: index == alarms.length - 1 && !hasMore,
);
}),
if (hasMore)
Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(7),
bottomRight: Radius.circular(7),
),
),
child: Center(
child: Text(
'+${state.activeAlarms.length - maxDisplayed} more alarms',
style: Theme.of(context).textTheme.bodySmall,
),
),
),
],
),
),
if (state.error != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
state.error!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
),
if (state.lastSync != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Last sync: ${_formatTime(state.lastSync!)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
),
),
],
);
},
);
}
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:'
'${time.minute.toString().padLeft(2, '0')}:'
'${time.second.toString().padLeft(2, '0')}';
}
}
class _AlarmTile extends StatelessWidget {
final AlarmState alarm;
final bool isLast;
const _AlarmTile({
required this.alarm,
required this.isLast,
});
@override
Widget build(BuildContext context) {
final isBlocking = alarm.blocksMonitor;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isBlocking ? Colors.red.shade50 : null,
border: isLast
? null
: Border(
bottom: BorderSide(
color: Colors.red.shade200,
width: 1,
),
),
),
child: Row(
children: [
Icon(
isBlocking ? Icons.warning : Icons.info_outline,
color: isBlocking ? Colors.red : Colors.orange,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
alarm.eventName.isNotEmpty
? alarm.eventName
: 'Event ${alarm.eventId}',
style: TextStyle(
fontWeight: isBlocking ? FontWeight.bold : FontWeight.normal,
),
),
if (alarm.foreignKey > 0)
Text(
'Camera/Contact: ${alarm.foreignKey}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
Text(
_formatTime(alarm.startedAt),
style: Theme.of(context).textTheme.bodySmall,
),
if (alarm.associatedMonitor != null) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'M${alarm.associatedMonitor}',
style: TextStyle(
fontSize: 10,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
],
],
),
);
}
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:'
'${time.minute.toString().padLeft(2, '0')}:'
'${time.second.toString().padLeft(2, '0')}';
}
}

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/camera/camera_bloc.dart';
import '../blocs/camera/camera_event.dart';
import '../blocs/camera/camera_state.dart';
import '../blocs/monitor/monitor_bloc.dart';
import '../blocs/monitor/monitor_state.dart';
class CameraGrid extends StatelessWidget {
final int columns;
const CameraGrid({super.key, this.columns = 8});
@override
Widget build(BuildContext context) {
return BlocBuilder<CameraBloc, CameraState>(
builder: (context, cameraState) {
return BlocBuilder<MonitorBloc, MonitorBlocState>(
builder: (context, monitorState) {
final cameras = cameraState.availableCameras;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'CAMERAS',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Wrap(
spacing: 4,
runSpacing: 4,
children: cameras.map((cameraId) {
final isSelected =
cameraState.selectedCameraId == cameraId;
final isOnSelectedMonitor =
monitorState.selectedMonitorCamera == cameraId;
return _CameraButton(
cameraId: cameraId,
isSelected: isSelected,
isOnSelectedMonitor: isOnSelectedMonitor,
onPressed: () => _onCameraPressed(
context,
cameraId,
monitorState.selectedMonitorId,
),
);
}).toList(),
),
],
);
},
);
},
);
}
void _onCameraPressed(BuildContext context, int cameraId, int? monitorId) {
final cameraBloc = context.read<CameraBloc>();
if (monitorId != null) {
// Monitor is selected, connect camera to it
cameraBloc.add(ConnectCameraToMonitor(
cameraId: cameraId,
monitorId: monitorId,
));
} else {
// Just select the camera
cameraBloc.add(SelectCamera(cameraId));
}
}
}
class _CameraButton extends StatelessWidget {
final int cameraId;
final bool isSelected;
final bool isOnSelectedMonitor;
final VoidCallback onPressed;
const _CameraButton({
required this.cameraId,
required this.isSelected,
required this.isOnSelectedMonitor,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
Color backgroundColor;
Color foregroundColor;
if (isSelected) {
backgroundColor = Theme.of(context).colorScheme.primary;
foregroundColor = Theme.of(context).colorScheme.onPrimary;
} else if (isOnSelectedMonitor) {
backgroundColor = Theme.of(context).colorScheme.secondary;
foregroundColor = Theme.of(context).colorScheme.onSecondary;
} else {
backgroundColor = Theme.of(context).colorScheme.surfaceContainerHighest;
foregroundColor = Theme.of(context).colorScheme.onSurface;
}
return SizedBox(
width: 48,
height: 40,
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
child: Text(
'$cameraId',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
),
);
}
}

View File

@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/connection/connection_bloc.dart';
import '../blocs/connection/connection_event.dart';
import '../blocs/connection/connection_state.dart' as conn;
class ConnectionStatusBar extends StatelessWidget {
const ConnectionStatusBar({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<ConnectionBloc, conn.ConnectionState>(
builder: (context, state) {
Color backgroundColor;
Color textColor;
String statusText;
IconData statusIcon;
switch (state.overallStatus) {
case conn.ConnectionOverallStatus.connected:
backgroundColor = Colors.green.shade100;
textColor = Colors.green.shade800;
statusText = 'Connected (${state.connectedCount}/${state.totalCount})';
statusIcon = Icons.cloud_done;
case conn.ConnectionOverallStatus.partial:
backgroundColor = Colors.orange.shade100;
textColor = Colors.orange.shade800;
statusText = 'Partial (${state.connectedCount}/${state.totalCount})';
statusIcon = Icons.cloud_off;
case conn.ConnectionOverallStatus.connecting:
backgroundColor = Colors.blue.shade100;
textColor = Colors.blue.shade800;
statusText = 'Connecting...';
statusIcon = Icons.cloud_sync;
case conn.ConnectionOverallStatus.disconnected:
backgroundColor = Colors.red.shade100;
textColor = Colors.red.shade800;
statusText = 'Disconnected';
statusIcon = Icons.cloud_off;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(statusIcon, color: textColor, size: 18),
const SizedBox(width: 8),
Text(
statusText,
style: TextStyle(
color: textColor,
fontWeight: FontWeight.w500,
),
),
if (state.overallStatus == conn.ConnectionOverallStatus.disconnected ||
state.overallStatus == conn.ConnectionOverallStatus.partial) ...[
const SizedBox(width: 8),
InkWell(
onTap: () => context
.read<ConnectionBloc>()
.add(const RetryConnections()),
child: Icon(Icons.refresh, color: textColor, size: 18),
),
],
],
),
);
},
);
}
}

View File

@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/entities/monitor_state.dart';
import '../blocs/monitor/monitor_bloc.dart';
import '../blocs/monitor/monitor_event.dart';
import '../blocs/monitor/monitor_state.dart';
class MonitorGrid extends StatelessWidget {
final int columns;
const MonitorGrid({super.key, this.columns = 4});
@override
Widget build(BuildContext context) {
return BlocBuilder<MonitorBloc, MonitorBlocState>(
builder: (context, state) {
final monitors = state.availableMonitors;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'MONITORS',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Wrap(
spacing: 4,
runSpacing: 4,
children: monitors.map((monitorId) {
final isSelected = state.selectedMonitorId == monitorId;
final monitorState = state.monitorStates[monitorId];
return _MonitorButton(
monitorId: monitorId,
isSelected: isSelected,
monitorState: monitorState,
onPressed: () => _onMonitorPressed(context, monitorId),
onLongPress: () => _onMonitorLongPress(context, monitorId),
);
}).toList(),
),
],
);
},
);
}
void _onMonitorPressed(BuildContext context, int monitorId) {
context.read<MonitorBloc>().add(SelectMonitor(monitorId));
}
void _onMonitorLongPress(BuildContext context, int monitorId) {
context.read<MonitorBloc>().add(ClearMonitor(monitorId));
}
}
class _MonitorButton extends StatelessWidget {
final int monitorId;
final bool isSelected;
final MonitorState? monitorState;
final VoidCallback onPressed;
final VoidCallback onLongPress;
const _MonitorButton({
required this.monitorId,
required this.isSelected,
this.monitorState,
required this.onPressed,
required this.onLongPress,
});
@override
Widget build(BuildContext context) {
final hasAlarm = monitorState?.hasAlarm ?? false;
final currentCamera = monitorState?.currentChannel ?? 0;
final isActive = currentCamera > 0;
Color backgroundColor;
Color foregroundColor;
Color? borderColor;
if (hasAlarm) {
backgroundColor = Colors.red.shade700;
foregroundColor = Colors.white;
borderColor = Colors.red.shade900;
} else if (isSelected) {
backgroundColor = Theme.of(context).colorScheme.primary;
foregroundColor = Theme.of(context).colorScheme.onPrimary;
} else if (isActive) {
backgroundColor = Theme.of(context).colorScheme.primaryContainer;
foregroundColor = Theme.of(context).colorScheme.onPrimaryContainer;
} else {
backgroundColor = Theme.of(context).colorScheme.surfaceContainerHighest;
foregroundColor = Theme.of(context).colorScheme.onSurface;
}
return SizedBox(
width: 64,
height: 48,
child: GestureDetector(
onLongPress: onLongPress,
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
padding: const EdgeInsets.all(4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
side: borderColor != null
? BorderSide(color: borderColor, width: 2)
: BorderSide.none,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (hasAlarm)
const Icon(Icons.warning, size: 12, color: Colors.yellow),
Text(
'$monitorId',
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.bold),
),
],
),
if (isActive)
Text(
'C$currentCamera',
style: TextStyle(
fontSize: 10,
color: foregroundColor.withValues(alpha: 0.8),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,261 @@
import 'package:flutter/material.dart';
import '../../../domain/entities/wall_config.dart';
import '../../blocs/wall/wall_state.dart';
/// Overview screen showing all wall sections in spatial layout (matching D6)
class WallOverview extends StatelessWidget {
final WallConfig config;
final WallState wallState;
final Function(String sectionId) onSectionTap;
const WallOverview({
super.key,
required this.config,
required this.wallState,
required this.onSectionTap,
});
@override
Widget build(BuildContext context) {
return Container(
color: const Color(0xFF5A5A5A), // D6 background color
child: LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: _buildSpatialLayout(constraints),
);
},
),
);
}
List<Widget> _buildSpatialLayout(BoxConstraints constraints) {
final widgets = <Widget>[];
final width = constraints.maxWidth;
final height = constraints.maxHeight;
// Position sections based on D6 layout
// The D6 app shows:
// - "4. Vrchní část" (top) at the top center
// - "1. Levá část" (left), "2. Střed stěny" (center), "3. Pravá část" (right) in middle row
// - "5. Stupínek" (bottom) at the bottom center
for (final section in config.sections) {
final position = _getSectionPosition(section.id, width, height);
final size = _getSectionSize(section.id, width, height);
widgets.add(
Positioned(
left: position.dx,
top: position.dy,
width: size.width,
height: size.height,
child: _SectionTile(
section: section,
wallState: wallState,
onTap: () => onSectionTap(section.id),
),
),
);
}
return widgets;
}
Offset _getSectionPosition(String sectionId, double width, double height) {
// Layout matching D6 screenshot
switch (sectionId) {
case 'top': // 4. Vrchní část - top center
return Offset(width * 0.35, height * 0.05);
case 'left': // 1. Levá část - middle left
return Offset(width * 0.08, height * 0.35);
case 'center': // 2. Střed stěny - middle center
return Offset(width * 0.33, height * 0.35);
case 'right': // 3. Pravá část - middle right
return Offset(width * 0.58, height * 0.35);
case 'bottom': // 5. Stupínek - bottom center
return Offset(width * 0.33, height * 0.68);
default:
return Offset.zero;
}
}
Size _getSectionSize(String sectionId, double width, double height) {
// Sizes proportional to D6 layout
switch (sectionId) {
case 'top':
return Size(width * 0.30, height * 0.22);
case 'left':
return Size(width * 0.22, height * 0.25);
case 'center':
return Size(width * 0.22, height * 0.25);
case 'right':
return Size(width * 0.22, height * 0.25);
case 'bottom':
return Size(width * 0.30, height * 0.25);
default:
return Size(width * 0.2, height * 0.2);
}
}
}
class _SectionTile extends StatelessWidget {
final WallSection section;
final WallState wallState;
final VoidCallback onTap;
const _SectionTile({
required this.section,
required this.wallState,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section label
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'${_getSectionNumber(section.id)}. ${section.name}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
// Mini monitors grid
Expanded(
child: _MiniMonitorsGrid(
section: section,
wallState: wallState,
),
),
],
),
);
}
int _getSectionNumber(String id) {
switch (id) {
case 'left': return 1;
case 'center': return 2;
case 'right': return 3;
case 'top': return 4;
case 'bottom': return 5;
default: return 0;
}
}
}
class _MiniMonitorsGrid extends StatelessWidget {
final WallSection section;
final WallState wallState;
const _MiniMonitorsGrid({
required this.section,
required this.wallState,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final monitors = section.monitors;
final gridCols = section.columns;
final gridRows = section.rows;
// Calculate cell dimensions based on grid size
final cellWidth = constraints.maxWidth / gridCols;
final cellHeight = constraints.maxHeight / gridRows;
// Position monitors using their explicit row/col values (matching detail view)
return Stack(
children: monitors.map((monitor) {
// Convert 1-based row/col to 0-based for positioning
final row = monitor.row - 1;
final col = monitor.col - 1;
return Positioned(
left: col * cellWidth,
top: row * cellHeight,
width: monitor.colSpan * cellWidth,
height: monitor.rowSpan * cellHeight,
child: _MiniPhysicalMonitor(
monitor: monitor,
wallState: wallState,
),
);
}).toList(),
);
},
);
}
}
class _MiniPhysicalMonitor extends StatelessWidget {
final PhysicalMonitor monitor;
final WallState wallState;
const _MiniPhysicalMonitor({
required this.monitor,
required this.wallState,
});
@override
Widget build(BuildContext context) {
final viewers = monitor.viewerIds;
final gridCols = monitor.colSpan;
final gridRows = monitor.rowSpan;
// Overview: no cyan borders, just dark grid lines between viewers
return Container(
color: const Color(0xFF4A4A4A), // Dark background shows as grid lines
child: Column(
children: List.generate(gridRows, (row) {
return Expanded(
child: Row(
children: List.generate(gridCols, (col) {
final index = row * gridCols + col;
if (index >= viewers.length) {
return Expanded(
child: Padding(
padding: const EdgeInsets.all(0.5),
child: Container(
color: const Color(0xFF6A6A6A),
),
),
);
}
final viewerId = viewers[index];
final viewerState = wallState.getViewerState(viewerId);
Color tileColor;
if (viewerState.hasAlarm) {
tileColor = const Color(0xFFDC2626);
} else {
tileColor = const Color(0xFF6A6A6A);
}
return Expanded(
child: Padding(
padding: const EdgeInsets.all(0.5),
child: Container(
color: tileColor,
),
),
);
}),
),
);
}),
),
);
}
}

View File

@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/ptz/ptz_bloc.dart';
import '../blocs/ptz/ptz_event.dart';
import '../blocs/monitor/monitor_bloc.dart';
import '../blocs/monitor/monitor_state.dart';
class PresetButtons extends StatelessWidget {
final int presetCount;
const PresetButtons({super.key, this.presetCount = 8});
@override
Widget build(BuildContext context) {
return BlocBuilder<MonitorBloc, MonitorBlocState>(
builder: (context, monitorState) {
final cameraId = monitorState.selectedMonitorCamera;
final isEnabled = cameraId != null && cameraId > 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'PRESETS',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Wrap(
spacing: 4,
runSpacing: 4,
children: List.generate(presetCount, (index) {
final presetId = index + 1;
return _PresetButton(
presetId: presetId,
isEnabled: isEnabled,
onPressed: isEnabled
? () => context.read<PtzBloc>().add(
PtzGoToPreset(cameraId: cameraId, preset: presetId),
)
: null,
);
}),
),
],
);
},
);
}
}
class _PresetButton extends StatelessWidget {
final int presetId;
final bool isEnabled;
final VoidCallback? onPressed;
const _PresetButton({
required this.presetId,
required this.isEnabled,
this.onPressed,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 44,
height: 36,
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: isEnabled
? Theme.of(context).colorScheme.secondaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: isEnabled
? Theme.of(context).colorScheme.onSecondaryContainer
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4),
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
child: Text(
'$presetId',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
),
);
}
}

View File

@@ -0,0 +1,231 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/ptz/ptz_bloc.dart';
import '../blocs/ptz/ptz_event.dart';
import '../blocs/ptz/ptz_state.dart';
import '../blocs/monitor/monitor_bloc.dart';
import '../blocs/monitor/monitor_state.dart';
class PtzControl extends StatelessWidget {
const PtzControl({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<MonitorBloc, MonitorBlocState>(
builder: (context, monitorState) {
return BlocBuilder<PtzBloc, PtzState>(
builder: (context, ptzState) {
final cameraId = monitorState.selectedMonitorCamera;
final isEnabled = cameraId != null && cameraId > 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Text(
'PTZ CONTROL',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (cameraId != null && cameraId > 0) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Camera $cameraId',
style: TextStyle(
fontSize: 12,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
),
],
],
),
),
_buildPtzPad(context, cameraId, isEnabled),
const SizedBox(height: 8),
_buildZoomControls(context, cameraId, isEnabled),
],
);
},
);
},
);
}
Widget _buildPtzPad(BuildContext context, int? cameraId, bool isEnabled) {
return Column(
children: [
// Up
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_PtzButton(
icon: Icons.arrow_upward,
onPressStart: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(
PtzTiltStart(cameraId: cameraId, direction: 'up'),
)
: null,
onPressEnd: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
: null,
),
],
),
// Left, Stop, Right
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_PtzButton(
icon: Icons.arrow_back,
onPressStart: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(
PtzPanStart(cameraId: cameraId, direction: 'left'),
)
: null,
onPressEnd: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
: null,
),
const SizedBox(width: 4),
_PtzButton(
icon: Icons.stop,
isStop: true,
onPressStart: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
: null,
),
const SizedBox(width: 4),
_PtzButton(
icon: Icons.arrow_forward,
onPressStart: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(
PtzPanStart(cameraId: cameraId, direction: 'right'),
)
: null,
onPressEnd: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
: null,
),
],
),
// Down
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_PtzButton(
icon: Icons.arrow_downward,
onPressStart: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(
PtzTiltStart(cameraId: cameraId, direction: 'down'),
)
: null,
onPressEnd: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
: null,
),
],
),
],
);
}
Widget _buildZoomControls(
BuildContext context, int? cameraId, bool isEnabled) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_PtzButton(
icon: Icons.zoom_out,
onPressStart: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(
PtzZoomStart(cameraId: cameraId, direction: 'out'),
)
: null,
onPressEnd: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
: null,
),
const SizedBox(width: 4),
_PtzButton(
icon: Icons.zoom_in,
onPressStart: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(
PtzZoomStart(cameraId: cameraId, direction: 'in'),
)
: null,
onPressEnd: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
: null,
),
],
);
}
}
class _PtzButton extends StatelessWidget {
final IconData icon;
final VoidCallback? onPressStart;
final VoidCallback? onPressEnd;
final bool isStop;
const _PtzButton({
required this.icon,
this.onPressStart,
this.onPressEnd,
this.isStop = false,
});
@override
Widget build(BuildContext context) {
final isEnabled = onPressStart != null;
return GestureDetector(
onTapDown: isEnabled ? (_) => onPressStart?.call() : null,
onTapUp: isEnabled ? (_) => onPressEnd?.call() : null,
onTapCancel: isEnabled ? () => onPressEnd?.call() : null,
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: isEnabled
? (isStop
? Theme.of(context).colorScheme.errorContainer
: Theme.of(context).colorScheme.primaryContainer)
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isEnabled
? (isStop
? Theme.of(context).colorScheme.error
: Theme.of(context).colorScheme.primary)
: Theme.of(context).colorScheme.outline,
width: 1,
),
),
child: Icon(
icon,
color: isEnabled
? (isStop
? Theme.of(context).colorScheme.onErrorContainer
: Theme.of(context).colorScheme.onPrimaryContainer)
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4),
),
),
);
}
}

View File

@@ -0,0 +1,675 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../domain/entities/wall_config.dart';
import '../../blocs/wall/wall_bloc.dart';
import '../../blocs/wall/wall_event.dart';
import '../../blocs/wall/wall_state.dart';
/// Full section view matching D6 app design
class SectionView extends StatelessWidget {
final WallSection section;
final WallState wallState;
final VoidCallback onBack;
final Function(int viewerId) onViewerTap;
const SectionView({
super.key,
required this.section,
required this.wallState,
required this.onBack,
required this.onViewerTap,
});
@override
Widget build(BuildContext context) {
return Container(
color: const Color(0xFF555555), // D6 background color
child: Column(
children: [
// Header bar
_HeaderBar(
section: section,
wallState: wallState,
),
// Monitors grid - takes all available space
Expanded(
child: _MonitorsGrid(
section: section,
wallState: wallState,
onViewerTap: onViewerTap,
),
),
// Bottom toolbar with circular icons
_BottomIconBar(
wallState: wallState,
onSegmentsTap: onBack,
),
],
),
);
}
}
class _HeaderBar extends StatelessWidget {
final WallSection section;
final WallState wallState;
const _HeaderBar({
required this.section,
required this.wallState,
});
@override
Widget build(BuildContext context) {
// Get section number from id
final sectionNum = _getSectionNumber(section.id);
return Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 8),
color: const Color(0xFF555555),
child: Row(
children: [
// Video camera icon (matching D6 app)
const Icon(
Icons.videocam,
color: Colors.white,
size: 24,
),
const SizedBox(width: 8),
// Section name
Text(
'$sectionNum | ${section.name}',
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w500,
),
),
const Spacer(),
// Status message (red when server unavailable)
const Text(
'Aplikační server není dostupný, některé funkce nejsou k dispozici',
style: TextStyle(
color: Color(0xFFFF4444),
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 8),
// Status icons
const Icon(Icons.dns, color: Color(0xFFFF4444), size: 28),
const SizedBox(width: 6),
const Icon(Icons.lan, color: Color(0xFF24FF00), size: 28),
],
),
);
}
int _getSectionNumber(String id) {
switch (id) {
case 'left': return 1;
case 'center': return 2;
case 'right': return 3;
case 'top': return 4;
case 'bottom': return 5;
default: return 0;
}
}
}
class _MonitorsGrid extends StatelessWidget {
final WallSection section;
final WallState wallState;
final Function(int viewerId) onViewerTap;
const _MonitorsGrid({
required this.section,
required this.wallState,
required this.onViewerTap,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final monitors = section.monitors;
final gridCols = section.columns;
final gridRows = section.rows;
// Calculate cell dimensions based on grid size
final cellWidth = constraints.maxWidth / gridCols;
final cellHeight = constraints.maxHeight / gridRows;
// Build a map of which physical monitor each grid cell belongs to
final cellToMonitor = <String, PhysicalMonitor>{};
for (final monitor in monitors) {
for (int r = 0; r < monitor.rowSpan; r++) {
for (int c = 0; c < monitor.colSpan; c++) {
final gridRow = monitor.row - 1 + r;
final gridCol = monitor.col - 1 + c;
cellToMonitor['$gridRow,$gridCol'] = monitor;
}
}
}
// Position monitors using their explicit row/col values
return Stack(
children: [
// First layer: monitor content without borders
...monitors.map((monitor) {
final row = monitor.row - 1;
final col = monitor.col - 1;
return Positioned(
left: col * cellWidth,
top: row * cellHeight,
width: monitor.colSpan * cellWidth,
height: monitor.rowSpan * cellHeight,
child: _PhysicalMonitorContent(
monitor: monitor,
wallState: wallState,
onViewerTap: onViewerTap,
),
);
}),
// Second layer: border overlay
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _GridBorderPainter(
monitors: monitors,
gridCols: gridCols,
gridRows: gridRows,
cellToMonitor: cellToMonitor,
),
),
),
),
],
);
},
);
}
}
/// Custom painter that draws all grid borders with correct colors
class _GridBorderPainter extends CustomPainter {
final List<PhysicalMonitor> monitors;
final int gridCols;
final int gridRows;
final Map<String, PhysicalMonitor> cellToMonitor;
static const _borderWidth = 2.0;
static const _cyanColor = Color(0xFF00BFFF);
static const _darkColor = Color(0xFF4A4A4A);
_GridBorderPainter({
required this.monitors,
required this.gridCols,
required this.gridRows,
required this.cellToMonitor,
});
@override
void paint(Canvas canvas, Size size) {
final cellWidth = size.width / gridCols;
final cellHeight = size.height / gridRows;
final cyanPaint = Paint()
..color = _cyanColor
..strokeWidth = _borderWidth
..style = PaintingStyle.stroke;
final darkPaint = Paint()
..color = _darkColor
..strokeWidth = _borderWidth
..style = PaintingStyle.stroke;
// Collect all border segments, draw dark first then cyan on top
final darkLines = <_LineSegment>[];
final cyanLines = <_LineSegment>[];
// Collect horizontal lines
for (int row = 0; row <= gridRows; row++) {
for (int col = 0; col < gridCols; col++) {
final x1 = col * cellWidth;
final x2 = (col + 1) * cellWidth;
final y = row * cellHeight;
final cellAbove = row > 0 ? cellToMonitor['${row - 1},$col'] : null;
final cellBelow = row < gridRows ? cellToMonitor['$row,$col'] : null;
// Only draw border if at least one side has a physical monitor
if (cellAbove == null && cellBelow == null) continue;
// Cyan if: edge of physical monitor (one side empty or different monitor)
final isCyan = cellAbove == null || cellBelow == null ||
cellAbove.id != cellBelow.id;
// Skip internal borders for single-viewer monitors
if (!isCyan && cellAbove != null && cellAbove.viewerIds.length == 1) {
continue;
}
final segment = _LineSegment(Offset(x1, y), Offset(x2, y));
if (isCyan) {
cyanLines.add(segment);
} else {
darkLines.add(segment);
}
}
}
// Collect vertical lines
for (int col = 0; col <= gridCols; col++) {
for (int row = 0; row < gridRows; row++) {
final x = col * cellWidth;
final y1 = row * cellHeight;
final y2 = (row + 1) * cellHeight;
final cellLeft = col > 0 ? cellToMonitor['$row,${col - 1}'] : null;
final cellRight = col < gridCols ? cellToMonitor['$row,$col'] : null;
// Only draw border if at least one side has a physical monitor
if (cellLeft == null && cellRight == null) continue;
// Cyan if: edge of physical monitor (one side empty or different monitor)
final isCyan = cellLeft == null || cellRight == null ||
cellLeft.id != cellRight.id;
// Skip internal borders for single-viewer monitors
if (!isCyan && cellLeft != null && cellLeft.viewerIds.length == 1) {
continue;
}
final segment = _LineSegment(Offset(x, y1), Offset(x, y2));
if (isCyan) {
cyanLines.add(segment);
} else {
darkLines.add(segment);
}
}
}
// Draw dark borders first (behind)
for (final line in darkLines) {
canvas.drawLine(line.start, line.end, darkPaint);
}
// Draw cyan borders on top (in front)
for (final line in cyanLines) {
canvas.drawLine(line.start, line.end, cyanPaint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class _LineSegment {
final Offset start;
final Offset end;
_LineSegment(this.start, this.end);
}
/// Physical monitor content without borders (borders drawn by overlay)
class _PhysicalMonitorContent extends StatelessWidget {
final PhysicalMonitor monitor;
final WallState wallState;
final Function(int viewerId) onViewerTap;
const _PhysicalMonitorContent({
required this.monitor,
required this.wallState,
required this.onViewerTap,
});
@override
Widget build(BuildContext context) {
final viewers = monitor.viewerIds;
// Single viewer fills entire monitor space
if (viewers.length == 1) {
final viewerId = viewers.first;
final isSelected = wallState.isViewerSelected(viewerId);
return _ViewerTile(
viewerId: viewerId,
viewerState: wallState.getViewerState(viewerId),
isSelected: isSelected,
cameraInputDisplay: isSelected ? wallState.cameraInputDisplay : null,
onTap: () => onViewerTap(viewerId),
);
}
// Multiple viewers: determine grid based on viewer count and monitor shape
final gridCols = monitor.colSpan;
final gridRows = monitor.rowSpan;
return Column(
children: List.generate(gridRows, (row) {
return Expanded(
child: Row(
children: List.generate(gridCols, (col) {
final index = row * gridCols + col;
if (index >= viewers.length) {
return Expanded(
child: Container(
color: const Color(0xFF6A6A6A),
),
);
}
final viewerId = viewers[index];
final isSelected = wallState.isViewerSelected(viewerId);
return Expanded(
child: _ViewerTile(
viewerId: viewerId,
viewerState: wallState.getViewerState(viewerId),
isSelected: isSelected,
cameraInputDisplay: isSelected ? wallState.cameraInputDisplay : null,
onTap: () => onViewerTap(viewerId),
),
);
}),
),
);
}),
);
}
}
class _ViewerTile extends StatelessWidget {
final int viewerId;
final ViewerState viewerState;
final bool isSelected;
final String? cameraInputDisplay;
final VoidCallback onTap;
const _ViewerTile({
required this.viewerId,
required this.viewerState,
required this.isSelected,
this.cameraInputDisplay,
required this.onTap,
});
@override
Widget build(BuildContext context) {
// D6 style: selected = cyan fill, alarm = red, normal = gray
Color bgColor;
if (isSelected) {
bgColor = const Color(0xFF00D4FF); // Cyan fill for selected
} else if (viewerState.hasAlarm) {
bgColor = const Color(0xFFDC2626); // Red for alarm
} else {
bgColor = const Color(0xFF6A6A6A); // Gray for normal
}
return GestureDetector(
onTap: onTap,
child: Container(
color: bgColor,
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Viewer ID at top (smaller)
Text(
'$viewerId',
style: TextStyle(
color: isSelected ? Colors.white : Colors.white,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
// Camera number - large white text, matching D6 style
if (isSelected && cameraInputDisplay != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
cameraInputDisplay!,
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
);
}
}
class _ViewerTileWithBorder extends StatelessWidget {
final int viewerId;
final ViewerState viewerState;
final bool isSelected;
final String? cameraInputDisplay;
final VoidCallback onTap;
final Border border;
const _ViewerTileWithBorder({
required this.viewerId,
required this.viewerState,
required this.isSelected,
this.cameraInputDisplay,
required this.onTap,
required this.border,
});
@override
Widget build(BuildContext context) {
// D6 style: selected = cyan fill, alarm = red, normal = gray
Color bgColor;
if (isSelected) {
bgColor = const Color(0xFF00D4FF); // Cyan fill for selected
} else if (viewerState.hasAlarm) {
bgColor = const Color(0xFFDC2626); // Red for alarm
} else {
bgColor = const Color(0xFF6A6A6A); // Gray for normal
}
return GestureDetector(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: bgColor,
border: border,
),
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Viewer ID at top (smaller)
Text(
'$viewerId',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
// Camera number - large white text, matching D6 style
if (isSelected && cameraInputDisplay != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
cameraInputDisplay!,
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
);
}
}
class _BottomIconBar extends StatelessWidget {
final WallState wallState;
final VoidCallback onSegmentsTap;
const _BottomIconBar({
required this.wallState,
required this.onSegmentsTap,
});
@override
Widget build(BuildContext context) {
final hasSelection = wallState.selectedViewerId != null;
return Container(
height: 86,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: const Color(0xFF555555),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
// Search
_CircleIconButton(
icon: Icons.search,
isActive: hasSelection,
onTap: () {},
),
// Lock
_CircleIconButton(
icon: Icons.lock_outline,
onTap: () {},
),
// Quad view
_CircleIconButton(
icon: Icons.grid_view,
onTap: () {},
),
// Segments - navigate to overview
_CircleIconButton(
icon: Icons.apps,
isActive: true,
onTap: onSegmentsTap,
),
// Image/Camera
_CircleIconButton(
icon: Icons.image_outlined,
onTap: () {},
),
// Alarm (red)
_CircleIconButton(
icon: Icons.notification_important,
iconColor: const Color(0xFFCC4444),
onTap: () {},
),
// History
_CircleIconButton(
icon: Icons.history,
onTap: () {},
),
// Monitor
_CircleIconButton(
icon: Icons.tv,
onTap: () {},
),
// Prefix selector
_PrefixButton(
prefix: wallState.cameraPrefix,
onTap: () {
// Cycle through prefixes
final nextPrefix = wallState.cameraPrefix == 500 ? 501
: wallState.cameraPrefix == 501 ? 502 : 500;
context.read<WallBloc>().add(SetCameraPrefix(nextPrefix));
},
),
],
),
);
}
}
class _CircleIconButton extends StatelessWidget {
final IconData icon;
final bool isActive;
final Color? iconColor;
final VoidCallback onTap;
const _CircleIconButton({
required this.icon,
this.isActive = false,
this.iconColor,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: isActive ? Colors.white : Colors.white38,
width: 2,
),
color: isActive ? const Color(0xFF333333) : Colors.transparent,
),
child: Icon(
icon,
color: iconColor ?? (isActive ? Colors.white : Colors.white60),
size: 38,
),
),
);
}
}
class _PrefixButton extends StatelessWidget {
final int prefix;
final VoidCallback onTap;
const _PrefixButton({
required this.prefix,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
color: const Color(0xFF333333),
),
child: Center(
child: Text(
'$prefix',
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,182 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/sequence/sequence_bloc.dart';
import '../blocs/sequence/sequence_event.dart';
import '../blocs/sequence/sequence_state.dart';
/// Panel for starting/stopping camera rotation sequences.
/// Shown as a dialog from the toolbar.
class SequencePanel extends StatelessWidget {
final int viewerId;
const SequencePanel({super.key, required this.viewerId});
@override
Widget build(BuildContext context) {
return BlocBuilder<SequenceBloc, SequenceState>(
builder: (context, state) {
if (state.isLoading) {
return const SizedBox(
height: 200,
child: Center(child: CircularProgressIndicator()),
);
}
final isRunning = state.isRunningOnViewer(viewerId);
final runningSeq = state.getRunning(viewerId);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Running sequence indicator
if (isRunning && runningSeq != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF38A169).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: const Color(0xFF38A169)),
),
child: Row(
children: [
const Icon(Icons.play_circle, color: Color(0xFF38A169)),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_getSequenceName(state, runningSeq.sequenceId),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
Text(
'Běží na monitoru $viewerId',
style: const TextStyle(
color: Colors.white70, fontSize: 12),
),
],
),
),
ElevatedButton.icon(
onPressed: () {
context
.read<SequenceBloc>()
.add(StopSequence(viewerId));
},
icon: const Icon(Icons.stop, size: 16),
label: const Text('Zastavit'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFDC2626),
foregroundColor: Colors.white,
),
),
],
),
),
const SizedBox(height: 12),
],
// Category filter chips
if (state.categories.isNotEmpty) ...[
Wrap(
spacing: 8,
children: [
FilterChip(
label: const Text('Vše'),
selected: state.selectedCategoryId == null,
onSelected: (_) =>
context.read<SequenceBloc>().add(SelectCategory(null)),
selectedColor: const Color(0xFF2B6CB0),
labelStyle: TextStyle(
color: state.selectedCategoryId == null
? Colors.white
: Colors.white70,
),
backgroundColor: const Color(0xFF2D3748),
),
...state.categories.map((cat) => FilterChip(
label: Text(cat.name),
selected: state.selectedCategoryId == cat.id,
onSelected: (_) => context
.read<SequenceBloc>()
.add(SelectCategory(cat.id)),
selectedColor: const Color(0xFF2B6CB0),
labelStyle: TextStyle(
color: state.selectedCategoryId == cat.id
? Colors.white
: Colors.white70,
),
backgroundColor: const Color(0xFF2D3748),
)),
],
),
const SizedBox(height: 12),
],
// Sequence list
if (state.filteredSequences.isEmpty)
const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: Text(
'Žádné sekvence k dispozici.',
style: TextStyle(color: Colors.white54),
),
),
)
else
...state.filteredSequences.map((seq) {
final isThisRunning =
runningSeq?.sequenceId == seq.id && isRunning;
return ListTile(
dense: true,
leading: Icon(
isThisRunning ? Icons.play_circle : Icons.loop,
color: isThisRunning
? const Color(0xFF38A169)
: const Color(0xFF718096),
),
title: Text(
seq.name,
style: const TextStyle(color: Colors.white),
),
subtitle: Text(
'${seq.cameras.length} kamer, ${seq.intervalSeconds}s interval',
style:
const TextStyle(color: Colors.white54, fontSize: 12),
),
trailing: isThisRunning
? TextButton(
onPressed: () => context
.read<SequenceBloc>()
.add(StopSequence(viewerId)),
child: const Text('Zastavit',
style: TextStyle(color: Color(0xFFDC2626))),
)
: TextButton(
onPressed: () => context
.read<SequenceBloc>()
.add(StartSequence(
viewerId: viewerId, sequenceId: seq.id)),
child: const Text('Spustit'),
),
);
}),
],
);
},
);
}
String _getSequenceName(SequenceState state, int sequenceId) {
final seq =
state.sequences.where((s) => s.id == sequenceId).firstOrNull;
return seq?.name ?? 'Sekvence #$sequenceId';
}
}

View File

@@ -0,0 +1,147 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/lock/lock_bloc.dart';
import '../blocs/lock/lock_event.dart';
import '../blocs/lock/lock_state.dart';
/// Show the takeover confirmation dialog.
/// Called when another keyboard requests takeover of a camera we have locked.
void showTakeoverDialog(BuildContext context, TakeoverRequest request) {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => BlocProvider.value(
value: context.read<LockBloc>(),
child: _TakeoverDialog(request: request),
),
);
}
class _TakeoverDialog extends StatefulWidget {
final TakeoverRequest request;
const _TakeoverDialog({required this.request});
@override
State<_TakeoverDialog> createState() => _TakeoverDialogState();
}
class _TakeoverDialogState extends State<_TakeoverDialog> {
static const _autoRejectSeconds = 30;
late Timer _autoRejectTimer;
int _remainingSeconds = _autoRejectSeconds;
@override
void initState() {
super.initState();
_autoRejectTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (_remainingSeconds <= 1) {
_reject();
} else {
setState(() => _remainingSeconds--);
}
});
}
@override
void dispose() {
_autoRejectTimer.cancel();
super.dispose();
}
void _confirm() {
_autoRejectTimer.cancel();
context.read<LockBloc>().add(
ConfirmTakeover(widget.request.cameraId, confirm: true),
);
Navigator.of(context).pop();
}
void _reject() {
_autoRejectTimer.cancel();
context.read<LockBloc>().add(
ConfirmTakeover(widget.request.cameraId, confirm: false),
);
if (mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: const Color(0xFF1A202C),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: const BorderSide(color: Color(0xFFED8936), width: 2),
),
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Color(0xFFED8936), size: 28),
SizedBox(width: 8),
Text(
'Požadavek na převzetí',
style: TextStyle(color: Colors.white),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
style: const TextStyle(color: Colors.white70, fontSize: 14),
children: [
const TextSpan(text: 'Klávesnice '),
TextSpan(
text: widget.request.requestingKeyboard,
style: const TextStyle(
fontWeight: FontWeight.bold, color: Colors.white),
),
const TextSpan(text: ' požaduje převzetí kamery '),
TextSpan(
text: '${widget.request.cameraId}',
style: const TextStyle(
fontWeight: FontWeight.bold, color: Color(0xFF00D4FF)),
),
const TextSpan(text: '.'),
],
),
),
const SizedBox(height: 16),
// Countdown progress
LinearProgressIndicator(
value: _remainingSeconds / _autoRejectSeconds,
backgroundColor: const Color(0xFF2D3748),
valueColor:
const AlwaysStoppedAnimation<Color>(Color(0xFFED8936)),
),
const SizedBox(height: 4),
Text(
'Automatické zamítnutí za $_remainingSeconds s',
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
actions: [
TextButton(
onPressed: _reject,
child: const Text(
'Zamítnout',
style: TextStyle(color: Color(0xFFDC2626)),
),
),
ElevatedButton(
onPressed: _confirm,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFED8936),
foregroundColor: Colors.white,
),
child: const Text('Povolit převzetí'),
),
],
);
}
}

View File

@@ -0,0 +1,303 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../blocs/wall/wall_bloc.dart';
import '../../blocs/wall/wall_state.dart';
import 'camera_input_widget.dart';
import 'function_buttons_widget.dart';
/// Bottom toolbar with camera input and action buttons
class BottomToolbar extends StatelessWidget {
final VoidCallback? onSearchPressed;
final VoidCallback? onPrepositionPressed;
final VoidCallback? onPlaybackPressed;
final VoidCallback? onAlarmListPressed;
final VoidCallback? onLockPressed;
final VoidCallback? onSequencePressed;
final Function(String)? onFunctionButtonPressed;
const BottomToolbar({
super.key,
this.onSearchPressed,
this.onPrepositionPressed,
this.onPlaybackPressed,
this.onAlarmListPressed,
this.onLockPressed,
this.onSequencePressed,
this.onFunctionButtonPressed,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: const BoxDecoration(
color: Color(0xFF1A202C),
border: Border(
top: BorderSide(color: Color(0xFF4A5568), width: 1),
),
),
child: BlocBuilder<WallBloc, WallState>(
builder: (context, state) {
final hasSelection = state.selectedViewerId != null;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Top row - Camera input and function buttons
Row(
children: [
// Camera input
const Expanded(
child: CameraInputWidget(),
),
const SizedBox(width: 24),
// Function buttons
FunctionButtonsWidget(
onButtonPressed: onFunctionButtonPressed,
),
],
),
const SizedBox(height: 12),
// Bottom row - Context actions
Row(
children: [
if (hasSelection) ...[
// Search button
_ActionButton(
icon: Icons.search,
label: 'Hledat',
onPressed: onSearchPressed,
),
const SizedBox(width: 8),
// Preposition button
_ActionButton(
icon: Icons.place,
label: 'Prepozice',
onPressed: onPrepositionPressed,
),
const SizedBox(width: 8),
// Playback button
_ActionButton(
icon: Icons.history,
label: 'PvZ',
onPressed: onPlaybackPressed,
),
const SizedBox(width: 8),
// Alarm list button
_ActionButton(
icon: Icons.notifications,
label: 'Alarmy',
onPressed: onAlarmListPressed,
),
const SizedBox(width: 8),
// Sequence button
_ActionButton(
icon: Icons.loop,
label: 'Sekvence',
onPressed: onSequencePressed,
),
const SizedBox(width: 8),
// Lock button
_LockButton(
viewerState: state.getViewerState(state.selectedViewerId!),
onPressed: onLockPressed,
),
] else ...[
// Show selection hint when nothing selected
const Expanded(
child: Text(
'Vyberte monitor pro zobrazení akcí',
style: TextStyle(
color: Color(0xFF718096),
fontSize: 14,
),
),
),
],
const Spacer(),
// Selected monitor info
if (hasSelection)
_SelectionInfo(
viewerId: state.selectedViewerId!,
viewerState: state.getViewerState(state.selectedViewerId!),
),
],
),
],
);
},
),
);
}
}
class _ActionButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback? onPressed;
const _ActionButton({
required this.icon,
required this.label,
this.onPressed,
});
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0xFF2D3748),
borderRadius: BorderRadius.circular(4),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: onPressed != null ? Colors.white : const Color(0xFF4A5568),
size: 18,
),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
color: onPressed != null ? Colors.white : const Color(0xFF4A5568),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
}
}
class _LockButton extends StatelessWidget {
final ViewerState viewerState;
final VoidCallback? onPressed;
const _LockButton({
required this.viewerState,
this.onPressed,
});
@override
Widget build(BuildContext context) {
final isLocked = viewerState.isLocked;
final isLockedByOther = viewerState.isLockedByOther;
return Material(
color: isLocked
? (isLockedByOther ? const Color(0xFFDC2626) : const Color(0xFF38A169))
: const Color(0xFF2D3748),
borderRadius: BorderRadius.circular(4),
child: InkWell(
onTap: isLockedByOther ? null : onPressed,
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isLocked ? Icons.lock : Icons.lock_open,
color: Colors.white,
size: 18,
),
const SizedBox(width: 6),
Text(
isLocked ? 'Zamčeno' : 'Zamknout',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
}
}
class _SelectionInfo extends StatelessWidget {
final int viewerId;
final ViewerState viewerState;
const _SelectionInfo({
required this.viewerId,
required this.viewerState,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF2D3748),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: const Color(0xFF00D4FF), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.tv,
color: Color(0xFF00D4FF),
size: 16,
),
const SizedBox(width: 8),
Text(
'Monitor $viewerId',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
if (viewerState.hasCamera) ...[
const SizedBox(width: 12),
const Icon(
Icons.videocam,
color: Colors.white70,
size: 16,
),
const SizedBox(width: 4),
Text(
'${viewerState.currentCameraId}',
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
],
if (viewerState.hasAlarm) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFDC2626),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'ALARM',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
);
}
}

View File

@@ -0,0 +1,247 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../blocs/wall/wall_bloc.dart';
import '../../blocs/wall/wall_event.dart';
import '../../blocs/wall/wall_state.dart';
/// Camera number input widget with prefix selection.
///
/// Legacy behavior: field starts empty, digits typed by user (up to 6),
/// prefix applied on Enter via [WallState.fullCameraNumber].
class CameraInputWidget extends StatefulWidget {
const CameraInputWidget({super.key});
@override
State<CameraInputWidget> createState() => _CameraInputWidgetState();
}
class _CameraInputWidgetState extends State<CameraInputWidget> {
final FocusNode _focusNode = FocusNode();
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<WallBloc, WallState>(
builder: (context, state) {
final hasSelection = state.selectedViewerId != null;
return KeyboardListener(
focusNode: _focusNode,
autofocus: true,
onKeyEvent: (event) => _handleKeyEvent(context, event, state),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Prefix buttons
_PrefixButton(
prefix: 500,
isSelected: state.cameraPrefix == 500,
onTap: hasSelection
? () => context
.read<WallBloc>()
.add(const SetCameraPrefix(500))
: null,
),
const SizedBox(width: 4),
_PrefixButton(
prefix: 501,
isSelected: state.cameraPrefix == 501,
onTap: hasSelection
? () => context
.read<WallBloc>()
.add(const SetCameraPrefix(501))
: null,
),
const SizedBox(width: 4),
_PrefixButton(
prefix: 502,
isSelected: state.cameraPrefix == 502,
onTap: hasSelection
? () => context
.read<WallBloc>()
.add(const SetCameraPrefix(502))
: null,
),
const SizedBox(width: 16),
// Camera number display (shows only typed digits)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: state.isEditing
? const Color(0xFF1A3A5C)
: (hasSelection
? const Color(0xFF2D3748)
: const Color(0xFF1A202C)),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: state.isEditing
? const Color(0xFF00D4FF)
: (hasSelection
? const Color(0xFF4A5568)
: const Color(0xFF2D3748)),
width: state.isEditing ? 2 : 1,
),
),
child: SizedBox(
width: 100,
child: Text(
state.cameraInputDisplay,
style: TextStyle(
color: hasSelection
? Colors.white
: const Color(0xFF4A5568),
fontSize: 20,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
letterSpacing: 2,
),
),
),
),
const SizedBox(width: 8),
// Backspace button
IconButton(
icon: const Icon(Icons.backspace),
color: Colors.white70,
onPressed: hasSelection && state.cameraNumberInput.isNotEmpty
? () => context
.read<WallBloc>()
.add(const BackspaceCameraDigit())
: null,
),
// Execute button
ElevatedButton.icon(
icon: const Icon(Icons.send),
label: const Text('OK'),
style: ElevatedButton.styleFrom(
backgroundColor:
state.canExecuteCrossSwitch ? Colors.green : Colors.grey,
foregroundColor: Colors.white,
),
onPressed: state.canExecuteCrossSwitch
? () => context
.read<WallBloc>()
.add(const ExecuteCrossSwitch())
: null,
),
],
),
);
},
);
}
void _handleKeyEvent(
BuildContext context, KeyEvent event, WallState state) {
if (event is! KeyDownEvent) return;
if (state.selectedViewerId == null) return;
final key = event.logicalKey;
// Handle digit keys
if (key.keyId >= LogicalKeyboardKey.digit0.keyId &&
key.keyId <= LogicalKeyboardKey.digit9.keyId) {
final digit = key.keyId - LogicalKeyboardKey.digit0.keyId;
context.read<WallBloc>().add(AddCameraDigit(digit));
return;
}
// Handle numpad digits
if (key.keyId >= LogicalKeyboardKey.numpad0.keyId &&
key.keyId <= LogicalKeyboardKey.numpad9.keyId) {
final digit = key.keyId - LogicalKeyboardKey.numpad0.keyId;
context.read<WallBloc>().add(AddCameraDigit(digit));
return;
}
// Handle Enter
if (key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.numpadEnter) {
if (state.canExecuteCrossSwitch) {
context.read<WallBloc>().add(const ExecuteCrossSwitch());
}
return;
}
// Handle Backspace — remove last digit
if (key == LogicalKeyboardKey.backspace) {
context.read<WallBloc>().add(const BackspaceCameraDigit());
return;
}
// Handle Delete — cancel edit
if (key == LogicalKeyboardKey.delete) {
context.read<WallBloc>().add(const CancelCameraEdit());
return;
}
// Handle Escape
if (key == LogicalKeyboardKey.escape) {
if (state.isEditing) {
context.read<WallBloc>().add(const CancelCameraEdit());
} else {
context.read<WallBloc>().add(const DeselectViewer());
}
return;
}
// Handle period / Tab — cycle prefix
if (key == LogicalKeyboardKey.period ||
key == LogicalKeyboardKey.numpadDecimal) {
context.read<WallBloc>().add(const CycleCameraPrefix());
return;
}
}
}
class _PrefixButton extends StatelessWidget {
final int prefix;
final bool isSelected;
final VoidCallback? onTap;
const _PrefixButton({
required this.prefix,
required this.isSelected,
this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: isSelected ? const Color(0xFF3182CE) : const Color(0xFF2D3748),
borderRadius: BorderRadius.circular(4),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: isSelected
? const Color(0xFF63B3ED)
: const Color(0xFF4A5568),
width: isSelected ? 2.0 : 1.0,
),
),
child: Text(
'$prefix',
style: TextStyle(
color: onTap != null ? Colors.white : const Color(0xFF4A5568),
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
/// Function buttons (HOME, F1-F7) for wall presets
class FunctionButtonsWidget extends StatelessWidget {
final Function(String buttonId)? onButtonPressed;
const FunctionButtonsWidget({
super.key,
this.onButtonPressed,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_FunctionButton(
label: 'HOME',
buttonId: 'HOME',
onPressed: onButtonPressed,
isHome: true,
),
const SizedBox(width: 4),
...List.generate(7, (index) {
return Padding(
padding: const EdgeInsets.only(left: 4),
child: _FunctionButton(
label: 'F${index + 1}',
buttonId: 'F${index + 1}',
onPressed: onButtonPressed,
),
);
}),
],
);
}
}
class _FunctionButton extends StatelessWidget {
final String label;
final String buttonId;
final Function(String)? onPressed;
final bool isHome;
const _FunctionButton({
required this.label,
required this.buttonId,
this.onPressed,
this.isHome = false,
});
@override
Widget build(BuildContext context) {
return Material(
color: isHome ? const Color(0xFF2B6CB0) : const Color(0xFF4A5568),
borderRadius: BorderRadius.circular(4),
child: InkWell(
onTap: onPressed != null ? () => onPressed!(buttonId) : null,
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import '../../../domain/entities/wall_config.dart';
import '../../blocs/wall/wall_state.dart';
import 'viewer_tile.dart';
/// A physical monitor that can contain 1-4 viewers (quad view)
class PhysicalMonitorTile extends StatelessWidget {
final PhysicalMonitor monitor;
final WallState wallState;
final Function(int viewerId) onViewerTap;
const PhysicalMonitorTile({
super.key,
required this.monitor,
required this.wallState,
required this.onViewerTap,
});
@override
Widget build(BuildContext context) {
final isSelected = wallState.isPhysicalMonitorSelected(monitor);
final hasAlarm = _hasAnyAlarm();
return Container(
decoration: BoxDecoration(
border: isSelected
? Border.all(color: const Color(0xFF00D4FF), width: 3)
: Border.all(color: const Color(0xFF4A5568), width: 1),
borderRadius: BorderRadius.circular(6),
color: hasAlarm
? const Color(0xFFDC2626).withValues(alpha: 0.3)
: Colors.transparent,
),
child: Padding(
padding: const EdgeInsets.all(2),
child: monitor.hasMultipleViewers
? _buildQuadView(context)
: _buildSingleView(context),
),
);
}
bool _hasAnyAlarm() {
for (final viewerId in monitor.viewerIds) {
if (wallState.getViewerState(viewerId).hasAlarm) {
return true;
}
}
return false;
}
Widget _buildSingleView(BuildContext context) {
final viewerId = monitor.primaryViewerId;
final viewerState = wallState.getViewerState(viewerId);
final isViewerSelected = wallState.isViewerSelected(viewerId);
return AspectRatio(
aspectRatio: 16 / 9,
child: ViewerTile(
viewerId: viewerId,
viewerState: viewerState,
isSelected: isViewerSelected,
onTap: () => onViewerTap(viewerId),
),
);
}
Widget _buildQuadView(BuildContext context) {
// Arrange viewers in 2x2 grid
return AspectRatio(
aspectRatio: 16 / 9,
child: GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 2,
crossAxisSpacing: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: monitor.viewerIds.map((viewerId) {
final viewerState = wallState.getViewerState(viewerId);
final isViewerSelected = wallState.isViewerSelected(viewerId);
return ViewerTile(
viewerId: viewerId,
viewerState: viewerState,
isSelected: isViewerSelected,
isPartOfQuad: true,
onTap: () => onViewerTap(viewerId),
);
}).toList(),
),
);
}
}

View File

@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import '../../blocs/wall/wall_state.dart';
/// A single viewer tile within a physical monitor
class ViewerTile extends StatelessWidget {
final int viewerId;
final ViewerState viewerState;
final bool isSelected;
final bool isPartOfQuad;
final VoidCallback onTap;
const ViewerTile({
super.key,
required this.viewerId,
required this.viewerState,
required this.isSelected,
this.isPartOfQuad = false,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: _getBackgroundColor(context),
border: isSelected && !isPartOfQuad
? Border.all(color: const Color(0xFF00D4FF), width: 3)
: Border.all(color: const Color(0xFF4A5568), width: 1),
borderRadius: BorderRadius.circular(4),
),
child: Stack(
children: [
// Main content
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Viewer ID
Text(
'$viewerId',
style: TextStyle(
color: Colors.white,
fontSize: isPartOfQuad ? 12 : 16,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
),
// Camera ID if assigned
if (viewerState.hasCamera)
Text(
'${viewerState.currentCameraId}',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: isPartOfQuad ? 9 : 11,
fontFamily: 'monospace',
),
),
],
),
),
// Lock indicator
if (viewerState.isLocked)
Positioned(
top: 2,
right: 2,
child: Icon(
Icons.lock,
size: isPartOfQuad ? 10 : 14,
color: viewerState.isLockedByOther
? Colors.red
: Colors.green,
),
),
// Live/Playback indicator
if (viewerState.hasCamera && !viewerState.isLive)
Positioned(
bottom: 2,
left: 2,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(2),
),
child: Text(
'PvZ',
style: TextStyle(
color: Colors.white,
fontSize: isPartOfQuad ? 7 : 9,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
);
}
Color _getBackgroundColor(BuildContext context) {
if (viewerState.hasAlarm) {
return const Color(0xFFDC2626); // Red for alarm
}
if (viewerState.hasCamera) {
return const Color(0xFF2D3748); // Dark gray with camera
}
return const Color(0xFF1A202C); // Darker gray without camera
}
}

View File

@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../blocs/wall/wall_bloc.dart';
import '../../blocs/wall/wall_event.dart';
import '../../blocs/wall/wall_state.dart';
import 'wall_section_widget.dart';
/// Main video wall grid displaying all sections
class WallGrid extends StatelessWidget {
const WallGrid({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<WallBloc, WallState>(
builder: (context, state) {
if (state.isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (state.error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 48),
const SizedBox(height: 16),
Text(
state.error!,
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
context.read<WallBloc>().add(const LoadWallConfig());
},
child: const Text('Retry'),
),
],
),
);
}
final config = state.config;
if (config == null) {
return const Center(
child: Text(
'No wall configuration loaded',
style: TextStyle(color: Colors.white54),
),
);
}
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Wall name header
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
config.name,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
// Sections
...config.sections.map((section) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: WallSectionWidget(
section: section,
wallState: state,
isExpanded: state.isSectionExpanded(section.id),
onToggleExpanded: () {
context
.read<WallBloc>()
.add(ToggleSectionExpanded(section.id));
},
onViewerTap: (viewerId) {
context.read<WallBloc>().add(SelectViewer(viewerId));
},
),
);
}),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import '../../../domain/entities/wall_config.dart';
import '../../blocs/wall/wall_state.dart';
import 'physical_monitor_tile.dart';
/// A collapsible section of the video wall
class WallSectionWidget extends StatelessWidget {
final WallSection section;
final WallState wallState;
final bool isExpanded;
final VoidCallback onToggleExpanded;
final Function(int viewerId) onViewerTap;
const WallSectionWidget({
super.key,
required this.section,
required this.wallState,
required this.isExpanded,
required this.onToggleExpanded,
required this.onViewerTap,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section header
InkWell(
onTap: onToggleExpanded,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFF2D3748),
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
color: Colors.white70,
size: 20,
),
const SizedBox(width: 8),
Text(
section.name,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Text(
'${section.monitors.length} monitors',
style: const TextStyle(
color: Colors.white54,
fontSize: 12,
),
),
],
),
),
),
// Monitors grid
if (isExpanded)
Padding(
padding: const EdgeInsets.only(top: 8),
child: LayoutBuilder(
builder: (context, constraints) {
// Calculate tile width based on columns
final tileWidth =
(constraints.maxWidth - (section.columns - 1) * 8) /
section.columns;
final tileHeight = tileWidth * 9 / 16; // 16:9 aspect ratio
return Wrap(
spacing: 8,
runSpacing: 8,
children: section.monitors.map((monitor) {
return SizedBox(
width: tileWidth,
height: tileHeight + 8, // Extra padding for quad border
child: PhysicalMonitorTile(
monitor: monitor,
wallState: wallState,
onViewerTap: onViewerTap,
),
);
}).toList(),
);
},
),
),
],
);
}
}