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>
262 lines
7.4 KiB
Dart
262 lines
7.4 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../../../domain/entities/wall_config.dart';
|
|
import '../../blocs/wall/wall_state.dart';
|
|
|
|
/// Overview screen showing all wall sections in spatial layout (matching D6)
|
|
class WallOverview extends StatelessWidget {
|
|
final WallConfig config;
|
|
final WallState wallState;
|
|
final Function(String sectionId) onSectionTap;
|
|
|
|
const WallOverview({
|
|
super.key,
|
|
required this.config,
|
|
required this.wallState,
|
|
required this.onSectionTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
color: const Color(0xFF5A5A5A), // D6 background color
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
return Stack(
|
|
children: _buildSpatialLayout(constraints),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Widget> _buildSpatialLayout(BoxConstraints constraints) {
|
|
final widgets = <Widget>[];
|
|
final width = constraints.maxWidth;
|
|
final height = constraints.maxHeight;
|
|
|
|
// Position sections based on D6 layout
|
|
// The D6 app shows:
|
|
// - "4. Vrchní část" (top) at the top center
|
|
// - "1. Levá část" (left), "2. Střed stěny" (center), "3. Pravá část" (right) in middle row
|
|
// - "5. Stupínek" (bottom) at the bottom center
|
|
|
|
for (final section in config.sections) {
|
|
final position = _getSectionPosition(section.id, width, height);
|
|
final size = _getSectionSize(section.id, width, height);
|
|
|
|
widgets.add(
|
|
Positioned(
|
|
left: position.dx,
|
|
top: position.dy,
|
|
width: size.width,
|
|
height: size.height,
|
|
child: _SectionTile(
|
|
section: section,
|
|
wallState: wallState,
|
|
onTap: () => onSectionTap(section.id),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return widgets;
|
|
}
|
|
|
|
Offset _getSectionPosition(String sectionId, double width, double height) {
|
|
// Layout matching D6 screenshot
|
|
switch (sectionId) {
|
|
case 'top': // 4. Vrchní část - top center
|
|
return Offset(width * 0.35, height * 0.05);
|
|
case 'left': // 1. Levá část - middle left
|
|
return Offset(width * 0.08, height * 0.35);
|
|
case 'center': // 2. Střed stěny - middle center
|
|
return Offset(width * 0.33, height * 0.35);
|
|
case 'right': // 3. Pravá část - middle right
|
|
return Offset(width * 0.58, height * 0.35);
|
|
case 'bottom': // 5. Stupínek - bottom center
|
|
return Offset(width * 0.33, height * 0.68);
|
|
default:
|
|
return Offset.zero;
|
|
}
|
|
}
|
|
|
|
Size _getSectionSize(String sectionId, double width, double height) {
|
|
// Sizes proportional to D6 layout
|
|
switch (sectionId) {
|
|
case 'top':
|
|
return Size(width * 0.30, height * 0.22);
|
|
case 'left':
|
|
return Size(width * 0.22, height * 0.25);
|
|
case 'center':
|
|
return Size(width * 0.22, height * 0.25);
|
|
case 'right':
|
|
return Size(width * 0.22, height * 0.25);
|
|
case 'bottom':
|
|
return Size(width * 0.30, height * 0.25);
|
|
default:
|
|
return Size(width * 0.2, height * 0.2);
|
|
}
|
|
}
|
|
}
|
|
|
|
class _SectionTile extends StatelessWidget {
|
|
final WallSection section;
|
|
final WallState wallState;
|
|
final VoidCallback onTap;
|
|
|
|
const _SectionTile({
|
|
required this.section,
|
|
required this.wallState,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Section label
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: Text(
|
|
'${_getSectionNumber(section.id)}. ${section.name}',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
// Mini monitors grid
|
|
Expanded(
|
|
child: _MiniMonitorsGrid(
|
|
section: section,
|
|
wallState: wallState,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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 _MiniMonitorsGrid extends StatelessWidget {
|
|
final WallSection section;
|
|
final WallState wallState;
|
|
|
|
const _MiniMonitorsGrid({
|
|
required this.section,
|
|
required this.wallState,
|
|
});
|
|
|
|
@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;
|
|
|
|
// Position monitors using their explicit row/col values (matching detail view)
|
|
return Stack(
|
|
children: monitors.map((monitor) {
|
|
// Convert 1-based row/col to 0-based for positioning
|
|
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: _MiniPhysicalMonitor(
|
|
monitor: monitor,
|
|
wallState: wallState,
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MiniPhysicalMonitor extends StatelessWidget {
|
|
final PhysicalMonitor monitor;
|
|
final WallState wallState;
|
|
|
|
const _MiniPhysicalMonitor({
|
|
required this.monitor,
|
|
required this.wallState,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final viewers = monitor.viewerIds;
|
|
final gridCols = monitor.colSpan;
|
|
final gridRows = monitor.rowSpan;
|
|
|
|
// Overview: no cyan borders, just dark grid lines between viewers
|
|
return Container(
|
|
color: const Color(0xFF4A4A4A), // Dark background shows as grid lines
|
|
child: 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: Padding(
|
|
padding: const EdgeInsets.all(0.5),
|
|
child: Container(
|
|
color: const Color(0xFF6A6A6A),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
final viewerId = viewers[index];
|
|
final viewerState = wallState.getViewerState(viewerId);
|
|
|
|
Color tileColor;
|
|
if (viewerState.hasAlarm) {
|
|
tileColor = const Color(0xFFDC2626);
|
|
} else {
|
|
tileColor = const Color(0xFF6A6A6A);
|
|
}
|
|
|
|
return Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(0.5),
|
|
child: Container(
|
|
color: tileColor,
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
}
|