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,349 @@
import 'package:flutter/material.dart' hide LockState;
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../config/app_config.dart';
import '../../data/services/function_button_service.dart';
import '../../injection_container.dart';
import '../blocs/connection/connection_bloc.dart';
import '../blocs/camera/camera_bloc.dart';
import '../blocs/monitor/monitor_bloc.dart';
import '../blocs/ptz/ptz_bloc.dart';
import '../blocs/alarm/alarm_bloc.dart';
import '../blocs/lock/lock_bloc.dart';
import '../blocs/lock/lock_event.dart';
import '../blocs/lock/lock_state.dart';
import '../blocs/sequence/sequence_bloc.dart';
import '../blocs/sequence/sequence_event.dart';
import '../blocs/wall/wall_bloc.dart';
import '../blocs/wall/wall_event.dart';
import '../widgets/wall_grid/wall_grid.dart';
import '../widgets/toolbar/bottom_toolbar.dart';
import '../widgets/connection_status_bar.dart';
import '../widgets/ptz_control.dart';
import '../widgets/sequence_panel.dart';
import '../widgets/takeover_dialog.dart';
class KeyboardScreen extends StatelessWidget {
const KeyboardScreen({super.key});
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<ConnectionBloc>(create: (_) => sl<ConnectionBloc>()),
BlocProvider<CameraBloc>(create: (_) => sl<CameraBloc>()),
BlocProvider<MonitorBloc>(create: (_) => sl<MonitorBloc>()),
BlocProvider<PtzBloc>(create: (_) => sl<PtzBloc>()),
BlocProvider<AlarmBloc>(create: (_) => sl<AlarmBloc>()),
BlocProvider<LockBloc>(create: (_) => sl<LockBloc>()),
BlocProvider<SequenceBloc>(
create: (_) => sl<SequenceBloc>()..add(LoadSequences()),
),
BlocProvider<WallBloc>(
create: (_) => sl<WallBloc>()..add(const LoadWallConfig()),
),
],
child: BlocListener<LockBloc, LockState>(
listenWhen: (prev, curr) =>
prev.pendingTakeover != curr.pendingTakeover &&
curr.pendingTakeover != null,
listener: (context, state) {
if (state.pendingTakeover != null) {
showTakeoverDialog(context, state.pendingTakeover!);
}
},
child: const _KeyboardScreenContent(),
),
);
}
}
class _KeyboardScreenContent extends StatelessWidget {
const _KeyboardScreenContent();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0D1117),
body: Column(
children: [
// Top bar with connection status
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: const BoxDecoration(
color: Color(0xFF161B22),
border: Border(
bottom: BorderSide(color: Color(0xFF30363D), width: 1),
),
),
child: Row(
children: [
const Text(
'COPILOT Keyboard',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 12),
const Text(
'v0.3.0-build5',
style: TextStyle(
color: Color(0xFF7F7F7F),
fontSize: 11,
),
),
const Spacer(),
const ConnectionStatusBar(),
],
),
),
// Main content area
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
// Wide layout with PTZ on the side
if (constraints.maxWidth > 1200) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Wall grid
Expanded(
flex: 4,
child: Padding(
padding: const EdgeInsets.all(16),
child: const WallGrid(),
),
),
// PTZ controls sidebar
Container(
width: 220,
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Color(0xFF161B22),
border: Border(
left: BorderSide(color: Color(0xFF30363D), width: 1),
),
),
child: const SingleChildScrollView(
child: PtzControl(),
),
),
],
);
}
// Narrow layout - PTZ in bottom sheet or collapsed
return Padding(
padding: const EdgeInsets.all(16),
child: const WallGrid(),
);
},
),
),
// Bottom toolbar
BottomToolbar(
onSearchPressed: () => _showSearchDialog(context),
onPrepositionPressed: () => _showPrepositionDialog(context),
onPlaybackPressed: () => _showPlaybackOverlay(context),
onAlarmListPressed: () => _showAlarmListDialog(context),
onSequencePressed: () => _showSequenceDialog(context),
onLockPressed: () => _toggleLock(context),
onFunctionButtonPressed: (buttonId) =>
_executeFunctionButton(context, buttonId),
),
],
),
);
}
void _showSearchDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1A202C),
title: const Text(
'Hledat kameru',
style: TextStyle(color: Colors.white),
),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
hintText: 'Číslo nebo název kamery',
hintStyle: TextStyle(color: Colors.white54),
prefixIcon: Icon(Icons.search, color: Colors.white54),
border: OutlineInputBorder(),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.white24),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Color(0xFF00D4FF)),
),
),
autofocus: true,
),
const SizedBox(height: 16),
const Text(
'Funkce bude implementována v další fázi.',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Zavřít'),
),
],
),
);
}
void _showPrepositionDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1A202C),
title: const Text(
'Prepozice',
style: TextStyle(color: Colors.white),
),
content: const SizedBox(
width: 400,
height: 300,
child: Center(
child: Text(
'Seznam prepozic bude implementován v další fázi.',
style: TextStyle(color: Colors.white54),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Zavřít'),
),
],
),
);
}
void _showPlaybackOverlay(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Režim přehrávání (PvZ) bude implementován v další fázi.'),
backgroundColor: Color(0xFF2D3748),
),
);
}
void _showSequenceDialog(BuildContext context) {
final wallState = context.read<WallBloc>().state;
final viewerId = wallState.selectedViewerId;
if (viewerId == null) return;
// Refresh sequence list
context.read<SequenceBloc>().add(LoadSequences());
showDialog(
context: context,
builder: (_) => BlocProvider.value(
value: context.read<SequenceBloc>(),
child: AlertDialog(
backgroundColor: const Color(0xFF1A202C),
title: Text(
'Sekvence — Monitor $viewerId',
style: const TextStyle(color: Colors.white),
),
content: SizedBox(
width: 500,
height: 400,
child: SingleChildScrollView(
child: SequencePanel(viewerId: viewerId),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Zavřít'),
),
],
),
),
);
}
void _showAlarmListDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1A202C),
title: const Text(
'Historie alarmů',
style: TextStyle(color: Colors.white),
),
content: const SizedBox(
width: 500,
height: 400,
child: Center(
child: Text(
'Seznam alarmů bude implementován v další fázi.',
style: TextStyle(color: Colors.white54),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Zavřít'),
),
],
),
);
}
void _toggleLock(BuildContext context) {
final wallState = context.read<WallBloc>().state;
final viewerId = wallState.selectedViewerId;
if (viewerId == null) return;
final viewerState = wallState.getViewerState(viewerId);
final cameraId = viewerState.currentCameraId;
if (cameraId <= 0) return;
final lockBloc = context.read<LockBloc>();
final lockState = lockBloc.state;
if (lockState.isCameraLockedByMe(
cameraId, sl<AppConfig>().keyboardId)) {
lockBloc.add(ReleaseLock(cameraId));
} else {
lockBloc.add(TryLock(cameraId));
}
}
void _executeFunctionButton(BuildContext context, String buttonId) {
final wallBloc = context.read<WallBloc>();
final wallId = wallBloc.state.config?.id ?? '1';
final service = sl<FunctionButtonService>();
if (!service.hasActions(wallId, buttonId)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$buttonId — žádná akce pro tuto stěnu'),
backgroundColor: const Color(0xFF2D3748),
duration: const Duration(seconds: 1),
),
);
return;
}
service.execute(wallId, buttonId);
}
}

View File

@@ -0,0 +1,168 @@
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';
import '../widgets/overview/wall_overview.dart';
import '../widgets/section/section_view.dart';
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
String? _selectedSectionId;
final FocusNode _focusNode = FocusNode();
WallBloc? _wallBloc;
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<WallBloc, WallState>(
builder: (blocContext, state) {
// Store bloc reference for keyboard handler
_wallBloc = BlocProvider.of<WallBloc>(blocContext, listen: false);
return KeyboardListener(
focusNode: _focusNode,
autofocus: true,
onKeyEvent: _handleKeyEvent,
child: Scaffold(
backgroundColor: const Color(0xFF0A0E14),
body: _buildBody(blocContext, state),
),
);
},
);
}
Widget _buildBody(BuildContext context, WallState state) {
if (state.isLoading || state.config == null) {
return const Center(
child: CircularProgressIndicator(
color: Color(0xFF00D4FF),
),
);
}
// Show section view or wall overview
if (_selectedSectionId != null) {
final section = state.config!.sections.firstWhere(
(s) => s.id == _selectedSectionId,
orElse: () => state.config!.sections.first,
);
return SectionView(
section: section,
wallState: state,
onBack: () => setState(() => _selectedSectionId = null),
onViewerTap: (viewerId) {
context.read<WallBloc>().add(SelectViewer(viewerId));
// Re-request focus for keyboard input after tile tap
_focusNode.requestFocus();
},
);
}
return WallOverview(
config: state.config!,
wallState: state,
onSectionTap: (sectionId) {
setState(() => _selectedSectionId = sectionId);
},
);
}
void _handleKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent) return;
final bloc = _wallBloc;
if (bloc == null) return;
final state = bloc.state;
final key = event.logicalKey;
// Escape - go back or deselect
if (key == LogicalKeyboardKey.escape) {
if (state.selectedViewerId != null) {
bloc.add(const DeselectViewer());
} else if (_selectedSectionId != null) {
setState(() => _selectedSectionId = null);
}
return;
}
// Only handle camera input when a viewer is selected
if (state.selectedViewerId == null) return;
// Digit keys 0-9
if (key.keyId >= LogicalKeyboardKey.digit0.keyId &&
key.keyId <= LogicalKeyboardKey.digit9.keyId) {
final digit = key.keyId - LogicalKeyboardKey.digit0.keyId;
bloc.add(AddCameraDigit(digit));
return;
}
// Numpad digits
if (key.keyId >= LogicalKeyboardKey.numpad0.keyId &&
key.keyId <= LogicalKeyboardKey.numpad9.keyId) {
final digit = key.keyId - LogicalKeyboardKey.numpad0.keyId;
bloc.add(AddCameraDigit(digit));
return;
}
// Enter - execute CrossSwitch
if (key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.numpadEnter) {
if (state.canExecuteCrossSwitch) {
bloc.add(const ExecuteCrossSwitch());
}
return;
}
// Backspace - remove last digit
if (key == LogicalKeyboardKey.backspace) {
bloc.add(const BackspaceCameraDigit());
return;
}
// Delete - cancel edit
if (key == LogicalKeyboardKey.delete) {
bloc.add(const CancelCameraEdit());
return;
}
// Escape - cancel edit or deselect
if (key == LogicalKeyboardKey.escape) {
if (state.isEditing) {
bloc.add(const CancelCameraEdit());
} else {
bloc.add(const DeselectViewer());
}
return;
}
// F1-F3 for prefix selection
if (key == LogicalKeyboardKey.f1) {
bloc.add(const SetCameraPrefix(500));
return;
}
if (key == LogicalKeyboardKey.f2) {
bloc.add(const SetCameraPrefix(501));
return;
}
if (key == LogicalKeyboardKey.f3) {
bloc.add(const SetCameraPrefix(502));
return;
}
}
}