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:
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