机器状态数据和地图切换,系统配置页面

This commit is contained in:
2026-11-06 08:49:21 +08:00
parent 84eec7da96
commit 4e92398349
12 changed files with 3660 additions and 304 deletions

View File

@@ -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<RouteType?> showRouteTypePicker(
BuildContext context, {
String title = '选择规划类型',
RouteType? initial,
double? maxWidth,
EdgeInsets? insetPadding,
}) {
return showDialog<RouteType>(
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,
),
),
),
],
),
),
);
}
}

View File

@@ -549,8 +549,32 @@ class TcpClient {
_socket!.add(data);
}
void sendRaw(int cmd, List<int> payload) {
if (_socket == null) return;
/// 🔥 断线丢帧日志节流时间戳:控制循环每 200ms 就会撞一次「未连接」,
/// 全量打印会淹掉日志文件,故 5 秒内只记一次(丢帧本身仍如实返回 false)。
DateTime? _lastSendDropLogAt;
/// 把一帧协议数据写入 socket。
///
/// 返回 `false` = 这一帧**根本没交给系统 socket**(未连接,或写入抛异常)。
/// 🔥 原来的 void 版本在 `_socket == null` 时静默 return,调用方完全无从知晓:
/// `stopAllMovement()` 连发的 30 帧停止指令会在断线窗口里一帧都不出网,
/// 而代码以为「已发送」——设备只保留了断开前最后一帧运动指令,正是
/// 「松手了车还在走」的一条隐藏通路。现在必须由调用方判断结果自行兜底。
bool sendRaw(int cmd, List<int> 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;
}
}

View File

@@ -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 <ReturnRestPoint>[],
this.obstacles = const <ReturnObstaclePoint>[],
this.id,
});
/// siteMapInfo 主键:更新已有方案时传,新建传 null(省略由后端插入)
final int? id;
final int siteId;
final int orgId;
final List<ReturnRestPoint> restPoints;
final List<ReturnObstaclePoint> obstacles;
bool get isEmpty => restPoints.isEmpty && obstacles.isEmpty;
}

View File

@@ -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',

View File

@@ -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<bool, SaveDeviceSchedulePlanParams> {
final DeviceTaskRepository repository;
SaveDeviceSchedulePlanUseCase(this.repository);
@override
Future<Either<Failure, bool>> 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});
}

View File

@@ -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<RunningStatusPage> 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<RunningStatusPage> 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) {

View File

@@ -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<MapPageEnterprise> {
}
}
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<MapPageEnterprise> {
// 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<MapPageEnterprise> {
debugPrint('📤 [保存地块-请求详情] ================= END =================');
}
Future<void> _savePlotData(String plotName, String? imgBase64) async {
Future<void> _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<MapPageEnterprise> {
? 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<MapPageEnterprise> {
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(动态),失败回退内置兜底

View File

@@ -55,9 +55,12 @@ class _RightJoystickAreaState extends State<RightJoystickArea> {
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,
),

View File

@@ -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 = '蓝牙模式';

View File

@@ -317,7 +317,9 @@ class _SystemSettingsPageState extends State<SystemSettingsPage> {
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<SystemSettingsPage> {
},
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<SystemSettingsPage> {
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<SystemSettingsPage> {
),
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<SystemSettingsPage> {
),
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<SystemSettingsPage> {
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,

View File

@@ -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<SchedulePlanCreateState> {
SchedulePlanCreateCubit(this._savePlanUseCase)
: super(const SchedulePlanCreateState());
final SaveDeviceSchedulePlanUseCase _savePlanUseCase;
/// 提交创建。实体本地校验(含后端业务强校验)已在 UseCase 内前置拦截,
/// 这里不重复校验、也不直连任何 Repository/接口。
Future<void> 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));
}
}