Initial commit: COPILOT D6 Flutter keyboard controller

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

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

View File

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