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>
238 lines
7.4 KiB
Dart
238 lines
7.4 KiB
Dart
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));
|
|
}
|
|
}
|