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:
45
copilot_keyboard/.gitignore
vendored
Normal file
45
copilot_keyboard/.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
16
copilot_keyboard/README.md
Normal file
16
copilot_keyboard/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# copilot_keyboard
|
||||
|
||||
A new Flutter project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
28
copilot_keyboard/analysis_options.yaml
Normal file
28
copilot_keyboard/analysis_options.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
66
copilot_keyboard/assets/config/servers.json
Normal file
66
copilot_keyboard/assets/config/servers.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"servers": [
|
||||
{
|
||||
"id": "geviscope1",
|
||||
"name": "GeViScope Local",
|
||||
"type": "geviscope",
|
||||
"enabled": true,
|
||||
"connection": {
|
||||
"address": "localhost:12003",
|
||||
"port": 12003,
|
||||
"username": "sysadmin"
|
||||
},
|
||||
"bridge": {
|
||||
"url": "http://localhost:7720",
|
||||
"websocket": "ws://localhost:7720/ws/events"
|
||||
},
|
||||
"resources": {
|
||||
"cameraRange": {"start": 1, "end": 999},
|
||||
"monitorRange": {"start": 1, "end": 9999}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gcore1",
|
||||
"name": "G-Core Server 1",
|
||||
"type": "gcore",
|
||||
"enabled": false,
|
||||
"connection": {
|
||||
"address": "192.168.1.20",
|
||||
"port": 7700,
|
||||
"username": "admin"
|
||||
},
|
||||
"bridge": {
|
||||
"url": "http://localhost:7721",
|
||||
"websocket": "ws://localhost:7721/ws/events"
|
||||
},
|
||||
"resources": {
|
||||
"cameraRange": {"start": 1000, "end": 1999},
|
||||
"monitorRange": {"start": 1000, "end": 1999}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "geviserver1",
|
||||
"name": "GeViServer",
|
||||
"type": "geviserver",
|
||||
"enabled": false,
|
||||
"connection": {
|
||||
"address": "192.168.1.30",
|
||||
"port": 7700,
|
||||
"username": "admin"
|
||||
},
|
||||
"bridge": {
|
||||
"url": "http://localhost:7710",
|
||||
"websocket": "ws://localhost:7710/ws"
|
||||
},
|
||||
"resources": {
|
||||
"cameraRange": {"start": 0, "end": 0},
|
||||
"monitorRange": {"start": 0, "end": 0}
|
||||
}
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"alarmSyncIntervalSeconds": 30,
|
||||
"connectionRetrySeconds": 5,
|
||||
"commandTimeoutSeconds": 10
|
||||
}
|
||||
}
|
||||
50
copilot_keyboard/lib/app.dart
Normal file
50
copilot_keyboard/lib/app.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'injection_container.dart';
|
||||
import 'presentation/blocs/wall/wall_bloc.dart';
|
||||
import 'presentation/blocs/wall/wall_event.dart';
|
||||
import 'presentation/screens/main_screen.dart';
|
||||
|
||||
class CopilotKeyboardApp extends StatelessWidget {
|
||||
const CopilotKeyboardApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<WallBloc>(
|
||||
create: (_) => sl<WallBloc>()..add(const LoadWallConfig()),
|
||||
child: MaterialApp(
|
||||
title: 'COPILOT Keyboard',
|
||||
theme: _buildDarkTheme(),
|
||||
darkTheme: _buildDarkTheme(),
|
||||
themeMode: ThemeMode.dark,
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: const MainScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ThemeData _buildDarkTheme() {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF00D4FF),
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFF0A0E14),
|
||||
cardTheme: CardTheme(
|
||||
elevation: 0,
|
||||
color: const Color(0xFF151A22),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(36, 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
179
copilot_keyboard/lib/config/app_config.dart
Normal file
179
copilot_keyboard/lib/config/app_config.dart
Normal file
@@ -0,0 +1,179 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
import '../domain/entities/function_button_config.dart';
|
||||
import '../domain/entities/server_config.dart';
|
||||
|
||||
/// Application configuration loaded from servers.json
|
||||
class AppConfig {
|
||||
final List<ServerConfig> servers;
|
||||
final String coordinatorUrl;
|
||||
final String keyboardId;
|
||||
final FunctionButtonConfig functionButtons;
|
||||
final int alarmSyncIntervalSeconds;
|
||||
final int connectionRetrySeconds;
|
||||
final int commandTimeoutSeconds;
|
||||
|
||||
const AppConfig({
|
||||
required this.servers,
|
||||
this.coordinatorUrl = 'http://localhost:8090',
|
||||
this.keyboardId = 'keyboard-1',
|
||||
this.functionButtons = const FunctionButtonConfig(),
|
||||
this.alarmSyncIntervalSeconds = 30,
|
||||
this.connectionRetrySeconds = 5,
|
||||
this.commandTimeoutSeconds = 10,
|
||||
});
|
||||
|
||||
/// Load configuration from file or assets
|
||||
static Future<AppConfig> load({String? configPath}) async {
|
||||
final logger = Logger();
|
||||
Map<String, dynamic> configJson;
|
||||
|
||||
// Try loading from file path first
|
||||
if (configPath != null) {
|
||||
try {
|
||||
final file = File(configPath);
|
||||
if (await file.exists()) {
|
||||
final contents = await file.readAsString();
|
||||
configJson = jsonDecode(contents) as Map<String, dynamic>;
|
||||
logger.i('Loaded config from: $configPath');
|
||||
return _parseConfig(configJson);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.w('Failed to load config from file: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Try common file locations
|
||||
final commonPaths = [
|
||||
'servers.json',
|
||||
'../servers.json',
|
||||
'config/servers.json',
|
||||
r'C:\DEV\COPILOT_D6\servers.json',
|
||||
];
|
||||
|
||||
for (final path in commonPaths) {
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
final contents = await file.readAsString();
|
||||
configJson = jsonDecode(contents) as Map<String, dynamic>;
|
||||
logger.i('Loaded config from: $path');
|
||||
return _parseConfig(configJson);
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to next path
|
||||
}
|
||||
}
|
||||
|
||||
// Try loading from assets
|
||||
try {
|
||||
final contents = await rootBundle.loadString('assets/config/servers.json');
|
||||
configJson = jsonDecode(contents) as Map<String, dynamic>;
|
||||
logger.i('Loaded config from assets');
|
||||
return _parseConfig(configJson);
|
||||
} catch (e) {
|
||||
logger.w('Failed to load config from assets: $e');
|
||||
}
|
||||
|
||||
// Return default empty config
|
||||
logger.w('No config file found, using defaults');
|
||||
return const AppConfig(servers: []);
|
||||
}
|
||||
|
||||
static AppConfig _parseConfig(Map<String, dynamic> json) {
|
||||
final serversJson = json['servers'] as List<dynamic>? ?? [];
|
||||
final servers = serversJson
|
||||
.map((s) => ServerConfig.fromJson(s as Map<String, dynamic>))
|
||||
.where((s) => s.enabled)
|
||||
.toList();
|
||||
|
||||
final settings = json['settings'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
// Parse function button config
|
||||
final fbJson = json['functionButtons'] as Map<String, dynamic>?;
|
||||
final functionButtons = fbJson != null
|
||||
? FunctionButtonConfig.fromJson(fbJson)
|
||||
: const FunctionButtonConfig();
|
||||
|
||||
return AppConfig(
|
||||
servers: servers,
|
||||
coordinatorUrl: settings['coordinatorUrl'] as String? ?? 'http://localhost:8090',
|
||||
keyboardId: settings['keyboardId'] as String? ?? 'keyboard-1',
|
||||
functionButtons: functionButtons,
|
||||
alarmSyncIntervalSeconds: settings['alarmSyncIntervalSeconds'] as int? ?? 30,
|
||||
connectionRetrySeconds: settings['connectionRetrySeconds'] as int? ?? 5,
|
||||
commandTimeoutSeconds: settings['commandTimeoutSeconds'] as int? ?? 10,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get servers by type
|
||||
List<ServerConfig> getServersByType(ServerType type) {
|
||||
return servers.where((s) => s.type == type).toList();
|
||||
}
|
||||
|
||||
/// Get server that owns a camera ID
|
||||
ServerConfig? getServerForCamera(int cameraId) {
|
||||
for (final server in servers) {
|
||||
if (server.ownsCamera(cameraId)) {
|
||||
return server;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get server that owns a monitor ID
|
||||
ServerConfig? getServerForMonitor(int monitorId) {
|
||||
for (final server in servers) {
|
||||
if (server.ownsMonitor(monitorId)) {
|
||||
return server;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get total camera count across all servers
|
||||
int get totalCameras {
|
||||
int count = 0;
|
||||
for (final server in servers) {
|
||||
count += (server.cameraRangeEnd - server.cameraRangeStart + 1);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Get total monitor count across all servers
|
||||
int get totalMonitors {
|
||||
int count = 0;
|
||||
for (final server in servers) {
|
||||
count += (server.monitorRangeEnd - server.monitorRangeStart + 1);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Get all camera IDs
|
||||
List<int> get allCameraIds {
|
||||
final ids = <int>[];
|
||||
for (final server in servers) {
|
||||
for (int i = server.cameraRangeStart; i <= server.cameraRangeEnd; i++) {
|
||||
ids.add(i);
|
||||
}
|
||||
}
|
||||
ids.sort();
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// Get all monitor IDs
|
||||
List<int> get allMonitorIds {
|
||||
final ids = <int>[];
|
||||
for (final server in servers) {
|
||||
for (int i = server.monitorRangeStart; i <= server.monitorRangeEnd; i++) {
|
||||
ids.add(i);
|
||||
}
|
||||
}
|
||||
ids.sort();
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
64
copilot_keyboard/lib/data/models/bridge_event.dart
Normal file
64
copilot_keyboard/lib/data/models/bridge_event.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
/// Event received from bridge WebSocket
|
||||
class BridgeEvent {
|
||||
final String serverId;
|
||||
final DateTime timestamp;
|
||||
final String action;
|
||||
final Map<String, dynamic> params;
|
||||
|
||||
BridgeEvent({
|
||||
required this.serverId,
|
||||
required this.timestamp,
|
||||
required this.action,
|
||||
required this.params,
|
||||
});
|
||||
|
||||
factory BridgeEvent.fromJson(Map<String, dynamic> json, String serverId) {
|
||||
return BridgeEvent(
|
||||
serverId: serverId,
|
||||
timestamp: json['timestamp'] != null
|
||||
? DateTime.parse(json['timestamp'] as String)
|
||||
: DateTime.now(),
|
||||
action: json['action'] as String? ?? '',
|
||||
params: json['params'] as Map<String, dynamic>? ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
// Specific event type checks
|
||||
bool get isViewerConnected => action == 'ViewerConnected';
|
||||
bool get isViewerCleared => action == 'ViewerCleared';
|
||||
bool get isViewerSelectionChanged => action == 'ViewerSelectionChanged';
|
||||
bool get isEventStarted => action == 'EventStarted';
|
||||
bool get isEventStopped => action == 'EventStopped';
|
||||
bool get isDigitalInput => action == 'DigitalInput';
|
||||
bool get isAlarmQueueNotification => action == 'VCAlarmQueueNotification';
|
||||
bool get isConnectionLost => action == 'ConnectionLost';
|
||||
|
||||
// Viewer events
|
||||
int? get viewer => params['Viewer'] as int?;
|
||||
int? get channel => params['Channel'] as int?;
|
||||
int? get playMode => params['PlayMode'] as int?;
|
||||
|
||||
// Alarm events
|
||||
int? get eventId => params['EventID'] as int?;
|
||||
String? get eventName => params['EventName'] as String?;
|
||||
int? get typeId => params['TypeID'] as int?;
|
||||
int? get foreignKey {
|
||||
final fk = params['ForeignKey'];
|
||||
if (fk is int) return fk;
|
||||
if (fk is String) return int.tryParse(fk);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Digital input
|
||||
int? get contact => params['Contact'] as int?;
|
||||
int? get state => params['State'] as int?;
|
||||
|
||||
// Alarm queue notification
|
||||
int? get notification => params['Notification'] as int?;
|
||||
int? get alarmId => params['AlarmID'] as int?;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BridgeEvent($action from $serverId at $timestamp)';
|
||||
}
|
||||
}
|
||||
265
copilot_keyboard/lib/data/services/alarm_service.dart
Normal file
265
copilot_keyboard/lib/data/services/alarm_service.dart
Normal file
@@ -0,0 +1,265 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
import '../../domain/entities/alarm_state.dart';
|
||||
import '../../domain/entities/server_config.dart';
|
||||
import '../models/bridge_event.dart';
|
||||
|
||||
/// Service for querying and tracking alarm state from GeViServer
|
||||
class AlarmService {
|
||||
final Logger _logger = Logger();
|
||||
final Map<String, Dio> _httpClients = {};
|
||||
final Map<String, ServerConfig> _servers = {};
|
||||
|
||||
// Alarm state
|
||||
final _alarmsController = BehaviorSubject<List<AlarmState>>.seeded([]);
|
||||
Timer? _syncTimer;
|
||||
|
||||
/// Stream of active alarms
|
||||
Stream<List<AlarmState>> get alarms => _alarmsController.stream;
|
||||
|
||||
/// Current active alarms
|
||||
List<AlarmState> get currentAlarms => _alarmsController.value;
|
||||
|
||||
/// Initialize with GeViServer configurations
|
||||
Future<void> initialize(List<ServerConfig> servers) async {
|
||||
// Only use GeViServer type servers for alarm queries
|
||||
final geviServers =
|
||||
servers.where((s) => s.type == ServerType.geviserver && s.enabled);
|
||||
|
||||
for (final server in geviServers) {
|
||||
_servers[server.id] = server;
|
||||
_httpClients[server.id] = Dio(BaseOptions(
|
||||
baseUrl: server.bridgeUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
));
|
||||
}
|
||||
|
||||
_logger.i('AlarmService initialized with ${_servers.length} GeViServers');
|
||||
}
|
||||
|
||||
/// Query all active alarms from all GeViServers
|
||||
Future<List<AlarmState>> queryAllAlarms() async {
|
||||
final allAlarms = <AlarmState>[];
|
||||
|
||||
for (final entry in _servers.entries) {
|
||||
final serverId = entry.key;
|
||||
try {
|
||||
final alarms = await _queryAlarmsFromServer(serverId);
|
||||
allAlarms.addAll(alarms);
|
||||
_logger.i('Queried ${alarms.length} alarms from $serverId');
|
||||
} catch (e) {
|
||||
_logger.e('Failed to query alarms from $serverId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
_alarmsController.add(allAlarms);
|
||||
return allAlarms;
|
||||
}
|
||||
|
||||
/// Query alarms from a specific server using GetFirstAlarm/GetNextAlarm pattern
|
||||
Future<List<AlarmState>> _queryAlarmsFromServer(String serverId) async {
|
||||
final client = _httpClients[serverId];
|
||||
if (client == null) return [];
|
||||
|
||||
final alarms = <AlarmState>[];
|
||||
|
||||
try {
|
||||
// Try the bulk endpoint first (preferred)
|
||||
final response = await client.get('/alarms/active');
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final alarmList = data['alarms'] as List<dynamic>? ?? [];
|
||||
|
||||
for (final alarmJson in alarmList) {
|
||||
final alarm = _parseAlarmFromJson(
|
||||
alarmJson as Map<String, dynamic>,
|
||||
serverId,
|
||||
);
|
||||
if (alarm != null) {
|
||||
alarms.add(alarm);
|
||||
}
|
||||
}
|
||||
return alarms;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.d('Bulk alarm query failed, trying iteration: $e');
|
||||
}
|
||||
|
||||
// Fallback to GetFirstAlarm/GetNextAlarm iteration
|
||||
try {
|
||||
// Get first alarm
|
||||
var response = await client.get('/alarms/first');
|
||||
if (response.statusCode != 200) return alarms;
|
||||
|
||||
var data = response.data as Map<String, dynamic>;
|
||||
if (data['alarm'] == null) return alarms; // No alarms
|
||||
|
||||
var alarm = _parseAlarmFromJson(
|
||||
data['alarm'] as Map<String, dynamic>,
|
||||
serverId,
|
||||
);
|
||||
if (alarm != null) alarms.add(alarm);
|
||||
|
||||
// Iterate through remaining alarms
|
||||
while (true) {
|
||||
response = await client.get('/alarms/next');
|
||||
if (response.statusCode != 200) break;
|
||||
|
||||
data = response.data as Map<String, dynamic>;
|
||||
if (data['alarm'] == null) break; // No more alarms
|
||||
|
||||
alarm = _parseAlarmFromJson(
|
||||
data['alarm'] as Map<String, dynamic>,
|
||||
serverId,
|
||||
);
|
||||
if (alarm != null) alarms.add(alarm);
|
||||
|
||||
// Safety limit
|
||||
if (alarms.length > 1000) {
|
||||
_logger.w('Alarm query hit safety limit of 1000');
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('Alarm iteration failed: $e');
|
||||
}
|
||||
|
||||
return alarms;
|
||||
}
|
||||
|
||||
/// Parse alarm from JSON response
|
||||
AlarmState? _parseAlarmFromJson(Map<String, dynamic> json, String serverId) {
|
||||
try {
|
||||
return AlarmState(
|
||||
eventId: json['EventID'] as int? ?? json['event_id'] as int? ?? 0,
|
||||
eventName:
|
||||
json['EventName'] as String? ?? json['event_name'] as String? ?? '',
|
||||
typeId: json['TypeID'] as int? ?? json['type_id'] as int? ?? 0,
|
||||
foreignKey: _parseForeignKey(json['ForeignKey'] ?? json['foreign_key']),
|
||||
serverId: serverId,
|
||||
startedAt: _parseDateTime(json['StartedAt'] ?? json['started_at']),
|
||||
stoppedAt: _parseNullableDateTime(json['StoppedAt'] ?? json['stopped_at']),
|
||||
isActive: json['IsActive'] as bool? ?? json['is_active'] as bool? ?? true,
|
||||
status: AlarmStatus.fromValue(
|
||||
json['Status'] as int? ?? json['status'] as int? ?? 0,
|
||||
),
|
||||
associatedMonitor: json['AssociatedMonitor'] as int? ??
|
||||
json['associated_monitor'] as int?,
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.e('Failed to parse alarm: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
int _parseForeignKey(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
DateTime _parseDateTime(dynamic value) {
|
||||
if (value is DateTime) return value;
|
||||
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||
return DateTime.now();
|
||||
}
|
||||
|
||||
DateTime? _parseNullableDateTime(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is DateTime) return value;
|
||||
if (value is String) return DateTime.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Start periodic alarm sync
|
||||
void startPeriodicSync(Duration interval) {
|
||||
stopPeriodicSync();
|
||||
_syncTimer = Timer.periodic(interval, (_) => queryAllAlarms());
|
||||
_logger.i('Started periodic alarm sync every ${interval.inSeconds}s');
|
||||
}
|
||||
|
||||
/// Stop periodic alarm sync
|
||||
void stopPeriodicSync() {
|
||||
_syncTimer?.cancel();
|
||||
_syncTimer = null;
|
||||
}
|
||||
|
||||
/// Handle alarm event from WebSocket
|
||||
void handleAlarmEvent(BridgeEvent event) {
|
||||
final currentAlarms = List<AlarmState>.from(_alarmsController.value);
|
||||
|
||||
if (event.isEventStarted) {
|
||||
// New alarm started
|
||||
final newAlarm = AlarmState(
|
||||
eventId: event.eventId ?? 0,
|
||||
eventName: event.eventName ?? '',
|
||||
typeId: event.typeId ?? 0,
|
||||
foreignKey: event.foreignKey ?? 0,
|
||||
serverId: event.serverId,
|
||||
startedAt: event.timestamp,
|
||||
isActive: true,
|
||||
status: AlarmStatus.newAlarm,
|
||||
);
|
||||
|
||||
// Remove existing alarm with same ID if any
|
||||
currentAlarms.removeWhere((a) =>
|
||||
a.eventId == newAlarm.eventId && a.serverId == newAlarm.serverId);
|
||||
currentAlarms.add(newAlarm);
|
||||
|
||||
_logger.d('Alarm started: ${newAlarm.eventName} (ID: ${newAlarm.eventId})');
|
||||
} else if (event.isEventStopped) {
|
||||
// Alarm stopped
|
||||
final index = currentAlarms.indexWhere((a) =>
|
||||
a.eventId == event.eventId && a.serverId == event.serverId);
|
||||
|
||||
if (index >= 0) {
|
||||
currentAlarms[index] = currentAlarms[index].stopped();
|
||||
_logger.d('Alarm stopped: ${currentAlarms[index].eventName}');
|
||||
}
|
||||
} else if (event.isAlarmQueueNotification) {
|
||||
// Alarm status changed (presented, confirmed, etc.)
|
||||
final alarmId = event.alarmId;
|
||||
final notification = event.notification;
|
||||
|
||||
if (alarmId != null && notification != null) {
|
||||
final index = currentAlarms.indexWhere((a) =>
|
||||
a.eventId == alarmId && a.serverId == event.serverId);
|
||||
|
||||
if (index >= 0) {
|
||||
final newStatus = AlarmStatus.fromValue(notification);
|
||||
currentAlarms[index] = currentAlarms[index].withStatus(newStatus);
|
||||
_logger.d(
|
||||
'Alarm status changed: ${currentAlarms[index].eventName} -> ${newStatus.name}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_alarmsController.add(currentAlarms);
|
||||
}
|
||||
|
||||
/// Get alarms blocking a specific monitor
|
||||
List<AlarmState> getAlarmsForMonitor(int monitorId) {
|
||||
return _alarmsController.value
|
||||
.where((a) => a.associatedMonitor == monitorId && a.blocksMonitor)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Check if any alarm blocks a monitor
|
||||
bool isMonitorBlocked(int monitorId) {
|
||||
return _alarmsController.value
|
||||
.any((a) => a.associatedMonitor == monitorId && a.blocksMonitor);
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
stopPeriodicSync();
|
||||
_alarmsController.close();
|
||||
_httpClients.clear();
|
||||
_servers.clear();
|
||||
}
|
||||
}
|
||||
487
copilot_keyboard/lib/data/services/bridge_service.dart
Normal file
487
copilot_keyboard/lib/data/services/bridge_service.dart
Normal file
@@ -0,0 +1,487 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../../domain/entities/server_config.dart';
|
||||
import '../models/bridge_event.dart';
|
||||
|
||||
/// Service for communicating with all bridges.
|
||||
/// Includes auto-reconnection with exponential backoff and health polling.
|
||||
class BridgeService {
|
||||
final Logger _logger = Logger();
|
||||
final Map<String, Dio> _httpClients = {};
|
||||
final Map<String, WebSocketChannel> _wsChannels = {};
|
||||
final Map<String, StreamSubscription> _wsSubscriptions = {};
|
||||
final Map<String, ServerConfig> _servers = {};
|
||||
final Map<String, int> _reconnectAttempts = {};
|
||||
final Map<String, Timer> _reconnectTimers = {};
|
||||
Timer? _healthCheckTimer;
|
||||
bool _disposed = false;
|
||||
|
||||
// Reconnection config
|
||||
static const _initialReconnectDelay = Duration(seconds: 1);
|
||||
static const _maxReconnectDelay = Duration(seconds: 30);
|
||||
static const _healthCheckInterval = Duration(seconds: 10);
|
||||
static const _commandRetryCount = 3;
|
||||
static const _commandRetryDelay = Duration(milliseconds: 200);
|
||||
|
||||
// Event streams
|
||||
final _eventController = BehaviorSubject<BridgeEvent>();
|
||||
final _connectionStatusController =
|
||||
BehaviorSubject<Map<String, bool>>.seeded({});
|
||||
|
||||
// Callback for state resync after reconnection
|
||||
void Function(String serverId)? onReconnected;
|
||||
|
||||
/// Stream of all events from all bridges
|
||||
Stream<BridgeEvent> get eventStream => _eventController.stream;
|
||||
|
||||
/// Stream of connection status per server
|
||||
Stream<Map<String, bool>> get connectionStatus =>
|
||||
_connectionStatusController.stream;
|
||||
|
||||
/// Get current connection status
|
||||
Map<String, bool> get currentConnectionStatus =>
|
||||
_connectionStatusController.value;
|
||||
|
||||
/// Initialize the service with server configurations
|
||||
Future<void> initialize(List<ServerConfig> servers) async {
|
||||
_logger.i('Initializing BridgeService with ${servers.length} servers');
|
||||
|
||||
for (final server in servers) {
|
||||
if (!server.enabled) {
|
||||
_logger.d('Skipping disabled server: ${server.id}');
|
||||
continue;
|
||||
}
|
||||
|
||||
_servers[server.id] = server;
|
||||
|
||||
// Create HTTP client
|
||||
_httpClients[server.id] = Dio(BaseOptions(
|
||||
baseUrl: server.bridgeUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
));
|
||||
|
||||
_updateConnectionStatus(server.id, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to a specific server's bridge
|
||||
Future<bool> connect(String serverId) async {
|
||||
final server = _servers[serverId];
|
||||
if (server == null) {
|
||||
_logger.w('Unknown server: $serverId');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check bridge health
|
||||
final response =
|
||||
await _httpClients[serverId]!.get('/health');
|
||||
if (response.statusCode == 200) {
|
||||
_updateConnectionStatus(serverId, true);
|
||||
_logger.i('Connected to bridge: $serverId');
|
||||
|
||||
// Connect WebSocket for events if available
|
||||
if (server.websocketUrl != null) {
|
||||
await _connectWebSocket(serverId, server.websocketUrl!);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('Failed to connect to $serverId: $e');
|
||||
}
|
||||
|
||||
_updateConnectionStatus(serverId, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Connect to all configured servers and start health monitoring
|
||||
Future<void> connectAll() async {
|
||||
for (final serverId in _servers.keys) {
|
||||
await connect(serverId);
|
||||
}
|
||||
_startHealthChecks();
|
||||
}
|
||||
|
||||
/// Disconnect from a specific server
|
||||
Future<void> disconnect(String serverId) async {
|
||||
await _disconnectWebSocket(serverId);
|
||||
_updateConnectionStatus(serverId, false);
|
||||
_logger.i('Disconnected from bridge: $serverId');
|
||||
}
|
||||
|
||||
/// Disconnect from all servers
|
||||
Future<void> disconnectAll() async {
|
||||
for (final serverId in _servers.keys) {
|
||||
await disconnect(serverId);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Command Methods
|
||||
// ============================================================
|
||||
|
||||
/// Switch camera to monitor (ViewerConnectLive).
|
||||
/// Critical command — retries up to 3 times with backoff.
|
||||
Future<bool> viewerConnectLive(int viewer, int channel) async {
|
||||
final serverId = _findServerForMonitor(viewer);
|
||||
if (serverId == null) {
|
||||
_logger.w('No server found for monitor $viewer');
|
||||
return false;
|
||||
}
|
||||
|
||||
return _retryCommand('viewerConnectLive', () async {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/viewer/connect-live',
|
||||
data: {'Viewer': viewer, 'Channel': channel},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear monitor (ViewerClear)
|
||||
Future<bool> viewerClear(int viewer) async {
|
||||
final serverId = _findServerForMonitor(viewer);
|
||||
if (serverId == null) return false;
|
||||
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/viewer/clear',
|
||||
data: {'Viewer': viewer},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('viewerClear failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// PTZ Pan control
|
||||
Future<bool> ptzPan(int camera, String direction, int speed) async {
|
||||
final serverId = _findServerForCamera(camera);
|
||||
if (serverId == null) return false;
|
||||
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/camera/pan',
|
||||
data: {'Camera': camera, 'Direction': direction, 'Speed': speed},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('ptzPan failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// PTZ Tilt control
|
||||
Future<bool> ptzTilt(int camera, String direction, int speed) async {
|
||||
final serverId = _findServerForCamera(camera);
|
||||
if (serverId == null) return false;
|
||||
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/camera/tilt',
|
||||
data: {'Camera': camera, 'Direction': direction, 'Speed': speed},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('ptzTilt failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// PTZ Zoom control
|
||||
Future<bool> ptzZoom(int camera, String direction, int speed) async {
|
||||
final serverId = _findServerForCamera(camera);
|
||||
if (serverId == null) return false;
|
||||
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/camera/zoom',
|
||||
data: {'Camera': camera, 'Direction': direction, 'Speed': speed},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('ptzZoom failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// PTZ Stop all movement
|
||||
Future<bool> ptzStop(int camera) async {
|
||||
final serverId = _findServerForCamera(camera);
|
||||
if (serverId == null) return false;
|
||||
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/camera/stop',
|
||||
data: {'Camera': camera},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('ptzStop failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// PTZ Go to preset
|
||||
Future<bool> ptzPreset(int camera, int preset) async {
|
||||
final serverId = _findServerForCamera(camera);
|
||||
if (serverId == null) return false;
|
||||
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/camera/preset',
|
||||
data: {'Camera': camera, 'Preset': preset},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('ptzPreset failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Query Methods
|
||||
// ============================================================
|
||||
|
||||
/// Get current monitor states from a bridge
|
||||
Future<List<Map<String, dynamic>>> getMonitorStates(String serverId) async {
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.get('/monitors');
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final monitors = data['monitors'] as List<dynamic>? ?? [];
|
||||
return monitors.cast<Map<String, dynamic>>();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getMonitorStates failed for $serverId: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get active alarms from a bridge
|
||||
Future<List<Map<String, dynamic>>> getActiveAlarms(String serverId) async {
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.get('/alarms/active');
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final alarms = data['alarms'] as List<dynamic>? ?? [];
|
||||
return alarms.cast<Map<String, dynamic>>();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getActiveAlarms failed for $serverId: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get bridge status
|
||||
Future<Map<String, dynamic>?> getBridgeStatus(String serverId) async {
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.get('/status');
|
||||
if (response.statusCode == 200) {
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getBridgeStatus failed for $serverId: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Private Methods
|
||||
// ============================================================
|
||||
|
||||
String? _findServerForCamera(int cameraId) {
|
||||
for (final entry in _servers.entries) {
|
||||
if (entry.value.ownsCamera(cameraId)) {
|
||||
return entry.key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _findServerForMonitor(int monitorId) {
|
||||
for (final entry in _servers.entries) {
|
||||
if (entry.value.ownsMonitor(monitorId)) {
|
||||
return entry.key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _updateConnectionStatus(String serverId, bool connected) {
|
||||
final current = Map<String, bool>.from(_connectionStatusController.value);
|
||||
current[serverId] = connected;
|
||||
_connectionStatusController.add(current);
|
||||
}
|
||||
|
||||
Future<void> _connectWebSocket(String serverId, String url) async {
|
||||
try {
|
||||
await _disconnectWebSocket(serverId);
|
||||
|
||||
_logger.d('Connecting WebSocket to $url');
|
||||
final channel = WebSocketChannel.connect(Uri.parse(url));
|
||||
_wsChannels[serverId] = channel;
|
||||
_reconnectAttempts[serverId] = 0; // Reset on successful connection
|
||||
|
||||
_wsSubscriptions[serverId] = channel.stream.listen(
|
||||
(message) {
|
||||
try {
|
||||
final json = jsonDecode(message as String) as Map<String, dynamic>;
|
||||
if (json['type'] == 'connected') {
|
||||
_logger.d('WebSocket connected to $serverId');
|
||||
return;
|
||||
}
|
||||
final event = BridgeEvent.fromJson(json, serverId);
|
||||
_eventController.add(event);
|
||||
} catch (e) {
|
||||
_logger.e('Failed to parse WebSocket message: $e');
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
_logger.e('WebSocket error for $serverId: $error');
|
||||
_updateConnectionStatus(serverId, false);
|
||||
_scheduleReconnect(serverId);
|
||||
},
|
||||
onDone: () {
|
||||
_logger.w('WebSocket closed for $serverId');
|
||||
_updateConnectionStatus(serverId, false);
|
||||
_scheduleReconnect(serverId);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.e('Failed to connect WebSocket to $serverId: $e');
|
||||
_scheduleReconnect(serverId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnectWebSocket(String serverId) async {
|
||||
await _wsSubscriptions[serverId]?.cancel();
|
||||
_wsSubscriptions.remove(serverId);
|
||||
|
||||
await _wsChannels[serverId]?.sink.close();
|
||||
_wsChannels.remove(serverId);
|
||||
}
|
||||
|
||||
/// Retry a critical command with exponential backoff.
|
||||
/// Used for CrossSwitch and other critical operations.
|
||||
Future<bool> _retryCommand(String name, Future<bool> Function() command) async {
|
||||
for (int attempt = 1; attempt <= _commandRetryCount; attempt++) {
|
||||
try {
|
||||
final result = await command();
|
||||
if (result) return true;
|
||||
} catch (e) {
|
||||
if (attempt == _commandRetryCount) {
|
||||
_logger.e('$name failed after $attempt attempts: $e');
|
||||
return false;
|
||||
}
|
||||
_logger.w('$name attempt $attempt failed, retrying: $e');
|
||||
await Future.delayed(_commandRetryDelay * attempt);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Schedule WebSocket reconnection with exponential backoff.
|
||||
void _scheduleReconnect(String serverId) {
|
||||
if (_disposed) return;
|
||||
_reconnectTimers[serverId]?.cancel();
|
||||
|
||||
final attempts = _reconnectAttempts[serverId] ?? 0;
|
||||
final delay = Duration(
|
||||
milliseconds: (_initialReconnectDelay.inMilliseconds *
|
||||
(1 << attempts.clamp(0, 5))) // 1s, 2s, 4s, 8s, 16s, 32s
|
||||
.clamp(0, _maxReconnectDelay.inMilliseconds),
|
||||
);
|
||||
|
||||
_logger.i('Scheduling reconnect for $serverId in ${delay.inSeconds}s (attempt ${attempts + 1})');
|
||||
|
||||
_reconnectTimers[serverId] = Timer(delay, () async {
|
||||
if (_disposed) return;
|
||||
_reconnectAttempts[serverId] = attempts + 1;
|
||||
|
||||
final server = _servers[serverId];
|
||||
if (server == null) return;
|
||||
|
||||
// Check if bridge is healthy before reconnecting WebSocket
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.get('/health');
|
||||
if (response.statusCode == 200) {
|
||||
_updateConnectionStatus(serverId, true);
|
||||
if (server.websocketUrl != null) {
|
||||
await _connectWebSocket(serverId, server.websocketUrl!);
|
||||
}
|
||||
_logger.i('Reconnected to $serverId');
|
||||
onReconnected?.call(serverId);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.d('Reconnect health check failed for $serverId: $e');
|
||||
_scheduleReconnect(serverId); // Try again
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Start periodic health checks for all bridges.
|
||||
/// Detects when a bridge comes back online after failure.
|
||||
void _startHealthChecks() {
|
||||
_healthCheckTimer?.cancel();
|
||||
_healthCheckTimer = Timer.periodic(_healthCheckInterval, (_) async {
|
||||
if (_disposed) return;
|
||||
for (final serverId in _servers.keys) {
|
||||
final isConnected = currentConnectionStatus[serverId] ?? false;
|
||||
if (!isConnected) {
|
||||
// Bridge is down — try to reconnect
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.get('/health');
|
||||
if (response.statusCode == 200) {
|
||||
_logger.i('Bridge $serverId is back online');
|
||||
_updateConnectionStatus(serverId, true);
|
||||
_reconnectAttempts[serverId] = 0;
|
||||
|
||||
final server = _servers[serverId];
|
||||
if (server?.websocketUrl != null) {
|
||||
await _connectWebSocket(serverId, server!.websocketUrl!);
|
||||
}
|
||||
onReconnected?.call(serverId);
|
||||
}
|
||||
} catch (_) {
|
||||
// Still down, will check again next cycle
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Dispose of all resources
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_healthCheckTimer?.cancel();
|
||||
for (final timer in _reconnectTimers.values) {
|
||||
timer.cancel();
|
||||
}
|
||||
_reconnectTimers.clear();
|
||||
_eventController.close();
|
||||
_connectionStatusController.close();
|
||||
|
||||
for (final sub in _wsSubscriptions.values) {
|
||||
sub.cancel();
|
||||
}
|
||||
_wsSubscriptions.clear();
|
||||
|
||||
for (final channel in _wsChannels.values) {
|
||||
channel.sink.close();
|
||||
}
|
||||
_wsChannels.clear();
|
||||
|
||||
_httpClients.clear();
|
||||
_servers.clear();
|
||||
}
|
||||
}
|
||||
464
copilot_keyboard/lib/data/services/coordination_service.dart
Normal file
464
copilot_keyboard/lib/data/services/coordination_service.dart
Normal file
@@ -0,0 +1,464 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../../domain/entities/camera_lock.dart';
|
||||
|
||||
/// Client for the COPILOT Coordinator service (:8090).
|
||||
/// Handles camera locks, sequences, and keyboard coordination.
|
||||
class CoordinationService {
|
||||
final Logger _logger = Logger();
|
||||
late final Dio _http;
|
||||
WebSocketChannel? _ws;
|
||||
StreamSubscription? _wsSubscription;
|
||||
Timer? _reconnectTimer;
|
||||
Timer? _lockResetTimer;
|
||||
bool _disposed = false;
|
||||
|
||||
String _coordinatorUrl = '';
|
||||
String _keyboardId = '';
|
||||
|
||||
// Reconnection config
|
||||
static const _initialReconnectDelay = Duration(seconds: 1);
|
||||
static const _maxReconnectDelay = Duration(seconds: 30);
|
||||
int _reconnectAttempts = 0;
|
||||
|
||||
// Connection status
|
||||
final _connectedController = BehaviorSubject<bool>.seeded(false);
|
||||
|
||||
// Lock state
|
||||
final _locksController = BehaviorSubject<Map<int, CameraLock>>.seeded({});
|
||||
final _notificationController =
|
||||
BehaviorSubject<CameraLockNotification?>.seeded(null);
|
||||
|
||||
/// Whether connected to the coordinator
|
||||
Stream<bool> get connected => _connectedController.stream;
|
||||
bool get isConnected => _connectedController.value;
|
||||
|
||||
/// Current camera locks (all keyboards)
|
||||
Stream<Map<int, CameraLock>> get locks => _locksController.stream;
|
||||
Map<int, CameraLock> get currentLocks => _locksController.value;
|
||||
|
||||
/// Lock notifications targeted at this keyboard
|
||||
Stream<CameraLockNotification?> get notifications =>
|
||||
_notificationController.stream;
|
||||
|
||||
/// Initialize with coordinator URL and keyboard identity
|
||||
Future<void> initialize(String coordinatorUrl, String keyboardId) async {
|
||||
_coordinatorUrl = coordinatorUrl;
|
||||
_keyboardId = keyboardId;
|
||||
|
||||
_http = Dio(BaseOptions(
|
||||
baseUrl: coordinatorUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
));
|
||||
|
||||
_logger.i(
|
||||
'CoordinationService initialized: $coordinatorUrl (keyboard: $keyboardId)');
|
||||
}
|
||||
|
||||
/// Connect to the coordinator (HTTP health check + WebSocket)
|
||||
Future<bool> connect() async {
|
||||
try {
|
||||
final response = await _http.get('/health');
|
||||
if (response.statusCode == 200) {
|
||||
_connectedController.add(true);
|
||||
_reconnectAttempts = 0;
|
||||
await _connectWebSocket();
|
||||
|
||||
// Sync current lock state
|
||||
await syncLocks();
|
||||
_logger.i('Connected to coordinator');
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('Failed to connect to coordinator: $e');
|
||||
}
|
||||
|
||||
_connectedController.add(false);
|
||||
_scheduleReconnect();
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Disconnect from the coordinator
|
||||
Future<void> disconnect() async {
|
||||
_reconnectTimer?.cancel();
|
||||
_lockResetTimer?.cancel();
|
||||
await _disconnectWebSocket();
|
||||
_connectedController.add(false);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Lock Operations
|
||||
// ============================================================
|
||||
|
||||
/// Try to acquire a lock on a camera
|
||||
Future<({bool acquired, CameraLock? lock})> tryLock(
|
||||
int cameraId, {
|
||||
CameraLockPriority priority = CameraLockPriority.low,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _http.post('/locks/try', data: {
|
||||
'CameraId': cameraId,
|
||||
'KeyboardId': _keyboardId,
|
||||
'Priority': priority.name,
|
||||
});
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final acquired = data['acquired'] as bool? ?? false;
|
||||
final lockJson = data['lock'] as Map<String, dynamic>?;
|
||||
final lock = lockJson != null ? CameraLock.fromJson(lockJson) : null;
|
||||
|
||||
if (acquired && lock != null) {
|
||||
_updateLockState(lock);
|
||||
_startLockResetTimer(cameraId);
|
||||
}
|
||||
|
||||
return (acquired: acquired, lock: lock);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('tryLock failed for camera $cameraId: $e');
|
||||
}
|
||||
return (acquired: false, lock: null);
|
||||
}
|
||||
|
||||
/// Release a camera lock
|
||||
Future<bool> releaseLock(int cameraId) async {
|
||||
try {
|
||||
final response = await _http.post('/locks/release', data: {
|
||||
'CameraId': cameraId,
|
||||
'KeyboardId': _keyboardId,
|
||||
});
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
_removeLockState(cameraId);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('releaseLock failed for camera $cameraId: $e');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Request takeover of a camera locked by another keyboard
|
||||
Future<bool> requestTakeover(
|
||||
int cameraId, {
|
||||
CameraLockPriority priority = CameraLockPriority.low,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _http.post('/locks/takeover', data: {
|
||||
'CameraId': cameraId,
|
||||
'KeyboardId': _keyboardId,
|
||||
'Priority': priority.name,
|
||||
});
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('requestTakeover failed for camera $cameraId: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirm or reject a takeover request
|
||||
Future<bool> confirmTakeover(int cameraId, bool confirm) async {
|
||||
try {
|
||||
final response = await _http.post('/locks/confirm', data: {
|
||||
'CameraId': cameraId,
|
||||
'KeyboardId': _keyboardId,
|
||||
'Confirm': confirm,
|
||||
});
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('confirmTakeover failed for camera $cameraId: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset lock expiration timer (called periodically while PTZ is active)
|
||||
Future<bool> resetExpiration(int cameraId) async {
|
||||
try {
|
||||
final response = await _http.post('/locks/reset', data: {
|
||||
'CameraId': cameraId,
|
||||
'KeyboardId': _keyboardId,
|
||||
});
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('resetExpiration failed for camera $cameraId: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all current locks from the coordinator
|
||||
Future<void> syncLocks() async {
|
||||
try {
|
||||
final response = await _http.get('/locks');
|
||||
if (response.statusCode == 200) {
|
||||
final lockList = response.data as List<dynamic>;
|
||||
final locks = <int, CameraLock>{};
|
||||
for (final lockJson in lockList) {
|
||||
final lock = CameraLock.fromJson(lockJson as Map<String, dynamic>);
|
||||
locks[lock.cameraId] = lock;
|
||||
}
|
||||
_locksController.add(locks);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('syncLocks failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get camera IDs locked by this keyboard
|
||||
Future<List<int>> getMyLockedCameras() async {
|
||||
try {
|
||||
final response = await _http.get('/locks/$_keyboardId');
|
||||
if (response.statusCode == 200) {
|
||||
final ids = response.data as List<dynamic>;
|
||||
return ids.cast<int>();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getMyLockedCameras failed: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Check if a camera is locked by this keyboard
|
||||
bool isCameraLockedByMe(int cameraId) {
|
||||
final lock = currentLocks[cameraId];
|
||||
return lock != null &&
|
||||
lock.ownerName.toLowerCase() == _keyboardId.toLowerCase();
|
||||
}
|
||||
|
||||
/// Check if a camera is locked by another keyboard
|
||||
bool isCameraLockedByOther(int cameraId) {
|
||||
final lock = currentLocks[cameraId];
|
||||
return lock != null &&
|
||||
lock.ownerName.toLowerCase() != _keyboardId.toLowerCase();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Sequence Operations
|
||||
// ============================================================
|
||||
|
||||
/// Start a sequence on a viewer
|
||||
Future<Map<String, dynamic>?> startSequence(
|
||||
int viewerId, int sequenceId) async {
|
||||
try {
|
||||
final response = await _http.post('/sequences/start', data: {
|
||||
'ViewerId': viewerId,
|
||||
'SequenceId': sequenceId,
|
||||
});
|
||||
if (response.statusCode == 200) {
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('startSequence failed: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Stop a sequence on a viewer
|
||||
Future<bool> stopSequence(int viewerId) async {
|
||||
try {
|
||||
final response = await _http.post('/sequences/stop', data: {
|
||||
'ViewerId': viewerId,
|
||||
});
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('stopSequence failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get running sequences
|
||||
Future<List<Map<String, dynamic>>> getRunningSequences() async {
|
||||
try {
|
||||
final response = await _http.get('/sequences/running');
|
||||
if (response.statusCode == 200) {
|
||||
final list = response.data as List<dynamic>;
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getRunningSequences failed: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get available sequences, optionally filtered by category
|
||||
Future<List<Map<String, dynamic>>> getSequences({int? categoryId}) async {
|
||||
try {
|
||||
final queryParams = categoryId != null ? {'categoryId': categoryId} : null;
|
||||
final response =
|
||||
await _http.get('/sequences', queryParameters: queryParams);
|
||||
if (response.statusCode == 200) {
|
||||
final list = response.data as List<dynamic>;
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getSequences failed: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get sequence categories
|
||||
Future<List<Map<String, dynamic>>> getSequenceCategories() async {
|
||||
try {
|
||||
final response = await _http.get('/sequences/categories');
|
||||
if (response.statusCode == 200) {
|
||||
final list = response.data as List<dynamic>;
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getSequenceCategories failed: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Private Methods
|
||||
// ============================================================
|
||||
|
||||
Future<void> _connectWebSocket() async {
|
||||
await _disconnectWebSocket();
|
||||
|
||||
try {
|
||||
final wsUrl =
|
||||
'${_coordinatorUrl.replaceFirst('http', 'ws')}/ws?keyboard=$_keyboardId';
|
||||
_logger.d('Connecting coordinator WebSocket: $wsUrl');
|
||||
|
||||
_ws = WebSocketChannel.connect(Uri.parse(wsUrl));
|
||||
|
||||
_wsSubscription = _ws!.stream.listen(
|
||||
(message) => _handleWsMessage(message as String),
|
||||
onError: (error) {
|
||||
_logger.e('Coordinator WebSocket error: $error');
|
||||
_connectedController.add(false);
|
||||
_scheduleReconnect();
|
||||
},
|
||||
onDone: () {
|
||||
_logger.w('Coordinator WebSocket closed');
|
||||
_connectedController.add(false);
|
||||
_scheduleReconnect();
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.e('Failed to connect coordinator WebSocket: $e');
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnectWebSocket() async {
|
||||
await _wsSubscription?.cancel();
|
||||
_wsSubscription = null;
|
||||
await _ws?.sink.close();
|
||||
_ws = null;
|
||||
}
|
||||
|
||||
void _handleWsMessage(String message) {
|
||||
try {
|
||||
final json = jsonDecode(message) as Map<String, dynamic>;
|
||||
final type = json['type'] as String?;
|
||||
final data = json['data'];
|
||||
|
||||
switch (type) {
|
||||
case 'lock_acquired':
|
||||
if (data is Map<String, dynamic>) {
|
||||
final lock = CameraLock.fromJson(data);
|
||||
_updateLockState(lock);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'lock_released':
|
||||
if (data is Map<String, dynamic>) {
|
||||
final cameraId =
|
||||
data['cameraId'] as int? ?? data['CameraId'] as int? ?? 0;
|
||||
_removeLockState(cameraId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'lock_notification':
|
||||
if (data is Map<String, dynamic>) {
|
||||
final notification = CameraLockNotification.fromJson(data);
|
||||
_notificationController.add(notification);
|
||||
_logger.i(
|
||||
'Lock notification: ${notification.type.name} camera ${notification.cameraId}');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'sequence_started':
|
||||
case 'sequence_stopped':
|
||||
_logger.d('Sequence event: $type');
|
||||
break;
|
||||
|
||||
case 'keyboard_online':
|
||||
case 'keyboard_offline':
|
||||
_logger.d('Keyboard event: $type');
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.d('Unknown coordinator event: $type');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('Failed to parse coordinator message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _updateLockState(CameraLock lock) {
|
||||
final locks = Map<int, CameraLock>.from(_locksController.value);
|
||||
locks[lock.cameraId] = lock;
|
||||
_locksController.add(locks);
|
||||
}
|
||||
|
||||
void _removeLockState(int cameraId) {
|
||||
final locks = Map<int, CameraLock>.from(_locksController.value);
|
||||
locks.remove(cameraId);
|
||||
_locksController.add(locks);
|
||||
}
|
||||
|
||||
/// Auto-reset lock expiration every 2 minutes while a lock is held
|
||||
void _startLockResetTimer(int cameraId) {
|
||||
_lockResetTimer?.cancel();
|
||||
_lockResetTimer = Timer.periodic(const Duration(minutes: 2), (_) {
|
||||
if (isCameraLockedByMe(cameraId)) {
|
||||
resetExpiration(cameraId);
|
||||
} else {
|
||||
_lockResetTimer?.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
if (_disposed) return;
|
||||
_reconnectTimer?.cancel();
|
||||
|
||||
final delay = Duration(
|
||||
milliseconds: (_initialReconnectDelay.inMilliseconds *
|
||||
(1 << _reconnectAttempts.clamp(0, 5)))
|
||||
.clamp(0, _maxReconnectDelay.inMilliseconds),
|
||||
);
|
||||
|
||||
_logger.i(
|
||||
'Scheduling coordinator reconnect in ${delay.inSeconds}s (attempt ${_reconnectAttempts + 1})');
|
||||
|
||||
_reconnectTimer = Timer(delay, () async {
|
||||
if (_disposed) return;
|
||||
_reconnectAttempts++;
|
||||
await connect();
|
||||
});
|
||||
}
|
||||
|
||||
/// Dispose all resources
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_reconnectTimer?.cancel();
|
||||
_lockResetTimer?.cancel();
|
||||
_wsSubscription?.cancel();
|
||||
_ws?.sink.close();
|
||||
_connectedController.close();
|
||||
_locksController.close();
|
||||
_notificationController.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
import '../../domain/entities/function_button_config.dart';
|
||||
import 'bridge_service.dart';
|
||||
import 'coordination_service.dart';
|
||||
|
||||
/// Executes function button actions (F1-F7).
|
||||
/// Ported from legacy FunctionButtonsService.cs.
|
||||
class FunctionButtonService {
|
||||
final BridgeService _bridgeService;
|
||||
final CoordinationService _coordinationService;
|
||||
final Logger _logger = Logger();
|
||||
|
||||
FunctionButtonConfig _config = const FunctionButtonConfig();
|
||||
|
||||
FunctionButtonService({
|
||||
required BridgeService bridgeService,
|
||||
required CoordinationService coordinationService,
|
||||
}) : _bridgeService = bridgeService,
|
||||
_coordinationService = coordinationService;
|
||||
|
||||
/// Load function button configuration.
|
||||
void loadConfig(FunctionButtonConfig config) {
|
||||
_config = config;
|
||||
_logger.i('Loaded function button config: ${config.walls.length} walls');
|
||||
}
|
||||
|
||||
/// Execute all actions for a button on a specific wall.
|
||||
/// Actions are executed sequentially (like legacy).
|
||||
Future<bool> execute(String wallId, String buttonKey) async {
|
||||
final actions = _config.getActions(wallId, buttonKey);
|
||||
if (actions.isEmpty) {
|
||||
_logger.d('No actions for $buttonKey on wall $wallId');
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.i('Executing $buttonKey on wall $wallId (${actions.length} actions)');
|
||||
|
||||
for (final action in actions) {
|
||||
try {
|
||||
switch (action.type) {
|
||||
case FunctionButtonActionType.crossSwitch:
|
||||
await _bridgeService.viewerConnectLive(
|
||||
action.viewerId, action.sourceId);
|
||||
_logger.d(
|
||||
'CrossSwitch: viewer ${action.viewerId} -> camera ${action.sourceId}');
|
||||
|
||||
case FunctionButtonActionType.sequenceStart:
|
||||
await _coordinationService.startSequence(
|
||||
action.viewerId, action.sourceId);
|
||||
_logger.d(
|
||||
'SequenceStart: viewer ${action.viewerId} -> sequence ${action.sourceId}');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e(
|
||||
'Function button action failed: ${action.type.name} - $e');
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Check if a button has actions for a given wall.
|
||||
bool hasActions(String wallId, String buttonKey) {
|
||||
return _config.hasActions(wallId, buttonKey);
|
||||
}
|
||||
}
|
||||
189
copilot_keyboard/lib/data/services/state_service.dart
Normal file
189
copilot_keyboard/lib/data/services/state_service.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
import '../../domain/entities/monitor_state.dart';
|
||||
import '../../domain/entities/alarm_state.dart';
|
||||
import '../models/bridge_event.dart';
|
||||
import 'bridge_service.dart';
|
||||
import 'alarm_service.dart';
|
||||
|
||||
/// Service for tracking overall system state (monitors + alarms)
|
||||
class StateService {
|
||||
final BridgeService _bridgeService;
|
||||
final AlarmService _alarmService;
|
||||
final Logger _logger = Logger();
|
||||
|
||||
StreamSubscription? _eventSubscription;
|
||||
|
||||
// Monitor state
|
||||
final _monitorStatesController =
|
||||
BehaviorSubject<Map<int, MonitorState>>.seeded({});
|
||||
|
||||
// Combined state stream (monitors with alarm flags)
|
||||
final _combinedMonitorStatesController =
|
||||
BehaviorSubject<Map<int, MonitorState>>.seeded({});
|
||||
|
||||
StateService({
|
||||
required BridgeService bridgeService,
|
||||
required AlarmService alarmService,
|
||||
}) : _bridgeService = bridgeService,
|
||||
_alarmService = alarmService;
|
||||
|
||||
/// Stream of monitor states (without alarm info)
|
||||
Stream<Map<int, MonitorState>> get monitorStates =>
|
||||
_monitorStatesController.stream;
|
||||
|
||||
/// Stream of monitor states with alarm flags
|
||||
Stream<Map<int, MonitorState>> get combinedMonitorStates =>
|
||||
_combinedMonitorStatesController.stream;
|
||||
|
||||
/// Stream of active alarms (delegated to AlarmService)
|
||||
Stream<List<AlarmState>> get activeAlarms => _alarmService.alarms;
|
||||
|
||||
/// Current monitor states
|
||||
Map<int, MonitorState> get currentMonitorStates =>
|
||||
_monitorStatesController.value;
|
||||
|
||||
/// Get state for a specific monitor
|
||||
MonitorState? getMonitorState(int viewerId) {
|
||||
return _combinedMonitorStatesController.value[viewerId];
|
||||
}
|
||||
|
||||
/// Initialize state tracking
|
||||
Future<void> initialize() async {
|
||||
// Subscribe to bridge events
|
||||
_eventSubscription = _bridgeService.eventStream.listen(_handleBridgeEvent);
|
||||
|
||||
// Subscribe to alarm changes to update monitor flags
|
||||
_alarmService.alarms.listen((_) => _updateCombinedStates());
|
||||
|
||||
_logger.i('StateService initialized');
|
||||
}
|
||||
|
||||
/// Sync initial state from all bridges
|
||||
Future<void> syncFromBridges() async {
|
||||
_logger.i('Syncing state from bridges...');
|
||||
|
||||
final connectionStatus = _bridgeService.currentConnectionStatus;
|
||||
final monitors = <int, MonitorState>{};
|
||||
|
||||
for (final entry in connectionStatus.entries) {
|
||||
if (!entry.value) continue; // Skip disconnected servers
|
||||
|
||||
try {
|
||||
final serverMonitors =
|
||||
await _bridgeService.getMonitorStates(entry.key);
|
||||
|
||||
for (final monitorJson in serverMonitors) {
|
||||
final state = MonitorState.fromJson(monitorJson);
|
||||
monitors[state.viewerId] = state;
|
||||
}
|
||||
|
||||
_logger.d(
|
||||
'Synced ${serverMonitors.length} monitors from ${entry.key}');
|
||||
} catch (e) {
|
||||
_logger.e('Failed to sync monitors from ${entry.key}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
_monitorStatesController.add(monitors);
|
||||
_updateCombinedStates();
|
||||
|
||||
// Also sync alarms
|
||||
await _alarmService.queryAllAlarms();
|
||||
|
||||
_logger.i('State sync complete: ${monitors.length} monitors');
|
||||
}
|
||||
|
||||
/// Handle incoming bridge event
|
||||
void _handleBridgeEvent(BridgeEvent event) {
|
||||
if (event.isViewerConnected || event.isViewerSelectionChanged) {
|
||||
_handleViewerConnected(event);
|
||||
} else if (event.isViewerCleared) {
|
||||
_handleViewerCleared(event);
|
||||
} else if (event.isEventStarted ||
|
||||
event.isEventStopped ||
|
||||
event.isAlarmQueueNotification) {
|
||||
// Delegate alarm events to AlarmService
|
||||
_alarmService.handleAlarmEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle viewer connected event
|
||||
void _handleViewerConnected(BridgeEvent event) {
|
||||
final viewer = event.viewer;
|
||||
final channel = event.channel;
|
||||
final playMode = event.playMode;
|
||||
|
||||
if (viewer == null) return;
|
||||
|
||||
final monitors = Map<int, MonitorState>.from(_monitorStatesController.value);
|
||||
final existing = monitors[viewer];
|
||||
|
||||
monitors[viewer] = MonitorState(
|
||||
viewerId: viewer,
|
||||
currentChannel: channel ?? existing?.currentChannel ?? 0,
|
||||
playMode: PlayMode.fromValue(playMode ?? existing?.playMode.value ?? 0),
|
||||
serverId: event.serverId,
|
||||
lastUpdated: event.timestamp,
|
||||
);
|
||||
|
||||
_monitorStatesController.add(monitors);
|
||||
_updateCombinedStates();
|
||||
|
||||
_logger.d('Monitor $viewer connected to channel $channel');
|
||||
}
|
||||
|
||||
/// Handle viewer cleared event
|
||||
void _handleViewerCleared(BridgeEvent event) {
|
||||
final viewer = event.viewer;
|
||||
if (viewer == null) return;
|
||||
|
||||
final monitors = Map<int, MonitorState>.from(_monitorStatesController.value);
|
||||
final existing = monitors[viewer];
|
||||
|
||||
if (existing != null) {
|
||||
monitors[viewer] = existing.cleared();
|
||||
} else {
|
||||
monitors[viewer] = MonitorState(
|
||||
viewerId: viewer,
|
||||
currentChannel: 0,
|
||||
playMode: PlayMode.unknown,
|
||||
serverId: event.serverId,
|
||||
lastUpdated: event.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
_monitorStatesController.add(monitors);
|
||||
_updateCombinedStates();
|
||||
|
||||
_logger.d('Monitor $viewer cleared');
|
||||
}
|
||||
|
||||
/// Update combined states with alarm flags
|
||||
void _updateCombinedStates() {
|
||||
final monitors = _monitorStatesController.value;
|
||||
final combined = <int, MonitorState>{};
|
||||
|
||||
for (final entry in monitors.entries) {
|
||||
final hasAlarm = _alarmService.isMonitorBlocked(entry.key);
|
||||
combined[entry.key] = entry.value.withAlarm(hasAlarm);
|
||||
}
|
||||
|
||||
_combinedMonitorStatesController.add(combined);
|
||||
}
|
||||
|
||||
/// Check if a monitor is blocked by an alarm
|
||||
bool isMonitorBlocked(int viewerId) {
|
||||
return _alarmService.isMonitorBlocked(viewerId);
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
_eventSubscription?.cancel();
|
||||
_monitorStatesController.close();
|
||||
_combinedMonitorStatesController.close();
|
||||
}
|
||||
}
|
||||
137
copilot_keyboard/lib/domain/entities/alarm_state.dart
Normal file
137
copilot_keyboard/lib/domain/entities/alarm_state.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Alarm state enumeration matching SDK PlcViewerAlarmState
|
||||
enum AlarmStatus {
|
||||
newAlarm(0, 'vasNewAlarm'),
|
||||
presented(1, 'vasPresented'),
|
||||
stacked(2, 'vasStacked'),
|
||||
confirmed(3, 'vasConfirmed'),
|
||||
removed(4, 'vasRemoved'),
|
||||
lastConfirmed(5, 'vasLastConfirmed'),
|
||||
lastRemoved(6, 'vasLastRemoved');
|
||||
|
||||
final int value;
|
||||
final String name;
|
||||
const AlarmStatus(this.value, this.name);
|
||||
|
||||
static AlarmStatus fromValue(int value) {
|
||||
return AlarmStatus.values.firstWhere(
|
||||
(s) => s.value == value,
|
||||
orElse: () => AlarmStatus.newAlarm,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if this status blocks the monitor
|
||||
bool get blocksMonitor =>
|
||||
this == AlarmStatus.newAlarm || this == AlarmStatus.presented;
|
||||
}
|
||||
|
||||
/// State of a single alarm/event
|
||||
class AlarmState extends Equatable {
|
||||
final int eventId;
|
||||
final String eventName;
|
||||
final int typeId;
|
||||
final int foreignKey; // Camera or contact ID
|
||||
final String? serverId;
|
||||
final DateTime startedAt;
|
||||
final DateTime? stoppedAt;
|
||||
final bool isActive;
|
||||
final AlarmStatus status;
|
||||
final int? associatedMonitor;
|
||||
|
||||
const AlarmState({
|
||||
required this.eventId,
|
||||
required this.eventName,
|
||||
required this.typeId,
|
||||
required this.foreignKey,
|
||||
this.serverId,
|
||||
required this.startedAt,
|
||||
this.stoppedAt,
|
||||
required this.isActive,
|
||||
this.status = AlarmStatus.newAlarm,
|
||||
this.associatedMonitor,
|
||||
});
|
||||
|
||||
/// Check if this alarm blocks a monitor
|
||||
bool get blocksMonitor => isActive && status.blocksMonitor;
|
||||
|
||||
/// Create a stopped alarm
|
||||
AlarmState stopped() {
|
||||
return AlarmState(
|
||||
eventId: eventId,
|
||||
eventName: eventName,
|
||||
typeId: typeId,
|
||||
foreignKey: foreignKey,
|
||||
serverId: serverId,
|
||||
startedAt: startedAt,
|
||||
stoppedAt: DateTime.now(),
|
||||
isActive: false,
|
||||
status: AlarmStatus.removed,
|
||||
associatedMonitor: associatedMonitor,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create alarm with updated status
|
||||
AlarmState withStatus(AlarmStatus newStatus) {
|
||||
return AlarmState(
|
||||
eventId: eventId,
|
||||
eventName: eventName,
|
||||
typeId: typeId,
|
||||
foreignKey: foreignKey,
|
||||
serverId: serverId,
|
||||
startedAt: startedAt,
|
||||
stoppedAt: stoppedAt,
|
||||
isActive: isActive,
|
||||
status: newStatus,
|
||||
associatedMonitor: associatedMonitor,
|
||||
);
|
||||
}
|
||||
|
||||
factory AlarmState.fromJson(Map<String, dynamic> json) {
|
||||
return AlarmState(
|
||||
eventId: json['event_id'] as int? ?? 0,
|
||||
eventName: json['event_name'] as String? ?? '',
|
||||
typeId: json['type_id'] as int? ?? 0,
|
||||
foreignKey: json['foreign_key'] as int? ?? 0,
|
||||
serverId: json['server_id'] as String?,
|
||||
startedAt: json['started_at'] != null
|
||||
? DateTime.parse(json['started_at'] as String)
|
||||
: DateTime.now(),
|
||||
stoppedAt: json['stopped_at'] != null
|
||||
? DateTime.parse(json['stopped_at'] as String)
|
||||
: null,
|
||||
isActive: json['is_active'] as bool? ?? true,
|
||||
status: AlarmStatus.fromValue(json['status'] as int? ?? 0),
|
||||
associatedMonitor: json['associated_monitor'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'event_id': eventId,
|
||||
'event_name': eventName,
|
||||
'type_id': typeId,
|
||||
'foreign_key': foreignKey,
|
||||
'server_id': serverId,
|
||||
'started_at': startedAt.toIso8601String(),
|
||||
'stopped_at': stoppedAt?.toIso8601String(),
|
||||
'is_active': isActive,
|
||||
'status': status.value,
|
||||
'associated_monitor': associatedMonitor,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
eventId,
|
||||
eventName,
|
||||
typeId,
|
||||
foreignKey,
|
||||
serverId,
|
||||
startedAt,
|
||||
stoppedAt,
|
||||
isActive,
|
||||
status,
|
||||
associatedMonitor,
|
||||
];
|
||||
}
|
||||
123
copilot_keyboard/lib/domain/entities/camera_lock.dart
Normal file
123
copilot_keyboard/lib/domain/entities/camera_lock.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
/// Camera lock entity matching the coordinator's CameraLock model.
|
||||
class CameraLock {
|
||||
final int cameraId;
|
||||
final CameraLockPriority priority;
|
||||
final String ownerName;
|
||||
final DateTime ownedSince;
|
||||
final DateTime expiresAt;
|
||||
|
||||
const CameraLock({
|
||||
required this.cameraId,
|
||||
required this.priority,
|
||||
required this.ownerName,
|
||||
required this.ownedSince,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
factory CameraLock.fromJson(Map<String, dynamic> json) {
|
||||
return CameraLock(
|
||||
cameraId: json['cameraId'] as int? ?? json['CameraId'] as int? ?? 0,
|
||||
priority: CameraLockPriority.fromString(
|
||||
json['priority'] as String? ?? json['Priority'] as String? ?? 'Low'),
|
||||
ownerName:
|
||||
json['ownerName'] as String? ?? json['OwnerName'] as String? ?? '',
|
||||
ownedSince: DateTime.parse(
|
||||
json['ownedSince'] as String? ?? json['OwnedSince'] as String? ?? ''),
|
||||
expiresAt: DateTime.parse(
|
||||
json['expiresAt'] as String? ?? json['ExpiresAt'] as String? ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
Duration get timeRemaining {
|
||||
final remaining = expiresAt.difference(DateTime.now().toUtc());
|
||||
return remaining.isNegative ? Duration.zero : remaining;
|
||||
}
|
||||
|
||||
bool get isExpiringSoon => timeRemaining.inSeconds <= 60;
|
||||
bool get isExpired => timeRemaining == Duration.zero;
|
||||
bool isOwnedBy(String keyboardId) =>
|
||||
ownerName.toLowerCase() == keyboardId.toLowerCase();
|
||||
}
|
||||
|
||||
enum CameraLockPriority {
|
||||
none,
|
||||
high,
|
||||
low;
|
||||
|
||||
static CameraLockPriority fromString(String value) {
|
||||
switch (value.toLowerCase()) {
|
||||
case 'high':
|
||||
return CameraLockPriority.high;
|
||||
case 'low':
|
||||
return CameraLockPriority.low;
|
||||
default:
|
||||
return CameraLockPriority.none;
|
||||
}
|
||||
}
|
||||
|
||||
String get name {
|
||||
switch (this) {
|
||||
case CameraLockPriority.high:
|
||||
return 'High';
|
||||
case CameraLockPriority.low:
|
||||
return 'Low';
|
||||
case CameraLockPriority.none:
|
||||
return 'None';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CameraLockNotificationType {
|
||||
acquired,
|
||||
takenOver,
|
||||
confirmTakeOver,
|
||||
confirmed,
|
||||
rejected,
|
||||
expireSoon,
|
||||
unlocked;
|
||||
|
||||
static CameraLockNotificationType fromString(String value) {
|
||||
switch (value) {
|
||||
case 'Acquired':
|
||||
return CameraLockNotificationType.acquired;
|
||||
case 'TakenOver':
|
||||
return CameraLockNotificationType.takenOver;
|
||||
case 'ConfirmTakeOver':
|
||||
return CameraLockNotificationType.confirmTakeOver;
|
||||
case 'Confirmed':
|
||||
return CameraLockNotificationType.confirmed;
|
||||
case 'Rejected':
|
||||
return CameraLockNotificationType.rejected;
|
||||
case 'ExpireSoon':
|
||||
return CameraLockNotificationType.expireSoon;
|
||||
case 'Unlocked':
|
||||
return CameraLockNotificationType.unlocked;
|
||||
default:
|
||||
return CameraLockNotificationType.acquired;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock notification from the coordinator (sent via WebSocket)
|
||||
class CameraLockNotification {
|
||||
final CameraLockNotificationType type;
|
||||
final int cameraId;
|
||||
final String copilotName;
|
||||
|
||||
const CameraLockNotification({
|
||||
required this.type,
|
||||
required this.cameraId,
|
||||
required this.copilotName,
|
||||
});
|
||||
|
||||
factory CameraLockNotification.fromJson(Map<String, dynamic> json) {
|
||||
return CameraLockNotification(
|
||||
type: CameraLockNotificationType.fromString(
|
||||
json['type'] as String? ?? json['Type'] as String? ?? ''),
|
||||
cameraId: json['cameraId'] as int? ?? json['CameraId'] as int? ?? 0,
|
||||
copilotName: json['copilotName'] as String? ??
|
||||
json['CopilotName'] as String? ??
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/// Configuration for function buttons (F1-F7) per wall.
|
||||
/// Loaded from the "functionButtons" section of the config file.
|
||||
class FunctionButtonConfig {
|
||||
final Map<String, Map<String, List<FunctionButtonAction>>> walls;
|
||||
|
||||
const FunctionButtonConfig({this.walls = const {}});
|
||||
|
||||
/// Get actions for a specific wall and button.
|
||||
List<FunctionButtonAction> getActions(String wallId, String buttonKey) {
|
||||
return walls[wallId]?[buttonKey] ?? [];
|
||||
}
|
||||
|
||||
/// Check if a button has any actions configured for this wall.
|
||||
bool hasActions(String wallId, String buttonKey) {
|
||||
return getActions(wallId, buttonKey).isNotEmpty;
|
||||
}
|
||||
|
||||
factory FunctionButtonConfig.fromJson(Map<String, dynamic> json) {
|
||||
final wallsJson = json['walls'] as Map<String, dynamic>? ?? {};
|
||||
final walls = <String, Map<String, List<FunctionButtonAction>>>{};
|
||||
|
||||
for (final wallEntry in wallsJson.entries) {
|
||||
final buttonsJson = wallEntry.value as Map<String, dynamic>? ?? {};
|
||||
final buttons = <String, List<FunctionButtonAction>>{};
|
||||
|
||||
for (final buttonEntry in buttonsJson.entries) {
|
||||
final actionsJson = buttonEntry.value as List<dynamic>? ?? [];
|
||||
buttons[buttonEntry.key] = actionsJson
|
||||
.map((a) =>
|
||||
FunctionButtonAction.fromJson(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
walls[wallEntry.key] = buttons;
|
||||
}
|
||||
|
||||
return FunctionButtonConfig(walls: walls);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single action triggered by a function button press.
|
||||
class FunctionButtonAction {
|
||||
final FunctionButtonActionType type;
|
||||
final int viewerId;
|
||||
final int sourceId;
|
||||
|
||||
const FunctionButtonAction({
|
||||
required this.type,
|
||||
required this.viewerId,
|
||||
required this.sourceId,
|
||||
});
|
||||
|
||||
factory FunctionButtonAction.fromJson(Map<String, dynamic> json) {
|
||||
return FunctionButtonAction(
|
||||
type: FunctionButtonActionType.fromString(
|
||||
json['actionType'] as String? ?? ''),
|
||||
viewerId: json['viewerId'] as int? ?? 0,
|
||||
sourceId: json['sourceId'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum FunctionButtonActionType {
|
||||
crossSwitch,
|
||||
sequenceStart;
|
||||
|
||||
static FunctionButtonActionType fromString(String value) {
|
||||
switch (value.toLowerCase()) {
|
||||
case 'crossswitch':
|
||||
return FunctionButtonActionType.crossSwitch;
|
||||
case 'sequencestart':
|
||||
return FunctionButtonActionType.sequenceStart;
|
||||
default:
|
||||
return FunctionButtonActionType.crossSwitch;
|
||||
}
|
||||
}
|
||||
}
|
||||
127
copilot_keyboard/lib/domain/entities/monitor_state.dart
Normal file
127
copilot_keyboard/lib/domain/entities/monitor_state.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Play mode enumeration matching SDK values
|
||||
enum PlayMode {
|
||||
unknown(0),
|
||||
playStop(1),
|
||||
playForward(2),
|
||||
playBackward(3),
|
||||
fastForward(4),
|
||||
fastBackward(5),
|
||||
stepForward(6),
|
||||
stepBackward(7),
|
||||
playBOD(8),
|
||||
playEOD(9),
|
||||
quasiLive(10),
|
||||
live(11),
|
||||
nextEvent(12),
|
||||
prevEvent(13),
|
||||
peekLivePicture(14),
|
||||
nextDetectedMotion(17),
|
||||
prevDetectedMotion(18);
|
||||
|
||||
final int value;
|
||||
const PlayMode(this.value);
|
||||
|
||||
static PlayMode fromValue(int value) {
|
||||
return PlayMode.values.firstWhere(
|
||||
(m) => m.value == value,
|
||||
orElse: () => PlayMode.unknown,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// State of a single monitor/viewer
|
||||
class MonitorState extends Equatable {
|
||||
final int viewerId;
|
||||
final int currentChannel;
|
||||
final PlayMode playMode;
|
||||
final String? serverId;
|
||||
final DateTime lastUpdated;
|
||||
final bool hasAlarm;
|
||||
|
||||
const MonitorState({
|
||||
required this.viewerId,
|
||||
required this.currentChannel,
|
||||
required this.playMode,
|
||||
this.serverId,
|
||||
required this.lastUpdated,
|
||||
this.hasAlarm = false,
|
||||
});
|
||||
|
||||
/// Check if monitor is currently displaying a camera
|
||||
bool get isActive => currentChannel > 0;
|
||||
|
||||
/// Check if monitor is in live mode
|
||||
bool get isLive => playMode == PlayMode.live || playMode == PlayMode.quasiLive;
|
||||
|
||||
/// Create a cleared state
|
||||
MonitorState cleared() {
|
||||
return MonitorState(
|
||||
viewerId: viewerId,
|
||||
currentChannel: 0,
|
||||
playMode: PlayMode.unknown,
|
||||
serverId: serverId,
|
||||
lastUpdated: DateTime.now(),
|
||||
hasAlarm: hasAlarm,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create updated state with new camera
|
||||
MonitorState withCamera(int channel, PlayMode mode) {
|
||||
return MonitorState(
|
||||
viewerId: viewerId,
|
||||
currentChannel: channel,
|
||||
playMode: mode,
|
||||
serverId: serverId,
|
||||
lastUpdated: DateTime.now(),
|
||||
hasAlarm: hasAlarm,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create state with alarm flag updated
|
||||
MonitorState withAlarm(bool alarm) {
|
||||
return MonitorState(
|
||||
viewerId: viewerId,
|
||||
currentChannel: currentChannel,
|
||||
playMode: playMode,
|
||||
serverId: serverId,
|
||||
lastUpdated: lastUpdated,
|
||||
hasAlarm: alarm,
|
||||
);
|
||||
}
|
||||
|
||||
factory MonitorState.fromJson(Map<String, dynamic> json) {
|
||||
return MonitorState(
|
||||
viewerId: json['viewer_id'] as int? ?? 0,
|
||||
currentChannel: json['current_channel'] as int? ?? 0,
|
||||
playMode: PlayMode.fromValue(json['play_mode'] as int? ?? 0),
|
||||
serverId: json['server_id'] as String?,
|
||||
lastUpdated: json['last_updated'] != null
|
||||
? DateTime.parse(json['last_updated'] as String)
|
||||
: DateTime.now(),
|
||||
hasAlarm: json['has_alarm'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'viewer_id': viewerId,
|
||||
'current_channel': currentChannel,
|
||||
'play_mode': playMode.value,
|
||||
'server_id': serverId,
|
||||
'last_updated': lastUpdated.toIso8601String(),
|
||||
'has_alarm': hasAlarm,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
viewerId,
|
||||
currentChannel,
|
||||
playMode,
|
||||
serverId,
|
||||
lastUpdated,
|
||||
hasAlarm,
|
||||
];
|
||||
}
|
||||
72
copilot_keyboard/lib/domain/entities/sequence.dart
Normal file
72
copilot_keyboard/lib/domain/entities/sequence.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
/// Sequence definition loaded from the coordinator.
|
||||
class SequenceDefinition {
|
||||
final int id;
|
||||
final String name;
|
||||
final int categoryId;
|
||||
final List<int> cameras;
|
||||
final int intervalSeconds;
|
||||
|
||||
const SequenceDefinition({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.categoryId,
|
||||
required this.cameras,
|
||||
required this.intervalSeconds,
|
||||
});
|
||||
|
||||
factory SequenceDefinition.fromJson(Map<String, dynamic> json) {
|
||||
return SequenceDefinition(
|
||||
id: json['id'] as int? ?? json['Id'] as int? ?? 0,
|
||||
name: json['name'] as String? ?? json['Name'] as String? ?? '',
|
||||
categoryId:
|
||||
json['categoryId'] as int? ?? json['CategoryId'] as int? ?? 0,
|
||||
cameras: (json['cameras'] as List<dynamic>? ??
|
||||
json['Cameras'] as List<dynamic>? ??
|
||||
[])
|
||||
.cast<int>(),
|
||||
intervalSeconds: json['intervalSeconds'] as int? ??
|
||||
json['IntervalSeconds'] as int? ??
|
||||
5,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sequence category for grouping sequences.
|
||||
class SequenceCategory {
|
||||
final int id;
|
||||
final String name;
|
||||
|
||||
const SequenceCategory({required this.id, required this.name});
|
||||
|
||||
factory SequenceCategory.fromJson(Map<String, dynamic> json) {
|
||||
return SequenceCategory(
|
||||
id: json['id'] as int? ?? json['Id'] as int? ?? 0,
|
||||
name: json['name'] as String? ?? json['Name'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A sequence currently running on a viewer.
|
||||
class RunningSequence {
|
||||
final int viewerId;
|
||||
final int sequenceId;
|
||||
final DateTime startedAt;
|
||||
|
||||
const RunningSequence({
|
||||
required this.viewerId,
|
||||
required this.sequenceId,
|
||||
required this.startedAt,
|
||||
});
|
||||
|
||||
factory RunningSequence.fromJson(Map<String, dynamic> json) {
|
||||
return RunningSequence(
|
||||
viewerId: json['viewerId'] as int? ?? json['ViewerId'] as int? ?? 0,
|
||||
sequenceId:
|
||||
json['sequenceId'] as int? ?? json['SequenceId'] as int? ?? 0,
|
||||
startedAt: DateTime.tryParse(json['startedAt'] as String? ??
|
||||
json['StartedAt'] as String? ??
|
||||
'') ??
|
||||
DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
94
copilot_keyboard/lib/domain/entities/server_config.dart
Normal file
94
copilot_keyboard/lib/domain/entities/server_config.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Server type enumeration
|
||||
enum ServerType { geviscope, gcore, geviserver }
|
||||
|
||||
/// Configuration for a single recording server
|
||||
class ServerConfig extends Equatable {
|
||||
final String id;
|
||||
final String name;
|
||||
final ServerType type;
|
||||
final bool enabled;
|
||||
final String address;
|
||||
final int port;
|
||||
final String username;
|
||||
final String bridgeUrl;
|
||||
final String? websocketUrl;
|
||||
final int cameraRangeStart;
|
||||
final int cameraRangeEnd;
|
||||
final int monitorRangeStart;
|
||||
final int monitorRangeEnd;
|
||||
|
||||
const ServerConfig({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.enabled,
|
||||
required this.address,
|
||||
required this.port,
|
||||
required this.username,
|
||||
required this.bridgeUrl,
|
||||
this.websocketUrl,
|
||||
required this.cameraRangeStart,
|
||||
required this.cameraRangeEnd,
|
||||
required this.monitorRangeStart,
|
||||
required this.monitorRangeEnd,
|
||||
});
|
||||
|
||||
/// Check if this server owns a camera ID
|
||||
bool ownsCamera(int cameraId) {
|
||||
return cameraId >= cameraRangeStart && cameraId <= cameraRangeEnd;
|
||||
}
|
||||
|
||||
/// Check if this server owns a monitor ID
|
||||
bool ownsMonitor(int monitorId) {
|
||||
return monitorId >= monitorRangeStart && monitorId <= monitorRangeEnd;
|
||||
}
|
||||
|
||||
factory ServerConfig.fromJson(Map<String, dynamic> json) {
|
||||
final typeStr = json['type'] as String;
|
||||
final type = ServerType.values.firstWhere(
|
||||
(t) => t.name == typeStr,
|
||||
orElse: () => ServerType.geviscope,
|
||||
);
|
||||
|
||||
final connection = json['connection'] as Map<String, dynamic>? ?? {};
|
||||
final bridge = json['bridge'] as Map<String, dynamic>? ?? {};
|
||||
final resources = json['resources'] as Map<String, dynamic>? ?? {};
|
||||
final cameraRange = resources['cameraRange'] as Map<String, dynamic>? ?? {};
|
||||
final monitorRange = resources['monitorRange'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
return ServerConfig(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String? ?? json['id'] as String,
|
||||
type: type,
|
||||
enabled: json['enabled'] as bool? ?? true,
|
||||
address: connection['address'] as String? ?? '',
|
||||
port: connection['port'] as int? ?? 7700,
|
||||
username: connection['username'] as String? ?? '',
|
||||
bridgeUrl: bridge['url'] as String? ?? '',
|
||||
websocketUrl: bridge['websocket'] as String?,
|
||||
cameraRangeStart: cameraRange['start'] as int? ?? 0,
|
||||
cameraRangeEnd: cameraRange['end'] as int? ?? 0,
|
||||
monitorRangeStart: monitorRange['start'] as int? ?? 0,
|
||||
monitorRangeEnd: monitorRange['end'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
enabled,
|
||||
address,
|
||||
port,
|
||||
username,
|
||||
bridgeUrl,
|
||||
websocketUrl,
|
||||
cameraRangeStart,
|
||||
cameraRangeEnd,
|
||||
monitorRangeStart,
|
||||
monitorRangeEnd,
|
||||
];
|
||||
}
|
||||
439
copilot_keyboard/lib/domain/entities/wall_config.dart
Normal file
439
copilot_keyboard/lib/domain/entities/wall_config.dart
Normal file
@@ -0,0 +1,439 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Configuration for a physical monitor that can display 1-4 viewers
|
||||
class PhysicalMonitor extends Equatable {
|
||||
final int id;
|
||||
final String? name;
|
||||
final List<int> viewerIds; // 1-4 viewer IDs in this physical monitor
|
||||
final bool isQuadView;
|
||||
final int row; // Grid row position (1-based)
|
||||
final int col; // Grid column position (1-based)
|
||||
final int rowSpan; // How many rows this monitor spans
|
||||
final int colSpan; // How many columns this monitor spans
|
||||
|
||||
const PhysicalMonitor({
|
||||
required this.id,
|
||||
this.name,
|
||||
required this.viewerIds,
|
||||
this.isQuadView = false,
|
||||
this.row = 1,
|
||||
this.col = 1,
|
||||
this.rowSpan = 1,
|
||||
this.colSpan = 1,
|
||||
});
|
||||
|
||||
/// Whether this monitor has multiple viewers (quad view)
|
||||
bool get hasMultipleViewers => viewerIds.length > 1;
|
||||
|
||||
/// Get the primary viewer ID (first one)
|
||||
int get primaryViewerId => viewerIds.isNotEmpty ? viewerIds.first : 0;
|
||||
|
||||
factory PhysicalMonitor.fromJson(Map<String, dynamic> json) {
|
||||
final viewers = json['viewer_ids'] as List<dynamic>?;
|
||||
return PhysicalMonitor(
|
||||
id: json['id'] as int,
|
||||
name: json['name'] as String?,
|
||||
viewerIds: viewers?.map((v) => v as int).toList() ?? [],
|
||||
isQuadView: json['is_quad_view'] as bool? ?? false,
|
||||
row: json['row'] as int? ?? 1,
|
||||
col: json['col'] as int? ?? 1,
|
||||
rowSpan: json['row_span'] as int? ?? 1,
|
||||
colSpan: json['col_span'] as int? ?? 1,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'viewer_ids': viewerIds,
|
||||
'is_quad_view': isQuadView,
|
||||
'row': row,
|
||||
'col': col,
|
||||
'row_span': rowSpan,
|
||||
'col_span': colSpan,
|
||||
};
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, name, viewerIds, isQuadView, row, col, rowSpan, colSpan];
|
||||
}
|
||||
|
||||
/// A section of the video wall (e.g., "Vrchní část", "Pravá část")
|
||||
class WallSection extends Equatable {
|
||||
final String id;
|
||||
final String name;
|
||||
final List<PhysicalMonitor> monitors;
|
||||
final int columns; // Grid layout columns for this section
|
||||
final int rows; // Grid layout rows for this section
|
||||
|
||||
const WallSection({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.monitors,
|
||||
this.columns = 8,
|
||||
this.rows = 4,
|
||||
});
|
||||
|
||||
factory WallSection.fromJson(Map<String, dynamic> json) {
|
||||
final monitors = json['monitors'] as List<dynamic>?;
|
||||
return WallSection(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
monitors: monitors
|
||||
?.map((m) => PhysicalMonitor.fromJson(m as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
columns: json['columns'] as int? ?? 8,
|
||||
rows: json['rows'] as int? ?? 4,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'monitors': monitors.map((m) => m.toJson()).toList(),
|
||||
'columns': columns,
|
||||
'rows': rows,
|
||||
};
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, name, monitors, columns, rows];
|
||||
}
|
||||
|
||||
/// Complete wall configuration with all sections
|
||||
class WallConfig extends Equatable {
|
||||
final String id;
|
||||
final String name;
|
||||
final List<WallSection> sections;
|
||||
final List<int> alarmMonitorIds; // Monitor IDs designated for alarms
|
||||
|
||||
const WallConfig({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.sections,
|
||||
this.alarmMonitorIds = const [],
|
||||
});
|
||||
|
||||
/// Get all viewer IDs across all sections
|
||||
List<int> get allViewerIds {
|
||||
final ids = <int>[];
|
||||
for (final section in sections) {
|
||||
for (final monitor in section.monitors) {
|
||||
ids.addAll(monitor.viewerIds);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// Get all physical monitors across all sections
|
||||
List<PhysicalMonitor> get allMonitors {
|
||||
final monitors = <PhysicalMonitor>[];
|
||||
for (final section in sections) {
|
||||
monitors.addAll(section.monitors);
|
||||
}
|
||||
return monitors;
|
||||
}
|
||||
|
||||
/// Find physical monitor containing a viewer ID
|
||||
PhysicalMonitor? findMonitorByViewerId(int viewerId) {
|
||||
for (final section in sections) {
|
||||
for (final monitor in section.monitors) {
|
||||
if (monitor.viewerIds.contains(viewerId)) {
|
||||
return monitor;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Find section containing a viewer ID
|
||||
WallSection? findSectionByViewerId(int viewerId) {
|
||||
for (final section in sections) {
|
||||
for (final monitor in section.monitors) {
|
||||
if (monitor.viewerIds.contains(viewerId)) {
|
||||
return section;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
factory WallConfig.fromJson(Map<String, dynamic> json) {
|
||||
final sections = json['sections'] as List<dynamic>?;
|
||||
final alarmIds = json['alarm_monitor_ids'] as List<dynamic>?;
|
||||
return WallConfig(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
sections: sections
|
||||
?.map((s) => WallSection.fromJson(s as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
alarmMonitorIds: alarmIds?.map((i) => i as int).toList() ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'sections': sections.map((s) => s.toJson()).toList(),
|
||||
'alarm_monitor_ids': alarmMonitorIds,
|
||||
};
|
||||
|
||||
/// Create sample wall config matching D6 app structure
|
||||
factory WallConfig.sample() {
|
||||
return WallConfig(
|
||||
id: 'wall_1',
|
||||
name: 'Hlavní videostěna',
|
||||
sections: [
|
||||
// Vrchní část - 8 columns x 4 rows irregular grid
|
||||
WallSection(
|
||||
id: 'top',
|
||||
name: 'Vrchní část',
|
||||
columns: 8,
|
||||
rows: 4,
|
||||
monitors: [
|
||||
// Row 1-2: Three quad monitors
|
||||
PhysicalMonitor(
|
||||
id: 1,
|
||||
viewerIds: [210, 211, 212, 213],
|
||||
isQuadView: true,
|
||||
row: 1, col: 1, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 2,
|
||||
viewerIds: [214, 215, 216, 217],
|
||||
isQuadView: true,
|
||||
row: 1, col: 3, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 3,
|
||||
viewerIds: [1001, 1002, 1003, 1004],
|
||||
isQuadView: true,
|
||||
row: 1, col: 5, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
// Row 4: Single monitor
|
||||
PhysicalMonitor(
|
||||
id: 4,
|
||||
viewerIds: [222],
|
||||
row: 4, col: 2, rowSpan: 1, colSpan: 1,
|
||||
),
|
||||
// Row 3-4: Three quad monitors
|
||||
PhysicalMonitor(
|
||||
id: 5,
|
||||
viewerIds: [223, 224, 225, 226],
|
||||
isQuadView: true,
|
||||
row: 3, col: 3, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 6,
|
||||
viewerIds: [227, 228, 229, 230],
|
||||
isQuadView: true,
|
||||
row: 3, col: 5, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 7,
|
||||
viewerIds: [231, 232, 233, 234],
|
||||
isQuadView: true,
|
||||
row: 3, col: 7, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
// Levá část - 7 columns x 6 rows
|
||||
WallSection(
|
||||
id: 'left',
|
||||
name: 'Levá část',
|
||||
columns: 7,
|
||||
rows: 6,
|
||||
monitors: [
|
||||
// Row 1-2: 3x2 monitor + two 2x2 quads
|
||||
PhysicalMonitor(
|
||||
id: 8,
|
||||
viewerIds: [88, 89, 90, 91, 92, 93],
|
||||
row: 1, col: 1, rowSpan: 2, colSpan: 3,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 9,
|
||||
viewerIds: [40, 41, 42, 43],
|
||||
isQuadView: true,
|
||||
row: 1, col: 4, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 10,
|
||||
viewerIds: [44, 45, 46, 47],
|
||||
isQuadView: true,
|
||||
row: 1, col: 6, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
// Row 3-4: Two 2x2 quads
|
||||
PhysicalMonitor(
|
||||
id: 11,
|
||||
viewerIds: [48, 49, 50, 51],
|
||||
isQuadView: true,
|
||||
row: 3, col: 4, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 12,
|
||||
viewerIds: [52, 53, 54, 55],
|
||||
isQuadView: true,
|
||||
row: 3, col: 6, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
// Row 5-6: Two 2x2 quads
|
||||
PhysicalMonitor(
|
||||
id: 13,
|
||||
viewerIds: [56, 57, 58, 59],
|
||||
isQuadView: true,
|
||||
row: 5, col: 4, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 14,
|
||||
viewerIds: [60, 61, 62, 63],
|
||||
isQuadView: true,
|
||||
row: 5, col: 6, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
// Střed stěny - 8 columns x 4 rows
|
||||
WallSection(
|
||||
id: 'center',
|
||||
name: 'Střed stěny',
|
||||
columns: 8,
|
||||
rows: 4,
|
||||
monitors: [
|
||||
// Row 1-2: Quad + 4 tall single monitors
|
||||
PhysicalMonitor(
|
||||
id: 15,
|
||||
viewerIds: [14, 15, 16, 17],
|
||||
isQuadView: true,
|
||||
row: 1, col: 2, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(id: 16, viewerIds: [18], row: 1, col: 4, rowSpan: 2, colSpan: 1),
|
||||
PhysicalMonitor(id: 17, viewerIds: [19], row: 1, col: 5, rowSpan: 2, colSpan: 1),
|
||||
PhysicalMonitor(id: 18, viewerIds: [20], row: 1, col: 6, rowSpan: 2, colSpan: 1),
|
||||
PhysicalMonitor(id: 19, viewerIds: [21], row: 1, col: 7, rowSpan: 2, colSpan: 1),
|
||||
// Row 3-4: Four 2x2 quads
|
||||
PhysicalMonitor(
|
||||
id: 20,
|
||||
viewerIds: [24, 25, 32, 33],
|
||||
isQuadView: true,
|
||||
row: 3, col: 1, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 21,
|
||||
viewerIds: [26, 27, 34, 35],
|
||||
isQuadView: true,
|
||||
row: 3, col: 3, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 22,
|
||||
viewerIds: [28, 29, 36, 37],
|
||||
isQuadView: true,
|
||||
row: 3, col: 5, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 23,
|
||||
viewerIds: [30, 31, 38, 39],
|
||||
isQuadView: true,
|
||||
row: 3, col: 7, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
// Pravá část - 7 columns x 6 rows
|
||||
WallSection(
|
||||
id: 'right',
|
||||
name: 'Pravá část',
|
||||
columns: 7,
|
||||
rows: 6,
|
||||
monitors: [
|
||||
// Row 1-2: Two 2x2 quads + 3x2 monitor
|
||||
PhysicalMonitor(
|
||||
id: 24,
|
||||
viewerIds: [64, 65, 66, 67],
|
||||
isQuadView: true,
|
||||
row: 1, col: 1, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 25,
|
||||
viewerIds: [68, 69, 70, 71],
|
||||
isQuadView: true,
|
||||
row: 1, col: 3, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 26,
|
||||
viewerIds: [94, 95, 96, 97, 98, 99],
|
||||
row: 1, col: 5, rowSpan: 2, colSpan: 3,
|
||||
),
|
||||
// Row 3-4: Two 2x2 quads
|
||||
PhysicalMonitor(
|
||||
id: 27,
|
||||
viewerIds: [72, 73, 74, 75],
|
||||
isQuadView: true,
|
||||
row: 3, col: 1, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 28,
|
||||
viewerIds: [76, 77, 78, 79],
|
||||
isQuadView: true,
|
||||
row: 3, col: 3, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
// Row 5-6: Two 2x2 quads
|
||||
PhysicalMonitor(
|
||||
id: 29,
|
||||
viewerIds: [80, 81, 82, 83],
|
||||
isQuadView: true,
|
||||
row: 5, col: 1, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 30,
|
||||
viewerIds: [84, 85, 86, 87],
|
||||
isQuadView: true,
|
||||
row: 5, col: 3, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
// Stupínek - 8 columns x 4 rows
|
||||
WallSection(
|
||||
id: 'bottom',
|
||||
name: 'Stupínek',
|
||||
columns: 8,
|
||||
rows: 4,
|
||||
monitors: [
|
||||
// Row 1-2: Three 2x2 quads
|
||||
PhysicalMonitor(
|
||||
id: 31,
|
||||
viewerIds: [183, 184, 185, 186],
|
||||
isQuadView: true,
|
||||
row: 1, col: 1, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 32,
|
||||
viewerIds: [187, 188, 189, 190],
|
||||
isQuadView: true,
|
||||
row: 1, col: 3, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 33,
|
||||
viewerIds: [191, 192, 193, 194],
|
||||
isQuadView: true,
|
||||
row: 1, col: 5, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
// Row 3-4: Two 2x2 quads
|
||||
PhysicalMonitor(
|
||||
id: 34,
|
||||
viewerIds: [195, 196, 197, 198],
|
||||
isQuadView: true,
|
||||
row: 3, col: 5, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 35,
|
||||
viewerIds: [199, 200, 201, 202],
|
||||
isQuadView: true,
|
||||
row: 3, col: 7, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
alarmMonitorIds: [222, 223, 224], // Example alarm monitors
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, name, sections, alarmMonitorIds];
|
||||
}
|
||||
123
copilot_keyboard/lib/injection_container.dart
Normal file
123
copilot_keyboard/lib/injection_container.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import 'config/app_config.dart';
|
||||
import 'data/services/bridge_service.dart';
|
||||
import 'data/services/alarm_service.dart';
|
||||
import 'data/services/coordination_service.dart';
|
||||
import 'data/services/function_button_service.dart';
|
||||
import 'data/services/state_service.dart';
|
||||
import 'presentation/blocs/connection/connection_bloc.dart';
|
||||
import 'presentation/blocs/camera/camera_bloc.dart';
|
||||
import 'presentation/blocs/monitor/monitor_bloc.dart';
|
||||
import 'presentation/blocs/ptz/ptz_bloc.dart';
|
||||
import 'presentation/blocs/alarm/alarm_bloc.dart';
|
||||
import 'presentation/blocs/lock/lock_bloc.dart';
|
||||
import 'presentation/blocs/sequence/sequence_bloc.dart';
|
||||
import 'presentation/blocs/wall/wall_bloc.dart';
|
||||
|
||||
final sl = GetIt.instance;
|
||||
|
||||
/// Initialize all dependencies
|
||||
Future<void> initializeDependencies() async {
|
||||
// Config
|
||||
final config = await AppConfig.load();
|
||||
sl.registerSingleton<AppConfig>(config);
|
||||
|
||||
// Services
|
||||
sl.registerLazySingleton<BridgeService>(() => BridgeService());
|
||||
sl.registerLazySingleton<AlarmService>(() => AlarmService());
|
||||
sl.registerLazySingleton<StateService>(() => StateService(
|
||||
bridgeService: sl<BridgeService>(),
|
||||
alarmService: sl<AlarmService>(),
|
||||
));
|
||||
sl.registerLazySingleton<CoordinationService>(() => CoordinationService());
|
||||
sl.registerLazySingleton<FunctionButtonService>(() => FunctionButtonService(
|
||||
bridgeService: sl<BridgeService>(),
|
||||
coordinationService: sl<CoordinationService>(),
|
||||
));
|
||||
|
||||
// BLoCs
|
||||
sl.registerFactory<ConnectionBloc>(() => ConnectionBloc(
|
||||
bridgeService: sl<BridgeService>(),
|
||||
config: sl<AppConfig>(),
|
||||
));
|
||||
|
||||
sl.registerFactory<CameraBloc>(() => CameraBloc(
|
||||
bridgeService: sl<BridgeService>(),
|
||||
config: sl<AppConfig>(),
|
||||
));
|
||||
|
||||
sl.registerFactory<MonitorBloc>(() => MonitorBloc(
|
||||
stateService: sl<StateService>(),
|
||||
config: sl<AppConfig>(),
|
||||
));
|
||||
|
||||
sl.registerFactory<PtzBloc>(() => PtzBloc(
|
||||
bridgeService: sl<BridgeService>(),
|
||||
coordinationService: sl<CoordinationService>(),
|
||||
));
|
||||
|
||||
sl.registerFactory<AlarmBloc>(() => AlarmBloc(
|
||||
alarmService: sl<AlarmService>(),
|
||||
stateService: sl<StateService>(),
|
||||
));
|
||||
|
||||
sl.registerFactory<LockBloc>(() => LockBloc(
|
||||
coordinationService: sl<CoordinationService>(),
|
||||
keyboardId: sl<AppConfig>().keyboardId,
|
||||
));
|
||||
|
||||
sl.registerFactory<SequenceBloc>(() => SequenceBloc(
|
||||
coordinationService: sl<CoordinationService>(),
|
||||
));
|
||||
|
||||
sl.registerFactory<WallBloc>(() => WallBloc(
|
||||
bridgeService: sl<BridgeService>(),
|
||||
));
|
||||
}
|
||||
|
||||
/// Initialize services (call after dependencies are set up)
|
||||
Future<void> initializeServices() async {
|
||||
final config = sl<AppConfig>();
|
||||
final bridgeService = sl<BridgeService>();
|
||||
final alarmService = sl<AlarmService>();
|
||||
final stateService = sl<StateService>();
|
||||
|
||||
final coordinationService = sl<CoordinationService>();
|
||||
|
||||
// Initialize services with config
|
||||
await bridgeService.initialize(config.servers);
|
||||
await alarmService.initialize(config.servers);
|
||||
await stateService.initialize();
|
||||
await coordinationService.initialize(config.coordinatorUrl, config.keyboardId);
|
||||
|
||||
// Load function button config
|
||||
sl<FunctionButtonService>().loadConfig(config.functionButtons);
|
||||
|
||||
// Wire reconnection callback: resync state when a bridge comes back online
|
||||
bridgeService.onReconnected = (serverId) {
|
||||
stateService.syncFromBridges();
|
||||
};
|
||||
|
||||
// Connect to all bridges
|
||||
await bridgeService.connectAll();
|
||||
|
||||
// Sync initial state
|
||||
await stateService.syncFromBridges();
|
||||
|
||||
// Start periodic alarm sync
|
||||
alarmService.startPeriodicSync(
|
||||
Duration(seconds: config.alarmSyncIntervalSeconds),
|
||||
);
|
||||
|
||||
// Connect to coordinator (non-blocking, auto-reconnects)
|
||||
coordinationService.connect();
|
||||
}
|
||||
|
||||
/// Dispose all services
|
||||
void disposeServices() {
|
||||
sl<CoordinationService>().dispose();
|
||||
sl<AlarmService>().dispose();
|
||||
sl<StateService>().dispose();
|
||||
sl<BridgeService>().dispose();
|
||||
}
|
||||
28
copilot_keyboard/lib/main.dart
Normal file
28
copilot_keyboard/lib/main.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'injection_container.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final logger = Logger();
|
||||
|
||||
try {
|
||||
// Initialize dependencies
|
||||
logger.i('Initializing dependencies...');
|
||||
await initializeDependencies();
|
||||
|
||||
// Initialize services and connect to bridges
|
||||
logger.i('Initializing services...');
|
||||
await initializeServices();
|
||||
|
||||
logger.i('Starting COPILOT Keyboard app...');
|
||||
} catch (e, stackTrace) {
|
||||
logger.e('Initialization failed', error: e, stackTrace: stackTrace);
|
||||
// Continue anyway - app will show disconnected state
|
||||
}
|
||||
|
||||
runApp(const CopilotKeyboardApp());
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../data/services/alarm_service.dart';
|
||||
import '../../../data/services/state_service.dart';
|
||||
import 'alarm_event.dart';
|
||||
import 'alarm_state.dart';
|
||||
|
||||
class AlarmBloc extends Bloc<AlarmEvent, AlarmBlocState> {
|
||||
final AlarmService _alarmService;
|
||||
final StateService _stateService;
|
||||
StreamSubscription? _alarmSubscription;
|
||||
|
||||
AlarmBloc({
|
||||
required AlarmService alarmService,
|
||||
required StateService stateService,
|
||||
}) : _alarmService = alarmService,
|
||||
_stateService = stateService,
|
||||
super(const AlarmBlocState()) {
|
||||
on<RefreshAlarms>(_onRefreshAlarms);
|
||||
on<AlarmsUpdated>(_onAlarmsUpdated);
|
||||
on<AcknowledgeAlarm>(_onAcknowledgeAlarm);
|
||||
|
||||
// Subscribe to alarm changes
|
||||
_alarmSubscription = _alarmService.alarms.listen((alarms) {
|
||||
add(AlarmsUpdated(alarms));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onRefreshAlarms(
|
||||
RefreshAlarms event,
|
||||
Emitter<AlarmBlocState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isLoading: true, error: null));
|
||||
|
||||
try {
|
||||
await _alarmService.queryAllAlarms();
|
||||
emit(state.copyWith(isLoading: false, lastSync: DateTime.now()));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
error: 'Failed to refresh alarms: $e',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _onAlarmsUpdated(
|
||||
AlarmsUpdated event,
|
||||
Emitter<AlarmBlocState> emit,
|
||||
) {
|
||||
// Filter to only active alarms for display
|
||||
final activeAlarms = event.alarms.where((a) => a.isActive).toList();
|
||||
|
||||
// Sort by start time (newest first)
|
||||
activeAlarms.sort((a, b) => b.startedAt.compareTo(a.startedAt));
|
||||
|
||||
emit(state.copyWith(
|
||||
activeAlarms: activeAlarms,
|
||||
lastSync: DateTime.now(),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _onAcknowledgeAlarm(
|
||||
AcknowledgeAlarm event,
|
||||
Emitter<AlarmBlocState> emit,
|
||||
) async {
|
||||
// Alarm acknowledgment would be implemented here
|
||||
// This would call the bridge to acknowledge the alarm
|
||||
// For now, just log that we received the event
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_alarmSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../../domain/entities/alarm_state.dart';
|
||||
|
||||
abstract class AlarmEvent extends Equatable {
|
||||
const AlarmEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Refresh alarms from server
|
||||
class RefreshAlarms extends AlarmEvent {
|
||||
const RefreshAlarms();
|
||||
}
|
||||
|
||||
/// Alarms updated (internal)
|
||||
class AlarmsUpdated extends AlarmEvent {
|
||||
final List<AlarmState> alarms;
|
||||
|
||||
const AlarmsUpdated(this.alarms);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [alarms];
|
||||
}
|
||||
|
||||
/// Acknowledge an alarm
|
||||
class AcknowledgeAlarm extends AlarmEvent {
|
||||
final int alarmId;
|
||||
final String serverId;
|
||||
|
||||
const AcknowledgeAlarm({
|
||||
required this.alarmId,
|
||||
required this.serverId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [alarmId, serverId];
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../../domain/entities/alarm_state.dart' as domain;
|
||||
|
||||
class AlarmBlocState extends Equatable {
|
||||
final List<domain.AlarmState> activeAlarms;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
final DateTime? lastSync;
|
||||
|
||||
const AlarmBlocState({
|
||||
this.activeAlarms = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
this.lastSync,
|
||||
});
|
||||
|
||||
/// Get count of active blocking alarms
|
||||
int get blockingAlarmCount =>
|
||||
activeAlarms.where((a) => a.blocksMonitor).length;
|
||||
|
||||
/// Get alarms for a specific monitor
|
||||
List<domain.AlarmState> alarmsForMonitor(int monitorId) {
|
||||
return activeAlarms
|
||||
.where((a) => a.associatedMonitor == monitorId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Check if monitor has blocking alarm
|
||||
bool monitorHasBlockingAlarm(int monitorId) {
|
||||
return activeAlarms.any(
|
||||
(a) => a.associatedMonitor == monitorId && a.blocksMonitor);
|
||||
}
|
||||
|
||||
AlarmBlocState copyWith({
|
||||
List<domain.AlarmState>? activeAlarms,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
DateTime? lastSync,
|
||||
}) {
|
||||
return AlarmBlocState(
|
||||
activeAlarms: activeAlarms ?? this.activeAlarms,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
lastSync: lastSync ?? this.lastSync,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [activeAlarms, isLoading, error, lastSync];
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../config/app_config.dart';
|
||||
import '../../../data/services/bridge_service.dart';
|
||||
import 'camera_event.dart';
|
||||
import 'camera_state.dart';
|
||||
|
||||
class CameraBloc extends Bloc<CameraEvent, CameraState> {
|
||||
final BridgeService _bridgeService;
|
||||
final AppConfig _config;
|
||||
|
||||
CameraBloc({
|
||||
required BridgeService bridgeService,
|
||||
required AppConfig config,
|
||||
}) : _bridgeService = bridgeService,
|
||||
_config = config,
|
||||
super(CameraState(availableCameras: config.allCameraIds)) {
|
||||
on<SelectCamera>(_onSelectCamera);
|
||||
on<ConnectCameraToMonitor>(_onConnectCameraToMonitor);
|
||||
on<ClearCameraSelection>(_onClearCameraSelection);
|
||||
}
|
||||
|
||||
void _onSelectCamera(
|
||||
SelectCamera event,
|
||||
Emitter<CameraState> emit,
|
||||
) {
|
||||
emit(state.copyWith(selectedCameraId: event.cameraId, error: null));
|
||||
}
|
||||
|
||||
Future<void> _onConnectCameraToMonitor(
|
||||
ConnectCameraToMonitor event,
|
||||
Emitter<CameraState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isConnecting: true, error: null));
|
||||
|
||||
try {
|
||||
final success = await _bridgeService.viewerConnectLive(
|
||||
event.monitorId,
|
||||
event.cameraId,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
emit(state.copyWith(isConnecting: false));
|
||||
} else {
|
||||
emit(state.copyWith(
|
||||
isConnecting: false,
|
||||
error: 'Failed to connect camera ${event.cameraId} to monitor ${event.monitorId}',
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
isConnecting: false,
|
||||
error: e.toString(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _onClearCameraSelection(
|
||||
ClearCameraSelection event,
|
||||
Emitter<CameraState> emit,
|
||||
) {
|
||||
emit(state.copyWith(clearSelection: true, error: null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class CameraEvent extends Equatable {
|
||||
const CameraEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Select a camera for viewing/control
|
||||
class SelectCamera extends CameraEvent {
|
||||
final int cameraId;
|
||||
|
||||
const SelectCamera(this.cameraId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cameraId];
|
||||
}
|
||||
|
||||
/// Connect selected camera to a monitor
|
||||
class ConnectCameraToMonitor extends CameraEvent {
|
||||
final int cameraId;
|
||||
final int monitorId;
|
||||
|
||||
const ConnectCameraToMonitor({
|
||||
required this.cameraId,
|
||||
required this.monitorId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cameraId, monitorId];
|
||||
}
|
||||
|
||||
/// Clear selection
|
||||
class ClearCameraSelection extends CameraEvent {
|
||||
const ClearCameraSelection();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class CameraState extends Equatable {
|
||||
final int? selectedCameraId;
|
||||
final bool isConnecting;
|
||||
final String? error;
|
||||
final List<int> availableCameras;
|
||||
|
||||
const CameraState({
|
||||
this.selectedCameraId,
|
||||
this.isConnecting = false,
|
||||
this.error,
|
||||
this.availableCameras = const [],
|
||||
});
|
||||
|
||||
bool get hasSelection => selectedCameraId != null;
|
||||
|
||||
CameraState copyWith({
|
||||
int? selectedCameraId,
|
||||
bool? isConnecting,
|
||||
String? error,
|
||||
List<int>? availableCameras,
|
||||
bool clearSelection = false,
|
||||
}) {
|
||||
return CameraState(
|
||||
selectedCameraId:
|
||||
clearSelection ? null : (selectedCameraId ?? this.selectedCameraId),
|
||||
isConnecting: isConnecting ?? this.isConnecting,
|
||||
error: error,
|
||||
availableCameras: availableCameras ?? this.availableCameras,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[selectedCameraId, isConnecting, error, availableCameras];
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../config/app_config.dart';
|
||||
import '../../../data/services/bridge_service.dart';
|
||||
import 'connection_event.dart';
|
||||
import 'connection_state.dart';
|
||||
|
||||
class ConnectionBloc extends Bloc<ConnectionEvent, ConnectionState> {
|
||||
final BridgeService _bridgeService;
|
||||
final AppConfig _config;
|
||||
StreamSubscription? _statusSubscription;
|
||||
|
||||
ConnectionBloc({
|
||||
required BridgeService bridgeService,
|
||||
required AppConfig config,
|
||||
}) : _bridgeService = bridgeService,
|
||||
_config = config,
|
||||
super(const ConnectionState()) {
|
||||
on<ConnectAll>(_onConnectAll);
|
||||
on<ConnectServer>(_onConnectServer);
|
||||
on<DisconnectServer>(_onDisconnectServer);
|
||||
on<DisconnectAll>(_onDisconnectAll);
|
||||
on<RetryConnections>(_onRetryConnections);
|
||||
on<ConnectionStatusUpdated>(_onConnectionStatusUpdated);
|
||||
|
||||
// Subscribe to connection status changes
|
||||
_statusSubscription = _bridgeService.connectionStatus.listen((status) {
|
||||
add(ConnectionStatusUpdated(status));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onConnectAll(
|
||||
ConnectAll event,
|
||||
Emitter<ConnectionState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(overallStatus: ConnectionOverallStatus.connecting));
|
||||
|
||||
try {
|
||||
await _bridgeService.connectAll();
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
overallStatus: ConnectionOverallStatus.disconnected,
|
||||
error: e.toString(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onConnectServer(
|
||||
ConnectServer event,
|
||||
Emitter<ConnectionState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _bridgeService.connect(event.serverId);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: 'Failed to connect to ${event.serverId}: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onDisconnectServer(
|
||||
DisconnectServer event,
|
||||
Emitter<ConnectionState> emit,
|
||||
) async {
|
||||
await _bridgeService.disconnect(event.serverId);
|
||||
}
|
||||
|
||||
Future<void> _onDisconnectAll(
|
||||
DisconnectAll event,
|
||||
Emitter<ConnectionState> emit,
|
||||
) async {
|
||||
await _bridgeService.disconnectAll();
|
||||
emit(state.copyWith(overallStatus: ConnectionOverallStatus.disconnected));
|
||||
}
|
||||
|
||||
Future<void> _onRetryConnections(
|
||||
RetryConnections event,
|
||||
Emitter<ConnectionState> emit,
|
||||
) async {
|
||||
// Retry only disconnected servers
|
||||
final disconnected = state.serverStatus.entries
|
||||
.where((e) => !e.value)
|
||||
.map((e) => e.key)
|
||||
.toList();
|
||||
|
||||
for (final serverId in disconnected) {
|
||||
await _bridgeService.connect(serverId);
|
||||
}
|
||||
}
|
||||
|
||||
void _onConnectionStatusUpdated(
|
||||
ConnectionStatusUpdated event,
|
||||
Emitter<ConnectionState> emit,
|
||||
) {
|
||||
final status = event.status;
|
||||
ConnectionOverallStatus overall;
|
||||
|
||||
if (status.isEmpty) {
|
||||
overall = ConnectionOverallStatus.disconnected;
|
||||
} else if (status.values.every((v) => v)) {
|
||||
overall = ConnectionOverallStatus.connected;
|
||||
} else if (status.values.any((v) => v)) {
|
||||
overall = ConnectionOverallStatus.partial;
|
||||
} else {
|
||||
overall = ConnectionOverallStatus.disconnected;
|
||||
}
|
||||
|
||||
emit(state.copyWith(
|
||||
overallStatus: overall,
|
||||
serverStatus: status,
|
||||
error: null,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_statusSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class ConnectionEvent extends Equatable {
|
||||
const ConnectionEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Connect to all servers
|
||||
class ConnectAll extends ConnectionEvent {
|
||||
const ConnectAll();
|
||||
}
|
||||
|
||||
/// Connect to a specific server
|
||||
class ConnectServer extends ConnectionEvent {
|
||||
final String serverId;
|
||||
|
||||
const ConnectServer(this.serverId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [serverId];
|
||||
}
|
||||
|
||||
/// Disconnect from a specific server
|
||||
class DisconnectServer extends ConnectionEvent {
|
||||
final String serverId;
|
||||
|
||||
const DisconnectServer(this.serverId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [serverId];
|
||||
}
|
||||
|
||||
/// Disconnect from all servers
|
||||
class DisconnectAll extends ConnectionEvent {
|
||||
const DisconnectAll();
|
||||
}
|
||||
|
||||
/// Retry failed connections
|
||||
class RetryConnections extends ConnectionEvent {
|
||||
const RetryConnections();
|
||||
}
|
||||
|
||||
/// Connection status updated (internal)
|
||||
class ConnectionStatusUpdated extends ConnectionEvent {
|
||||
final Map<String, bool> status;
|
||||
|
||||
const ConnectionStatusUpdated(this.status);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [status];
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
enum ConnectionOverallStatus { disconnected, connecting, connected, partial }
|
||||
|
||||
class ConnectionState extends Equatable {
|
||||
final ConnectionOverallStatus overallStatus;
|
||||
final Map<String, bool> serverStatus;
|
||||
final String? error;
|
||||
|
||||
const ConnectionState({
|
||||
this.overallStatus = ConnectionOverallStatus.disconnected,
|
||||
this.serverStatus = const {},
|
||||
this.error,
|
||||
});
|
||||
|
||||
/// Check if all servers are connected
|
||||
bool get allConnected =>
|
||||
serverStatus.isNotEmpty && serverStatus.values.every((v) => v);
|
||||
|
||||
/// Check if any server is connected
|
||||
bool get anyConnected => serverStatus.values.any((v) => v);
|
||||
|
||||
/// Get count of connected servers
|
||||
int get connectedCount => serverStatus.values.where((v) => v).length;
|
||||
|
||||
/// Get count of total servers
|
||||
int get totalCount => serverStatus.length;
|
||||
|
||||
ConnectionState copyWith({
|
||||
ConnectionOverallStatus? overallStatus,
|
||||
Map<String, bool>? serverStatus,
|
||||
String? error,
|
||||
}) {
|
||||
return ConnectionState(
|
||||
overallStatus: overallStatus ?? this.overallStatus,
|
||||
serverStatus: serverStatus ?? this.serverStatus,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [overallStatus, serverStatus, error];
|
||||
}
|
||||
168
copilot_keyboard/lib/presentation/blocs/lock/lock_bloc.dart
Normal file
168
copilot_keyboard/lib/presentation/blocs/lock/lock_bloc.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../data/services/coordination_service.dart';
|
||||
import '../../../domain/entities/camera_lock.dart';
|
||||
import 'lock_event.dart';
|
||||
import 'lock_state.dart';
|
||||
|
||||
class LockBloc extends Bloc<LockEvent, LockState> {
|
||||
final CoordinationService _coordinationService;
|
||||
|
||||
StreamSubscription? _locksSub;
|
||||
StreamSubscription? _notifSub;
|
||||
StreamSubscription? _connSub;
|
||||
|
||||
LockBloc({
|
||||
required CoordinationService coordinationService,
|
||||
required String keyboardId,
|
||||
}) : _coordinationService = coordinationService,
|
||||
super(const LockState()) {
|
||||
on<TryLock>(_onTryLock);
|
||||
on<ReleaseLock>(_onReleaseLock);
|
||||
on<ReleaseAllLocks>(_onReleaseAllLocks);
|
||||
on<RequestTakeover>(_onRequestTakeover);
|
||||
on<ConfirmTakeover>(_onConfirmTakeover);
|
||||
on<ResetLockExpiration>(_onResetLockExpiration);
|
||||
on<LocksUpdated>(_onLocksUpdated);
|
||||
on<LockNotificationReceived>(_onLockNotificationReceived);
|
||||
on<CoordinatorConnectionChanged>(_onCoordinatorConnectionChanged);
|
||||
|
||||
// Subscribe to coordinator streams
|
||||
_locksSub = _coordinationService.locks.listen((locks) {
|
||||
add(LocksUpdated(locks));
|
||||
});
|
||||
|
||||
_notifSub = _coordinationService.notifications.listen((notification) {
|
||||
if (notification != null) {
|
||||
add(LockNotificationReceived(notification));
|
||||
}
|
||||
});
|
||||
|
||||
_connSub = _coordinationService.connected.listen((connected) {
|
||||
add(CoordinatorConnectionChanged(connected));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onTryLock(TryLock event, Emitter<LockState> emit) async {
|
||||
final result = await _coordinationService.tryLock(
|
||||
event.cameraId,
|
||||
priority: event.priority,
|
||||
);
|
||||
|
||||
if (!result.acquired) {
|
||||
final lock = result.lock;
|
||||
final owner = lock?.ownerName ?? 'unknown';
|
||||
emit(state.copyWith(
|
||||
lastNotification: 'Camera ${event.cameraId} locked by $owner',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onReleaseLock(
|
||||
ReleaseLock event, Emitter<LockState> emit) async {
|
||||
await _coordinationService.releaseLock(event.cameraId);
|
||||
}
|
||||
|
||||
Future<void> _onReleaseAllLocks(
|
||||
ReleaseAllLocks event, Emitter<LockState> emit) async {
|
||||
final myLocks = await _coordinationService.getMyLockedCameras();
|
||||
for (final cameraId in myLocks) {
|
||||
await _coordinationService.releaseLock(cameraId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRequestTakeover(
|
||||
RequestTakeover event, Emitter<LockState> emit) async {
|
||||
final success = await _coordinationService.requestTakeover(
|
||||
event.cameraId,
|
||||
priority: event.priority,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
emit(state.copyWith(
|
||||
lastNotification: 'Takeover requested for camera ${event.cameraId}',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onConfirmTakeover(
|
||||
ConfirmTakeover event, Emitter<LockState> emit) async {
|
||||
await _coordinationService.confirmTakeover(
|
||||
event.cameraId, event.confirm);
|
||||
emit(state.copyWith(clearPendingTakeover: true));
|
||||
}
|
||||
|
||||
Future<void> _onResetLockExpiration(
|
||||
ResetLockExpiration event, Emitter<LockState> emit) async {
|
||||
await _coordinationService.resetExpiration(event.cameraId);
|
||||
}
|
||||
|
||||
void _onLocksUpdated(LocksUpdated event, Emitter<LockState> emit) {
|
||||
emit(state.copyWith(locks: event.locks));
|
||||
}
|
||||
|
||||
void _onLockNotificationReceived(
|
||||
LockNotificationReceived event, Emitter<LockState> emit) {
|
||||
final notification = event.notification;
|
||||
|
||||
switch (notification.type) {
|
||||
case CameraLockNotificationType.confirmTakeOver:
|
||||
// Show takeover confirmation dialog
|
||||
emit(state.copyWith(
|
||||
pendingTakeover: TakeoverRequest(
|
||||
cameraId: notification.cameraId,
|
||||
requestingKeyboard: notification.copilotName,
|
||||
),
|
||||
));
|
||||
break;
|
||||
|
||||
case CameraLockNotificationType.takenOver:
|
||||
emit(state.copyWith(
|
||||
lastNotification:
|
||||
'Camera ${notification.cameraId} taken over by ${notification.copilotName}',
|
||||
));
|
||||
break;
|
||||
|
||||
case CameraLockNotificationType.expireSoon:
|
||||
emit(state.copyWith(
|
||||
lastNotification:
|
||||
'Lock on camera ${notification.cameraId} expiring soon',
|
||||
));
|
||||
break;
|
||||
|
||||
case CameraLockNotificationType.confirmed:
|
||||
emit(state.copyWith(
|
||||
lastNotification:
|
||||
'Takeover confirmed for camera ${notification.cameraId}',
|
||||
));
|
||||
break;
|
||||
|
||||
case CameraLockNotificationType.rejected:
|
||||
emit(state.copyWith(
|
||||
lastNotification:
|
||||
'Takeover rejected for camera ${notification.cameraId}',
|
||||
));
|
||||
break;
|
||||
|
||||
case CameraLockNotificationType.unlocked:
|
||||
case CameraLockNotificationType.acquired:
|
||||
// Handled by lock state updates
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _onCoordinatorConnectionChanged(
|
||||
CoordinatorConnectionChanged event, Emitter<LockState> emit) {
|
||||
emit(state.copyWith(coordinatorConnected: event.connected));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_locksSub?.cancel();
|
||||
_notifSub?.cancel();
|
||||
_connSub?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
60
copilot_keyboard/lib/presentation/blocs/lock/lock_event.dart
Normal file
60
copilot_keyboard/lib/presentation/blocs/lock/lock_event.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import '../../../domain/entities/camera_lock.dart';
|
||||
|
||||
abstract class LockEvent {}
|
||||
|
||||
/// Try to acquire a lock on a camera
|
||||
class TryLock extends LockEvent {
|
||||
final int cameraId;
|
||||
final CameraLockPriority priority;
|
||||
|
||||
TryLock(this.cameraId, {this.priority = CameraLockPriority.low});
|
||||
}
|
||||
|
||||
/// Release a camera lock
|
||||
class ReleaseLock extends LockEvent {
|
||||
final int cameraId;
|
||||
ReleaseLock(this.cameraId);
|
||||
}
|
||||
|
||||
/// Release all locks held by this keyboard
|
||||
class ReleaseAllLocks extends LockEvent {}
|
||||
|
||||
/// Request takeover of a camera locked by another keyboard
|
||||
class RequestTakeover extends LockEvent {
|
||||
final int cameraId;
|
||||
final CameraLockPriority priority;
|
||||
|
||||
RequestTakeover(this.cameraId, {this.priority = CameraLockPriority.low});
|
||||
}
|
||||
|
||||
/// Confirm or reject a takeover request from another keyboard
|
||||
class ConfirmTakeover extends LockEvent {
|
||||
final int cameraId;
|
||||
final bool confirm;
|
||||
|
||||
ConfirmTakeover(this.cameraId, {required this.confirm});
|
||||
}
|
||||
|
||||
/// Reset lock expiration (keep-alive during PTZ)
|
||||
class ResetLockExpiration extends LockEvent {
|
||||
final int cameraId;
|
||||
ResetLockExpiration(this.cameraId);
|
||||
}
|
||||
|
||||
/// Internal: lock state updated from coordinator WebSocket
|
||||
class LocksUpdated extends LockEvent {
|
||||
final Map<int, CameraLock> locks;
|
||||
LocksUpdated(this.locks);
|
||||
}
|
||||
|
||||
/// Internal: lock notification received from coordinator
|
||||
class LockNotificationReceived extends LockEvent {
|
||||
final CameraLockNotification notification;
|
||||
LockNotificationReceived(this.notification);
|
||||
}
|
||||
|
||||
/// Internal: coordinator connection status changed
|
||||
class CoordinatorConnectionChanged extends LockEvent {
|
||||
final bool connected;
|
||||
CoordinatorConnectionChanged(this.connected);
|
||||
}
|
||||
75
copilot_keyboard/lib/presentation/blocs/lock/lock_state.dart
Normal file
75
copilot_keyboard/lib/presentation/blocs/lock/lock_state.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import '../../../domain/entities/camera_lock.dart';
|
||||
|
||||
class LockState {
|
||||
/// All known camera locks
|
||||
final Map<int, CameraLock> locks;
|
||||
|
||||
/// Whether the coordinator is connected
|
||||
final bool coordinatorConnected;
|
||||
|
||||
/// Pending takeover confirmation request (show dialog to user)
|
||||
final TakeoverRequest? pendingTakeover;
|
||||
|
||||
/// Last notification message (for snackbar/toast)
|
||||
final String? lastNotification;
|
||||
|
||||
/// Error message
|
||||
final String? error;
|
||||
|
||||
const LockState({
|
||||
this.locks = const {},
|
||||
this.coordinatorConnected = false,
|
||||
this.pendingTakeover,
|
||||
this.lastNotification,
|
||||
this.error,
|
||||
});
|
||||
|
||||
LockState copyWith({
|
||||
Map<int, CameraLock>? locks,
|
||||
bool? coordinatorConnected,
|
||||
TakeoverRequest? pendingTakeover,
|
||||
bool clearPendingTakeover = false,
|
||||
String? lastNotification,
|
||||
bool clearNotification = false,
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return LockState(
|
||||
locks: locks ?? this.locks,
|
||||
coordinatorConnected: coordinatorConnected ?? this.coordinatorConnected,
|
||||
pendingTakeover:
|
||||
clearPendingTakeover ? null : (pendingTakeover ?? this.pendingTakeover),
|
||||
lastNotification:
|
||||
clearNotification ? null : (lastNotification ?? this.lastNotification),
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if a camera is locked by this keyboard
|
||||
bool isCameraLockedByMe(int cameraId, String keyboardId) {
|
||||
final lock = locks[cameraId];
|
||||
return lock != null &&
|
||||
lock.ownerName.toLowerCase() == keyboardId.toLowerCase();
|
||||
}
|
||||
|
||||
/// Check if a camera is locked by another keyboard
|
||||
bool isCameraLockedByOther(int cameraId, String keyboardId) {
|
||||
final lock = locks[cameraId];
|
||||
return lock != null &&
|
||||
lock.ownerName.toLowerCase() != keyboardId.toLowerCase();
|
||||
}
|
||||
|
||||
/// Get the lock for a camera, if any
|
||||
CameraLock? getLock(int cameraId) => locks[cameraId];
|
||||
}
|
||||
|
||||
/// Pending takeover request shown as a dialog
|
||||
class TakeoverRequest {
|
||||
final int cameraId;
|
||||
final String requestingKeyboard;
|
||||
|
||||
const TakeoverRequest({
|
||||
required this.cameraId,
|
||||
required this.requestingKeyboard,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../config/app_config.dart';
|
||||
import '../../../data/services/state_service.dart';
|
||||
import '../../../data/services/bridge_service.dart';
|
||||
import '../../../injection_container.dart';
|
||||
import 'monitor_event.dart';
|
||||
import 'monitor_state.dart';
|
||||
|
||||
class MonitorBloc extends Bloc<MonitorEvent, MonitorBlocState> {
|
||||
final StateService _stateService;
|
||||
final AppConfig _config;
|
||||
StreamSubscription? _stateSubscription;
|
||||
|
||||
MonitorBloc({
|
||||
required StateService stateService,
|
||||
required AppConfig config,
|
||||
}) : _stateService = stateService,
|
||||
_config = config,
|
||||
super(MonitorBlocState(availableMonitors: config.allMonitorIds)) {
|
||||
on<SelectMonitor>(_onSelectMonitor);
|
||||
on<ClearMonitor>(_onClearMonitor);
|
||||
on<ClearMonitorSelection>(_onClearMonitorSelection);
|
||||
on<MonitorStatesUpdated>(_onMonitorStatesUpdated);
|
||||
|
||||
// Subscribe to monitor state changes
|
||||
_stateSubscription = _stateService.combinedMonitorStates.listen((states) {
|
||||
add(MonitorStatesUpdated(states));
|
||||
});
|
||||
}
|
||||
|
||||
void _onSelectMonitor(
|
||||
SelectMonitor event,
|
||||
Emitter<MonitorBlocState> emit,
|
||||
) {
|
||||
emit(state.copyWith(selectedMonitorId: event.monitorId, error: null));
|
||||
}
|
||||
|
||||
Future<void> _onClearMonitor(
|
||||
ClearMonitor event,
|
||||
Emitter<MonitorBlocState> emit,
|
||||
) async {
|
||||
try {
|
||||
final bridgeService = sl<BridgeService>();
|
||||
await bridgeService.viewerClear(event.monitorId);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: 'Failed to clear monitor: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
void _onClearMonitorSelection(
|
||||
ClearMonitorSelection event,
|
||||
Emitter<MonitorBlocState> emit,
|
||||
) {
|
||||
emit(state.copyWith(clearSelection: true, error: null));
|
||||
}
|
||||
|
||||
void _onMonitorStatesUpdated(
|
||||
MonitorStatesUpdated event,
|
||||
Emitter<MonitorBlocState> emit,
|
||||
) {
|
||||
emit(state.copyWith(monitorStates: event.states));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_stateSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../../domain/entities/monitor_state.dart';
|
||||
|
||||
abstract class MonitorEvent extends Equatable {
|
||||
const MonitorEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Select a monitor for camera switching
|
||||
class SelectMonitor extends MonitorEvent {
|
||||
final int monitorId;
|
||||
|
||||
const SelectMonitor(this.monitorId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [monitorId];
|
||||
}
|
||||
|
||||
/// Clear the selected monitor
|
||||
class ClearMonitor extends MonitorEvent {
|
||||
final int monitorId;
|
||||
|
||||
const ClearMonitor(this.monitorId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [monitorId];
|
||||
}
|
||||
|
||||
/// Clear selection
|
||||
class ClearMonitorSelection extends MonitorEvent {
|
||||
const ClearMonitorSelection();
|
||||
}
|
||||
|
||||
/// Monitor states updated (internal)
|
||||
class MonitorStatesUpdated extends MonitorEvent {
|
||||
final Map<int, MonitorState> states;
|
||||
|
||||
const MonitorStatesUpdated(this.states);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [states];
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../../domain/entities/monitor_state.dart' as domain;
|
||||
|
||||
class MonitorBlocState extends Equatable {
|
||||
final int? selectedMonitorId;
|
||||
final Map<int, domain.MonitorState> monitorStates;
|
||||
final List<int> availableMonitors;
|
||||
final String? error;
|
||||
|
||||
const MonitorBlocState({
|
||||
this.selectedMonitorId,
|
||||
this.monitorStates = const {},
|
||||
this.availableMonitors = const [],
|
||||
this.error,
|
||||
});
|
||||
|
||||
bool get hasSelection => selectedMonitorId != null;
|
||||
|
||||
/// Get the currently selected monitor's state
|
||||
domain.MonitorState? get selectedMonitorState {
|
||||
if (selectedMonitorId == null) return null;
|
||||
return monitorStates[selectedMonitorId];
|
||||
}
|
||||
|
||||
/// Get the camera currently on the selected monitor
|
||||
int? get selectedMonitorCamera {
|
||||
return selectedMonitorState?.currentChannel;
|
||||
}
|
||||
|
||||
MonitorBlocState copyWith({
|
||||
int? selectedMonitorId,
|
||||
Map<int, domain.MonitorState>? monitorStates,
|
||||
List<int>? availableMonitors,
|
||||
String? error,
|
||||
bool clearSelection = false,
|
||||
}) {
|
||||
return MonitorBlocState(
|
||||
selectedMonitorId:
|
||||
clearSelection ? null : (selectedMonitorId ?? this.selectedMonitorId),
|
||||
monitorStates: monitorStates ?? this.monitorStates,
|
||||
availableMonitors: availableMonitors ?? this.availableMonitors,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[selectedMonitorId, monitorStates, availableMonitors, error];
|
||||
}
|
||||
189
copilot_keyboard/lib/presentation/blocs/ptz/ptz_bloc.dart
Normal file
189
copilot_keyboard/lib/presentation/blocs/ptz/ptz_bloc.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../data/services/bridge_service.dart';
|
||||
import '../../../data/services/coordination_service.dart';
|
||||
import 'ptz_event.dart';
|
||||
import 'ptz_state.dart';
|
||||
|
||||
class PtzBloc extends Bloc<PtzEvent, PtzState> {
|
||||
final BridgeService _bridgeService;
|
||||
final CoordinationService _coordinationService;
|
||||
|
||||
Timer? _lockResetTimer;
|
||||
|
||||
PtzBloc({
|
||||
required BridgeService bridgeService,
|
||||
required CoordinationService coordinationService,
|
||||
}) : _bridgeService = bridgeService,
|
||||
_coordinationService = coordinationService,
|
||||
super(const PtzState()) {
|
||||
on<PtzPanStart>(_onPanStart);
|
||||
on<PtzTiltStart>(_onTiltStart);
|
||||
on<PtzZoomStart>(_onZoomStart);
|
||||
on<PtzStop>(_onStop);
|
||||
on<PtzGoToPreset>(_onGoToPreset);
|
||||
on<PtzSetCamera>(_onSetCamera);
|
||||
}
|
||||
|
||||
/// Ensure we have a lock on the camera before PTZ movement.
|
||||
/// Returns true if lock was acquired or already held.
|
||||
Future<bool> _ensureLock(int cameraId, Emitter<PtzState> emit) async {
|
||||
// Already locked by us
|
||||
if (_coordinationService.isCameraLockedByMe(cameraId)) {
|
||||
emit(state.copyWith(lockStatus: PtzLockStatus.locked));
|
||||
return true;
|
||||
}
|
||||
|
||||
emit(state.copyWith(lockStatus: PtzLockStatus.acquiring));
|
||||
|
||||
final result = await _coordinationService.tryLock(cameraId);
|
||||
if (result.acquired) {
|
||||
emit(state.copyWith(lockStatus: PtzLockStatus.locked));
|
||||
_startLockResetTimer(cameraId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Lock denied — someone else has it
|
||||
emit(state.copyWith(
|
||||
lockStatus: PtzLockStatus.denied,
|
||||
lockedBy: result.lock?.ownerName,
|
||||
error: 'Camera locked by ${result.lock?.ownerName ?? "another keyboard"}',
|
||||
));
|
||||
return false;
|
||||
}
|
||||
|
||||
void _startLockResetTimer(int cameraId) {
|
||||
_lockResetTimer?.cancel();
|
||||
_lockResetTimer = Timer.periodic(const Duration(minutes: 2), (_) {
|
||||
if (_coordinationService.isCameraLockedByMe(cameraId)) {
|
||||
_coordinationService.resetExpiration(cameraId);
|
||||
} else {
|
||||
_lockResetTimer?.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onPanStart(
|
||||
PtzPanStart event,
|
||||
Emitter<PtzState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(activeCameraId: event.cameraId));
|
||||
|
||||
if (!await _ensureLock(event.cameraId, emit)) return;
|
||||
|
||||
final direction =
|
||||
event.direction == 'left' ? PtzDirection.left : PtzDirection.right;
|
||||
emit(state.copyWith(
|
||||
currentDirection: direction,
|
||||
isMoving: true,
|
||||
error: null,
|
||||
));
|
||||
|
||||
try {
|
||||
await _bridgeService.ptzPan(event.cameraId, event.direction, event.speed);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString(), isMoving: false));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onTiltStart(
|
||||
PtzTiltStart event,
|
||||
Emitter<PtzState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(activeCameraId: event.cameraId));
|
||||
|
||||
if (!await _ensureLock(event.cameraId, emit)) return;
|
||||
|
||||
final direction =
|
||||
event.direction == 'up' ? PtzDirection.up : PtzDirection.down;
|
||||
emit(state.copyWith(
|
||||
currentDirection: direction,
|
||||
isMoving: true,
|
||||
error: null,
|
||||
));
|
||||
|
||||
try {
|
||||
await _bridgeService.ptzTilt(
|
||||
event.cameraId, event.direction, event.speed);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString(), isMoving: false));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onZoomStart(
|
||||
PtzZoomStart event,
|
||||
Emitter<PtzState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(activeCameraId: event.cameraId));
|
||||
|
||||
if (!await _ensureLock(event.cameraId, emit)) return;
|
||||
|
||||
final direction =
|
||||
event.direction == 'in' ? PtzDirection.zoomIn : PtzDirection.zoomOut;
|
||||
emit(state.copyWith(
|
||||
currentDirection: direction,
|
||||
isMoving: true,
|
||||
error: null,
|
||||
));
|
||||
|
||||
try {
|
||||
await _bridgeService.ptzZoom(
|
||||
event.cameraId, event.direction, event.speed);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString(), isMoving: false));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onStop(
|
||||
PtzStop event,
|
||||
Emitter<PtzState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(
|
||||
currentDirection: PtzDirection.none,
|
||||
isMoving: false,
|
||||
error: null,
|
||||
));
|
||||
|
||||
try {
|
||||
await _bridgeService.ptzStop(event.cameraId);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onGoToPreset(
|
||||
PtzGoToPreset event,
|
||||
Emitter<PtzState> emit,
|
||||
) async {
|
||||
if (!await _ensureLock(event.cameraId, emit)) return;
|
||||
|
||||
emit(state.copyWith(error: null));
|
||||
|
||||
try {
|
||||
await _bridgeService.ptzPreset(event.cameraId, event.preset);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
void _onSetCamera(
|
||||
PtzSetCamera event,
|
||||
Emitter<PtzState> emit,
|
||||
) {
|
||||
if (event.cameraId == null) {
|
||||
_lockResetTimer?.cancel();
|
||||
emit(state.copyWith(
|
||||
clearCamera: true, lockStatus: PtzLockStatus.none));
|
||||
} else {
|
||||
emit(state.copyWith(activeCameraId: event.cameraId));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_lockResetTimer?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
90
copilot_keyboard/lib/presentation/blocs/ptz/ptz_event.dart
Normal file
90
copilot_keyboard/lib/presentation/blocs/ptz/ptz_event.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class PtzEvent extends Equatable {
|
||||
const PtzEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Start panning
|
||||
class PtzPanStart extends PtzEvent {
|
||||
final int cameraId;
|
||||
final String direction; // 'left' or 'right'
|
||||
final int speed;
|
||||
|
||||
const PtzPanStart({
|
||||
required this.cameraId,
|
||||
required this.direction,
|
||||
this.speed = 50,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cameraId, direction, speed];
|
||||
}
|
||||
|
||||
/// Start tilting
|
||||
class PtzTiltStart extends PtzEvent {
|
||||
final int cameraId;
|
||||
final String direction; // 'up' or 'down'
|
||||
final int speed;
|
||||
|
||||
const PtzTiltStart({
|
||||
required this.cameraId,
|
||||
required this.direction,
|
||||
this.speed = 50,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cameraId, direction, speed];
|
||||
}
|
||||
|
||||
/// Start zooming
|
||||
class PtzZoomStart extends PtzEvent {
|
||||
final int cameraId;
|
||||
final String direction; // 'in' or 'out'
|
||||
final int speed;
|
||||
|
||||
const PtzZoomStart({
|
||||
required this.cameraId,
|
||||
required this.direction,
|
||||
this.speed = 50,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cameraId, direction, speed];
|
||||
}
|
||||
|
||||
/// Stop all PTZ movement
|
||||
class PtzStop extends PtzEvent {
|
||||
final int cameraId;
|
||||
|
||||
const PtzStop(this.cameraId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cameraId];
|
||||
}
|
||||
|
||||
/// Go to preset
|
||||
class PtzGoToPreset extends PtzEvent {
|
||||
final int cameraId;
|
||||
final int preset;
|
||||
|
||||
const PtzGoToPreset({
|
||||
required this.cameraId,
|
||||
required this.preset,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cameraId, preset];
|
||||
}
|
||||
|
||||
/// Set camera for PTZ control
|
||||
class PtzSetCamera extends PtzEvent {
|
||||
final int? cameraId;
|
||||
|
||||
const PtzSetCamera(this.cameraId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cameraId];
|
||||
}
|
||||
50
copilot_keyboard/lib/presentation/blocs/ptz/ptz_state.dart
Normal file
50
copilot_keyboard/lib/presentation/blocs/ptz/ptz_state.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
enum PtzDirection { none, left, right, up, down, zoomIn, zoomOut }
|
||||
|
||||
enum PtzLockStatus { none, acquiring, locked, denied }
|
||||
|
||||
class PtzState extends Equatable {
|
||||
final int? activeCameraId;
|
||||
final PtzDirection currentDirection;
|
||||
final bool isMoving;
|
||||
final PtzLockStatus lockStatus;
|
||||
final String? lockedBy;
|
||||
final String? error;
|
||||
|
||||
const PtzState({
|
||||
this.activeCameraId,
|
||||
this.currentDirection = PtzDirection.none,
|
||||
this.isMoving = false,
|
||||
this.lockStatus = PtzLockStatus.none,
|
||||
this.lockedBy,
|
||||
this.error,
|
||||
});
|
||||
|
||||
bool get hasActiveCamera => activeCameraId != null;
|
||||
bool get hasLock => lockStatus == PtzLockStatus.locked;
|
||||
|
||||
PtzState copyWith({
|
||||
int? activeCameraId,
|
||||
PtzDirection? currentDirection,
|
||||
bool? isMoving,
|
||||
PtzLockStatus? lockStatus,
|
||||
String? lockedBy,
|
||||
String? error,
|
||||
bool clearCamera = false,
|
||||
}) {
|
||||
return PtzState(
|
||||
activeCameraId:
|
||||
clearCamera ? null : (activeCameraId ?? this.activeCameraId),
|
||||
currentDirection: currentDirection ?? this.currentDirection,
|
||||
isMoving: isMoving ?? this.isMoving,
|
||||
lockStatus: lockStatus ?? this.lockStatus,
|
||||
lockedBy: lockedBy ?? this.lockedBy,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[activeCameraId, currentDirection, isMoving, lockStatus, lockedBy, error];
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../data/services/coordination_service.dart';
|
||||
import '../../../domain/entities/sequence.dart';
|
||||
import 'sequence_event.dart';
|
||||
import 'sequence_state.dart';
|
||||
|
||||
class SequenceBloc extends Bloc<SequenceEvent, SequenceState> {
|
||||
final CoordinationService _coordinationService;
|
||||
|
||||
SequenceBloc({required CoordinationService coordinationService})
|
||||
: _coordinationService = coordinationService,
|
||||
super(const SequenceState()) {
|
||||
on<LoadSequences>(_onLoadSequences);
|
||||
on<StartSequence>(_onStartSequence);
|
||||
on<StopSequence>(_onStopSequence);
|
||||
on<SelectCategory>(_onSelectCategory);
|
||||
}
|
||||
|
||||
Future<void> _onLoadSequences(
|
||||
LoadSequences event, Emitter<SequenceState> emit) async {
|
||||
emit(state.copyWith(isLoading: true, clearError: true));
|
||||
|
||||
try {
|
||||
final sequencesJson = await _coordinationService.getSequences();
|
||||
final categoriesJson = await _coordinationService.getSequenceCategories();
|
||||
final runningJson = await _coordinationService.getRunningSequences();
|
||||
|
||||
final sequences = sequencesJson
|
||||
.map((j) => SequenceDefinition.fromJson(j))
|
||||
.toList();
|
||||
final categories =
|
||||
categoriesJson.map((j) => SequenceCategory.fromJson(j)).toList();
|
||||
|
||||
final running = <int, RunningSequence>{};
|
||||
for (final j in runningJson) {
|
||||
final rs = RunningSequence.fromJson(j);
|
||||
running[rs.viewerId] = rs;
|
||||
}
|
||||
|
||||
emit(state.copyWith(
|
||||
sequences: sequences,
|
||||
categories: categories,
|
||||
running: running,
|
||||
isLoading: false,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isLoading: false, error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onStartSequence(
|
||||
StartSequence event, Emitter<SequenceState> emit) async {
|
||||
try {
|
||||
final result = await _coordinationService.startSequence(
|
||||
event.viewerId, event.sequenceId);
|
||||
|
||||
if (result != null) {
|
||||
final rs = RunningSequence.fromJson(result);
|
||||
final running = Map<int, RunningSequence>.from(state.running);
|
||||
running[rs.viewerId] = rs;
|
||||
emit(state.copyWith(running: running));
|
||||
}
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onStopSequence(
|
||||
StopSequence event, Emitter<SequenceState> emit) async {
|
||||
try {
|
||||
await _coordinationService.stopSequence(event.viewerId);
|
||||
|
||||
final running = Map<int, RunningSequence>.from(state.running);
|
||||
running.remove(event.viewerId);
|
||||
emit(state.copyWith(running: running));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
void _onSelectCategory(
|
||||
SelectCategory event, Emitter<SequenceState> emit) {
|
||||
if (event.categoryId == null) {
|
||||
emit(state.copyWith(clearCategory: true));
|
||||
} else {
|
||||
emit(state.copyWith(selectedCategoryId: event.categoryId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
abstract class SequenceEvent {}
|
||||
|
||||
/// Load available sequences and categories from coordinator
|
||||
class LoadSequences extends SequenceEvent {}
|
||||
|
||||
/// Start a sequence on a viewer
|
||||
class StartSequence extends SequenceEvent {
|
||||
final int viewerId;
|
||||
final int sequenceId;
|
||||
|
||||
StartSequence({required this.viewerId, required this.sequenceId});
|
||||
}
|
||||
|
||||
/// Stop a sequence on a viewer
|
||||
class StopSequence extends SequenceEvent {
|
||||
final int viewerId;
|
||||
|
||||
StopSequence(this.viewerId);
|
||||
}
|
||||
|
||||
/// Filter sequences by category
|
||||
class SelectCategory extends SequenceEvent {
|
||||
final int? categoryId;
|
||||
|
||||
SelectCategory(this.categoryId);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import '../../../domain/entities/sequence.dart';
|
||||
|
||||
class SequenceState {
|
||||
final List<SequenceDefinition> sequences;
|
||||
final List<SequenceCategory> categories;
|
||||
final Map<int, RunningSequence> running; // viewerId -> RunningSequence
|
||||
final int? selectedCategoryId;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const SequenceState({
|
||||
this.sequences = const [],
|
||||
this.categories = const [],
|
||||
this.running = const {},
|
||||
this.selectedCategoryId,
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
/// Sequences filtered by selected category
|
||||
List<SequenceDefinition> get filteredSequences {
|
||||
if (selectedCategoryId == null) return sequences;
|
||||
return sequences
|
||||
.where((s) => s.categoryId == selectedCategoryId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Check if a sequence is running on a viewer
|
||||
bool isRunningOnViewer(int viewerId) => running.containsKey(viewerId);
|
||||
|
||||
/// Get running sequence for a viewer
|
||||
RunningSequence? getRunning(int viewerId) => running[viewerId];
|
||||
|
||||
SequenceState copyWith({
|
||||
List<SequenceDefinition>? sequences,
|
||||
List<SequenceCategory>? categories,
|
||||
Map<int, RunningSequence>? running,
|
||||
int? selectedCategoryId,
|
||||
bool clearCategory = false,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return SequenceState(
|
||||
sequences: sequences ?? this.sequences,
|
||||
categories: categories ?? this.categories,
|
||||
running: running ?? this.running,
|
||||
selectedCategoryId: clearCategory
|
||||
? null
|
||||
: (selectedCategoryId ?? this.selectedCategoryId),
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
);
|
||||
}
|
||||
}
|
||||
237
copilot_keyboard/lib/presentation/blocs/wall/wall_bloc.dart
Normal file
237
copilot_keyboard/lib/presentation/blocs/wall/wall_bloc.dart
Normal file
@@ -0,0 +1,237 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../data/services/bridge_service.dart';
|
||||
import '../../../domain/entities/wall_config.dart';
|
||||
import 'wall_event.dart';
|
||||
import 'wall_state.dart';
|
||||
|
||||
class WallBloc extends Bloc<WallEvent, WallState> {
|
||||
final BridgeService _bridgeService;
|
||||
Timer? _editTimeoutTimer;
|
||||
|
||||
/// Legacy cancel timeout: 5 seconds of inactivity cancels edit mode.
|
||||
static const _editTimeout = Duration(seconds: 5);
|
||||
|
||||
WallBloc({required BridgeService bridgeService})
|
||||
: _bridgeService = bridgeService,
|
||||
super(const WallState()) {
|
||||
on<LoadWallConfig>(_onLoadWallConfig);
|
||||
on<SelectViewer>(_onSelectViewer);
|
||||
on<DeselectViewer>(_onDeselectViewer);
|
||||
on<SetCameraPrefix>(_onSetCameraPrefix);
|
||||
on<AddCameraDigit>(_onAddCameraDigit);
|
||||
on<BackspaceCameraDigit>(_onBackspaceCameraDigit);
|
||||
on<CancelCameraEdit>(_onCancelCameraEdit);
|
||||
on<CycleCameraPrefix>(_onCycleCameraPrefix);
|
||||
on<ExecuteCrossSwitch>(_onExecuteCrossSwitch);
|
||||
on<UpdateViewerCamera>(_onUpdateViewerCamera);
|
||||
on<SetViewerAlarm>(_onSetViewerAlarm);
|
||||
on<SetViewerLock>(_onSetViewerLock);
|
||||
on<ToggleSectionExpanded>(_onToggleSectionExpanded);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_editTimeoutTimer?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
void _restartEditTimeout() {
|
||||
_editTimeoutTimer?.cancel();
|
||||
_editTimeoutTimer = Timer(_editTimeout, () {
|
||||
add(const CancelCameraEdit());
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelEditTimeout() {
|
||||
_editTimeoutTimer?.cancel();
|
||||
}
|
||||
|
||||
void _onLoadWallConfig(LoadWallConfig event, Emitter<WallState> emit) {
|
||||
emit(state.copyWith(isLoading: true, clearError: true));
|
||||
|
||||
try {
|
||||
// Use provided config or load sample
|
||||
final config = event.config ?? WallConfig.sample();
|
||||
|
||||
// Initialize viewer states for all viewers
|
||||
final viewerStates = <int, ViewerState>{};
|
||||
for (final viewerId in config.allViewerIds) {
|
||||
viewerStates[viewerId] = ViewerState(viewerId: viewerId);
|
||||
}
|
||||
|
||||
// Expand all sections by default
|
||||
final expandedSections = config.sections.map((s) => s.id).toSet();
|
||||
|
||||
emit(state.copyWith(
|
||||
config: config,
|
||||
isLoading: false,
|
||||
viewerStates: viewerStates,
|
||||
expandedSections: expandedSections,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
error: 'Failed to load wall config: $e',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _onSelectViewer(SelectViewer event, Emitter<WallState> emit) {
|
||||
if (state.config == null) return;
|
||||
|
||||
// Find the physical monitor containing this viewer
|
||||
final monitor = state.config!.findMonitorByViewerId(event.viewerId);
|
||||
|
||||
_cancelEditTimeout();
|
||||
emit(state.copyWith(
|
||||
selectedViewerId: event.viewerId,
|
||||
selectedPhysicalMonitorId: monitor?.id,
|
||||
cameraNumberInput: '',
|
||||
isEditing: false,
|
||||
));
|
||||
}
|
||||
|
||||
void _onDeselectViewer(DeselectViewer event, Emitter<WallState> emit) {
|
||||
_cancelEditTimeout();
|
||||
emit(state.copyWith(
|
||||
clearSelection: true,
|
||||
cameraNumberInput: '',
|
||||
isEditing: false,
|
||||
));
|
||||
}
|
||||
|
||||
void _onSetCameraPrefix(SetCameraPrefix event, Emitter<WallState> emit) {
|
||||
if (event.prefix != 500 && event.prefix != 501 && event.prefix != 502) {
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(cameraPrefix: event.prefix));
|
||||
}
|
||||
|
||||
void _onAddCameraDigit(AddCameraDigit event, Emitter<WallState> emit) {
|
||||
if (event.digit < 0 || event.digit > 9) return;
|
||||
if (state.selectedViewerId == null) return;
|
||||
if (state.cameraNumberInput.length >= 6) return; // Max 6 digits (legacy)
|
||||
|
||||
_restartEditTimeout();
|
||||
emit(state.copyWith(
|
||||
cameraNumberInput: state.cameraNumberInput + event.digit.toString(),
|
||||
isEditing: true,
|
||||
));
|
||||
}
|
||||
|
||||
void _onBackspaceCameraDigit(
|
||||
BackspaceCameraDigit event, Emitter<WallState> emit) {
|
||||
if (state.cameraNumberInput.isEmpty) return;
|
||||
|
||||
final newInput = state.cameraNumberInput
|
||||
.substring(0, state.cameraNumberInput.length - 1);
|
||||
if (newInput.isEmpty) {
|
||||
_cancelEditTimeout();
|
||||
emit(state.copyWith(cameraNumberInput: '', isEditing: false));
|
||||
} else {
|
||||
_restartEditTimeout();
|
||||
emit(state.copyWith(cameraNumberInput: newInput));
|
||||
}
|
||||
}
|
||||
|
||||
void _onCancelCameraEdit(CancelCameraEdit event, Emitter<WallState> emit) {
|
||||
_cancelEditTimeout();
|
||||
emit(state.copyWith(cameraNumberInput: '', isEditing: false));
|
||||
}
|
||||
|
||||
void _onCycleCameraPrefix(
|
||||
CycleCameraPrefix event, Emitter<WallState> emit) {
|
||||
const prefixes = [500, 501, 502];
|
||||
final idx = prefixes.indexOf(state.cameraPrefix);
|
||||
final next = prefixes[(idx + 1) % prefixes.length];
|
||||
emit(state.copyWith(cameraPrefix: next));
|
||||
}
|
||||
|
||||
Future<void> _onExecuteCrossSwitch(
|
||||
ExecuteCrossSwitch event, Emitter<WallState> emit) async {
|
||||
print('CrossSwitch: canExecute=${state.canExecuteCrossSwitch}, selectedViewer=${state.selectedViewerId}, cameraInput=${state.cameraNumberInput}, fullCamera=${state.fullCameraNumber}');
|
||||
if (!state.canExecuteCrossSwitch) {
|
||||
print('CrossSwitch: Cannot execute - returning early');
|
||||
return;
|
||||
}
|
||||
|
||||
final viewerId = state.selectedViewerId!;
|
||||
final cameraId = state.fullCameraNumber!;
|
||||
|
||||
try {
|
||||
print('CrossSwitch: Calling viewerConnectLive(viewer=$viewerId, camera=$cameraId)');
|
||||
// Execute CrossSwitch via bridge service
|
||||
final result = await _bridgeService.viewerConnectLive(viewerId, cameraId);
|
||||
print('CrossSwitch: Result = $result');
|
||||
|
||||
// Update local state
|
||||
final viewerStates = Map<int, ViewerState>.from(state.viewerStates);
|
||||
viewerStates[viewerId] = state.getViewerState(viewerId).copyWith(
|
||||
currentCameraId: cameraId,
|
||||
isLive: true,
|
||||
);
|
||||
|
||||
_cancelEditTimeout();
|
||||
emit(state.copyWith(
|
||||
viewerStates: viewerStates,
|
||||
cameraNumberInput: '',
|
||||
isEditing: false,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: 'CrossSwitch failed: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
void _onUpdateViewerCamera(
|
||||
UpdateViewerCamera event, Emitter<WallState> emit) {
|
||||
final viewerStates = Map<int, ViewerState>.from(state.viewerStates);
|
||||
|
||||
viewerStates[event.viewerId] =
|
||||
(viewerStates[event.viewerId] ?? ViewerState(viewerId: event.viewerId))
|
||||
.copyWith(
|
||||
currentCameraId: event.cameraId,
|
||||
isLive: event.isLive,
|
||||
);
|
||||
|
||||
emit(state.copyWith(viewerStates: viewerStates));
|
||||
}
|
||||
|
||||
void _onSetViewerAlarm(SetViewerAlarm event, Emitter<WallState> emit) {
|
||||
final viewerStates = Map<int, ViewerState>.from(state.viewerStates);
|
||||
|
||||
viewerStates[event.viewerId] =
|
||||
(viewerStates[event.viewerId] ?? ViewerState(viewerId: event.viewerId))
|
||||
.copyWith(hasAlarm: event.hasAlarm);
|
||||
|
||||
emit(state.copyWith(viewerStates: viewerStates));
|
||||
}
|
||||
|
||||
void _onSetViewerLock(SetViewerLock event, Emitter<WallState> emit) {
|
||||
final viewerStates = Map<int, ViewerState>.from(state.viewerStates);
|
||||
|
||||
viewerStates[event.viewerId] =
|
||||
(viewerStates[event.viewerId] ?? ViewerState(viewerId: event.viewerId))
|
||||
.copyWith(
|
||||
isLocked: event.isLocked,
|
||||
lockedBy: event.lockedBy,
|
||||
);
|
||||
|
||||
emit(state.copyWith(viewerStates: viewerStates));
|
||||
}
|
||||
|
||||
void _onToggleSectionExpanded(
|
||||
ToggleSectionExpanded event, Emitter<WallState> emit) {
|
||||
final expandedSections = Set<String>.from(state.expandedSections);
|
||||
|
||||
if (expandedSections.contains(event.sectionId)) {
|
||||
expandedSections.remove(event.sectionId);
|
||||
} else {
|
||||
expandedSections.add(event.sectionId);
|
||||
}
|
||||
|
||||
emit(state.copyWith(expandedSections: expandedSections));
|
||||
}
|
||||
}
|
||||
131
copilot_keyboard/lib/presentation/blocs/wall/wall_event.dart
Normal file
131
copilot_keyboard/lib/presentation/blocs/wall/wall_event.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../../domain/entities/wall_config.dart';
|
||||
|
||||
abstract class WallEvent extends Equatable {
|
||||
const WallEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Load wall configuration
|
||||
class LoadWallConfig extends WallEvent {
|
||||
final WallConfig? config;
|
||||
|
||||
const LoadWallConfig([this.config]);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [config];
|
||||
}
|
||||
|
||||
/// Select a viewer by tapping on it
|
||||
class SelectViewer extends WallEvent {
|
||||
final int viewerId;
|
||||
|
||||
const SelectViewer(this.viewerId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [viewerId];
|
||||
}
|
||||
|
||||
/// Deselect current viewer
|
||||
class DeselectViewer extends WallEvent {
|
||||
const DeselectViewer();
|
||||
}
|
||||
|
||||
/// Update camera prefix for input
|
||||
class SetCameraPrefix extends WallEvent {
|
||||
final int prefix; // 500, 501, 502
|
||||
|
||||
const SetCameraPrefix(this.prefix);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [prefix];
|
||||
}
|
||||
|
||||
/// Add digit to camera number input
|
||||
class AddCameraDigit extends WallEvent {
|
||||
final int digit;
|
||||
|
||||
const AddCameraDigit(this.digit);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [digit];
|
||||
}
|
||||
|
||||
/// Remove last digit from camera input (Backspace)
|
||||
class BackspaceCameraDigit extends WallEvent {
|
||||
const BackspaceCameraDigit();
|
||||
}
|
||||
|
||||
/// Cancel camera edit (Escape or timeout)
|
||||
class CancelCameraEdit extends WallEvent {
|
||||
const CancelCameraEdit();
|
||||
}
|
||||
|
||||
/// Cycle to next camera prefix (Prefix key)
|
||||
class CycleCameraPrefix extends WallEvent {
|
||||
const CycleCameraPrefix();
|
||||
}
|
||||
|
||||
/// Execute CrossSwitch with current camera input
|
||||
class ExecuteCrossSwitch extends WallEvent {
|
||||
const ExecuteCrossSwitch();
|
||||
}
|
||||
|
||||
/// Update viewer state (from WebSocket events)
|
||||
class UpdateViewerCamera extends WallEvent {
|
||||
final int viewerId;
|
||||
final int cameraId;
|
||||
final bool isLive;
|
||||
|
||||
const UpdateViewerCamera({
|
||||
required this.viewerId,
|
||||
required this.cameraId,
|
||||
this.isLive = true,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [viewerId, cameraId, isLive];
|
||||
}
|
||||
|
||||
/// Set alarm state on a viewer
|
||||
class SetViewerAlarm extends WallEvent {
|
||||
final int viewerId;
|
||||
final bool hasAlarm;
|
||||
|
||||
const SetViewerAlarm({
|
||||
required this.viewerId,
|
||||
required this.hasAlarm,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [viewerId, hasAlarm];
|
||||
}
|
||||
|
||||
/// Set lock state on a viewer's camera
|
||||
class SetViewerLock extends WallEvent {
|
||||
final int viewerId;
|
||||
final bool isLocked;
|
||||
final String? lockedBy;
|
||||
|
||||
const SetViewerLock({
|
||||
required this.viewerId,
|
||||
required this.isLocked,
|
||||
this.lockedBy,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [viewerId, isLocked, lockedBy];
|
||||
}
|
||||
|
||||
/// Toggle expanded section
|
||||
class ToggleSectionExpanded extends WallEvent {
|
||||
final String sectionId;
|
||||
|
||||
const ToggleSectionExpanded(this.sectionId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [sectionId];
|
||||
}
|
||||
180
copilot_keyboard/lib/presentation/blocs/wall/wall_state.dart
Normal file
180
copilot_keyboard/lib/presentation/blocs/wall/wall_state.dart
Normal file
@@ -0,0 +1,180 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../../domain/entities/wall_config.dart';
|
||||
|
||||
/// State for a single viewer
|
||||
class ViewerState extends Equatable {
|
||||
final int viewerId;
|
||||
final int currentCameraId;
|
||||
final bool isLive;
|
||||
final bool hasAlarm;
|
||||
final bool isLocked;
|
||||
final String? lockedBy;
|
||||
|
||||
const ViewerState({
|
||||
required this.viewerId,
|
||||
this.currentCameraId = 0,
|
||||
this.isLive = true,
|
||||
this.hasAlarm = false,
|
||||
this.isLocked = false,
|
||||
this.lockedBy,
|
||||
});
|
||||
|
||||
bool get hasCamera => currentCameraId > 0;
|
||||
bool get isLockedByOther => isLocked && lockedBy != null;
|
||||
|
||||
ViewerState copyWith({
|
||||
int? currentCameraId,
|
||||
bool? isLive,
|
||||
bool? hasAlarm,
|
||||
bool? isLocked,
|
||||
String? lockedBy,
|
||||
}) {
|
||||
return ViewerState(
|
||||
viewerId: viewerId,
|
||||
currentCameraId: currentCameraId ?? this.currentCameraId,
|
||||
isLive: isLive ?? this.isLive,
|
||||
hasAlarm: hasAlarm ?? this.hasAlarm,
|
||||
isLocked: isLocked ?? this.isLocked,
|
||||
lockedBy: lockedBy ?? this.lockedBy,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[viewerId, currentCameraId, isLive, hasAlarm, isLocked, lockedBy];
|
||||
}
|
||||
|
||||
/// Main wall bloc state
|
||||
class WallState extends Equatable {
|
||||
final WallConfig? config;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
// Selection state
|
||||
final int? selectedViewerId;
|
||||
final int? selectedPhysicalMonitorId;
|
||||
|
||||
// Camera input state
|
||||
final int cameraPrefix; // 500, 501, 502
|
||||
final String cameraNumberInput; // Up to 6 digits typed by user
|
||||
final bool isEditing; // Whether camera input is active
|
||||
|
||||
// Viewer states (keyed by viewer ID)
|
||||
final Map<int, ViewerState> viewerStates;
|
||||
|
||||
// Expanded sections
|
||||
final Set<String> expandedSections;
|
||||
|
||||
const WallState({
|
||||
this.config,
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
this.selectedViewerId,
|
||||
this.selectedPhysicalMonitorId,
|
||||
this.cameraPrefix = 500,
|
||||
this.cameraNumberInput = '',
|
||||
this.isEditing = false,
|
||||
this.viewerStates = const {},
|
||||
this.expandedSections = const {},
|
||||
});
|
||||
|
||||
static const int _maxLength = 6;
|
||||
static const List<int> _prefixes = [500, 501, 502];
|
||||
|
||||
/// Compose camera number with prefix (legacy CameraNumber.GetCameraNumberWithPrefix).
|
||||
/// If digits > prefix length: use digits as-is, right-pad with zeros.
|
||||
/// If digits <= prefix length: prefix + left-padded digits.
|
||||
int? get fullCameraNumber {
|
||||
if (cameraNumberInput.isEmpty) return null;
|
||||
|
||||
final prefix = cameraPrefix.toString();
|
||||
final String composed;
|
||||
|
||||
if (cameraNumberInput.length > prefix.length) {
|
||||
composed = cameraNumberInput.padRight(_maxLength, '0');
|
||||
} else {
|
||||
composed = prefix +
|
||||
cameraNumberInput.padLeft(_maxLength - prefix.length, '0');
|
||||
}
|
||||
|
||||
return int.tryParse(composed);
|
||||
}
|
||||
|
||||
/// Display string: typed digits only (no prefix shown in field).
|
||||
String get cameraInputDisplay {
|
||||
if (!isEditing || cameraNumberInput.isEmpty) return '';
|
||||
return cameraNumberInput;
|
||||
}
|
||||
|
||||
/// Check if a viewer is selected
|
||||
bool isViewerSelected(int viewerId) => selectedViewerId == viewerId;
|
||||
|
||||
/// Check if a physical monitor is selected (any of its viewers)
|
||||
bool isPhysicalMonitorSelected(PhysicalMonitor monitor) =>
|
||||
selectedPhysicalMonitorId == monitor.id;
|
||||
|
||||
/// Get viewer state
|
||||
ViewerState getViewerState(int viewerId) {
|
||||
return viewerStates[viewerId] ?? ViewerState(viewerId: viewerId);
|
||||
}
|
||||
|
||||
/// Check if section is expanded
|
||||
bool isSectionExpanded(String sectionId) =>
|
||||
expandedSections.contains(sectionId);
|
||||
|
||||
/// Check if CrossSwitch can be executed
|
||||
bool get canExecuteCrossSwitch {
|
||||
if (selectedViewerId == null) return false;
|
||||
if (!isEditing || cameraNumberInput.isEmpty) return false;
|
||||
if (fullCameraNumber == null) return false;
|
||||
final viewerState = getViewerState(selectedViewerId!);
|
||||
if (viewerState.hasAlarm) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
WallState copyWith({
|
||||
WallConfig? config,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
int? selectedViewerId,
|
||||
int? selectedPhysicalMonitorId,
|
||||
int? cameraPrefix,
|
||||
String? cameraNumberInput,
|
||||
bool? isEditing,
|
||||
Map<int, ViewerState>? viewerStates,
|
||||
Set<String>? expandedSections,
|
||||
bool clearSelection = false,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return WallState(
|
||||
config: config ?? this.config,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
selectedViewerId:
|
||||
clearSelection ? null : (selectedViewerId ?? this.selectedViewerId),
|
||||
selectedPhysicalMonitorId: clearSelection
|
||||
? null
|
||||
: (selectedPhysicalMonitorId ?? this.selectedPhysicalMonitorId),
|
||||
cameraPrefix: cameraPrefix ?? this.cameraPrefix,
|
||||
cameraNumberInput: cameraNumberInput ?? this.cameraNumberInput,
|
||||
isEditing: isEditing ?? this.isEditing,
|
||||
viewerStates: viewerStates ?? this.viewerStates,
|
||||
expandedSections: expandedSections ?? this.expandedSections,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
config,
|
||||
isLoading,
|
||||
error,
|
||||
selectedViewerId,
|
||||
selectedPhysicalMonitorId,
|
||||
cameraPrefix,
|
||||
cameraNumberInput,
|
||||
isEditing,
|
||||
viewerStates,
|
||||
expandedSections,
|
||||
];
|
||||
}
|
||||
349
copilot_keyboard/lib/presentation/screens/keyboard_screen.dart
Normal file
349
copilot_keyboard/lib/presentation/screens/keyboard_screen.dart
Normal file
@@ -0,0 +1,349 @@
|
||||
import 'package:flutter/material.dart' hide LockState;
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../config/app_config.dart';
|
||||
import '../../data/services/function_button_service.dart';
|
||||
import '../../injection_container.dart';
|
||||
import '../blocs/connection/connection_bloc.dart';
|
||||
import '../blocs/camera/camera_bloc.dart';
|
||||
import '../blocs/monitor/monitor_bloc.dart';
|
||||
import '../blocs/ptz/ptz_bloc.dart';
|
||||
import '../blocs/alarm/alarm_bloc.dart';
|
||||
import '../blocs/lock/lock_bloc.dart';
|
||||
import '../blocs/lock/lock_event.dart';
|
||||
import '../blocs/lock/lock_state.dart';
|
||||
import '../blocs/sequence/sequence_bloc.dart';
|
||||
import '../blocs/sequence/sequence_event.dart';
|
||||
import '../blocs/wall/wall_bloc.dart';
|
||||
import '../blocs/wall/wall_event.dart';
|
||||
import '../widgets/wall_grid/wall_grid.dart';
|
||||
import '../widgets/toolbar/bottom_toolbar.dart';
|
||||
import '../widgets/connection_status_bar.dart';
|
||||
import '../widgets/ptz_control.dart';
|
||||
import '../widgets/sequence_panel.dart';
|
||||
import '../widgets/takeover_dialog.dart';
|
||||
|
||||
class KeyboardScreen extends StatelessWidget {
|
||||
const KeyboardScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<ConnectionBloc>(create: (_) => sl<ConnectionBloc>()),
|
||||
BlocProvider<CameraBloc>(create: (_) => sl<CameraBloc>()),
|
||||
BlocProvider<MonitorBloc>(create: (_) => sl<MonitorBloc>()),
|
||||
BlocProvider<PtzBloc>(create: (_) => sl<PtzBloc>()),
|
||||
BlocProvider<AlarmBloc>(create: (_) => sl<AlarmBloc>()),
|
||||
BlocProvider<LockBloc>(create: (_) => sl<LockBloc>()),
|
||||
BlocProvider<SequenceBloc>(
|
||||
create: (_) => sl<SequenceBloc>()..add(LoadSequences()),
|
||||
),
|
||||
BlocProvider<WallBloc>(
|
||||
create: (_) => sl<WallBloc>()..add(const LoadWallConfig()),
|
||||
),
|
||||
],
|
||||
child: BlocListener<LockBloc, LockState>(
|
||||
listenWhen: (prev, curr) =>
|
||||
prev.pendingTakeover != curr.pendingTakeover &&
|
||||
curr.pendingTakeover != null,
|
||||
listener: (context, state) {
|
||||
if (state.pendingTakeover != null) {
|
||||
showTakeoverDialog(context, state.pendingTakeover!);
|
||||
}
|
||||
},
|
||||
child: const _KeyboardScreenContent(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KeyboardScreenContent extends StatelessWidget {
|
||||
const _KeyboardScreenContent();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0D1117),
|
||||
body: Column(
|
||||
children: [
|
||||
// Top bar with connection status
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF161B22),
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Color(0xFF30363D), width: 1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'COPILOT Keyboard',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'v0.3.0-build5',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF7F7F7F),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const ConnectionStatusBar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Main content area
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Wide layout with PTZ on the side
|
||||
if (constraints.maxWidth > 1200) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Wall grid
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: const WallGrid(),
|
||||
),
|
||||
),
|
||||
// PTZ controls sidebar
|
||||
Container(
|
||||
width: 220,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF161B22),
|
||||
border: Border(
|
||||
left: BorderSide(color: Color(0xFF30363D), width: 1),
|
||||
),
|
||||
),
|
||||
child: const SingleChildScrollView(
|
||||
child: PtzControl(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
// Narrow layout - PTZ in bottom sheet or collapsed
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: const WallGrid(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Bottom toolbar
|
||||
BottomToolbar(
|
||||
onSearchPressed: () => _showSearchDialog(context),
|
||||
onPrepositionPressed: () => _showPrepositionDialog(context),
|
||||
onPlaybackPressed: () => _showPlaybackOverlay(context),
|
||||
onAlarmListPressed: () => _showAlarmListDialog(context),
|
||||
onSequencePressed: () => _showSequenceDialog(context),
|
||||
onLockPressed: () => _toggleLock(context),
|
||||
onFunctionButtonPressed: (buttonId) =>
|
||||
_executeFunctionButton(context, buttonId),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSearchDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF1A202C),
|
||||
title: const Text(
|
||||
'Hledat kameru',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Číslo nebo název kamery',
|
||||
hintStyle: TextStyle(color: Colors.white54),
|
||||
prefixIcon: Icon(Icons.search, color: Colors.white54),
|
||||
border: OutlineInputBorder(),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.white24),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: Color(0xFF00D4FF)),
|
||||
),
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Funkce bude implementována v další fázi.',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Zavřít'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPrepositionDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF1A202C),
|
||||
title: const Text(
|
||||
'Prepozice',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
content: const SizedBox(
|
||||
width: 400,
|
||||
height: 300,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Seznam prepozic bude implementován v další fázi.',
|
||||
style: TextStyle(color: Colors.white54),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Zavřít'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPlaybackOverlay(BuildContext context) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Režim přehrávání (PvZ) bude implementován v další fázi.'),
|
||||
backgroundColor: Color(0xFF2D3748),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSequenceDialog(BuildContext context) {
|
||||
final wallState = context.read<WallBloc>().state;
|
||||
final viewerId = wallState.selectedViewerId;
|
||||
if (viewerId == null) return;
|
||||
|
||||
// Refresh sequence list
|
||||
context.read<SequenceBloc>().add(LoadSequences());
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => BlocProvider.value(
|
||||
value: context.read<SequenceBloc>(),
|
||||
child: AlertDialog(
|
||||
backgroundColor: const Color(0xFF1A202C),
|
||||
title: Text(
|
||||
'Sekvence — Monitor $viewerId',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 500,
|
||||
height: 400,
|
||||
child: SingleChildScrollView(
|
||||
child: SequencePanel(viewerId: viewerId),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Zavřít'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAlarmListDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF1A202C),
|
||||
title: const Text(
|
||||
'Historie alarmů',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
content: const SizedBox(
|
||||
width: 500,
|
||||
height: 400,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Seznam alarmů bude implementován v další fázi.',
|
||||
style: TextStyle(color: Colors.white54),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Zavřít'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleLock(BuildContext context) {
|
||||
final wallState = context.read<WallBloc>().state;
|
||||
final viewerId = wallState.selectedViewerId;
|
||||
if (viewerId == null) return;
|
||||
|
||||
final viewerState = wallState.getViewerState(viewerId);
|
||||
final cameraId = viewerState.currentCameraId;
|
||||
if (cameraId <= 0) return;
|
||||
|
||||
final lockBloc = context.read<LockBloc>();
|
||||
final lockState = lockBloc.state;
|
||||
|
||||
if (lockState.isCameraLockedByMe(
|
||||
cameraId, sl<AppConfig>().keyboardId)) {
|
||||
lockBloc.add(ReleaseLock(cameraId));
|
||||
} else {
|
||||
lockBloc.add(TryLock(cameraId));
|
||||
}
|
||||
}
|
||||
|
||||
void _executeFunctionButton(BuildContext context, String buttonId) {
|
||||
final wallBloc = context.read<WallBloc>();
|
||||
final wallId = wallBloc.state.config?.id ?? '1';
|
||||
final service = sl<FunctionButtonService>();
|
||||
|
||||
if (!service.hasActions(wallId, buttonId)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$buttonId — žádná akce pro tuto stěnu'),
|
||||
backgroundColor: const Color(0xFF2D3748),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
service.execute(wallId, buttonId);
|
||||
}
|
||||
}
|
||||
168
copilot_keyboard/lib/presentation/screens/main_screen.dart
Normal file
168
copilot_keyboard/lib/presentation/screens/main_screen.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
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';
|
||||
import '../widgets/overview/wall_overview.dart';
|
||||
import '../widgets/section/section_view.dart';
|
||||
|
||||
class MainScreen extends StatefulWidget {
|
||||
const MainScreen({super.key});
|
||||
|
||||
@override
|
||||
State<MainScreen> createState() => _MainScreenState();
|
||||
}
|
||||
|
||||
class _MainScreenState extends State<MainScreen> {
|
||||
String? _selectedSectionId;
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
WallBloc? _wallBloc;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<WallBloc, WallState>(
|
||||
builder: (blocContext, state) {
|
||||
// Store bloc reference for keyboard handler
|
||||
_wallBloc = BlocProvider.of<WallBloc>(blocContext, listen: false);
|
||||
|
||||
return KeyboardListener(
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFF0A0E14),
|
||||
body: _buildBody(blocContext, state),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context, WallState state) {
|
||||
if (state.isLoading || state.config == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFF00D4FF),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Show section view or wall overview
|
||||
if (_selectedSectionId != null) {
|
||||
final section = state.config!.sections.firstWhere(
|
||||
(s) => s.id == _selectedSectionId,
|
||||
orElse: () => state.config!.sections.first,
|
||||
);
|
||||
return SectionView(
|
||||
section: section,
|
||||
wallState: state,
|
||||
onBack: () => setState(() => _selectedSectionId = null),
|
||||
onViewerTap: (viewerId) {
|
||||
context.read<WallBloc>().add(SelectViewer(viewerId));
|
||||
// Re-request focus for keyboard input after tile tap
|
||||
_focusNode.requestFocus();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return WallOverview(
|
||||
config: state.config!,
|
||||
wallState: state,
|
||||
onSectionTap: (sectionId) {
|
||||
setState(() => _selectedSectionId = sectionId);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleKeyEvent(KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return;
|
||||
|
||||
final bloc = _wallBloc;
|
||||
if (bloc == null) return;
|
||||
|
||||
final state = bloc.state;
|
||||
final key = event.logicalKey;
|
||||
|
||||
// Escape - go back or deselect
|
||||
if (key == LogicalKeyboardKey.escape) {
|
||||
if (state.selectedViewerId != null) {
|
||||
bloc.add(const DeselectViewer());
|
||||
} else if (_selectedSectionId != null) {
|
||||
setState(() => _selectedSectionId = null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle camera input when a viewer is selected
|
||||
if (state.selectedViewerId == null) return;
|
||||
|
||||
// Digit keys 0-9
|
||||
if (key.keyId >= LogicalKeyboardKey.digit0.keyId &&
|
||||
key.keyId <= LogicalKeyboardKey.digit9.keyId) {
|
||||
final digit = key.keyId - LogicalKeyboardKey.digit0.keyId;
|
||||
bloc.add(AddCameraDigit(digit));
|
||||
return;
|
||||
}
|
||||
|
||||
// Numpad digits
|
||||
if (key.keyId >= LogicalKeyboardKey.numpad0.keyId &&
|
||||
key.keyId <= LogicalKeyboardKey.numpad9.keyId) {
|
||||
final digit = key.keyId - LogicalKeyboardKey.numpad0.keyId;
|
||||
bloc.add(AddCameraDigit(digit));
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter - execute CrossSwitch
|
||||
if (key == LogicalKeyboardKey.enter ||
|
||||
key == LogicalKeyboardKey.numpadEnter) {
|
||||
if (state.canExecuteCrossSwitch) {
|
||||
bloc.add(const ExecuteCrossSwitch());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace - remove last digit
|
||||
if (key == LogicalKeyboardKey.backspace) {
|
||||
bloc.add(const BackspaceCameraDigit());
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete - cancel edit
|
||||
if (key == LogicalKeyboardKey.delete) {
|
||||
bloc.add(const CancelCameraEdit());
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape - cancel edit or deselect
|
||||
if (key == LogicalKeyboardKey.escape) {
|
||||
if (state.isEditing) {
|
||||
bloc.add(const CancelCameraEdit());
|
||||
} else {
|
||||
bloc.add(const DeselectViewer());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// F1-F3 for prefix selection
|
||||
if (key == LogicalKeyboardKey.f1) {
|
||||
bloc.add(const SetCameraPrefix(500));
|
||||
return;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.f2) {
|
||||
bloc.add(const SetCameraPrefix(501));
|
||||
return;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.f3) {
|
||||
bloc.add(const SetCameraPrefix(502));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
903
copilot_keyboard/pubspec.lock
Normal file
903
copilot_keyboard/pubspec.lock
Normal file
@@ -0,0 +1,903 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_fe_analyzer_shared:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "76.0.0"
|
||||
_macros:
|
||||
dependency: transitive
|
||||
description: dart
|
||||
source: sdk
|
||||
version: "0.3.3"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.11.0"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.0"
|
||||
bloc:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bloc
|
||||
sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.4"
|
||||
bloc_test:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: bloc_test
|
||||
sha256: "165a6ec950d9252ebe36dc5335f2e6eb13055f33d56db0eeb7642768849b43d2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.1.7"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
build:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build
|
||||
sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_config
|
||||
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
build_daemon:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_daemon
|
||||
sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.4"
|
||||
build_resolvers:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_resolvers
|
||||
sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.4"
|
||||
build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: build_runner
|
||||
sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.15"
|
||||
build_runner_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_runner_core
|
||||
sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.0"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_collection
|
||||
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
built_value:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_value
|
||||
sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.3"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: checked_yaml
|
||||
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_config
|
||||
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
code_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_builder
|
||||
sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.10.1"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.0"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
coverage:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: coverage
|
||||
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.15.0"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
diff_match_patch:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: diff_match_patch
|
||||
sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.1"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.9.1"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
equatable:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.8"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_bloc:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_bloc
|
||||
sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.6"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: frontend_server_client
|
||||
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
get_it:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: get_it
|
||||
sha256: ae78de7c3f2304b8d81f2bb6e320833e5e81de942188542328f074978cc0efa9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
go_router:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: go_router
|
||||
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.8.1"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: graphs
|
||||
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_multi_server
|
||||
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: io
|
||||
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.1"
|
||||
json_annotation:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.0"
|
||||
json_serializable:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: json_serializable
|
||||
sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.9.5"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.7"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.8"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
logger:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: logger
|
||||
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.2"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
macros:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: macros
|
||||
sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3-main.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.16+1"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.15.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
mocktail:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: mocktail
|
||||
sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: nested
|
||||
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: node_preamble
|
||||
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pool
|
||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: provider
|
||||
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5+1"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
pubspec_parse:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pubspec_parse
|
||||
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
rxdart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: rxdart
|
||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.3"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.11"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf
|
||||
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
shelf_packages_handler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_packages_handler
|
||||
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
shelf_static:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_static
|
||||
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
shelf_web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_web_socket
|
||||
sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_gen:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
source_helper:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_helper
|
||||
sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.5"
|
||||
source_map_stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_map_stack_trace
|
||||
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
source_maps:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_maps
|
||||
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.13"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.0"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.0"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
stream_transform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_transform
|
||||
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
test:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test
|
||||
sha256: "713a8789d62f3233c46b4a90b174737b2c04cb6ae4500f2aa8b1be8f03f5e67f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.25.8"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.3"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: "12391302411737c176b0b5d6491f466b0dd56d4763e347b6714efbaa74d7953d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.5"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timing
|
||||
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.3.0"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket
|
||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
web_socket_channel:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webkit_inspection_protocol:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webkit_inspection_protocol
|
||||
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.6.0 <4.0.0"
|
||||
flutter: ">=3.27.0"
|
||||
51
copilot_keyboard/pubspec.yaml
Normal file
51
copilot_keyboard/pubspec.yaml
Normal file
@@ -0,0 +1,51 @@
|
||||
name: copilot_keyboard
|
||||
description: "COPILOT CCTV Keyboard Controller - Flutter-based keyboard for video management"
|
||||
publish_to: 'none'
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.6.0
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# UI
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
# State Management
|
||||
flutter_bloc: ^8.1.6
|
||||
equatable: ^2.0.5
|
||||
|
||||
# Networking
|
||||
dio: ^5.7.0
|
||||
web_socket_channel: ^3.0.1
|
||||
|
||||
# Local Storage
|
||||
shared_preferences: ^2.3.3
|
||||
|
||||
# Routing
|
||||
go_router: ^14.6.2
|
||||
|
||||
# Dependency Injection
|
||||
get_it: ^8.0.2
|
||||
|
||||
# Utilities
|
||||
json_annotation: ^4.9.0
|
||||
rxdart: ^0.28.0
|
||||
logger: ^2.4.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^5.0.0
|
||||
build_runner: ^2.4.13
|
||||
json_serializable: ^6.8.0
|
||||
bloc_test: ^9.1.7
|
||||
mocktail: ^1.0.4
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
assets:
|
||||
- assets/config/
|
||||
13
copilot_keyboard/test/widget_test.dart
Normal file
13
copilot_keyboard/test/widget_test.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:copilot_keyboard/app.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('App should display keyboard screen', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const CopilotKeyboardApp());
|
||||
|
||||
// Verify app title is displayed
|
||||
expect(find.text('COPILOT Keyboard'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
BIN
copilot_keyboard/web/favicon.png
Normal file
BIN
copilot_keyboard/web/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 917 B |
BIN
copilot_keyboard/web/icons/Icon-192.png
Normal file
BIN
copilot_keyboard/web/icons/Icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
copilot_keyboard/web/icons/Icon-512.png
Normal file
BIN
copilot_keyboard/web/icons/Icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
BIN
copilot_keyboard/web/icons/Icon-maskable-192.png
Normal file
BIN
copilot_keyboard/web/icons/Icon-maskable-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
BIN
copilot_keyboard/web/icons/Icon-maskable-512.png
Normal file
BIN
copilot_keyboard/web/icons/Icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
37
copilot_keyboard/web/index.html
Normal file
37
copilot_keyboard/web/index.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="COPILOT D6 Keyboard Controller">
|
||||
|
||||
<!-- Disable caching during development -->
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="copilot_keyboard">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>copilot_keyboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Unregister any existing service worker and load without SW -->
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(function(registrations) {
|
||||
for (var reg of registrations) { reg.unregister(); }
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
35
copilot_keyboard/web/manifest.json
Normal file
35
copilot_keyboard/web/manifest.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "copilot_keyboard",
|
||||
"short_name": "copilot_keyboard",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "A new Flutter project.",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/Icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user