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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
168
copilot_keyboard/lib/presentation/blocs/lock/lock_bloc.dart
Normal file
168
copilot_keyboard/lib/presentation/blocs/lock/lock_bloc.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
60
copilot_keyboard/lib/presentation/blocs/lock/lock_event.dart
Normal file
60
copilot_keyboard/lib/presentation/blocs/lock/lock_event.dart
Normal 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);
|
||||
}
|
||||
75
copilot_keyboard/lib/presentation/blocs/lock/lock_state.dart
Normal file
75
copilot_keyboard/lib/presentation/blocs/lock/lock_state.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
189
copilot_keyboard/lib/presentation/blocs/ptz/ptz_bloc.dart
Normal file
189
copilot_keyboard/lib/presentation/blocs/ptz/ptz_bloc.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
90
copilot_keyboard/lib/presentation/blocs/ptz/ptz_event.dart
Normal file
90
copilot_keyboard/lib/presentation/blocs/ptz/ptz_event.dart
Normal 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];
|
||||
}
|
||||
50
copilot_keyboard/lib/presentation/blocs/ptz/ptz_state.dart
Normal file
50
copilot_keyboard/lib/presentation/blocs/ptz/ptz_state.dart
Normal 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];
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
237
copilot_keyboard/lib/presentation/blocs/wall/wall_bloc.dart
Normal file
237
copilot_keyboard/lib/presentation/blocs/wall/wall_bloc.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
131
copilot_keyboard/lib/presentation/blocs/wall/wall_event.dart
Normal file
131
copilot_keyboard/lib/presentation/blocs/wall/wall_event.dart
Normal 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];
|
||||
}
|
||||
180
copilot_keyboard/lib/presentation/blocs/wall/wall_state.dart
Normal file
180
copilot_keyboard/lib/presentation/blocs/wall/wall_state.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user