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,303 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../blocs/wall/wall_bloc.dart';
|
||||
import '../../blocs/wall/wall_state.dart';
|
||||
import 'camera_input_widget.dart';
|
||||
import 'function_buttons_widget.dart';
|
||||
|
||||
/// Bottom toolbar with camera input and action buttons
|
||||
class BottomToolbar extends StatelessWidget {
|
||||
final VoidCallback? onSearchPressed;
|
||||
final VoidCallback? onPrepositionPressed;
|
||||
final VoidCallback? onPlaybackPressed;
|
||||
final VoidCallback? onAlarmListPressed;
|
||||
final VoidCallback? onLockPressed;
|
||||
final VoidCallback? onSequencePressed;
|
||||
final Function(String)? onFunctionButtonPressed;
|
||||
|
||||
const BottomToolbar({
|
||||
super.key,
|
||||
this.onSearchPressed,
|
||||
this.onPrepositionPressed,
|
||||
this.onPlaybackPressed,
|
||||
this.onAlarmListPressed,
|
||||
this.onLockPressed,
|
||||
this.onSequencePressed,
|
||||
this.onFunctionButtonPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF1A202C),
|
||||
border: Border(
|
||||
top: BorderSide(color: Color(0xFF4A5568), width: 1),
|
||||
),
|
||||
),
|
||||
child: BlocBuilder<WallBloc, WallState>(
|
||||
builder: (context, state) {
|
||||
final hasSelection = state.selectedViewerId != null;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Top row - Camera input and function buttons
|
||||
Row(
|
||||
children: [
|
||||
// Camera input
|
||||
const Expanded(
|
||||
child: CameraInputWidget(),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
// Function buttons
|
||||
FunctionButtonsWidget(
|
||||
onButtonPressed: onFunctionButtonPressed,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Bottom row - Context actions
|
||||
Row(
|
||||
children: [
|
||||
if (hasSelection) ...[
|
||||
// Search button
|
||||
_ActionButton(
|
||||
icon: Icons.search,
|
||||
label: 'Hledat',
|
||||
onPressed: onSearchPressed,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Preposition button
|
||||
_ActionButton(
|
||||
icon: Icons.place,
|
||||
label: 'Prepozice',
|
||||
onPressed: onPrepositionPressed,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Playback button
|
||||
_ActionButton(
|
||||
icon: Icons.history,
|
||||
label: 'PvZ',
|
||||
onPressed: onPlaybackPressed,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Alarm list button
|
||||
_ActionButton(
|
||||
icon: Icons.notifications,
|
||||
label: 'Alarmy',
|
||||
onPressed: onAlarmListPressed,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Sequence button
|
||||
_ActionButton(
|
||||
icon: Icons.loop,
|
||||
label: 'Sekvence',
|
||||
onPressed: onSequencePressed,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Lock button
|
||||
_LockButton(
|
||||
viewerState: state.getViewerState(state.selectedViewerId!),
|
||||
onPressed: onLockPressed,
|
||||
),
|
||||
] else ...[
|
||||
// Show selection hint when nothing selected
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Vyberte monitor pro zobrazení akcí',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF718096),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
// Selected monitor info
|
||||
if (hasSelection)
|
||||
_SelectionInfo(
|
||||
viewerId: state.selectedViewerId!,
|
||||
viewerState: state.getViewerState(state.selectedViewerId!),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const _ActionButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: const Color(0xFF2D3748),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: onPressed != null ? Colors.white : const Color(0xFF4A5568),
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: onPressed != null ? Colors.white : const Color(0xFF4A5568),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LockButton extends StatelessWidget {
|
||||
final ViewerState viewerState;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const _LockButton({
|
||||
required this.viewerState,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLocked = viewerState.isLocked;
|
||||
final isLockedByOther = viewerState.isLockedByOther;
|
||||
|
||||
return Material(
|
||||
color: isLocked
|
||||
? (isLockedByOther ? const Color(0xFFDC2626) : const Color(0xFF38A169))
|
||||
: const Color(0xFF2D3748),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: InkWell(
|
||||
onTap: isLockedByOther ? null : onPressed,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isLocked ? Icons.lock : Icons.lock_open,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
isLocked ? 'Zamčeno' : 'Zamknout',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectionInfo extends StatelessWidget {
|
||||
final int viewerId;
|
||||
final ViewerState viewerState;
|
||||
|
||||
const _SelectionInfo({
|
||||
required this.viewerId,
|
||||
required this.viewerState,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2D3748),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: const Color(0xFF00D4FF), width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.tv,
|
||||
color: Color(0xFF00D4FF),
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Monitor $viewerId',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (viewerState.hasCamera) ...[
|
||||
const SizedBox(width: 12),
|
||||
const Icon(
|
||||
Icons.videocam,
|
||||
color: Colors.white70,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${viewerState.currentCameraId}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (viewerState.hasAlarm) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFDC2626),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'ALARM',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Function buttons (HOME, F1-F7) for wall presets
|
||||
class FunctionButtonsWidget extends StatelessWidget {
|
||||
final Function(String buttonId)? onButtonPressed;
|
||||
|
||||
const FunctionButtonsWidget({
|
||||
super.key,
|
||||
this.onButtonPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_FunctionButton(
|
||||
label: 'HOME',
|
||||
buttonId: 'HOME',
|
||||
onPressed: onButtonPressed,
|
||||
isHome: true,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
...List.generate(7, (index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: _FunctionButton(
|
||||
label: 'F${index + 1}',
|
||||
buttonId: 'F${index + 1}',
|
||||
onPressed: onButtonPressed,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FunctionButton extends StatelessWidget {
|
||||
final String label;
|
||||
final String buttonId;
|
||||
final Function(String)? onPressed;
|
||||
final bool isHome;
|
||||
|
||||
const _FunctionButton({
|
||||
required this.label,
|
||||
required this.buttonId,
|
||||
this.onPressed,
|
||||
this.isHome = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: isHome ? const Color(0xFF2B6CB0) : const Color(0xFF4A5568),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: InkWell(
|
||||
onTap: onPressed != null ? () => onPressed!(buttonId) : null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user