diff --git a/lib/components/route_type_picker.dart b/lib/components/route_type_picker.dart new file mode 100644 index 00000000..ae0669da --- /dev/null +++ b/lib/components/route_type_picker.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; + +import 'package:maibu_satabot_v2/features/home/presentation/widgets/common/enum.dart'; + +/// 保存打点前的「规划类型」选择框:对应后端 workRecord.routeType(必填)。 +/// +/// 后端该字段缺失时保存接口直接报「未选择规划类型」。新版「地图与区域编辑」 +/// 与旧版路径规划共用 /iot/workRecord/add,所以两边共用这一个弹窗, +/// 保证取值与交互一致。返回 null 表示用户取消。 +/// +/// [maxWidth]:横屏页传较小值(如 240),避免默认 AlertDialog 撑满整屏; +/// [insetPadding]:横屏页收窄两侧留白; +/// [initial]:默认选中项,默认「割草任务」。 +Future showRouteTypePicker( + BuildContext context, { + String title = '选择规划类型', + RouteType? initial, + double? maxWidth, + EdgeInsets? insetPadding, +}) { + return showDialog( + context: context, + builder: (dialogContext) => _RouteTypeDialog( + title: title, + initial: initial ?? RouteType.mowingTask, + maxWidth: maxWidth, + insetPadding: insetPadding, + ), + ); +} + +class _RouteTypeDialog extends StatefulWidget { + const _RouteTypeDialog({ + required this.title, + required this.initial, + this.maxWidth, + this.insetPadding, + }); + + final String title; + final RouteType initial; + final double? maxWidth; + final EdgeInsets? insetPadding; + + @override + State<_RouteTypeDialog> createState() => _RouteTypeDialogState(); +} + +class _RouteTypeDialogState extends State<_RouteTypeDialog> { + late RouteType _picked = widget.initial; + + @override + Widget build(BuildContext context) { + final Color accent = Theme.of(context).colorScheme.primary; + return AlertDialog( + insetPadding: widget.insetPadding ?? + const EdgeInsets.symmetric(horizontal: 40.0, vertical: 24.0), + title: Text(widget.title, style: const TextStyle(fontSize: 18)), + // 横屏可视高度只有 300dp 上下(刘海机型更少):标题/内容/按钮的 + // 上下留白全部压紧,再加下面的可滚动兼底,避免 RenderFlex 溢出 + titlePadding: const EdgeInsets.fromLTRB(24, 14, 24, 4), + contentPadding: const EdgeInsets.fromLTRB(24, 4, 24, 0), + actionsPadding: const EdgeInsets.fromLTRB(24, 4, 24, 10), + content: SizedBox( + width: widget.maxWidth, + // 兼容极矮屏幕(横屏 + 挖孔 + 系统栏):内容超高时滚动,不再顶破弹窗 + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final RouteType type in RouteType.values) + _buildOption(type, accent), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(_picked), + child: Text( + '确定', + style: TextStyle( + color: accent, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ); + } + + Widget _buildOption(RouteType type, Color accent) { + final bool selected = type == _picked; + return InkWell( + onTap: () => setState(() => _picked = type), + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Row( + children: [ + // 自绘单选圈:不用 RadioListTile,避开其 groupValue/onChanged 已废弃的告警 + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: selected ? accent : Colors.grey.shade400, + width: selected ? 6 : 1.5, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + type.label, + style: TextStyle( + fontSize: 15, + color: selected ? accent : null, + fontWeight: selected ? FontWeight.w600 : null, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/core/network/tcp/tcp_client.dart b/lib/core/network/tcp/tcp_client.dart index 5bf5d3e4..dc95ae8a 100644 --- a/lib/core/network/tcp/tcp_client.dart +++ b/lib/core/network/tcp/tcp_client.dart @@ -549,8 +549,32 @@ class TcpClient { _socket!.add(data); } - void sendRaw(int cmd, List payload) { - if (_socket == null) return; + /// 🔥 断线丢帧日志节流时间戳:控制循环每 200ms 就会撞一次「未连接」, + /// 全量打印会淹掉日志文件,故 5 秒内只记一次(丢帧本身仍如实返回 false)。 + DateTime? _lastSendDropLogAt; + + /// 把一帧协议数据写入 socket。 + /// + /// 返回 `false` = 这一帧**根本没交给系统 socket**(未连接,或写入抛异常)。 + /// 🔥 原来的 void 版本在 `_socket == null` 时静默 return,调用方完全无从知晓: + /// `stopAllMovement()` 连发的 30 帧停止指令会在断线窗口里一帧都不出网, + /// 而代码以为「已发送」——设备只保留了断开前最后一帧运动指令,正是 + /// 「松手了车还在走」的一条隐藏通路。现在必须由调用方判断结果自行兜底。 + bool sendRaw(int cmd, List payload) { + if (_socket == null) { + final now = DateTime.now(); + if (_lastSendDropLogAt == null || + now.difference(_lastSendDropLogAt!) >= const Duration(seconds: 5)) { + _lastSendDropLogAt = now; + _logger.logWithLevel( + '🔗 [联调-TCP] sendRaw ❌未连接(_socket=null),本帧丢弃 ' + 'cmd=0x${cmd.toRadixString(16).padLeft(2, '0')}(同类丢弃 5s 内不再重复打印)', + level: 'ERROR', + shouldLog: true, + ); + } + return false; + } final builder = BytesBuilder() ..addByte(0xAB) @@ -573,12 +597,14 @@ class TcpClient { 'len=${packet.length} frameHex=${_hex(packet)}', shouldLog: true, ); + return true; } catch (e) { _logger.logWithLevel( '🔗 [联调-TCP] sendRaw ❌写入socket失败 cmd=0x${cmd.toRadixString(16).padLeft(2, '0')} err=$e', level: 'ERROR', shouldLog: true, ); + return false; } } diff --git a/lib/features/autonomous_work/domain/entities/return_plan_models.dart b/lib/features/autonomous_work/domain/entities/return_plan_models.dart new file mode 100644 index 00000000..f2f47467 --- /dev/null +++ b/lib/features/autonomous_work/domain/entities/return_plan_models.dart @@ -0,0 +1,57 @@ +import 'package:latlong2/latlong.dart'; + +/// 一键返航方案的领域模型:返航点 + 线上禁行区 + 场站上下文。 +/// 只描述业务数据,后端字段名(size_x / source / tempObstaclePoints 等) +/// 全部留给 data 层网关映射,页面与领域层不感知。 + +/// 单个返航点:坐标 + 绑定的设备 SN(MC700-…)+ 名称 +class ReturnRestPoint { + const ReturnRestPoint({ + required this.position, + required this.alias, + this.bindDeviceId, + }); + + final LatLng position; + final String alias; + final String? bindDeviceId; +} + +/// 单个障碍(禁行区):中心 + 尺寸(米) + 形状 + 名称 + 来源 +class ReturnObstaclePoint { + const ReturnObstaclePoint({ + required this.center, + required this.alias, + this.shapeType = 'rect', + this.sizeX = 0, + this.sizeY = 0, + this.source = 'platform', + }); + + final LatLng center; + final String alias; + final String shapeType; // rect / circle + final double sizeX; // 宽(米,对应经度方向) + final double sizeY; // 高(米,对应纬度方向) + final String source; // 默认 platform +} + +/// 一次返航方案提交(对应 /system/siteMapInfo/save) +class ReturnPlanDraft { + const ReturnPlanDraft({ + required this.siteId, + required this.orgId, + this.restPoints = const [], + this.obstacles = const [], + this.id, + }); + + /// siteMapInfo 主键:更新已有方案时传,新建传 null(省略由后端插入) + final int? id; + final int siteId; + final int orgId; + final List restPoints; + final List obstacles; + + bool get isEmpty => restPoints.isEmpty && obstacles.isEmpty; +} diff --git a/lib/features/devices/domain/entities/running_status_entity.dart b/lib/features/devices/domain/entities/running_status_entity.dart index 544d1c40..20043ad3 100644 --- a/lib/features/devices/domain/entities/running_status_entity.dart +++ b/lib/features/devices/domain/entities/running_status_entity.dart @@ -37,7 +37,10 @@ class RunningStatusEntity extends Equatable { // === 扩展信息 (字段 18-23) === final String timestamp; // [18] 时间戳 (格式:20-1-11-31 08:00:00) final String knifeCuttingSpeed; // [19] 割刀速度 - final String controlMode; // [20] 控制模式 + /// [20] 控制模式。**空串 = 后端从未给过**(UI 显示「未知」); + /// 协议值 0=未知 1=本地 2=自主任务 3=远程遥控。 + /// 以前默认写死 '0',把「没数据」也当成一个有效档位,UI 无从分辨。 + final String controlMode; final String battery; // [21] 电量百分比 final String workingArea; // [22] 工作面积 final String obstacleFlag; // [23] 障碍物标志 @@ -69,7 +72,7 @@ class RunningStatusEntity extends Equatable { this.longitude = 0.0, this.timestamp = '', this.knifeCuttingSpeed = '0', - this.controlMode = '0', + this.controlMode = '', this.battery = '0', this.workingArea = '0', this.obstacleFlag = '0', @@ -143,7 +146,8 @@ class RunningStatusEntity extends Equatable { // 扩展信息 (保持原始字符串,由调用方决定如何解析) timestamp: fields[18].trim(), knifeCuttingSpeed: fields[19].trim().isNotEmpty ? fields[19].trim() : '0', - controlMode: fields[20].trim().isNotEmpty ? fields[20].trim() : '0', + // 空串原样保留:表示后端这一帧没带该字段,UI 据此显示「未知」 + controlMode: fields[20].trim(), battery: fields[21].trim().isNotEmpty ? fields[21].trim() : '0', workingArea: fields[22].trim().isNotEmpty ? fields[22].trim() : '0', obstacleFlag: fields[23].trim().isNotEmpty ? fields[23].trim() : '0', diff --git a/lib/features/devices/domain/usecases/save_device_schedule_plan_usecase.dart b/lib/features/devices/domain/usecases/save_device_schedule_plan_usecase.dart new file mode 100644 index 00000000..75582b5a --- /dev/null +++ b/lib/features/devices/domain/usecases/save_device_schedule_plan_usecase.dart @@ -0,0 +1,32 @@ +import 'package:fpdart/fpdart.dart'; +import '../../../../core/domain/usecases/base_usecase.dart'; +import '../../../../core/error/failure.dart'; +import '../entities/device_schedule_plan_entity.dart'; +import '../repositories/device_task_repository.dart'; + +/// 创建设备定时任务(定时作业计划,按天/周/月循环触发)。 +/// +/// 提交前先执行实体的本地校验(与后端入参规则逐条对齐, +/// 含「生效区间内必须存在可执行日期」的业务强校验), +/// 本地能拦的直接 Left,不再浪费一次失败往返。 +class SaveDeviceSchedulePlanUseCase + implements BaseUseCase { + final DeviceTaskRepository repository; + + SaveDeviceSchedulePlanUseCase(this.repository); + + @override + Future> call( + SaveDeviceSchedulePlanParams params, + ) async { + final error = params.plan.getValidationError(); + if (error != null) return Left(Failure(error)); + return await repository.saveDeviceSchedulePlan(plan: params.plan); + } +} + +class SaveDeviceSchedulePlanParams { + final DeviceSchedulePlanEntity plan; + + SaveDeviceSchedulePlanParams({required this.plan}); +} diff --git a/lib/features/home/presentation/pages/running_status_page.dart b/lib/features/home/presentation/pages/running_status_page.dart index 6ad9e72e..05d7e754 100644 --- a/lib/features/home/presentation/pages/running_status_page.dart +++ b/lib/features/home/presentation/pages/running_status_page.dart @@ -17,6 +17,7 @@ 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 '../../../../components/control_mode_text.dart'; import '../../../auth/presentation/bloc/auth_cubit.dart'; import '../../../devices/presentation/bloc/devices_cubit.dart'; import '../../../devices/presentation/bloc/device_status_bloc.dart'; @@ -800,7 +801,7 @@ class _RunningStatusPageState extends State with WidgetsBindi 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.control_mode')), _buildTableCell(controlModeDisplayText(AppLocalizations.of(context), 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))]), ], @@ -867,15 +868,9 @@ class _RunningStatusPageState extends State with WidgetsBindi } } - // 辅助方法:根据控制模式值返回翻译键 - String _getControlModeKey(int modeValue) { - switch (modeValue) { - case 3: - return 'running_status.remote_control'; - default: - return 'running_status.local_control'; - } - } + // 控制模式文案已下沉到 lib/components/control_mode_text.dart(与设备数据监控共用)。 + // 原来此处是 int.parse(status.controlMode) + default 一律当「本地控制」: + // 后端一发非数字直接抛 FormatException,发空值(没给)也会显示成「本地控制」。 @override Widget build(BuildContext context) { diff --git a/lib/features/home/presentation/widgets/map/testmap_pages.dart b/lib/features/home/presentation/widgets/map/testmap_pages.dart index e2875956..3c155aad 100644 --- a/lib/features/home/presentation/widgets/map/testmap_pages.dart +++ b/lib/features/home/presentation/widgets/map/testmap_pages.dart @@ -47,11 +47,13 @@ import 'package:maibu_satabot_v2/features/home/presentation/widgets/BottomDirect import 'package:maibu_satabot_v2/features/home/presentation/widgets/common/commonFn.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/common/enum.dart'; import 'package:maibu_satabot_v2/components/input_confirm_dialog.dart'; +import 'package:maibu_satabot_v2/components/route_type_picker.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/common/tracepoint.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/CenterLocation.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/HeadingPointer.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/dji_tile_layer.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/geoserver_suzhou_layer.dart'; +import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/app_base_map_layer.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/obsToast.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/startpoint_area.dart'; import 'package:image/image.dart' as _image; // 注意命名空间冲突,使用 as img @@ -904,8 +906,14 @@ class _MapPageEnterpriseState extends State { } } - void _showSavePlotDialog() { + void _showSavePlotDialog() async { final loc = AppLocalizations.of(context); + + // 后端 workRecord.routeType 必填(与新版「地图与区域编辑」共用同一接口): + // 先选规划类型,再输地块名称;取消则不进入保存。 + final RouteType? routeType = await showRouteTypePicker(context); + if (routeType == null || !mounted) return; + showInputConfirmDialog( context: context, title: loc.translate('route_planning.save_plot'), // 自定义标题 @@ -947,10 +955,10 @@ class _MapPageEnterpriseState extends State { // 4. 处理截图结果并保存 if (mounted) { if (imgBase64 != null) { - _savePlotData(plotName, imgBase64); // 传入 Base64 + _savePlotData(plotName, imgBase64, routeType.code); // 传入 Base64 } else { // 截图失败时,允许空图片保存(可根据业务调整为强制失败) - _savePlotData(plotName, null); + _savePlotData(plotName, null, routeType.code); _showPageToast( message: loc.translate('route_planning.save_success') + @@ -1179,7 +1187,11 @@ class _MapPageEnterpriseState extends State { debugPrint('📤 [保存地块-请求详情] ================= END ================='); } - Future _savePlotData(String plotName, String? imgBase64) async { + Future _savePlotData( + String plotName, + String? imgBase64, + String routeType, + ) async { debugPrint('🔍 [保存地块] ====== _savePlotData 被调用, plotName=$plotName ======'); final loc = AppLocalizations.of(context); // 🔥 V2 适配:使用 SiteCubit 获取站点ID(从 v2 首页场站选择获取,有默认值) @@ -1247,7 +1259,12 @@ class _MapPageEnterpriseState extends State { ? WorkMode.bow.modeCode : WorkMode.custom.modeCode; workRecord['mode'] = modeCode; - debugPrint('📤 [保存地块] workRecord 附加字段: orgId=$orgId, mode=$modeCode'); + // 🔥 后端必填的规划类型(Java 枚举 RouteType.name()),与 mode 平级; + // 缺失时后端直接返回「未选择规划类型」 + workRecord['routeType'] = routeType; + debugPrint( + '📤 [保存地块] workRecord 附加字段: orgId=$orgId, mode=$modeCode, routeType=$routeType', + ); final String workRecordJson = jsonEncode(workRecord); final http.MultipartRequest request = http.MultipartRequest( 'POST', @@ -2309,15 +2326,8 @@ class _MapPageEnterpriseState extends State { onTap: (_, __) {}, // 空实现,禁用地图点击响应 ), children: [ - /// ESRI World Imagery(WGS84 坐标系,全球高清卫星图) - /// 替代高德地图(GCJ02),统一坐标系,无需坐标转换 - TileLayer( - urlTemplate: - 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', - userAgentPackageName: 'com.example.app', - minZoom: 0, - maxZoom: 19, - ), + /// 底图:中文=ESRI(境内稳定),非中文=Google(英文地名,不可达自动回退) + const AppBaseMapLayer(maxZoom: 22), /// GeoServer 全部场站无人机高清影像叠加层(WGS84 基准,无偏移) /// 图层范围优先取自 GeoServer GetCapabilities(动态),失败回退内置兜底 diff --git a/lib/features/remote_control/presentation/widgets/right_joystick_area.dart b/lib/features/remote_control/presentation/widgets/right_joystick_area.dart index 121b0e50..02c89f03 100644 --- a/lib/features/remote_control/presentation/widgets/right_joystick_area.dart +++ b/lib/features/remote_control/presentation/widgets/right_joystick_area.dart @@ -55,9 +55,12 @@ class _RightJoystickAreaState extends State { color: const Color(0xE5333333), borderRadius: BorderRadius.circular(12), ), - child: const Text( - '暂未收到该机器的推送,不能控制', - style: TextStyle( + child: Text( + AppLocalizations.of( + context, + ).translate('remote_control.joystick_no_data'), + textAlign: TextAlign.center, + style: const TextStyle( color: Colors.white, fontSize: 14, ), diff --git a/lib/features/remote_control/presentation/widgets/top_status_bar.dart b/lib/features/remote_control/presentation/widgets/top_status_bar.dart index 5b6e27b9..66affe32 100644 --- a/lib/features/remote_control/presentation/widgets/top_status_bar.dart +++ b/lib/features/remote_control/presentation/widgets/top_status_bar.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/svg.dart'; import 'package:go_router/go_router.dart'; import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; +import 'package:maibu_satabot_v2/components/control_mode_text.dart'; import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/control_mode.dart'; import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_state.dart'; import 'package:maibu_satabot_v2/features/remote_control/presentation/widgets/status_chip.dart'; @@ -317,14 +318,14 @@ class TopStatusBar extends StatelessWidget { } Widget _buildControlModeChip(String mode, BuildContext context) { - String displayText; - if (mode == '') { - displayText = AppLocalizations.of( - context, - ).translate('remote_control.mode_none'); - } else { - displayText = mode; - } + // mode 为协议原始值(0未知 / 1本地 / 2自主任务 / 3远程遥控), + // 文案统一走 lib/components/control_mode_text.dart,与「设备数据监控」同一口径。 + // 以前此处把 '' 当「无模式」、又把 0 归到「本地模式」,两者都不对: + // 现在 '' = 后端没给 = 未知,'0' = 未知(协议本意),'2' = 自主任务。 + // 清空缓存时 cubit 塞的是 '--' 占位,保持原样不动。 + final String displayText = mode == '--' + ? mode + : controlModeDisplayText(AppLocalizations.of(context), mode); // switch (mode) { // case "蓝牙模式": // displayText = '蓝牙模式'; diff --git a/lib/features/v2/device_list/presentation/pages/route_editor_page_v2.dart b/lib/features/v2/device_list/presentation/pages/route_editor_page_v2.dart index cb638cec..e244d471 100644 --- a/lib/features/v2/device_list/presentation/pages/route_editor_page_v2.dart +++ b/lib/features/v2/device_list/presentation/pages/route_editor_page_v2.dart @@ -1,25 +1,44 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:image/image.dart' as img; import 'package:latlong2/latlong.dart'; import 'package:maibu_satabot_v2/components/capsule_toast.dart'; import 'package:maibu_satabot_v2/components/input_confirm_dialog.dart'; +import 'package:maibu_satabot_v2/components/route_type_picker.dart'; import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart'; import 'package:maibu_satabot_v2/core/di/injection.dart'; import 'package:maibu_satabot_v2/core/router/route_paths.dart'; import 'package:maibu_satabot_v2/features/autonomous_work/domain/entities/work_session_models.dart'; +import 'package:maibu_satabot_v2/features/autonomous_work/domain/entities/return_plan_models.dart'; +import 'package:maibu_satabot_v2/features/autonomous_work/data/v2_work_store.dart'; import 'package:maibu_satabot_v2/features/autonomous_work/domain/repositories/autonomous_work_gateway.dart'; +import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart'; +import 'package:maibu_satabot_v2/features/devices/domain/entities/running_status_entity.dart'; +import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_bloc.dart'; +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/home/presentation/widgets/common/enum.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/CenterLocation.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/video.dart'; import 'package:maibu_satabot_v2/features/pv_control/presentation/widgets/pv_work_map.dart'; import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart'; +import 'package:maibu_satabot_v2/features/regions/domain/entities/region_draft.dart'; +import 'package:maibu_satabot_v2/features/regions/presentation/cubit/region_editor_cubit.dart'; +import 'package:maibu_satabot_v2/features/regions/presentation/cubit/region_list_cubit.dart'; +import 'package:maibu_satabot_v2/features/regions/presentation/widgets/region_form_dialog.dart'; +import 'package:maibu_satabot_v2/features/regions/presentation/widgets/region_list_panel.dart'; +import 'package:maibu_satabot_v2/features/regions/presentation/widgets/region_boundary_layer.dart'; +import 'package:maibu_satabot_v2/features/regions/presentation/widgets/no_device_crosshair_hint.dart'; import '../widgets/autonomous/work_ui.dart'; @@ -32,18 +51,41 @@ import '../widgets/autonomous/work_ui.dart'; /// - 生成规则:覆盖模式 path = 闭合打点环,自定义模式 path = 打点、outer 为空; /// - 保存:multipart 提交 /iot/workRecord/add(带地图截图、siteId/orgId/mode)。 /// -/// 差异只在于:本页不向路径规划页的本地缓存(SharedPreferences)写状态, +/// 草稿使用新版独立缓存,不读取或覆盖路径规划页的本地状态, /// 几何与保存全部经 [AutonomousWorkGateway],页面不直接调接口。 class RouteEditorPageV2 extends StatefulWidget { - const RouteEditorPageV2({super.key, this.deviceSn = ''}); + const RouteEditorPageV2({ + super.key, + this.deviceSn = '', + this.exitToLandscape = false, + this.initialRegion, + this.startRegionEditor = false, + }); final String deviceSn; + final RegionDraft? initialRegion; + final bool startRegionEditor; + + /// 退出本页后交还的方向归属: + /// true = 上一个页面是横屏锁定页(自主作业中心),退出时锁回横屏; + /// false = 上一个页面是竖屏页(设备详情 / 作业首页等),退出时恢复系统方向。 + final bool exitToLandscape; /// 进入转场:淡入 + 极轻缩放(260ms)。 /// 相比默认的横向滑入,这种「层级浮现」更贴合编辑器,且不带 Hero / 共享元素, /// 转场期间没有额外的布局与绘制抖动。 - static Route route({String deviceSn = ''}) { - final page = RouteEditorPageV2(deviceSn: deviceSn); + static Route route({ + String deviceSn = '', + bool exitToLandscape = false, + RegionDraft? initialRegion, + bool startRegionEditor = false, + }) { + final page = RouteEditorPageV2( + deviceSn: deviceSn, + exitToLandscape: exitToLandscape, + initialRegion: initialRegion, + startRegionEditor: startRegionEditor, + ); return PageRouteBuilder( transitionDuration: const Duration(milliseconds: 260), reverseTransitionDuration: const Duration(milliseconds: 190), @@ -71,11 +113,14 @@ class RouteEditorPageV2 extends StatefulWidget { } class _RouteEditorPageV2State extends State { - /// 左侧竖栏:与路径规划的竖浮菜单同名同功能,末尾追加「一键返航」。 - /// 「一键返航」按需求只加入口,业务暂未开发。 + /// 左侧竖栏:与路径规划竖浮菜单同名同功能,末尾追加「一键返航」。 + /// 「一键返航」为独立模块:只叠加返航点图层与面板,不读写圈地状态。 static const List<(IconData, String)> _tools = <(IconData, String)>[ (Icons.crop_free_rounded, '圈地'), - (Icons.format_list_bulleted_rounded, '列表'), + // 按需求隐藏「列表」入口(_openRouteList 实现保留,恢复时取消注释即可)。 + // ⚠️ 取消注释后,下面 _onToolTap 的 case 编号与两处「一键返航」的 _tool = 5 + // 都要整体后移一位,否则工具点击会串位。 + // (Icons.format_list_bulleted_rounded, '列表'), (Icons.train_rounded, '场站'), (Icons.navigation_rounded, '定位'), (Icons.refresh_rounded, '刷新'), @@ -83,6 +128,215 @@ class _RouteEditorPageV2State extends State { (Icons.u_turn_left_rounded, '一键返航'), ]; + late String _deviceSn; + late final RegionEditorCubit _regionEditor = sl(); + late final String? _regionOwnerId; + StreamSubscription? _regionSubscription; + late final RegionListCubit _regionList = sl(); + StreamSubscription? _regionListSubscription; + RegionListState? _lastRegionListState; + final List> _regionScopeSubscriptions = []; + bool _isRegionListOpen = false; + List _regionFocusPoints = const []; + int _regionFocusSignal = 0; + Object get _currentRegionScope => ( + sl().state.user?.userId ?? '', + sl().state.selectedSite?.id ?? 0, + ); + bool get _regionScopeCurrent => + (_regionOwnerId?.isNotEmpty ?? false) && + _currentRegionScope == (_regionOwnerId, _draftSiteId); + RobotMode _regionRobotMode = RobotMode.point; + List get _regionPoints => [ + for (final point in _regionEditor.state.draft.scope) + LatLng(point.lat, point.lng), + ]; + late final V2WorkStore _draftStore = V2WorkStore( + sl().state.user?.userId ?? '', + ); + late final int? _draftSiteId = sl().state.selectedSite?.id; + bool _draftLoaded = false; + int _draftGeneration = 0; + Timer? _draftTimer; + String get _draftKey => 'editor/$_draftSiteId/$_deviceSn'; + + Map _draftSnapshot() => { + 'points': _pointsJson(_markedPoints), + 'holes': [for (final hole in _obstacleHoles) _pointsJson(hole)], + 'holePoints': _pointsJson(_currentObstaclePoints), + 'path': _pointsJson(_pathPoints), + 'outer': _pointsJson(_outerPoints), + 'completed': _isWorkAreaCompleted, + 'robotMode': _robotMode.name, + 'areaMode': _areaMode.name, + 'workMode': _workMode.name, + 'distance': _workDistance, + 'angle': _angle, + 'routeId': _selectedRouteId, + 'returnSaved': _returnSaved, + 'returnPlanId': _returnPlanId, + 'returnPoints': [ + for (final point in _returnPoints) + { + 'lat': point.point.latitude, + 'lng': point.point.longitude, + 'deviceSn': point.deviceSn, + 'deviceLabel': point.deviceLabel, + 'alias': point.alias, + }, + ], + 'returnObstacles': [ + for (final hole in _returnObstacleHoles) _pointsJson(hole), + ], + 'returnObstacleNames': [..._returnObstacleNames], + 'returnObstacleSizes': ?>[ + for (final (double w, double h) in _returnObstacleSizes) [w, h], + ], + }; + + static List> _pointsJson(List points) => [ + for (final p in points) {'lat': p.latitude, 'lng': p.longitude}, + ]; + static List _readPoints(dynamic raw) => [ + if (raw is List) + for (final p in raw) + if (p is Map && p['lat'] is num && p['lng'] is num) + LatLng((p['lat'] as num).toDouble(), (p['lng'] as num).toDouble()), + ]; + + void _persistDraft() { + if (!_draftLoaded) return; + unawaited( + _draftStore.write(_draftKey, _draftSnapshot()).catchError((Object error) { + debugPrint('[区域编辑] 草稿保存失败: $error'); + }), + ); + } + + @override + void setState(VoidCallback fn) { + super.setState(fn); + if (!_draftLoaded) return; + _draftTimer?.cancel(); + _draftTimer = Timer(const Duration(milliseconds: 180), _persistDraft); + } + + Future _restoreDraft() async { + final generation = ++_draftGeneration; + try { + final data = await _draftStore.read(_draftKey); + if (!mounted || generation != _draftGeneration) return; + if (data != null) { + setState(() { + _markedPoints.addAll(_readPoints(data['points'])); + _currentObstaclePoints.addAll(_readPoints(data['holePoints'])); + for (final hole in data['holes'] as List? ?? []) { + _obstacleHoles.add(_readPoints(hole)); + } + _pathPoints = _readPoints(data['path']); + _outerPoints = _readPoints(data['outer']); + _isWorkAreaCompleted = data['completed'] == true; + _robotMode = RobotMode.values.firstWhere( + (m) => m.name == data['robotMode'], + orElse: () => RobotMode.robot, + ); + _areaMode = AreaMode.values.firstWhere( + (m) => m.name == data['areaMode'], + orElse: () => AreaMode.work, + ); + _workMode = WorkMode.values.firstWhere( + (m) => m.name == data['workMode'], + orElse: () => WorkMode.bow, + ); + _workDistance = (data['distance'] as num?)?.toDouble() ?? 0.5; + _angle = (data['angle'] as num?)?.toDouble() ?? 0; + _selectedRouteId = data['routeId'] as String?; + _isObstacleEditing = _currentObstaclePoints.isNotEmpty; + _isPanelOpen = _markedPoints.isNotEmpty; + for (final raw in data['returnPoints'] as List? ?? []) { + final points = _readPoints([raw]); + if (points.isNotEmpty) { + _returnPoints.add( + _ReturnPoint( + point: points.single, + deviceSn: raw['deviceSn'] as String?, + deviceLabel: raw['deviceLabel'] as String? ?? '未选择设备', + alias: raw['alias'] as String?, + ), + ); + } + } + final List obHoles = + data['returnObstacles'] as List? ?? []; + final List obNames = + data['returnObstacleNames'] as List? ?? []; + final List obSizes = + data['returnObstacleSizes'] as List? ?? []; + for (int hi = 0; hi < obHoles.length; hi++) { + final List corners = _readPoints(obHoles[hi]); + _returnObstacleHoles.add(corners); + _returnObstacleNames.add( + hi < obNames.length ? obNames[hi] as String? : null, + ); + // 优先用草稿里保存的实际尺寸;旧草稿无尺寸则从四角反算兜底 + final dynamic rawSize = hi < obSizes.length ? obSizes[hi] : null; + _returnObstacleSizes.add( + rawSize is List && rawSize.length >= 2 + ? ( + (rawSize[0] as num).toDouble(), + (rawSize[1] as num).toDouble(), + ) + : _rectSizeOf(corners), + ); + } + _returnSaved = data['returnSaved'] == true; + _returnPlanId = (data['returnPlanId'] as num?)?.toInt(); + }); + } + } catch (e) { + debugPrint('[区域编辑] 草稿恢复失败: $e'); + } finally { + if (mounted && generation == _draftGeneration) { + setState(() { + _draftLoaded = true; + if (_deviceSn.isEmpty) _robotMode = RobotMode.point; + }); + } + } + } + + Future _changeEditorDevice(String sn) async { + if (sn == _deviceSn) return; + _draftTimer?.cancel(); + _persistDraft(); + setState(() { + _draftLoaded = false; + _deviceSn = sn; + _devicePos = null; + _markedPoints.clear(); + _obstacleHoles.clear(); + _currentObstaclePoints.clear(); + _pathPoints = []; + _outerPoints = []; + _returnPoints.clear(); + _returnObstacleHoles.clear(); + _returnObstacleNames.clear(); + _returnObstacleSizes.clear(); + _returnDropObstacle = false; + _isWorkAreaCompleted = false; + _isPanelOpen = false; + _isReturnMode = false; + _isReturnListOpen = false; + _pendingReturnDraft = null; + _returnLoading = false; + _isObstacleEditing = false; + _returnSaved = false; + _selectedRouteId = null; + }); + _statusBloc.startMqttRealtimeListening(sn); + await _restoreDraft(); + } + int _tool = -1; bool _mapReady = false; @@ -106,6 +360,16 @@ class _RouteEditorPageV2State extends State { /// 操作面板当前标签:true = 地块,false = 航线 bool _panelTabLand = true; + /// 分区/障碍物打点面板是否打开(与一键返航共用同一套面板布局) + bool _isDropPanel = false; + + /// 底部打点面板模式:0=一键返航,1=分区(作业区),2=障碍物(禁行区),-1=未打开 + int get _dropMode { + if (_isReturnMode) return 0; + if (_isDropPanel) return _areaMode == AreaMode.work ? 1 : 2; + return -1; + } + /// 作业行距(米)与航线方向角(度,-1 表示最优航向) double _workDistance = 0.5; double _angle = 0.0; @@ -128,6 +392,47 @@ class _RouteEditorPageV2State extends State { /// 监控:右上角浮动视频窝(同路径规划 _isVideoDialogOpen) bool _isVideoOpen = false; + // ===== 一键返航:返航点编辑(独立模块,不读写圈地状态)===== + /// 是否处于返航打点模式(与圈地面板互斥) + bool _isReturnMode = false; + + /// 返航方案是否已保存(本地暂存标记,仅界面) + bool _returnSaved = false; + + /// 返航方案保存请求进行中(防重复点击 + 按钮 loading) + bool _savingReturn = false; + + /// 进入返航时正在按场站拉取已有方案(selectBySiteId):期间盖一层 loading + bool _returnLoading = false; + + /// 一键返航方案列表浮层:查到已有方案时先弹列表,选中它才进编辑器=修改态 + bool _isReturnListOpen = false; + + /// selectBySiteId 拉到的方案(含完整几何):点列表项时直接回填,不再查详情 + ReturnPlanDraft? _pendingReturnDraft; + + /// 后端 siteMapInfo 主键:拿到后再次保存走「更新」,为 null 则每次新建 + int? _returnPlanId; + + /// 新增返航点时使用的默认设备 SN + String? _returnDeviceSn; + + /// 已打返航点(按打点顺序下发返航) + final List<_ReturnPoint> _returnPoints = <_ReturnPoint>[]; + + /// 返航打点子模式:false = 打返航点,true = 在返航线上打障碍矩形 + bool _returnDropObstacle = false; + + /// 返航方案独立的障碍禁行区(与圈地 _obstacleHoles 互不影响) + final List> _returnObstacleHoles = >[]; + + /// 返航障碍自定义名字:与 _returnObstacleHoles 一一对应(null=用尺寸默认名) + final List _returnObstacleNames = []; + + /// 返航障碍的实际尺寸(米):与 _returnObstacleHoles 一一对应, + /// 保存接口时直接用用户填写的宽高,避免从四角反算出现 3.99999 的浮点漂移。 + final List<(double, double)> _returnObstacleSizes = <(double, double)>[]; + final List _markedPoints = []; // 作业区打点(WGS84) final List> _obstacleHoles = >[]; // 已完成的禁行区 final List _currentObstaclePoints = []; // 正在画的禁行区 @@ -142,10 +447,124 @@ class _RouteEditorPageV2State extends State { static const MethodChannel _windowChannel = MethodChannel('satabot/window'); + /// 方向归属:本页当前是否持有横屏。 + /// 跳到竖屏页(路径规划 / 场站)期间先置 false,避免那些页面被整体销毁时 + /// 本页的 dispose 又把方向抢回横屏,把竖屏页顶成横屏。 + bool _holdsLandscape = true; + + // ===== 实时设备数据(与自主作业中心/远程遥控同源:全局 DeviceStatusBloc,MQTT 500ms 节流)===== + DeviceStatusBloc get _statusBloc => sl(); + + /// 从 DeviceStatus 状态里取最新机器状态快照(未收到数据为 null) + RunningStatusEntity? _statusOf(DeviceStatusState s) => + _deviceSn.isNotEmpty && s is DeviceStatusUpdated ? s.status : null; + + /// 定位质量枚举 → 文案(与设备数据监控 _getLocationQualityText 同口径); + /// short = 窄屏短版:顶栏横排空间有限,长文案会撞出 RenderFlex 溢出 + static String _qualText(int? q, {bool short = false}) => short + ? switch (q) { + null => '未知', + 0 => '无定位', + 1 => '单点', + 2 => '差分', + 3 => '惯导', + 4 => '固定解', + 5 => '浮点解', + 6 => '单点', + 7 => '差分', + 8 => '固定', + 9 => '浮点', + _ => '未知', + } + : switch (q) { + null => '定位未知', + 0 => '无定位', + 1 => '单点定位', + 2 => '差分定位', + 3 => '惯性导航', + 4 => 'RTK 固定解', + 5 => 'RTK 浮点解', + 6 => '单点(无航向)', + 7 => '差分(无航向)', + 8 => 'RTK 固定(无航向)', + 9 => 'RTK 浮点(无航向)', + _ => '定位未知', + }; + + /// 是否 RTK 固定解(4=固定,8=固定无航向) + static bool _isRtkFixed(int? q) => q == 4 || q == 8; + + /// 实时电量(%):仅当已收到 host 实时数据时取 status.battery;未收到返回 null + static double? _batteryOf(RunningStatusEntity? s) => + (s != null && s.hostDataReceived) ? double.tryParse(s.battery) : null; + @override void initState() { super.initState(); + _regionOwnerId = sl().state.user?.userId; + _regionEditor.initialize( + widget.initialRegion ?? RegionDraft(siteId: _draftSiteId ?? 0), + ); + _regionSubscription = _regionEditor.stream.listen((_) { + if (mounted) setState(() {}); + }); + _regionListSubscription = _regionList.stream.listen((state) { + if (!mounted) return; + final previous = _lastRegionListState; + _lastRegionListState = state; + setState(() {}); + if (state.status == RegionListStatus.ready && + !_isDropPanel && + (_isRegionListOpen || + (!_isPanelOpen && + !_isReturnMode && + _pathPoints.isEmpty && + _outerPoints.isEmpty && + _markedPoints.isEmpty)) && + (!identical(previous?.regions, state.regions) || + previous?.selectedId != state.selectedId)) { + _focusRegionSelection(); + } + }); + _regionScopeSubscriptions.add( + sl().stream + .map((s) => s.selectedSite?.id) + .distinct() + .listen((_) => _reloadRegions()), + ); + _regionScopeSubscriptions.add( + sl().stream + .map((s) => s.user?.userId) + .distinct() + .listen((_) => _reloadRegions()), + ); + unawaited(_reloadRegions()); + _deviceSn = widget.deviceSn.trim(); + if (_deviceSn.isEmpty) { + _deviceSn = sl().state.selectedDevice?.deviceName ?? ''; + if (_deviceSn.isEmpty && _devices.length == 1) { + _deviceSn = _devices.single.deviceName; + } + } + unawaited( + _restoreDraft().then((_) { + if (mounted && + (widget.startRegionEditor || widget.initialRegion != null)) { + setState(() { + _isDropPanel = true; + _isPanelOpen = false; + _areaMode = AreaMode.work; + _regionFocusPoints = _regionPoints; + _regionFocusSignal++; + }); + } + }), + ); _enterLandscape(); + // 顶栏 RTK / 电量 需要实时机器状态:订阅本设备 MQTT(同自主作业中心,幂等兑底) + if (_deviceSn.isNotEmpty) { + _statusBloc.startMqttRealtimeListening(_deviceSn); + } // 转场期间先不挂底图:首次解码瓦片会打断 260ms 的入场动画, // 等转场结束后再挂载并淡入,保证进场「平滑快速」。 WidgetsBinding.instance.addPostFrameCallback((_) { @@ -157,11 +576,31 @@ class _RouteEditorPageV2State extends State { @override void dispose() { - // 离开本页恢复竖屏 + 系统栏 + 不占用挖孔(同自主作业中心) - _restoreOrientation(); + _regionSubscription?.cancel(); + _regionEditor.close(); + _regionListSubscription?.cancel(); + for (final subscription in _regionScopeSubscriptions) { + subscription.cancel(); + } + _regionList.close(); + _draftTimer?.cancel(); + _persistDraft(); + // 交还方向:系统返回键 / 侧滑返回不经过返回按钮,由这里兜底 + _exitOrientation(); super.dispose(); } + /// 退出本页时把方向还给「上一个页面该有的形态」: + /// 从自主作业中心(横屏锁定)进来要还横屏,从竖屏页进来恢复系统方向。 + void _exitOrientation() { + if (!_holdsLandscape) return; // 方向已交给子页,不要抢回来 + if (widget.exitToLandscape) { + _enterLandscape(); + } else { + _restoreOrientation(); + } + } + /// 进入横屏(与 PV 遥控页 / 自主作业中心一致):沉浸式 + 占用挖孔区域铺满全屏 void _enterLandscape() { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); @@ -188,13 +627,28 @@ class _RouteEditorPageV2State extends State { /// 右上角「竖屏」:切回竖屏并进入原来的路径规划页,返回后恢复横屏 Future _openRoutePlanPortrait() async { + _holdsLandscape = false; _restoreOrientation(); - await SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - ]); + await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); if (!mounted) return; await context.push(RoutePaths.routePlan); - if (mounted) _enterLandscape(); + if (!mounted) return; + _holdsLandscape = true; + _enterLandscape(); + } + + /// 「场站」是竖屏 H5 页(自带 AppBar):先切竖屏再推入,返回后恢复本页横屏。 + /// extra=true 告知 DjPage 用 pop 退出:它内部的 context.go() 是整栈替换, + /// 会让下面的 await 永不完成、横屏恢复被跳过(选完场站卡竖屏的根因)。 + Future _pushDjMapPortrait() async { + _holdsLandscape = false; + _restoreOrientation(); + await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + if (!mounted) return; + await context.push(RoutePaths.djMap, extra: true); + if (!mounted) return; + _holdsLandscape = true; + _enterLandscape(); } /// 双击地图:顶部导航栏上滑隐藏 / 再双击滑出。 @@ -218,7 +672,8 @@ class _RouteEditorPageV2State extends State { prev.longitude == center.longitude) { return; } - if (_robotMode != RobotMode.point || _markedPoints.isEmpty) { + if (_robotMode != RobotMode.point || + (_markedPoints.isEmpty && _returnPoints.isEmpty && !_isReturnMode)) { _mapCenter = center; return; } @@ -244,11 +699,71 @@ class _RouteEditorPageV2State extends State { return [...points, LatLng(first.latitude, first.longitude)]; } + /// 障碍物矩形默认尺寸(米):点「+」落矩形时的初值,随后弹窗可改 X/Y + static const double _defaultObstacleW = 2.0; + static const double _defaultObstacleH = 2.0; + + /// 由中心点 + 宽(米)×高(米) 生成正北对齐矩形的 4 个角点 + /// (不闭合,交给多边形渲染自动闭合) + List _rectCorners(LatLng center, double wM, double hM) { + final double cosLat = math.cos(center.latitude * math.pi / 180.0); + final double dLat = (hM / 2.0) / 111320.0; + final double dLng = cosLat == 0 ? 0.0 : (wM / 2.0) / (111320.0 * cosLat); + final double lat = center.latitude; + final double lng = center.longitude; + return [ + LatLng(lat - dLat, lng - dLng), + LatLng(lat - dLat, lng + dLng), + LatLng(lat + dLat, lng + dLng), + LatLng(lat + dLat, lng - dLng), + ]; + } + + /// 由角点反算矩形宽高(米):用于卡片名字与编辑预填 + (double, double) _rectSizeOf(List corners) { + if (corners.length < 2) return (0.0, 0.0); + double minLat = corners.first.latitude, maxLat = minLat; + double minLng = corners.first.longitude, maxLng = minLng; + for (final p in corners) { + minLat = math.min(minLat, p.latitude); + maxLat = math.max(maxLat, p.latitude); + minLng = math.min(minLng, p.longitude); + maxLng = math.max(maxLng, p.longitude); + } + final double midLat = (minLat + maxLat) / 2.0; + final double cosLat = math.cos(midLat * math.pi / 180.0); + final double hM = (maxLat - minLat) * 111320.0; + final double wM = cosLat == 0 ? 0.0 : (maxLng - minLng) * 111320.0 * cosLat; + return (wM, hM); + } + + /// 角点集合的几何中心:编辑时以中心为锚,只改尺寸不动位置 + LatLng _rectCenter(List corners) { + if (corners.isEmpty) return const LatLng(0, 0); + double sumLat = 0.0, sumLng = 0.0; + for (final p in corners) { + sumLat += p.latitude; + sumLng += p.longitude; + } + return LatLng(sumLat / corners.length, sumLng / corners.length); + } + + String _fmtRectName(double wM, double hM) => + '${wM.toStringAsFixed(1)}m×${hM.toStringAsFixed(1)}m'; + + String _rectName(List corners) { + final (double w, double h) = _rectSizeOf(corners); + return _fmtRectName(w, h); + } + /// 增加点(复刻 _addMarkedPoint):准星取地图中心,设备取实时坐标。 void _addMarkedPoint() { if (_areaMode == AreaMode.work && _isWorkAreaCompleted) { - _toast('作业区域已完成,无法继续添加打点!'); - return; + setState(() { + _isWorkAreaCompleted = false; + _pathPoints = []; + _outerPoints = []; + }); } LatLng? point; @@ -280,29 +795,23 @@ class _RouteEditorPageV2State extends State { return; } - if (_markedPoints.isEmpty) { - _toast('请先完成作业区域打点!'); - return; - } - setState(() { - _currentObstaclePoints.add(point!); - _isObstacleEditing = true; - }); - _toast('已添加第 ${_currentObstaclePoints.length} 个禁行区打点'); + // 障碍物与分区相互独立:不依赖作业区打点,随时可单独落矩形。 + // 点一下在准星/设备中心落一个默认尺寸矩形,随即弹窗改 X/Y(尺寸即名字) + final LatLng center = point!; + _showRectSizeDialog( + center: center, + w: _defaultObstacleW, + h: _defaultObstacleH, + onOk: (double w, double h) { + setState(() { + _obstacleHoles.add(_rectCorners(center, w, h)); + }); + _toast('已添加禁行区矩形(${_fmtRectName(w, h)})'); + }, + ); } - /// 完成当前禁行区(复刻 _completeObstacle):至少 3 点成组。 - void _completeObstacle() { - if (_currentObstaclePoints.length < 3) { - _toast('障碍物区域至少需要3个打点!'); - return; - } - setState(() { - _obstacleHoles.add(List.from(_currentObstaclePoints)); - _isObstacleEditing = false; - }); - _toast('已添加第 ${_obstacleHoles.length} 个禁行区'); - } + /// 障碍物已改为「点一下落矩形即成组」,原「≥3 点成组」的 _completeObstacle 移除。 /// 生成路线(复刻 _generatePath): /// - 自定义模式:path = 打点(不闭合),outer = [],planModel = 2; @@ -350,34 +859,27 @@ class _RouteEditorPageV2State extends State { } /// 撤回(复刻 _undoAction):作业区与禁行区各自一套。 - /// 作业区只移除最后一个点,已生成的路线保留(点数≥3 时重新生成),与原版一致。 + /// 作业点变更后回到编辑态,旧预览作废,完成时重新生成。 Future _undoAction() async { if (_areaMode == AreaMode.work) { if (_markedPoints.isEmpty) { _toast('暂无作业区域点可撤回'); return; } - setState(() => _markedPoints.removeLast()); - // 已生成过路线:撤回打点后重新生成,保持地图与数据同步 - if (_isWorkAreaCompleted && _markedPoints.length >= 3) { - await _generatePath(showTips: false); - } + setState(() { + _markedPoints.removeLast(); + _isWorkAreaCompleted = false; + _pathPoints = []; + _outerPoints = []; + }); _toast('已撤回作业区域最后一个点,剩余 ${_markedPoints.length} 个点'); return; } - if (_currentObstaclePoints.isNotEmpty) { - setState(() { - _currentObstaclePoints.removeLast(); - if (_currentObstaclePoints.isEmpty) _isObstacleEditing = false; - }); - _toast('已撤回当前禁行区最后一个点,剩余 ${_currentObstaclePoints.length} 个点'); - return; - } if (_obstacleHoles.isNotEmpty) { setState(() => _obstacleHoles.removeLast()); if (_isWorkAreaCompleted) await _generatePath(showTips: false); - _toast('已撤回上一个完整禁行区,剩余 ${_obstacleHoles.length} 个'); + _toast('已撤回上一个禁行区矩形,剩余 ${_obstacleHoles.length} 个'); return; } _toast('暂无可撤回的禁行区'); @@ -403,7 +905,7 @@ class _RouteEditorPageV2State extends State { return; } - if (_obstacleHoles.isEmpty && _currentObstaclePoints.isEmpty) { + if (_obstacleHoles.isEmpty) { _toast('暂无可删除的禁行区'); return; } @@ -418,25 +920,241 @@ class _RouteEditorPageV2State extends State { /// 左侧竖栏:与路径规划竖浮菜单同一个映射 Future _onToolTap(int index) async { - setState(() => _tool = index); + // 主栏任意一项都先收起分区/障碍物打点面板(面板互斥) + setState(() { + _tool = index; + _isDropPanel = false; + _isRegionListOpen = false; + }); switch (index) { case 0: // 圈地 + // 只退出返航编辑,保留已打的返航点(避免误点丢数据) + if (_isReturnMode) _exitReturnMode(clear: false); await _openWorkModeDialog(); - case 1: // 列表 - await _openRouteList(); - case 2: // 场站 - context.push(RoutePaths.djMap); - case 3: // 定位 + // 「列表」入口已按需求隐藏(见 _tools),后续项索引整体前移一位 + case 1: // 场站(竖屏 H5 页,跳转前后切换方向) + await _pushDjMapPortrait(); + case 2: // 定位 _recenter(); - case 4: // 刷新 + case 3: // 刷新 await _refreshAll(); - case 5: // 监控 + case 4: // 监控 setState(() => _isVideoOpen = !_isVideoOpen); - case 6: // 一键返航(仅入口,业务暂未开发) - _toast('一键返航暂未开发,当前仅预留入口'); + case 5: // 一键返航 + await _enterReturnMode(); } } + Future _reloadRegions({bool force = false}) => _regionList.load( + siteId: _regionScopeCurrent ? _draftSiteId : null, + ownerId: _regionScopeCurrent ? _regionOwnerId : null, + force: force, + ); + + void _focusRegionSelection() { + if (!mounted || !_regionScopeCurrent) return; + final state = _regionList.state; + final regions = state.regions.where( + (r) => + r.scopeError == null && + (state.selectedId == null || r.id == state.selectedId), + ); + final points = [ + for (final region in regions) + for (final p in region.scope) LatLng(p.lat, p.lng), + ]; + if (points.isEmpty) return; + setState(() { + _regionFocusPoints = points; + _regionFocusSignal++; + }); + } + + void _editRegion([RegionDraft? region]) { + if (!_regionScopeCurrent || + _regionEditor.state.isSaving || + _regionList.state.deletingId != null) { + return; + } + if (region != null && region.siteId != _draftSiteId) return; + _regionEditor.initialize(region ?? RegionDraft(siteId: _draftSiteId ?? 0)); + _regionList.select(region?.id); + setState(() { + _isRegionListOpen = false; + _isDropPanel = true; + _isPanelOpen = false; + _areaMode = AreaMode.work; + _regionRobotMode = RobotMode.point; + if (region != null && region.scope.isNotEmpty) { + _regionFocusPoints = _regionPoints; + _regionFocusSignal++; + } + }); + _toast(region == null ? '移动地图,用准星依次打 4 个边界点后保存分区' : '已回填分区范围,可修改信息或重画边界'); + } + + /// 分区先展示列表,新增和修改才进入独立的四点编辑状态。 + void _onZoneTap(int index) { + if (index == 0 && !_regionScopeCurrent) { + _toast('请先选择场站并登录;场站或账号切换后需重新进入编辑器'); + return; + } + // 返航编辑中点分区类工具:先退出返航,保留已打返航点 + if (_isReturnMode) _exitReturnMode(clear: false); + setState(() { + _tool = -1; + _areaMode = index == 0 ? AreaMode.work : AreaMode.obstacle; + // 与一键返航共用同一个打点面板(布局同款,数据各归各) + _isDropPanel = index != 0; + _isRegionListOpen = index == 0; + _isPanelOpen = false; + _isReturnListOpen = false; + _isListOpen = false; + }); + if (index == 0) { + _focusRegionSelection(); + unawaited(_reloadRegions()); + } else { + _toast('障碍物模式:点「+」独立添加禁行区矩形(与分区互不影响)'); + } + } + + /// 收起分区/障碍物打点面板(回到底部状态条) + void _closeDropPanel() { + final wasRegion = _dropMode == 1; + setState(() { + _isDropPanel = false; + _isRegionListOpen = wasRegion; + _tool = -1; + }); + if (wasRegion) _focusRegionSelection(); + } + + void _addRegionPoint() { + final point = _regionRobotMode == RobotMode.point ? _mapCenter : _devicePos; + if (point == null) { + _toast( + _regionRobotMode == RobotMode.point + ? '地图中心尚未就绪,请稍后重试' + : '设备坐标无效,无法添加分区点', + ); + return; + } + final error = _regionEditor.addPoint( + RegionPoint(lat: point.latitude, lng: point.longitude), + ); + _toast(error ?? '已添加分区第 ${_regionEditor.state.draft.scope.length}/4 个点'); + } + + Future _showRegionDialog() async { + final state = _regionEditor.state; + if (state.isSaving) return; + if (state.savedWithoutId) { + _toast('该分区已保存,请点「新建」绘制下一个分区'); + return; + } + if (state.draft.scopeError != null) { + _toast(state.draft.scopeError!); + return; + } + final saved = await showRegionFormDialog( + context, + cubit: _regionEditor, + isScopeCurrent: () => + (_regionOwnerId?.isNotEmpty ?? false) && + sl().state.selectedSite?.id == + _regionEditor.state.draft.siteId && + sl().state.user?.userId == _regionOwnerId, + ); + if (!mounted || saved != true) return; + _toast('分区「${_regionEditor.state.draft.name.trim()}」已保存'); + setState(() { + _isDropPanel = false; + _isRegionListOpen = true; + _tool = -1; + }); + unawaited(_reloadRegions(force: true)); + } + + Widget _regionActionRow(_EL m) { + final state = _regionEditor.state; + return Row( + children: [ + Expanded( + child: Text( + '分区 ${state.draft.scope.length}/4 个点', + style: TextStyle(fontSize: m.fs(11), color: kWtGray2), + ), + ), + _barButton( + m, + '取消', + primary: false, + onPressed: state.isSaving ? null : _closeDropPanel, + ), + const SizedBox(width: 5), + _barButton( + m, + '撤回', + primary: false, + icon: Icons.undo_rounded, + onPressed: + state.isSaving || + state.savedWithoutId || + state.draft.scope.isEmpty + ? null + : () => _regionEditor.removePoint(state.draft.scope.length - 1), + ), + const SizedBox(width: 5), + _barButton( + m, + state.savedWithoutId ? '新建' : '重画', + primary: false, + onPressed: state.isSaving + ? null + : () => _regionEditor.initialize( + state.savedWithoutId + ? RegionDraft(siteId: state.draft.siteId) + : state.draft.withScope([]), + ), + ), + const SizedBox(width: 5), + _barButton( + m, + state.savedWithoutId + ? '已保存' + : state.draft.id == null + ? '保存' + : '修改', + primary: true, + icon: Icons.save_outlined, + onPressed: state.isSaving || state.savedWithoutId + ? null + : _showRegionDialog, + ), + ], + ); + } + + /// 打点面板卡片×删除(分区/障碍物共用): + /// 作业区删点 → 已生成路线作废;障碍物删一个矩形 + void _removeDraftPoint(int index) { + if (_areaMode == AreaMode.work) { + if (index < 0 || index >= _markedPoints.length) return; + setState(() { + _markedPoints.removeAt(index); + _isWorkAreaCompleted = false; + _pathPoints = []; + _outerPoints = []; + }); + _toast('已删除第 ${index + 1} 个点,剩余 ${_markedPoints.length} 个'); + return; + } + if (index < 0 || index >= _obstacleHoles.length) return; + setState(() => _obstacleHoles.removeAt(index)); + _toast('已删除第 ${index + 1} 个禁行区矩形,剩余 ${_obstacleHoles.length} 个'); + } + /// 圈地入口:与路径规划 _showModeDialog 同一逻辑 /// (不可点外部关闭、选中态可切、取消返回 null),UI 换成 v2 样式。 /// 选完之后才出现底部操作面板(同 onEditTap(true))。 @@ -455,15 +1173,13 @@ class _RouteEditorPageV2State extends State { _isPanelOpen = true; _panelTabLand = true; }); - _toast( - result == WorkMode.bow - ? '覆盖模式:沿边界打点,生成时首尾闭合' - : '自定义模式:按打点顺序生成路线', - ); + _toast(result == WorkMode.bow ? '覆盖模式:沿边界打点,生成时首尾闭合' : '自定义模式:按打点顺序生成路线'); } /// 列表:按当前场站拉作业记录(同路径规划 onListBox), /// 点一条就把路线回显到地图(同点选列表项)。 + /// 「列表」入口已按需求隐藏(见 _tools),实现保留待恢复,故忽略未引用告警。 + // ignore: unused_element Future _openRouteList() async { final siteId = sl().state.selectedSite?.id; if (siteId == null) { @@ -475,9 +1191,7 @@ class _RouteEditorPageV2State extends State { _isPanelOpen = false; _listLoading = true; }); - final result = await sl().loadRoutes( - siteId: siteId, - ); + final result = await sl().loadRoutes(siteId: siteId); if (!mounted) return; setState(() { _listLoading = false; @@ -501,7 +1215,7 @@ class _RouteEditorPageV2State extends State { _isObstacleEditing = false; }); final result = await sl().resolveRouteGeometry( - deviceId: widget.deviceSn, + deviceId: _deviceSn, route: route, ); if (!mounted) return; @@ -516,9 +1230,24 @@ class _RouteEditorPageV2State extends State { _outerPoints = geo.coverageMode ? List.from(geo.outerPoints) : []; - _isWorkAreaCompleted = true; + final points = List.from( + geo.coverageMode && geo.outerPoints.isNotEmpty + ? geo.outerPoints + : geo.previewPoints, + ); + if (geo.coverageMode && + points.length > 1 && + points.first == points.last) { + points.removeLast(); + } + _markedPoints.addAll(points); + _isWorkAreaCompleted = false; + _pathPoints = []; + _outerPoints = []; + _isPanelOpen = true; + _tool = 0; }); - _toast('已载入「${geo.name}」'); + _toast('已载入「${geo.name}」,可继续编辑并另存'); } /// 定位:把地图移回设备当前坐标(同路径规划 _handleGotoLocation) @@ -542,35 +1271,532 @@ class _RouteEditorPageV2State extends State { _isWorkAreaCompleted = false; _selectedRouteId = null; _isPanelOpen = false; + _isDropPanel = false; + _isRegionListOpen = false; _panelTabLand = true; _isListOpen = false; _isVideoOpen = false; // 模式重置(同原页):机器人打点、作业区、覆盖模式 - _robotMode = RobotMode.robot; + _robotMode = _deviceSn.isEmpty ? RobotMode.point : RobotMode.robot; _areaMode = AreaMode.work; _workMode = WorkMode.bow; _workDistance = 0.5; _angle = 0.0; _tool = -1; + // 返航点一并清空(独立状态) + _isReturnMode = false; + _isReturnListOpen = false; + _pendingReturnDraft = null; + _returnLoading = false; + _returnSaved = false; + _returnPoints.clear(); + _returnObstacleHoles.clear(); + _returnObstacleNames.clear(); + _returnObstacleSizes.clear(); + _returnDropObstacle = false; // 同时回定位(同原页刷新时 move 到设备点) _recenterSignal++; _clearTraceSignal++; }); + _regionEditor.initialize(RegionDraft(siteId: _draftSiteId ?? 0)); + _regionRobotMode = RobotMode.point; + unawaited(_reloadRegions()); _toast('已重置页面状态'); } finally { if (mounted) setState(() => _refreshing = false); } } + // ================= 一键返航(返航点打点:界面 + 本地暂存,接口待接入)================= + + /// 设备列表(取不到就当空,不让页面挂掉) + List get _devices { + try { + return sl().state.devices; + } catch (_) { + return const []; + } + } + + /// 设备下拉项:(deviceSn, 展示名),展示名优先别名 + List<(String, String)> get _deviceOptions { + final options = <(String, String)>[ + for (final device in _devices) + ( + device.deviceName, + (device.deviceAlias?.trim().isNotEmpty ?? false) + ? device.deviceAlias!.trim() + : device.deviceName, + ), + ]; + if (options.isEmpty && _deviceSn.isNotEmpty) { + options.add((_deviceSn, _deviceSn)); + } + return options; + } + + /// 把某点已绑定的设备补进选项(设备被解绑时仍能显示原名) + List<(String, String)> _deviceOptionsFor(String? sn, String label) { + final options = _deviceOptions; + if (sn == null || sn.isEmpty || options.any((item) => item.$1 == sn)) { + return options; + } + return <(String, String)>[...options, (sn, label.isEmpty ? sn : label)]; + } + + String _labelOfDevice(String? sn) { + if (sn == null || sn.isEmpty) return '未选择设备'; + for (final (value, label) in _deviceOptions) { + if (value == sn) return label; + } + return sn; + } + + List get _returnLatLngs => [ + for (final point in _returnPoints) point.point, + ]; + + String get _returnHint { + if (_returnPoints.isEmpty) { + return '打点方式:${_robotMode == RobotMode.point ? '准星' : '设备'} · 点「+」添加返航点'; + } + final int bound = _returnPoints + .where((point) => (point.deviceSn ?? '').isNotEmpty) + .length; + return '共 ${_returnPoints.length} 点 · 已绑定设备 $bound 台' + '${_returnSaved ? ' · 已保存' : ''}'; + } + + /// 进入返航:先按当前全局选中的场站查已有方案(一个场站只允许一条)。 + /// - 有 → 先弹方案列表(单条),点它才回填编辑器 = 修改态(按钮「修改」,带 id) + /// - 无 → 直接进空白编辑器 = 新建态(按钮「保存」,不带 id) + /// 查询以服务器为准,会覆盖本地缓存的返航点;查询失败则保留当前内容不误判为空。 + Future _enterReturnMode() async { + final siteId = sl().state.selectedSite?.id; + if (siteId == null) { + _toast('请先在场站列表中选择一个场站!'); + return; + } + setState(() { + _isReturnMode = true; + _isListOpen = false; + _isVideoOpen = false; + _isDropPanel = false; + _returnDropObstacle = false; + _tool = 5; // 一键返航(「列表」隐藏后由 6 前移一位) + _returnLoading = true; + _isReturnListOpen = false; + _pendingReturnDraft = null; + if (_returnDeviceSn == null) { + final options = _deviceOptions; + if (options.isNotEmpty) _returnDeviceSn = options.first.$1; + } + }); + final result = await sl().fetchReturnPlanBySiteId( + siteId: siteId, + ); + if (!mounted) return; + setState(() => _returnLoading = false); + if (!result.isOk) { + // 失败不臆断为「无方案」:保留编辑器现状,仅提示 + _toast(result.error ?? '返航方案加载失败'); + return; + } + final draft = result.data; + if (draft == null) { + // 该场站暂无方案 → 直接空白新建 + _startNewReturnPlan(); + return; + } + // 已有方案 → 先弹列表,选中才进编辑(数据已在手,不再查详情) + setState(() { + _pendingReturnDraft = draft; + _isReturnListOpen = true; + }); + } + + /// 无已有方案:清空编辑器进新建态(按钮「保存」,不带 id) + void _startNewReturnPlan() { + setState(() { + _isReturnMode = true; + _tool = 5; // 一键返航(「列表」隐藏后由 6 前移一位) + _isReturnListOpen = false; + _pendingReturnDraft = null; + _returnPoints.clear(); + _returnObstacleHoles.clear(); + _returnObstacleNames.clear(); + _returnObstacleSizes.clear(); + _returnPlanId = null; + _returnSaved = false; + _returnDropObstacle = false; + }); + _persistDraft(); + _toast('新建返航方案:点绿色「+」添加返航点'); + } + + /// 选中列表里那条方案:直接回填已拉到的几何 = 修改态(按钮「修改」,带 id) + void _selectReturnPlan() { + final draft = _pendingReturnDraft; + if (draft == null) return; + setState(() { + _applyReturnDraft(draft); + _returnPlanId = draft.id; + _returnSaved = true; + _isReturnListOpen = false; + _returnDropObstacle = false; + }); + _persistDraft(); + _toast('已载入返航方案,修改后点「修改」保存'); + } + + /// 把服务器方案回填到编辑器:返航点(含绑定设备/自定义名)+ 障碍矩形。 + /// 障碍尺寸用详情原值(sizeX/sizeY),避免从四角反算出现浮点漂移。 + void _applyReturnDraft(ReturnPlanDraft draft) { + _returnPoints + ..clear() + ..addAll(<_ReturnPoint>[ + for (final p in draft.restPoints) + _ReturnPoint( + point: p.position, + deviceSn: p.bindDeviceId, + deviceLabel: _labelOfDevice(p.bindDeviceId), + alias: p.alias.trim().isEmpty ? null : p.alias.trim(), + ), + ]); + _returnObstacleHoles.clear(); + _returnObstacleNames.clear(); + _returnObstacleSizes.clear(); + for (final o in draft.obstacles) { + _returnObstacleHoles.add(_rectCorners(o.center, o.sizeX, o.sizeY)); + _returnObstacleNames.add(o.alias.trim().isEmpty ? null : o.alias.trim()); + _returnObstacleSizes.add((o.sizeX, o.sizeY)); + } + } + + void _exitReturnMode({bool clear = true}) { + setState(() { + _isReturnMode = false; + _tool = -1; + _returnLoading = false; + _isReturnListOpen = false; + _pendingReturnDraft = null; + if (clear) { + _returnPoints.clear(); + _returnObstacleHoles.clear(); + _returnObstacleNames.clear(); + _returnObstacleSizes.clear(); + _returnDropObstacle = false; + _returnSaved = false; + _returnPlanId = null; + } + }); + if (clear) _toast('已退出返航打点模式'); + } + + /// 增加返航点:准星取地图中心,设备取实时坐标(复用圈地的打点方式) + void _addReturnPoint() { + final LatLng? point = _robotMode == RobotMode.point + ? _mapCenter + : _devicePos; + if (point == null) { + _toast( + _robotMode == RobotMode.point ? '地图中心尚未就绪,请稍后重试' : '设备坐标无效,无法添加返航点!', + ); + return; + } + setState(() { + _returnPoints.add( + _ReturnPoint( + point: point, + deviceSn: _returnDeviceSn, + deviceLabel: _labelOfDevice(_returnDeviceSn), + ), + ); + _returnSaved = false; + }); + _toast('已添加第 ${_returnPoints.length} 个返航点'); + } + + /// 在返航线上落一个障碍矩形(返航方案独立一套):取准星/设备中心为默认尺寸建矩形,随即弹窗改 X/Y + void _addReturnObstacle() { + final LatLng? point = _robotMode == RobotMode.point + ? _mapCenter + : _devicePos; + if (point == null) { + _toast( + _robotMode == RobotMode.point ? '地图中心尚未就绪,请稍后重试' : '设备坐标无效,无法添加障碍!', + ); + return; + } + final LatLng center = point; + _showRectSizeDialog( + center: center, + w: _defaultObstacleW, + h: _defaultObstacleH, + onOk: (double w, double h) { + setState(() { + _returnObstacleHoles.add(_rectCorners(center, w, h)); + _returnObstacleNames.add(null); + _returnObstacleSizes.add((w, h)); + _returnSaved = false; + }); + _toast('已添加返航禁行区矩形(${_fmtRectName(w, h)})'); + }, + ); + } + + /// 编辑返航障碍矩形尺寸:以当前中心为锚重建四角替换 + void _editReturnObstacleRect(int index) { + if (index < 0 || index >= _returnObstacleHoles.length) return; + final corners = _returnObstacleHoles[index]; + final center = _rectCenter(corners); + final (double w, double h) = _rectSizeOf(corners); + _showRectSizeDialog( + center: center, + w: w, + h: h, + onOk: (double nw, double nh) { + setState(() { + _returnObstacleHoles[index] = _rectCorners(center, nw, nh); + while (_returnObstacleSizes.length <= index) { + _returnObstacleSizes.add(_rectSizeOf(corners)); + } + _returnObstacleSizes[index] = (nw, nh); + _returnSaved = false; + }); + }, + ); + } + + void _removeReturnObstacle(int index) { + if (index < 0 || index >= _returnObstacleHoles.length) return; + setState(() { + _returnObstacleHoles.removeAt(index); + if (index < _returnObstacleNames.length) { + _returnObstacleNames.removeAt(index); + } + if (index < _returnObstacleSizes.length) { + _returnObstacleSizes.removeAt(index); + } + _returnSaved = false; + }); + _toast('已删除第 ${index + 1} 个返航禁行区,剩余 ${_returnObstacleHoles.length} 个'); + } + + void _undoReturnObstacle() { + if (_returnObstacleHoles.isEmpty) { + _toast('暂无可撤回的返航禁行区'); + return; + } + setState(() { + _returnObstacleHoles.removeLast(); + if (_returnObstacleNames.length >= _returnObstacleHoles.length + 1) { + _returnObstacleNames.removeLast(); + } + if (_returnObstacleSizes.length >= _returnObstacleHoles.length + 1) { + _returnObstacleSizes.removeLast(); + } + _returnSaved = false; + }); + _toast('已撤回上一个返航禁行区,剩余 ${_returnObstacleHoles.length} 个'); + } + + void _removeReturnPoint(int index) { + if (index < 0 || index >= _returnPoints.length) return; + setState(() { + _returnPoints.removeAt(index); + if (_returnPoints.isEmpty) _returnSaved = false; + }); + _toast('已取消该返航点,剩余 ${_returnPoints.length} 个'); + } + + void _undoReturnPoint() { + if (_returnPoints.isEmpty) { + _toast('暂无可撤回的返航点'); + return; + } + setState(() { + _returnPoints.removeLast(); + if (_returnPoints.isEmpty) _returnSaved = false; + }); + _toast('已撤回最后一个返航点,剩余 ${_returnPoints.length} 个'); + } + + void _setReturnPointDevice(int index, String sn) { + if (index < 0 || index >= _returnPoints.length) return; + setState(() { + _returnPoints[index] = _returnPoints[index].copyWith( + deviceSn: sn, + deviceLabel: _labelOfDevice(sn), + ); + _returnSaved = false; + }); + } + + /// 返航点显示名:自定义优先,否则「返航点-XX」 + String _returnPointDisplay(int index) { + final name = _returnPoints[index].alias?.trim() ?? ''; + return name.isNotEmpty + ? name + : '返航点-${(index + 1).toString().padLeft(2, '0')}'; + } + + /// 返航障碍显示名:自定义优先,否则尺寸「Wm×Hm」 + String _returnObstacleDisplay(int index) { + final name = index < _returnObstacleNames.length + ? (_returnObstacleNames[index]?.trim() ?? '') + : ''; + return name.isNotEmpty ? name : _rectName(_returnObstacleHoles[index]); + } + + /// 返航点改名(点卡片上的名字) + void _renameReturnPoint(int index) { + if (index < 0 || index >= _returnPoints.length) return; + final point = _returnPoints[index]; + showInputConfirmDialog( + context: context, + title: '命名返航点 ${index + 1}', + hintText: '给这个返航点起个名字(留空用默认编号)', + labelText: '名字', + confirmText: '确定', + cancelText: '取消', + maxWidth: 260, + initialText: point.alias?.trim() ?? '', + inputValidator: (text) => text.length > 20 ? '名字不能超过 20 个字符' : null, + onConfirm: (text) { + setState(() { + _returnPoints[index] = _ReturnPoint( + point: point.point, + deviceSn: point.deviceSn, + deviceLabel: point.deviceLabel, + alias: text.isEmpty ? null : text, + ); + _returnSaved = false; + }); + }, + ); + } + + /// 返航障碍改名 + void _renameReturnObstacle(int index) { + if (index < 0 || index >= _returnObstacleHoles.length) return; + final current = index < _returnObstacleNames.length + ? (_returnObstacleNames[index]?.trim() ?? '') + : ''; + showInputConfirmDialog( + context: context, + title: '命名障碍 ${index + 1}', + hintText: '给这个障碍起个名字(留空用尺寸)', + labelText: '名字', + confirmText: '确定', + cancelText: '取消', + maxWidth: 260, + initialText: current, + inputValidator: (text) => text.length > 20 ? '名字不能超过 20 个字符' : null, + onConfirm: (text) { + setState(() { + while (_returnObstacleNames.length <= index) { + _returnObstacleNames.add(null); + } + _returnObstacleNames[index] = text.isEmpty ? null : text; + _returnSaved = false; + }); + }, + ); + } + + /// 保存:把当前返航点 + 返航障碍组装为 [ReturnPlanDraft], + /// 经 [AutonomousWorkGateway.saveReturnPlan] 落库(/system/siteMapInfo/save)。 + /// 页面只取几何、不拼任何后端字段(报文日志在网关里打印)。 + Future _saveReturnPlan() async { + if (_savingReturn) return; + if (_returnPoints.isEmpty && _returnObstacleHoles.isEmpty) { + _toast('请先在地图上添加返航点'); + return; + } + final site = sl().state.selectedSite; + if (site == null) { + _toast('请先选择一个场站!'); + return; + } + if (site.id != _draftSiteId) { + _toast('场站已切换,请重新进入编辑器后保存'); + return; + } + + final List restPoints = [ + for (int i = 0; i < _returnPoints.length; i++) + ReturnRestPoint( + position: _returnPoints[i].point, + bindDeviceId: _returnPoints[i].deviceSn, + alias: _returnPointDisplay(i), + ), + ]; + final List obstacles = [ + for (int i = 0; i < _returnObstacleHoles.length; i++) + () { + final List corners = _returnObstacleHoles[i]; + // 直接用用户在尺寸弹窗里填写的宽高(避免从四角反算出现 3.99999 漂移); + // 若无记录(旧草稿)才退回四角反算。 + final (double w, double h) = i < _returnObstacleSizes.length + ? _returnObstacleSizes[i] + : _rectSizeOf(corners); + final custom = i < _returnObstacleNames.length + ? (_returnObstacleNames[i]?.trim() ?? '') + : ''; + return ReturnObstaclePoint( + center: _rectCenter(corners), + alias: custom.isNotEmpty ? custom : '障碍物点-${i + 1}', + sizeX: w, + sizeY: h, + ); + }(), + ]; + + final ReturnPlanDraft draft = ReturnPlanDraft( + siteId: site.id, + orgId: site.orgId, + id: _returnPlanId, + restPoints: restPoints, + obstacles: obstacles, + ); + + setState(() => _savingReturn = true); + final result = await sl().saveReturnPlan(draft); + if (!mounted) return; + setState(() => _savingReturn = false); + + if (!result.isOk) { + _toast(result.error ?? '返航方案保存失败'); + return; + } + setState(() { + _returnSaved = true; + // 后端若回写主键则记住,下次保存走更新;否则保持 null + if (result.data != null) _returnPlanId = result.data; + }); + _persistDraft(); + CapsuleToast.show( + '返航方案已保存:${restPoints.length} 个返航点' + '${obstacles.isEmpty ? '' : ' · ${obstacles.length} 个障碍'}', + ); + } + + /// 设备返航接口未开放,保留草稿,不模拟下发成功。 + Future _executeReturnPlan() async { + _persistDraft(); + _toast('返航执行接口尚未接入,返航点已保留'); + } + /// 监控视频流:与路径规划同一套拼接(device/{sn}/live + 登录 token) String get _videoStreamUrl { final token = sl().state.user?.token; - if (widget.deviceSn.isEmpty || token == null) return ''; + if (_deviceSn.isEmpty || token == null) return ''; return 'webrtc://${TCPConsts.TCP_IP}/live/livestream/' - '${widget.deviceSn}?token=$token'; + '$_deviceSn?token=$token'; } - /// 保存区域(复刻 _showSavePlotDialog):先输名称,再截图,再走网关上传。 + /// 保存区域(复刻 _showSavePlotDialog):先选规划类型,再输名称,再截图上传。 Future _showSaveDialog() async { if (_markedPoints.isEmpty) { _toast('请先添加作业区域打点!'); @@ -580,6 +1806,13 @@ class _RouteEditorPageV2State extends State { _toast('作业区域至少需要 3 个打点!'); return; } + // 后端 workRecord.routeType 必填(与旧版路径规划共用同一接口) + final RouteType? routeType = await showRouteTypePicker( + context, + maxWidth: 240, + insetPadding: const EdgeInsets.symmetric(horizontal: 96, vertical: 24), + ); + if (routeType == null || !mounted) return; await showInputConfirmDialog( context: context, title: '保存区域', @@ -593,11 +1826,15 @@ class _RouteEditorPageV2State extends State { if (text.length > 20) return '区域名称不能超过 20 个字符'; return null; }, - onConfirm: _saveRecord, + onConfirm: (name) => _saveRecord(name, routeType), ); } - Future _saveRecord(String name) async { + Future _saveRecord(String name, RouteType routeType) async { + if (sl().state.selectedSite?.id != _draftSiteId) { + _toast('场站已切换,请重新进入编辑器后保存'); + return; + } setState(() => _saving = true); String? imageBase64; try { @@ -609,11 +1846,12 @@ class _RouteEditorPageV2State extends State { // 覆盖模式:outer 与 path 同源(闭合打点环);自定义模式:outer 为空 final ring = _closeBowRing(_markedPoints); final result = await sl().saveRouteRecord( - deviceId: widget.deviceSn, + deviceId: _deviceSn, name: name, path: ring, outer: _workMode == WorkMode.bow ? ring : const [], planModel: _workMode.value, + routeType: routeType.code, imageBase64: imageBase64, ); if (!mounted) return; @@ -623,9 +1861,7 @@ class _RouteEditorPageV2State extends State { _toast(result.error ?? '保存失败'); return; } - _toast( - imageBase64 == null ? '「$name」已保存,但地图截图生成失败' : '「$name」已保存', - ); + _toast(imageBase64 == null ? '「$name」已保存,但地图截图生成失败' : '「$name」已保存'); // 保存成功后清空,回到可以画下一块地的状态(同路径规划) setState(() { _markedPoints.clear(); @@ -637,6 +1873,7 @@ class _RouteEditorPageV2State extends State { _isWorkAreaCompleted = false; _areaMode = AreaMode.work; _isPanelOpen = false; + _isDropPanel = false; _tool = -1; }); } @@ -650,8 +1887,9 @@ class _RouteEditorPageV2State extends State { return null; } final ui.Image image = await boundary.toImage(pixelRatio: 2.0); - final ByteData? byteData = - await image.toByteData(format: ui.ImageByteFormat.png); + final ByteData? byteData = await image.toByteData( + format: ui.ImageByteFormat.png, + ); if (byteData == null) { debugPrint('[区域编辑] 截图失败:字节数据为空'); return null; @@ -667,8 +1905,7 @@ class _RouteEditorPageV2State extends State { } String get _statusTextOfArea { - final holeCount = _obstacleHoles.length + (_isObstacleEditing ? 1 : 0); - return '作业区 ${_markedPoints.length} 点 · 禁行区 $holeCount 处 · ' + return '作业区 ${_markedPoints.length} 点 · 禁行区 ${_obstacleHoles.length} 处 · ' '${_workMode == WorkMode.bow ? '覆盖' : '自定义'}模式'; } @@ -682,31 +1919,76 @@ class _RouteEditorPageV2State extends State { constraints.biggest, cutout: MediaQuery.viewPaddingOf(context), ); + // 底部面板(返航/分区/障碍打点、圈地操作)打开时,把地图与准星整体上抬, + // 让打点中心落到面板上方的可视区,不再被操作栏压住(横屏面板高、中心最易被挡)。 + final bool panelOpen = _isReturnMode || _isDropPanel || _isPanelOpen; + final double bottomReserve = panelOpen + ? (m.tiny ? 104.0 : 128.0) + : 0.0; return Stack( children: [ Positioned.fill( // 双击地图切换顶部导航栏(不影响拖动/缩放手势) child: GestureDetector( onDoubleTap: _toggleTopBar, - child: _buildMapLayer(), + child: AnimatedPadding( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + padding: EdgeInsets.only(bottom: bottomReserve), + child: _buildMapLayer(), + ), ), ), _buildTopBar(m), _buildToolRail(m), - _buildStationCard(m), + _buildZoneRail(m), + Positioned( + top: m.contentTop, + left: m.safeLeft + m.margin + m.toolW + 10, + width: math.min( + 330.0, + math.max( + 160.0, + m.size.width - + m.safeLeft - + m.safeRight - + m.toolW - + 4 * m.margin - + (_isRegionListOpen ? _regionListWidth(m) : 0), + ), + ), + child: NoDeviceCrosshairHint(hasDevice: _deviceSn.isNotEmpty), + ), + // 左上「当前场站 · 新建区域」信息卡:内容与底部/面板重复(同一条 _statusTextOfArea), + // 按需求先注释隐藏;_buildStationCard 方法保留,便于日后回滚。 + // _buildStationCard(m), _buildScaleBar(m), - // 未圈地:底部状态条;圈地后:底部操作面板(同路径规划) - if (_isPanelOpen) _buildOperationPanel(m) else _buildBottomBar(m), + // 圈地后:底部操作面板;返航/分区/障碍物:共用打点面板(互斥) + if (_isReturnMode || _isDropPanel) + _buildReturnPanel(m) + else if (_isPanelOpen) + _buildOperationPanel(m), + // 底部说明条(状态文案 + 保存区域/圈地 按钮):入口「圈地」在左侧竖栏、保存按钮在操作面板, + // 该条信息重复且占位,按需求先注释隐藏;_buildBottomBar 方法保留,便于日后回滚。 + // else + // _buildBottomBar(m), if (_isVideoOpen) _buildVideoPopup(m), // 准星:准星打点时,地图中心就是打点位置(同路径规划的红十字) - if (_mapReady && _robotMode == RobotMode.point) + if (_mapReady && + (_dropMode == 1 ? _regionRobotMode : _robotMode) == + RobotMode.point) Positioned.fill( child: IgnorePointer( - child: Center( - child: SizedBox( - width: 16, - height: 16, - child: CustomPaint(painter: CrosshairPainter()), + child: AnimatedPadding( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + padding: EdgeInsets.only(bottom: bottomReserve), + child: Center( + child: SizedBox( + width: 16, + height: 16, + child: CustomPaint(painter: CrosshairPainter()), + ), ), ), ), @@ -715,6 +1997,26 @@ class _RouteEditorPageV2State extends State { if (_refreshing) _buildRefreshingMask(), // 作业记录列表(底部面板) if (_isListOpen) _buildRecordList(m), + // 一键返航方案列表(进入返航先弹,选中回填=修改) + if (_isReturnListOpen) _buildReturnListPanel(m), + if (_isRegionListOpen) _buildRegionListPanel(m), + // 一键返航:正在按场站拉取已有方案(selectBySiteId) + if (_returnLoading) + Positioned.fill( + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.25), + child: const Center( + child: SizedBox( + width: 34, + height: 34, + child: CircularProgressIndicator( + strokeWidth: 3, + color: Colors.white, + ), + ), + ), + ), + ), // 保存中:盖遮罩,避免重复提交 if (_saving) Positioned.fill( @@ -744,7 +2046,7 @@ class _RouteEditorPageV2State extends State { fit: StackFit.expand, children: [ const ColoredBox(color: Color(0xFF33402A)), - if (_mapReady) + if (_mapReady && _draftLoaded) TweenAnimationBuilder( tween: Tween(begin: 0, end: 1), duration: const Duration(milliseconds: 320), @@ -755,10 +2057,14 @@ class _RouteEditorPageV2State extends State { // 保存区域时把这个子树截成 jpg(覆盖地图 + 图层) key: _mapKey, child: PvWorkMap( - deviceSn: widget.deviceSn, + deviceSn: _deviceSn, + showDevice: _deviceSn.isNotEmpty, + recordLocationTrace: false, interactive: true, - // 底部有操作白条:把「定位回中」按钮抬高,避免压在条上 - floatInset: _isPanelOpen ? 200 : 56, + // 地图已随面板上抬(build 里 bottomReserve),「定位回中」按钮只需小幅避让面板顶边 + floatInset: (_isPanelOpen || _isReturnMode || _isDropPanel) + ? 96 + : 56, // 区域编辑时不要跟着设备跑:否则每次定位推送都会把正在打的位置移走 followDevice: false, // 页面点「定位」/「刷新」时把地图移回设备坐标 @@ -768,18 +2074,39 @@ class _RouteEditorPageV2State extends State { previewRoute: _pathPoints, previewOuter: _outerPoints, previewCoverage: _workMode == WorkMode.bow, - draftPoints: _markedPoints, - draftHoles: _obstacleHoles, + extraMapLayers: _regionMapLayers(), + focusPoints: _regionFocusPoints, + focusSignal: _regionFocusSignal, + focusPadding: EdgeInsets.fromLTRB( + 110, + 70, + _isRegionListOpen + ? _regionListWidth( + _EL( + MediaQuery.sizeOf(context), + cutout: MediaQuery.viewPaddingOf(context), + ), + ) + + 20 + : 40, + 40, + ), + draftPoints: _dropMode == 1 ? const [] : _markedPoints, + // 禁行区矩形:返航模式画返航方案独立障碍,否则画圈地障碍(两者互不影响) + draftHoles: _isReturnMode + ? _returnObstacleHoles + : _obstacleHoles, draftHolePoints: _currentObstaclePoints, - draftPolygon: - _robotMode == RobotMode.point - ? _draftPolygon() - : const [], + draftPolygon: _dropMode != 1 && _robotMode == RobotMode.point + ? _draftPolygon() + : const [], draftCenter: _mapCenter, draftCompleted: _isWorkAreaCompleted, - draftObstacle: - _areaMode == AreaMode.obstacle && _isObstacleEditing, - draftCrosshair: _robotMode == RobotMode.point, + // 障碍矩形:处于障碍模式或返航模式就渲染已落矩形(不再依赖逐点编辑态) + draftObstacle: _isReturnMode || _areaMode == AreaMode.obstacle, + draftCrosshair: _dropMode != 1 && _robotMode == RobotMode.point, + // 返航点:独立紫色图层(有数据就画,与圈地草稿互不影响) + returnPoints: _returnLatLngs, onCenterChanged: _onMapCenterChanged, onDevicePosition: (position) => _devicePos = position, ), @@ -789,6 +2116,58 @@ class _RouteEditorPageV2State extends State { ); } + double _regionListWidth(_EL m) => + (m.size.width * 0.36).clamp(260.0, 340.0).toDouble(); + + Widget _buildRegionListPanel(_EL m) => Positioned( + top: m.contentTop, + bottom: m.margin, + right: m.safeRight + m.margin, + width: _regionListWidth(m), + child: Material( + elevation: 8, + color: const Color(0xFFF5F8FC), + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: RegionListPanel( + cubit: _regionList, + currentScope: () => _currentRegionScope, + onCreate: () => _editRegion(), + onEdit: _editRegion, + onRefresh: () => unawaited(_reloadRegions()), + onClose: () => setState(() => _isRegionListOpen = false), + onDeleted: (region) { + if (_regionEditor.state.draft.id == region.id) { + _regionEditor.initialize(RegionDraft(siteId: _draftSiteId ?? 0)); + } + }, + ), + ), + ); + + List _regionMapLayers() { + if (!_regionScopeCurrent) return const []; + final editing = _dropMode == 1; + final editedId = _regionEditor.state.draft.id; + return [ + for (final region in _regionList.state.regions) + if (region.scopeError == null && !(editing && region.id == editedId)) + RegionBoundaryLayer( + key: ValueKey('region_map_${region.id}'), + points: [for (final p in region.scope) LatLng(p.lat, p.lng)], + selected: region.id == _regionList.state.selectedId, + enabled: region.enabled, + showVertices: false, + label: region.name.isEmpty ? '分区 #${region.id}' : region.name, + ), + if (editing) + RegionBoundaryLayer( + key: const ValueKey('region_map_draft'), + points: _regionPoints, + ), + ]; + } + // ===== 顶栏(双击地图可上滑隐藏 / 再双击滑出)===== Widget _buildTopBar(_EL m) { return Positioned( @@ -821,6 +2200,7 @@ class _RouteEditorPageV2State extends State { _backButton(m), SizedBox(width: (10 * m.k).clamp(6.0, 10.0)), Flexible( + flex: 2, child: Text( '地图与区域编辑', maxLines: 1, @@ -834,13 +2214,51 @@ class _RouteEditorPageV2State extends State { ), ), SizedBox(width: (10 * m.k).clamp(6.0, 10.0)), - _deviceChip(m), + BlocBuilder( + bloc: sl(), + builder: (context, _) => _deviceChip(m), + ), SizedBox(width: (14 * m.k).clamp(8.0, 14.0)), - _statusText(m, Icons.wifi_rounded, '4G在线'), + Flexible(child: _statusText(m, Icons.wifi_rounded, '4G在线')), SizedBox(width: (12 * m.k).clamp(7.0, 12.0)), - _statusText(m, Icons.gps_fixed_rounded, 'RTK有效'), + // 定位质量(实时):与自主作业中心同口径,取 DeviceStatusBloc 的 qual; + // 窄屏用短文案,并包 Flexible 兑底防溢出 + Flexible( + child: BlocBuilder( + bloc: _statusBloc, + builder: (context, ds) { + final qual = _statusOf(ds)?.qual; + return _statusText( + m, + Icons.gps_fixed_rounded, + _qualText(qual, short: m.tiny), + color: qual == null + ? kWtMuted + : (_isRtkFixed(qual) ? kWtGreen : kWtWarn), + ); + }, + ), + ), SizedBox(width: (12 * m.k).clamp(7.0, 12.0)), - _statusText(m, Icons.battery_5_bar_rounded, '85%'), + // 电量(实时):仅 host 实时数据到达后有值,否则“电量未知” + Flexible( + child: BlocBuilder( + bloc: _statusBloc, + builder: (context, ds) { + final pct = _batteryOf(_statusOf(ds)); + return _statusText( + m, + Icons.battery_5_bar_rounded, + pct == null + ? (m.tiny ? '--' : '电量未知') + : '${pct.round()}%', + color: pct == null + ? kWtMuted + : (pct > 20 ? kWtGreen : kWtDanger), + ); + }, + ), + ), const Spacer(), _sunButton(m), SizedBox(width: (10 * m.k).clamp(6.0, 10.0)), @@ -857,7 +2275,11 @@ class _RouteEditorPageV2State extends State { Widget _backButton(_EL m) { final side = (36 * m.k).clamp(26.0, 36.0); return InkWell( - onTap: () => Navigator.of(context).maybePop(), + onTap: () { + // 先交还目标方向再 pop:退场动画期间下层页就已是正确方向,不会闪竖屏 + _exitOrientation(); + Navigator.of(context).maybePop(); + }, borderRadius: BorderRadius.circular(10), child: Container( width: side, @@ -877,42 +2299,75 @@ class _RouteEditorPageV2State extends State { ); } + /// 顶栏设备名:以入口带入的 deviceSn 为准(设备详情→详情那台、自主作业中心→中心那台), + /// 从设备列表反查展示名(优先别名);deviceSn 为空时通过设备菜单选择,避免误用其它设备的位置。 + String _editorDeviceLabel() { + final sn = _deviceSn.trim(); + if (sn.isNotEmpty) { + for (final (value, label) in _deviceOptions) { + if (value == sn) return label; + } + return sn; // 列表尚未加载/未含该设备:兑底显示编码本身 + } + return '未选择设备'; + } + Widget _deviceChip(_EL m) { - return Container( - height: (24 * m.k).clamp(18.0, 24.0), - padding: EdgeInsets.symmetric(horizontal: (10 * m.k).clamp(6.0, 10.0)), - alignment: Alignment.center, - decoration: BoxDecoration( - color: kWtSoftBlue, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - 'MC700 · PLUS', - maxLines: 1, - style: TextStyle( - fontSize: m.fs(11.5), - height: 1.1, - fontWeight: FontWeight.w700, - color: kWtBlue, + return PopupMenuButton( + enabled: widget.deviceSn.isEmpty && _draftLoaded, + onSelected: _changeEditorDevice, + itemBuilder: (_) => [ + for (final (sn, label) in _deviceOptions) + PopupMenuItem(value: sn, child: Text(label)), + ], + child: Container( + height: (24 * m.k).clamp(18.0, 24.0), + constraints: BoxConstraints(maxWidth: (160 * m.k).clamp(96.0, 160.0)), + padding: EdgeInsets.symmetric(horizontal: (10 * m.k).clamp(6.0, 10.0)), + alignment: Alignment.center, + decoration: BoxDecoration( + color: kWtSoftBlue, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + _editorDeviceLabel(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: m.fs(11.5), + height: 1.1, + fontWeight: FontWeight.w700, + color: kWtBlue, + ), ), ), ); } - Widget _statusText(_EL m, IconData icon, String text) { + Widget _statusText( + _EL m, + IconData icon, + String text, { + Color color = kWtNavy, + }) { return Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: (15 * m.k).clamp(11.0, 15.0), color: kWtNavy), + Icon(icon, size: (15 * m.k).clamp(11.0, 15.0), color: color), SizedBox(width: (4 * m.k).clamp(2.0, 4.0)), - Text( - text, - maxLines: 1, - style: TextStyle( - fontSize: m.fs(12.5), - height: 1.1, - fontWeight: FontWeight.w700, - color: kWtNavy, + // 文字可收缩 + 省略号:顶栏空间不够时宁可截断也不抛 RenderFlex 溢出 + Flexible( + child: Text( + text, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: m.fs(12.5), + height: 1.1, + fontWeight: FontWeight.w700, + color: color, + ), ), ), ], @@ -979,75 +2434,149 @@ class _RouteEditorPageV2State extends State { ); } - // ===== 左侧竖向工具栏 ===== + // ===== 左侧竖向工具栏:整卡分组式(一张白卡 + 细分隔线 + 选中左侧指示条)===== + BoxDecoration _railCardDeco() => const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(16)), + boxShadow: [ + BoxShadow(color: Color(0x1A0B1B2B), blurRadius: 12, offset: Offset(0, 4)), + ], + ); + Widget _buildToolRail(_EL m) { final int n = _tools.length; - // 可用高度:顶栏下沿 → 底栏上沿(底栏 bottom = margin,高 barH) + final double pad = (5 * m.k).clamp(3.0, 5.0); + const double divH = 0.8; + // 左下角「分区/障碍物」组卡占位(2 项 + 分隔线 + 内边距),主栏底部给它留出空间 + final double zoneItemH = (44 * m.k).clamp(34.0, 44.0); + final double zoneH = zoneItemH * 2 + divH + pad * 2; final double available = - m.size.height - m.margin - m.barH - m.margin - m.contentTop; - // 7 项(含一键返航)在手机横屏下比较紧,间距也收一档 - final double gap = m.tiny ? 6.0 : m.toolGap; - // 7 项按可用高度等比分配:项高自适应,使上下间距完全相等且不压底栏 - final double itemH = ((available - gap * (n - 1)) / n).clamp( - 28.0, + m.size.height - + m.margin - + m.contentTop - + zoneH - + m.margin - + (10 * m.k).clamp(6.0, 10.0); + // 项高按可用高度等比分配;卡内不再逐项留间隙,改用细分隔线分隔 + final double itemH = ((available - pad * 2 - divH * (n - 1)) / n).clamp( + 24.0, m.toolItemH, ); return Positioned( left: m.margin + m.safeLeft, top: m.contentTop, width: m.toolW, - child: Column( - children: [ - for (var i = 0; i < n; i++) ...[ - if (i > 0) SizedBox(height: gap), - _toolTile(m, i, itemH), + child: Container( + decoration: _railCardDeco(), + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.symmetric(vertical: pad), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < n; i++) ...[ + if (i > 0) + Container( + height: divH, + margin: EdgeInsets.only(left: (12 * m.k).clamp(8.0, 12.0)), + color: const Color(0xFFF0F4FA), + ), + _railTile( + m, + _tools[i].$1, + _tools[i].$2, + _tool == i, + () => _onToolTap(i), + itemH, + ), + ], ], - ], + ), ), ); } - Widget _toolTile(_EL m, int index, double itemH) { - final selected = _tool == index; - final (icon, label) = _tools[index]; - return InkWell( - onTap: () => _onToolTap(index), - borderRadius: BorderRadius.circular(14), + // ===== 左下角「区域类型」组卡:分区(作业区)/ 障碍物(禁行区)===== + // 与主栏同一套整卡样式;选中态直接跟真实的 _areaMode 状态走, + // 面板里「作业区域/障碍区域」下拉切换时这里也会同步亮。 + Widget _buildZoneRail(_EL m) { + final double pad = (5 * m.k).clamp(3.0, 5.0); + const double divH = 0.8; + final double zoneItemH = (44 * m.k).clamp(34.0, 44.0); + final bool workSel = + _isRegionListOpen || _isDropPanel && _areaMode == AreaMode.work; + final bool obstacleSel = _isDropPanel && _areaMode == AreaMode.obstacle; + return Positioned( + left: m.margin + m.safeLeft, + // 与底栏同一条底线(bottom = margin),两者左右相邻 + bottom: m.margin, + width: m.toolW, child: Container( - width: m.toolW, - height: itemH, - decoration: BoxDecoration( - color: selected ? kWtSoftBlue : Colors.white, - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: selected ? kWtBlue : const Color(0x140B1B2B), - width: selected ? 1.4 : 1, - ), - boxShadow: const [ - BoxShadow( - color: Color(0x140B1B2B), - blurRadius: 10, - offset: Offset(0, 4), + decoration: _railCardDeco(), + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.symmetric(vertical: pad), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _railTile( + m, + Icons.category_rounded, + '分区', + workSel, + () => _onZoneTap(0), + zoneItemH, + ), + Container( + height: divH, + margin: EdgeInsets.only(left: (12 * m.k).clamp(8.0, 12.0)), + color: const Color(0xFFF0F4FA), + ), + _railTile( + m, + Icons.block_rounded, + '障碍物', + obstacleSel, + () => _onZoneTap(1), + zoneItemH, ), ], ), + ), + ); + } + + /// 竖向栏通用项:图标 + 文字。 + /// 选中态:淡蓝底铺满整块项区域(宽、高都占满),图标/文字加粗变蓝。 + Widget _railTile( + _EL m, + IconData icon, + String label, + bool selected, + VoidCallback onTap, + double itemH, + ) { + return InkWell( + onTap: onTap, + child: Container( + height: itemH, + width: double.infinity, + color: selected ? kWtSoftBlue : Colors.transparent, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( icon, - size: (22 * m.k).clamp(14.0, 22.0), + size: (19 * m.k).clamp(13.0, 19.0), color: selected ? kWtBlue : kWtGray2, ), - SizedBox(height: (4 * m.k).clamp(1.0, 4.0)), + SizedBox(height: (2 * m.k).clamp(1.0, 2.0)), Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: m.fs(10.5), + fontSize: m.fs(9.5), height: 1.1, - fontWeight: selected ? FontWeight.w700 : FontWeight.w600, + fontWeight: selected ? FontWeight.w800 : FontWeight.w500, color: selected ? kWtBlue : kWtGray2, ), ), @@ -1083,7 +2612,7 @@ class _RouteEditorPageV2State extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '当前场站 · 新建区域', + _isReturnMode ? '一键返航 · 返航点' : '当前场站 · 新建区域', style: TextStyle( fontSize: m.fs(14.5), height: 1.15, @@ -1093,7 +2622,7 @@ class _RouteEditorPageV2State extends State { ), const SizedBox(height: 3), Text( - _statusTextOfArea, + _isReturnMode ? _returnHint : _statusTextOfArea, style: TextStyle( fontSize: m.fs(11.5), height: 1.15, @@ -1111,9 +2640,11 @@ class _RouteEditorPageV2State extends State { return Positioned( right: m.margin + m.safeRight + (m.tiny ? 8 : 18), // 比「定位回中」按钮再高一层(按钮 bottom 56 + 高 28),两者不重叠 - // 底部操作面板打开时再抬一层面板高度 + // 底部操作面板 / 返航面板打开时再抬一层 bottom: - (_isPanelOpen ? 150.0 : 0.0) + + ((_isReturnMode || _isDropPanel) + ? 200.0 + : (_isPanelOpen ? 150.0 : 0.0)) + m.barH + m.margin * 2 + (m.tiny ? 34 : 40), @@ -1236,41 +2767,1041 @@ class _RouteEditorPageV2State extends State { ); } + // ===== 一键返航面板(返航点列表 + 单点设备绑定 + 保存/执行)===== + /// 与圈地面板同款式:底部居中浮卡,左不压工具栏、右不铺满整屏。 + /// 只读自己的 _return* 状态,不碰圈地草稿。 + Widget _buildReturnPanel(_EL m) { + return Positioned( + left: 0, + right: 0, + bottom: m.margin + (4 * m.k).clamp(2.0, 4.0), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: (m.size.width * 0.64).clamp(380.0, 660.0), + maxHeight: m.size.height * 0.66, + ), + child: Container( + padding: EdgeInsets.fromLTRB( + (16 * m.k).clamp(12.0, 16.0), + (12 * m.k).clamp(8.0, 12.0), + (16 * m.k).clamp(12.0, 16.0), + (10 * m.k).clamp(6.0, 10.0), + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: const [ + BoxShadow( + color: Color(0x2E1A2B4A), + blurRadius: 26, + offset: Offset(0, 10), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _returnHeaderRow(m), + SizedBox(height: (8 * m.k).clamp(5.0, 8.0)), + _returnControlRow(m), + SizedBox(height: (8 * m.k).clamp(5.0, 8.0)), + _returnPointStrip(m), + SizedBox(height: (8 * m.k).clamp(5.0, 8.0)), + _returnActionRow(m), + ], + ), + ), + ), + ), + ); + } + + Widget _returnHeaderRow(_EL m) { + // 一键返航 / 分区(作业区)/ 障碍物(禁行区)三种模式共用本面板 + final int mode = _dropMode; + final ( + IconData headIcon, + String headTitle, + int headCount, + String headTail, + ) = switch (mode) { + 1 => ( + Icons.category_rounded, + '分区 · 四点范围', + _regionEditor.state.draft.scope.length, + '依次打 4 个边界点', + ), + 2 => ( + Icons.block_rounded, + '障碍物 · 禁行区矩形', + _obstacleHoles.length, + '点「+」落矩形,改 X/Y 尺寸', + ), + _ => ( + _returnDropObstacle ? Icons.block_rounded : Icons.u_turn_left_rounded, + _returnDropObstacle ? '一键返航 · 障碍点' : '一键返航 · 返航点', + _returnDropObstacle + ? _returnObstacleHoles.length + : _returnPoints.length, + _returnDropObstacle ? '点「+」在返航线上落障碍矩形' : '按打点顺序返航', + ), + }; + return Row( + children: [ + Icon(headIcon, size: (17 * m.k).clamp(13.0, 17.0), color: kWtBlue), + const SizedBox(width: 6), + Text( + headTitle, + style: TextStyle( + fontSize: m.fs(15.5), + height: 1.1, + fontWeight: FontWeight.w800, + color: kWtNavy, + ), + ), + const SizedBox(width: 7), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: kWtSoftBlue, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '$headCount', + style: TextStyle( + fontSize: m.fs(11.5), + fontWeight: FontWeight.w700, + color: kWtBlue, + ), + ), + ), + if (mode == 0 && _returnSaved) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: kWtGreenBg, + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.check_circle_rounded, + size: 12, + color: kWtGreen, + ), + const SizedBox(width: 3), + Text( + '已保存', + style: TextStyle( + fontSize: m.fs(11), + fontWeight: FontWeight.w700, + color: kWtGreen, + ), + ), + ], + ), + ), + ], + const Spacer(), + Text( + headTail, + style: TextStyle(fontSize: m.fs(11), color: kWtGray), + ), + const SizedBox(width: 8), + InkWell( + onTap: mode == 0 + ? () => _exitReturnMode(clear: true) + : _closeDropPanel, + borderRadius: BorderRadius.circular(999), + child: Container( + width: (26 * m.k).clamp(20.0, 26.0), + height: (26 * m.k).clamp(20.0, 26.0), + alignment: Alignment.center, + decoration: const BoxDecoration( + color: Color(0xFFF4F7FB), + shape: BoxShape.circle, + ), + child: Icon( + Icons.close_rounded, + size: (16 * m.k).clamp(12.0, 16.0), + color: kWtGray, + ), + ), + ), + ], + ); + } + + /// 打点方式 +(仅返航:默认设备)+ 增加点 + Widget _returnControlRow(_EL m) { + return Row( + children: [ + _dropdown( + m, + value: _dropMode == 1 ? _regionRobotMode : _robotMode, + items: const [(RobotMode.point, '准星打点'), (RobotMode.robot, '设备打点')], + onChanged: (mode) { + setState(() { + if (_dropMode == 1) { + _regionRobotMode = mode; + } else { + _robotMode = mode; + } + }); + _toast( + _dropMode == 0 + ? (mode == RobotMode.point ? '准星打点:地图中心即返航点' : '设备打点:取设备实时坐标') + : (mode == RobotMode.point + ? '准星打点:地图中心即打点位置' + : '设备打点:取设备实时坐标'), + ); + }, + ), + // 返航:返航点 / 障碍点 子模式切换(同一条返航线上交替打) + // 保持固有宽度:设备名再长也不挤压「返航点 / 障碍点」切换 pill + if (_dropMode == 0) ...[ + SizedBox(width: (8 * m.k).clamp(5.0, 8.0)), + _returnKindSelector(m), + ], + // 障碍物形状选择:矩形可用,圆形暂不支持 + if (_dropMode == 2) ...[ + SizedBox(width: (8 * m.k).clamp(5.0, 8.0)), + Flexible(child: _obstacleShapeSelector(m)), + ], + // 默认设备只对返航点有意义(每个点可单独改绑);障碍点属于当前设备,不展示 + if (_dropMode == 0 && !_returnDropObstacle) + // 设备名过长时优先自身往右收缩(Expanded 吃掉剩余宽度 + 内部省略号), + // 不再挤占前面的切换 pill;「+」被顶到最右,反正后面本来就没东西。 + ...[ + SizedBox(width: (8 * m.k).clamp(5.0, 8.0)), + Text( + '默认设备', + style: TextStyle( + fontSize: m.fs(11.5), + fontWeight: FontWeight.w600, + color: kWtGray2, + ), + ), + SizedBox(width: (5 * m.k).clamp(3.0, 5.0)), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: _returnDeviceChip(m), + ), + ), + ] else + const Spacer(), + _addPointButton(m, onTap: _onPlusTap), + ], + ); + } + + /// 「+」按钮:按当前面板模式分流(返航子模式再分返航点/障碍点) + void _onPlusTap() { + if (_dropMode == 1) { + _addRegionPoint(); + return; + } + if (_dropMode == 0) { + if (_returnDropObstacle) { + _addReturnObstacle(); + } else { + _addReturnPoint(); + } + return; + } + _addMarkedPoint(); + } + + /// 返航打点子模式切换:返航点 / 障碍点(仅返航面板) + Widget _returnKindSelector(_EL m) { + Widget pill( + IconData icon, + String label, + bool selected, + VoidCallback onTap, + ) { + final Color fg = selected ? kWtBlue : kWtGray2; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: EdgeInsets.symmetric( + horizontal: (8 * m.k).clamp(5.0, 8.0), + vertical: (4 * m.k).clamp(2.0, 4.0), + ), + decoration: BoxDecoration( + color: selected ? kWtSoftBlue : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: selected ? kWtBlue : kWtBorder), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: (13 * m.k).clamp(10.0, 13.0), color: fg), + const SizedBox(width: 3), + Text( + label, + style: TextStyle( + fontSize: m.fs(11.5), + fontWeight: FontWeight.w700, + color: fg, + ), + ), + ], + ), + ), + ); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + pill(Icons.u_turn_left_rounded, '返航点', !_returnDropObstacle, () { + if (_returnDropObstacle) setState(() => _returnDropObstacle = false); + }), + SizedBox(width: (6 * m.k).clamp(4.0, 6.0)), + pill(Icons.block_rounded, '障碍点', _returnDropObstacle, () { + if (!_returnDropObstacle) setState(() => _returnDropObstacle = true); + }), + ], + ); + } + + /// 障碍物形状选择:矩形可用(选中),圆形暂不支持(禁用不可点) + Widget _obstacleShapeSelector(_EL m) { + Widget pill(IconData icon, String label, bool enabled, bool selected) { + final Color fg = !enabled ? kWtGray : (selected ? kWtBlue : kWtGray2); + return Container( + padding: EdgeInsets.symmetric( + horizontal: (8 * m.k).clamp(5.0, 8.0), + vertical: (4 * m.k).clamp(2.0, 4.0), + ), + decoration: BoxDecoration( + color: selected ? kWtSoftBlue : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: selected ? kWtBlue : kWtBorder), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: (13 * m.k).clamp(10.0, 13.0), color: fg), + const SizedBox(width: 3), + Text( + label, + style: TextStyle( + fontSize: m.fs(11.5), + fontWeight: FontWeight.w700, + color: fg, + ), + ), + ], + ), + ); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + pill(Icons.crop_square_rounded, '矩形', true, true), + SizedBox(width: (6 * m.k).clamp(4.0, 6.0)), + pill(Icons.radio_button_unchecked_rounded, '圆形', false, false), + ], + ); + } + + /// 默认设备选择(只影响后加的点,已有可单点再改) + Widget _returnDeviceChip(_EL m) { + final options = _deviceOptions; + final String label = _returnDeviceSn == null + ? '选择设备' + : _labelOfDevice(_returnDeviceSn); + return PopupMenuButton( + tooltip: '', + padding: EdgeInsets.zero, + color: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + itemBuilder: (context) => >[ + if (options.isEmpty) + PopupMenuItem( + enabled: false, + height: 34, + child: Text( + '暂无可用设备', + style: TextStyle(fontSize: m.fs(12), color: kWtGray), + ), + ), + for (final (value, text) in options) + PopupMenuItem( + value: value, + height: 34, + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: m.fs(12), + fontWeight: FontWeight.w600, + color: kWtNavy, + ), + ), + ), + ], + onSelected: (value) => setState(() => _returnDeviceSn = value), + child: Container( + height: m.tiny ? 24.0 : (28 * m.k).clamp(24.0, 28.0), + constraints: const BoxConstraints(maxWidth: 150), + padding: EdgeInsets.symmetric(horizontal: (8 * m.k).clamp(5.0, 8.0)), + decoration: BoxDecoration( + color: kWtSoftBlue, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.memory_rounded, + size: (13 * m.k).clamp(10.0, 13.0), + color: kWtBlue, + ), + SizedBox(width: (4 * m.k).clamp(2.0, 4.0)), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: m.fs(11.5), + fontWeight: FontWeight.w700, + color: kWtBlue, + ), + ), + ), + Icon( + Icons.expand_more_rounded, + size: (14 * m.k).clamp(11.0, 14.0), + color: kWtBlue, + ), + ], + ), + ), + ); + } + + /// 障碍矩形卡片横排(圈地障碍与返航障碍共用):序号徒标 + 名字(宽×高) + 编辑 + 删除 + Widget _obstacleRectStrip( + _EL m, + List> holes, { + required ValueChanged onEdit, + required ValueChanged onDelete, + required String emptyHint, + + /// 自定义显示名(不传则用尺寸名);返航障碍传入以支持改名 + String Function(List corners, int index)? nameOf, + + /// 点名字改名的回调(不传则名字不可点) + ValueChanged? onRename, + }) { + if (holes.isEmpty) { + return Container( + height: m.tiny ? 40.0 : 46.0, + alignment: Alignment.center, + decoration: BoxDecoration( + color: kWtSegBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: kWtBorder), + ), + child: Text( + emptyHint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: m.fs(11.5), color: kWtGray), + ), + ); + } + return SizedBox( + height: m.tiny ? 40.0 : 46.0, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: holes.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final corners = holes[index]; + final String label = nameOf != null + ? nameOf(corners, index) + : _rectName(corners); + return Container( + width: m.tiny ? 180.0 : 208.0, + padding: EdgeInsets.symmetric( + horizontal: (8 * m.k).clamp(6.0, 8.0), + ), + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: kWtBorder), + ), + child: Row( + children: [ + Container( + width: 16, + height: 16, + alignment: Alignment.center, + decoration: const BoxDecoration( + color: kWtWarn, + shape: BoxShape.circle, + ), + child: Text( + '${index + 1}', + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 5), + Expanded( + child: onRename == null + ? Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: m.fs(11), + height: 1.1, + fontWeight: FontWeight.w700, + color: kWtNavy, + ), + ) + : InkWell( + onTap: () => onRename(index), + borderRadius: BorderRadius.circular(6), + child: Row( + children: [ + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: m.fs(11), + height: 1.1, + fontWeight: FontWeight.w700, + color: kWtNavy, + ), + ), + ), + Icon( + Icons.drive_file_rename_outline_rounded, + size: (13 * m.k).clamp(10.0, 13.0), + color: kWtGray, + ), + ], + ), + ), + ), + InkWell( + onTap: () => onEdit(index), + borderRadius: BorderRadius.circular(999), + child: Padding( + padding: const EdgeInsets.all(2), + child: Icon( + Icons.edit_rounded, + size: (15 * m.k).clamp(12.0, 15.0), + color: kWtBlue, + ), + ), + ), + SizedBox(width: (2 * m.k).clamp(1.0, 2.0)), + InkWell( + onTap: () => onDelete(index), + borderRadius: BorderRadius.circular(999), + child: Padding( + padding: const EdgeInsets.all(2), + child: Icon( + Icons.close_rounded, + size: (14 * m.k).clamp(11.0, 14.0), + color: kWtGray, + ), + ), + ), + ], + ), + ); + }, + ), + ); + } + + /// 已打点:横向滚动卡片。返航 = 序号+坐标+设备;分区 = 序号+坐标+删除;障碍物/返航障碍 = 名字(宽×高)+编辑+删除 + Widget _returnPointStrip(_EL m) { + // 返航障碍点:与圈地障碍同一套矩形卡片,数据走 _returnObstacleHoles(独立) + if (_dropMode == 0 && _returnDropObstacle) { + return _obstacleRectStrip( + m, + _returnObstacleHoles, + onEdit: _editReturnObstacleRect, + onDelete: _removeReturnObstacle, + nameOf: (corners, i) => _returnObstacleDisplay(i), + onRename: _renameReturnObstacle, + emptyHint: '对准障碍位置点绿色「+」在返航线上落一个矩形(点名字可改名)', + ); + } + // 障碍物(圈地):每个矩形一张卡,显示名字 + 编辑尺寸 + 删除 + if (_dropMode == 2) { + return _obstacleRectStrip( + m, + _obstacleHoles, + onEdit: _editObstacleRect, + onDelete: _removeDraftPoint, + emptyHint: '对准障碍物位置点绿色「+」落一个矩形', + ); + } + // 分区(作业区):序号 + 坐标 + 删除 + if (_dropMode == 1) { + final points = _regionPoints; + if (points.isEmpty) { + return Container( + height: m.tiny ? 40.0 : 46.0, + alignment: Alignment.center, + decoration: BoxDecoration( + color: kWtSegBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: kWtBorder), + ), + child: Text( + '尚未加分区点:点绿色「+」依次打 4 个边界点', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: m.fs(11.5), color: kWtGray), + ), + ); + } + return SizedBox( + height: m.tiny ? 40.0 : 46.0, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: points.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final p = points[index]; + return Container( + width: m.tiny ? 168.0 : 196.0, + padding: EdgeInsets.symmetric( + horizontal: (8 * m.k).clamp(6.0, 8.0), + ), + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: kWtBorder), + ), + child: Row( + children: [ + Container( + width: 16, + height: 16, + alignment: Alignment.center, + decoration: const BoxDecoration( + color: kWtBlue, + shape: BoxShape.circle, + ), + child: Text( + '${index + 1}', + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 5), + Expanded( + child: Text( + '${p.latitude.toStringAsFixed(6)}, ' + '${p.longitude.toStringAsFixed(6)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: m.fs(9.5), + height: 1.1, + fontWeight: FontWeight.w600, + color: kWtGray2, + ), + ), + ), + InkWell( + onTap: _regionEditor.state.savedWithoutId + ? null + : () => _regionEditor.removePoint(index), + borderRadius: BorderRadius.circular(999), + child: Padding( + padding: const EdgeInsets.all(2), + child: Icon( + Icons.close_rounded, + size: (14 * m.k).clamp(11.0, 14.0), + color: kWtGray, + ), + ), + ), + ], + ), + ); + }, + ), + ); + } + if (_returnPoints.isEmpty) { + return Container( + height: m.tiny ? 40.0 : 46.0, + alignment: Alignment.center, + decoration: BoxDecoration( + color: kWtSegBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: kWtBorder), + ), + child: Text( + '尚未添加返航点:点绿色「+」在当前准星/设备位置打点', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: m.fs(11.5), color: kWtGray), + ), + ); + } + return SizedBox( + height: m.tiny ? 54.0 : 60.0, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _returnPoints.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, index) => _returnPointCard(m, index), + ), + ); + } + + Widget _returnPointCard(_EL m, int index) { + final bool isFirst = index == 0; + return Container( + width: m.tiny ? 150.0 : 172.0, + padding: EdgeInsets.fromLTRB((8 * m.k).clamp(6.0, 8.0), 5, 5, 5), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: kWtBorder), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 16, + height: 16, + alignment: Alignment.center, + decoration: BoxDecoration( + color: isFirst ? kWtBlue : const Color(0xFF7B61FF), + shape: BoxShape.circle, + ), + child: Text( + isFirst ? 'H' : '${index + 1}', + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 5), + Expanded( + child: InkWell( + onTap: () => _renameReturnPoint(index), + borderRadius: BorderRadius.circular(6), + child: Row( + children: [ + Expanded( + child: Text( + _returnPointDisplay(index), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: m.fs(10.5), + height: 1.1, + fontWeight: FontWeight.w700, + color: kWtNavy, + ), + ), + ), + Icon( + Icons.drive_file_rename_outline_rounded, + size: (13 * m.k).clamp(10.0, 13.0), + color: kWtGray, + ), + ], + ), + ), + ), + InkWell( + onTap: () => _removeReturnPoint(index), + borderRadius: BorderRadius.circular(999), + child: Padding( + padding: const EdgeInsets.all(2), + child: Icon( + Icons.close_rounded, + size: (14 * m.k).clamp(11.0, 14.0), + color: kWtGray, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + _returnPointDeviceSelector(m, index), + ], + ), + ); + } + + /// 单点设备选择(点开列表选设备) + Widget _returnPointDeviceSelector(_EL m, int index) { + final point = _returnPoints[index]; + final options = _deviceOptionsFor(point.deviceSn, point.deviceLabel); + return PopupMenuButton( + tooltip: '', + padding: EdgeInsets.zero, + color: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + itemBuilder: (context) => >[ + if (options.isEmpty) + PopupMenuItem( + enabled: false, + height: 34, + child: Text( + '暂无可用设备', + style: TextStyle(fontSize: m.fs(12), color: kWtGray), + ), + ), + for (final (value, text) in options) + PopupMenuItem( + value: value, + height: 34, + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: m.fs(12), + fontWeight: FontWeight.w600, + color: kWtNavy, + ), + ), + ), + ], + onSelected: (value) => _setReturnPointDevice(index, value), + child: Container( + height: m.tiny ? 20.0 : 22.0, + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: (6 * m.k).clamp(4.0, 6.0)), + decoration: BoxDecoration( + color: kWtSoftBlue, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + children: [ + Icon( + Icons.memory_rounded, + size: (12 * m.k).clamp(9.0, 12.0), + color: kWtBlue, + ), + const SizedBox(width: 3), + Expanded( + child: Text( + point.deviceLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: m.fs(10.5), + fontWeight: FontWeight.w700, + color: kWtBlue, + ), + ), + ), + Icon( + Icons.expand_more_rounded, + size: (12 * m.k).clamp(9.0, 12.0), + color: kWtBlue, + ), + ], + ), + ), + ); + } + + /// 取消 / 撤回 / 保存 / 执行(返航);分区/障碍物 = 取消/撤回/完成/保存 + Widget _returnActionRow(_EL m) { + if (_dropMode == 1) return _regionActionRow(m); + if (_dropMode != 0) { + final bool obstacle = _dropMode == 2; + return Row( + children: [ + Expanded( + child: Text( + _statusTextOfArea, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: m.fs(11), + height: 1.15, + fontWeight: FontWeight.w600, + color: kWtGray2, + ), + ), + ), + SizedBox(width: (10 * m.k).clamp(6.0, 10.0)), + _barButton( + m, + '取消', + primary: false, + icon: Icons.close_rounded, + onPressed: _closeDropPanel, + ), + SizedBox(width: (6 * m.k).clamp(4.0, 6.0)), + _barButton( + m, + '撤回', + primary: false, + icon: Icons.undo_rounded, + onPressed: _undoAction, + ), + // 障碍物矩形落点即成组,无需「完成/成组」;分区仍保留「完成」生成路线 + if (!obstacle) ...[ + SizedBox(width: (6 * m.k).clamp(4.0, 6.0)), + _barButton( + m, + '完成', + primary: false, + icon: Icons.check_rounded, + onPressed: _onCompleteTap, + ), + ], + SizedBox(width: (6 * m.k).clamp(4.0, 6.0)), + _barButton( + m, + '保存', + primary: true, + icon: Icons.save_outlined, + onPressed: _showSaveDialog, + ), + ], + ); + } + return Row( + children: [ + Expanded( + child: Text( + _returnDropObstacle + ? '障碍点 ${_returnObstacleHoles.length} 个' + '${_returnSaved ? ' · 已保存' : ' · 与分区/障碍互不影响'}' + : _returnHint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: m.fs(11), + height: 1.15, + fontWeight: FontWeight.w600, + color: kWtGray2, + ), + ), + ), + SizedBox(width: (10 * m.k).clamp(6.0, 10.0)), + _barButton( + m, + '取消', + primary: false, + icon: Icons.close_rounded, + onPressed: () => _exitReturnMode(clear: true), + ), + SizedBox(width: (6 * m.k).clamp(4.0, 6.0)), + _barButton( + m, + '撤回', + primary: false, + icon: Icons.undo_rounded, + onPressed: _returnDropObstacle + ? _undoReturnObstacle + : _undoReturnPoint, + ), + SizedBox(width: (6 * m.k).clamp(4.0, 6.0)), + _barButton( + m, + _savingReturn ? '保存中' : (_returnPlanId != null ? '修改' : '保存'), + primary: false, + icon: _savingReturn ? Icons.autorenew_rounded : Icons.save_outlined, + onPressed: _saveReturnPlan, + ), + SizedBox(width: (6 * m.k).clamp(4.0, 6.0)), + _barButton( + m, + '执行', + primary: true, + icon: Icons.send_rounded, + onPressed: _executeReturnPlan, + ), + ], + ); + } + // ===== 底部操作面板(圈地后出现,逻辑同路径规划 BottomOperationPanel)===== + /// 横屏下不再铺满整宽:会成底部居中的窄卡片(最大 620), + /// 左右留白、圆角悬浮,既不像“白条”一样横跨整屏,也不挡左侧工具栏。 Widget _buildOperationPanel(_EL m) { return Positioned( left: 0, right: 0, - bottom: 0, - child: Container( - padding: EdgeInsets.fromLTRB( - m.pad + m.safeLeft, - (12 * m.k).clamp(8.0, 12.0), - m.pad + m.safeRight, - (10 * m.k).clamp(6.0, 10.0), - ), - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(18)), - boxShadow: [ - BoxShadow( - color: Color(0x241A2B4A), - blurRadius: 20, - offset: Offset(0, -6), + bottom: m.margin + (4 * m.k).clamp(2.0, 4.0), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: (m.size.width * 0.58).clamp(360.0, 620.0), + ), + child: Container( + padding: EdgeInsets.fromLTRB( + (16 * m.k).clamp(12.0, 16.0), + (12 * m.k).clamp(8.0, 12.0), + (16 * m.k).clamp(12.0, 16.0), + (10 * m.k).clamp(6.0, 10.0), ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // 内容区:地块 / 航线(同 _buildTabContent) - if (_panelTabLand) ..._panelLandRows(m) else ..._panelRouteRows(m), - // 底部标签切换:只有覆盖模式才说明(同 isShowButton) - if (_workMode == WorkMode.bow) ...[ - SizedBox(height: (8 * m.k).clamp(5.0, 8.0)), - _panelTabs(m), - ], - ], + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: const [ + BoxShadow( + color: Color(0x2E1A2B4A), + blurRadius: 26, + offset: Offset(0, 10), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 内容区:地块 / 航线(同 _buildTabContent) + if (_panelTabLand) + ..._panelLandRows(m) + else + ..._panelRouteRows(m), + // 底部标签切换:只有覆盖模式才说明(同 isShowButton) + if (_workMode == WorkMode.bow) ...[ + SizedBox(height: (8 * m.k).clamp(5.0, 8.0)), + _panelTabs(m), + ], + ], + ), + ), ), ), ); @@ -1285,16 +3816,11 @@ class _RouteEditorPageV2State extends State { _dropdown( m, value: _robotMode, - items: const [ - (RobotMode.point, '准星打点'), - (RobotMode.robot, '设备打点'), - ], + items: const [(RobotMode.point, '准星打点'), (RobotMode.robot, '设备打点')], onChanged: (mode) { setState(() => _robotMode = mode); _toast( - mode == RobotMode.point - ? '准星打点:地图中心即打点位置' - : '设备打点:取设备实时坐标', + mode == RobotMode.point ? '准星打点:地图中心即打点位置' : '设备打点:取设备实时坐标', ); }, ), @@ -1326,7 +3852,13 @@ class _RouteEditorPageV2State extends State { true, () => _deleteAllAction(), ), - _roundAction(m, Icons.undo_rounded, '撤回', canUndo, () => _undoAction()), + _roundAction( + m, + Icons.undo_rounded, + '撤回', + canUndo, + () => _undoAction(), + ), _roundAction(m, Icons.check_rounded, '完成', true, _onCompleteTap), _roundAction( m, @@ -1399,14 +3931,43 @@ class _RouteEditorPageV2State extends State { ]; } - /// 完成:禁行区先成组再生成路线(同 onComplete) + /// 完成:障碍物改为落矩形即成组,这里统一只生成路线 Future _onCompleteTap() async { - if (_areaMode == AreaMode.obstacle) { - _completeObstacle(); - await _generatePath(); - } else { - await _generatePath(); - } + await _generatePath(); + } + + /// 编辑已落矩形的 X/Y 尺寸:以当前中心为锚重建四角替换 + void _editObstacleRect(int index) { + if (index < 0 || index >= _obstacleHoles.length) return; + final corners = _obstacleHoles[index]; + final center = _rectCenter(corners); + final (double w, double h) = _rectSizeOf(corners); + _showRectSizeDialog( + center: center, + w: w, + h: h, + onOk: (double nw, double nh) { + setState(() { + _obstacleHoles[index] = _rectCorners(center, nw, nh); + }); + }, + ); + } + + /// 矩形尺寸编辑弹窗:宽 X / 高 Y(米),实时预览名字;确定回调按中心重建四角 + Future _showRectSizeDialog({ + required LatLng center, + required double w, + required double h, + required void Function(double w, double h) onOk, + }) async { + // 用独立 StatefulWidget 而非 StatefulBuilder: + // 后者在横屏键盘弹/收的 MediaQuery 重建中易踩 _dependents.isEmpty 断言。 + final (double, double)? result = await showDialog<(double, double)>( + context: context, + builder: (_) => _RectSizeDialog(initialW: w, initialH: h), + ); + if (result != null) onOk(result.$1, result.$2); } /// 地块 / 航线切换条 @@ -1446,11 +4007,11 @@ class _RouteEditorPageV2State extends State { ); } - /// 绿色圆形「增加点」按钮(同原面板的加号) - Widget _addPointButton(_EL m) { + /// 绿色圆形「增加点」按钮(同原面板的加号);返航模式传自己的打点回调 + Widget _addPointButton(_EL m, {VoidCallback? onTap}) { final double side = m.tiny ? 34.0 : 40.0; return InkWell( - onTap: _addMarkedPoint, + onTap: onTap ?? _addMarkedPoint, customBorder: const CircleBorder(), child: Container( width: side, @@ -1459,11 +4020,7 @@ class _RouteEditorPageV2State extends State { color: Color(0xFF00C853), shape: BoxShape.circle, ), - child: Icon( - Icons.add_rounded, - size: side * 0.62, - color: Colors.white, - ), + child: Icon(Icons.add_rounded, size: side * 0.62, color: Colors.white), ), ); } @@ -1650,16 +4207,199 @@ class _RouteEditorPageV2State extends State { child: ColoredBox( color: Colors.black.withValues(alpha: 0.3), child: const Center( - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 3, - ), + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 3), ), ), ); } // ===== 作业记录列表面板(同路径规划列表面板)===== + /// 一键返航方案列表浮层:一个场站业务上只允许一条,列表最多一项。 + /// 点这一项→直接回填已拉到的几何=修改态;关闭→退出返航(不新建)。 + Widget _buildReturnListPanel(_EL m) { + final draft = _pendingReturnDraft; + if (draft == null) return const SizedBox.shrink(); + final subtitle = + '${draft.restPoints.length} 个返航点 · ${draft.obstacles.length} 个障碍'; + return Positioned.fill( + child: Column( + children: [ + Expanded( + child: GestureDetector( + onTap: () => _exitReturnMode(clear: true), + child: Container(color: Colors.black.withValues(alpha: 0.3)), + ), + ), + Container( + margin: EdgeInsets.only( + left: (16 * m.k).clamp(10.0, 16.0), + right: (16 * m.k).clamp(10.0, 16.0), + bottom: (12 * m.k).clamp(8.0, 12.0), + ), + constraints: BoxConstraints( + maxWidth: (m.size.width * 0.46).clamp(320.0, 520.0), + maxHeight: m.size.height * 0.68, + ), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: const [ + BoxShadow( + color: Color(0x331A2B4A), + blurRadius: 30, + offset: Offset(0, 12), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.fromLTRB( + (16 * m.k).clamp(12.0, 16.0), + (14 * m.k).clamp(10.0, 14.0), + (10 * m.k).clamp(8.0, 10.0), + (12 * m.k).clamp(8.0, 12.0), + ), + child: Row( + children: [ + Container( + width: (28 * m.k).clamp(22.0, 28.0), + height: (28 * m.k).clamp(22.0, 28.0), + decoration: BoxDecoration( + color: kWtSoftBlue, + borderRadius: BorderRadius.circular(9), + ), + child: Icon( + Icons.u_turn_left_rounded, + size: (16 * m.k).clamp(13.0, 16.0), + color: kWtBlue, + ), + ), + const SizedBox(width: 9), + Text( + '一键返航', + style: TextStyle( + fontSize: m.fs(16), + fontWeight: FontWeight.w800, + color: kWtNavy, + ), + ), + const SizedBox(width: 7), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: kWtSoftBlue, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '1', + style: TextStyle( + fontSize: m.fs(11.5), + fontWeight: FontWeight.w700, + color: kWtBlue, + ), + ), + ), + const Spacer(), + InkWell( + onTap: () => _exitReturnMode(clear: true), + borderRadius: BorderRadius.circular(999), + child: Container( + width: (28 * m.k).clamp(22.0, 28.0), + height: (28 * m.k).clamp(22.0, 28.0), + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xFFF4F7FB), + borderRadius: BorderRadius.circular(999), + ), + child: Icon( + Icons.close_rounded, + size: (17 * m.k).clamp(13.0, 17.0), + color: kWtGray, + ), + ), + ), + ], + ), + ), + const Divider(height: 1), + InkWell( + onTap: _selectReturnPlan, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: (14 * m.k).clamp(10.0, 14.0), + vertical: (12 * m.k).clamp(8.0, 12.0), + ), + child: Row( + children: [ + Icon( + Icons.u_turn_left_rounded, + size: (18 * m.k).clamp(14.0, 18.0), + color: kWtBlue, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '返航方案', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: m.fs(13.5), + fontWeight: FontWeight.w700, + color: kWtNavy, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: m.fs(11), + color: kWtGray, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: kWtSoftBlue, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '修改', + style: TextStyle( + fontSize: m.fs(11.5), + fontWeight: FontWeight.w700, + color: kWtBlue, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + Widget _buildRecordList(_EL m) { return Positioned.fill( child: Column( @@ -1671,38 +4411,96 @@ class _RouteEditorPageV2State extends State { ), ), Container( - constraints: BoxConstraints(maxHeight: m.size.height * 0.74), - decoration: const BoxDecoration( + // 宽度不再跟随整屏:居中窄卡(320~520),两侧留白 + 悬浮圆角 + margin: EdgeInsets.only( + left: (16 * m.k).clamp(10.0, 16.0), + right: (16 * m.k).clamp(10.0, 16.0), + bottom: (12 * m.k).clamp(8.0, 12.0), + ), + constraints: BoxConstraints( + maxWidth: (m.size.width * 0.46).clamp(320.0, 520.0), + maxHeight: m.size.height * 0.68, + ), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + borderRadius: BorderRadius.circular(20), + boxShadow: const [ + BoxShadow( + color: Color(0x331A2B4A), + blurRadius: 30, + offset: Offset(0, 12), + ), + ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( - padding: EdgeInsets.symmetric( - horizontal: (16 * m.k).clamp(10.0, 16.0), - vertical: (10 * m.k).clamp(6.0, 10.0), + padding: EdgeInsets.fromLTRB( + (16 * m.k).clamp(12.0, 16.0), + (14 * m.k).clamp(10.0, 14.0), + (10 * m.k).clamp(8.0, 10.0), + (12 * m.k).clamp(8.0, 12.0), ), child: Row( children: [ + Container( + width: (28 * m.k).clamp(22.0, 28.0), + height: (28 * m.k).clamp(22.0, 28.0), + decoration: BoxDecoration( + color: kWtSoftBlue, + borderRadius: BorderRadius.circular(9), + ), + child: Icon( + Icons.format_list_bulleted_rounded, + size: (16 * m.k).clamp(13.0, 16.0), + color: kWtBlue, + ), + ), + const SizedBox(width: 9), Text( - '作业记录(${_routes.length})', + '作业记录', style: TextStyle( fontSize: m.fs(16), fontWeight: FontWeight.w800, color: kWtNavy, ), ), + const SizedBox(width: 7), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: kWtSoftBlue, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '${_routes.length}', + style: TextStyle( + fontSize: m.fs(11.5), + fontWeight: FontWeight.w700, + color: kWtBlue, + ), + ), + ), const Spacer(), InkWell( onTap: () => setState(() => _isListOpen = false), borderRadius: BorderRadius.circular(999), - child: Padding( - padding: const EdgeInsets.all(4), + child: Container( + width: (28 * m.k).clamp(22.0, 28.0), + height: (28 * m.k).clamp(22.0, 28.0), + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xFFF4F7FB), + borderRadius: BorderRadius.circular(999), + ), child: Icon( Icons.close_rounded, - size: (20 * m.k).clamp(15.0, 20.0), + size: (17 * m.k).clamp(13.0, 17.0), color: kWtGray, ), ), @@ -1768,10 +4566,13 @@ class _RouteEditorPageV2State extends State { const SizedBox(width: 10), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.start, children: [ Text( - route.name.isEmpty ? '未命名作业' : route.name, + route.name.isEmpty + ? '未命名作业' + : route.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( @@ -1835,10 +4636,7 @@ class _RouteEditorPageV2State extends State { Center( child: Text( '未获取到视频流', - style: TextStyle( - fontSize: m.fs(12), - color: Colors.white, - ), + style: TextStyle(fontSize: m.fs(12), color: Colors.white), ), ), Positioned( @@ -1871,7 +4669,7 @@ class _RouteEditorPageV2State extends State { _EL m, String label, { required bool primary, - required VoidCallback onPressed, + required VoidCallback? onPressed, IconData? icon, }) { return SizedBox( @@ -2090,6 +4888,194 @@ class _WorkModeDialogState extends State<_WorkModeDialog> { } } +/// 矩形尺寸编辑弹窗(独立 StatefulWidget):宽 X / 高 Y(米)+ 实时名字预览。 +/// 自己管 controller 生命周期,键盘弹起时整体上抬,避免 StatefulBuilder 崩溃与内容挤压。 +class _RectSizeDialog extends StatefulWidget { + const _RectSizeDialog({required this.initialW, required this.initialH}); + + final double initialW; + final double initialH; + + @override + State<_RectSizeDialog> createState() => _RectSizeDialogState(); +} + +class _RectSizeDialogState extends State<_RectSizeDialog> { + late final TextEditingController _wCtrl = TextEditingController( + text: widget.initialW.toStringAsFixed(1), + ); + late final TextEditingController _hCtrl = TextEditingController( + text: widget.initialH.toStringAsFixed(1), + ); + + @override + void dispose() { + _wCtrl.dispose(); + _hCtrl.dispose(); + super.dispose(); + } + + double? get _w => double.tryParse(_wCtrl.text.trim()); + double? get _h => double.tryParse(_hCtrl.text.trim()); + bool get _valid => (_w ?? 0) > 0 && (_h ?? 0) > 0; + + /// 只允许数字与小数点(allow 是非 const 工厂,故用静态 final 列表而非 const) + static final List _digits = [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')), + ]; + + @override + Widget build(BuildContext context) { + final double previewW = _w ?? widget.initialW; + final double previewH = _h ?? widget.initialH; + // 与改名弹窗同一策略:不用 AlertDialog 固定 title/actions(横屏键盘下会撑出溢出), + // 而是顶对齐 + 整体限高 + 内容区可滚,保证“确/取消”永远可见。 + final MediaQueryData mq = MediaQuery.of(context); + final double keyboardInset = mq.viewInsets.bottom; + final bool lifted = keyboardInset > 0; + final double maxDialogHeight = (mq.size.height - keyboardInset - 24).clamp( + 160.0, + 520.0, + ); + return Dialog( + alignment: lifted ? Alignment.topCenter : Alignment.center, + backgroundColor: Colors.white, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + insetPadding: EdgeInsets.fromLTRB( + 40, + mq.padding.top + (lifted ? 12 : 24), + 40, + keyboardInset + 12, + ), + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxDialogHeight, maxWidth: 280), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + '矩形尺寸', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 12), + // 字段+预览区单独可滚:极矮屏也不顶标题/按钮 + Flexible( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _wCtrl, + autofocus: false, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + inputFormatters: _digits, + decoration: const InputDecoration(labelText: '宽 X(米)'), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 12), + TextField( + controller: _hCtrl, + autofocus: false, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + inputFormatters: _digits, + decoration: const InputDecoration(labelText: '高 Y(米)'), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: Text( + '名字:${previewW.toStringAsFixed(1)}m×${previewH.toStringAsFixed(1)}m', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: kWtBlue, + ), + ), + ), + if (!_valid) ...[ + const SizedBox(height: 4), + const Align( + alignment: Alignment.centerLeft, + child: Text( + '请输入大于 0 的宽高', + style: TextStyle(fontSize: 12, color: kWtDanger), + ), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 6), + // 按钮行固定底部,始终可见 + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: () { + final double? ww = _w; + final double? hh = _h; + if (ww == null || hh == null || ww <= 0 || hh <= 0) { + setState(() {}); // 触发内联错误提示 + return; + } + Navigator.pop(context, (ww, hh)); + }, + child: const Text('确定'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// 一键返航点:坐标 + 该点绑定的设备 + 可选自定义名字 +class _ReturnPoint { + const _ReturnPoint({ + required this.point, + this.deviceSn, + this.deviceLabel = '未选择设备', + this.alias, + }); + + final LatLng point; + final String? deviceSn; + final String deviceLabel; + + /// 自定义名字(为空时界面与保存都退回「返航点-XX」自动编号) + final String? alias; + + _ReturnPoint copyWith({ + String? deviceSn, + String? deviceLabel, + String? alias, + }) { + return _ReturnPoint( + point: point, + deviceSn: deviceSn ?? this.deviceSn, + deviceLabel: deviceLabel ?? this.deviceLabel, + alias: alias ?? this.alias, + ); + } +} + // 绘制图层全部由 PvWorkMap 的 draft* 参数承载:作业区打点(橙色连线 + 绿色编号)、 // 准星蓝线与距离、草稿多边形、禁行区红框与红编号,与路径规划页同一套画法。 // 本页只维护状态(打点/禁行区/生成结果),不直接碰任何接口。 diff --git a/lib/features/v2/my/presentation/pages/system_settings_page.dart b/lib/features/v2/my/presentation/pages/system_settings_page.dart index 5cac40b4..29962d99 100644 --- a/lib/features/v2/my/presentation/pages/system_settings_page.dart +++ b/lib/features/v2/my/presentation/pages/system_settings_page.dart @@ -317,7 +317,9 @@ class _SystemSettingsPageState extends State { if (state is UpdateUpToDate) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: const Text('当前已是最新版本'), + content: Text( + AppLocalizations.of(context).translate('my_v2.up_to_date'), + ), backgroundColor: context.appColors.success, duration: const Duration(seconds: 2), ), @@ -326,6 +328,7 @@ class _SystemSettingsPageState extends State { }, builder: (context, state) { final isChecking = state is UpdateChecking; + final l10n = AppLocalizations.of(context); return Row( children: [ Expanded( @@ -333,7 +336,7 @@ class _SystemSettingsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '版本更新', + l10n.translate('my_v2.version_update'), style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, @@ -342,7 +345,12 @@ class _SystemSettingsPageState extends State { ), const SizedBox(height: 4), Text( - '当前版本: $_currentVersion ($_currentVersionCode)', + l10n + .translate('my_v2.current_version') + .replaceAll( + '%s', + '$_currentVersion ($_currentVersionCode)', + ), style: TextStyle( fontSize: 13, color: context.appColors.textTertiary, @@ -350,7 +358,9 @@ class _SystemSettingsPageState extends State { ), const SizedBox(height: 2), Text( - isChecking ? '正在检查更新...' : '点击检查是否有新版本', + isChecking + ? l10n.translate('my_v2.checking_update') + : l10n.translate('my_v2.tap_to_check'), style: TextStyle( fontSize: 13, color: context.appColors.textTertiary, @@ -379,9 +389,9 @@ class _SystemSettingsPageState extends State { color: const Color(0xFF165DFF), borderRadius: BorderRadius.circular(8), ), - child: const Text( - '检查更新', - style: TextStyle( + child: Text( + l10n.translate('my_v2.check_update'), + style: const TextStyle( fontSize: 14, color: Colors.white, fontWeight: FontWeight.w500, diff --git a/lib/features/v2/work/presentation/cubit/schedule_plan_create_cubit.dart b/lib/features/v2/work/presentation/cubit/schedule_plan_create_cubit.dart new file mode 100644 index 00000000..148c732a --- /dev/null +++ b/lib/features/v2/work/presentation/cubit/schedule_plan_create_cubit.dart @@ -0,0 +1,99 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../devices/domain/entities/device_schedule_plan_entity.dart'; +import '../../../../devices/domain/usecases/save_device_schedule_plan_usecase.dart'; + +/// 提交状态 +enum SchedulePlanCreateStatus { idle, submitting, success, failure } + +/// 新建作业计划页的提交状态。 +/// +/// 表单字段(设备/路线/周期规则/区间)与自动生成的计划名称由页面提供, +/// 由页面持有;本 Cubit 只负责「提交创建」这一条接口链路的状态: +/// idle → submitting → success / failure(message)。 +class SchedulePlanCreateState { + const SchedulePlanCreateState({ + this.status = SchedulePlanCreateStatus.idle, + this.message, + }); + + final SchedulePlanCreateStatus status; + + /// 失败原因(本地校验文案或后端 msg 透传),仅 failure 时有值 + final String? message; + + bool get isSubmitting => status == SchedulePlanCreateStatus.submitting; + + SchedulePlanCreateState copyWith({ + SchedulePlanCreateStatus? status, + String? message, + }) { + return SchedulePlanCreateState( + status: status ?? this.status, + message: message, + ); + } +} + +/// 创建定时作业计划:页面只调 submit,链路走 +/// UseCase → Repository → DataSource(POST /iot/deviceTask/save)。 +class SchedulePlanCreateCubit extends Cubit { + SchedulePlanCreateCubit(this._savePlanUseCase) + : super(const SchedulePlanCreateState()); + + final SaveDeviceSchedulePlanUseCase _savePlanUseCase; + + /// 提交创建。实体本地校验(含后端业务强校验)已在 UseCase 内前置拦截, + /// 这里不重复校验、也不直连任何 Repository/接口。 + Future submit(DeviceSchedulePlanEntity plan) async { + if (state.isSubmitting) return; + debugPrint( + '[新建计划] 开始提交 siteId=${plan.siteId}, routeId=${plan.routeId}, ' + 'type=${plan.type.name}', + ); + emit(state.copyWith(status: SchedulePlanCreateStatus.submitting)); + try { + final result = await _savePlanUseCase( + SaveDeviceSchedulePlanParams(plan: plan), + ); + if (isClosed) return; + emit( + result.fold( + (failure) { + debugPrint('[新建计划] 提交未成功(校验或接口失败):${failure.message}'); + return state.copyWith( + status: SchedulePlanCreateStatus.failure, + message: failure.message, + ); + }, + (saved) { + debugPrint('[新建计划] 保存结果:$saved'); + return SchedulePlanCreateState( + status: saved + ? SchedulePlanCreateStatus.success + : SchedulePlanCreateStatus.failure, + message: saved ? null : '创建计划失败,请重试', + ); + }, + ), + ); + } catch (error, stackTrace) { + debugPrint('[新建计划] 提交异常:$error'); + debugPrintStack(stackTrace: stackTrace); + if (isClosed) return; + emit( + const SchedulePlanCreateState( + status: SchedulePlanCreateStatus.failure, + message: '创建计划失败,请重试', + ), + ); + } + } + + /// failure 被提示后回到 idle,允许修改表单再次提交 + void clearFailure() { + if (state.status != SchedulePlanCreateStatus.failure) return; + emit(state.copyWith(status: SchedulePlanCreateStatus.idle)); + } +}