Commit ddc45594 authored by 张宏's avatar 张宏

cc 合并到 master

parent f0145cb5
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_usbcamera/flutter_usbcamera.dart';
import 'package:path_provider/path_provider.dart';
/// USB 摄像头服务层
///
/// 职责:
/// - 封装 [UsbCameraController],对上层提供统一的业务接口
/// - 监听设备热插拔事件,自动管理摄像头生命周期
/// - 将事件流中的拍摄完成回调转换为 Future 返回值
///
/// 涉及页面:首页监护舱 - 监控画面
class UsbCameraService {
static UsbCameraService? _instance;
final UsbCameraController _controller;
StreamSubscription<CameraEvent>? _eventSub;
// 事件广播流(供 UI 层订阅设备状态变化)
final StreamController<CameraEvent> _eventController =
StreamController<CameraEvent>.broadcast();
// 拍照/录像完成时的 Completer
Completer<String?>? _captureCompleter;
UsbCameraService._() : _controller = UsbCameraController();
/// 单例实例
static UsbCameraService get instance {
_instance ??= UsbCameraService._();
return _instance!;
}
/// USB 设备事件流(设备插拔、摄像头状态、拍摄回调等)
Stream<CameraEvent> get eventStream => _eventController.stream;
/// 当前控制器引用(供需要直接调用的场景)
UsbCameraController get controller => _controller;
/// 摄像头是否已打开
bool _isOpened = false;
bool get isOpened => _isOpened;
/// 是否正在录像
bool _isRecording = false;
bool get isRecording => _isRecording;
// ==================== 初始化与销毁 ====================
/// 初始化:注册 USB 监听,开始检测设备插拔
Future<void> initialize() async {
if (_eventSub != null) return; // 已初始化
// 监听插件事件流
_eventSub = UsbCameraController.events.listen(_handleEvent);
// 注册 USB 监听
await _controller.registerUsb();
debugPrint('[UsbCameraService] 初始化完成,USB 监听已注册');
}
/// 处理来自插件的所有事件
void _handleEvent(CameraEvent event) {
debugPrint('[UsbCameraService] 收到事件: ${event.type.name}, '
'deviceId=${event.deviceId}, state=${event.state}, path=${event.path}');
switch (event.type) {
case CameraEventType.onAttachDev:
// USB 摄像头插入 → 自动请求权限
if (event.deviceId != null) {
_controller.requestPermission(event.deviceId!);
}
break;
case CameraEventType.onDetachDev:
case CameraEventType.onDisConnectDev:
// 设备拔出或断开
_isOpened = false;
_isRecording = false;
break;
case CameraEventType.onConnectDev:
// 权限通过,自动打开摄像头
if (event.deviceId != null) {
_openCameraInternal(event.deviceId!);
}
break;
case CameraEventType.onCancelDev:
// 权限被拒绝
_isOpened = false;
break;
case CameraEventType.onCameraState:
if (event.state == 'opened') {
_isOpened = true;
} else if (event.state == 'closed' || event.state == 'error') {
_isOpened = false;
_isRecording = false;
}
break;
case CameraEventType.onCaptureBegin:
// 拍摄开始
break;
case CameraEventType.onCaptureComplete:
// 拍摄完成 → 解析 Completer
_isRecording = false;
if (_captureCompleter != null && !_captureCompleter!.isCompleted) {
_captureCompleter!.complete(event.path);
_captureCompleter = null;
}
break;
case CameraEventType.onCaptureError:
// 拍摄出错
_isRecording = false;
if (_captureCompleter != null && !_captureCompleter!.isCompleted) {
_captureCompleter!.completeError(
Exception(event.error ?? '拍摄失败'),
);
_captureCompleter = null;
}
break;
default:
break;
}
// 转发事件给所有订阅者
_eventController.add(event);
}
/// 内部打开摄像头方法
Future<void> _openCameraInternal(int deviceId) async {
try {
await _controller.openCamera(
deviceId,
request: const CameraRequest(
previewWidth: 1280,
previewHeight: 720,
renderMode: RenderMode.opengl,
previewFormat: PreviewFormat.mjpeg,
),
);
debugPrint('[UsbCameraService] 摄像头已打开: deviceId=$deviceId');
} catch (e) {
debugPrint('[UsbCameraService] 打开摄像头失败: $e');
}
}
// ==================== 设备管理 ====================
/// 获取已连接的 USB 设备列表
Future<List<UsbDevice>> getDeviceList() async {
try {
return await _controller.getDeviceList();
} catch (e) {
debugPrint('[UsbCameraService] getDeviceList error: $e');
return [];
}
}
/// 手动打开指定摄像头
Future<bool> openCamera(int deviceId, {CameraRequest? request}) async {
try {
await _controller.openCamera(
deviceId,
request: request ?? const CameraRequest(
previewWidth: 1280,
previewHeight: 720,
renderMode: RenderMode.opengl,
previewFormat: PreviewFormat.mjpeg,
),
);
return true;
} catch (e) {
debugPrint('[UsbCameraService] openCamera error: $e');
return false;
}
}
// ==================== 摄像头控制 ====================
/// 关闭摄像头
Future<void> closeCamera() async {
try {
await _controller.closeCamera();
_isOpened = false;
_isRecording = false;
} catch (e) {
debugPrint('[UsbCameraService] closeCamera error: $e');
}
}
/// 拍照
/// [customPath] 可选,指定照片保存路径;不传则自动生成
/// 返回照片文件路径
Future<String?> takePhoto({String? customPath}) async {
if (!_isOpened) {
debugPrint('[UsbCameraService] 摄像头未打开,无法拍照');
return null;
}
try {
_captureCompleter = Completer<String?>();
final path = customPath ?? await _generatePhotoPath();
final success = await _controller.captureImage(path: path);
if (!success) {
_captureCompleter = null;
return null;
}
// 等待 onCaptureComplete 事件返回路径
final result = await _captureCompleter!.future;
return result;
} catch (e) {
_captureCompleter = null;
debugPrint('[UsbCameraService] takePhoto error: $e');
return null;
}
}
/// 开始录像
/// [customPath] 可选,指定视频保存路径
Future<bool> startRecord({String? customPath}) async {
if (!_isOpened) {
debugPrint('[UsbCameraService] 摄像头未打开,无法录像');
return false;
}
try {
final path = customPath ?? await _generateVideoPath();
final success = await _controller.captureVideoStart(path: path);
if (success) {
_isRecording = true;
}
return success;
} catch (e) {
debugPrint('[UsbCameraService] startRecord error: $e');
return false;
}
}
/// 停止录像
/// 返回视频文件路径
Future<String?> stopRecord() async {
if (!_isRecording) {
debugPrint('[UsbCameraService] 当前未在录像');
return null;
}
try {
_captureCompleter = Completer<String?>();
final success = await _controller.captureVideoStop();
if (!success) {
_captureCompleter = null;
_isRecording = false;
return null;
}
// 等待 onCaptureComplete 事件返回路径
final result = await _captureCompleter!.future;
_isRecording = false;
return result;
} catch (e) {
_captureCompleter = null;
_isRecording = false;
debugPrint('[UsbCameraService] stopRecord error: $e');
return null;
}
}
// ==================== 路径生成 ====================
/// 生成照片保存路径
Future<String> _generatePhotoPath() async {
final dir = await _getCameraDirectory();
final timestamp = _timestamp();
return '${dir.path}/IMG_$timestamp.jpg';
}
/// 生成视频保存路径
Future<String> _generateVideoPath() async {
final dir = await _getCameraDirectory();
final timestamp = _timestamp();
return '${dir.path}/VID_$timestamp.mp4';
}
/// 获取 USB 摄像头存储目录
Future<Directory> _getCameraDirectory() async {
final baseDir = Platform.isAndroid
? await getExternalStorageDirectory()
: await getApplicationDocumentsDirectory();
final cameraDir = Directory('${baseDir!.path}/usb_camera');
if (!await cameraDir.exists()) {
await cameraDir.create(recursive: true);
}
return cameraDir;
}
/// 生成时间戳字符串
String _timestamp() {
final now = DateTime.now();
return '${now.year}${_pad(now.month)}${_pad(now.day)}_'
'${_pad(now.hour)}${_pad(now.minute)}${_pad(now.second)}';
}
String _pad(int value) => value.toString().padLeft(2, '0');
// ==================== 销毁 ====================
/// 释放所有资源
Future<void> dispose() async {
_captureCompleter = null;
_isOpened = false;
_isRecording = false;
await _eventSub?.cancel();
_eventSub = null;
await _eventController.close();
await _controller.dispose();
debugPrint('[UsbCameraService] 资源已释放');
}
}
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:laki_icu_app/enums/video_stream_mode_enum.dart';
import 'package:laki_icu_app/services/monitoring_service.dart';
import 'package:laki_icu_app/services/p2p_video_service.dart';
import 'package:laki_icu_app/services/webrtc_service.dart';
import 'package:laki_icu_app/utils/bluetooth/index.dart';
import 'package:laki_icu_app/utils/storage/storage_service.dart';
import 'home_index_state.dart';
class HomeIndexCubit extends Cubit<HomeIndexState> {
final MonitoringService _monitoringService;
final WebrtcService _webrtcService;
final P2pVideoService _p2pVideoService;
final StorageService _storageService;
final BleBluetoothManager _bluetoothManager;
final McuReportFrameDecoder _mcuReportDecoder;
StreamSubscription<WebrtcConnectionState>? _webrtcStateSub;
StreamSubscription<P2pServiceState>? _p2pStateSub;
StreamSubscription<List<BluetoothScanDevice>>? _bluetoothScanSub;
StreamSubscription<BluetoothConnectionStatus>? _bluetoothStateSub;
StreamSubscription<String>? _bluetoothMessageSub;
StreamSubscription<BluetoothDataPacket>? _bluetoothDataSub;
/// 连接代际计数器 —— 每次切换模式时 +1,
/// 防止旧连接的异步结果污染当前模式的状态。
int _switchGen = 0;
HomeIndexCubit()
: _monitoringService = MonitoringService(),
_webrtcService = WebrtcService(),
_p2pVideoService = P2pVideoService(),
_storageService = StorageService(),
_bluetoothManager = BleBluetoothManager(),
_mcuReportDecoder = McuReportFrameDecoder(),
super(const HomeIndexState()) {
_listenWebrtcState();
_listenP2pState();
_listenBluetoothState();
_loadBoundBluetoothDevice();
loadData();
}
/// 监听 WebRTC 连接状态变化并同步到 State
void _listenWebrtcState() {
_webrtcStateSub = _webrtcService.connectionStateStream.listen((state) {
if (isClosed) return;
emit(this.state.copyWith(videoConnectionState: state));
});
}
/// 监听 P2P 视频服务状态变化并同步到 State
void _listenP2pState() {
_p2pStateSub = _p2pVideoService.stateStream.listen((state) {
if (isClosed) return;
emit(this.state.copyWith(
isP2pConnected: state == P2pServiceState.connected,
));
});
}
/// 监听蓝牙扫描、连接和数据状态
void _listenBluetoothState() {
_bluetoothScanSub = _bluetoothManager.scanDevicesStream.listen((devices) {
if (isClosed) return;
emit(state.copyWith(bluetoothDevices: devices));
});
_bluetoothStateSub = _bluetoothManager.stateStream.listen((status) {
if (isClosed) return;
emit(state.copyWith(bluetoothConnectionStatus: status));
});
_bluetoothMessageSub = _bluetoothManager.messageStream.listen((message) {
if (isClosed) return;
emit(state.copyWith(bluetoothMessage: message));
});
_bluetoothDataSub = _bluetoothManager.dataStream.listen((packet) {
if (isClosed) return;
try {
final reports = _mcuReportDecoder.addBytes(packet.bytes);
final latestReport = reports.isEmpty ? null : reports.last;
emit(state.copyWith(
latestBluetoothRawHex: packet.rawHex,
latestMcuReport: latestReport,
bluetoothMessage: latestReport == null
? state.bluetoothMessage
: '蓝牙数据解析成功: ${latestReport.sn.isEmpty ? latestReport.head : latestReport.sn}',
));
} catch (e) {
emit(state.copyWith(
latestBluetoothRawHex: packet.rawHex,
bluetoothMessage: '蓝牙数据解析失败: $e',
));
}
});
}
Future<void> _loadBoundBluetoothDevice() async {
final boundDevice = await _storageService.getBoundBluetoothDevice();
if (isClosed) return;
final deviceId = boundDevice['deviceId'];
if (deviceId != null && deviceId.isNotEmpty) {
final userMac = await _storageService.getUserMac();
if (userMac == null || userMac.isEmpty) {
await _storageService.saveUserMac(deviceId);
}
} else {
await _storageService.getUserMac();
}
if (isClosed) return;
emit(state.copyWith(
boundBluetoothDeviceId:
deviceId == null || deviceId.isEmpty ? null : deviceId,
boundBluetoothDeviceName: boundDevice['deviceName'],
));
}
Future<void> loadData() async {
emit(state.copyWith(isLoading: true, error: null));
try {
final metrics = await _monitoringService.getMetrics();
if (isClosed) return;
final patientInfo = await _monitoringService.getPatientInfo();
if (isClosed) return;
final alerts = await _monitoringService.getAlerts();
if (isClosed) return;
final menuItems = await _monitoringService.getMenuItems();
if (isClosed) return;
emit(state.copyWith(
isLoading: false,
status: HomeIndexStatus.success,
metrics: metrics,
patientInfo: patientInfo,
alerts: alerts,
menuItems: menuItems,
error: null,
));
// 数据加载完成后,按当前模式初始化视频连接
_initVideoByMode();
} catch (e) {
if (isClosed) return;
emit(state.copyWith(
isLoading: false,
status: HomeIndexStatus.failure,
error: e.toString(),
));
}
}
/// 依据当前 [VideoStreamMode] 启动对应的视频连接
void _initVideoByMode() {
if (state.videoStreamMode == VideoStreamMode.p2p) {
_initP2p();
} else {
_initWebrtc();
}
}
/// 初始化 WebRTC 视频连接(已有逻辑保持不变)
Future<void> _initWebrtc() async {
await _webrtcService.connect(
// deviceNo: _mockDeviceNo,
// password: _mockDevicePassword,
deviceNo: 'VE10085871QOXG',
password: '143548',
);
}
/// 手动重试 WebRTC
Future<void> retryWebrtc() async {
await _initWebrtc();
}
/// 初始化 P2P 视频连接
Future<void> _initP2p() async {
await _p2pVideoService.connect(
deviceId: 'VE10085871QOXG',
username: 'admin',
password: '143548',
);
}
// ==================== 模式切换 ====================
/// 切换视频传输模式
///
/// 先停止当前模式 → 更新状态 → 启动新模式。
/// 使用 [_switchGen] 代际计数器防止旧连接的异步结果污染当前状态。
Future<void> switchStreamMode(VideoStreamMode mode) async {
if (state.isSwitchingMode) return;
if (state.videoStreamMode == mode) return;
final currentGen = ++_switchGen;
debugPrint(
'[HomeIndexCubit] 切换模式: ${state.videoStreamMode}$mode (gen=$currentGen)');
// 1. 标记切换中
emit(state.copyWith(isSwitchingMode: true));
// 2. 停止当前模式(用 stop 停连接,保留 StreamController 可复用)
if (state.videoStreamMode == VideoStreamMode.p2p) {
await _p2pVideoService.stop();
} else {
await _webrtcService.stop();
}
// 3. 竞态检查:如果在清理期间又触发了一次切换则放弃本次
if (currentGen != _switchGen || isClosed) return;
// 4. 切换到新模式
emit(state.copyWith(
videoStreamMode: mode,
isSwitchingMode: false,
isP2pConnected: false,
videoConnectionState: WebrtcConnectionState.disconnected,
));
// 5. 启动新模式视频连接
if (mode == VideoStreamMode.p2p) {
await _initP2p();
} else {
await _initWebrtc();
}
}
/// 手动重试当前模式的视频连接
Future<void> retryCurrentMode() async {
if (state.videoStreamMode == VideoStreamMode.p2p) {
await _p2pVideoService.stop();
await _initP2p();
} else {
await _webrtcService.stop();
await _initWebrtc();
}
}
Future<void> refreshData() async {
try {
final metrics = await _monitoringService.getMetrics();
if (isClosed) return;
final patientInfo = await _monitoringService.getPatientInfo();
if (isClosed) return;
final alerts = await _monitoringService.getAlerts();
if (isClosed) return;
final menuItems = await _monitoringService.getMenuItems();
if (isClosed) return;
emit(state.copyWith(
status: HomeIndexStatus.success,
metrics: metrics,
patientInfo: patientInfo,
alerts: alerts,
menuItems: menuItems,
error: null,
));
} catch (e) {
if (isClosed) return;
emit(state.copyWith(
status: HomeIndexStatus.failure,
error: e.toString(),
));
}
}
// ==================== 蓝牙扫描/绑定 ====================
Future<void> scanBluetoothDevices() async {
if (state.isBluetoothScanning) return;
emit(state.copyWith(
isBluetoothScanning: true,
bluetoothDevices: const [],
bluetoothMessage: '开始扫描蓝牙设备',
));
try {
await _bluetoothManager.scanForDevices(
namePrefixes: const [],
timeout: const Duration(seconds: 10),
);
} catch (e) {
if (isClosed) return;
emit(state.copyWith(bluetoothMessage: '蓝牙扫描失败: $e'));
} finally {
if (!isClosed) {
emit(state.copyWith(isBluetoothScanning: false));
}
}
}
Future<void> bindBluetoothDevice(BluetoothScanDevice device) async {
if (state.isBluetoothBinding) return;
_mcuReportDecoder.clear();
emit(state.copyWith(
isBluetoothBinding: true,
bindingBluetoothDeviceId: device.remoteId,
bluetoothMessage: '正在绑定蓝牙设备: ${device.name}',
));
try {
await _bluetoothManager.connectToDevice(device);
await _storageService.saveBoundBluetoothDevice(
deviceId: device.remoteId,
deviceName: device.name,
);
await _storageService.saveUserMac(device.remoteId);
if (isClosed) return;
emit(state.copyWith(
isBluetoothBinding: false,
bindingBluetoothDeviceId: '',
boundBluetoothDeviceId: device.remoteId,
boundBluetoothDeviceName: device.name,
bluetoothMessage: '蓝牙设备已绑定: ${device.name}',
));
} catch (e) {
if (isClosed) return;
emit(state.copyWith(
isBluetoothBinding: false,
bindingBluetoothDeviceId: '',
bluetoothMessage: '蓝牙绑定失败: $e',
));
}
}
Future<void> unbindBluetoothDevice() async {
await _bluetoothManager.disconnect();
await _storageService.deleteBoundBluetoothDevice();
await _storageService.deleteUserMac();
_mcuReportDecoder.clear();
if (isClosed) return;
emit(state.copyWith(
clearBoundBluetoothDevice: true,
clearLatestMcuReport: true,
bluetoothMessage: '蓝牙设备已解绑',
latestBluetoothRawHex: '',
));
}
/// 获取 WebRTC 渲染器供 UI 层使用
WebrtcService get webrtcService => _webrtcService;
/// 获取 P2P 视频播放控制器供 UI 层使用
P2pVideoService get p2pVideoService => _p2pVideoService;
@override
Future<void> close() {
_webrtcStateSub?.cancel();
_p2pStateSub?.cancel();
_bluetoothScanSub?.cancel();
_bluetoothStateSub?.cancel();
_bluetoothMessageSub?.cancel();
_bluetoothDataSub?.cancel();
_webrtcService.dispose();
_p2pVideoService.dispose();
return _bluetoothManager.release().then((_) => super.close());
}
}
import 'package:equatable/equatable.dart';
import 'package:laki_icu_app/enums/video_stream_mode_enum.dart';
import 'package:laki_icu_app/models/bo/monitoring_bo.dart';
import 'package:laki_icu_app/services/webrtc_service.dart';
import 'package:laki_icu_app/utils/bluetooth/index.dart';
enum HomeIndexStatus {
initial,
loading,
success,
failure,
}
class HomeIndexState extends Equatable {
final HomeIndexStatus status;
final bool isLoading;
final String? error;
final List<MonitoringMetricBO> metrics;
final PatientInfoBO? patientInfo;
final List<AlertInfoBO> alerts;
final List<MonitoringMenuItemBO> menuItems;
/// WebRTC 视频连接状态
final WebrtcConnectionState videoConnectionState;
/// 当前视频传输模式
final VideoStreamMode videoStreamMode;
/// 是否正在切换传输模式(用于 UI loading 状态)
final bool isSwitchingMode;
/// P2P 模式是否已连接
final bool isP2pConnected;
/// 蓝牙扫描中
final bool isBluetoothScanning;
/// 蓝牙绑定/连接处理中
final bool isBluetoothBinding;
/// 正在绑定/连接的蓝牙设备 ID
final String bindingBluetoothDeviceId;
/// 扫描到的蓝牙设备
final List<BluetoothScanDevice> bluetoothDevices;
/// 已绑定蓝牙设备 ID
final String? boundBluetoothDeviceId;
/// 已绑定蓝牙设备名称
final String? boundBluetoothDeviceName;
/// 蓝牙连接状态
final BluetoothConnectionStatus bluetoothConnectionStatus;
/// 蓝牙状态提示
final String? bluetoothMessage;
/// 最近收到的蓝牙原始 Hex 数据
final String? latestBluetoothRawHex;
/// 最近解析出的 MCU 上报数据
final McuReport? latestMcuReport;
const HomeIndexState({
this.status = HomeIndexStatus.initial,
this.isLoading = false,
this.error,
this.metrics = const [],
this.patientInfo,
this.alerts = const [],
this.menuItems = const [],
this.videoConnectionState = WebrtcConnectionState.disconnected,
this.videoStreamMode = VideoStreamMode.webrtc,
this.isSwitchingMode = false,
this.isP2pConnected = false,
this.isBluetoothScanning = false,
this.isBluetoothBinding = false,
this.bindingBluetoothDeviceId = '',
this.bluetoothDevices = const [],
this.boundBluetoothDeviceId,
this.boundBluetoothDeviceName,
this.bluetoothConnectionStatus = BluetoothConnectionStatus.disconnected,
this.bluetoothMessage,
this.latestBluetoothRawHex,
this.latestMcuReport,
});
HomeIndexState copyWith({
HomeIndexStatus? status,
bool? isLoading,
String? error,
List<MonitoringMetricBO>? metrics,
PatientInfoBO? patientInfo,
List<AlertInfoBO>? alerts,
List<MonitoringMenuItemBO>? menuItems,
WebrtcConnectionState? videoConnectionState,
VideoStreamMode? videoStreamMode,
bool? isSwitchingMode,
bool? isP2pConnected,
bool? isBluetoothScanning,
bool? isBluetoothBinding,
String? bindingBluetoothDeviceId,
List<BluetoothScanDevice>? bluetoothDevices,
String? boundBluetoothDeviceId,
String? boundBluetoothDeviceName,
BluetoothConnectionStatus? bluetoothConnectionStatus,
String? bluetoothMessage,
String? latestBluetoothRawHex,
McuReport? latestMcuReport,
bool clearBoundBluetoothDevice = false,
bool clearLatestMcuReport = false,
}) {
return HomeIndexState(
status: status ?? this.status,
isLoading: isLoading ?? this.isLoading,
error: error ?? this.error,
metrics: metrics ?? this.metrics,
patientInfo: patientInfo ?? this.patientInfo,
alerts: alerts ?? this.alerts,
menuItems: menuItems ?? this.menuItems,
videoConnectionState: videoConnectionState ?? this.videoConnectionState,
videoStreamMode: videoStreamMode ?? this.videoStreamMode,
isSwitchingMode: isSwitchingMode ?? this.isSwitchingMode,
isP2pConnected: isP2pConnected ?? this.isP2pConnected,
isBluetoothScanning: isBluetoothScanning ?? this.isBluetoothScanning,
isBluetoothBinding: isBluetoothBinding ?? this.isBluetoothBinding,
bindingBluetoothDeviceId:
bindingBluetoothDeviceId ?? this.bindingBluetoothDeviceId,
bluetoothDevices: bluetoothDevices ?? this.bluetoothDevices,
boundBluetoothDeviceId: clearBoundBluetoothDevice
? null
: boundBluetoothDeviceId ?? this.boundBluetoothDeviceId,
boundBluetoothDeviceName: clearBoundBluetoothDevice
? null
: boundBluetoothDeviceName ?? this.boundBluetoothDeviceName,
bluetoothConnectionStatus:
bluetoothConnectionStatus ?? this.bluetoothConnectionStatus,
bluetoothMessage: bluetoothMessage ?? this.bluetoothMessage,
latestBluetoothRawHex:
latestBluetoothRawHex ?? this.latestBluetoothRawHex,
latestMcuReport:
clearLatestMcuReport ? null : latestMcuReport ?? this.latestMcuReport,
);
}
@override
List<Object?> get props => [
status,
isLoading,
error,
metrics,
patientInfo,
alerts,
menuItems,
videoConnectionState,
videoStreamMode,
isSwitchingMode,
isP2pConnected,
isBluetoothScanning,
isBluetoothBinding,
bindingBluetoothDeviceId,
bluetoothDevices,
boundBluetoothDeviceId,
boundBluetoothDeviceName,
bluetoothConnectionStatus,
bluetoothMessage,
latestBluetoothRawHex,
latestMcuReport,
];
}
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:laki_icu_app/enums/video_stream_mode_enum.dart';
import 'package:laki_icu_app/models/bo/monitoring_bo.dart';
import 'cubit/home_index_cubit.dart';
import 'cubit/home_index_state.dart';
import 'widgets/alert_info_card.dart';
import 'widgets/bluetooth_bind_dialog.dart';
import 'widgets/metric_card.dart';
import 'widgets/patient_info_card.dart';
import 'widgets/sidebar_menu_item.dart';
import 'widgets/video_player_widget.dart';
@RoutePage()
class HomeIndexView extends StatelessWidget {
const HomeIndexView({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => HomeIndexCubit(),
child: const HomeIndexContent(),
);
}
}
class HomeIndexContent extends StatefulWidget {
const HomeIndexContent({super.key});
@override
State<HomeIndexContent> createState() => _HomeIndexContentState();
}
class _HomeIndexContentState extends State<HomeIndexContent> {
late final HomeIndexCubit _homeIndexCubit;
@override
void initState() {
super.initState();
_homeIndexCubit = context.read<HomeIndexCubit>();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0F1F47),
body: SafeArea(
child: Column(
children: [
BlocBuilder<HomeIndexCubit, HomeIndexState>(
buildWhen: (previous, current) =>
previous.boundBluetoothDeviceId !=
current.boundBluetoothDeviceId,
builder: (context, state) => _buildAppBar(state),
),
Expanded(
child: BlocBuilder<HomeIndexCubit, HomeIndexState>(
builder: (context, state) {
if (state.isLoading && state.metrics.isEmpty) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (state.status == HomeIndexStatus.failure &&
state.metrics.isEmpty) {
return _buildError(state.error);
}
return _buildMainContent(state);
},
),
),
_buildFooter(),
],
),
),
);
}
Widget _buildError(String? error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'加载失败: ${error ?? '未知错误'}',
style: TextStyle(color: Colors.white, fontSize: 24.sp),
),
SizedBox(height: 24.h),
ElevatedButton(
onPressed: () => _homeIndexCubit.loadData(),
child: const Text('重试'),
),
],
),
);
}
Widget _buildAppBar(HomeIndexState state) {
final isBluetoothBound = state.boundBluetoothDeviceId?.isNotEmpty == true;
return Container(
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 12.h),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF1E10B6), Color(0xFF3A87FF)],
),
border: Border(bottom: BorderSide(color: Colors.white24, width: 1)),
),
child: Row(
children: [
const CircleAvatar(
backgroundColor: Colors.white,
radius: 24,
child:
Icon(Icons.local_hospital, color: Color(0xFF1E10B6), size: 28),
),
SizedBox(width: 16.w),
Text(
'加邦TSAAS动物医疗监护舱',
style: TextStyle(
fontSize: 28.sp,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const Spacer(),
_buildAppBarIcon(
Icons.bluetooth,
'TSAAS',
isActive: isBluetoothBound,
onTap: _showBluetoothDialog,
),
SizedBox(width: 16.w),
_buildAppBarIcon(Icons.description, null),
SizedBox(width: 16.w),
_buildAppBarIcon(Icons.settings, null),
],
),
);
}
Widget _buildAppBarIcon(
IconData icon,
String? label, {
bool isActive = false,
VoidCallback? onTap,
}) {
final foregroundColor = isActive ? const Color(0xFF003A8C) : Colors.white;
final backgroundColor =
isActive ? const Color(0xFF00F6FF) : Colors.transparent;
final borderColor = isActive ? const Color(0xFFB8FFFF) : Colors.white24;
return GestureDetector(
onTap: onTap,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
decoration: BoxDecoration(
color: backgroundColor,
border: Border.all(color: borderColor, width: 1),
borderRadius: BorderRadius.circular(20.r),
),
child: Row(
children: [
Icon(icon, color: foregroundColor, size: 20.w),
if (label != null) ...[
SizedBox(width: 6.w),
Text(
label,
style: TextStyle(
fontSize: 18.sp,
color: foregroundColor,
fontWeight: isActive ? FontWeight.w700 : FontWeight.w400,
),
),
],
],
),
),
);
}
void _showBluetoothDialog() {
showDialog<void>(
context: context,
builder: (_) => BlocProvider.value(
value: _homeIndexCubit,
child: const BluetoothBindDialog(),
),
);
}
Widget _buildMainContent(HomeIndexState state) {
return SingleChildScrollView(
padding: EdgeInsets.all(24.w),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 左侧区域 (3/4宽度)
Expanded(
flex: 3,
child: Column(
children: [
// 指标栏(100%宽度)
_buildMetricsSection(state.metrics),
SizedBox(height: 24.h),
// 下方:监控画面 + 右侧信息卡片
SizedBox(
height: 500.h,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 监控画面模块(2/3宽度、100%高度)
Expanded(
flex: 2,
child: VideoPlayerWidget(
streamMode: state.videoStreamMode,
isSwitchingMode: state.isSwitchingMode,
webrtcRenderer:
_homeIndexCubit.webrtcService.renderer,
webrtcConnectionState: state.videoConnectionState,
p2pController:
_homeIndexCubit.p2pVideoService.playerController,
isP2pConnected: state.isP2pConnected,
onRetry: () => _homeIndexCubit.retryCurrentMode(),
onToggleMode: _onToggleStreamMode,
),
),
SizedBox(width: 24.w),
// 右侧(1/3宽度):患者信息卡片 + 告警信息卡片
Expanded(
flex: 1,
child: Column(
children: [
// 患者信息卡片(50%高度)
Expanded(
flex: 1,
child: state.patientInfo == null
? _buildEmptyCard('暂无患者信息')
: PatientInfoCard(
patientInfo: state.patientInfo!,
),
),
SizedBox(height: 24.h),
// 告警信息卡片(50%高度)
Expanded(
flex: 1,
child: AlertInfoCard(alerts: state.alerts),
),
],
),
),
],
),
),
],
),
),
SizedBox(width: 24.w),
// 右侧区域 (1/4宽度)
Expanded(
flex: 1,
child: SizedBox(
height: 500.h,
child: Column(
children: state.menuItems.map((item) {
return Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: 24.h),
child: SidebarMenuItem(
item: item,
onTap: () {},
),
),
);
}).toList(),
),
),
),
],
),
);
}
/// 模式切换回调
void _onToggleStreamMode() {
final cubit = _homeIndexCubit;
final currentMode = cubit.state.videoStreamMode;
final nextMode = currentMode == VideoStreamMode.p2p
? VideoStreamMode.webrtc
: VideoStreamMode.p2p;
cubit.switchStreamMode(nextMode);
}
Widget _buildEmptyCard(String text) {
return Container(
padding: EdgeInsets.all(24.w),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF1E10B6), Color(0xFF3A87FF)],
),
borderRadius: BorderRadius.circular(20.r),
border: Border.all(color: Colors.white24, width: 1),
),
child: Center(
child: Text(
text,
style: TextStyle(color: Colors.white70, fontSize: 24.sp),
),
),
);
}
Widget _buildMetricsSection(List<MonitoringMetricBO> metrics) {
return Container(
padding: EdgeInsets.all(24.w),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF1E10B6), Color(0xFF3A87FF)],
),
borderRadius: BorderRadius.circular(20.r),
border: Border.all(color: Colors.white24, width: 1),
),
child: Row(
children: metrics.map((metric) {
return Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8.w),
child: MetricCard(metric: metric),
),
);
}).toList(),
),
);
}
Widget _buildFooter() {
return Container(
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 12.h),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF1E10B6), Color(0xFF3A87FF)],
),
border: Border(top: BorderSide(color: Colors.white24, width: 1)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'总运行时长: 35 h',
style: TextStyle(
fontSize: 20.sp,
color: Colors.white70,
),
),
Text(
'环境温度: 25.6 ℃',
style: TextStyle(
fontSize: 20.sp,
color: Colors.white70,
),
),
],
),
);
}
}
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:laki_icu_app/utils/bluetooth/index.dart';
import 'package:laki_icu_app/views/home/index/cubit/home_index_cubit.dart';
import 'package:laki_icu_app/views/home/index/cubit/home_index_state.dart';
class BluetoothBindDialog extends StatefulWidget {
const BluetoothBindDialog({super.key});
@override
State<BluetoothBindDialog> createState() => _BluetoothBindDialogState();
}
class _BluetoothBindDialogState extends State<BluetoothBindDialog> {
bool _hasAutoStartedScan = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _hasAutoStartedScan) return;
_hasAutoStartedScan = true;
final cubit = context.read<HomeIndexCubit>();
if (!cubit.state.isBluetoothScanning) {
cubit.scanBluetoothDevices();
}
});
}
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: const Color(0xFFF4F4F4),
insetPadding: EdgeInsets.zero,
child: BlocBuilder<HomeIndexCubit, HomeIndexState>(
builder: (context, state) {
return SizedBox.expand(
child: _buildContent(context, state),
);
},
),
);
}
Widget _buildContent(BuildContext context, HomeIndexState state) {
return Container(
width: double.infinity,
// margin: EdgeInsets.fromLTRB(16.w, 0, 16.w, 16.h),
padding: EdgeInsets.fromLTRB(108.w, 52.h, 108.w, 94.h),
decoration: BoxDecoration(
color: const Color(0xFF79A9EE),
border: Border.all(color: const Color(0xFF0799FF), width: 3.w),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTopBar(context),
SizedBox(height: 38.h),
Expanded(child: _buildDevicePanel(context, state)),
],
),
);
}
Widget _buildTopBar(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'设备连接',
style: TextStyle(
color: Colors.white,
fontSize: 42.sp,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 14.h),
Text(
'搜索蓝牙设备完成配对连接',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.84),
fontSize: 22.sp,
),
),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: Icon(Icons.close, color: Colors.white, size: 48.w),
tooltip: '关闭',
padding: EdgeInsets.zero,
constraints: BoxConstraints.tight(Size(64.w, 64.w)),
),
],
);
}
Widget _buildDevicePanel(BuildContext context, HomeIndexState state) {
return Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(72.w, 56.h, 72.w, 34.h),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF0028E8), Color(0xFF001646)],
),
borderRadius: BorderRadius.circular(14.r),
border: Border.all(color: Colors.white.withValues(alpha: 0.2)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.26),
blurRadius: 12.r,
offset: Offset(0, 6.h),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildPanelStatus(context, state),
SizedBox(height: 28.h),
Expanded(child: _buildDeviceList(context, state)),
_buildLatestData(state),
],
),
);
}
Widget _buildPanelStatus(BuildContext context, HomeIndexState state) {
final message = state.isBluetoothScanning
? '自动发现附近可配对的监护舱设备,搜索中...'
: state.bluetoothDevices.isEmpty
? '未发现设备,可重新搜索附近蓝牙设备'
: '自动发现附近可配对的监护舱设备';
return Row(
children: [
Expanded(
child: Text(
message,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.88),
fontSize: 21.sp,
fontWeight: FontWeight.w400,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
SizedBox(width: 16.w),
TextButton.icon(
onPressed: state.isBluetoothScanning
? null
: () => context.read<HomeIndexCubit>().scanBluetoothDevices(),
icon: state.isBluetoothScanning
? SizedBox(
width: 18.w,
height: 18.w,
child: CircularProgressIndicator(
strokeWidth: 2.w,
color: Colors.white,
),
)
: Icon(Icons.refresh, size: 22.w),
label: Text(state.isBluetoothScanning ? '搜索中' : '重新搜索'),
style: TextButton.styleFrom(
foregroundColor: Colors.white,
disabledForegroundColor: Colors.white70,
textStyle: TextStyle(fontSize: 17.sp),
),
),
],
);
}
Widget _buildDeviceList(BuildContext context, HomeIndexState state) {
if (state.bluetoothDevices.isEmpty) {
return Center(
child: Text(
state.isBluetoothScanning ? '正在扫描附近设备...' : '暂无设备',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.76),
fontSize: 24.sp,
),
),
);
}
final devices = _sortDevicesForDisplay(state);
return ListView.separated(
padding: EdgeInsets.zero,
itemCount: devices.length,
separatorBuilder: (_, __) => SizedBox(height: 18.h),
itemBuilder: (context, index) {
final device = devices[index];
final isBound = state.boundBluetoothDeviceId == device.remoteId;
final isBinding = state.bindingBluetoothDeviceId == device.remoteId;
return _BluetoothDeviceRow(
key: ValueKey(device.remoteId),
device: device,
isBound: isBound,
isBinding: isBinding,
isDisabled: state.isBluetoothBinding,
onTap: isBound
? () => _confirmUnbindBluetoothDevice(context, device)
: () => context.read<HomeIndexCubit>().bindBluetoothDevice(
device,
),
);
},
);
}
List<BluetoothScanDevice> _sortDevicesForDisplay(HomeIndexState state) {
final boundDeviceId = state.boundBluetoothDeviceId;
if (boundDeviceId == null || boundDeviceId.isEmpty) {
return state.bluetoothDevices;
}
return List<BluetoothScanDevice>.from(state.bluetoothDevices)
..sort((left, right) {
final leftIsBound = left.remoteId == boundDeviceId;
final rightIsBound = right.remoteId == boundDeviceId;
if (leftIsBound == rightIsBound) {
return 0;
}
return leftIsBound ? -1 : 1;
});
}
Future<void> _confirmUnbindBluetoothDevice(
BuildContext context,
BluetoothScanDevice device,
) async {
final shouldUnbind = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('解绑蓝牙设备'),
content: Text('是否要解绑 ${device.name}?解绑后可重新选择设备绑定。'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('确认解绑'),
),
],
);
},
);
if (shouldUnbind == true && context.mounted) {
await context.read<HomeIndexCubit>().unbindBluetoothDevice();
}
}
Widget _buildLatestData(HomeIndexState state) {
final report = state.latestMcuReport;
final rawHex = state.latestBluetoothRawHex;
final message = state.bluetoothMessage;
if (report == null &&
(rawHex == null || rawHex.isEmpty) &&
(message == null || message.isEmpty)) {
return SizedBox(height: 10.h);
}
if (report != null) {
return _BluetoothReportSummary(report: report);
}
return Padding(
padding: EdgeInsets.only(top: 18.h),
child: Text(
rawHex != null && rawHex.isNotEmpty ? '最新数据: $rawHex' : message!,
style: TextStyle(
color: rawHex != null && rawHex.isNotEmpty
? const Color(0xFF00F6FF)
: Colors.white.withValues(alpha: 0.66),
fontSize: 15.sp,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
);
}
}
class _BluetoothReportSummary extends StatelessWidget {
const _BluetoothReportSummary({required this.report});
final McuReport report;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: EdgeInsets.only(top: 18.h),
padding: EdgeInsets.symmetric(horizontal: 18.w, vertical: 14.h),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(color: Colors.white.withValues(alpha: 0.12)),
),
child: Row(
children: [
Expanded(
flex: 2,
child: Text(
report.sn.isEmpty ? 'SN --' : 'SN ${report.sn}',
style: TextStyle(
color: const Color(0xFF00F6FF),
fontSize: 15.sp,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
_BluetoothReportMetric(
label: '温度',
value: report.mainTemperature.toStringAsFixed(1),
unit: '℃',
),
_BluetoothReportMetric(
label: '湿度',
value: '${report.humidity}',
unit: '%',
),
_BluetoothReportMetric(
label: '氧气',
value: '${report.oxygenConcentration}',
unit: '%',
),
_BluetoothReportMetric(
label: 'CO2',
value: '${report.co2Measurement}',
unit: 'ppm',
),
],
),
);
}
}
class _BluetoothReportMetric extends StatelessWidget {
const _BluetoothReportMetric({
required this.label,
required this.value,
required this.unit,
});
final String label;
final String value;
final String unit;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 128.w,
child: RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: TextSpan(
children: [
TextSpan(
text: '$label ',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.58),
fontSize: 13.sp,
),
),
TextSpan(
text: value,
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w600,
),
),
TextSpan(
text: unit,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
fontSize: 12.sp,
),
),
],
),
),
);
}
}
class _BluetoothDeviceRow extends StatelessWidget {
const _BluetoothDeviceRow({
super.key,
required this.device,
required this.isBound,
required this.isBinding,
required this.isDisabled,
required this.onTap,
});
final BluetoothScanDevice device;
final bool isBound;
final bool isBinding;
final bool isDisabled;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
const highlightColor = Color(0xFF00F6FF);
final textColor = isBound ? highlightColor : Colors.white;
final statusText = isBound
? '已连接'
: isBinding
? '连接中'
: '未连接';
return InkWell(
onTap: isDisabled ? null : onTap,
borderRadius: BorderRadius.circular(8.r),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 0, vertical: 14.h),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
device.name,
style: TextStyle(
color: textColor,
fontSize: 26.sp,
fontWeight: isBound ? FontWeight.w600 : FontWeight.w400,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 5.h),
Text(
'${device.remoteId} RSSI ${device.rssi}',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.42),
fontSize: 13.sp,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
SizedBox(width: 32.w),
SizedBox(
width: 138.w,
child: Text(
statusText,
textAlign: TextAlign.left,
style: TextStyle(
color: textColor,
fontSize: 25.sp,
fontWeight: isBound ? FontWeight.w600 : FontWeight.w400,
),
),
),
],
),
),
);
}
}
......@@ -50,8 +50,8 @@ dependencies:
logger: ^2.7.0
fluttertoast: ^9.0.0
permission_handler: ^11.3.1
flutter_usbcamera:
path: ./third_party/flutter_usbcamera
# flutter_usbcamera:
# path: ./third_party/flutter_usbcamera
path_provider: ^2.1.2
flutter_webrtc:
path: ./plugins/flutter_webrtc
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment