Initial commit: COPILOT D6 Flutter keyboard controller

Flutter web app replacing legacy WPF CCTV surveillance keyboard controller.
Includes wall overview, section view with monitor grid, camera input,
PTZ control, alarm/lock/sequence BLoCs, and legacy-matching UI styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
klas
2026-02-12 14:57:38 +01:00
commit 40143734fc
125 changed files with 65073 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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