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>
676 lines
19 KiB
Dart
676 lines
19 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|