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,253 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/entities/alarm_state.dart';
import '../blocs/alarm/alarm_bloc.dart';
import '../blocs/alarm/alarm_event.dart';
import '../blocs/alarm/alarm_state.dart';
class AlarmPanel extends StatelessWidget {
final int maxDisplayed;
const AlarmPanel({super.key, this.maxDisplayed = 5});
@override
Widget build(BuildContext context) {
return BlocBuilder<AlarmBloc, AlarmBlocState>(
builder: (context, state) {
final alarms = state.activeAlarms.take(maxDisplayed).toList();
final hasMore = state.activeAlarms.length > maxDisplayed;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'ACTIVE ALARMS',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
if (state.blockingAlarmCount > 0)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${state.blockingAlarmCount}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
const Spacer(),
if (state.isLoading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: () =>
context.read<AlarmBloc>().add(const RefreshAlarms()),
tooltip: 'Refresh alarms',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: 8),
if (alarms.isEmpty)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.check_circle,
color: Colors.green.shade600,
),
const SizedBox(width: 8),
Text(
'No active alarms',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
)
else
Container(
decoration: BoxDecoration(
border: Border.all(
color: Colors.red.shade200,
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
...alarms.asMap().entries.map((entry) {
final index = entry.key;
final alarm = entry.value;
return _AlarmTile(
alarm: alarm,
isLast: index == alarms.length - 1 && !hasMore,
);
}),
if (hasMore)
Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(7),
bottomRight: Radius.circular(7),
),
),
child: Center(
child: Text(
'+${state.activeAlarms.length - maxDisplayed} more alarms',
style: Theme.of(context).textTheme.bodySmall,
),
),
),
],
),
),
if (state.error != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
state.error!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
),
if (state.lastSync != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Last sync: ${_formatTime(state.lastSync!)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
),
),
],
);
},
);
}
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:'
'${time.minute.toString().padLeft(2, '0')}:'
'${time.second.toString().padLeft(2, '0')}';
}
}
class _AlarmTile extends StatelessWidget {
final AlarmState alarm;
final bool isLast;
const _AlarmTile({
required this.alarm,
required this.isLast,
});
@override
Widget build(BuildContext context) {
final isBlocking = alarm.blocksMonitor;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isBlocking ? Colors.red.shade50 : null,
border: isLast
? null
: Border(
bottom: BorderSide(
color: Colors.red.shade200,
width: 1,
),
),
),
child: Row(
children: [
Icon(
isBlocking ? Icons.warning : Icons.info_outline,
color: isBlocking ? Colors.red : Colors.orange,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
alarm.eventName.isNotEmpty
? alarm.eventName
: 'Event ${alarm.eventId}',
style: TextStyle(
fontWeight: isBlocking ? FontWeight.bold : FontWeight.normal,
),
),
if (alarm.foreignKey > 0)
Text(
'Camera/Contact: ${alarm.foreignKey}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
Text(
_formatTime(alarm.startedAt),
style: Theme.of(context).textTheme.bodySmall,
),
if (alarm.associatedMonitor != null) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'M${alarm.associatedMonitor}',
style: TextStyle(
fontSize: 10,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
],
],
),
);
}
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:'
'${time.minute.toString().padLeft(2, '0')}:'
'${time.second.toString().padLeft(2, '0')}';
}
}

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/camera/camera_bloc.dart';
import '../blocs/camera/camera_event.dart';
import '../blocs/camera/camera_state.dart';
import '../blocs/monitor/monitor_bloc.dart';
import '../blocs/monitor/monitor_state.dart';
class CameraGrid extends StatelessWidget {
final int columns;
const CameraGrid({super.key, this.columns = 8});
@override
Widget build(BuildContext context) {
return BlocBuilder<CameraBloc, CameraState>(
builder: (context, cameraState) {
return BlocBuilder<MonitorBloc, MonitorBlocState>(
builder: (context, monitorState) {
final cameras = cameraState.availableCameras;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'CAMERAS',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Wrap(
spacing: 4,
runSpacing: 4,
children: cameras.map((cameraId) {
final isSelected =
cameraState.selectedCameraId == cameraId;
final isOnSelectedMonitor =
monitorState.selectedMonitorCamera == cameraId;
return _CameraButton(
cameraId: cameraId,
isSelected: isSelected,
isOnSelectedMonitor: isOnSelectedMonitor,
onPressed: () => _onCameraPressed(
context,
cameraId,
monitorState.selectedMonitorId,
),
);
}).toList(),
),
],
);
},
);
},
);
}
void _onCameraPressed(BuildContext context, int cameraId, int? monitorId) {
final cameraBloc = context.read<CameraBloc>();
if (monitorId != null) {
// Monitor is selected, connect camera to it
cameraBloc.add(ConnectCameraToMonitor(
cameraId: cameraId,
monitorId: monitorId,
));
} else {
// Just select the camera
cameraBloc.add(SelectCamera(cameraId));
}
}
}
class _CameraButton extends StatelessWidget {
final int cameraId;
final bool isSelected;
final bool isOnSelectedMonitor;
final VoidCallback onPressed;
const _CameraButton({
required this.cameraId,
required this.isSelected,
required this.isOnSelectedMonitor,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
Color backgroundColor;
Color foregroundColor;
if (isSelected) {
backgroundColor = Theme.of(context).colorScheme.primary;
foregroundColor = Theme.of(context).colorScheme.onPrimary;
} else if (isOnSelectedMonitor) {
backgroundColor = Theme.of(context).colorScheme.secondary;
foregroundColor = Theme.of(context).colorScheme.onSecondary;
} else {
backgroundColor = Theme.of(context).colorScheme.surfaceContainerHighest;
foregroundColor = Theme.of(context).colorScheme.onSurface;
}
return SizedBox(
width: 48,
height: 40,
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
child: Text(
'$cameraId',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
),
);
}
}

View File

@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/connection/connection_bloc.dart';
import '../blocs/connection/connection_event.dart';
import '../blocs/connection/connection_state.dart' as conn;
class ConnectionStatusBar extends StatelessWidget {
const ConnectionStatusBar({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<ConnectionBloc, conn.ConnectionState>(
builder: (context, state) {
Color backgroundColor;
Color textColor;
String statusText;
IconData statusIcon;
switch (state.overallStatus) {
case conn.ConnectionOverallStatus.connected:
backgroundColor = Colors.green.shade100;
textColor = Colors.green.shade800;
statusText = 'Connected (${state.connectedCount}/${state.totalCount})';
statusIcon = Icons.cloud_done;
case conn.ConnectionOverallStatus.partial:
backgroundColor = Colors.orange.shade100;
textColor = Colors.orange.shade800;
statusText = 'Partial (${state.connectedCount}/${state.totalCount})';
statusIcon = Icons.cloud_off;
case conn.ConnectionOverallStatus.connecting:
backgroundColor = Colors.blue.shade100;
textColor = Colors.blue.shade800;
statusText = 'Connecting...';
statusIcon = Icons.cloud_sync;
case conn.ConnectionOverallStatus.disconnected:
backgroundColor = Colors.red.shade100;
textColor = Colors.red.shade800;
statusText = 'Disconnected';
statusIcon = Icons.cloud_off;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(statusIcon, color: textColor, size: 18),
const SizedBox(width: 8),
Text(
statusText,
style: TextStyle(
color: textColor,
fontWeight: FontWeight.w500,
),
),
if (state.overallStatus == conn.ConnectionOverallStatus.disconnected ||
state.overallStatus == conn.ConnectionOverallStatus.partial) ...[
const SizedBox(width: 8),
InkWell(
onTap: () => context
.read<ConnectionBloc>()
.add(const RetryConnections()),
child: Icon(Icons.refresh, color: textColor, size: 18),
),
],
],
),
);
},
);
}
}

View File

@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/entities/monitor_state.dart';
import '../blocs/monitor/monitor_bloc.dart';
import '../blocs/monitor/monitor_event.dart';
import '../blocs/monitor/monitor_state.dart';
class MonitorGrid extends StatelessWidget {
final int columns;
const MonitorGrid({super.key, this.columns = 4});
@override
Widget build(BuildContext context) {
return BlocBuilder<MonitorBloc, MonitorBlocState>(
builder: (context, state) {
final monitors = state.availableMonitors;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'MONITORS',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Wrap(
spacing: 4,
runSpacing: 4,
children: monitors.map((monitorId) {
final isSelected = state.selectedMonitorId == monitorId;
final monitorState = state.monitorStates[monitorId];
return _MonitorButton(
monitorId: monitorId,
isSelected: isSelected,
monitorState: monitorState,
onPressed: () => _onMonitorPressed(context, monitorId),
onLongPress: () => _onMonitorLongPress(context, monitorId),
);
}).toList(),
),
],
);
},
);
}
void _onMonitorPressed(BuildContext context, int monitorId) {
context.read<MonitorBloc>().add(SelectMonitor(monitorId));
}
void _onMonitorLongPress(BuildContext context, int monitorId) {
context.read<MonitorBloc>().add(ClearMonitor(monitorId));
}
}
class _MonitorButton extends StatelessWidget {
final int monitorId;
final bool isSelected;
final MonitorState? monitorState;
final VoidCallback onPressed;
final VoidCallback onLongPress;
const _MonitorButton({
required this.monitorId,
required this.isSelected,
this.monitorState,
required this.onPressed,
required this.onLongPress,
});
@override
Widget build(BuildContext context) {
final hasAlarm = monitorState?.hasAlarm ?? false;
final currentCamera = monitorState?.currentChannel ?? 0;
final isActive = currentCamera > 0;
Color backgroundColor;
Color foregroundColor;
Color? borderColor;
if (hasAlarm) {
backgroundColor = Colors.red.shade700;
foregroundColor = Colors.white;
borderColor = Colors.red.shade900;
} else if (isSelected) {
backgroundColor = Theme.of(context).colorScheme.primary;
foregroundColor = Theme.of(context).colorScheme.onPrimary;
} else if (isActive) {
backgroundColor = Theme.of(context).colorScheme.primaryContainer;
foregroundColor = Theme.of(context).colorScheme.onPrimaryContainer;
} else {
backgroundColor = Theme.of(context).colorScheme.surfaceContainerHighest;
foregroundColor = Theme.of(context).colorScheme.onSurface;
}
return SizedBox(
width: 64,
height: 48,
child: GestureDetector(
onLongPress: onLongPress,
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
padding: const EdgeInsets.all(4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
side: borderColor != null
? BorderSide(color: borderColor, width: 2)
: BorderSide.none,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (hasAlarm)
const Icon(Icons.warning, size: 12, color: Colors.yellow),
Text(
'$monitorId',
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.bold),
),
],
),
if (isActive)
Text(
'C$currentCamera',
style: TextStyle(
fontSize: 10,
color: foregroundColor.withValues(alpha: 0.8),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,261 @@
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,
),
),
);
}),
),
);
}),
),
);
}
}

View File

@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/ptz/ptz_bloc.dart';
import '../blocs/ptz/ptz_event.dart';
import '../blocs/monitor/monitor_bloc.dart';
import '../blocs/monitor/monitor_state.dart';
class PresetButtons extends StatelessWidget {
final int presetCount;
const PresetButtons({super.key, this.presetCount = 8});
@override
Widget build(BuildContext context) {
return BlocBuilder<MonitorBloc, MonitorBlocState>(
builder: (context, monitorState) {
final cameraId = monitorState.selectedMonitorCamera;
final isEnabled = cameraId != null && cameraId > 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'PRESETS',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Wrap(
spacing: 4,
runSpacing: 4,
children: List.generate(presetCount, (index) {
final presetId = index + 1;
return _PresetButton(
presetId: presetId,
isEnabled: isEnabled,
onPressed: isEnabled
? () => context.read<PtzBloc>().add(
PtzGoToPreset(cameraId: cameraId, preset: presetId),
)
: null,
);
}),
),
],
);
},
);
}
}
class _PresetButton extends StatelessWidget {
final int presetId;
final bool isEnabled;
final VoidCallback? onPressed;
const _PresetButton({
required this.presetId,
required this.isEnabled,
this.onPressed,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 44,
height: 36,
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: isEnabled
? Theme.of(context).colorScheme.secondaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: isEnabled
? Theme.of(context).colorScheme.onSecondaryContainer
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4),
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
child: Text(
'$presetId',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
),
);
}
}

View File

@@ -0,0 +1,231 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/ptz/ptz_bloc.dart';
import '../blocs/ptz/ptz_event.dart';
import '../blocs/ptz/ptz_state.dart';
import '../blocs/monitor/monitor_bloc.dart';
import '../blocs/monitor/monitor_state.dart';
class PtzControl extends StatelessWidget {
const PtzControl({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<MonitorBloc, MonitorBlocState>(
builder: (context, monitorState) {
return BlocBuilder<PtzBloc, PtzState>(
builder: (context, ptzState) {
final cameraId = monitorState.selectedMonitorCamera;
final isEnabled = cameraId != null && cameraId > 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Text(
'PTZ CONTROL',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (cameraId != null && cameraId > 0) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Camera $cameraId',
style: TextStyle(
fontSize: 12,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
),
],
],
),
),
_buildPtzPad(context, cameraId, isEnabled),
const SizedBox(height: 8),
_buildZoomControls(context, cameraId, isEnabled),
],
);
},
);
},
);
}
Widget _buildPtzPad(BuildContext context, int? cameraId, bool isEnabled) {
return Column(
children: [
// Up
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_PtzButton(
icon: Icons.arrow_upward,
onPressStart: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(
PtzTiltStart(cameraId: cameraId, direction: 'up'),
)
: null,
onPressEnd: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
: null,
),
],
),
// Left, Stop, Right
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_PtzButton(
icon: Icons.arrow_back,
onPressStart: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(
PtzPanStart(cameraId: cameraId, direction: 'left'),
)
: null,
onPressEnd: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
: null,
),
const SizedBox(width: 4),
_PtzButton(
icon: Icons.stop,
isStop: true,
onPressStart: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
: null,
),
const SizedBox(width: 4),
_PtzButton(
icon: Icons.arrow_forward,
onPressStart: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(
PtzPanStart(cameraId: cameraId, direction: 'right'),
)
: null,
onPressEnd: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
: null,
),
],
),
// Down
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_PtzButton(
icon: Icons.arrow_downward,
onPressStart: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(
PtzTiltStart(cameraId: cameraId, direction: 'down'),
)
: null,
onPressEnd: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
: null,
),
],
),
],
);
}
Widget _buildZoomControls(
BuildContext context, int? cameraId, bool isEnabled) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_PtzButton(
icon: Icons.zoom_out,
onPressStart: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(
PtzZoomStart(cameraId: cameraId, direction: 'out'),
)
: null,
onPressEnd: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
: null,
),
const SizedBox(width: 4),
_PtzButton(
icon: Icons.zoom_in,
onPressStart: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(
PtzZoomStart(cameraId: cameraId, direction: 'in'),
)
: null,
onPressEnd: isEnabled && cameraId != null
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
: null,
),
],
);
}
}
class _PtzButton extends StatelessWidget {
final IconData icon;
final VoidCallback? onPressStart;
final VoidCallback? onPressEnd;
final bool isStop;
const _PtzButton({
required this.icon,
this.onPressStart,
this.onPressEnd,
this.isStop = false,
});
@override
Widget build(BuildContext context) {
final isEnabled = onPressStart != null;
return GestureDetector(
onTapDown: isEnabled ? (_) => onPressStart?.call() : null,
onTapUp: isEnabled ? (_) => onPressEnd?.call() : null,
onTapCancel: isEnabled ? () => onPressEnd?.call() : null,
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: isEnabled
? (isStop
? Theme.of(context).colorScheme.errorContainer
: Theme.of(context).colorScheme.primaryContainer)
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isEnabled
? (isStop
? Theme.of(context).colorScheme.error
: Theme.of(context).colorScheme.primary)
: Theme.of(context).colorScheme.outline,
width: 1,
),
),
child: Icon(
icon,
color: isEnabled
? (isStop
? Theme.of(context).colorScheme.onErrorContainer
: Theme.of(context).colorScheme.onPrimaryContainer)
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4),
),
),
);
}
}

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,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,182 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/sequence/sequence_bloc.dart';
import '../blocs/sequence/sequence_event.dart';
import '../blocs/sequence/sequence_state.dart';
/// Panel for starting/stopping camera rotation sequences.
/// Shown as a dialog from the toolbar.
class SequencePanel extends StatelessWidget {
final int viewerId;
const SequencePanel({super.key, required this.viewerId});
@override
Widget build(BuildContext context) {
return BlocBuilder<SequenceBloc, SequenceState>(
builder: (context, state) {
if (state.isLoading) {
return const SizedBox(
height: 200,
child: Center(child: CircularProgressIndicator()),
);
}
final isRunning = state.isRunningOnViewer(viewerId);
final runningSeq = state.getRunning(viewerId);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Running sequence indicator
if (isRunning && runningSeq != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF38A169).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: const Color(0xFF38A169)),
),
child: Row(
children: [
const Icon(Icons.play_circle, color: Color(0xFF38A169)),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_getSequenceName(state, runningSeq.sequenceId),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
Text(
'Běží na monitoru $viewerId',
style: const TextStyle(
color: Colors.white70, fontSize: 12),
),
],
),
),
ElevatedButton.icon(
onPressed: () {
context
.read<SequenceBloc>()
.add(StopSequence(viewerId));
},
icon: const Icon(Icons.stop, size: 16),
label: const Text('Zastavit'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFDC2626),
foregroundColor: Colors.white,
),
),
],
),
),
const SizedBox(height: 12),
],
// Category filter chips
if (state.categories.isNotEmpty) ...[
Wrap(
spacing: 8,
children: [
FilterChip(
label: const Text('Vše'),
selected: state.selectedCategoryId == null,
onSelected: (_) =>
context.read<SequenceBloc>().add(SelectCategory(null)),
selectedColor: const Color(0xFF2B6CB0),
labelStyle: TextStyle(
color: state.selectedCategoryId == null
? Colors.white
: Colors.white70,
),
backgroundColor: const Color(0xFF2D3748),
),
...state.categories.map((cat) => FilterChip(
label: Text(cat.name),
selected: state.selectedCategoryId == cat.id,
onSelected: (_) => context
.read<SequenceBloc>()
.add(SelectCategory(cat.id)),
selectedColor: const Color(0xFF2B6CB0),
labelStyle: TextStyle(
color: state.selectedCategoryId == cat.id
? Colors.white
: Colors.white70,
),
backgroundColor: const Color(0xFF2D3748),
)),
],
),
const SizedBox(height: 12),
],
// Sequence list
if (state.filteredSequences.isEmpty)
const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: Text(
'Žádné sekvence k dispozici.',
style: TextStyle(color: Colors.white54),
),
),
)
else
...state.filteredSequences.map((seq) {
final isThisRunning =
runningSeq?.sequenceId == seq.id && isRunning;
return ListTile(
dense: true,
leading: Icon(
isThisRunning ? Icons.play_circle : Icons.loop,
color: isThisRunning
? const Color(0xFF38A169)
: const Color(0xFF718096),
),
title: Text(
seq.name,
style: const TextStyle(color: Colors.white),
),
subtitle: Text(
'${seq.cameras.length} kamer, ${seq.intervalSeconds}s interval',
style:
const TextStyle(color: Colors.white54, fontSize: 12),
),
trailing: isThisRunning
? TextButton(
onPressed: () => context
.read<SequenceBloc>()
.add(StopSequence(viewerId)),
child: const Text('Zastavit',
style: TextStyle(color: Color(0xFFDC2626))),
)
: TextButton(
onPressed: () => context
.read<SequenceBloc>()
.add(StartSequence(
viewerId: viewerId, sequenceId: seq.id)),
child: const Text('Spustit'),
),
);
}),
],
);
},
);
}
String _getSequenceName(SequenceState state, int sequenceId) {
final seq =
state.sequences.where((s) => s.id == sequenceId).firstOrNull;
return seq?.name ?? 'Sekvence #$sequenceId';
}
}

View File

@@ -0,0 +1,147 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/lock/lock_bloc.dart';
import '../blocs/lock/lock_event.dart';
import '../blocs/lock/lock_state.dart';
/// Show the takeover confirmation dialog.
/// Called when another keyboard requests takeover of a camera we have locked.
void showTakeoverDialog(BuildContext context, TakeoverRequest request) {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => BlocProvider.value(
value: context.read<LockBloc>(),
child: _TakeoverDialog(request: request),
),
);
}
class _TakeoverDialog extends StatefulWidget {
final TakeoverRequest request;
const _TakeoverDialog({required this.request});
@override
State<_TakeoverDialog> createState() => _TakeoverDialogState();
}
class _TakeoverDialogState extends State<_TakeoverDialog> {
static const _autoRejectSeconds = 30;
late Timer _autoRejectTimer;
int _remainingSeconds = _autoRejectSeconds;
@override
void initState() {
super.initState();
_autoRejectTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (_remainingSeconds <= 1) {
_reject();
} else {
setState(() => _remainingSeconds--);
}
});
}
@override
void dispose() {
_autoRejectTimer.cancel();
super.dispose();
}
void _confirm() {
_autoRejectTimer.cancel();
context.read<LockBloc>().add(
ConfirmTakeover(widget.request.cameraId, confirm: true),
);
Navigator.of(context).pop();
}
void _reject() {
_autoRejectTimer.cancel();
context.read<LockBloc>().add(
ConfirmTakeover(widget.request.cameraId, confirm: false),
);
if (mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: const Color(0xFF1A202C),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: const BorderSide(color: Color(0xFFED8936), width: 2),
),
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Color(0xFFED8936), size: 28),
SizedBox(width: 8),
Text(
'Požadavek na převzetí',
style: TextStyle(color: Colors.white),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
style: const TextStyle(color: Colors.white70, fontSize: 14),
children: [
const TextSpan(text: 'Klávesnice '),
TextSpan(
text: widget.request.requestingKeyboard,
style: const TextStyle(
fontWeight: FontWeight.bold, color: Colors.white),
),
const TextSpan(text: ' požaduje převzetí kamery '),
TextSpan(
text: '${widget.request.cameraId}',
style: const TextStyle(
fontWeight: FontWeight.bold, color: Color(0xFF00D4FF)),
),
const TextSpan(text: '.'),
],
),
),
const SizedBox(height: 16),
// Countdown progress
LinearProgressIndicator(
value: _remainingSeconds / _autoRejectSeconds,
backgroundColor: const Color(0xFF2D3748),
valueColor:
const AlwaysStoppedAnimation<Color>(Color(0xFFED8936)),
),
const SizedBox(height: 4),
Text(
'Automatické zamítnutí za $_remainingSeconds s',
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
actions: [
TextButton(
onPressed: _reject,
child: const Text(
'Zamítnout',
style: TextStyle(color: Color(0xFFDC2626)),
),
),
ElevatedButton(
onPressed: _confirm,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFED8936),
foregroundColor: Colors.white,
),
child: const Text('Povolit převzetí'),
),
],
);
}
}

View File

@@ -0,0 +1,303 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../blocs/wall/wall_bloc.dart';
import '../../blocs/wall/wall_state.dart';
import 'camera_input_widget.dart';
import 'function_buttons_widget.dart';
/// Bottom toolbar with camera input and action buttons
class BottomToolbar extends StatelessWidget {
final VoidCallback? onSearchPressed;
final VoidCallback? onPrepositionPressed;
final VoidCallback? onPlaybackPressed;
final VoidCallback? onAlarmListPressed;
final VoidCallback? onLockPressed;
final VoidCallback? onSequencePressed;
final Function(String)? onFunctionButtonPressed;
const BottomToolbar({
super.key,
this.onSearchPressed,
this.onPrepositionPressed,
this.onPlaybackPressed,
this.onAlarmListPressed,
this.onLockPressed,
this.onSequencePressed,
this.onFunctionButtonPressed,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: const BoxDecoration(
color: Color(0xFF1A202C),
border: Border(
top: BorderSide(color: Color(0xFF4A5568), width: 1),
),
),
child: BlocBuilder<WallBloc, WallState>(
builder: (context, state) {
final hasSelection = state.selectedViewerId != null;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Top row - Camera input and function buttons
Row(
children: [
// Camera input
const Expanded(
child: CameraInputWidget(),
),
const SizedBox(width: 24),
// Function buttons
FunctionButtonsWidget(
onButtonPressed: onFunctionButtonPressed,
),
],
),
const SizedBox(height: 12),
// Bottom row - Context actions
Row(
children: [
if (hasSelection) ...[
// Search button
_ActionButton(
icon: Icons.search,
label: 'Hledat',
onPressed: onSearchPressed,
),
const SizedBox(width: 8),
// Preposition button
_ActionButton(
icon: Icons.place,
label: 'Prepozice',
onPressed: onPrepositionPressed,
),
const SizedBox(width: 8),
// Playback button
_ActionButton(
icon: Icons.history,
label: 'PvZ',
onPressed: onPlaybackPressed,
),
const SizedBox(width: 8),
// Alarm list button
_ActionButton(
icon: Icons.notifications,
label: 'Alarmy',
onPressed: onAlarmListPressed,
),
const SizedBox(width: 8),
// Sequence button
_ActionButton(
icon: Icons.loop,
label: 'Sekvence',
onPressed: onSequencePressed,
),
const SizedBox(width: 8),
// Lock button
_LockButton(
viewerState: state.getViewerState(state.selectedViewerId!),
onPressed: onLockPressed,
),
] else ...[
// Show selection hint when nothing selected
const Expanded(
child: Text(
'Vyberte monitor pro zobrazení akcí',
style: TextStyle(
color: Color(0xFF718096),
fontSize: 14,
),
),
),
],
const Spacer(),
// Selected monitor info
if (hasSelection)
_SelectionInfo(
viewerId: state.selectedViewerId!,
viewerState: state.getViewerState(state.selectedViewerId!),
),
],
),
],
);
},
),
);
}
}
class _ActionButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback? onPressed;
const _ActionButton({
required this.icon,
required this.label,
this.onPressed,
});
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0xFF2D3748),
borderRadius: BorderRadius.circular(4),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: onPressed != null ? Colors.white : const Color(0xFF4A5568),
size: 18,
),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
color: onPressed != null ? Colors.white : const Color(0xFF4A5568),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
}
}
class _LockButton extends StatelessWidget {
final ViewerState viewerState;
final VoidCallback? onPressed;
const _LockButton({
required this.viewerState,
this.onPressed,
});
@override
Widget build(BuildContext context) {
final isLocked = viewerState.isLocked;
final isLockedByOther = viewerState.isLockedByOther;
return Material(
color: isLocked
? (isLockedByOther ? const Color(0xFFDC2626) : const Color(0xFF38A169))
: const Color(0xFF2D3748),
borderRadius: BorderRadius.circular(4),
child: InkWell(
onTap: isLockedByOther ? null : onPressed,
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isLocked ? Icons.lock : Icons.lock_open,
color: Colors.white,
size: 18,
),
const SizedBox(width: 6),
Text(
isLocked ? 'Zamčeno' : 'Zamknout',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
}
}
class _SelectionInfo extends StatelessWidget {
final int viewerId;
final ViewerState viewerState;
const _SelectionInfo({
required this.viewerId,
required this.viewerState,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF2D3748),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: const Color(0xFF00D4FF), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.tv,
color: Color(0xFF00D4FF),
size: 16,
),
const SizedBox(width: 8),
Text(
'Monitor $viewerId',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
if (viewerState.hasCamera) ...[
const SizedBox(width: 12),
const Icon(
Icons.videocam,
color: Colors.white70,
size: 16,
),
const SizedBox(width: 4),
Text(
'${viewerState.currentCameraId}',
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
],
if (viewerState.hasAlarm) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFDC2626),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'ALARM',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
);
}
}

View File

@@ -0,0 +1,247 @@
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';
/// Camera number input widget with prefix selection.
///
/// Legacy behavior: field starts empty, digits typed by user (up to 6),
/// prefix applied on Enter via [WallState.fullCameraNumber].
class CameraInputWidget extends StatefulWidget {
const CameraInputWidget({super.key});
@override
State<CameraInputWidget> createState() => _CameraInputWidgetState();
}
class _CameraInputWidgetState extends State<CameraInputWidget> {
final FocusNode _focusNode = FocusNode();
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<WallBloc, WallState>(
builder: (context, state) {
final hasSelection = state.selectedViewerId != null;
return KeyboardListener(
focusNode: _focusNode,
autofocus: true,
onKeyEvent: (event) => _handleKeyEvent(context, event, state),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Prefix buttons
_PrefixButton(
prefix: 500,
isSelected: state.cameraPrefix == 500,
onTap: hasSelection
? () => context
.read<WallBloc>()
.add(const SetCameraPrefix(500))
: null,
),
const SizedBox(width: 4),
_PrefixButton(
prefix: 501,
isSelected: state.cameraPrefix == 501,
onTap: hasSelection
? () => context
.read<WallBloc>()
.add(const SetCameraPrefix(501))
: null,
),
const SizedBox(width: 4),
_PrefixButton(
prefix: 502,
isSelected: state.cameraPrefix == 502,
onTap: hasSelection
? () => context
.read<WallBloc>()
.add(const SetCameraPrefix(502))
: null,
),
const SizedBox(width: 16),
// Camera number display (shows only typed digits)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: state.isEditing
? const Color(0xFF1A3A5C)
: (hasSelection
? const Color(0xFF2D3748)
: const Color(0xFF1A202C)),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: state.isEditing
? const Color(0xFF00D4FF)
: (hasSelection
? const Color(0xFF4A5568)
: const Color(0xFF2D3748)),
width: state.isEditing ? 2 : 1,
),
),
child: SizedBox(
width: 100,
child: Text(
state.cameraInputDisplay,
style: TextStyle(
color: hasSelection
? Colors.white
: const Color(0xFF4A5568),
fontSize: 20,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
letterSpacing: 2,
),
),
),
),
const SizedBox(width: 8),
// Backspace button
IconButton(
icon: const Icon(Icons.backspace),
color: Colors.white70,
onPressed: hasSelection && state.cameraNumberInput.isNotEmpty
? () => context
.read<WallBloc>()
.add(const BackspaceCameraDigit())
: null,
),
// Execute button
ElevatedButton.icon(
icon: const Icon(Icons.send),
label: const Text('OK'),
style: ElevatedButton.styleFrom(
backgroundColor:
state.canExecuteCrossSwitch ? Colors.green : Colors.grey,
foregroundColor: Colors.white,
),
onPressed: state.canExecuteCrossSwitch
? () => context
.read<WallBloc>()
.add(const ExecuteCrossSwitch())
: null,
),
],
),
);
},
);
}
void _handleKeyEvent(
BuildContext context, KeyEvent event, WallState state) {
if (event is! KeyDownEvent) return;
if (state.selectedViewerId == null) return;
final key = event.logicalKey;
// Handle digit keys
if (key.keyId >= LogicalKeyboardKey.digit0.keyId &&
key.keyId <= LogicalKeyboardKey.digit9.keyId) {
final digit = key.keyId - LogicalKeyboardKey.digit0.keyId;
context.read<WallBloc>().add(AddCameraDigit(digit));
return;
}
// Handle numpad digits
if (key.keyId >= LogicalKeyboardKey.numpad0.keyId &&
key.keyId <= LogicalKeyboardKey.numpad9.keyId) {
final digit = key.keyId - LogicalKeyboardKey.numpad0.keyId;
context.read<WallBloc>().add(AddCameraDigit(digit));
return;
}
// Handle Enter
if (key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.numpadEnter) {
if (state.canExecuteCrossSwitch) {
context.read<WallBloc>().add(const ExecuteCrossSwitch());
}
return;
}
// Handle Backspace — remove last digit
if (key == LogicalKeyboardKey.backspace) {
context.read<WallBloc>().add(const BackspaceCameraDigit());
return;
}
// Handle Delete — cancel edit
if (key == LogicalKeyboardKey.delete) {
context.read<WallBloc>().add(const CancelCameraEdit());
return;
}
// Handle Escape
if (key == LogicalKeyboardKey.escape) {
if (state.isEditing) {
context.read<WallBloc>().add(const CancelCameraEdit());
} else {
context.read<WallBloc>().add(const DeselectViewer());
}
return;
}
// Handle period / Tab — cycle prefix
if (key == LogicalKeyboardKey.period ||
key == LogicalKeyboardKey.numpadDecimal) {
context.read<WallBloc>().add(const CycleCameraPrefix());
return;
}
}
}
class _PrefixButton extends StatelessWidget {
final int prefix;
final bool isSelected;
final VoidCallback? onTap;
const _PrefixButton({
required this.prefix,
required this.isSelected,
this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: isSelected ? const Color(0xFF3182CE) : const Color(0xFF2D3748),
borderRadius: BorderRadius.circular(4),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: isSelected
? const Color(0xFF63B3ED)
: const Color(0xFF4A5568),
width: isSelected ? 2.0 : 1.0,
),
),
child: Text(
'$prefix',
style: TextStyle(
color: onTap != null ? Colors.white : const Color(0xFF4A5568),
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
/// Function buttons (HOME, F1-F7) for wall presets
class FunctionButtonsWidget extends StatelessWidget {
final Function(String buttonId)? onButtonPressed;
const FunctionButtonsWidget({
super.key,
this.onButtonPressed,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_FunctionButton(
label: 'HOME',
buttonId: 'HOME',
onPressed: onButtonPressed,
isHome: true,
),
const SizedBox(width: 4),
...List.generate(7, (index) {
return Padding(
padding: const EdgeInsets.only(left: 4),
child: _FunctionButton(
label: 'F${index + 1}',
buttonId: 'F${index + 1}',
onPressed: onButtonPressed,
),
);
}),
],
);
}
}
class _FunctionButton extends StatelessWidget {
final String label;
final String buttonId;
final Function(String)? onPressed;
final bool isHome;
const _FunctionButton({
required this.label,
required this.buttonId,
this.onPressed,
this.isHome = false,
});
@override
Widget build(BuildContext context) {
return Material(
color: isHome ? const Color(0xFF2B6CB0) : const Color(0xFF4A5568),
borderRadius: BorderRadius.circular(4),
child: InkWell(
onTap: onPressed != null ? () => onPressed!(buttonId) : null,
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import '../../../domain/entities/wall_config.dart';
import '../../blocs/wall/wall_state.dart';
import 'viewer_tile.dart';
/// A physical monitor that can contain 1-4 viewers (quad view)
class PhysicalMonitorTile extends StatelessWidget {
final PhysicalMonitor monitor;
final WallState wallState;
final Function(int viewerId) onViewerTap;
const PhysicalMonitorTile({
super.key,
required this.monitor,
required this.wallState,
required this.onViewerTap,
});
@override
Widget build(BuildContext context) {
final isSelected = wallState.isPhysicalMonitorSelected(monitor);
final hasAlarm = _hasAnyAlarm();
return Container(
decoration: BoxDecoration(
border: isSelected
? Border.all(color: const Color(0xFF00D4FF), width: 3)
: Border.all(color: const Color(0xFF4A5568), width: 1),
borderRadius: BorderRadius.circular(6),
color: hasAlarm
? const Color(0xFFDC2626).withValues(alpha: 0.3)
: Colors.transparent,
),
child: Padding(
padding: const EdgeInsets.all(2),
child: monitor.hasMultipleViewers
? _buildQuadView(context)
: _buildSingleView(context),
),
);
}
bool _hasAnyAlarm() {
for (final viewerId in monitor.viewerIds) {
if (wallState.getViewerState(viewerId).hasAlarm) {
return true;
}
}
return false;
}
Widget _buildSingleView(BuildContext context) {
final viewerId = monitor.primaryViewerId;
final viewerState = wallState.getViewerState(viewerId);
final isViewerSelected = wallState.isViewerSelected(viewerId);
return AspectRatio(
aspectRatio: 16 / 9,
child: ViewerTile(
viewerId: viewerId,
viewerState: viewerState,
isSelected: isViewerSelected,
onTap: () => onViewerTap(viewerId),
),
);
}
Widget _buildQuadView(BuildContext context) {
// Arrange viewers in 2x2 grid
return AspectRatio(
aspectRatio: 16 / 9,
child: GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 2,
crossAxisSpacing: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: monitor.viewerIds.map((viewerId) {
final viewerState = wallState.getViewerState(viewerId);
final isViewerSelected = wallState.isViewerSelected(viewerId);
return ViewerTile(
viewerId: viewerId,
viewerState: viewerState,
isSelected: isViewerSelected,
isPartOfQuad: true,
onTap: () => onViewerTap(viewerId),
);
}).toList(),
),
);
}
}

View File

@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import '../../blocs/wall/wall_state.dart';
/// A single viewer tile within a physical monitor
class ViewerTile extends StatelessWidget {
final int viewerId;
final ViewerState viewerState;
final bool isSelected;
final bool isPartOfQuad;
final VoidCallback onTap;
const ViewerTile({
super.key,
required this.viewerId,
required this.viewerState,
required this.isSelected,
this.isPartOfQuad = false,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: _getBackgroundColor(context),
border: isSelected && !isPartOfQuad
? Border.all(color: const Color(0xFF00D4FF), width: 3)
: Border.all(color: const Color(0xFF4A5568), width: 1),
borderRadius: BorderRadius.circular(4),
),
child: Stack(
children: [
// Main content
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Viewer ID
Text(
'$viewerId',
style: TextStyle(
color: Colors.white,
fontSize: isPartOfQuad ? 12 : 16,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
),
// Camera ID if assigned
if (viewerState.hasCamera)
Text(
'${viewerState.currentCameraId}',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: isPartOfQuad ? 9 : 11,
fontFamily: 'monospace',
),
),
],
),
),
// Lock indicator
if (viewerState.isLocked)
Positioned(
top: 2,
right: 2,
child: Icon(
Icons.lock,
size: isPartOfQuad ? 10 : 14,
color: viewerState.isLockedByOther
? Colors.red
: Colors.green,
),
),
// Live/Playback indicator
if (viewerState.hasCamera && !viewerState.isLive)
Positioned(
bottom: 2,
left: 2,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(2),
),
child: Text(
'PvZ',
style: TextStyle(
color: Colors.white,
fontSize: isPartOfQuad ? 7 : 9,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
);
}
Color _getBackgroundColor(BuildContext context) {
if (viewerState.hasAlarm) {
return const Color(0xFFDC2626); // Red for alarm
}
if (viewerState.hasCamera) {
return const Color(0xFF2D3748); // Dark gray with camera
}
return const Color(0xFF1A202C); // Darker gray without camera
}
}

View File

@@ -0,0 +1,98 @@
import 'package:flutter/material.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 'wall_section_widget.dart';
/// Main video wall grid displaying all sections
class WallGrid extends StatelessWidget {
const WallGrid({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<WallBloc, WallState>(
builder: (context, state) {
if (state.isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (state.error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 48),
const SizedBox(height: 16),
Text(
state.error!,
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
context.read<WallBloc>().add(const LoadWallConfig());
},
child: const Text('Retry'),
),
],
),
);
}
final config = state.config;
if (config == null) {
return const Center(
child: Text(
'No wall configuration loaded',
style: TextStyle(color: Colors.white54),
),
);
}
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Wall name header
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
config.name,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
// Sections
...config.sections.map((section) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: WallSectionWidget(
section: section,
wallState: state,
isExpanded: state.isSectionExpanded(section.id),
onToggleExpanded: () {
context
.read<WallBloc>()
.add(ToggleSectionExpanded(section.id));
},
onViewerTap: (viewerId) {
context.read<WallBloc>().add(SelectViewer(viewerId));
},
),
);
}),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import '../../../domain/entities/wall_config.dart';
import '../../blocs/wall/wall_state.dart';
import 'physical_monitor_tile.dart';
/// A collapsible section of the video wall
class WallSectionWidget extends StatelessWidget {
final WallSection section;
final WallState wallState;
final bool isExpanded;
final VoidCallback onToggleExpanded;
final Function(int viewerId) onViewerTap;
const WallSectionWidget({
super.key,
required this.section,
required this.wallState,
required this.isExpanded,
required this.onToggleExpanded,
required this.onViewerTap,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section header
InkWell(
onTap: onToggleExpanded,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFF2D3748),
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
color: Colors.white70,
size: 20,
),
const SizedBox(width: 8),
Text(
section.name,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Text(
'${section.monitors.length} monitors',
style: const TextStyle(
color: Colors.white54,
fontSize: 12,
),
),
],
),
),
),
// Monitors grid
if (isExpanded)
Padding(
padding: const EdgeInsets.only(top: 8),
child: LayoutBuilder(
builder: (context, constraints) {
// Calculate tile width based on columns
final tileWidth =
(constraints.maxWidth - (section.columns - 1) * 8) /
section.columns;
final tileHeight = tileWidth * 9 / 16; // 16:9 aspect ratio
return Wrap(
spacing: 8,
runSpacing: 8,
children: section.monitors.map((monitor) {
return SizedBox(
width: tileWidth,
height: tileHeight + 8, // Extra padding for quad border
child: PhysicalMonitorTile(
monitor: monitor,
wallState: wallState,
onViewerTap: onViewerTap,
),
);
}).toList(),
);
},
),
),
],
);
}
}