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