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,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user