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:
253
copilot_keyboard/lib/presentation/widgets/alarm_panel.dart
Normal file
253
copilot_keyboard/lib/presentation/widgets/alarm_panel.dart
Normal 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')}';
|
||||
}
|
||||
}
|
||||
129
copilot_keyboard/lib/presentation/widgets/camera_grid.dart
Normal file
129
copilot_keyboard/lib/presentation/widgets/camera_grid.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
150
copilot_keyboard/lib/presentation/widgets/monitor_grid.dart
Normal file
150
copilot_keyboard/lib/presentation/widgets/monitor_grid.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
231
copilot_keyboard/lib/presentation/widgets/ptz_control.dart
Normal file
231
copilot_keyboard/lib/presentation/widgets/ptz_control.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
182
copilot_keyboard/lib/presentation/widgets/sequence_panel.dart
Normal file
182
copilot_keyboard/lib/presentation/widgets/sequence_panel.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
147
copilot_keyboard/lib/presentation/widgets/takeover_dialog.dart
Normal file
147
copilot_keyboard/lib/presentation/widgets/takeover_dialog.dart
Normal 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í'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user