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,675 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../domain/entities/wall_config.dart';
import '../../blocs/wall/wall_bloc.dart';
import '../../blocs/wall/wall_event.dart';
import '../../blocs/wall/wall_state.dart';
/// Full section view matching D6 app design
class SectionView extends StatelessWidget {
final WallSection section;
final WallState wallState;
final VoidCallback onBack;
final Function(int viewerId) onViewerTap;
const SectionView({
super.key,
required this.section,
required this.wallState,
required this.onBack,
required this.onViewerTap,
});
@override
Widget build(BuildContext context) {
return Container(
color: const Color(0xFF555555), // D6 background color
child: Column(
children: [
// Header bar
_HeaderBar(
section: section,
wallState: wallState,
),
// Monitors grid - takes all available space
Expanded(
child: _MonitorsGrid(
section: section,
wallState: wallState,
onViewerTap: onViewerTap,
),
),
// Bottom toolbar with circular icons
_BottomIconBar(
wallState: wallState,
onSegmentsTap: onBack,
),
],
),
);
}
}
class _HeaderBar extends StatelessWidget {
final WallSection section;
final WallState wallState;
const _HeaderBar({
required this.section,
required this.wallState,
});
@override
Widget build(BuildContext context) {
// Get section number from id
final sectionNum = _getSectionNumber(section.id);
return Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 8),
color: const Color(0xFF555555),
child: Row(
children: [
// Video camera icon (matching D6 app)
const Icon(
Icons.videocam,
color: Colors.white,
size: 24,
),
const SizedBox(width: 8),
// Section name
Text(
'$sectionNum | ${section.name}',
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w500,
),
),
const Spacer(),
// Status message (red when server unavailable)
const Text(
'Aplikační server není dostupný, některé funkce nejsou k dispozici',
style: TextStyle(
color: Color(0xFFFF4444),
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 8),
// Status icons
const Icon(Icons.dns, color: Color(0xFFFF4444), size: 28),
const SizedBox(width: 6),
const Icon(Icons.lan, color: Color(0xFF24FF00), size: 28),
],
),
);
}
int _getSectionNumber(String id) {
switch (id) {
case 'left': return 1;
case 'center': return 2;
case 'right': return 3;
case 'top': return 4;
case 'bottom': return 5;
default: return 0;
}
}
}
class _MonitorsGrid extends StatelessWidget {
final WallSection section;
final WallState wallState;
final Function(int viewerId) onViewerTap;
const _MonitorsGrid({
required this.section,
required this.wallState,
required this.onViewerTap,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final monitors = section.monitors;
final gridCols = section.columns;
final gridRows = section.rows;
// Calculate cell dimensions based on grid size
final cellWidth = constraints.maxWidth / gridCols;
final cellHeight = constraints.maxHeight / gridRows;
// Build a map of which physical monitor each grid cell belongs to
final cellToMonitor = <String, PhysicalMonitor>{};
for (final monitor in monitors) {
for (int r = 0; r < monitor.rowSpan; r++) {
for (int c = 0; c < monitor.colSpan; c++) {
final gridRow = monitor.row - 1 + r;
final gridCol = monitor.col - 1 + c;
cellToMonitor['$gridRow,$gridCol'] = monitor;
}
}
}
// Position monitors using their explicit row/col values
return Stack(
children: [
// First layer: monitor content without borders
...monitors.map((monitor) {
final row = monitor.row - 1;
final col = monitor.col - 1;
return Positioned(
left: col * cellWidth,
top: row * cellHeight,
width: monitor.colSpan * cellWidth,
height: monitor.rowSpan * cellHeight,
child: _PhysicalMonitorContent(
monitor: monitor,
wallState: wallState,
onViewerTap: onViewerTap,
),
);
}),
// Second layer: border overlay
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _GridBorderPainter(
monitors: monitors,
gridCols: gridCols,
gridRows: gridRows,
cellToMonitor: cellToMonitor,
),
),
),
),
],
);
},
);
}
}
/// Custom painter that draws all grid borders with correct colors
class _GridBorderPainter extends CustomPainter {
final List<PhysicalMonitor> monitors;
final int gridCols;
final int gridRows;
final Map<String, PhysicalMonitor> cellToMonitor;
static const _borderWidth = 2.0;
static const _cyanColor = Color(0xFF00BFFF);
static const _darkColor = Color(0xFF4A4A4A);
_GridBorderPainter({
required this.monitors,
required this.gridCols,
required this.gridRows,
required this.cellToMonitor,
});
@override
void paint(Canvas canvas, Size size) {
final cellWidth = size.width / gridCols;
final cellHeight = size.height / gridRows;
final cyanPaint = Paint()
..color = _cyanColor
..strokeWidth = _borderWidth
..style = PaintingStyle.stroke;
final darkPaint = Paint()
..color = _darkColor
..strokeWidth = _borderWidth
..style = PaintingStyle.stroke;
// Collect all border segments, draw dark first then cyan on top
final darkLines = <_LineSegment>[];
final cyanLines = <_LineSegment>[];
// Collect horizontal lines
for (int row = 0; row <= gridRows; row++) {
for (int col = 0; col < gridCols; col++) {
final x1 = col * cellWidth;
final x2 = (col + 1) * cellWidth;
final y = row * cellHeight;
final cellAbove = row > 0 ? cellToMonitor['${row - 1},$col'] : null;
final cellBelow = row < gridRows ? cellToMonitor['$row,$col'] : null;
// Only draw border if at least one side has a physical monitor
if (cellAbove == null && cellBelow == null) continue;
// Cyan if: edge of physical monitor (one side empty or different monitor)
final isCyan = cellAbove == null || cellBelow == null ||
cellAbove.id != cellBelow.id;
// Skip internal borders for single-viewer monitors
if (!isCyan && cellAbove != null && cellAbove.viewerIds.length == 1) {
continue;
}
final segment = _LineSegment(Offset(x1, y), Offset(x2, y));
if (isCyan) {
cyanLines.add(segment);
} else {
darkLines.add(segment);
}
}
}
// Collect vertical lines
for (int col = 0; col <= gridCols; col++) {
for (int row = 0; row < gridRows; row++) {
final x = col * cellWidth;
final y1 = row * cellHeight;
final y2 = (row + 1) * cellHeight;
final cellLeft = col > 0 ? cellToMonitor['$row,${col - 1}'] : null;
final cellRight = col < gridCols ? cellToMonitor['$row,$col'] : null;
// Only draw border if at least one side has a physical monitor
if (cellLeft == null && cellRight == null) continue;
// Cyan if: edge of physical monitor (one side empty or different monitor)
final isCyan = cellLeft == null || cellRight == null ||
cellLeft.id != cellRight.id;
// Skip internal borders for single-viewer monitors
if (!isCyan && cellLeft != null && cellLeft.viewerIds.length == 1) {
continue;
}
final segment = _LineSegment(Offset(x, y1), Offset(x, y2));
if (isCyan) {
cyanLines.add(segment);
} else {
darkLines.add(segment);
}
}
}
// Draw dark borders first (behind)
for (final line in darkLines) {
canvas.drawLine(line.start, line.end, darkPaint);
}
// Draw cyan borders on top (in front)
for (final line in cyanLines) {
canvas.drawLine(line.start, line.end, cyanPaint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class _LineSegment {
final Offset start;
final Offset end;
_LineSegment(this.start, this.end);
}
/// Physical monitor content without borders (borders drawn by overlay)
class _PhysicalMonitorContent extends StatelessWidget {
final PhysicalMonitor monitor;
final WallState wallState;
final Function(int viewerId) onViewerTap;
const _PhysicalMonitorContent({
required this.monitor,
required this.wallState,
required this.onViewerTap,
});
@override
Widget build(BuildContext context) {
final viewers = monitor.viewerIds;
// Single viewer fills entire monitor space
if (viewers.length == 1) {
final viewerId = viewers.first;
final isSelected = wallState.isViewerSelected(viewerId);
return _ViewerTile(
viewerId: viewerId,
viewerState: wallState.getViewerState(viewerId),
isSelected: isSelected,
cameraInputDisplay: isSelected ? wallState.cameraInputDisplay : null,
onTap: () => onViewerTap(viewerId),
);
}
// Multiple viewers: determine grid based on viewer count and monitor shape
final gridCols = monitor.colSpan;
final gridRows = monitor.rowSpan;
return Column(
children: List.generate(gridRows, (row) {
return Expanded(
child: Row(
children: List.generate(gridCols, (col) {
final index = row * gridCols + col;
if (index >= viewers.length) {
return Expanded(
child: Container(
color: const Color(0xFF6A6A6A),
),
);
}
final viewerId = viewers[index];
final isSelected = wallState.isViewerSelected(viewerId);
return Expanded(
child: _ViewerTile(
viewerId: viewerId,
viewerState: wallState.getViewerState(viewerId),
isSelected: isSelected,
cameraInputDisplay: isSelected ? wallState.cameraInputDisplay : null,
onTap: () => onViewerTap(viewerId),
),
);
}),
),
);
}),
);
}
}
class _ViewerTile extends StatelessWidget {
final int viewerId;
final ViewerState viewerState;
final bool isSelected;
final String? cameraInputDisplay;
final VoidCallback onTap;
const _ViewerTile({
required this.viewerId,
required this.viewerState,
required this.isSelected,
this.cameraInputDisplay,
required this.onTap,
});
@override
Widget build(BuildContext context) {
// D6 style: selected = cyan fill, alarm = red, normal = gray
Color bgColor;
if (isSelected) {
bgColor = const Color(0xFF00D4FF); // Cyan fill for selected
} else if (viewerState.hasAlarm) {
bgColor = const Color(0xFFDC2626); // Red for alarm
} else {
bgColor = const Color(0xFF6A6A6A); // Gray for normal
}
return GestureDetector(
onTap: onTap,
child: Container(
color: bgColor,
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Viewer ID at top (smaller)
Text(
'$viewerId',
style: TextStyle(
color: isSelected ? Colors.white : Colors.white,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
// Camera number - large white text, matching D6 style
if (isSelected && cameraInputDisplay != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
cameraInputDisplay!,
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
);
}
}
class _ViewerTileWithBorder extends StatelessWidget {
final int viewerId;
final ViewerState viewerState;
final bool isSelected;
final String? cameraInputDisplay;
final VoidCallback onTap;
final Border border;
const _ViewerTileWithBorder({
required this.viewerId,
required this.viewerState,
required this.isSelected,
this.cameraInputDisplay,
required this.onTap,
required this.border,
});
@override
Widget build(BuildContext context) {
// D6 style: selected = cyan fill, alarm = red, normal = gray
Color bgColor;
if (isSelected) {
bgColor = const Color(0xFF00D4FF); // Cyan fill for selected
} else if (viewerState.hasAlarm) {
bgColor = const Color(0xFFDC2626); // Red for alarm
} else {
bgColor = const Color(0xFF6A6A6A); // Gray for normal
}
return GestureDetector(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: bgColor,
border: border,
),
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Viewer ID at top (smaller)
Text(
'$viewerId',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
// Camera number - large white text, matching D6 style
if (isSelected && cameraInputDisplay != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
cameraInputDisplay!,
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
);
}
}
class _BottomIconBar extends StatelessWidget {
final WallState wallState;
final VoidCallback onSegmentsTap;
const _BottomIconBar({
required this.wallState,
required this.onSegmentsTap,
});
@override
Widget build(BuildContext context) {
final hasSelection = wallState.selectedViewerId != null;
return Container(
height: 86,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: const Color(0xFF555555),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
// Search
_CircleIconButton(
icon: Icons.search,
isActive: hasSelection,
onTap: () {},
),
// Lock
_CircleIconButton(
icon: Icons.lock_outline,
onTap: () {},
),
// Quad view
_CircleIconButton(
icon: Icons.grid_view,
onTap: () {},
),
// Segments - navigate to overview
_CircleIconButton(
icon: Icons.apps,
isActive: true,
onTap: onSegmentsTap,
),
// Image/Camera
_CircleIconButton(
icon: Icons.image_outlined,
onTap: () {},
),
// Alarm (red)
_CircleIconButton(
icon: Icons.notification_important,
iconColor: const Color(0xFFCC4444),
onTap: () {},
),
// History
_CircleIconButton(
icon: Icons.history,
onTap: () {},
),
// Monitor
_CircleIconButton(
icon: Icons.tv,
onTap: () {},
),
// Prefix selector
_PrefixButton(
prefix: wallState.cameraPrefix,
onTap: () {
// Cycle through prefixes
final nextPrefix = wallState.cameraPrefix == 500 ? 501
: wallState.cameraPrefix == 501 ? 502 : 500;
context.read<WallBloc>().add(SetCameraPrefix(nextPrefix));
},
),
],
),
);
}
}
class _CircleIconButton extends StatelessWidget {
final IconData icon;
final bool isActive;
final Color? iconColor;
final VoidCallback onTap;
const _CircleIconButton({
required this.icon,
this.isActive = false,
this.iconColor,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: isActive ? Colors.white : Colors.white38,
width: 2,
),
color: isActive ? const Color(0xFF333333) : Colors.transparent,
),
child: Icon(
icon,
color: iconColor ?? (isActive ? Colors.white : Colors.white60),
size: 38,
),
),
);
}
}
class _PrefixButton extends StatelessWidget {
final int prefix;
final VoidCallback onTap;
const _PrefixButton({
required this.prefix,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
color: const Color(0xFF333333),
),
child: Center(
child: Text(
'$prefix',
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
}