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:
@@ -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