From 058a5d9588401950a81668a7a99dbebdf0a7c1b8 Mon Sep 17 00:00:00 2001 From: Songzex <2402265378@qq.com> Date: Tue, 9 Jun 2026 14:39:55 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=B9=E6=8E=A5=E8=B7=AF=E5=BE=84=E8=A7=84?= =?UTF-8?q?=E5=88=92=E7=9A=84=E5=88=97=E8=A1=A8=EF=BC=88=E6=A0=B9=E6=8D=AE?= =?UTF-8?q?=E5=9C=BA=E7=AB=99=E7=9A=84=E4=B8=8B=E7=9A=84=EF=BC=89=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=97=A0=E4=BA=BA=E6=9C=BA=E6=9C=BA=E5=9C=BA?= =?UTF-8?q?=E7=9A=84=E7=9B=91=E6=8E=A7=E8=A7=86=E9=A2=91=E7=9A=84=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E8=A7=86=E9=A2=91=E6=B5=81=E7=95=85=E5=BA=A6=E6=8F=90?= =?UTF-8?q?=E5=8D=8780%=20=E6=B7=BB=E5=8A=A0=E9=A6=96=E9=A1=B5tcp=E6=8C=87?= =?UTF-8?q?=E7=A4=BA=E7=81=AF=E7=9A=84=E6=9C=BA=E5=99=A8=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E5=B1=95=E7=A4=BA=E5=8A=9F=E8=83=BD=E3=80=82?= =?UTF-8?q?=EF=BC=88=E6=9A=82=E4=B8=8D=E6=94=AF=E6=8C=81=E5=9C=A8=E6=AD=A4?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=B8=8A=E8=AE=BE=E5=A4=87=E7=9A=84=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E6=98=BE=E7=A4=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/languages/en-US.json | 4 +- assets/languages/zh-CN.json | 4 +- lib/components/device_status_modal.dart | 1347 +++++++++++++++++ lib/components/tcp_status_indicator.dart | 65 +- lib/core/consts/http_api_consts.dart | 3 + lib/core/di/injection.dart | 39 +- .../data/models/work_record_entity.dart | 237 +++ .../generate_path_repository_Impl.dart | 279 +++- .../domain/repositories/path_repository.dart | 6 +- .../get_work_records_by_site_id_usecase.dart | 19 + .../presentation/bloc/devices_cubit.dart | 340 ++++- .../pages/running_status_page.dart | 58 +- .../widgets/map/testmap_pages.dart | 22 +- .../pages/custom_main_container.dart | 67 +- .../datasources/drone_station_datasource.dart | 6 + .../drone_station_datasource_impl.dart | 52 +- .../domain/entities/flight_task_entity.dart | 91 ++ .../pages/device_status_page.dart | 114 +- .../pages/drone_mission_control_page.dart | 144 +- .../pages/drone_monitor_page.dart | 1114 +++++--------- .../pages/drone_station_detail_page.dart | 160 +- .../widgets/flight_task_selector_modal.dart | 318 ++++ .../home/presentation/pages/home_v2_page.dart | 105 +- .../widgets/tcp_status_indicator.dart | 65 +- pubspec.lock | 2 +- pubspec.yaml | 1 + 26 files changed, 3634 insertions(+), 1028 deletions(-) create mode 100644 lib/components/device_status_modal.dart create mode 100644 lib/features/devices/data/models/work_record_entity.dart create mode 100644 lib/features/devices/domain/usecases/get_work_records_by_site_id_usecase.dart create mode 100644 lib/features/v2/device_list/domain/entities/flight_task_entity.dart create mode 100644 lib/features/v2/device_list/presentation/widgets/flight_task_selector_modal.dart diff --git a/assets/languages/en-US.json b/assets/languages/en-US.json index f1f641d5..9b8c3fa8 100644 --- a/assets/languages/en-US.json +++ b/assets/languages/en-US.json @@ -98,7 +98,9 @@ "value": "Value", "initialized": "Initialized", "no_data": "No Data", - "voltage": "Voltage" + "voltage": "Voltage", + "tcp_reconnected": "TCP connection restored", + "tcp_reconnect_failed": "TCP reconnection failed, please check network" }, "machine_details": { diff --git a/assets/languages/zh-CN.json b/assets/languages/zh-CN.json index 2c752640..a4abdc15 100644 --- a/assets/languages/zh-CN.json +++ b/assets/languages/zh-CN.json @@ -98,7 +98,9 @@ "value": "数值", "initialized": "已初始化", "no_data": "暂无数据", - "voltage": "电压" + "voltage": "电压", + "tcp_reconnected": "TCP连接已恢复", + "tcp_reconnect_failed": "TCP重连失败,请检查网络" }, "machine_details": { diff --git a/lib/components/device_status_modal.dart b/lib/components/device_status_modal.dart new file mode 100644 index 00000000..e589e2ab --- /dev/null +++ b/lib/components/device_status_modal.dart @@ -0,0 +1,1347 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; +import 'package:syncfusion_flutter_gauges/gauges.dart'; + +import '../features/devices/presentation/bloc/device_status_bloc.dart'; +import '../features/devices/presentation/bloc/device_status_state.dart'; +import '../features/devices/presentation/bloc/device_status_event.dart'; + +const int DATA_TIMEOUT_SECONDS = 5; + +class DeviceStatusModal extends StatefulWidget { + const DeviceStatusModal({super.key}); + + @override + State createState() => _DeviceStatusModalState(); +} + +class _DeviceStatusModalState extends State { + bool _isCardView = true; + bool _voltageGaugeMode = true; + bool _chipTempGaugeMode = true; + bool _knifeSpeedGaugeMode = false; + + final List _leftMeasureHistory = []; + final List _rightMeasureHistory = []; + final List _leftTargetHistory = []; + final List _rightTargetHistory = []; + final List _leftCurrentHistory = []; + final List _rightCurrentHistory = []; + final List _leftTempHistory = []; + final List _rightTempHistory = []; + final List _knifeHistory = []; + final List _chipTempHistory = []; + final List _voltageHistory = []; + + double _timeIndex = 0; + Timer? _dataTimeoutTimer; + bool _isDataTimeout = false; + + @override + void initState() { + super.initState(); + _startDataTimeoutTimer(); + } + + @override + void dispose() { + _dataTimeoutTimer?.cancel(); + super.dispose(); + } + + void _startDataTimeoutTimer() { + _dataTimeoutTimer?.cancel(); + _dataTimeoutTimer = Timer(Duration(seconds: DATA_TIMEOUT_SECONDS), () { + if (mounted) { + setState(() { + _isDataTimeout = true; + _resetChartData(); + }); + } + }); + } + + void _resetChartData() { + _leftMeasureHistory.clear(); + _rightMeasureHistory.clear(); + _leftTargetHistory.clear(); + _rightTargetHistory.clear(); + _leftCurrentHistory.clear(); + _rightCurrentHistory.clear(); + _leftTempHistory.clear(); + _rightTempHistory.clear(); + _knifeHistory.clear(); + _chipTempHistory.clear(); + _voltageHistory.clear(); + _timeIndex = 0; + } + + List _getMappedSpots(List data) { + final List mapped = []; + final List limited = data.length > 6 + ? data.sublist(data.length - 6) + : List.from(data); + for (int i = 0; i < limited.length; i++) { + mapped.add(FlSpot(i.toDouble(), limited[i].y)); + } + while (mapped.length < 6) { + mapped.add(FlSpot(mapped.length.toDouble(), 0)); + } + return mapped; + } + + void _limit(List list, {int max = 30}) { + if (list.length > max) list.removeAt(0); + } + + void _appendChartData(DeviceStatusUpdated state) { + final status = state.status; + + _leftTargetHistory.add(FlSpot(_timeIndex, status.leftTargetSpeed)); + _rightTargetHistory.add(FlSpot(_timeIndex, status.rightTargetSpeed)); + _leftMeasureHistory.add(FlSpot(_timeIndex, status.leftMeasureSpeed)); + _rightMeasureHistory.add(FlSpot(_timeIndex, status.rightMeasureSpeed)); + _leftCurrentHistory.add(FlSpot(_timeIndex, status.leftCurrent)); + _rightCurrentHistory.add(FlSpot(_timeIndex, status.rightCurrent)); + _leftTempHistory.add(FlSpot(_timeIndex, status.leftMotorTemp)); + _rightTempHistory.add(FlSpot(_timeIndex, status.rightMotorTemp)); + _knifeHistory.add( + FlSpot(_timeIndex, double.tryParse(status.knifeCuttingSpeed) ?? 0), + ); + _chipTempHistory.add(FlSpot(_timeIndex, status.chipTemp)); + _voltageHistory.add(FlSpot(_timeIndex, status.voltage)); + + _timeIndex += 1; + + _limit(_leftTargetHistory); + _limit(_rightTargetHistory); + _limit(_leftMeasureHistory); + _limit(_rightMeasureHistory); + _limit(_leftCurrentHistory); + _limit(_rightCurrentHistory); + _limit(_leftTempHistory); + _limit(_rightTempHistory); + _limit(_knifeHistory); + _limit(_chipTempHistory); + _limit(_voltageHistory); + } + + Widget _noDataWidget() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.inbox_outlined, color: Colors.grey, size: 48), + SizedBox(height: 16), + Text( + AppLocalizations.of(context).translate('running_status.no_data'), + style: TextStyle(color: Colors.grey, fontSize: 16), + ), + ], + ), + ); + } + + Widget _buildCardContentView(DeviceStatusState state) { + if (_isDataTimeout || state is DeviceStatusInitial) { + return _noDataWidget(); + } + + if (state is DeviceStatusUpdated) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _buildMotorTable(state), + const SizedBox(height: 20), + _buildOtherParamsTable(state), + ], + ), + ); + } else if (state is DeviceStatusError) { + return _noDataWidget(); + } else { + return _noDataWidget(); + } + } + + Widget _buildMotorTable(DeviceStatusUpdated state) { + final status = state.status; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + AppLocalizations.of( + context, + ).translate('running_status.motor_params'), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + Table( + border: TableBorder.all(color: const Color(0xFFE5E5E5)), + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + children: [ + TableRow( + decoration: const BoxDecoration(color: Color(0xFFF5F5F5)), + children: [ + const SizedBox(), + _buildTableCell( + AppLocalizations.of( + context, + ).translate('running_status.left_wheel'), + isHeader: true, + ), + _buildTableCell( + AppLocalizations.of( + context, + ).translate('running_status.right_wheel'), + isHeader: true, + ), + ], + ), + TableRow( + children: [ + _buildTableCell( + AppLocalizations.of( + context, + ).translate('running_status.target_speed') + + "\n(rpm)", + ), + _buildTableCell(status.leftTargetSpeed.toStringAsFixed(2)), + _buildTableCell(status.rightTargetSpeed.toStringAsFixed(2)), + ], + ), + TableRow( + children: [ + _buildTableCell( + AppLocalizations.of( + context, + ).translate('running_status.measure_speed') + + "\n(rpm)", + ), + _buildTableCell(status.leftMeasureSpeed.toStringAsFixed(2)), + _buildTableCell(status.rightMeasureSpeed.toStringAsFixed(2)), + ], + ), + TableRow( + children: [ + _buildTableCell( + AppLocalizations.of( + context, + ).translate('running_status.current') + + "(A)", + ), + _buildTableCell(status.leftCurrent.toStringAsFixed(2)), + _buildTableCell(status.rightCurrent.toStringAsFixed(2)), + ], + ), + TableRow( + children: [ + _buildTableCell( + AppLocalizations.of( + context, + ).translate('running_status.motor_temp') + + "(°C)", + ), + _buildTableCell(status.leftMotorTemp.toStringAsFixed(2)), + _buildTableCell(status.rightMotorTemp.toStringAsFixed(2)), + ], + ), + ], + ), + ], + ); + } + + Widget _buildOtherParamsTable(DeviceStatusUpdated state) { + final status = state.status; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + AppLocalizations.of( + context, + ).translate('running_status.other_params'), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + Table( + border: TableBorder.all(color: const Color(0xFFE5E5E5)), + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + children: [ + TableRow( + decoration: const BoxDecoration(color: Color(0xFFF5F5F5)), + children: [ + _buildTableCell( + AppLocalizations.of(context).translate('running_status.name'), + isHeader: true, + ), + _buildTableCell( + AppLocalizations.of( + context, + ).translate('running_status.value'), + isHeader: true, + ), + ], + ), + TableRow( + children: [ + _buildTableCell( + AppLocalizations.of( + context, + ).translate('running_status.pitch_angle') + + "(°)", + ), + _buildTableCell(status.pitch.toStringAsFixed(2)), + ], + ), + TableRow( + children: [ + _buildTableCell( + AppLocalizations.of( + context, + ).translate('running_status.roll_angle') + + "(°)", + ), + _buildTableCell(status.roll.toStringAsFixed(2)), + ], + ), + TableRow( + children: [ + _buildTableCell( + AppLocalizations.of( + context, + ).translate('running_status.heading_angle') + + "(°)", + ), + _buildTableCell(status.yaw.toStringAsFixed(2)), + ], + ), + TableRow( + children: [ + _buildTableCell( + AppLocalizations.of( + context, + ).translate('running_status.battery') + + "(%)", + ), + _buildTableCell(status.battery), + ], + ), + TableRow( + children: [ + _buildTableCell( + AppLocalizations.of( + context, + ).translate('running_status.chip_temp') + + "(°C)", + ), + _buildTableCell(status.chipTemp.toStringAsFixed(2)), + ], + ), + TableRow( + children: [ + _buildTableCell( + AppLocalizations.of( + context, + ).translate('running_status.knife_speed') + + "(rpm)", + ), + _buildTableCell(status.knifeCuttingSpeed), + ], + ), + TableRow( + children: [ + _buildTableCell( + AppLocalizations.of( + context, + ).translate('running_status.control_mode'), + ), + _buildTableCell( + AppLocalizations.of(context).translate( + _getControlModeKey(int.parse(status.controlMode)), + ), + ), + ], + ), + TableRow( + children: [ + _buildTableCell( + AppLocalizations.of( + context, + ).translate('running_status.longitude') + + "(°)", + ), + _buildTableCell(status.longitude.toStringAsFixed(6)), + ], + ), + TableRow( + children: [ + _buildTableCell( + AppLocalizations.of( + context, + ).translate('running_status.latitude') + + "(°)", + ), + _buildTableCell(status.latitude.toStringAsFixed(6)), + ], + ), + ], + ), + ], + ); + } + + Widget _buildTableCell(String text, {bool isHeader = false}) { + return TableCell( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 6), + child: Text( + text, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: isHeader ? const Color(0xFF333333) : const Color(0xFF666666), + fontWeight: isHeader ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ); + } + + String _getControlModeKey(int modeValue) { + switch (modeValue) { + case 3: + return 'running_status.remote_control'; + default: + return 'running_status.local_control'; + } + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (!_isDataTimeout && state is DeviceStatusUpdated) { + _startDataTimeoutTimer(); + if (_isDataTimeout) { + setState(() => _isDataTimeout = false); + } + _appendChartData(state); + } + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: const BoxDecoration( + color: Color(0xFF1677FF), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + AppLocalizations.of( + context, + ).translate('running_status.title'), + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + // Status Bar + BlocBuilder( + builder: (context, state) { + String qual = _isDataTimeout ? '--' : '--'; + String satelliteCnt = _isDataTimeout ? '--' : '--'; + String headingStatus = _isDataTimeout ? "--" : "--"; + + if (!_isDataTimeout && state is DeviceStatusUpdated) { + headingStatus = state.status.headingStatus == 0 + ? AppLocalizations.of( + context, + ).translate('running_status.not_initialized') + : AppLocalizations.of( + context, + ).translate('running_status.initialized'); + int qualValue = 0; + try { + qualValue = int.parse(state.status.qual.toString()); + } catch (e) { + qualValue = 0; + } + qual = AppLocalizations.of( + context, + ).translate(_getLocationQualityKey(qualValue)); + satelliteCnt = state.status.satelliteCnt.toString(); + } + + return Container( + color: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + flex: 3, + child: Text( + AppLocalizations.of( + context, + ).translate('running_status.heading_status') + + ":$headingStatus", + style: const TextStyle(fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Expanded( + flex: 4, + child: Text( + AppLocalizations.of( + context, + ).translate('running_status.position_quality') + + ":$qual", + style: const TextStyle(fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), + Expanded( + flex: 2, + child: Text( + AppLocalizations.of( + context, + ).translate('running_status.satellite_count') + + ":$satelliteCnt", + style: const TextStyle(fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + ), + ), + ], + ), + ); + }, + ), + // Tab Switch + Container( + color: Colors.white, + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + GestureDetector( + onTap: () => setState(() => _isCardView = true), + child: _buildTab( + AppLocalizations.of( + context, + ).translate('running_status.card'), + isActive: _isCardView, + ), + ), + const SizedBox(width: 24), + GestureDetector( + onTap: () => setState(() => _isCardView = false), + child: _buildTab( + AppLocalizations.of( + context, + ).translate('running_status.chart'), + isActive: !_isCardView, + ), + ), + ], + ), + IconButton( + icon: const Icon(Icons.refresh, color: Color(0xFF1677FF)), + onPressed: () { + context.read().add( + DeviceStatusReset(), + ); + _resetChartData(); + }, + ), + ], + ), + ), + // Content + Expanded( + child: Container( + margin: const EdgeInsets.all(8), + child: _isCardView + ? _buildCardContentView(state) + : _buildChartContentView(state), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildTab(String title, {required bool isActive}) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isActive ? const Color(0xFF1677FF) : Colors.transparent, + width: 2, + ), + ), + ), + child: Text( + title, + style: TextStyle( + color: isActive ? const Color(0xFF1677FF) : const Color(0xFF666666), + fontSize: 16, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + height: 1.0, + ), + ), + ); + } + + Widget _buildChartContentView(DeviceStatusState state) { + if (_isDataTimeout || state is DeviceStatusInitial) { + return _noDataWidget(); + } + + if (state is! DeviceStatusUpdated) { + return _noDataWidget(); + } + + final status = state.status; + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _chartCard( + title: + AppLocalizations.of( + context, + ).translate('running_status.voltage') + + " (V)", + isGaugeMode: _voltageGaugeMode, + onGaugeTap: () => setState(() => _voltageGaugeMode = true), + onChartTap: () => setState(() => _voltageGaugeMode = false), + child: _voltageGaugeMode + ? _circularGauge( + value: status.voltage, + maxValue: 250, + unit: "V", + majorTicks: const [0, 50, 100, 150, 200, 250], + ) + : SizedBox( + height: 180, + child: _styledLineChart( + title: AppLocalizations.of( + context, + ).translate('running_status.voltage'), + lines: [ + _lineData(_voltageHistory, const Color(0xFF4CAF50)), + ], + yAxisMax: 250, + yAxisMin: 0, + yTickCount: 6, + ), + ), + ), + _gap(), + _chartCard( + title: + AppLocalizations.of( + context, + ).translate('running_status.chip_temp') + + " (°C)", + isGaugeMode: _chipTempGaugeMode, + onGaugeTap: () => setState(() => _chipTempGaugeMode = true), + onChartTap: () => setState(() => _chipTempGaugeMode = false), + child: _chipTempGaugeMode + ? _circularGauge( + value: status.chipTemp, + maxValue: 100, + unit: "°C", + majorTicks: const [ + 0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 90, + 100, + ], + ) + : SizedBox( + height: 180, + child: _styledLineChart( + title: AppLocalizations.of( + context, + ).translate('running_status.chip_temp'), + lines: [ + _lineData(_chipTempHistory, const Color(0xFF4CAF50)), + ], + yAxisMax: 100, + yAxisMin: 0, + yTickCount: 6, + ), + ), + ), + _gap(), + _chartCard( + title: + AppLocalizations.of( + context, + ).translate('running_status.knife_speed') + + " (rpm)", + isGaugeMode: _knifeSpeedGaugeMode, + onGaugeTap: () => setState(() => _knifeSpeedGaugeMode = true), + onChartTap: () => setState(() => _knifeSpeedGaugeMode = false), + child: _knifeSpeedGaugeMode + ? _circularGauge( + value: double.tryParse(status.knifeCuttingSpeed) ?? 0, + maxValue: 3000, + unit: "rpm", + majorTicks: const [0, 500, 1000, 1500, 2000, 2500, 3000], + ) + : SizedBox( + height: 180, + child: _styledLineChart( + title: AppLocalizations.of( + context, + ).translate('running_status.knife_speed'), + lines: [ + _lineData(_knifeHistory, const Color(0xFF4CAF50)), + ], + yAxisMax: 3000, + yAxisMin: -3000, + yTickCount: 7, + ), + ), + ), + _gap(), + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of( + context, + ).translate('running_status.left_right_measure_speed') + + " (rpm)", + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF1677FF), + ), + ), + const SizedBox(height: 16), + SizedBox( + height: 180, + child: _styledLineChart( + title: AppLocalizations.of( + context, + ).translate('running_status.left_right_measure_speed'), + lines: [ + _lineData(_leftMeasureHistory, const Color(0xFF3F51B5)), + _lineData(_rightMeasureHistory, const Color(0xFF8BC34A)), + ], + yAxisMax: 3000, + yAxisMin: -3000, + yTickCount: 7, + ), + ), + ], + ), + ), + _gap(), + ], + ), + ); + } + + Widget _gap({double height = 20}) { + return SizedBox(height: height); + } + + Widget _chartCard({ + required String title, + required Widget child, + required bool isGaugeMode, + required Function() onGaugeTap, + required Function() onChartTap, + }) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF1677FF), + ), + ), + Row( + children: [ + GestureDetector( + onTap: onGaugeTap, + child: Text( + "仪表盘", + style: TextStyle( + color: isGaugeMode + ? const Color(0xFF1677FF) + : Colors.grey, + fontSize: 14, + fontWeight: isGaugeMode + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ), + const SizedBox(width: 16), + GestureDetector( + onTap: onChartTap, + child: Text( + "折线图", + style: TextStyle( + color: !isGaugeMode + ? const Color(0xFF1677FF) + : Colors.grey, + fontSize: 14, + fontWeight: !isGaugeMode + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 16), + child, + ], + ), + ); + } + + Widget _circularGauge({ + required double value, + required double maxValue, + required String unit, + List majorTicks = const [ + 0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 90, + 100, + ], + }) { + return AnimatedCircularGauge( + value: value, + maxValue: maxValue, + unit: unit, + majorTicks: majorTicks, + ); + } + + Widget _styledLineChart({ + required String title, + required List lines, + required double yAxisMax, + double yAxisMin = 0, + List bottomLabels = const ["t", "t+1", "t+2", "t+3", "t+4", "t+5"], + int yTickCount = 5, + bool forceShowZeroTick = true, + double leftTitlePadding = 8.0, + double bottomTitlePadding = 8.0, + }) { + final List yTicks = _calculateYTicks( + yAxisMin, + yAxisMax, + yTickCount, + ); + final List finalYTicks = forceShowZeroTick + ? (yTicks.contains(0) ? yTicks : [...yTicks, 0] + ..sort()) + : yTicks; + + final double yInterval = finalYTicks.length > 1 + ? (finalYTicks.last - finalYTicks.first) / (finalYTicks.length - 1) + : 1.0; + + const double xMin = 0; + const double xMax = 5; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (lines.length > 1) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _legendItem( + color: const Color(0xFF3F51B5), + label: AppLocalizations.of( + context, + ).translate('running_status.left_wheel'), + ), + const SizedBox(width: 24), + _legendItem( + color: const Color(0xFF8BC34A), + label: AppLocalizations.of( + context, + ).translate('running_status.right_wheel'), + ), + ], + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + right: 35, + top: 5, + bottom: 0, + left: 0, + ), + child: LineChart( + LineChartData( + minY: yAxisMin, + maxY: yAxisMax, + minX: xMin, + maxX: xMax, + borderData: FlBorderData(show: false), + gridData: FlGridData( + show: true, + horizontalInterval: yInterval, + getDrawingHorizontalLine: (value) { + if (value == 0) { + return const FlLine( + color: Color(0xFFCCCCCC), + strokeWidth: 1.5, + ); + } + return const FlLine( + color: Color(0xFFE5E5E5), + strokeWidth: 1, + ); + }, + drawVerticalLine: false, + ), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 50 + leftTitlePadding, + interval: yInterval, + getTitlesWidget: (value, meta) { + if (finalYTicks.any( + (tick) => (value - tick).abs() < 0.01, + )) { + return Padding( + padding: EdgeInsets.only(right: leftTitlePadding), + child: Text( + value.toStringAsFixed(0), + style: const TextStyle( + fontSize: 12, + color: Colors.grey, + ), + textAlign: TextAlign.right, + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + interval: 1, + reservedSize: 35 + bottomTitlePadding, + getTitlesWidget: (value, meta) { + int idx = value.toInt(); + if (idx >= 0 && idx < bottomLabels.length) { + return Padding( + padding: EdgeInsets.only(top: bottomTitlePadding), + child: Transform.translate( + offset: const Offset(-5, 0), + child: Text( + bottomLabels[idx], + style: const TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + lineBarsData: lines, + extraLinesData: ExtraLinesData(horizontalLines: []), + ), + ), + ), + ), + ], + ); + } + + List _calculateYTicks(double min, double max, int count) { + if (min > 0) { + min = 0; + } + + final List ticks = []; + final double step = (max - min) / (count - 1); + + for (int i = 0; i < count; i++) { + ticks.add(min + step * i); + } + + return ticks; + } + + Widget _legendItem({required Color color, required String label}) { + return Row( + children: [ + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + border: Border.all(color: color, width: 2), + ), + ), + const SizedBox(width: 4), + Text( + label, + style: const TextStyle(fontSize: 14, color: Colors.black87), + ), + ], + ); + } + + LineChartBarData _lineData(List data, Color color) { + return LineChartBarData( + spots: _getMappedSpots(data), + isCurved: false, + color: color, + barWidth: 2, + isStrokeCapRound: true, + dotData: FlDotData( + show: true, + getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter( + radius: 4, + color: color, + strokeWidth: 2, + strokeColor: Colors.white, + ), + ), + belowBarData: BarAreaData(show: false), + ); + } + + String _getLocationQualityKey(int qualValue) { + switch (qualValue) { + case 0: + return 'running_status.invalid'; + case 4: + case 5: + return 'running_status.valid'; + default: + return 'common.unknown'; + } + } +} + +class AnimatedCircularGauge extends StatefulWidget { + final double value; + final double maxValue; + final String unit; + final List majorTicks; + + const AnimatedCircularGauge({ + super.key, + required this.value, + required this.maxValue, + required this.unit, + required this.majorTicks, + }); + + @override + State createState() => _AnimatedCircularGaugeState(); +} + +class _AnimatedCircularGaugeState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _valueAnimation; + double _previousValue = 0.0; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + _previousValue = widget.value; + _valueAnimation = + Tween(begin: 0, end: widget.value).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.easeOutCubic, + ), + )..addListener(() { + setState(() {}); + }); + _animationController.forward(); + } + + @override + void didUpdateWidget(covariant AnimatedCircularGauge oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.value != oldWidget.value) { + _previousValue = _valueAnimation.value; + _valueAnimation = + Tween(begin: _previousValue, end: widget.value).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.easeOutCubic, + ), + )..addListener(() { + setState(() {}); + }); + _animationController.reset(); + _animationController.forward(); + } + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 180, + child: Stack( + alignment: Alignment.center, + children: [ + CustomPaint( + painter: CircularGaugePainter( + value: _valueAnimation.value, + maxValue: widget.maxValue, + majorTicks: widget.majorTicks, + ), + size: const Size(240, 240), + ), + Positioned( + bottom: 20, + child: Text( + "${_valueAnimation.value.toStringAsFixed(2)} ${widget.unit}", + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + color: Colors.grey, + ), + ), + ), + ], + ), + ); + } +} + +class CircularGaugePainter extends CustomPainter { + final double value; + final double maxValue; + final List majorTicks; + + CircularGaugePainter({ + required this.value, + required this.maxValue, + required this.majorTicks, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width / 2 - 20; + + final backgroundPaint = Paint() + ..color = const Color(0xFFE5E5E5) + ..style = PaintingStyle.stroke + ..strokeWidth = 12; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -math.pi * 1.25, + math.pi * 2.5, + false, + backgroundPaint, + ); + + final progress = value / maxValue; + final progressPaint = Paint() + ..color = const Color(0xFF1677FF) + ..style = PaintingStyle.stroke + ..strokeWidth = 12 + ..strokeCap = StrokeCap.round; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -math.pi * 1.25, + math.pi * 2.5 * progress, + false, + progressPaint, + ); + + final tickPaint = Paint()..color = const Color(0xFF888888); + final textStyle = const TextStyle(fontSize: 10, color: Color(0xFF888888)); + final textPainter = TextPainter(textDirection: TextDirection.ltr); + + for (int i = 0; i < majorTicks.length; i++) { + final tickValue = majorTicks[i]; + final tickProgress = tickValue / maxValue; + final angle = -math.pi * 1.25 + math.pi * 2.5 * tickProgress; + final innerRadius = radius - 20; + final outerRadius = radius - 8; + + final start = Offset( + center.dx + innerRadius * math.cos(angle), + center.dy + innerRadius * math.sin(angle), + ); + final end = Offset( + center.dx + outerRadius * math.cos(angle), + center.dy + outerRadius * math.sin(angle), + ); + + canvas.drawLine(start, end, tickPaint); + + final labelRadius = radius - 35; + final labelOffset = Offset( + center.dx + labelRadius * math.cos(angle), + center.dy + labelRadius * math.sin(angle), + ); + + textPainter.text = TextSpan(text: tickValue.toString(), style: textStyle); + textPainter.layout(); + textPainter.paint( + canvas, + Offset( + labelOffset.dx - textPainter.width / 2, + labelOffset.dy - textPainter.height / 2, + ), + ); + } + } + + @override + bool shouldRepaint(covariant CircularGaugePainter oldDelegate) { + return value != oldDelegate.value || maxValue != oldDelegate.maxValue; + } +} diff --git a/lib/components/tcp_status_indicator.dart b/lib/components/tcp_status_indicator.dart index c1a2fcc3..546ce0ad 100644 --- a/lib/components/tcp_status_indicator.dart +++ b/lib/components/tcp_status_indicator.dart @@ -5,11 +5,13 @@ import 'package:maibu_satabot_v2/core/network/tcp/tcp_status_cubit.dart'; class TcpStatusIndicator extends StatefulWidget { final double size; final bool showDisconnected; // 是否显示未连接状态 + final VoidCallback? onTap; // 点击回调 const TcpStatusIndicator({ super.key, this.size = 12.0, this.showDisconnected = false, // 默认不显示未连接状态 + this.onTap, // 点击回调 }); @override @@ -27,18 +29,19 @@ class _TcpStatusIndicatorState extends State void initState() { super.initState(); _tcpStatusCubit = GetIt.I(); - + _controller = AnimationController( duration: const Duration(milliseconds: 1500), vsync: this, ); - - _animation = Tween(begin: 0.6, end: 1.0).animate( - CurvedAnimation(parent: _controller!, curve: Curves.easeInOut), - ); - + + _animation = Tween( + begin: 0.6, + end: 1.0, + ).animate(CurvedAnimation(parent: _controller!, curve: Curves.easeInOut)); + _controller!.repeat(reverse: true); - + _tcpStatusCubit.stream.listen((state) { if (state.status != TcpConnectionStatus.disconnected) { _hasActivity = true; @@ -95,28 +98,32 @@ class _TcpStatusIndicatorState extends State return Tooltip( message: tooltip, - child: AnimatedBuilder( - animation: _animation!, - builder: (context, child) { - final opacity = shouldAnimate ? _animation!.value : 1.0; - return Container( - width: widget.size, - height: widget.size, - decoration: BoxDecoration( - color: color.withOpacity(opacity), - shape: BoxShape.circle, - boxShadow: state.status == TcpConnectionStatus.connected - ? [ - BoxShadow( - color: Colors.green.withOpacity(0.5 * opacity), - blurRadius: 6 * opacity, - spreadRadius: 2 * opacity, - ), - ] - : [], - ), - ); - }, + child: InkWell( + onTap: widget.onTap, + borderRadius: BorderRadius.circular(widget.size / 2), + child: AnimatedBuilder( + animation: _animation!, + builder: (context, child) { + final opacity = shouldAnimate ? _animation!.value : 1.0; + return Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + color: color.withOpacity(opacity), + shape: BoxShape.circle, + boxShadow: state.status == TcpConnectionStatus.connected + ? [ + BoxShadow( + color: Colors.green.withOpacity(0.5 * opacity), + blurRadius: 6 * opacity, + spreadRadius: 2 * opacity, + ), + ] + : [], + ), + ); + }, + ), ), ); } diff --git a/lib/core/consts/http_api_consts.dart b/lib/core/consts/http_api_consts.dart index 74f52695..494ca4ba 100644 --- a/lib/core/consts/http_api_consts.dart +++ b/lib/core/consts/http_api_consts.dart @@ -36,4 +36,7 @@ class HttpApiConsts { // 获取机器人列表 static const String getRobotList = "$baseUrl/iot/device/getSiteList"; + + // 获取飞行任务列表 + static const String getFlightTask = "$baseUrl/iot/UAV/getFlightTask"; } diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index c4d70eee..db39af61 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -48,6 +48,7 @@ import '../../features/devices/domain/usecases/device_work_hostrirty_usecase.dar import '../../features/devices/domain/usecases/generate_path_usecase.dart'; import '../../features/devices/domain/usecases/get_device_location_usecase.dart'; import '../../features/devices/domain/usecases/get_work_record_usecase.dart'; +import '../../features/devices/domain/usecases/get_work_records_by_site_id_usecase.dart'; import '../../features/devices/domain/usecases/route_planning_usecase.dart'; import '../../features/devices/domain/usecases/save_work_record_usecase.dart'; import '../../features/devices/domain/usecases/select_work_record_usecase.dart'; @@ -362,25 +363,26 @@ Future init() async { sl.registerLazySingleton(() => GetDeviceLocationUseCase(sl())); sl.registerLazySingleton( () => DevicesCubit( - sl(), - sl(), - sl(), - sl(), - sl(), - sl(), - sl(), - sl(), - sl(), - sl(), - sl(), - sl(), - sl(), - sl(), - sl(), + sl(), // repository + sl(), // GetUserDeviceUseCase + sl(), // GetDeviceLocationUseCase + sl(), // GetWorkRecordUseCase + sl(), // GetWorkRecordsBySiteIdUseCase (NEW) + sl(), // DeleteWorkRecordUseCase + sl(), // UnbindDeviceUseCase + sl(), // UpdateDevicenameUsecase + sl(), // SelectWorkRecordUseCase + sl(), // SaveWorkRecordUseCase + sl(), // GeneratePathUseCase + sl(), // RoutePlanningUseCase + sl(), // BindDeviceUseCase + sl(), // DeviceStatusBloc + sl(), // TcpClient + sl(), // PathPlanningService ), ); - // 🔥 DeviceStatusBloc 必须在 RemoteControlCubit 之前注册(单例) + // DeviceStatusBloc 必须在 RemoteControlCubit 之前注册(单例) sl.registerLazySingleton( () => DeviceStatusBloc( sl(), @@ -417,6 +419,11 @@ Future init() async { sl.registerLazySingleton(() => GetWorkRecordUseCase(sl())); sl.registerLazySingleton(() => DeleteWorkRecordUseCase(sl())); + /// 根据场站ID获取工作记录(XML格式) + sl.registerLazySingleton( + () => GetWorkRecordsBySiteIdUseCase(sl()), + ); + /// 6. 认证 (Auth) // --- 关键修改点 1: AuthCubit 必须在 GoRouter 之前注册,并传入参数 --- sl.registerLazySingleton( diff --git a/lib/features/devices/data/models/work_record_entity.dart b/lib/features/devices/data/models/work_record_entity.dart new file mode 100644 index 00000000..b72a1572 --- /dev/null +++ b/lib/features/devices/data/models/work_record_entity.dart @@ -0,0 +1,237 @@ +/// 工作记录实体(用于XML解析) +class WorkRecordEntity { + final String? createBy; + final String? createTime; + final String? updateBy; + final String? updateTime; + final bool delFlag; + final String? remark; + final int orgId; + final int siteId; + final int userId; + final int id; + final String workName; + final WorkRecordJsonData? jsonData; + final String? imgUrl; + + WorkRecordEntity({ + this.createBy, + this.createTime, + this.updateBy, + this.updateTime, + required this.delFlag, + this.remark, + required this.orgId, + required this.siteId, + required this.userId, + required this.id, + required this.workName, + this.jsonData, + this.imgUrl, + }); + + factory WorkRecordEntity.fromXml(Map xmlData) { + return WorkRecordEntity( + createBy: xmlData['createBy'] as String?, + createTime: xmlData['createTime'] as String?, + updateBy: xmlData['updateBy'] as String?, + updateTime: xmlData['updateTime'] as String?, + delFlag: xmlData['delFlag'] == 'true', + remark: xmlData['remark'] as String?, + orgId: int.tryParse(xmlData['orgId']?.toString() ?? '0') ?? 0, + siteId: int.tryParse(xmlData['siteId']?.toString() ?? '0') ?? 0, + userId: int.tryParse(xmlData['userId']?.toString() ?? '0') ?? 0, + id: int.tryParse(xmlData['id']?.toString() ?? '0') ?? 0, + workName: xmlData['workName'] as String? ?? '', + jsonData: xmlData['jsonData'] != null + ? WorkRecordJsonData.fromXml( + xmlData['jsonData'] as Map, + ) + : null, + imgUrl: xmlData['imgUrl'] as String?, + ); + } + + factory WorkRecordEntity.fromJson(Map json) { + return WorkRecordEntity( + createBy: json['createBy'] as String?, + createTime: json['createTime'] as String?, + updateBy: json['updateBy'] as String?, + updateTime: json['updateTime'] as String?, + delFlag: json['delFlag'] == true || json['delFlag'] == 'true', + remark: json['remark'] as String?, + orgId: _parseInt(json['orgId']), + siteId: _parseInt(json['siteId']), + userId: _parseInt(json['userId']), + id: _parseInt(json['id']), + workName: json['workName'] as String? ?? '', + jsonData: json['jsonData'] != null + ? WorkRecordJsonData.fromJson( + json['jsonData'] as Map, + ) + : null, + imgUrl: json['imgUrl'] as String?, + ); + } + + static int _parseInt(dynamic value) { + if (value == null) return 0; + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value) ?? 0; + return 0; + } +} + +/// 工作记录JSON数据 +class WorkRecordJsonData { + final String? name; + final List>? path; + final List>? outer; + final String? img; + final int? planModel; + + WorkRecordJsonData({ + this.name, + this.path, + this.outer, + this.img, + this.planModel, + }); + + factory WorkRecordJsonData.fromXml(Map xmlData) { + return WorkRecordJsonData( + name: xmlData['name'] as String?, + path: _parsePathList(xmlData['path']), + outer: _parseOuterList(xmlData['outer']), + img: xmlData['img'] as String?, + planModel: int.tryParse(xmlData['planModel']?.toString() ?? '0'), + ); + } + + factory WorkRecordJsonData.fromJson(Map json) { + return WorkRecordJsonData( + name: json['name'] as String?, + path: _parsePathListJson(json['path']), + outer: _parseOuterListJson(json['outer']), + img: json['img'] as String?, + planModel: (json['planModel'] as num?)?.toInt(), + ); + } + + static List>? _parsePathList(dynamic pathData) { + if (pathData == null) return null; + + // path 可能是 List 或 Map + if (pathData is List) { + return pathData.map((item) { + if (item is Map) { + return { + 'lat': double.tryParse(item['lat']?.toString() ?? '0') ?? 0.0, + 'lng': double.tryParse(item['lng']?.toString() ?? '0') ?? 0.0, + }; + } + return {}; + }).toList(); + } else if (pathData is Map) { + // 如果是单个 Map,检查是否包含 path 子节点 + if (pathData.containsKey('path')) { + final pathList = pathData['path']; + if (pathList is List) { + return pathList.map((item) { + if (item is Map) { + return { + 'lat': double.tryParse(item['lat']?.toString() ?? '0') ?? 0.0, + 'lng': double.tryParse(item['lng']?.toString() ?? '0') ?? 0.0, + }; + } + return {}; + }).toList(); + } + } + // 直接是单个 path 节点 + return [ + { + 'lat': double.tryParse(pathData['lat']?.toString() ?? '0') ?? 0.0, + 'lng': double.tryParse(pathData['lng']?.toString() ?? '0') ?? 0.0, + }, + ]; + } + return null; + } + + static List>? _parseOuterList(dynamic outerData) { + if (outerData == null) return null; + + // outer 可能是 List 或 Map + if (outerData is List) { + return outerData.map((item) { + if (item is Map) { + return { + 'lat': double.tryParse(item['lat']?.toString() ?? '0') ?? 0.0, + 'lng': double.tryParse(item['lng']?.toString() ?? '0') ?? 0.0, + }; + } + return {}; + }).toList(); + } else if (outerData is Map) { + // 如果是单个 Map,检查是否包含 outer 子节点 + if (outerData.containsKey('outer')) { + final outerList = outerData['outer']; + if (outerList is List) { + return outerList.map((item) { + if (item is Map) { + return { + 'lat': double.tryParse(item['lat']?.toString() ?? '0') ?? 0.0, + 'lng': double.tryParse(item['lng']?.toString() ?? '0') ?? 0.0, + }; + } + return {}; + }).toList(); + } + } + // 直接是单个 outer 节点 + return [ + { + 'lat': double.tryParse(outerData['lat']?.toString() ?? '0') ?? 0.0, + 'lng': double.tryParse(outerData['lng']?.toString() ?? '0') ?? 0.0, + }, + ]; + } + return null; + } + + /// JSON格式解析path + static List>? _parsePathListJson(dynamic pathData) { + if (pathData == null) return null; + if (pathData is List) { + return pathData.map((item) { + if (item is Map) { + return { + 'lat': (item['lat'] as num?)?.toDouble() ?? 0.0, + 'lng': (item['lng'] as num?)?.toDouble() ?? 0.0, + }; + } + return {}; + }).toList(); + } + return null; + } + + /// JSON格式解析outer + static List>? _parseOuterListJson(dynamic outerData) { + if (outerData == null) return null; + if (outerData is List) { + return outerData.map((item) { + if (item is Map) { + return { + 'lat': (item['lat'] as num?)?.toDouble() ?? 0.0, + 'lng': (item['lng'] as num?)?.toDouble() ?? 0.0, + }; + } + return {}; + }).toList(); + } + return null; + } +} diff --git a/lib/features/devices/data/repositories/generate_path_repository_Impl.dart b/lib/features/devices/data/repositories/generate_path_repository_Impl.dart index 0aab7a6a..82b41e71 100644 --- a/lib/features/devices/data/repositories/generate_path_repository_Impl.dart +++ b/lib/features/devices/data/repositories/generate_path_repository_Impl.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:http/http.dart' as http; import 'dart:convert'; +import 'package:xml/xml.dart'; import '../../../../core/di/injection.dart'; import '../../../../core/storage/user_storage.dart'; @@ -9,10 +10,12 @@ import '../../domain/repositories/path_repository.dart'; import '../datasources/path_http_datasource.dart'; import '../models/device_add_path_point_model.dart'; import '../models/device_work_area_param_model.dart'; +import '../models/work_record_entity.dart'; class PathRepositoryImpl implements PathRepository { final PathHttpDatasource _datasource; - PathRepositoryImpl({required PathHttpDatasource datasource}) : _datasource = datasource; + PathRepositoryImpl({required PathHttpDatasource datasource}) + : _datasource = datasource; // 生成路径 @override Future> generatePath({ @@ -38,10 +41,18 @@ class PathRepositoryImpl implements PathRepository { // 保存工作记录 @override - Future> saveWorkRecord({required String workName, required String userId, required String jsonData}) async { + Future> saveWorkRecord({ + required String workName, + required String userId, + required String jsonData, + }) async { final url = Uri.parse('https://serviceri.satabot.com/iot/workRecord/add'); final headers = {'Content-Type': 'application/json'}; - final body = jsonEncode({'workName': workName, 'userId': userId, 'jsonData': jsonData}); + final body = jsonEncode({ + 'workName': workName, + 'userId': userId, + 'jsonData': jsonData, + }); try { final response = await http.post(url, headers: headers, body: body); @@ -57,7 +68,9 @@ class PathRepositoryImpl implements PathRepository { } @override - Future>> getWorkRecord({required String userId}) async { + Future>> getWorkRecord({ + required String userId, + }) async { final timestamp = DateTime.now().millisecondsSinceEpoch; final url = Uri.parse( 'https://serviceri.satabot.com/iot/workRecord/selectByUserId', @@ -73,7 +86,9 @@ class PathRepositoryImpl implements PathRepository { throw Exception('API error: ${data['msg'] ?? 'Unknown'}'); } } else { - throw Exception('HTTP ${response.statusCode}: ${response.reasonPhrase}'); + throw Exception( + 'HTTP ${response.statusCode}: ${response.reasonPhrase}', + ); } } catch (e) { throw Exception('Network error in getWorkRecord: $e'); @@ -81,11 +96,16 @@ class PathRepositoryImpl implements PathRepository { } @override - Future> deleteWorkRecord({required String workName}) async { + Future> deleteWorkRecord({ + required String workName, + }) async { final timestamp = DateTime.now().millisecondsSinceEpoch; - final url = Uri.parse( - 'https://serviceri.satabot.com/iot/workRecord/deleteByWorkName', - ).replace(queryParameters: {'workName': workName, '_t': timestamp.toString()}); + final url = + Uri.parse( + 'https://serviceri.satabot.com/iot/workRecord/deleteByWorkName', + ).replace( + queryParameters: {'workName': workName, '_t': timestamp.toString()}, + ); try { final response = await http.get(url); @@ -94,7 +114,9 @@ class PathRepositoryImpl implements PathRepository { if (response.statusCode == 200 && data['code'] == 200) { return data; // 返回 {"code": 200, "msg": "删除成功"} } else { - throw Exception('Delete failed: ${data['msg'] ?? response.reasonPhrase}'); + throw Exception( + 'Delete failed: ${data['msg'] ?? response.reasonPhrase}', + ); } } catch (e) { throw Exception('Network error in deleteWorkRecord: $e'); @@ -103,11 +125,16 @@ class PathRepositoryImpl implements PathRepository { /// 选择工作记录 @override - Future>> selectWorkRecordByName({required String workName}) async { + Future>> selectWorkRecordByName({ + required String workName, + }) async { final timestamp = DateTime.now().millisecondsSinceEpoch; - final url = Uri.parse( - 'https://serviceri.satabot.com/iot/workRecord/selectByWorkName', - ).replace(queryParameters: {'workName': workName, '_t': timestamp.toString()}); + final url = + Uri.parse( + 'https://serviceri.satabot.com/iot/workRecord/selectByWorkName', + ).replace( + queryParameters: {'workName': workName, '_t': timestamp.toString()}, + ); try { final response = await http.get(url); @@ -131,7 +158,9 @@ class PathRepositoryImpl implements PathRepository { } else if (rawData is Map) { records = [Map.from(rawData)]; } else { - throw Exception('Unexpected data type for "data": ${rawData.runtimeType}'); + throw Exception( + 'Unexpected data type for "data": ${rawData.runtimeType}', + ); } return records; @@ -139,10 +168,228 @@ class PathRepositoryImpl implements PathRepository { throw Exception('API error: ${data['msg'] ?? 'Unknown'}'); } } else { - throw Exception('HTTP ${response.statusCode}: ${response.reasonPhrase}'); + throw Exception( + 'HTTP ${response.statusCode}: ${response.reasonPhrase}', + ); } } catch (e) { throw Exception('Network error in selectWorkRecordByName: $e'); } } + + /// 根据场站ID查询工作记录列表(XML格式) + @override + Future> getWorkRecordsBySiteId({ + required int siteId, + }) async { + final timestamp = DateTime.now().millisecondsSinceEpoch; + final url = + Uri.parse( + 'http://1.95.137.212:59015/iot/workRecord/selectBySiteId', + ).replace( + queryParameters: { + 'siteId': siteId.toString(), + '_t': timestamp.toString(), + }, + ); + + try { + final response = await http.get( + url, + headers: {'Accept': 'application/xml, text/xml, */*'}, + ); + print('[XML接口] 响应状态码: ${response.statusCode}'); + print('[XML接口] 响应内容长度: ${response.body.length}'); + print('[XML接口] Content-Type: ${response.headers['content-type']}'); + + if (response.statusCode == 200) { + // 打印前200字符确认格式 + final preview = response.body.length > 200 + ? response.body.substring(0, 200) + : response.body; + print('[XML接口] 响应开头: $preview'); + + // 判断是JSON还是XML格式 + final trimmed = response.body.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + print('[XML接口] 检测到JSON格式,使用JSON解析'); + return _parseJsonResponse(response.body); + } else { + print('[XML接口] 检测到XML格式,使用XML解析'); + return _parseXmlResponse(response.body); + } + } else { + throw Exception( + 'HTTP ${response.statusCode}: ${response.reasonPhrase}', + ); + } + } catch (e) { + print('[XML接口] 错误: $e'); + throw Exception('Network error in getWorkRecordsBySiteId: $e'); + } + } + + /// 解析JSON格式响应 + Future> _parseJsonResponse(String body) async { + final data = jsonDecode(body) as Map; + + if (data['code'] != 200) { + throw Exception('API error: ${data['msg'] ?? 'Unknown'}'); + } + + final recordsData = data['data']; + if (recordsData == null) return []; + + final List records = []; + + if (recordsData is List) { + for (final item in recordsData) { + if (item is Map) { + records.add(WorkRecordEntity.fromJson(item)); + } + } + } else if (recordsData is Map) { + records.add(WorkRecordEntity.fromJson(recordsData)); + } + + print('[XML接口] 最终解析记录数: ${records.length}'); + return records; + } + + /// 解析XML格式响应 + Future> _parseXmlResponse(String body) async { + // 用更宽松的正则检查响应码(支持命名空间前缀) + final codeMatch = RegExp( + r'<\w*:?code[^>]*>(\d+)', + ).firstMatch(body); + final code = codeMatch?.group(1); + print('[XML接口] code: $code'); + + if (code != '200') { + final msgMatch = RegExp( + r'<\w*:?msg[^>]*>(.*?)', + ).firstMatch(body); + throw Exception('API error: ${msgMatch?.group(1) ?? 'Unknown'}'); + } + + // 使用正则表达式提取所有...节点 + // 使用非贪婪匹配,确保每个data节点独立提取 + final dataRegex = RegExp(r'([\s\S]*?)'); + final dataMatches = dataRegex.allMatches(body); + print('[XML接口] 找到data节点数量: ${dataMatches.length}'); + + // 解析所有工作记录 + final List records = []; + + for (int i = 0; i < dataMatches.length; i++) { + final match = dataMatches.elementAt(i); + final dataContent = match.group(1)!; // 获取和之间的内容 + + try { + // 将提取的内容包装成完整XML进行解析 + final wrappedXml = '$dataContent'; + final document = XmlDocument.parse(wrappedXml); + final recordElement = document.rootElement; + + final recordData = _parseXmlRecord(recordElement); + print( + '[XML接口] data[$i] 解析结果: workName=${recordData['workName']}, id=${recordData['id']}, imgUrl=${recordData['imgUrl']}', + ); + + records.add(WorkRecordEntity.fromXml(recordData)); + } catch (e) { + print('[XML接口] 解析单个data节点失败: $e'); + } + } + + print('[XML接口] 最终解析记录数: ${records.length}'); + return records; + } + + /// 解析XML工作记录节点 + Map _parseXmlRecord(XmlElement recordElement) { + final Map result = {}; + + print('[XML解析] 开始解析节点,子元素数量: ${recordElement.childElements.length}'); + + for (final child in recordElement.childElements) { + final tagName = child.name.local; + final innerText = child.innerText.trim(); + + print( + '[XML解析] 标签: $tagName, 值: ${innerText.length > 50 ? innerText.substring(0, 50) + '...' : innerText}', + ); + + // 特殊处理jsonData节点(包含嵌套结构) + if (tagName == 'jsonData') { + result['jsonData'] = _parseJsonDataNode(child); + } else { + // 普通节点直接取值 + result[tagName] = innerText; + } + } + + print('[XML解析] 解析完成,结果keys: ${result.keys.toList()}'); + return result; + } + + /// 解析jsonData节点 + Map _parseJsonDataNode(XmlElement jsonDataElement) { + final Map result = {}; + + for (final child in jsonDataElement.childElements) { + final tagName = child.name.local; + + if (tagName == 'path' || tagName == 'outer') { + // path和outer可能包含嵌套的path/outer节点 + result[tagName] = _parseCoordinateList(child, tagName); + } else { + // 普通字段(name, img, planModel等) + result[tagName] = child.innerText.trim(); + } + } + + return result; + } + + /// 解析坐标列表(path或outer) + List> _parseCoordinateList( + XmlElement element, + String tagName, + ) { + final List> coordinates = []; + + // 检查是否有嵌套的同名节点 + final nestedElements = element.childElements + .where((e) => e.name.local == tagName) + .toList(); + + if (nestedElements.isNotEmpty) { + // 有嵌套结构:outer > outer > {lat, lng} + for (final nestedElement in nestedElements) { + final latElement = nestedElement.getElement('lat'); + final lngElement = nestedElement.getElement('lng'); + + if (latElement != null && lngElement != null) { + coordinates.add({ + 'lat': double.tryParse(latElement.innerText.trim()) ?? 0.0, + 'lng': double.tryParse(lngElement.innerText.trim()) ?? 0.0, + }); + } + } + } else { + // 直接包含lat/lng节点 + final latElement = element.getElement('lat'); + final lngElement = element.getElement('lng'); + + if (latElement != null && lngElement != null) { + coordinates.add({ + 'lat': double.tryParse(latElement.innerText.trim()) ?? 0.0, + 'lng': double.tryParse(lngElement.innerText.trim()) ?? 0.0, + }); + } + } + + return coordinates; + } } diff --git a/lib/features/devices/domain/repositories/path_repository.dart b/lib/features/devices/domain/repositories/path_repository.dart index 20cb0538..69fee637 100644 --- a/lib/features/devices/domain/repositories/path_repository.dart +++ b/lib/features/devices/domain/repositories/path_repository.dart @@ -1,5 +1,6 @@ import '../../data/models/device_add_path_point_model.dart'; import '../../data/models/device_work_area_param_model.dart'; +import '../../data/models/work_record_entity.dart'; abstract class PathRepository { // Generate path(打点生成路径规划) @@ -20,6 +21,9 @@ abstract class PathRepository { // Delete work record (删除) Future> deleteWorkRecord({required String workName}); - /// 根据作业名查询路径记录(用于“选择一个路径”) + /// 根据作业名查询路径记录(用于"选择一个路径") Future>> selectWorkRecordByName({required String workName}); + + /// 根据场站ID查询工作记录列表(XML格式) + Future> getWorkRecordsBySiteId({required int siteId}); } diff --git a/lib/features/devices/domain/usecases/get_work_records_by_site_id_usecase.dart b/lib/features/devices/domain/usecases/get_work_records_by_site_id_usecase.dart new file mode 100644 index 00000000..aaf3bc9f --- /dev/null +++ b/lib/features/devices/domain/usecases/get_work_records_by_site_id_usecase.dart @@ -0,0 +1,19 @@ +import 'package:fpdart/fpdart.dart'; +import '../../data/models/work_record_entity.dart'; +import '../../domain/errors/device_failure.dart'; +import '../../domain/repositories/path_repository.dart'; + +class GetWorkRecordsBySiteIdUseCase { + final PathRepository repository; + + GetWorkRecordsBySiteIdUseCase(this.repository); + + Future>> call(int siteId) async { + try { + final result = await repository.getWorkRecordsBySiteId(siteId: siteId); + return Right(result); + } catch (e) { + return Left(DeviceFailure.networkError(message: e.toString())); + } + } +} diff --git a/lib/features/devices/presentation/bloc/devices_cubit.dart b/lib/features/devices/presentation/bloc/devices_cubit.dart index 26a1f4f9..9a21ca06 100644 --- a/lib/features/devices/presentation/bloc/devices_cubit.dart +++ b/lib/features/devices/presentation/bloc/devices_cubit.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:collection'; import 'package:flutter/rendering.dart'; @@ -7,6 +8,7 @@ import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity. import 'package:maibu_satabot_v2/features/devices/domain/repositories/device_repository.dart'; import 'package:maibu_satabot_v2/features/devices/domain/usecases/generate_path_usecase.dart'; import 'package:maibu_satabot_v2/features/devices/domain/usecases/get_user_device_usecase.dart'; +import 'package:maibu_satabot_v2/features/devices/data/models/work_record_entity.dart'; import 'package:maibu_satabot_v2/features/devices/domain/usecases/save_work_record_usecase.dart'; import 'package:maibu_satabot_v2/features/devices/domain/usecases/select_work_record_usecase.dart'; import 'package:maibu_satabot_v2/features/devices/domain/usecases/unbind_device_usecase.dart'; @@ -21,6 +23,7 @@ import '../../domain/usecases/bind_device_usecase.dart'; import '../../domain/usecases/delete_work_record_usecase.dart'; import '../../domain/usecases/get_device_location_usecase.dart'; import '../../domain/usecases/get_work_record_usecase.dart'; +import '../../domain/usecases/get_work_records_by_site_id_usecase.dart'; import '../../domain/usecases/route_planning_usecase.dart'; import '../../services/path_planning_service.dart'; import 'device_status_bloc.dart'; @@ -32,6 +35,7 @@ class DevicesCubit extends Cubit { final GetDeviceLocationUseCase _getDeviceLocationUseCase; final DeviceRepository repository; final GetWorkRecordUseCase _getWorkRecordUseCase; + final GetWorkRecordsBySiteIdUseCase _getWorkRecordsBySiteIdUseCase; final DeleteWorkRecordUseCase _deleteWorkRecordUseCase; final BindDeviceUseCase _bindDeviceUseCase; final UnbindDeviceUseCase _unbindDeviceUseCase; @@ -52,6 +56,7 @@ class DevicesCubit extends Cubit { this._getUserDeviceUseCase, this._getDeviceLocationUseCase, this._getWorkRecordUseCase, + this._getWorkRecordsBySiteIdUseCase, this._deleteWorkRecordUseCase, this._unbindDeviceUseCase, this._updateDevicename, @@ -66,7 +71,13 @@ class DevicesCubit extends Cubit { ) : super(const DevicesState()); Future unbindDevice(String deviceId, String deviceName) async { - emit(state.copyWith(isLoading: true, errorMessage: '', operationType: DeviceOperationType.unbind)); + emit( + state.copyWith( + isLoading: true, + errorMessage: '', + operationType: DeviceOperationType.unbind, + ), + ); try { final params = UnbindDeviceParams(deviceId, deviceName); @@ -92,55 +103,94 @@ class DevicesCubit extends Cubit { state.copyWith( isLoading: false, devices: updatedDevices, - selectedDevice: state.selectedDevice?.deviceName == deviceId ? null : state.selectedDevice, + selectedDevice: state.selectedDevice?.deviceName == deviceId + ? null + : state.selectedDevice, errorMessage: '', operationType: DeviceOperationType.none, ), ); } else { - emit(state.copyWith(isLoading: false, errorMessage: '解绑失败:状态码 $successCode', operationType: DeviceOperationType.none)); + emit( + state.copyWith( + isLoading: false, + errorMessage: '解绑失败:状态码 $successCode', + operationType: DeviceOperationType.none, + ), + ); } }, ); } catch (e) { - emit(state.copyWith(isLoading: false, errorMessage: '解绑异常:${e.toString()}', operationType: DeviceOperationType.none)); + emit( + state.copyWith( + isLoading: false, + errorMessage: '解绑异常:${e.toString()}', + operationType: DeviceOperationType.none, + ), + ); } } Future updateDeviceName(String deviceId, String deviceName) async { - emit(state.copyWith(isLoading: true, errorMessage: '', operationType: DeviceOperationType.updateName)); + emit( + state.copyWith( + isLoading: true, + errorMessage: '', + operationType: DeviceOperationType.updateName, + ), + ); try { final params = UpdateDevicenameParams(deviceId, deviceName); final result = await _updateDevicename.call(params); - result.fold((failure) => emit(state.copyWith(isLoading: false, errorMessage: failure.message ?? '更新设备名称失败', operationType: DeviceOperationType.none)), ( - successCode, - ) { - // 核心修复:int 转 bool 条件判断 - //print('更新设备名称结果代码: $successCode'); // 调试输出结果代码 - _logger.logWithLevel('更新设备名称结果代码: $successCode'); - final isSuccess = successCode == 1; // 显式转为 bool - if (isSuccess) { - //final updatedDevices = state.devices?.map((device) { - // return device.deviceName == deviceId ? device.copyWith(deviceName: deviceName) : device; - //}).toList(); + result.fold( + (failure) => emit( + state.copyWith( + isLoading: false, + errorMessage: failure.message ?? '更新设备名称失败', + operationType: DeviceOperationType.none, + ), + ), + (successCode) { + // 核心修复:int 转 bool 条件判断 + //print('更新设备名称结果代码: $successCode'); // 调试输出结果代码 + _logger.logWithLevel('更新设备名称结果代码: $successCode'); + final isSuccess = successCode == 1; // 显式转为 bool + if (isSuccess) { + //final updatedDevices = state.devices?.map((device) { + // return device.deviceName == deviceId ? device.copyWith(deviceName: deviceName) : device; + //}).toList(); - emit( - state.copyWith( - isLoading: false, - //devices: updatedDevices, - //selectedDevice: state.selectedDevice?.deviceName == deviceId ? state.selectedDevice?.copyWith(deviceName: deviceName) : state.selectedDevice, - errorMessage: '', - operationType: DeviceOperationType.none, - ), - ); - } else { - emit(state.copyWith(isLoading: false, errorMessage: '更新设备名称失败:状态码 $successCode', operationType: DeviceOperationType.none)); - } - }); + emit( + state.copyWith( + isLoading: false, + //devices: updatedDevices, + //selectedDevice: state.selectedDevice?.deviceName == deviceId ? state.selectedDevice?.copyWith(deviceName: deviceName) : state.selectedDevice, + errorMessage: '', + operationType: DeviceOperationType.none, + ), + ); + } else { + emit( + state.copyWith( + isLoading: false, + errorMessage: '更新设备名称失败:状态码 $successCode', + operationType: DeviceOperationType.none, + ), + ); + } + }, + ); } catch (e) { - emit(state.copyWith(isLoading: false, errorMessage: '更新设备名称异常:${e.toString()}', operationType: DeviceOperationType.none)); + emit( + state.copyWith( + isLoading: false, + errorMessage: '更新设备名称异常:${e.toString()}', + operationType: DeviceOperationType.none, + ), + ); } } @@ -154,32 +204,39 @@ class DevicesCubit extends Cubit { final String? oldSelectedDeviceName = state.selectedDevice?.deviceName; // 网络请求获取新列表 - var resultEither = await _getUserDeviceUseCase.call(GetUserDeviceParams(username)); + var resultEither = await _getUserDeviceUseCase.call( + GetUserDeviceParams(username), + ); - resultEither.fold((failure) => emit(state.copyWith(isLoading: false, errorMessage: failure.message)), (deviceList) { - // 🔥 关键步骤2:匹配新列表中对应的旧选中设备 - DeviceEntity? newSelectedDevice; - if (oldSelectedDeviceName != null && deviceList.isNotEmpty) { - // 在新列表中查找和旧选中设备名称一致的设备 - newSelectedDevice = deviceList.firstWhere( - (device) => device.deviceName == oldSelectedDeviceName, - // 如果找不到(如设备已解绑),返回 null - orElse: () => deviceList.first, // 兜底:选中第一个 + resultEither.fold( + (failure) => emit( + state.copyWith(isLoading: false, errorMessage: failure.message), + ), + (deviceList) { + // 🔥 关键步骤2:匹配新列表中对应的旧选中设备 + DeviceEntity? newSelectedDevice; + if (oldSelectedDeviceName != null && deviceList.isNotEmpty) { + // 在新列表中查找和旧选中设备名称一致的设备 + newSelectedDevice = deviceList.firstWhere( + (device) => device.deviceName == oldSelectedDeviceName, + // 如果找不到(如设备已解绑),返回 null + orElse: () => deviceList.first, // 兜底:选中第一个 + ); + } else { + // 无旧选中设备,默认选中第一个 + newSelectedDevice = deviceList.isNotEmpty ? deviceList.first : null; + } + + // 🔥 关键步骤3:更新状态,使用匹配后的选中设备 + emit( + state.copyWith( + devices: deviceList, + selectedDevice: newSelectedDevice, // 保留旧选中设备 + isLoading: false, + ), ); - } else { - // 无旧选中设备,默认选中第一个 - newSelectedDevice = deviceList.isNotEmpty ? deviceList.first : null; - } - - // 🔥 关键步骤3:更新状态,使用匹配后的选中设备 - emit( - state.copyWith( - devices: deviceList, - selectedDevice: newSelectedDevice, // 保留旧选中设备 - isLoading: false, - ), - ); - }); + }, + ); } catch (e) { emit(state.copyWith(isLoading: false, errorMessage: e.toString())); } @@ -197,7 +254,10 @@ class DevicesCubit extends Cubit { }).toList(); // 如果更新的是当前选中的设备,也要同步更新 selectedDevice - final newSelected = state.selectedDevice?.deviceName == updatedDevice.deviceName ? updatedDevice : state.selectedDevice; + final newSelected = + state.selectedDevice?.deviceName == updatedDevice.deviceName + ? updatedDevice + : state.selectedDevice; emit(state.copyWith(devices: newList, selectedDevice: newSelected)); } @@ -233,7 +293,11 @@ class DevicesCubit extends Cubit { // connect 方法内部会自动调用 _sendAuthPacket -> 获取设备列表 -> 自动订阅当前选中的设备 // debugPrint('🔌 开始重新新连接 TCP,将自动订阅新设备:${device.deviceName}'); _logger.logWithLevel('🔌 启动重新新连接 TCP,将自动订阅新设备:${device.deviceName}'); - await _tcpClient.connectBySwitch(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT, deviceName: device.deviceName); + await _tcpClient.connectBySwitch( + host: TCPConsts.TCP_IP, + port: TCPConsts.TCP_PORT, + deviceName: device.deviceName, + ); // 🔥 关键修复 3:重置设备状态 Bloc,清除旧设备图表数据 _deviceStatusBloc.add(DeviceStatusReset()); @@ -257,7 +321,12 @@ class DevicesCubit extends Cubit { result.fold( // 失败处理 (failure) { - emit(state.copyWith(isLoading: false, errorMessage: failure.message ?? '绑定设备失败')); + emit( + state.copyWith( + isLoading: false, + errorMessage: failure.message ?? '绑定设备失败', + ), + ); // 抛出异常,携带后端返回的错误消息(如“设备不存在”) throw Exception(failure.message ?? '绑定设备失败'); }, @@ -268,7 +337,12 @@ class DevicesCubit extends Cubit { // 绑定成功后刷新设备列表 //fetchAllDevices(state.?.username ?? ''); } else { - emit(state.copyWith(isLoading: false, errorMessage: '绑定失败:状态码 $successCode')); + emit( + state.copyWith( + isLoading: false, + errorMessage: '绑定失败:状态码 $successCode', + ), + ); } }, ); @@ -287,8 +361,15 @@ class DevicesCubit extends Cubit { emit(state.copyWith(isLoading: true)); final result = await _getDeviceLocationUseCase.call(device.deviceName); result.fold( - (failure) => emit(state.copyWith(isLoading: false, errorMessage: failure.message)), - (location) => emit(state.copyWith(isLoading: false, deviceLatitude: location.latitude, deviceLongitude: location.longitude)), + (failure) => + emit(state.copyWith(isLoading: false, errorMessage: failure.message)), + (location) => emit( + state.copyWith( + isLoading: false, + deviceLatitude: location.latitude, + deviceLongitude: location.longitude, + ), + ), ); } @@ -297,18 +378,71 @@ class DevicesCubit extends Cubit { emit(state.copyWith(isLoading: true)); final result = await _getWorkRecordUseCase(userId); result.fold( - (failure) => emit(state.copyWith(isLoading: false, errorMessage: failure.message)), + (failure) => + emit(state.copyWith(isLoading: false, errorMessage: failure.message)), (records) => emit(state.copyWith(isLoading: false, workRecords: records)), ); } + /// 根据场站ID获取工作记录(XML格式接口) + Future loadWorkRecordsBySiteId(int siteId) async { + print('🔍 [DevicesCubit] 开始加载场站ID=$siteId的工作记录'); + emit(state.copyWith(isLoading: true)); + final result = await _getWorkRecordsBySiteIdUseCase(siteId); + result.fold( + (failure) { + print('❌ [DevicesCubit] 加载失败: ${failure.message}'); + emit(state.copyWith(isLoading: false, errorMessage: failure.message)); + }, + (records) { + print('🔍 [DevicesCubit] 加载成功,记录数: ${records.length}'); + // 将 WorkRecordEntity 转换为 Map 以兼容现有UI + final mappedRecords = records.map((record) { + return { + 'id': record.id.toString(), + 'workName': record.workName, + 'imgUrl': record.imgUrl ?? '', + 'jsonData': record.jsonData != null + ? _workRecordJsonDataToJson(record.jsonData!) + : null, + }; + }).toList(); + print('🔍 [DevicesCubit] 转换后的数据: $mappedRecords'); + emit(state.copyWith(isLoading: false, workRecords: mappedRecords)); + }, + ); + } + + /// 将 WorkRecordJsonData 转换为 JSON 字符串 + String _workRecordJsonDataToJson(dynamic jsonData) { + // 将 jsonData 对象序列化为 JSON 字符串供UI使用 + if (jsonData is WorkRecordJsonData) { + return jsonEncode({ + 'name': jsonData.name, + 'path': jsonData.path, + 'outer': jsonData.outer, + 'img': jsonData.img, + 'planModel': jsonData.planModel, + }); + } + return jsonData.toString(); + } + /// 删除工作记录 Future deleteWorkRecord(String workName) async { emit(state.copyWith(isLoading: true)); final result = await _deleteWorkRecordUseCase(workName); result.fold( - (failure) => emit(state.copyWith(isLoading: false, errorMessage: failure.message)), - (_) => emit(state.copyWith(isLoading: false, workRecords: state.workRecords?.where((record) => record != workName).toList())), + (failure) => + emit(state.copyWith(isLoading: false, errorMessage: failure.message)), + (_) => emit( + state.copyWith( + isLoading: false, + workRecords: state.workRecords + ?.where((record) => record != workName) + .toList(), + ), + ), ); } @@ -318,7 +452,9 @@ class DevicesCubit extends Cubit { try { final result = await _selectWorkRecordUseCase.call(workName); result.fold( - (failure) => emit(state.copyWith(isLoading: false, errorMessage: failure.message)), + (failure) => emit( + state.copyWith(isLoading: false, errorMessage: failure.message), + ), (data) => emit(state.copyWith(isLoading: false, pathData: data)), ); } catch (e) { @@ -327,10 +463,22 @@ class DevicesCubit extends Cubit { } /// 保存路径数据 - Future saveWorkRecord(String workName, String userId, String jsonData) async { + Future saveWorkRecord( + String workName, + String userId, + String jsonData, + ) async { emit(state.copyWith(isLoading: true)); - final result = await _saveWorkRecordUseCase.call(workName: workName, userId: userId, jsonData: jsonData); - result.fold((failure) => emit(state.copyWith(isLoading: false, errorMessage: failure.message)), (data) => emit(state.copyWith(isLoading: false))); + final result = await _saveWorkRecordUseCase.call( + workName: workName, + userId: userId, + jsonData: jsonData, + ); + result.fold( + (failure) => + emit(state.copyWith(isLoading: false, errorMessage: failure.message)), + (data) => emit(state.copyWith(isLoading: false)), + ); } /// generatePath @@ -346,13 +494,24 @@ class DevicesCubit extends Cubit { // debugPrint('第${index + 1}组holes:$pointsStr'); //}); emit(state.copyWith(isLoading: true)); - final result = await _generatePathUseCase.execute(reference: reference, heading: heading, outer: outer, holes: holes, workType: workType); + final result = await _generatePathUseCase.execute( + reference: reference, + heading: heading, + outer: outer, + holes: holes, + workType: workType, + ); - result.fold((failure) => emit(state.copyWith(errorMessage: failure.message)), (pathData) => emit(state.copyWith(generatedPath: pathData))); + result.fold( + (failure) => emit(state.copyWith(errorMessage: failure.message)), + (pathData) => emit(state.copyWith(generatedPath: pathData)), + ); } // 开始路径规划 - Future startRoutePlanning(Queue locationQueue) async { + Future startRoutePlanning( + Queue locationQueue, + ) async { /// print("cubit层开始路径规划"); _logger.logWithLevel('开始路径规划'); // 清空全局 Service 中的队列 @@ -373,7 +532,12 @@ class DevicesCubit extends Cubit { _logger.logWithLevel('从 Service 获取的队列长度:${queue.length}'); final result = await _routePlanningUseCase.startRoutePlanning(queue); result.fold( - (failure) => emit(state.copyWith(isLoading: false, errorMessage: failure.message ?? '路径规划启动失败')), + (failure) => emit( + state.copyWith( + isLoading: false, + errorMessage: failure.message ?? '路径规划启动失败', + ), + ), (_) => emit(state.copyWith(isLoading: false, errorMessage: '')), ); } @@ -382,14 +546,30 @@ class DevicesCubit extends Cubit { Future pauseRoutePlanning() async { emit(state.copyWith(isLoading: true)); final result = await _routePlanningUseCase.pauseRPWork(); - result.fold((failure) => emit(state.copyWith(isLoading: false, errorMessage: failure.message ?? '暂停失败')), (_) => emit(state.copyWith(isLoading: false))); + result.fold( + (failure) => emit( + state.copyWith( + isLoading: false, + errorMessage: failure.message ?? '暂停失败', + ), + ), + (_) => emit(state.copyWith(isLoading: false)), + ); } // 恢复 Future resumeRoutePlanning() async { emit(state.copyWith(isLoading: true)); final result = await _routePlanningUseCase.resumeRPWork(); - result.fold((failure) => emit(state.copyWith(isLoading: false, errorMessage: failure.message ?? '恢复失败')), (_) => emit(state.copyWith(isLoading: false))); + result.fold( + (failure) => emit( + state.copyWith( + isLoading: false, + errorMessage: failure.message ?? '恢复失败', + ), + ), + (_) => emit(state.copyWith(isLoading: false)), + ); } // 停止 @@ -399,7 +579,15 @@ class DevicesCubit extends Cubit { // 🔥 重置 PathPlanningService 中的全局的队列 _pathPlanningService.clear(); - result.fold((failure) => emit(state.copyWith(isLoading: false, errorMessage: failure.message ?? '停止失败')), (_) => emit(state.copyWith(isLoading: false))); + result.fold( + (failure) => emit( + state.copyWith( + isLoading: false, + errorMessage: failure.message ?? '停止失败', + ), + ), + (_) => emit(state.copyWith(isLoading: false)), + ); } void updateAppState(AppState appState) { @@ -419,7 +607,9 @@ class DevicesCubit extends Cubit { void setArrivedLocation(double latitude, double longitude) { emit(state.copyWith(arriLatitude: latitude, arriLongitude: longitude)); // print('✅ [DevicesCubit] 监听更新已完成到达位置:Lat=$latitude, Lng=$longitude'); - _logger.logWithLevel('✅ [DevicesCubit] 监听更新已完成到达位置:Lat=$latitude, Lng=$longitude'); + _logger.logWithLevel( + '✅ [DevicesCubit] 监听更新已完成到达位置:Lat=$latitude, Lng=$longitude', + ); } //获取已到达的点的经纬度 diff --git a/lib/features/home/presentation/pages/running_status_page.dart b/lib/features/home/presentation/pages/running_status_page.dart index bbd0ee65..f598ab2a 100644 --- a/lib/features/home/presentation/pages/running_status_page.dart +++ b/lib/features/home/presentation/pages/running_status_page.dart @@ -5,6 +5,7 @@ import 'dart:math' as math; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/common/commonFn.dart'; import 'package:syncfusion_flutter_gauges/gauges.dart'; @@ -15,6 +16,8 @@ import '../../../../core/app/app_user_cubit.dart'; import '../../../../core/di/injection.dart'; import '../../../../core/network/net_message_dispatcher.dart'; import '../../../../core/network/protocol_decoder.dart'; +import '../../../../core/network/tcp/tcp_client.dart'; +import '../../../auth/presentation/bloc/auth_cubit.dart'; import '../../../devices/presentation/bloc/devices_cubit.dart'; import '../../../devices/presentation/bloc/device_status_bloc.dart'; import '../../../devices/presentation/bloc/device_status_event.dart'; @@ -81,6 +84,9 @@ class _RunningStatusPageState extends State with WidgetsBindi if (state == AppLifecycleState.resumed) { debugPrint('🔄 [RunningStatusPage] 应用恢复,强制刷新UI'); _forceRefresh(); + } else if (state == AppLifecycleState.paused) { + debugPrint('⏸️ [RunningStatusPage] 应用进入后台,暂停超时计时器'); + _dataTimeoutTimer?.cancel(); } } @@ -1056,11 +1062,53 @@ class _RunningStatusPageState extends State with WidgetsBindi void _forceRefresh() { if (mounted) { debugPrint('🔄 [RunningStatusPage] 执行强制刷新'); - setState(() { - // 触发 UI 重建 - }); - // 重置超时计时器 - _startDataTimeoutTimer(); + + final tcpClient = sl(); + if (!tcpClient.isConnected) { + debugPrint('⚠️ [RunningStatusPage] TCP未连接,尝试重连'); + _reconnectTcp(); + } else { + setState(() { + _isDataTimeout = false; + }); + _startDataTimeoutTimer(); + + debugPrint('✅ [RunningStatusPage] TCP已连接,发送心跳确认'); + tcpClient.sendHeartbeat(); + } + } + } + + Future _reconnectTcp() async { + try { + final authCubit = context.read(); + await authCubit.reconnectAfterResume(); + + if (mounted) { + setState(() { + _isDataTimeout = false; + }); + _startDataTimeoutTimer(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context).translate('running_status.tcp_reconnected')), + duration: const Duration(seconds: 2), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + debugPrint('❌ [RunningStatusPage] TCP重连失败: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context).translate('running_status.tcp_reconnect_failed')), + duration: const Duration(seconds: 2), + backgroundColor: Colors.red, + ), + ); + } } } } diff --git a/lib/features/home/presentation/widgets/map/testmap_pages.dart b/lib/features/home/presentation/widgets/map/testmap_pages.dart index 672a9ebf..c7f29ce7 100644 --- a/lib/features/home/presentation/widgets/map/testmap_pages.dart +++ b/lib/features/home/presentation/widgets/map/testmap_pages.dart @@ -34,6 +34,7 @@ import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_statu import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_state.dart'; import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart'; import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_state.dart'; +import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/BottomDirectionLine.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/common/commonFn.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/common/enum.dart'; @@ -2986,12 +2987,21 @@ class _MapPageEnterpriseState extends State { }); }, onListBox: (bool isOpen) { - // 🔥 改动5:加载作业记录(原有逻辑保留) - final userId = - context.read().state.user?.userId ?? - ""; - print('加载作业记录,当前用户ID:$userId'); - context.read().loadWorkRecords(userId); + // 🔥 使用新接口:根据场站ID加载作业记录 + final selectedSite = sl().state.selectedSite; + if (selectedSite == null) { + _showPageToast( + message: '请先选择场站', + type: ToastType.error, + ); + return; + } + print( + '加载作业记录,当前场站ID:${selectedSite.id},场站名称:${selectedSite.siteName}', + ); + context.read().loadWorkRecordsBySiteId( + selectedSite.id, + ); setState(() { _isListBoxOpen = isOpen; diff --git a/lib/features/main_container/presentation/pages/custom_main_container.dart b/lib/features/main_container/presentation/pages/custom_main_container.dart index 88ad92ff..86c7a5a0 100644 --- a/lib/features/main_container/presentation/pages/custom_main_container.dart +++ b/lib/features/main_container/presentation/pages/custom_main_container.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:maibu_satabot_v2/components/tcp_status_indicator.dart'; +import 'package:maibu_satabot_v2/components/device_status_modal.dart'; import 'package:maibu_satabot_v2/features/ai/presentation/pages/ai_page.dart'; import 'package:maibu_satabot_v2/features/home/presentation/pages/home_page.dart'; import 'package:maibu_satabot_v2/features/my/presentation/pages/my_page.dart'; @@ -12,6 +13,7 @@ import 'package:maibu_satabot_v2/features/v2/workorder/presentation/pages/workor import 'package:maibu_satabot_v2/features/v2/report/presentation/pages/report_page.dart'; import '../../../v2/waring_center/presentation/pages/alarm_center_page.dart'; +import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_bloc.dart'; class CustomMainContainer extends StatefulWidget { const CustomMainContainer({super.key}); @@ -60,29 +62,33 @@ class _CustomMainContainerState extends State index: currentIndex, children: _buildPages(enabledTabs), ), - // TCP状态指示灯 - 右上角,带白色背景确保可见 + // TCP状态指示灯 - 右上角,带白色背景确保可见,点击弹出设备状态模态框 Positioned( top: 40, right: 16, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), // 透明灰色背景 - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - TcpStatusIndicator(size: 12), - const SizedBox(width: 6), - const Text( - 'TCP', - style: TextStyle(fontSize: 12, color: Colors.white), - ), - ], + child: InkWell( + onTap: () => _showDeviceStatusModal(context), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), // 透明灰色背景 + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + TcpStatusIndicator(size: 12), + const SizedBox(width: 6), + const Text( + 'TCP', + style: TextStyle(fontSize: 12, color: Colors.white), + ), + ], + ), ), ), ), @@ -139,4 +145,25 @@ class _CustomMainContainerState extends State } }).toList(); } + + // 显示设备状态模态框 - 从底部滑出 + void _showDeviceStatusModal(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + builder: (BuildContext context) { + return BlocProvider.value( + value: context.read(), + child: const DeviceStatusModal(), + ); + }, + ); + } } diff --git a/lib/features/v2/device_list/data/datasources/drone_station_datasource.dart b/lib/features/v2/device_list/data/datasources/drone_station_datasource.dart index bd3e7312..bf93691a 100644 --- a/lib/features/v2/device_list/data/datasources/drone_station_datasource.dart +++ b/lib/features/v2/device_list/data/datasources/drone_station_datasource.dart @@ -1,5 +1,6 @@ import '../../domain/entities/drone_station_entity.dart'; import '../../domain/entities/video_stream_entity.dart'; +import '../../domain/entities/flight_task_entity.dart'; abstract class DroneStationDataSource { Future> getDroneStationList(int siteId); @@ -11,4 +12,9 @@ abstract class DroneStationDataSource { String qualityType = 'adaptive', int videoExpire = 7200, }); + Future?>> getFlightTasks({ + required List sns, + required int beginAt, + required int endAt, + }); } \ No newline at end of file diff --git a/lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart b/lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart index b3af494d..004273a4 100644 --- a/lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart +++ b/lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart @@ -3,6 +3,7 @@ import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart'; import '../datasources/drone_station_datasource.dart'; import '../../domain/entities/drone_station_entity.dart'; import '../../domain/entities/video_stream_entity.dart'; +import '../../domain/entities/flight_task_entity.dart'; class DroneStationDataSourceImpl implements DroneStationDataSource { final Dio dio; @@ -77,11 +78,60 @@ class DroneStationDataSourceImpl implements DroneStationDataSource { } final responseData = response.data; + + // 打印完整响应,方便调试 + print('=== 视频流API响应 ==='); + print('请求参数: sn=$sn, cameraIndex=$cameraIndex, cameraPosition=$cameraPosition'); + print('完整响应: ${responseData}'); if (responseData['code'] != 200) { throw Exception(responseData['msg'] ?? '业务异常'); } - return VideoStreamEntity.fromJson(responseData['data']); + final data = responseData['data']; + print('视频流URL: ${data['url']}'); + print('视频流URL Type: ${data['url_type']}'); + + return VideoStreamEntity.fromJson(data); + } + + @override + Future?>> getFlightTasks({ + required List sns, + required int beginAt, + required int endAt, + }) async { + final response = await dio.post( + HttpApiConsts.getFlightTask, + data: { + 'sns': sns, + 'beginAt': beginAt, + 'endAt': endAt, + }, + ); + + if (response.statusCode != 200) { + throw Exception('网络请求失败: ${response.statusCode}'); + } + + final responseData = response.data; + + if (responseData['code'] != 200) { + throw Exception(responseData['msg'] ?? '业务异常'); + } + + final data = responseData['data'] as Map; + final result = ?>{}; + + data.forEach((sn, value) { + if (value != null && value['list'] != null) { + final list = value['list'] as List; + result[sn] = list.map((item) => FlightTaskEntity.fromJson(item)).toList(); + } else { + result[sn] = null; + } + }); + + return result; } } \ No newline at end of file diff --git a/lib/features/v2/device_list/domain/entities/flight_task_entity.dart b/lib/features/v2/device_list/domain/entities/flight_task_entity.dart new file mode 100644 index 00000000..749ff3c8 --- /dev/null +++ b/lib/features/v2/device_list/domain/entities/flight_task_entity.dart @@ -0,0 +1,91 @@ +class FlightTaskEntity { + final String name; + final String uuid; + final String taskType; + final String status; + final String sn; + final String landingDockSn; + final String beginAt; + final String endAt; + final String runAt; + final String completedAt; + final String waylineUuid; + final int folderId; + final int currentWaypointIndex; + final int totalWaypoints; + final String mediaUploadStatus; + final String resumableStatus; + final bool isBreakPointResume; + final dynamic operations; + final dynamic exceptions; + + FlightTaskEntity({ + required this.name, + required this.uuid, + required this.taskType, + required this.status, + required this.sn, + required this.landingDockSn, + required this.beginAt, + required this.endAt, + required this.runAt, + required this.completedAt, + required this.waylineUuid, + required this.folderId, + required this.currentWaypointIndex, + required this.totalWaypoints, + required this.mediaUploadStatus, + required this.resumableStatus, + required this.isBreakPointResume, + this.operations, + this.exceptions, + }); + + factory FlightTaskEntity.fromJson(Map json) { + return FlightTaskEntity( + name: json['name'] ?? '', + uuid: json['uuid'] ?? '', + taskType: json['task_type'] ?? '', + status: json['status'] ?? '', + sn: json['sn'] ?? '', + landingDockSn: json['landing_dock_sn'] ?? '', + beginAt: json['begin_at'] ?? '', + endAt: json['end_at'] ?? '', + runAt: json['run_at'] ?? '', + completedAt: json['completed_at'] ?? '', + waylineUuid: json['wayline_uuid'] ?? '', + folderId: json['folder_id'] ?? 0, + currentWaypointIndex: json['current_waypoint_index'] ?? 0, + totalWaypoints: json['total_waypoints'] ?? 0, + mediaUploadStatus: json['media_upload_status'] ?? '', + resumableStatus: json['resumable_status'] ?? '', + isBreakPointResume: json['is_break_point_resume'] ?? false, + operations: json['operations'], + exceptions: json['exceptions'], + ); + } + + Map toJson() { + return { + 'name': name, + 'uuid': uuid, + 'task_type': taskType, + 'status': status, + 'sn': sn, + 'landing_dock_sn': landingDockSn, + 'begin_at': beginAt, + 'end_at': endAt, + 'run_at': runAt, + 'completed_at': completedAt, + 'wayline_uuid': waylineUuid, + 'folder_id': folderId, + 'current_waypoint_index': currentWaypointIndex, + 'total_waypoints': totalWaypoints, + 'media_upload_status': mediaUploadStatus, + 'resumable_status': resumableStatus, + 'is_break_point_resume': isBreakPointResume, + 'operations': operations, + 'exceptions': exceptions, + }; + } +} diff --git a/lib/features/v2/device_list/presentation/pages/device_status_page.dart b/lib/features/v2/device_list/presentation/pages/device_status_page.dart index b6d4dbaf..14c8ee91 100644 --- a/lib/features/v2/device_list/presentation/pages/device_status_page.dart +++ b/lib/features/v2/device_list/presentation/pages/device_status_page.dart @@ -4,10 +4,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../../core/di/injection.dart'; import '../../../../../core/app/app_user_cubit.dart'; import '../../../../../components/tcp_status_indicator.dart'; +import '../../../../../components/device_status_modal.dart'; import '../../../../v2/site/presentation/cubit/site_cubit.dart'; -import '../bloc/device_status_bloc.dart'; -import '../bloc/device_status_event.dart'; -import '../bloc/device_status_state.dart'; +import '../bloc/device_status_bloc.dart' as DeviceListBloc; +import '../bloc/device_status_event.dart' as DeviceListEvent; +import '../bloc/device_status_state.dart' as DeviceListState; +import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_bloc.dart'; import '../bloc/drone_station_bloc.dart'; import '../bloc/drone_station_event.dart'; import '../bloc/drone_station_state.dart'; @@ -27,7 +29,8 @@ class DeviceStatusPage extends StatelessWidget { return BlocProvider( create: (_) => - sl()..add(DeviceStatusLoadData(siteId: siteId)), + sl() + ..add(DeviceListEvent.DeviceStatusLoadData(siteId: siteId)), child: const DeviceStatusView(), ); } @@ -47,24 +50,28 @@ class DeviceStatusView extends StatelessWidget { child: Scaffold( backgroundColor: const Color(0xFFF7F7F7), body: SafeArea( - child: BlocBuilder( - builder: (context, state) { - return Column( - children: [ - _buildAppBar(), - _buildSearchBar(context), - _buildTypeFilterBar(context), - Expanded(child: _buildContent(context, state)), - ], - ); - }, - ), + child: + BlocBuilder< + DeviceListBloc.DeviceStatusBloc, + DeviceListState.DeviceStatusState + >( + builder: (context, state) { + return Column( + children: [ + _buildAppBar(context), + _buildSearchBar(context), + _buildTypeFilterBar(context), + Expanded(child: _buildContent(context, state)), + ], + ); + }, + ), ), ), ); } - Widget _buildAppBar() { + Widget _buildAppBar(BuildContext context) { return Container( height: 44.0, padding: const EdgeInsets.symmetric(horizontal: 16.0), @@ -92,7 +99,10 @@ class DeviceStatusView extends StatelessWidget { shape: BoxShape.circle, color: Colors.blue.withOpacity(0.1), ), - child: const TcpStatusIndicator(size: 12), + child: TcpStatusIndicator( + size: 12, + onTap: () => _showDeviceStatusModal(context), + ), ), ], ), @@ -148,7 +158,9 @@ class DeviceStatusView extends StatelessWidget { style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)), onSubmitted: (value) { // 🔥 点击键盘确定键时触发搜索 - context.read().add(DeviceStatusSearch(value)); + context.read().add( + DeviceListEvent.DeviceStatusSearch(value), + ); }, ), ); @@ -157,10 +169,13 @@ class DeviceStatusView extends StatelessWidget { Widget _buildTypeFilterBar(BuildContext context) { final types = ['全部', '机器人', '无人机机场', '逆变器', '汇流箱', '组件', '监控']; - return BlocBuilder( + return BlocBuilder< + DeviceListBloc.DeviceStatusBloc, + DeviceListState.DeviceStatusState + >( builder: (context, state) { String selectedType = '全部'; - if (state is DeviceStatusLoaded) { + if (state is DeviceListState.DeviceStatusLoaded) { selectedType = state.selectedType; } @@ -178,8 +193,8 @@ class DeviceStatusView extends StatelessWidget { return GestureDetector( onTap: () { - context.read().add( - DeviceStatusChangeType(type), + context.read().add( + DeviceListEvent.DeviceStatusChangeType(type), ); }, child: Column( @@ -219,14 +234,19 @@ class DeviceStatusView extends StatelessWidget { ); } - Widget _buildContent(BuildContext context, DeviceStatusState state) { + Widget _buildContent( + BuildContext context, + DeviceListState.DeviceStatusState state, + ) { // 如果选择的是"机器人",显示机器人专属页面 - if (state is DeviceStatusLoaded && state.selectedType == '机器人') { + if (state is DeviceListState.DeviceStatusLoaded && + state.selectedType == '机器人') { return const RobotListPage(); } // 如果选择的是"无人机机场",显示机场列表 - if (state is DeviceStatusLoaded && state.selectedType == '无人机机场') { + if (state is DeviceListState.DeviceStatusLoaded && + state.selectedType == '无人机机场') { return _buildDroneStationList(context); } @@ -234,14 +254,17 @@ class DeviceStatusView extends StatelessWidget { return _buildDeviceList(context, state); } - Widget _buildDeviceList(BuildContext context, DeviceStatusState state) { - if (state is DeviceStatusLoading) { + Widget _buildDeviceList( + BuildContext context, + DeviceListState.DeviceStatusState state, + ) { + if (state is DeviceListState.DeviceStatusLoading) { return const Center( child: CircularProgressIndicator(color: Color(0xFF165DFF)), ); } - if (state is DeviceStatusError) { + if (state is DeviceListState.DeviceStatusError) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -255,8 +278,8 @@ class DeviceStatusView extends StatelessWidget { const SizedBox(height: 16), ElevatedButton( onPressed: () { - context.read().add( - const DeviceStatusLoadData(), + context.read().add( + const DeviceListEvent.DeviceStatusLoadData(), ); }, style: ElevatedButton.styleFrom( @@ -270,7 +293,7 @@ class DeviceStatusView extends StatelessWidget { ); } - if (state is DeviceStatusLoaded) { + if (state is DeviceListState.DeviceStatusLoaded) { // 🔥 先根据类型过滤 List filteredByType = state.devices; if (state.selectedType != '全部') { @@ -292,7 +315,9 @@ class DeviceStatusView extends StatelessWidget { return RefreshIndicator( onRefresh: () async { - context.read().add(const DeviceStatusRefresh()); + context.read().add( + const DeviceListEvent.DeviceStatusRefresh(), + ); }, color: const Color(0xFF165DFF), child: ListView( @@ -441,7 +466,7 @@ class DeviceStatusView extends StatelessWidget { ); } - Widget _buildStatusCard(DeviceStatusLoaded state) { + Widget _buildStatusCard(DeviceListState.DeviceStatusLoaded state) { return Container( margin: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.all(16.0), @@ -533,4 +558,25 @@ class DeviceStatusView extends StatelessWidget { ), ); } + + // 显示设备状态模态框 - 从底部滑出 + void _showDeviceStatusModal(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + builder: (BuildContext context) { + return BlocProvider.value( + value: context.read(), + child: const DeviceStatusModal(), + ); + }, + ); + } } diff --git a/lib/features/v2/device_list/presentation/pages/drone_mission_control_page.dart b/lib/features/v2/device_list/presentation/pages/drone_mission_control_page.dart index 4a48d48a..2075f47b 100644 --- a/lib/features/v2/device_list/presentation/pages/drone_mission_control_page.dart +++ b/lib/features/v2/device_list/presentation/pages/drone_mission_control_page.dart @@ -1,8 +1,26 @@ import 'package:flutter/material.dart'; +import '../../domain/entities/flight_task_entity.dart'; /// 无人机任务与航线控制页面 -class DroneMissionControlPage extends StatelessWidget { - const DroneMissionControlPage({super.key}); +class DroneMissionControlPage extends StatefulWidget { + final List? selectedTasks; + + const DroneMissionControlPage({super.key, this.selectedTasks}); + + @override + State createState() => _DroneMissionControlPageState(); +} + +class _DroneMissionControlPageState extends State { + FlightTaskEntity? _currentTask; + + @override + void initState() { + super.initState(); + if (widget.selectedTasks != null && widget.selectedTasks!.isNotEmpty) { + _currentTask = widget.selectedTasks!.first; + } + } @override Widget build(BuildContext context) { @@ -78,44 +96,104 @@ class DroneMissionControlPage extends StatelessWidget { ), ), const SizedBox(height: 16), - _buildInfoRow('任务名称', '逆变器区巡检任务'), - const SizedBox(height: 12), - Row( - children: [ - _buildInfoColumn('任务编号', 'UAV-2025052001'), - const Spacer(), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFF165DFF).withOpacity(0.1), - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - '进行中', - style: TextStyle( - fontSize: 12, - color: Color(0xFF165DFF), - fontWeight: FontWeight.w500, + if (_currentTask != null) ...[ + _buildInfoRow('任务名称', _currentTask!.name), + const SizedBox(height: 12), + Row( + children: [ + _buildInfoColumn('任务ID', _currentTask!.uuid.substring(0, 8)), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _getStatusColor(_currentTask!.status).withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _currentTask!.status, + style: TextStyle( + fontSize: 12, + color: _getStatusColor(_currentTask!.status), + fontWeight: FontWeight.w500, + ), ), ), - ), - ], - ), - const SizedBox(height: 12), - _buildInfoRow('巡检区域', '逆变器区A区'), - const SizedBox(height: 12), - _buildInfoRow('飞行高度', '80 m'), - const SizedBox(height: 12), - _buildInfoRow('飞行速度', '8.0 m/s'), - const SizedBox(height: 12), - _buildInfoRow('预计时长', '26 min'), - const SizedBox(height: 12), - _buildInfoRow('电量预估', '68% (可飞行 22 min)'), + ], + ), + const SizedBox(height: 12), + _buildInfoRow('设备序列号', _currentTask!.sn), + const SizedBox(height: 12), + _buildInfoRow('任务类型', _currentTask!.taskType), + const SizedBox(height: 12), + _buildInfoRow('开始时间', _formatDateTime(_currentTask!.beginAt)), + const SizedBox(height: 12), + _buildInfoRow('结束时间', _formatDateTime(_currentTask!.endAt)), + const SizedBox(height: 12), + _buildInfoRow('航点数量', '${_currentTask!.totalWaypoints}'), + const SizedBox(height: 12), + _buildInfoRow('媒体上传', _currentTask!.mediaUploadStatus), + ] else ...[ + _buildInfoRow('任务名称', '逆变器区巡检任务'), + const SizedBox(height: 12), + Row( + children: [ + _buildInfoColumn('任务编号', 'UAV-2025052001'), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF165DFF).withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + '进行中', + style: TextStyle( + fontSize: 12, + color: Color(0xFF165DFF), + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + _buildInfoRow('巡检区域', '逆变器区A区'), + const SizedBox(height: 12), + _buildInfoRow('飞行高度', '80 m'), + const SizedBox(height: 12), + _buildInfoRow('飞行速度', '8.0 m/s'), + const SizedBox(height: 12), + _buildInfoRow('预计时长', '26 min'), + const SizedBox(height: 12), + _buildInfoRow('电量预估', '68% (可飞行 22 min)'), + ], ], ), ); } + Color _getStatusColor(String status) { + switch (status.toLowerCase()) { + case 'success': + return const Color(0xFF00B42A); + case 'failed': + return const Color(0xFFF53F3F); + case 'running': + return const Color(0xFF165DFF); + default: + return const Color(0xFF86909C); + } + } + + String _formatDateTime(String dateTimeStr) { + try { + final dateTime = DateTime.parse(dateTimeStr); + return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; + } catch (e) { + return dateTimeStr; + } + } + Widget _buildRouteMap() { return Container( height: 200, diff --git a/lib/features/v2/device_list/presentation/pages/drone_monitor_page.dart b/lib/features/v2/device_list/presentation/pages/drone_monitor_page.dart index 3ac23ca0..37128868 100644 --- a/lib/features/v2/device_list/presentation/pages/drone_monitor_page.dart +++ b/lib/features/v2/device_list/presentation/pages/drone_monitor_page.dart @@ -1,19 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:volc_engine_rtc/volc_engine_rtc.dart' as volc; import 'package:agora_rtc_engine/agora_rtc_engine.dart' as agora; +import 'package:volc_engine_rtc/volc_engine_rtc.dart' as volc; import '../../../../../core/di/injection.dart'; import '../bloc/drone_station_bloc.dart'; import '../bloc/drone_station_event.dart'; import '../bloc/drone_station_state.dart'; import '../../domain/entities/video_stream_entity.dart'; -// SDK 类型枚举 -enum RtcSdkType { volcengine, agora } - -// 视频流类型 -enum VideoStreamType { indoor, outdoor } - class DroneMonitorPage extends StatefulWidget { final String gatewaySn; final String cameraIndex; @@ -28,539 +22,351 @@ class DroneMonitorPage extends StatefulWidget { _DroneMonitorPageState createState() => _DroneMonitorPageState(); } -class _DroneMonitorPageState extends State - with WidgetsBindingObserver { +class _DroneMonitorPageState extends State { late DroneStationBloc _bloc; - - // 室内视频流状态 - VideoStreamEntity? _indoorVideoStream; - bool _indoorLoading = true; - String? _indoorErrorMessage; - String? _indoorRemoteUserId; - bool _indoorIsAgora = false; - volc.RTCEngine? _indoorRtcEngine; - volc.RTCRoom? _indoorRtcRoom; - volc.RTCViewContext? _indoorRemoteRenderContext; - agora.RtcEngine? _indoorAgoraEngine; - - // 室外视频流状态 - VideoStreamEntity? _outdoorVideoStream; - bool _outdoorLoading = true; - String? _outdoorErrorMessage; - String? _outdoorRemoteUserId; - bool _outdoorIsAgora = false; - volc.RTCEngine? _outdoorRtcEngine; - volc.RTCRoom? _outdoorRtcRoom; - volc.RTCViewContext? _outdoorRemoteRenderContext; - agora.RtcEngine? _outdoorAgoraEngine; - - // 视频卡顿检测:记录上次刷新时间 - DateTime? _lastOutdoorRefreshTime; - DateTime? _lastIndoorRefreshTime; - - // 重试计数 - int? _indoorRetryCount; - int? _outdoorRetryCount; + + // 当前选择的摄像头位置(true=室内,false=室外) + bool isIndoor = true; + + // 视频流状态 + VideoStreamEntity? _videoStream; + bool _isLoading = true; + String? _errorMessage; + + // RTC 类型 + String? _rtcType; // 'agora' 或 'volcengine' + + // Agora RTC + agora.RtcEngine? _agoraEngine; + String? _agoraRemoteUserId; + bool _isAgoraJoined = false; + + // VolcEngine RTC + volc.RTCEngine? _volcEngine; + volc.RTCRoom? _volcRoom; + volc.RTCViewContext? _volcRemoteRenderContext; + String? _volcRemoteUserId; + + // 事件处理器 + final volc.IRTCEngineEventHandler _volcEngineEventHandler = volc.IRTCEngineEventHandler(); + final volc.IRTCRoomEventHandler _volcRoomEventHandler = volc.IRTCRoomEventHandler(); @override void initState() { super.initState(); - WidgetsBinding.instance.addObserver(this); _bloc = sl(); - _loadBothVideoStreams(); - // 启动定时检测 - _startStuckDetection(); + _initVolcEventHandlers(); + _loadVideoStream(); } @override void dispose() { - WidgetsBinding.instance.removeObserver(this); - _bloc.close(); _destroyAllRtcEngines(); + _bloc.close(); super.dispose(); } - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) { - // App 从后台恢复时刷新视频 - debugPrint('App 恢复,重新加载视频流'); - _loadBothVideoStreams(); - } - } - - // 定时检测视频是否卡住 - void _startStuckDetection() { - Future.delayed(const Duration(seconds: 10), () { - if (!mounted) return; - - final now = DateTime.now(); - - // 如果室外视频超过10秒没有刷新,尝试刷新 - if (_lastOutdoorRefreshTime != null && - _outdoorRemoteUserId != null && - now.difference(_lastOutdoorRefreshTime!).inSeconds > 15) { - debugPrint('检测到室外视频卡住,尝试刷新...'); - _refreshOneVideoStream(VideoStreamType.outdoor); - } - - // 如果室内视频超过10秒没有刷新,尝试刷新 - if (_lastIndoorRefreshTime != null && - _indoorRemoteUserId != null && - now.difference(_lastIndoorRefreshTime!).inSeconds > 15) { - debugPrint('检测到室内视频卡住,尝试刷新...'); - _refreshOneVideoStream(VideoStreamType.indoor); - } - - // 继续定时检测 + // 初始化火山引擎事件处理器 + void _initVolcEventHandlers() { + _volcEngineEventHandler.onWarning = (volc.WarningCode code) { + debugPrint('⚠️ Volc Warning: $code'); + }; + + _volcEngineEventHandler.onError = (volc.ErrorCode code) { + debugPrint('❌ Volc Error: $code'); if (mounted) { - _startStuckDetection(); + setState(() { + _errorMessage = '视频错误: $code'; + _isLoading = false; + }); } - }); + }; + + _volcEngineEventHandler.onFirstRemoteVideoFrameDecoded = ( + String streamId, + volc.StreamInfo streamInfo, + volc.VideoFrameInfo frameInfo, + ) { + debugPrint('✅✅✅ Volc 第一帧视频解码完成!'); + debugPrint(' streamId: $streamId, userId: ${streamInfo.userId}'); + + if (streamInfo.userId.isNotEmpty && mounted) { + setState(() { + _volcRemoteUserId = streamInfo.userId; + _volcRemoteRenderContext = volc.RTCViewContext.remoteContext( + roomId: _videoStream?.roomId ?? '', + userId: streamInfo.userId, + streamId: streamId, + ); + _isLoading = false; + }); + } + }; + + _volcRoomEventHandler.onUserPublishStreamVideo = ( + String userId, + volc.StreamInfo streamInfo, + bool isPublish, + ) { + debugPrint('📹 Volc 远端用户 $userId 视频流状态: $isPublish'); + if (isPublish && mounted && _volcRemoteUserId == null) { + debugPrint('⏳ 检测到 Volc 视频流推送,等待第一帧解码...'); + } + }; + + _volcRoomEventHandler.onUserLeave = (String userId, int reason) { + debugPrint('👋 Volc 用户离开: $userId'); + if (userId == _volcRemoteUserId && mounted) { + setState(() { + _volcRemoteRenderContext = null; + _volcRemoteUserId = null; + }); + } + }; } - // 刷新单个视频流 - void _refreshOneVideoStream(VideoStreamType type) { - _destroyRtcEngine(type); - _loadVideoStream(type); - } - - // 同时加载室内和室外视频流 - 依次加载避免状态混乱 - void _loadBothVideoStreams() async { - // 先加载室内视频流 - _loadVideoStream(VideoStreamType.indoor); - // 等待室内加载完成后再加载室外 - await Future.delayed(const Duration(milliseconds: 500)); - _loadVideoStream(VideoStreamType.outdoor); - } - - void _loadVideoStream(VideoStreamType type) { - // 更新最后刷新时间 - if (type == VideoStreamType.indoor) { - _lastIndoorRefreshTime = DateTime.now(); - } else { - _lastOutdoorRefreshTime = DateTime.now(); - } - + // 加载视频流 + void _loadVideoStream() { + debugPrint('🔄 开始加载视频流: ${isIndoor ? "室内" : "室外"}'); setState(() { - if (type == VideoStreamType.indoor) { - _indoorLoading = true; - _indoorErrorMessage = null; - } else { - _outdoorLoading = true; - _outdoorErrorMessage = null; - } + _isLoading = true; + _errorMessage = null; }); - - _destroyRtcEngine(type); - + + _destroyAllRtcEngines(); + _bloc.add( VideoStreamLoad( sn: widget.gatewaySn, cameraIndex: widget.cameraIndex, - // 恢复原始参数(室内请求 indoor,室外请求 outdoor) - cameraPosition: type == VideoStreamType.indoor ? 'indoor' : 'outdoor', + cameraPosition: isIndoor ? 'indoor' : 'outdoor', ), ); } - void _destroyRtcEngine(VideoStreamType type) async { - if (type == VideoStreamType.indoor) { - // 销毁室内 RTC - if (_indoorRtcRoom != null) { - try { - await _indoorRtcRoom?.leaveRoom(); - } catch (_) {} - _indoorRtcRoom = null; - } - if (_indoorRtcEngine != null) { - try { - _indoorRtcEngine?.destroy(); - } catch (_) {} - _indoorRtcEngine = null; - } - if (_indoorAgoraEngine != null) { - try { - await _indoorAgoraEngine?.leaveChannel(); - } catch (_) {} - try { - _indoorAgoraEngine?.release(); - } catch (_) {} - _indoorAgoraEngine = null; - } - _indoorRemoteRenderContext = null; - _indoorRemoteUserId = null; - _indoorIsAgora = false; - } else { - // 销毁室外 RTC - if (_outdoorRtcRoom != null) { - try { - await _outdoorRtcRoom?.leaveRoom(); - } catch (_) {} - _outdoorRtcRoom = null; - } - if (_outdoorRtcEngine != null) { - try { - _outdoorRtcEngine?.destroy(); - } catch (_) {} - _outdoorRtcEngine = null; - } - if (_outdoorAgoraEngine != null) { - try { - await _outdoorAgoraEngine?.leaveChannel(); - } catch (_) {} - try { - _outdoorAgoraEngine?.release(); - } catch (_) {} - _outdoorAgoraEngine = null; - } - _outdoorRemoteRenderContext = null; - _outdoorRemoteUserId = null; - _outdoorIsAgora = false; - } + // 切换室内/室外 + void _onCameraPositionChanged(bool indoor) { + if (isIndoor == indoor) return; + setState(() { + isIndoor = indoor; + }); + _loadVideoStream(); } - void _destroyAllRtcEngines() async { - _destroyRtcEngine(VideoStreamType.indoor); - _destroyRtcEngine(VideoStreamType.outdoor); - } - - Future _initRtcEngine( - VideoStreamType type, - VideoStreamEntity? stream, - ) async { - if (stream == null) return; - - final appId = stream.appId; - final roomId = stream.roomId; - final token = stream.token; - - // 使用服务器返回的 userId 作为 uid - int uid; - if (stream.userId.isNotEmpty) { - uid = int.tryParse(stream.userId) ?? 0; - } else { - uid = 0; + // 销毁所有 RTC 引擎 + Future _destroyAllRtcEngines() async { + debugPrint('🗑️ 开始销毁所有 RTC 引擎...'); + + // 销毁 Agora + if (_agoraEngine != null) { + try { + debugPrint('销毁 Agora 引擎...'); + await _agoraEngine?.leaveChannel(); + await _agoraEngine?.release(); + _agoraEngine = null; + debugPrint('✅ Agora 引擎已销毁'); + } catch (e) { + debugPrint('⚠️ 销毁 Agora 引擎失败: $e'); + } } + _agoraRemoteUserId = null; + _isAgoraJoined = false; - debugPrint('${type.name} 使用的 uid: $uid'); - - if (appId.isEmpty || roomId.isEmpty || token.isEmpty) { - setState(() { - if (type == VideoStreamType.indoor) { - _indoorErrorMessage = 'RTC 参数缺失'; - _indoorLoading = false; - } else { - _outdoorErrorMessage = 'RTC 参数缺失'; - _outdoorLoading = false; + // 销毁 VolcEngine + if (_volcRoom != null || _volcEngine != null) { + try { + debugPrint('销毁 VolcEngine...'); + if (_volcRoom != null) { + await _volcRoom?.leaveRoom(); + _volcRoom = null; } + if (_volcEngine != null) { + _volcEngine?.destroy(); + _volcEngine = null; + } + debugPrint('✅ VolcEngine 已销毁'); + } catch (e) { + debugPrint('⚠️ 销毁 VolcEngine 失败: $e'); + } + } + _volcRemoteRenderContext = null; + _volcRemoteUserId = null; + + _rtcType = null; + debugPrint('✅ 所有 RTC 引擎销毁完成'); + } + + // 根据 urlType 选择 SDK 并初始化 + Future _initRtcEngine(VideoStreamEntity stream) async { + final urlType = stream.urlType.toLowerCase(); + _rtcType = urlType; + + debugPrint('\n=== 🔍 RTC 类型判断 ==='); + debugPrint('urlType: $urlType'); + debugPrint('原始 URL: ${stream.url}'); + debugPrint('==================\n'); + + if (urlType == 'agora') { + await _initAgoraEngine(stream); + } else { + // 默认使用火山引擎(包括 'volcengine', 'rtc', 或其他未知类型) + await _initVolcEngine(stream); + } + } + + // 初始化 Agora SDK + Future _initAgoraEngine(VideoStreamEntity stream) async { + final appId = stream.appId; + final channelId = stream.roomId; + final token = stream.token; + final uid = stream.userId.isNotEmpty + ? int.tryParse(stream.userId) ?? 0 + : 0; + + debugPrint('=== Agora 参数 ==='); + debugPrint('AppId: "$appId"'); + debugPrint('ChannelId: "$channelId"'); + debugPrint('Uid: $uid'); + + if (appId.isEmpty || channelId.isEmpty || token.isEmpty) { + setState(() { + _errorMessage = 'Agora 参数缺失'; + _isLoading = false; }); return; } - debugPrint('=== ${type.name} RTC 初始化 ==='); - debugPrint('AppId: $appId'); - debugPrint('RoomId: $roomId'); - debugPrint('Uid: $uid'); - debugPrint('URL Type: ${stream.urlType}'); - - final sdkType = stream.urlType.toLowerCase() == 'agora' - ? RtcSdkType.agora - : RtcSdkType.volcengine; - - if (sdkType == RtcSdkType.agora) { - await _initAgoraEngine(type, appId, roomId, token, uid); - } else { - await _initVolcEngine(type, appId, roomId, token, uid.toString()); - } - } - - // Agora SDK 初始化 - Future _initAgoraEngine( - VideoStreamType type, - String appId, - String channelId, - String token, - int uid, - ) async { try { - debugPrint('=== ${type.name} Agora RTC 初始化 ==='); - debugPrint('AppId: "$appId" (长度: ${appId.length})'); - debugPrint('RoomId: "$channelId"'); - debugPrint('Uid: $uid'); + debugPrint('🚀 创建 Agora 引擎...'); + + _agoraEngine = agora.createAgoraRtcEngine(); + await _agoraEngine!.initialize(agora.RtcEngineContext(appId: appId)); + debugPrint('✅ Agora 引擎初始化成功'); - // 参数验证 - if (appId.isEmpty) { - throw Exception('AppId 为空'); - } + _agoraEngine!.enableVideo(); + + await _agoraEngine!.setVideoEncoderConfiguration( + agora.VideoEncoderConfiguration( + dimensions: const agora.VideoDimensions(width: 1920, height: 1080), + frameRate: 30, + bitrate: 0, + ), + ); - final engine = agora.createAgoraRtcEngine(); - - // 添加 try-catch 捕获初始化异常 - try { - await engine.initialize(agora.RtcEngineContext(appId: appId)); - } catch (initError) { - debugPrint('❌ ${type.name} Agora 引擎初始化失败: $initError'); - throw initError; - } - - engine.enableVideo(); - debugPrint('${type.name} Agora 引擎初始化成功'); - - engine.registerEventHandler( + _agoraEngine!.registerEventHandler( agora.RtcEngineEventHandler( onJoinChannelSuccess: (agora.RtcConnection connection, int elapsed) { - debugPrint('✅ ${type.name} Agora 加入频道成功: ${connection.channelId}'); - }, - onUserJoined: (agora.RtcConnection connection, int uid, int elapsed) { - debugPrint('✅ ${type.name} Agora 用户加入: uid=$uid'); + debugPrint('✅ Agora 加入频道成功: ${connection.channelId}'); setState(() { - if (type == VideoStreamType.indoor) { - _indoorRemoteUserId = uid.toString(); - _indoorIsAgora = true; - _indoorLoading = false; - _indoorAgoraEngine = engine; - } else { - _outdoorRemoteUserId = uid.toString(); - _outdoorIsAgora = true; - _outdoorLoading = false; - _outdoorAgoraEngine = engine; - } + _isAgoraJoined = true; }); }, - onUserOffline: - ( - agora.RtcConnection connection, - int uid, - agora.UserOfflineReasonType reason, - ) { - debugPrint('${type.name} Agora 用户离开: $uid'); - setState(() { - if (type == VideoStreamType.indoor && - uid.toString() == _indoorRemoteUserId) { - _indoorRemoteUserId = null; - } else if (type == VideoStreamType.outdoor && - uid.toString() == _outdoorRemoteUserId) { - _outdoorRemoteUserId = null; - } - }); - }, - onError: (agora.ErrorCodeType err, String msg) { - debugPrint('❌ ${type.name} Agora 错误: $err - $msg'); + + onUserJoined: (agora.RtcConnection connection, int remoteUid, int elapsed) { + debugPrint('✅ Agora 远端用户加入: uid=$remoteUid'); setState(() { - if (type == VideoStreamType.indoor) { - _indoorErrorMessage = 'Agora RTC 错误:$err'; - _indoorLoading = false; - } else { - _outdoorErrorMessage = 'Agora RTC 错误:$err'; - _outdoorLoading = false; - } + _agoraRemoteUserId = remoteUid.toString(); + _isLoading = false; + }); + }, + + onUserOffline: (agora.RtcConnection connection, int remoteUid, agora.UserOfflineReasonType reason) { + debugPrint('👋 Agora 远端用户离开: uid=$remoteUid'); + if (remoteUid.toString() == _agoraRemoteUserId) { + setState(() { + _agoraRemoteUserId = null; + }); + } + }, + + onError: (agora.ErrorCodeType err, String msg) { + debugPrint('❌ Agora 错误: $err - $msg'); + setState(() { + _errorMessage = 'Agora 错误: $err'; + _isLoading = false; }); }, ), ); - // 设置视频配置(优化流畅度)- 使用默认配置,避免SDK版本兼容问题 - try { - await engine.setVideoEncoderConfiguration( - agora.VideoEncoderConfiguration(), - ); - debugPrint('${type.name} Agora 视频配置已设置'); - } catch (e) { - debugPrint('${type.name} Agora 视频配置设置失败: $e'); - } + debugPrint('🔑 加入 Agora 频道...'); + + await _agoraEngine!.joinChannel( + token: token, + channelId: channelId, + uid: uid, + options: agora.ChannelMediaOptions( + channelProfile: agora.ChannelProfileType.channelProfileLiveBroadcasting, + clientRoleType: agora.ClientRoleType.clientRoleAudience, + autoSubscribeVideo: true, + autoSubscribeAudio: false, + ), + ); - debugPrint('${type.name} 准备加入频道: channelId=$channelId, uid=$uid'); - - // 简化 joinChannel 参数,避免无效参数问题 - try { - // 使用服务器返回的 userId 作为 uid - await engine.joinChannel( - token: token, - channelId: channelId, - uid: uid, - options: agora.ChannelMediaOptions( - channelProfile: - agora.ChannelProfileType.channelProfileLiveBroadcasting, - clientRoleType: agora.ClientRoleType.clientRoleAudience, - autoSubscribeVideo: true, - autoSubscribeAudio: false, - ), - ); - debugPrint('✅ ${type.name} Agora joinChannel 调用成功'); - } catch (e) { - debugPrint('❌ ${type.name} Agora joinChannel 失败: $e'); - - // 记录重试次数 - if (type == VideoStreamType.indoor) { - _indoorRetryCount = (_indoorRetryCount ?? 0) + 1; - } else { - _outdoorRetryCount = (_outdoorRetryCount ?? 0) + 1; - } - - setState(() { - if (type == VideoStreamType.indoor) { - _indoorErrorMessage = '视频连接失败: $e'; - _indoorLoading = false; - } else { - _outdoorErrorMessage = '视频连接失败: $e'; - _outdoorLoading = false; - } - }); - - // 自动重试(最多3次) - final retryCount = type == VideoStreamType.indoor - ? _indoorRetryCount - : _outdoorRetryCount; - if (retryCount! < 3) { - debugPrint('${type.name} 第 $retryCount 次重试...'); - Future.delayed(const Duration(seconds: 2), () { - if (mounted) { - _loadVideoStream(type); - } - }); - } - - return; - } - - if (type == VideoStreamType.indoor) { - _indoorAgoraEngine = engine; - } else { - _outdoorAgoraEngine = engine; - } + debugPrint('✅ 成功加入 Agora 频道: $channelId'); + } catch (e) { - debugPrint('❌ ${type.name} Agora RTC 初始化失败: $e'); + debugPrint('❌ Agora 初始化失败: $e'); setState(() { - if (type == VideoStreamType.indoor) { - _indoorErrorMessage = 'Agora RTC 初始化失败:$e'; - _indoorLoading = false; - } else { - _outdoorErrorMessage = 'Agora RTC 初始化失败:$e'; - _outdoorLoading = false; - } + _errorMessage = 'Agora 加入失败: $e'; + _isLoading = false; }); } } - // 火山引擎 SDK 初始化 - Future _initVolcEngine( - VideoStreamType type, - String appId, - String roomId, - String token, - String userId, - ) async { + // 初始化火山引擎 SDK + Future _initVolcEngine(VideoStreamEntity stream) async { + final appId = stream.appId; + final roomId = stream.roomId; + final token = stream.token; + final userId = stream.userId.isNotEmpty + ? stream.userId + : 'user_${DateTime.now().millisecondsSinceEpoch}'; + + debugPrint('=== VolcEngine 参数 ==='); + debugPrint('AppId: "$appId"'); + debugPrint('RoomId: "$roomId"'); + debugPrint('UserId: "$userId"'); + + if (appId.isEmpty || roomId.isEmpty || token.isEmpty) { + setState(() { + _errorMessage = 'VolcEngine 参数缺失'; + _isLoading = false; + }); + return; + } + try { - debugPrint('=== ${type.name} VolcEngine RTC 初始化 ==='); - - final engineEventHandler = volc.IRTCEngineEventHandler( - onWarning: (volc.WarningCode code) { - debugPrint('${type.name} Volc Warning: $code'); - }, - onError: (volc.ErrorCode code) { - debugPrint('${type.name} Volc Error: $code'); - setState(() { - if (type == VideoStreamType.indoor) { - _indoorErrorMessage = 'Volc RTC 错误:$code'; - _indoorLoading = false; - } else { - _outdoorErrorMessage = 'Volc RTC 错误:$code'; - _outdoorLoading = false; - } - }); - }, + debugPrint('🚀 创建 VolcEngine 引擎...'); + + _volcEngine = await volc.RTCEngine.createRTCEngine( + volc.RTCVideoContext(appId: appId, eventHandler: _volcEngineEventHandler), ); - final engine = await volc.RTCEngine.createRTCEngine( - volc.RTCVideoContext(appId: appId, eventHandler: engineEventHandler), - ); - - if (engine == null) { + if (_volcEngine == null) { setState(() { - if (type == VideoStreamType.indoor) { - _indoorErrorMessage = 'Volc RTC 引擎创建失败'; - _indoorLoading = false; - } else { - _outdoorErrorMessage = 'Volc RTC 引擎创建失败'; - _outdoorLoading = false; - } + _errorMessage = 'VolcEngine 引擎创建失败'; + _isLoading = false; }); return; } + debugPrint('✅ VolcEngine 引擎创建成功'); - final room = await engine.createRTCRoom(roomId); - - if (room == null) { + _volcRoom = await _volcEngine?.createRTCRoom(roomId); + if (_volcRoom == null) { setState(() { - if (type == VideoStreamType.indoor) { - _indoorErrorMessage = 'Volc RTC 房间创建失败'; - _indoorLoading = false; - } else { - _outdoorErrorMessage = 'Volc RTC 房间创建失败'; - _outdoorLoading = false; - } + _errorMessage = 'VolcEngine 房间创建失败'; + _isLoading = false; }); return; } + debugPrint('✅ VolcEngine 房间创建成功'); - final roomEventHandler = volc.IRTCRoomEventHandler( - onUserPublishStreamVideo: - (String userId, volc.StreamInfo streamInfo, bool isPublish) { - debugPrint('${type.name} Volc 远端用户 $userId 视频流状态: $isPublish'); - setState(() { - if (isPublish) { - if (type == VideoStreamType.indoor) { - _indoorRemoteUserId = userId; - _indoorIsAgora = false; - _indoorRemoteRenderContext = - volc.RTCViewContext.remoteContext( - roomId: roomId, - userId: userId, - ); - _indoorLoading = false; - } else { - _outdoorRemoteUserId = userId; - _outdoorIsAgora = false; - _outdoorRemoteRenderContext = - volc.RTCViewContext.remoteContext( - roomId: roomId, - userId: userId, - ); - _outdoorLoading = false; - } - } else { - if (type == VideoStreamType.indoor && - userId == _indoorRemoteUserId) { - _indoorRemoteRenderContext = null; - _indoorRemoteUserId = null; - } else if (type == VideoStreamType.outdoor && - userId == _outdoorRemoteUserId) { - _outdoorRemoteRenderContext = null; - _outdoorRemoteUserId = null; - } - } - }); - }, - onUserLeave: (String userId, int reason) { - debugPrint('${type.name} Volc 用户离开: $userId'); - setState(() { - if (type == VideoStreamType.indoor && - userId == _indoorRemoteUserId) { - _indoorRemoteRenderContext = null; - _indoorRemoteUserId = null; - } else if (type == VideoStreamType.outdoor && - userId == _outdoorRemoteUserId) { - _outdoorRemoteRenderContext = null; - _outdoorRemoteUserId = null; - } - }); - }, - ); + await _volcRoom?.setRTCRoomEventHandler(_volcRoomEventHandler); - await room.setRTCRoomEventHandler(roomEventHandler); - - await room.joinRoom( + debugPrint('🔑 加入 VolcEngine 房间...'); + + await _volcRoom?.joinRoom( token: token, userInfo: volc.UserInfo(userId: userId, extraInfo: ''), userVisibility: true, @@ -572,23 +378,14 @@ class _DroneMonitorPageState extends State ), ); - if (type == VideoStreamType.indoor) { - _indoorRtcEngine = engine; - _indoorRtcRoom = room; - } else { - _outdoorRtcEngine = engine; - _outdoorRtcRoom = room; - } + debugPrint('✅ 成功加入 VolcEngine 房间: $roomId'); + debugPrint('⏳ 等待视频流推送...'); + } catch (e) { - debugPrint('❌ ${type.name} Volc RTC 初始化失败: $e'); + debugPrint('❌ VolcEngine 初始化失败: $e'); setState(() { - if (type == VideoStreamType.indoor) { - _indoorErrorMessage = 'Volc RTC 初始化失败:$e'; - _indoorLoading = false; - } else { - _outdoorErrorMessage = 'Volc RTC 初始化失败:$e'; - _outdoorLoading = false; - } + _errorMessage = 'VolcEngine 加入失败: $e'; + _isLoading = false; }); } } @@ -596,105 +393,57 @@ class _DroneMonitorPageState extends State @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: const Color(0xFF1D2129), appBar: AppBar( - title: const Text('无人机监控'), + backgroundColor: const Color(0xFF2A2E34), leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () { - _destroyAllRtcEngines(); - Navigator.pop(context); - }, + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), ), + title: const Text('实时监控', style: TextStyle(color: Colors.white)), ), body: BlocConsumer( bloc: _bloc, listener: (context, state) { if (state is VideoStreamLoaded) { - // 根据加载的摄像头位置判断是室内还是室外 - final position = state.cameraPosition; - final type = position == 'indoor' - ? VideoStreamType.indoor - : VideoStreamType.outdoor; - - // 打印详细的视频流参数,方便排查问题 - debugPrint('=== ${position} 视频流参数 ==='); - debugPrint('AppId: ${state.videoStream.appId}'); - debugPrint('RoomId: ${state.videoStream.roomId}'); - debugPrint('UserId: ${state.videoStream.userId}'); - debugPrint('Token: ${state.videoStream.token.substring(0, 20)}...'); - debugPrint('URL Type: ${state.videoStream.urlType}'); - - setState(() { - if (type == VideoStreamType.indoor) { - _indoorVideoStream = state.videoStream; - } else { - _outdoorVideoStream = state.videoStream; - } - }); - - _initRtcEngine(type, state.videoStream); + _videoStream = state.videoStream; + _initRtcEngine(state.videoStream); } else if (state is VideoStreamError) { - // 根据错误信息判断是哪个视频流 setState(() { - // 简化处理:两个都显示错误 - if (_indoorLoading) { - _indoorErrorMessage = state.message; - _indoorLoading = false; - } - if (_outdoorLoading) { - _outdoorErrorMessage = state.message; - _outdoorLoading = false; - } + _isLoading = false; + _errorMessage = state.message; }); } }, builder: (context, state) { return Column( children: [ - // 上下堆叠显示两个视频 - // 第一个视频(室内)- 占45%高度 Expanded( - flex: 9, - child: _buildVideoCard( - title: '室内', - type: VideoStreamType.indoor, - isLoading: _indoorLoading, - errorMessage: _indoorErrorMessage, - remoteUserId: _indoorRemoteUserId, - isAgora: _indoorIsAgora, - agoraEngine: _indoorAgoraEngine, - renderContext: _indoorRemoteRenderContext, - videoStream: _indoorVideoStream, - ), + child: _buildVideoView(), ), - // 分隔线 - Container(height: 1, color: Colors.grey.withOpacity(0.3)), - // 第二个视频(室外)- 占45%高度 - Expanded( - flex: 9, - child: _buildVideoCard( - title: '室外', - type: VideoStreamType.outdoor, - isLoading: _outdoorLoading, - errorMessage: _outdoorErrorMessage, - remoteUserId: _outdoorRemoteUserId, - isAgora: _outdoorIsAgora, - agoraEngine: _outdoorAgoraEngine, - renderContext: _outdoorRemoteRenderContext, - videoStream: _outdoorVideoStream, - ), - ), - // 刷新按钮区域 - 占10%高度 - Padding( + Container( padding: const EdgeInsets.all(16), - child: ElevatedButton.icon( - onPressed: _loadBothVideoStreams, - icon: const Icon(Icons.refresh), - label: const Text('刷新视频流'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF165DFF), - foregroundColor: Colors.white, - ), + color: const Color(0xFF2A2E34), + child: Row( + children: [ + Expanded( + child: _buildSwitchButton( + icon: Icons.home, + label: '室内', + active: isIndoor, + onTap: () => _onCameraPositionChanged(true), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildSwitchButton( + icon: Icons.sunny, + label: '室外', + active: !isIndoor, + onTap: () => _onCameraPositionChanged(false), + ), + ), + ], ), ), ], @@ -704,159 +453,124 @@ class _DroneMonitorPageState extends State ); } - // 构建单个视频卡片 - Widget _buildVideoCard({ - required String title, - required VideoStreamType type, - required bool isLoading, - required String? errorMessage, - required String? remoteUserId, - required bool isAgora, - required agora.RtcEngine? agoraEngine, - required volc.RTCViewContext? renderContext, - required VideoStreamEntity? videoStream, - }) { - return Container( - color: Colors.black, - child: Column( - children: [ - // 标题栏 - Container( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), - color: Colors.black.withOpacity(0.7), - child: Row( - children: [ - Icon( - type == VideoStreamType.indoor - ? Icons.home - : Icons.outdoor_grill, - color: Colors.white, - size: 16, - ), - const SizedBox(width: 8), - Text( - title, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - if (isLoading) - const SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, - ), - ), - if (errorMessage != null) - Icon(Icons.error_outline, color: Colors.red, size: 16), - if (remoteUserId != null) - Icon(Icons.video_camera_front, color: Colors.green, size: 16), - ], - ), - ), - // 视频区域 - 使用固定比例确保视频完整展示 - Expanded( - child: _buildVideoContent( - isLoading: isLoading, - errorMessage: errorMessage, - remoteUserId: remoteUserId, - isAgora: isAgora, - agoraEngine: agoraEngine, - renderContext: renderContext, - videoStream: videoStream, - ), - ), - ], - ), - ); - } - - // 构建视频内容 - Widget _buildVideoContent({ - required bool isLoading, - required String? errorMessage, - required String? remoteUserId, - required bool isAgora, - required agora.RtcEngine? agoraEngine, - required volc.RTCViewContext? renderContext, - required VideoStreamEntity? videoStream, - }) { - if (isLoading) { + // 构建视频视图 + Widget _buildVideoView() { + if (_isLoading) { return const Center( - child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + child: CircularProgressIndicator(color: Colors.white), ); } - if (errorMessage != null) { + if (_errorMessage != null) { return Center( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, size: 32, color: Colors.red), - const SizedBox(height: 8), - Text( - errorMessage, - style: const TextStyle(color: Colors.red, fontSize: 12), - textAlign: TextAlign.center, - ), - ], - ), - ), - ); - } - - if (remoteUserId == null) { - return const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.video_camera_front, size: 32, color: Colors.grey), - SizedBox(height: 8), - Text( - '等待视频流...', - style: TextStyle(color: Colors.grey, fontSize: 12), + const Icon(Icons.error_outline, size: 64, color: Colors.red), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + _errorMessage!, + style: const TextStyle(color: Colors.red, fontSize: 14), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _loadVideoStream, + child: const Text('重试'), ), ], ), ); } - // 让视频完整显示(使用 fit 模式,可能有黑边但内容完整) - return isAgora && agoraEngine != null - ? SizedBox.expand( - child: agora.AgoraVideoView( - controller: agora.VideoViewController.remote( - rtcEngine: agoraEngine, - canvas: agora.VideoCanvas( - uid: int.parse(remoteUserId), - renderMode: - agora.RenderModeType.renderModeFit, // 使用 Fit 模式确保内容完整 - ), - connection: agora.RtcConnection( - channelId: videoStream?.roomId ?? '', - ), + // 根据 RTC 类型显示不同的视频 + if (_rtcType == 'agora') { + // Agora 视频 + if (_agoraRemoteUserId == null || !_isAgoraJoined) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.videocam_off, size: 64, color: Colors.grey), + const SizedBox(height: 16), + const Text( + '等待视频流...', + style: TextStyle(color: Colors.grey, fontSize: 16), ), + ], + ), + ); + } + + return Container( + color: Colors.black, + child: agora.AgoraVideoView( + controller: agora.VideoViewController.remote( + rtcEngine: _agoraEngine!, + canvas: agora.VideoCanvas( + uid: int.parse(_agoraRemoteUserId!), + renderMode: agora.RenderModeType.renderModeFit, ), - ) - : renderContext != null - ? SizedBox.expand( - child: volc.RTCSurfaceView( - context: renderContext, - renderMode: volc.VideoRenderMode.fit, // 使用 fit 模式确保内容完整 + connection: agora.RtcConnection( + channelId: _videoStream?.roomId ?? '', ), - ) - : const Center( - child: Text( - '视频初始化中...', - style: TextStyle(color: Colors.grey, fontSize: 12), - ), - ); + ), + ), + ); + } else { + // VolcEngine 视频(默认) + if (_volcRemoteUserId == null || _volcRemoteRenderContext == null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.videocam_off, size: 64, color: Colors.grey), + const SizedBox(height: 16), + const Text( + '等待视频流...', + style: TextStyle(color: Colors.grey, fontSize: 16), + ), + ], + ), + ); + } + + return Container( + color: Colors.black, + child: volc.RTCSurfaceView( + context: _volcRemoteRenderContext!, + renderMode: volc.VideoRenderMode.fit, + ), + ); + } + } + + // 构建切换按钮 + Widget _buildSwitchButton({ + required IconData icon, + required String label, + required bool active, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: active ? const Color(0xFF165DFF) : const Color(0xFF3A3E44), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Icon(icon, color: Colors.white), + const SizedBox(height: 8), + Text(label, style: const TextStyle(color: Colors.white)), + ], + ), + ), + ); } } diff --git a/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart b/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart index 507a2991..ba53ce39 100644 --- a/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart +++ b/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:volc_engine_rtc/volc_engine_rtc.dart' as volc; @@ -11,6 +12,7 @@ import '../bloc/drone_station_state.dart'; import 'drone_video_control_page.dart'; import 'drone_mission_control_page.dart'; import 'drone_monitor_page.dart'; +import '../widgets/flight_task_selector_modal.dart'; // SDK 类型枚举 enum RtcSdkType { volcengine, agora } @@ -47,6 +49,10 @@ class _DroneStationDetailPageState extends State { // Agora RTC agora.RtcEngine? _floatingAgoraEngine; + // 加载超时计时器 + Timer? _floatingLoadingTimer; + static const _floatingLoadingTimeout = Duration(seconds: 15); + @override void initState() { super.initState(); @@ -63,6 +69,7 @@ class _DroneStationDetailPageState extends State { void dispose() { _bloc.close(); _destroyFloatingRtcEngine(); + _floatingLoadingTimer?.cancel(); super.dispose(); } @@ -125,11 +132,13 @@ class _DroneStationDetailPageState extends State { listener: (context, state) { // 监听视频流加载状态 if (state is VideoStreamLoaded) { + _floatingLoadingTimer?.cancel(); setState(() { _floatingVideoStream = state.videoStream; }); _initFloatingRtcEngine(); } else if (state is VideoStreamError) { + _floatingLoadingTimer?.cancel(); setState(() { _floatingErrorMessage = state.message; _isFloatingLoading = false; @@ -518,12 +527,7 @@ class _DroneStationDetailPageState extends State { label: '任务下发', color: const Color(0xFF165DFF), onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const DroneMissionControlPage(), - ), - ); + _showFlightTaskSelector(); }, ), ), @@ -535,6 +539,16 @@ class _DroneStationDetailPageState extends State { } void _goToMonitor() { + // 从当前状态中获取摄像头索引 + final currentState = _bloc.state; + String cameraIndex = '165-0-7'; // 默认值 + + if (currentState is UAVDetailLoaded && + currentState.detail.gatewayCameraList != null && + currentState.detail.gatewayCameraList!.isNotEmpty) { + cameraIndex = currentState.detail.gatewayCameraList!.first.cameraIndex; + } + showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( @@ -559,7 +573,7 @@ class _DroneStationDetailPageState extends State { MaterialPageRoute( builder: (context) => DroneMonitorPage( gatewaySn: widget.station.gatewaySn, - cameraIndex: '165-0-7', + cameraIndex: cameraIndex, ), ), ); @@ -589,36 +603,37 @@ class _DroneStationDetailPageState extends State { ), ), const SizedBox(height: 12), - GestureDetector( - onTap: () { - Navigator.pop(context); - _loadFloatingVideoStream(); - }, - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFF2F3F5), - borderRadius: BorderRadius.circular(12), - ), - child: const Row( - children: [ - Icon(Icons.picture_in_picture, color: Color(0xFF165DFF)), - SizedBox(width: 16), - Text( - '悬浮观看', - style: TextStyle( - fontSize: 16, - color: Color(0xFF1D2129), - fontWeight: FontWeight.w500, - ), - ), - Spacer(), - Icon(Icons.arrow_forward_ios, color: Color(0xFF86909C)), - ], - ), - ), - ), - const SizedBox(height: 16), + // 悬浮观看功能已禁用 + // GestureDetector( + // onTap: () { + // Navigator.pop(context); + // _loadFloatingVideoStream(); + // }, + // child: Container( + // padding: const EdgeInsets.all(16), + // decoration: BoxDecoration( + // color: const Color(0xFFF2F3F5), + // borderRadius: BorderRadius.circular(12), + // ), + // child: const Row( + // children: [ + // Icon(Icons.picture_in_picture, color: Color(0xFF165DFF)), + // SizedBox(width: 16), + // Text( + // '悬浮观看', + // style: TextStyle( + // fontSize: 16, + // color: Color(0xFF1D2129), + // fontWeight: FontWeight.w500, + // ), + // ), + // Spacer(), + // Icon(Icons.arrow_forward_ios, color: Color(0xFF86909C)), + // ], + // ), + // ), + // ), + // const SizedBox(height: 16), ], ), ); @@ -626,6 +641,46 @@ class _DroneStationDetailPageState extends State { ); } + // 显示飞行任务选择器 + void _showFlightTaskSelector() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 0.95, + builder: (context, scrollController) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + child: FlightTaskSelectorModal( + currentGatewaySn: widget.station.gatewaySn, + onTaskSelected: (tasks) { + if (tasks.isNotEmpty) { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + DroneMissionControlPage(selectedTasks: tasks), + ), + ); + } + }, + ), + ); + }, + ); + }, + ); + } + // 悬浮视频监控组件 Widget _buildFloatingMonitor() { if (!showFloatingMonitor) return const SizedBox(); @@ -858,6 +913,18 @@ class _DroneStationDetailPageState extends State { // 加载悬浮视频流 void _loadFloatingVideoStream() { + _floatingLoadingTimer?.cancel(); + + // 从当前状态中获取摄像头索引 + final currentState = _bloc.state; + String cameraIndex = '165-0-7'; // 默认值 + + if (currentState is UAVDetailLoaded && + currentState.detail.gatewayCameraList != null && + currentState.detail.gatewayCameraList!.isNotEmpty) { + cameraIndex = currentState.detail.gatewayCameraList!.first.cameraIndex; + } + setState(() { _isFloatingLoading = true; _floatingErrorMessage = null; @@ -866,10 +933,22 @@ class _DroneStationDetailPageState extends State { _destroyFloatingRtcEngine(); + // 设置加载超时计时器 + _floatingLoadingTimer = Timer(_floatingLoadingTimeout, () { + if (!mounted) return; + if (_isFloatingLoading) { + debugPrint('⚠️ 悬浮窗视频加载超时'); + setState(() { + _isFloatingLoading = false; + _floatingErrorMessage = '视频加载超时,请检查网络连接或点击刷新重试'; + }); + } + }); + _bloc.add( VideoStreamLoad( sn: widget.station.gatewaySn, - cameraIndex: '165-0-7', + cameraIndex: cameraIndex, cameraPosition: isFloatingIndoor ? 'indoor' : 'outdoor', ), ); @@ -1007,6 +1086,11 @@ class _DroneStationDetailPageState extends State { _isFloatingLoading = false; }); }, + onConnectionStateChanged: (state, reason) { + debugPrint( + 'Volc 悬浮窗 Connection State Changed: $state, reason: $reason', + ); + }, ); _floatingRtcEngine = await volc.RTCEngine.createRTCEngine( diff --git a/lib/features/v2/device_list/presentation/widgets/flight_task_selector_modal.dart b/lib/features/v2/device_list/presentation/widgets/flight_task_selector_modal.dart new file mode 100644 index 00000000..83fa5acc --- /dev/null +++ b/lib/features/v2/device_list/presentation/widgets/flight_task_selector_modal.dart @@ -0,0 +1,318 @@ +import 'package:flutter/material.dart'; +import 'package:dio/dio.dart'; +import '../../../../../core/consts/http_api_consts.dart'; +import '../../domain/entities/flight_task_entity.dart'; + +class FlightTaskSelectorModal extends StatefulWidget { + final String currentGatewaySn; + final Function(List) onTaskSelected; + + const FlightTaskSelectorModal({ + super.key, + required this.currentGatewaySn, + required this.onTaskSelected, + }); + + @override + State createState() => _FlightTaskSelectorModalState(); +} + +class _FlightTaskSelectorModalState extends State { + DateTimeRange? _selectedDateRange; + String? _selectedDeviceSn; + Map?> _taskData = {}; + bool _isLoading = false; + String? _errorMessage; + final Dio _dio = Dio(); + + @override + void initState() { + super.initState(); + _selectedDeviceSn = widget.currentGatewaySn; + _setDefaultDateRange(); + } + + void _setDefaultDateRange() { + final now = DateTime.now(); + final todayStart = DateTime(now.year, now.month, now.day); + final todayEnd = DateTime(now.year, now.month, now.day, 23, 59, 59); + _selectedDateRange = DateTimeRange(start: todayStart, end: todayEnd); + } + + Future _selectDateRange(BuildContext context) async { + final picked = await showDateRangePicker( + context: context, + firstDate: DateTime(2020), + lastDate: DateTime(2030), + initialDateRange: _selectedDateRange, + ); + if (picked != null) { + setState(() { + _selectedDateRange = picked; + }); + } + } + + Future _loadTasks() async { + if (_selectedDateRange == null || _selectedDeviceSn == null) { + setState(() { + _errorMessage = '请选择时间和设备'; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final beginAt = _selectedDateRange!.start.millisecondsSinceEpoch ~/ 1000; + final endAt = _selectedDateRange!.end.millisecondsSinceEpoch ~/ 1000; + + final response = await _dio.post( + HttpApiConsts.getFlightTask, + data: { + 'sns': [_selectedDeviceSn!], + 'beginAt': beginAt, + 'endAt': endAt, + }, + ); + + if (response.statusCode != 200) { + throw Exception('网络请求失败'); + } + + final responseData = response.data; + + if (responseData['code'] != 200) { + throw Exception(responseData['msg'] ?? '业务异常'); + } + + final data = responseData['data'] as Map; + final result = ?>{}; + + data.forEach((sn, value) { + if (value != null && value['list'] != null) { + final list = value['list'] as List; + result[sn] = list.map((item) => FlightTaskEntity.fromJson(item)).toList(); + } else { + result[sn] = null; + } + }); + + setState(() { + _taskData = result; + _isLoading = false; + }); + } catch (e) { + setState(() { + _isLoading = false; + _errorMessage = '加载失败: $e'; + }); + } + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + '选择飞行任务', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 24), + _buildTimeSelector(), + const SizedBox(height: 12), + _buildDeviceSelector(), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _isLoading ? null : _loadTasks, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF165DFF), + minimumSize: const Size(double.infinity, 48), + ), + child: _isLoading + ? const CircularProgressIndicator(color: Colors.white) + : const Text('查询任务'), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + Text( + _errorMessage!, + style: const TextStyle(color: Color(0xFFF53F3F)), + ), + ], + if (_taskData.isNotEmpty) ...[ + const SizedBox(height: 16), + Flexible( + child: _buildTaskList(), + ), + ], + ], + ), + ); + } + + Widget _buildTimeSelector() { + return GestureDetector( + onTap: () => _selectDateRange(context), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF2F3F5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.calendar_today, color: Color(0xFF86909C)), + const SizedBox(width: 12), + Expanded( + child: Text( + _selectedDateRange != null + ? '${_formatDate(_selectedDateRange!.start)} - ${_formatDate(_selectedDateRange!.end)}' + : '选择时间范围', + style: const TextStyle(fontSize: 14), + ), + ), + ], + ), + ), + ); + } + + Widget _buildDeviceSelector() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF2F3F5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.devices, color: Color(0xFF86909C)), + const SizedBox(width: 12), + Expanded( + child: Text( + _selectedDeviceSn ?? '未知设备', + style: const TextStyle(fontSize: 14), + ), + ), + ], + ), + ); + } + + Widget _buildTaskList() { + final tasks = _taskData[_selectedDeviceSn]; + + if (tasks == null || tasks.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: Text('暂无任务数据'), + ), + ); + } + + return ListView.builder( + shrinkWrap: true, + itemCount: tasks.length, + itemBuilder: (context, index) { + final task = tasks[index]; + return _buildTaskItem(task); + }, + ); + } + + Widget _buildTaskItem(FlightTaskEntity task) { + return GestureDetector( + onTap: () { + widget.onTaskSelected([task]); + }, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE5E6EB)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + task.name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Text( + '类型: ${task.taskType}', + style: const TextStyle(fontSize: 12, color: Color(0xFF86909C)), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: _getStatusColor(task.status).withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + task.status, + style: TextStyle( + fontSize: 11, + color: _getStatusColor(task.status), + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + '开始: ${_formatDateTime(task.beginAt)}', + style: const TextStyle(fontSize: 12, color: Color(0xFF86909C)), + ), + Text( + '结束: ${_formatDateTime(task.endAt)}', + style: const TextStyle(fontSize: 12, color: Color(0xFF86909C)), + ), + ], + ), + ), + ); + } + + Color _getStatusColor(String status) { + switch (status.toLowerCase()) { + case 'success': + return const Color(0xFF00B42A); + case 'failed': + return const Color(0xFFF53F3F); + case 'running': + return const Color(0xFF165DFF); + default: + return const Color(0xFF86909C); + } + } + + String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + String _formatDateTime(String dateTimeStr) { + try { + final dateTime = DateTime.parse(dateTimeStr); + return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; + } catch (e) { + return dateTimeStr; + } + } +} diff --git a/lib/features/v2/home/presentation/pages/home_v2_page.dart b/lib/features/v2/home/presentation/pages/home_v2_page.dart index f86ae1d9..3e07d9f4 100644 --- a/lib/features/v2/home/presentation/pages/home_v2_page.dart +++ b/lib/features/v2/home/presentation/pages/home_v2_page.dart @@ -12,6 +12,8 @@ import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/quick_ent import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/power_trend_chart.dart'; import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/plant_overview_card.dart'; import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/tcp_status_indicator.dart'; +import 'package:maibu_satabot_v2/components/device_status_modal.dart'; +import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_bloc.dart'; class HomeV2Page extends StatefulWidget { const HomeV2Page({super.key}); @@ -40,7 +42,11 @@ class _HomeV2PageState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.error_outline, size: 48, color: Color(0xFF86909C)), + const Icon( + Icons.error_outline, + size: 48, + color: Color(0xFF86909C), + ), const SizedBox(height: 16), Text(state.message), const SizedBox(height: 16), @@ -81,9 +87,12 @@ class _HomeV2PageState extends State { child: StreamBuilder( stream: sl().stream, builder: (context, snapshot) { - final siteState = snapshot.data ?? sl().state; + final siteState = + snapshot.data ?? + sl().state; return Text( - siteState.selectedSite?.siteName ?? '选择电站', + siteState.selectedSite?.siteName ?? + '选择电站', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( @@ -96,12 +105,18 @@ class _HomeV2PageState extends State { ), ), const SizedBox(width: 4), - const Icon(Icons.arrow_drop_down, size: 20, color: Color(0xFF1D2129)), + const Icon( + Icons.arrow_drop_down, + size: 20, + color: Color(0xFF1D2129), + ), ], ), ), ), - const TcpStatusIndicator(), + TcpStatusIndicator( + onTap: () => _showDeviceStatusModal(context), + ), ], ), ), @@ -117,7 +132,10 @@ class _HomeV2PageState extends State { children: [ Expanded( child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), decoration: BoxDecoration( color: const Color(0xFFF2F3F5), borderRadius: BorderRadius.circular(8), @@ -125,7 +143,11 @@ class _HomeV2PageState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.cloud_queue, size: 16, color: Color(0xFF165DFF)), + const Icon( + Icons.cloud_queue, + size: 16, + color: Color(0xFF165DFF), + ), const SizedBox(width: 6), const Text( '多云 28°C', @@ -141,7 +163,10 @@ class _HomeV2PageState extends State { const SizedBox(width: 8), Expanded( child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), decoration: BoxDecoration( color: const Color(0xFFFFF0F0), borderRadius: BorderRadius.circular(8), @@ -149,7 +174,11 @@ class _HomeV2PageState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.warning_amber_rounded, size: 16, color: Color(0xFFF53F3F)), + const Icon( + Icons.warning_amber_rounded, + size: 16, + color: Color(0xFFF53F3F), + ), const SizedBox(width: 6), const Expanded( child: Text( @@ -161,7 +190,11 @@ class _HomeV2PageState extends State { ), ), const SizedBox(width: 4), - const Icon(Icons.arrow_forward_ios, size: 12, color: Color(0xFFF53F3F)), + const Icon( + Icons.arrow_forward_ios, + size: 12, + color: Color(0xFFF53F3F), + ), ], ), ), @@ -173,7 +206,9 @@ class _HomeV2PageState extends State { const SizedBox(height: 10), StatsGrid(data: state.homeData), const SizedBox(height: 10), - PlantOverviewCard(overview: state.homeData.plantOverview), + PlantOverviewCard( + overview: state.homeData.plantOverview, + ), const SizedBox(height: 10), WorkOrderCard(stats: state.homeData.workOrderStats), const SizedBox(height: 10), @@ -182,7 +217,8 @@ class _HomeV2PageState extends State { PowerTrendChart( data: state.homeData.powerTrendData, trendType: state.trendType, - onToggleType: (type) => _bloc.add(HomeV2ToggleTrendType(type)), + onToggleType: (type) => + _bloc.add(HomeV2ToggleTrendType(type)), ), const SizedBox(height: 40), ]), @@ -215,14 +251,14 @@ class _HomeV2PageState extends State { void _showPlantSelector() { if (_bloc.state is! HomeV2Loaded) return; - + final currentState = _bloc.state as HomeV2Loaded; final sites = currentState.sites; - + if (sites.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('暂无可用电站')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('暂无可用电站'))); return; } @@ -237,7 +273,7 @@ class _HomeV2PageState extends State { bloc: _bloc, builder: (context, state) { if (state is! HomeV2Loaded) return Container(); - + return Container( padding: const EdgeInsets.all(14), constraints: BoxConstraints( @@ -262,20 +298,24 @@ class _HomeV2PageState extends State { itemBuilder: (context, index) { final site = state.sites[index]; final isSelected = state.selectedSite?.id == site.id; - + return ListTile( title: Text(site.siteName), - subtitle: site.siteCode != null && site.siteCode!.isNotEmpty + subtitle: + site.siteCode != null && site.siteCode!.isNotEmpty ? Text('编号: ${site.siteCode}') : null, trailing: isSelected - ? const Icon(Icons.check, color: Color(0xFF165DFF)) + ? const Icon( + Icons.check, + color: Color(0xFF165DFF), + ) : null, onTap: () { // 更新全局选中的场站 sl().selectSite(site); Navigator.pop(context); - + // 切换场站后,重新加载首页数据 _bloc.add(const HomeV2LoadData()); }, @@ -291,4 +331,25 @@ class _HomeV2PageState extends State { }, ); } + + // 显示设备状态模态框 - 从底部滑出 + void _showDeviceStatusModal(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + builder: (BuildContext context) { + return BlocProvider.value( + value: context.read(), + child: const DeviceStatusModal(), + ); + }, + ); + } } diff --git a/lib/features/v2/home/presentation/widgets/tcp_status_indicator.dart b/lib/features/v2/home/presentation/widgets/tcp_status_indicator.dart index 22696148..d1f48264 100644 --- a/lib/features/v2/home/presentation/widgets/tcp_status_indicator.dart +++ b/lib/features/v2/home/presentation/widgets/tcp_status_indicator.dart @@ -4,7 +4,9 @@ import 'package:maibu_satabot_v2/core/network/tcp/tcp_status_cubit.dart'; /// TCP 连接状态指示灯组件 class TcpStatusIndicator extends StatefulWidget { - const TcpStatusIndicator({super.key}); + final VoidCallback? onTap; // 点击回调 + + const TcpStatusIndicator({super.key, this.onTap}); @override State createState() => _TcpStatusIndicatorState(); @@ -21,16 +23,17 @@ class _TcpStatusIndicatorState extends State void initState() { super.initState(); _tcpStatusCubit = GetIt.I(); - + _controller = AnimationController( duration: const Duration(milliseconds: 1500), vsync: this, )..repeat(reverse: true); - - _animation = Tween(begin: 0.6, end: 1.0).animate( - CurvedAnimation(parent: _controller, curve: Curves.easeInOut), - ); - + + _animation = Tween( + begin: 0.6, + end: 1.0, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + _tcpStatusCubit.stream.listen((state) { if (state.status != TcpConnectionStatus.disconnected) { _hasActivity = true; @@ -81,28 +84,32 @@ class _TcpStatusIndicatorState extends State return Tooltip( message: tooltip, - child: AnimatedBuilder( - animation: _animation, - builder: (context, child) { - final opacity = shouldAnimate ? _animation.value : 1.0; - return Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: color.withOpacity(opacity), - shape: BoxShape.circle, - boxShadow: state.status == TcpConnectionStatus.connected - ? [ - BoxShadow( - color: Colors.green.withOpacity(0.5 * opacity), - blurRadius: 6 * opacity, - spreadRadius: 2 * opacity, - ), - ] - : [], - ), - ); - }, + child: InkWell( + onTap: widget.onTap, + borderRadius: BorderRadius.circular(6), + child: AnimatedBuilder( + animation: _animation, + builder: (context, child) { + final opacity = shouldAnimate ? _animation.value : 1.0; + return Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: color.withOpacity(opacity), + shape: BoxShape.circle, + boxShadow: state.status == TcpConnectionStatus.connected + ? [ + BoxShadow( + color: Colors.green.withOpacity(0.5 * opacity), + blurRadius: 6 * opacity, + spreadRadius: 2 * opacity, + ), + ] + : [], + ), + ); + }, + ), ), ); } diff --git a/pubspec.lock b/pubspec.lock index c3347c06..5a0ee76e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1730,7 +1730,7 @@ packages: source: hosted version: "1.1.0" xml: - dependency: transitive + dependency: "direct main" description: name: xml sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" diff --git a/pubspec.yaml b/pubspec.yaml index e8be34cb..b2374b3a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -56,6 +56,7 @@ dependencies: bloc: ^9.2.0 image: ^4.1.7 # 用于图片格式转换 http: ^1.1.0 + xml: ^6.5.0 logger: ^2.0.0 # 最新版本可查 pub.dev