From d882db79953bae64a12f03145e38dc5c4ad69c53 Mon Sep 17 00:00:00 2001 From: Songzex <2402265378@qq.com> Date: Fri, 6 Nov 2026 08:34:19 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=9C=E7=A8=8B=E9=81=A5=E6=8E=A7=E7=9B=B8?= =?UTF-8?q?=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/components/capsule_toast.dart | 29 +- lib/components/control_mode_text.dart | 56 + lib/core/bluetooth/ble_protocol_decoder.dart | 13 +- .../device_control_mode_realtime_adapter.dart | 34 + .../repositories/autonomous_work_gateway.dart | 35 + .../control_mode_remote_datasource.dart | 73 + .../device_control_mode_repository_impl.dart | 30 + .../domain/entities/device_control_mode.dart | 18 + .../device_control_mode_repository.dart | 11 + .../device_control_mode_usecases.dart | 25 + .../cubit/control_mode_entry_cubit.dart | 199 +++ .../widgets/control_mode_entry_gate.dart | 220 +++ .../widgets/map/app_base_map_layer.dart | 104 ++ .../widgets/map/base_map_config.dart | 102 ++ .../datasources/bind_device_datasource.dart | 9 + .../impl/bind_device_datasource_impl.dart | 158 ++- .../data/models/device_data_model.dart | 14 + .../bind_device_repository_impl.dart | 16 + .../domain/entities/bind_device_entities.dart | 36 + .../domain/entities/device_entity.dart | 8 + .../domain/entities/device_model_info.dart | 108 ++ .../repositories/bind_device_repository.dart | 9 + .../domain/usecases/bind_device_usecases.dart | 26 + .../presentation/cubit/bind_device_cubit.dart | 13 + .../cubit/device_ownership_action_cubit.dart | 497 +++++++ .../cubit/device_ownership_action_state.dart | 139 ++ .../pages/bind_activate_page_v2.dart | 123 +- .../pages/bind_device_page_v2.dart | 86 +- .../pages/ble_device_detail_page.dart | 208 ++- .../pages/device_all_functions_page_v2.dart | 146 +- .../pages/device_detail_page_v2.dart | 1224 ++++++++++++----- .../device_ownership_action_page_v2.dart | 959 +++++++++++++ .../widgets/bluetooth_scan_modal.dart | 122 +- .../presentation/widgets/device_card_v2.dart | 86 +- .../widgets/device_confirm_dialog.dart | 11 +- .../widgets/device_item_widget.dart | 18 + packages/cc_ui_kit/lib/src/cc_joystick.dart | 9 +- test/capsule_toast_test.dart | 36 + test/cc_joystick_safety_test.dart | 79 ++ test/control_mode_entry_widget_test.dart | 254 ++++ test/device_control_mode_test.dart | 251 ++++ test/support/control_mode_fakes.dart | 60 + 42 files changed, 5023 insertions(+), 631 deletions(-) create mode 100644 lib/components/control_mode_text.dart create mode 100644 lib/core/di/adapters/device_control_mode_realtime_adapter.dart create mode 100644 lib/features/device_control_mode/data/datasources/control_mode_remote_datasource.dart create mode 100644 lib/features/device_control_mode/data/repositories/device_control_mode_repository_impl.dart create mode 100644 lib/features/device_control_mode/domain/entities/device_control_mode.dart create mode 100644 lib/features/device_control_mode/domain/repositories/device_control_mode_repository.dart create mode 100644 lib/features/device_control_mode/domain/usecases/device_control_mode_usecases.dart create mode 100644 lib/features/device_control_mode/presentation/cubit/control_mode_entry_cubit.dart create mode 100644 lib/features/device_control_mode/presentation/widgets/control_mode_entry_gate.dart create mode 100644 lib/features/home/presentation/widgets/map/app_base_map_layer.dart create mode 100644 lib/features/home/presentation/widgets/map/base_map_config.dart create mode 100644 lib/features/v2/device_list/domain/entities/device_model_info.dart create mode 100644 lib/features/v2/device_list/presentation/cubit/device_ownership_action_cubit.dart create mode 100644 lib/features/v2/device_list/presentation/cubit/device_ownership_action_state.dart create mode 100644 lib/features/v2/device_list/presentation/pages/device_ownership_action_page_v2.dart create mode 100644 test/capsule_toast_test.dart create mode 100644 test/cc_joystick_safety_test.dart create mode 100644 test/control_mode_entry_widget_test.dart create mode 100644 test/device_control_mode_test.dart create mode 100644 test/support/control_mode_fakes.dart diff --git a/lib/components/capsule_toast.dart b/lib/components/capsule_toast.dart index edc1c83b..56c06867 100644 --- a/lib/components/capsule_toast.dart +++ b/lib/components/capsule_toast.dart @@ -7,6 +7,11 @@ class CapsuleToast { static OverlayEntry? _entry; static bool _showing = false; + /// 弹出代数:每次真正插入新胶囊时递增。 + /// 自动消失的定时器只允许关闭「自己这一代」的胶囊, + /// 否则旧提示的定时器会把后弹的新提示提前移除(连点时新 toast 一闪即没)。 + static int _generation = 0; + /// 显示胶囊提示 /// [message] 提示文案 /// [duration] 显示时长,默认 2 秒 @@ -49,6 +54,7 @@ class CapsuleToast { _entry?.remove(); _showing = false; } + final int gen = ++_generation; _entry = OverlayEntry( builder: (context) => IgnorePointer( @@ -56,7 +62,8 @@ class CapsuleToast { child: Align( alignment: Alignment.topCenter, child: Container( - margin: const EdgeInsets.only(top: 64), + // 左右留白:英文文案明显长于中文,限制胶囊最大宽度避免贴边溢出 + margin: const EdgeInsets.only(top: 64, left: 24, right: 24), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: const Color(0xE62C2C2C), @@ -80,12 +87,18 @@ class CapsuleToast { ), const SizedBox(width: 6), ], - Text( - message, - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w500, + // 长文案(尤其英文)需要可收缩并换行,否则 Row 会横向溢出 + Flexible( + child: Text( + message, + maxLines: 3, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), ), ), ], @@ -100,6 +113,8 @@ class CapsuleToast { _showing = true; Future.delayed(duration, () { + // 期间已弹出过更新的提示 → 本定时器属于旧一代,不能误杀新胶囊 + if (gen != _generation) return; if (_showing) { _entry?.remove(); _showing = false; diff --git a/lib/components/control_mode_text.dart b/lib/components/control_mode_text.dart new file mode 100644 index 00000000..9e77a6b9 --- /dev/null +++ b/lib/components/control_mode_text.dart @@ -0,0 +1,56 @@ +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; + +/// 控制模式(`RunningStatusEntity.controlMode`)→ 界面文案的唯一映射。 +/// +/// 为什么要单独一个文件:这个值以前在 7 个页面各写一遍「== 3 ? 远程 : 本地」, +/// 而数据层又把「后端没给」的默认值写成 `'0'`,两个错误叠在一起,结果是 +/// 设备从来没上报过控制模式时,界面一本正经地显示「本地控制」——假信号。 +/// +/// 协议定义(2026-11 后端重新确认,覆盖早先「0=无控制 / 2=蓝牙 / 4=其他接口」那版): +/// 空串 / null → 「未知」:后端根本没给这个字段 +/// 0 → 未知 +/// 1 → 本地控制 +/// 2 → 自主任务 +/// 3 → 远程控制(远程遥控) +/// 其它数字 → 「未知(6)」:协议外的值,不猜它是什么意思 +/// 非数字字符串 → 原样显示:后端给什么展示什么 +/// +/// [controlModeKey] 只返回已定义的 0~4,其余为 null,供各页面沿用本页的 +/// 翻译器与自定义文案接;需要一步拿到最终文本用 [controlModeDisplayText]。 + +/// 已定义的协议值 → 多语言 key;空值、非数字、协议外数字返回 null。 +String? controlModeKey(String? raw) { + final int? n = controlModeValue(raw); + if (n == null) return null; + return switch (n) { + 0 => 'common.unknown', + 1 => 'running_status.local_control', + 2 => 'running_status.autonomous_task', + 3 => 'running_status.remote_control', + _ => null, + }; +} + +/// 原始值 → 协议数值。空串/非数字返回 null,表示「没有可信的控制模式数据」。 +int? controlModeValue(String? raw) { + final String v = (raw ?? '').trim(); + if (v.isEmpty) return null; + return int.tryParse(v); +} + +/// 直接得到可显示文案(含未知/协议外/非数字的兜底)。 +String controlModeDisplayText(AppLocalizations l10n, String? raw) { + final String? key = controlModeKey(raw); + if (key != null) return l10n.translate(key); + + final String v = (raw ?? '').trim(); + if (v.isEmpty) return l10n.translate('common.unknown'); + + final int? n = int.tryParse(v); + // 协议外的数字:带上原值,现场排查时能看出后端到底发了什么 + if (n != null) { + return '${l10n.translate('common.unknown')}($n)'; + } + // 后端给的不是数字:按它给的显示,不做任何猜测 + return v; +} diff --git a/lib/core/bluetooth/ble_protocol_decoder.dart b/lib/core/bluetooth/ble_protocol_decoder.dart index 52ab9cbe..5eb3ff5c 100644 --- a/lib/core/bluetooth/ble_protocol_decoder.dart +++ b/lib/core/bluetooth/ble_protocol_decoder.dart @@ -152,18 +152,19 @@ class BleProtocolDecoder { parts.add(''); } - // 协议定义 控制模式: 0无控制 1本地遥控 2蓝牙 3TCP 4其他接口; + // 协议定义 控制模式: 0未知 1本地 2自主任务 3远程遥控(2026-11 后端重新确认, + // 覆盖早先 0无控制/1本地遥控/2蓝牙/3TCP/4其他接口 那版; + // 与 lib/components/control_mode_text.dart 保持同一口径); // 先按整数解析(容忍前后空格),有映射的一律显示对应文字; // 超出协议范围的码(如固件扩展值)显示为 未知(n),避免被误当成有效模式 String controlModeText(String v) { final n = int.tryParse(v.trim()); if (n == null) return v.isEmpty ? '--' : v; return switch (n) { - 0 => '无控制', - 1 => '本地遥控', - 2 => '蓝牙', - 3 => 'TCP', - 4 => '其他接口', + 0 => '未知', + 1 => '本地', + 2 => '自主任务', + 3 => '远程遥控', _ => '未知($n)', }; } diff --git a/lib/core/di/adapters/device_control_mode_realtime_adapter.dart b/lib/core/di/adapters/device_control_mode_realtime_adapter.dart new file mode 100644 index 00000000..b6769994 --- /dev/null +++ b/lib/core/di/adapters/device_control_mode_realtime_adapter.dart @@ -0,0 +1,34 @@ +import '../../../features/devices/presentation/bloc/device_status_bloc.dart'; +import '../../../features/devices/presentation/bloc/device_status_state.dart'; +import '../../../features/device_control_mode/domain/entities/device_control_mode.dart'; + +/// 适配设备详情「运行概况」的同一个实时源;不另建 MQTT 连接或猜测模式。 +class DeviceControlModeRealtimeAdapter { + DeviceControlModeRealtimeAdapter(this.statusBloc); + final DeviceStatusBloc statusBloc; + + Stream watch(String deviceId) { + return Stream.multi((controller) { + void forward(DeviceStatusState state) { + final belongsToDevice = statusBloc.listeningDeviceSn == deviceId; + controller.add( + DeviceControlModeReading( + deviceId: deviceId, + rawMode: belongsToDevice && state is DeviceStatusUpdated + ? state.status.controlMode + : null, + ), + ); + } + + final subscription = statusBloc.stream.listen( + forward, + onError: controller.addError, + ); + controller.onCancel = subscription.cancel; + // 换设备时 Bloc 同步清空旧缓存;同设备保留最新快照,与设备详情一致。 + statusBloc.startMqttRealtimeListening(deviceId); + forward(statusBloc.state); + }); + } +} diff --git a/lib/features/autonomous_work/domain/repositories/autonomous_work_gateway.dart b/lib/features/autonomous_work/domain/repositories/autonomous_work_gateway.dart index f85f8a15..d6129dcd 100644 --- a/lib/features/autonomous_work/domain/repositories/autonomous_work_gateway.dart +++ b/lib/features/autonomous_work/domain/repositories/autonomous_work_gateway.dart @@ -1,6 +1,18 @@ import 'package:latlong2/latlong.dart'; import '../entities/work_session_models.dart'; +import '../entities/return_plan_models.dart'; + +/// 可查询真实任务记录的新版实现;未实现时页面明确显示不可用。 +abstract interface class WorkTaskHistorySource { + Future>> loadTaskHistory({ + required String deviceId, + }); +} + +abstract interface class WorkTaskQueueSource { + Stream watchTaskChanges({required String deviceId}); +} /// 自主作业的业务门面。 /// @@ -46,6 +58,9 @@ abstract class AutonomousWorkGateway { /// /// - [path]:作业几何;[outer]:作业区边界(自定义模式传空) /// - [planModel]:0 = 覆盖模式、2 = 自定义模式(与 mode 字段同源) + /// - [routeType]:后端 workRecord.routeType,取 RouteType 枚举的 code + /// (如 'MOWING_TASK')。后端必填、与 mode 平级,缺失会报「未选择规划类型」; + /// 这里用 String 而不是枚举,避免 domain 层反向依赖 presentation。 /// - [imageBase64]:地图截图(jpg,不带 data url 前缀),截图失败传 null /// /// 环境差异(接口地址/鉴权/截图是否必需)全部封在实现里,页面只负责取几何。 @@ -55,9 +70,29 @@ abstract class AutonomousWorkGateway { required List path, required List outer, required int planModel, + required String routeType, String? imageBase64, }); + /// 保存一键返航方案(返航点 + 线上禁行区)→ /system/siteMapInfo/save。 + /// + /// 接口地址、后端字段名(size_x / tempObstaclePoints / source 等)、 + /// 鉴权与 id 语义(有 id=更新、无 id=新建)全部封在实现里; + /// 页面只交出业务数据 [ReturnPlanDraft]。 + /// 返回 data = 后端回写的 siteMapInfo 主键(拿不到则为 null,仅表示无法回填更新)。 + Future> saveReturnPlan(ReturnPlanDraft draft); + + /// 查询某场站已有的一键返航方案 → /system/siteMapInfo/selectBySiteId。 + /// + /// 一个场站业务上只允许一条方案,接口直接返回带完整几何的单体: + /// - 有 → data 为回填好的 [ReturnPlanDraft](id 非空 = 修改态) + /// - 无 → data 为 null(进入空白新建态) + /// 接口地址 / 后端字段名(size_x / tempObstaclePoints 等)/ 鉴权全部封在实现里, + /// 页面只按 data 是否为 null 决定「修改」还是「新建」。 + Future> fetchReturnPlanBySiteId({ + required int siteId, + }); + /// 当前任务实时状态流(null 表示设备上没有活跃任务) Stream watchTask({required String deviceId}); diff --git a/lib/features/device_control_mode/data/datasources/control_mode_remote_datasource.dart b/lib/features/device_control_mode/data/datasources/control_mode_remote_datasource.dart new file mode 100644 index 00000000..f62f1b9e --- /dev/null +++ b/lib/features/device_control_mode/data/datasources/control_mode_remote_datasource.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import '../../../../core/consts/http_api_consts.dart'; +import '../../../../core/logging/i_logger_service.dart'; +import '../../domain/entities/device_control_mode.dart'; + +class ControlModeRemoteDatasource { + ControlModeRemoteDatasource(this.dio, this.logger); + final Dio dio; + final ILoggerService logger; + + Future change(String deviceId, ControlModeTarget target) async { + final parameters = {'deviceId': deviceId, 'controlMode': target.value}; + final requestId = DateTime.now().microsecondsSinceEpoch.toString(); + final clock = Stopwatch()..start(); + final uri = Uri.parse(HttpApiConsts.hostModeChange).replace( + queryParameters: parameters.map((key, value) => MapEntry(key, '$value')), + ); + void log(String stage, Map details) { + try { + logger.logWithLevel( + '[控制模式切换][$stage] ${jsonEncode({'requestId': requestId, 'deviceId': deviceId, 'targetMode': target.value, 'elapsedMs': clock.elapsedMilliseconds, ...details})}', + shouldLog: true, + ); + } catch (error) { + debugPrint('[控制模式切换][日志输出异常] requestId=$requestId, error=$error'); + } + } + + log('请求', { + 'method': 'GET', + 'url': uri.toString(), + 'parameters': parameters, + 'deviceIdType': deviceId.runtimeType.toString(), + 'controlModeType': target.value.runtimeType.toString(), + }); + try { + final response = await dio.get( + HttpApiConsts.hostModeChange, + queryParameters: parameters, + options: Options(receiveTimeout: const Duration(seconds: 10)), + ); + final body = response.data; + log('响应', { + 'httpStatus': response.statusCode, + 'body': body, + 'bodyType': body.runtimeType.toString(), + if (body is Map) 'businessCode': body['code'], + if (body is Map) 'message': body['msg'], + }); + if (response.statusCode != 200 || + body is! Map || + '${body['code']}' != '200' || + body['data'] == false) { + throw Exception(body is Map ? body['msg'] ?? '模式切换失败' : '模式切换响应格式错误'); + } + log('结果', {'success': true, 'note': '接口成功,允许进入页面;实时模式仍以设备上报为准,不等待回推'}); + } catch (error, stack) { + log('异常', { + 'errorType': error.runtimeType.toString(), + 'error': '$error', + if (error is DioException) 'dioType': error.type.name, + if (error is DioException) 'httpStatus': error.response?.statusCode, + if (error is DioException) 'body': error.response?.data, + 'stack': '$stack', + }); + rethrow; + } finally { + clock.stop(); + } + } +} diff --git a/lib/features/device_control_mode/data/repositories/device_control_mode_repository_impl.dart b/lib/features/device_control_mode/data/repositories/device_control_mode_repository_impl.dart new file mode 100644 index 00000000..ba1948aa --- /dev/null +++ b/lib/features/device_control_mode/data/repositories/device_control_mode_repository_impl.dart @@ -0,0 +1,30 @@ +import 'package:fpdart/fpdart.dart'; +import '../../../../core/error/failure.dart'; +import '../../domain/entities/device_control_mode.dart'; +import '../../domain/repositories/device_control_mode_repository.dart'; +import '../datasources/control_mode_remote_datasource.dart'; + +class DeviceControlModeRepositoryImpl implements DeviceControlModeRepository { + DeviceControlModeRepositoryImpl({ + required this.realtime, + required this.remote, + }); + final Stream Function(String deviceId) realtime; + final ControlModeRemoteDatasource remote; + + @override + Stream watch(String deviceId) => realtime(deviceId); + + @override + Future> change( + String deviceId, + ControlModeTarget target, + ) async { + try { + await remote.change(deviceId, target); + return const Right(unit); + } catch (error) { + return Left(Failure(error.toString())); + } + } +} diff --git a/lib/features/device_control_mode/domain/entities/device_control_mode.dart b/lib/features/device_control_mode/domain/entities/device_control_mode.dart new file mode 100644 index 00000000..3c6bc528 --- /dev/null +++ b/lib/features/device_control_mode/domain/entities/device_control_mode.dart @@ -0,0 +1,18 @@ +enum ControlModeTarget { + autonomous(2), + remote(3); + + const ControlModeTarget(this.value); + final int value; +} + +/// 仅来自当前设备实时状态,不使用设备列表的静态字段。 +class DeviceControlModeReading { + const DeviceControlModeReading({required this.deviceId, this.rawMode}); + final String deviceId; + final String? rawMode; + + int? get value => int.tryParse(rawMode?.trim() ?? ''); + bool get hasReportedMode => rawMode?.trim().isNotEmpty == true; + bool matches(ControlModeTarget target) => value == target.value; +} diff --git a/lib/features/device_control_mode/domain/repositories/device_control_mode_repository.dart b/lib/features/device_control_mode/domain/repositories/device_control_mode_repository.dart new file mode 100644 index 00000000..2a080c54 --- /dev/null +++ b/lib/features/device_control_mode/domain/repositories/device_control_mode_repository.dart @@ -0,0 +1,11 @@ +import 'package:fpdart/fpdart.dart'; +import '../../../../core/error/failure.dart'; +import '../entities/device_control_mode.dart'; + +abstract interface class DeviceControlModeRepository { + Stream watch(String deviceId); + Future> change( + String deviceId, + ControlModeTarget target, + ); +} diff --git a/lib/features/device_control_mode/domain/usecases/device_control_mode_usecases.dart b/lib/features/device_control_mode/domain/usecases/device_control_mode_usecases.dart new file mode 100644 index 00000000..31e6eeef --- /dev/null +++ b/lib/features/device_control_mode/domain/usecases/device_control_mode_usecases.dart @@ -0,0 +1,25 @@ +import 'package:fpdart/fpdart.dart'; +import '../../../../core/error/failure.dart'; +import '../entities/device_control_mode.dart'; +import '../repositories/device_control_mode_repository.dart'; + +class WatchDeviceControlModeUseCase { + const WatchDeviceControlModeUseCase(this.repository); + final DeviceControlModeRepository repository; + Stream call(String deviceId) => + repository.watch(deviceId.trim()); +} + +class ChangeDeviceControlModeUseCase { + const ChangeDeviceControlModeUseCase(this.repository); + final DeviceControlModeRepository repository; + + Future> call( + String deviceId, + ControlModeTarget target, + ) { + final id = deviceId.trim(); + if (id.isEmpty) return Future.value(Left(Failure('设备编码不能为空'))); + return repository.change(id, target); + } +} diff --git a/lib/features/device_control_mode/presentation/cubit/control_mode_entry_cubit.dart b/lib/features/device_control_mode/presentation/cubit/control_mode_entry_cubit.dart new file mode 100644 index 00000000..563b214f --- /dev/null +++ b/lib/features/device_control_mode/presentation/cubit/control_mode_entry_cubit.dart @@ -0,0 +1,199 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../domain/entities/device_control_mode.dart'; +import '../../domain/usecases/device_control_mode_usecases.dart'; + +enum ControlModeEntryPhase { ready, submitting, failed, confirmed } + +class ControlModeEntryState { + const ControlModeEntryState({ + this.phase = ControlModeEntryPhase.ready, + this.reading, + this.error, + this.errorKey, + }); + final ControlModeEntryPhase phase; + final DeviceControlModeReading? reading; + final String? error; + final String? errorKey; + bool get busy => phase == ControlModeEntryPhase.submitting; + bool get canConfirm => + !busy && + phase != ControlModeEntryPhase.confirmed && + errorKey != 'invalid_device'; +} + +/// 用户确定后下发模式;接口成功即允许进入,不等待设备模式回推。 +/// 实时模式只展示设备上报值,不用请求目标覆盖它。 +class ControlModeEntryCubit extends Cubit { + ControlModeEntryCubit({ + required this.deviceId, + required this.target, + required this.watchMode, + required this.changeMode, + this.timeout = const Duration(seconds: 12), + }) : super(const ControlModeEntryState()); + final String deviceId; + final ControlModeTarget target; + final WatchDeviceControlModeUseCase watchMode; + final ChangeDeviceControlModeUseCase changeMode; + final Duration timeout; + StreamSubscription? _subscription; + int _generation = 0; + + void _trace(String stage, [Map details = const {}]) { + try { + debugPrint( + '[控制模式切换][$stage] ${jsonEncode({'time': DateTime.now().toIso8601String(), 'deviceId': deviceId, 'currentRawMode': state.reading?.rawMode, 'parsedMode': state.reading?.value, 'targetMode': target.value, 'phase': state.phase.name, 'confirmEnabled': state.canConfirm, ...details})}', + ); + } catch (_) { + // 诊断输出不能影响模式下发。 + } + } + + Future start() async { + ++_generation; + final previous = _subscription; + if (previous != null) unawaited(previous.cancel()); + if (isClosed) return; + emit(const ControlModeEntryState()); + _trace('开始读取实时模式'); + if (deviceId.trim().isEmpty) { + _trace('设备编码为空,禁止下发'); + emit( + const ControlModeEntryState( + phase: ControlModeEntryPhase.failed, + errorKey: 'invalid_device', + ), + ); + return; + } + _subscription = watchMode(deviceId).listen( + _onReading, + onError: (Object error) { + if (!isClosed && + !state.busy && + state.phase != ControlModeEntryPhase.confirmed) { + emit( + ControlModeEntryState( + phase: ControlModeEntryPhase.failed, + reading: state.reading, + error: '$error', + ), + ); + } + }, + ); + } + + void _onReading(DeviceControlModeReading reading) { + if (state.reading == null || state.reading?.rawMode != reading.rawMode) { + _trace('实时模式收到', { + 'receivedDeviceId': reading.deviceId, + 'receivedRawMode': reading.rawMode, + }); + } + if (isClosed || + reading.deviceId != deviceId || + state.phase == ControlModeEntryPhase.confirmed) + return; + emit( + ControlModeEntryState( + phase: state.phase, + reading: reading, + error: state.error, + errorKey: state.errorKey, + ), + ); + } + + Future confirm() async { + _trace('点击确定'); + if (isClosed || !state.canConfirm) { + _trace('点击被忽略', { + 'closed': isClosed, + 'reason': state.busy ? '请求发送中,防止重复下发' : '设备无效或页面已关闭', + }); + return; + } + if (state.reading?.matches(target) == true) { + _trace('模式已符合,跳过接口'); + _allowEntry('reported_match'); + return; + } + final generation = ++_generation; + emit( + ControlModeEntryState( + phase: ControlModeEntryPhase.submitting, + reading: state.reading, + ), + ); + _trace('开始下发', {'deviceIdType': deviceId.runtimeType.toString()}); + try { + final result = await changeMode(deviceId, target).timeout(timeout); + if (isClosed || generation != _generation) { + _trace('忽略迟到的接口结果', {'closed': isClosed}); + return; + } + result.fold((failure) { + _trace('接口失败', {'message': failure.message}); + emit( + ControlModeEntryState( + phase: ControlModeEntryPhase.failed, + reading: state.reading, + error: failure.message, + ), + ); + }, (_) => _allowEntry('http_success')); + } on TimeoutException { + _trace('请求超时', {'timeoutMs': timeout.inMilliseconds}); + if (!isClosed && generation == _generation) { + emit( + ControlModeEntryState( + phase: ControlModeEntryPhase.failed, + reading: state.reading, + errorKey: 'request_timeout', + ), + ); + } + } catch (error) { + _trace('提交异常', {'error': '$error'}); + if (!isClosed && generation == _generation) { + emit( + ControlModeEntryState( + phase: ControlModeEntryPhase.failed, + reading: state.reading, + error: '$error', + ), + ); + } + } + } + + void _allowEntry(String source) { + _trace('允许进入', { + 'source': source, + 'note': source == 'http_success' ? '接口成功,不等待模式回推' : '实时模式已符合', + }); + debugPrint( + '[控制模式] 允许进入 deviceId=$deviceId, target=${target.value}, source=$source, reportedMode=${state.reading?.rawMode}', + ); + emit( + ControlModeEntryState( + phase: ControlModeEntryPhase.confirmed, + reading: state.reading, + ), + ); + } + + @override + Future close() { + _generation++; + final cancelled = _subscription?.cancel(); + final closed = super.close(); + if (cancelled == null) return closed; + return Future.wait([cancelled, closed]).then((_) {}); + } +} diff --git a/lib/features/device_control_mode/presentation/widgets/control_mode_entry_gate.dart b/lib/features/device_control_mode/presentation/widgets/control_mode_entry_gate.dart new file mode 100644 index 00000000..c7b88e5e --- /dev/null +++ b/lib/features/device_control_mode/presentation/widgets/control_mode_entry_gate.dart @@ -0,0 +1,220 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../components/control_mode_text.dart'; +import '../../../../core/di/injection.dart'; +import '../../../../core/localization/app_localizations.dart'; +import '../../domain/entities/device_control_mode.dart'; +import '../../domain/usecases/device_control_mode_usecases.dart'; +import '../cubit/control_mode_entry_cubit.dart'; + +Future showControlModeEntryDialog( + BuildContext context, { + required String deviceId, + required ControlModeTarget target, + String? deviceName, + ControlModeEntryCubit? cubit, +}) async { + final entry = + cubit ?? + ControlModeEntryCubit( + deviceId: deviceId, + target: target, + watchMode: sl(), + changeMode: sl(), + ); + try { + entry.start(); + return await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => BlocProvider.value( + value: entry, + child: _ControlModeEntryDialog(deviceName: deviceName), + ), + ) ?? + false; + } finally { + unawaited(entry.close()); + } +} + +/// 当前模式已符合或切换接口成功后进入;不改动摇杆归零与停止协议。 +class ControlModeEntryGate extends StatefulWidget { + const ControlModeEntryGate({ + super.key, + required this.deviceId, + required this.target, + required this.child, + this.deviceName, + this.onReady, + }); + final String deviceId; + final String? deviceName; + final ControlModeTarget target; + final Widget child; + final VoidCallback? onReady; + @override + State createState() => _ControlModeEntryGateState(); +} + +class _ControlModeEntryGateState extends State { + bool _ready = false; + bool _cancelled = false; + bool _entering = false; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _enter()); + } + + Future _enter() async { + if (!mounted || _entering || _ready) return; + _entering = true; + setState(() => _cancelled = false); + try { + final accepted = await showControlModeEntryDialog( + context, + deviceId: widget.deviceId, + deviceName: widget.deviceName, + target: widget.target, + ); + if (!mounted) return; + if (accepted) { + setState(() => _ready = true); + widget.onReady?.call(); + } else { + final popped = await Navigator.of(context).maybePop(); + if (!popped && mounted) setState(() => _cancelled = true); + } + } finally { + _entering = false; + } + } + + @override + Widget build(BuildContext context) => Stack( + fit: StackFit.expand, + children: [ + IgnorePointer(ignoring: !_ready, child: widget.child), + if (_cancelled) + ColoredBox( + color: Colors.black54, + child: Center( + child: FilledButton( + onPressed: _enter, + child: Text( + AppLocalizations.of( + context, + ).translate('device_control_mode.retry'), + ), + ), + ), + ), + ], + ); +} + +class _ControlModeEntryDialog extends StatelessWidget { + const _ControlModeEntryDialog({this.deviceName}); + final String? deviceName; + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + String tr(String key) => l10n.translate('device_control_mode.$key'); + final cubit = context.read(); + return BlocConsumer( + listenWhen: (previous, current) => + previous.phase != current.phase || + previous.reading?.rawMode != current.reading?.rawMode || + previous.canConfirm != current.canConfirm, + listener: (context, state) { + debugPrint( + '[控制模式切换][弹窗状态] deviceId=${cubit.deviceId}, currentRawMode=${state.reading?.rawMode}, targetMode=${cubit.target.value}, phase=${state.phase.name}, confirmEnabled=${state.canConfirm}', + ); + if (state.phase == ControlModeEntryPhase.confirmed) { + Navigator.of(context).pop(true); + } + }, + builder: (context, state) { + final matches = state.reading?.matches(cubit.target) == true; + final targetText = controlModeDisplayText( + l10n, + '${cubit.target.value}', + ); + return PopScope( + canPop: !state.busy, + child: AlertDialog( + title: Text(tr('title')), + content: SizedBox( + width: 380, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + deviceName?.trim().isNotEmpty == true + ? '$deviceName · ${cubit.deviceId}' + : cubit.deviceId, + ), + const SizedBox(height: 12), + Text( + '${tr('current')}:${controlModeDisplayText(l10n, state.reading?.rawMode)}', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text('${tr('target')}:$targetText'), + const SizedBox(height: 12), + Text( + state.busy + ? tr('submitting') + : matches + ? tr('matched') + : state.reading?.hasReportedMode == true + ? tr('mismatch').replaceAll('%s', targetText) + : tr('unknown_mode').replaceAll('%s', targetText), + ), + if (state.error != null || state.errorKey != null) ...[ + const SizedBox(height: 10), + Text( + state.errorKey != null + ? tr(state.errorKey!) + : state.error!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + if (state.busy) ...[ + const SizedBox(height: 12), + const LinearProgressIndicator(), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: state.busy + ? null + : () => Navigator.of(context).pop(false), + child: Text(l10n.translate('common.back')), + ), + if (state.phase == ControlModeEntryPhase.failed && + state.errorKey != 'invalid_device') + TextButton(onPressed: cubit.start, child: Text(tr('retry'))), + FilledButton( + onPressed: state.canConfirm ? cubit.confirm : null, + child: Text(l10n.translate('common.ok')), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/features/home/presentation/widgets/map/app_base_map_layer.dart b/lib/features/home/presentation/widgets/map/app_base_map_layer.dart new file mode 100644 index 00000000..66940d74 --- /dev/null +++ b/lib/features/home/presentation/widgets/map/app_base_map_layer.dart @@ -0,0 +1,104 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; + +import 'base_map_config.dart'; +import 'resilient_tile_provider.dart'; + +const String _kUserAgent = 'com.maibu.maibu_satabot_v2'; + +/// App 统一底图:数据源跟随 App 语言 +/// - 中文 → ESRI 卫星影像(境内稳定,WGS84 直用) +/// - 其他语言 → Google 混合图(卫星 + 英文地名) +/// - Google 不可达(境内网络)→ 自动回退 ESRI,不出现空白地图 +class AppBaseMapLayer extends StatelessWidget { + const AppBaseMapLayer({super.key, this.maxZoom = 21, this.forceConfig}); + + final double maxZoom; + + /// 强制指定源(离线包/调试用),传了就忽略语言 + final BaseMapConfig? forceConfig; + + /// 已打印过的「语言 + 实际底图源」组合,避免每帧刷屏 + static String? _loggedSignature; + + @override + Widget build(BuildContext context) { + final locale = Localizations.localeOf(context); + final cfg = forceConfig ?? BaseMapConfig.of(locale); + if (cfg.provider == BaseMapProvider.esri) { + _logSelection(locale, cfg, fromFallback: false); + return _build(cfg); + } + + return FutureBuilder( + future: GoogleTileAvailability.reachable(), + builder: (context, snapshot) { + final googleOk = snapshot.data == true; + _logSelection( + locale, + googleOk ? cfg : BaseMapConfig.esri, + fromFallback: !googleOk, + ); + // 探测中 / 不可达 → 先用 ESRI 顶住 + if (!googleOk) return _build(BaseMapConfig.esri); + return _build(cfg); + }, + ); + } + + void _logSelection( + Locale locale, + BaseMapConfig cfg, { + required bool fromFallback, + }) { + final sig = '${locale.languageCode}/${cfg.provider.name}/$fromFallback'; + if (_loggedSignature == sig) return; + _loggedSignature = sig; + debugPrint( + '🗺️ [BaseMap] 语言=${locale.languageCode} → 底图=${cfg.provider.name}' + '${fromFallback ? '(Google 不可达,已回退)' : ''}', + ); + } + + Widget _build(BaseMapConfig cfg) { + // key 随源变化:语言切换时强制重建瓦片层,避免复用旧源瓦片 + final key = ValueKey('base_map_${cfg.provider.name}'); + if (cfg.provider == BaseMapProvider.google) { + return TileLayer( + key: key, + tileProvider: GoogleTileProvider(lyr: cfg.googleLyr!), + userAgentPackageName: _kUserAgent, + minZoom: 0, + maxNativeZoom: cfg.maxNativeZoom, + maxZoom: maxZoom, + ); + } + return TileLayer( + key: key, + urlTemplate: cfg.urlTemplate, + userAgentPackageName: _kUserAgent, + minZoom: 0, + maxNativeZoom: cfg.maxNativeZoom, + maxZoom: maxZoom, + ); + } +} + +/// Google 栅格瓦片:与 flutter_map 的 XYZ 编号一致,无需坐标转换 +class GoogleTileProvider extends TileProvider { + GoogleTileProvider({required this.lyr, this.hl = 'en'}); + + final String lyr; + final String hl; + + @override + ImageProvider getImage( + TileCoordinates coordinates, + TileLayer options, + ) { + final sub = coordinates.x % 4; + final url = 'https://mt$sub.google.com/vt/lyrs=$lyr&hl=$hl' + '&x=${coordinates.x}&y=${coordinates.y}&z=${coordinates.z}'; + return ResilientNetworkImage(url); + } +} diff --git a/lib/features/home/presentation/widgets/map/base_map_config.dart b/lib/features/home/presentation/widgets/map/base_map_config.dart new file mode 100644 index 00000000..92406710 --- /dev/null +++ b/lib/features/home/presentation/widgets/map/base_map_config.dart @@ -0,0 +1,102 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:http/http.dart' as http; + +/// 底图来源:中文用户用 ESRI,非中文用户用 Google +enum BaseMapProvider { esri, google } + +class BaseMapConfig { + const BaseMapConfig({ + required this.provider, + required this.maxNativeZoom, + this.urlTemplate, + this.googleLyr, + }); + + final BaseMapProvider provider; + + /// 原生最大层级:超出后由 flutter_map 用低级别瓦片拉伸,避免整片空白 + final int maxNativeZoom; + + /// ESRI XYZ 模板(WGS84,与设备 GPS 同一坐标系) + final String? urlTemplate; + + /// Google 瓦片图层:s=纯卫星 / h=地名 / y=卫星+地名 + final String? googleLyr; + + static const BaseMapConfig esri = BaseMapConfig( + provider: BaseMapProvider.esri, + maxNativeZoom: 19, + urlTemplate: + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + ); + + /// lyrs=y 让境外用户一张图同时拿到卫星影像 + 英文地名(hl=en) + static const BaseMapConfig google = BaseMapConfig( + provider: BaseMapProvider.google, + maxNativeZoom: 20, + googleLyr: 'y', + ); + + /// 规则:中文 → ESRI;其余语言 → Google + static BaseMapConfig of(Locale locale) => + locale.languageCode == 'zh' ? esri : google; +} + +/// Google 瓦片可达性探测:失败后允许定期重试,避免一次探测失败就永久降级 +class GoogleTileAvailability { + /// 失败后的重试冷却时长:开/关 VPN、切换网络后无需重启 App + static const Duration retryAfter = Duration(seconds: 30); + + static Future? _cached; + static bool? _lastResult; + static DateTime? _lastFailAt; + + static Future reachable() { + final cached = _cached; + if (cached != null) { + final failedAt = _lastFailAt; + final shouldRetry = _lastResult == false && + failedAt != null && + DateTime.now().difference(failedAt) >= retryAfter; + // 继续复用同一个 Future 对象,避免 FutureBuilder 因 future 变化而反复重建 + if (!shouldRetry) return cached; + } + final future = _probe(); + _cached = future; + return future; + } + + /// 手动重试(例如用户点「刷新地图」、App 从后台恢复) + static void reset() { + _cached = null; + _lastResult = null; + _lastFailAt = null; + } + + static Future _probe() async { + final sw = Stopwatch()..start(); + bool ok; + try { + final res = await http + .get(Uri.parse('https://mt0.google.com/vt/lyrs=s&hl=en&x=1&y=0&z=1')) + .timeout(const Duration(seconds: 3)); + ok = res.statusCode == 200 && res.bodyBytes.isNotEmpty; + debugPrint( + '🗺️ [BaseMap] Google 探测: http=${res.statusCode} ' + 'bytes=${res.bodyBytes.length} ${sw.elapsedMilliseconds}ms ' + '→ ${ok ? '可用,英文底图用 Google' : '不可用,回退 ESRI'}', + ); + } catch (e) { + ok = false; + debugPrint( + '🗺️ [BaseMap] Google 探测异常: ${e.runtimeType} ' + '${sw.elapsedMilliseconds}ms → 回退 ESRI', + ); + } + _lastResult = ok; + _lastFailAt = ok ? null : DateTime.now(); + return ok; + } +} diff --git a/lib/features/v2/device_list/data/datasources/bind_device_datasource.dart b/lib/features/v2/device_list/data/datasources/bind_device_datasource.dart index 1e77c893..5deabf0a 100644 --- a/lib/features/v2/device_list/data/datasources/bind_device_datasource.dart +++ b/lib/features/v2/device_list/data/datasources/bind_device_datasource.dart @@ -11,4 +11,13 @@ abstract class BindDeviceDatasource { int? siteId, int? userId, }); + + /// 批量解绑设备(后端 /iot/device/unbind)。 + /// + /// [type] 见 UnbindScope:1 清除负责人 / 2 清除场站 / 3 清除组织。 + /// 后端为批量接口,一次可解绑多台设备且必须指定清除层级。 + Future unbindDevice({ + required List deviceIds, + required int type, + }); } diff --git a/lib/features/v2/device_list/data/datasources/impl/bind_device_datasource_impl.dart b/lib/features/v2/device_list/data/datasources/impl/bind_device_datasource_impl.dart index 5762796a..b1195d12 100644 --- a/lib/features/v2/device_list/data/datasources/impl/bind_device_datasource_impl.dart +++ b/lib/features/v2/device_list/data/datasources/impl/bind_device_datasource_impl.dart @@ -1,4 +1,7 @@ +import 'dart:convert'; + import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart'; import 'package:maibu_satabot_v2/core/di/injection.dart'; import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; @@ -135,24 +138,151 @@ class BindDeviceDatasourceImpl implements BindDeviceDatasource { if (siteId != null) body['siteId'] = siteId; if (userId != null) body['userId'] = userId; - final response = await _dio.post( - HttpApiConsts.bindDevice, - data: body, - options: Options( - headers: { - 'Authorization': token != null ? 'Bearer $token' : '', - 'Content-Type': 'application/json', - }, - ), + // 绑定接口完整请求日志:body 用分段打印,避免 debugPrint 单条过长被截断 + debugPrint('>>> [BindDevice] ===== 开始绑定 ====='); + debugPrint('>>> [BindDevice] url=${HttpApiConsts.bindDevice}'); + debugPrint( + '>>> [BindDevice] 入参 deviceIds=$deviceIds orgId=$orgId ' + 'siteId=$siteId userId=$userId', ); + debugPrint( + '>>> [BindDevice] token=${token == null || token.isEmpty ? '空' : '已携带'}', + ); + _printChunked('>>> [BindDevice] requestBody: ', _safeEncode(body)); - if (response.statusCode != 200) { - throw Exception('网络请求失败: ${response.statusCode}'); + try { + final response = await _dio.post( + HttpApiConsts.bindDevice, + data: body, + options: Options( + headers: { + 'Authorization': token != null ? 'Bearer $token' : '', + 'Content-Type': 'application/json', + }, + ), + ); + + debugPrint('>>> [BindDevice] response statusCode=${response.statusCode}'); + _printChunked( + '>>> [BindDevice] responseBody: ', + _safeEncode(response.data), + ); + + if (response.statusCode != 200) { + throw Exception('网络请求失败: ${response.statusCode}'); + } + + final data = response.data; + if (data['code'] != 200) { + debugPrint( + '>>> [BindDevice] ❌ 业务失败 code=${data['code']} msg=${data['msg']}', + ); + throw Exception(data['msg'] ?? '绑定设备失败'); + } + debugPrint('>>> [BindDevice] ✅ 绑定成功'); + } on DioException catch (e) { + debugPrint( + '>>> [BindDevice] ❌ DioException type=${e.type} ' + 'status=${e.response?.statusCode} message=${e.message}', + ); + if (e.response?.data != null) { + _printChunked( + '>>> [BindDevice] errorResponseBody: ', + _safeEncode(e.response?.data), + ); + } + rethrow; } + } - final data = response.data; - if (data['code'] != 200) { - throw Exception(data['msg'] ?? '绑定设备失败'); + @override + Future unbindDevice({ + required List deviceIds, + required int type, + }) async { + final token = await _getToken(); + final Map body = {'deviceIds': deviceIds, 'type': type}; + + // 解绑为不可逆数据操作,请求前后均打印完整日志便于核对 deviceIds 与 type + debugPrint('>>> [UnbindDevice] ===== 开始解绑 ====='); + debugPrint('>>> [UnbindDevice] url=${HttpApiConsts.unbindDeviceV2}'); + debugPrint( + '>>> [UnbindDevice] 入参 deviceIds=$deviceIds type=$type ' + '(1清除负责人 2清除场站 3清除组织)', + ); + debugPrint( + '>>> [UnbindDevice] token=${token == null || token.isEmpty ? '空' : '已携带'}', + ); + _printChunked('>>> [UnbindDevice] requestBody: ', _safeEncode(body)); + + try { + final response = await _dio.post( + HttpApiConsts.unbindDeviceV2, + data: body, + options: Options( + headers: { + 'Authorization': token != null ? 'Bearer $token' : '', + 'Content-Type': 'application/json', + }, + ), + ); + + debugPrint( + '>>> [UnbindDevice] response statusCode=${response.statusCode}', + ); + _printChunked( + '>>> [UnbindDevice] responseBody: ', + _safeEncode(response.data), + ); + + if (response.statusCode != 200) { + throw Exception('网络请求失败: ${response.statusCode}'); + } + + // 后端返回格式未固定,只在明确给出业务码时才按失败处理: + // {code:200, data:true} 与 {code:200, msg:"..."} 两种都会当作成功 + final data = response.data; + if (data is Map && data['code'] != null && data['code'] != 200) { + debugPrint( + '>>> [UnbindDevice] ❌ 业务失败 code=${data['code']} msg=${data['msg']}', + ); + throw Exception(data['msg'] ?? '解绑设备失败'); + } + debugPrint('>>> [UnbindDevice] ✅ 解绑成功'); + } on DioException catch (e) { + debugPrint( + '>>> [UnbindDevice] ❌ DioException type=${e.type} ' + 'status=${e.response?.statusCode} message=${e.message}', + ); + if (e.response?.data != null) { + _printChunked( + '>>> [UnbindDevice] errorResponseBody: ', + _safeEncode(e.response?.data), + ); + } + rethrow; + } + } + + /// 长文本分段打印,避免 debugPrint 单条过长被截断 + void _printChunked(String prefix, String content) { + const chunk = 700; + var head = prefix; + for (int start = 0; start < content.length; start += chunk) { + final end = + start + chunk < content.length ? start + chunk : content.length; + debugPrint('$head${content.substring(start, end)}'); + head = ' ↳ '; + } + } + + /// JSON 编码失败时退回 toString,保证日志本身不抛异常 + String _safeEncode(dynamic data) { + if (data == null) return 'null'; + try { + return jsonEncode(data); + } catch (_) { + return data.toString(); } } } diff --git a/lib/features/v2/device_list/data/models/device_data_model.dart b/lib/features/v2/device_list/data/models/device_data_model.dart index 80972bc7..4e8904cb 100644 --- a/lib/features/v2/device_list/data/models/device_data_model.dart +++ b/lib/features/v2/device_list/data/models/device_data_model.dart @@ -14,6 +14,12 @@ class DeviceDataModel { final double? radiation; final bool isRobot; + /// 产品型号(如「割草机产品MC700」) + final String productName; + + /// 机型编码(如 MC700PRO) + final String? productModel; + const DeviceDataModel({ required this.deviceId, required this.name, @@ -26,6 +32,8 @@ class DeviceDataModel { this.voltage, this.radiation, this.isRobot = false, + this.productName = '', + this.productModel, }); /// 从 JSON 创建数据模型 @@ -41,6 +49,8 @@ class DeviceDataModel { current: json['current']?.toDouble(), voltage: json['voltage']?.toDouble(), radiation: json['radiation']?.toDouble(), + productName: (json['productName'] as String?) ?? '', + productModel: json['productModel'] as String?, ); } @@ -57,6 +67,8 @@ class DeviceDataModel { 'current': current, 'voltage': voltage, 'radiation': radiation, + 'productName': productName, + 'productModel': productModel, }; } @@ -74,6 +86,8 @@ class DeviceDataModel { voltage: voltage, radiation: radiation, isRobot: isRobot, + productName: productName, + productModel: productModel, ); } } diff --git a/lib/features/v2/device_list/data/repositories/bind_device_repository_impl.dart b/lib/features/v2/device_list/data/repositories/bind_device_repository_impl.dart index 9d695c1f..f833b8dd 100644 --- a/lib/features/v2/device_list/data/repositories/bind_device_repository_impl.dart +++ b/lib/features/v2/device_list/data/repositories/bind_device_repository_impl.dart @@ -77,4 +77,20 @@ class BindDeviceRepositoryImpl implements BindDeviceRepository { return Left(Failure(e.toString())); } } + + @override + Future> unbindDevice({ + required List deviceIds, + required UnbindScope scope, + }) async { + try { + await _datasource.unbindDevice( + deviceIds: deviceIds, + type: scope.value, + ); + return const Right(null); + } catch (e) { + return Left(Failure(e.toString())); + } + } } diff --git a/lib/features/v2/device_list/domain/entities/bind_device_entities.dart b/lib/features/v2/device_list/domain/entities/bind_device_entities.dart index d1e2f154..5b7ba031 100644 --- a/lib/features/v2/device_list/domain/entities/bind_device_entities.dart +++ b/lib/features/v2/device_list/domain/entities/bind_device_entities.dart @@ -1,3 +1,39 @@ +/// 设备归属操作类型(设备归属与安全页的两个高风险动作)。 +/// +/// 两者共用同一套「扫码核验 + 组织/场站/负责人三级选择」流程, +/// 差别只在提交: +/// - [transfer] 转交设备 = 将设备挂到选定层级(POST /iot/device/bind) +/// - [unbind] 解绑设备 = 将设备从选定层级摘除(POST /iot/device/unbind) +enum OwnershipAction { + transfer, + unbind; +} + +/// 解绑清除范围(后端解绑接口的 type 字段) +/// +/// 归属层级为“组织 → 场站 → 负责人”,解绑需要指明从哪一级摘除: +/// 1 = 清除负责人,2 = 清除场站,3 = 清除组织。 +/// 值越大解绑得越彻底,不可越级(例如设备只挂在场站下时不要传 org)。 +enum UnbindScope { + user(1, '清除负责人'), + site(2, '清除场站'), + org(3, '清除组织'); + + final int value; + final String label; + + const UnbindScope(this.value, this.label); + + /// 后端回传的 type 反查枚举;未知值返回 null + static UnbindScope? fromValue(int? value) { + if (value == null) return null; + for (final scope in values) { + if (scope.value == value) return scope; + } + return null; + } +} + class OrgEntity { final int id; final String name; diff --git a/lib/features/v2/device_list/domain/entities/device_entity.dart b/lib/features/v2/device_list/domain/entities/device_entity.dart index b1542206..c45e9450 100644 --- a/lib/features/v2/device_list/domain/entities/device_entity.dart +++ b/lib/features/v2/device_list/domain/entities/device_entity.dart @@ -12,6 +12,12 @@ class DeviceEntity { final double? radiation; final bool isRobot; + /// 产品型号(如「割草机产品MC700」) + final String productName; + + /// 机型编码(如 MC700PRO),列表型号展示以此为准 + final String? productModel; + const DeviceEntity({ required this.deviceId, required this.name, @@ -24,5 +30,7 @@ class DeviceEntity { this.voltage, this.radiation, this.isRobot = false, + this.productName = '', + this.productModel, }); } diff --git a/lib/features/v2/device_list/domain/entities/device_model_info.dart b/lib/features/v2/device_list/domain/entities/device_model_info.dart new file mode 100644 index 00000000..2dce4173 --- /dev/null +++ b/lib/features/v2/device_list/domain/entities/device_model_info.dart @@ -0,0 +1,108 @@ +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; + +/// 设备机型:把后端 productModel 解析成「系列 + 版本」。 +/// +/// 字段口径:getSiteList 返回的 productModel 形如 +/// `MC700` / `MC700STD` / `MC700PLUS` / `MC700PRO`, +/// 也可能带长后缀:`MC700PRO-CN-JS-1751525588120-0000000A-9527`。 +/// +/// 该解析结果同时供列表卡展示与详情页功能开放使用,禁止再用伪随机/写死兜底。 +/// `label` 仅供内部解析/搜索匹配,展示请用 `labelOf` 取当前语言文案。 +enum DeviceEdition { + std('标准版', 'std'), + plus('智能版', 'plus'), + pro('旗舰版', 'pro'), + air('轻享版', 'air'), + unknown('未知版本', 'unknown'); + + const DeviceEdition(this.label, this.code); + + /// 中文短名(仅解析/搜索匹配用,不再直接展示) + final String label; + + /// 语言包代码:device_list_v2.edition_ + final String code; +} + +class DeviceModelInfo { + /// 后端原始值(如 MC700PRO) + final String raw; + + /// 型号系列(MC700 / MCAIR / RCHWD …) + final String series; + + /// 设备版本(std / plus / pro / air) + final DeviceEdition edition; + + const DeviceModelInfo({ + required this.raw, + required this.series, + required this.edition, + }); + + static const empty = DeviceModelInfo( + raw: '', + series: '', + edition: DeviceEdition.unknown, + ); + + /// 是否解析出版本 + bool get resolved => edition != DeviceEdition.unknown; + + /// 展示文案:`MC700 · PRO 旗舰版`;只有系列时 `MC700`; + /// productModel 为 null / 空串时统一显示未知。 + /// + /// [deviceType] 是后端另一个字段(如 MC700 / 割草机器人),要排在 + /// 版本号(PRO / PLUS / STD)之前,并且优先于从 productModel 里解析出的系列: + /// 后端明确给了机型就用它,没给才回退到 productModel 的解析结果。 + String labelOf(AppLocalizations l10n, {String? deviceType}) { + final typeText = (deviceType ?? '').trim(); + // deviceType 若是纯数字编码(1/2/3)拿来展示没意义,回退到 productModel 解析结果 + final usableType = RegExp(r'^\d+$').hasMatch(typeText) ? '' : typeText; + final head = usableType.isNotEmpty ? usableType : series; + + if (raw.isEmpty && head.isEmpty) { + return l10n.translate('device_list_v2.unknown'); + } + if (head.isEmpty && edition == DeviceEdition.unknown) return raw; + if (edition == DeviceEdition.unknown) return head; + return '$head · ${editionLabelOf(l10n)}'; + } + + /// 版本短文案:`PRO 旗舰版`;未知时为空 + String editionLabelOf(AppLocalizations l10n) => edition == DeviceEdition.unknown + ? '' + : '${edition.name.toUpperCase()} ${l10n.translate('device_list_v2.edition_${edition.code}')}'; + + /// 搜索用关键字:原始值 + 系列 + 版本中英文 + String get searchText => + '$raw $series ${edition.name.toUpperCase()} ${edition.label} ${edition.name}' + .toLowerCase(); + + /// 解析:兼容 MC700PRO / MC700-PRO / MC700 PRO / 长后缀 / 中文「旗舰版」 + static DeviceModelInfo parse(String? value) { + final raw = (value ?? '').trim(); + if (raw.isEmpty) return empty; + + final compact = raw.toUpperCase().replaceAll(RegExp(r'[\s\-_·]+'), ''); + final seriesMatch = RegExp( + r'(MCAIR|RCH[A-Z]*|RCE[A-Z]*|MC\d{3})', + ).firstMatch(compact); + final series = seriesMatch?.group(1) ?? ''; + + final DeviceEdition edition; + if (compact.contains('PRO') || raw.contains('旗舰')) { + edition = DeviceEdition.pro; + } else if (compact.contains('PLUS') || raw.contains('智能')) { + edition = DeviceEdition.plus; + } else if (compact.contains('AIR') || raw.contains('轻享')) { + edition = DeviceEdition.air; + } else if (compact.contains('STD') || raw.contains('标准')) { + edition = DeviceEdition.std; + } else { + edition = DeviceEdition.unknown; + } + + return DeviceModelInfo(raw: raw, series: series, edition: edition); + } +} diff --git a/lib/features/v2/device_list/domain/repositories/bind_device_repository.dart b/lib/features/v2/device_list/domain/repositories/bind_device_repository.dart index 49d0fb33..a1f83ed8 100644 --- a/lib/features/v2/device_list/domain/repositories/bind_device_repository.dart +++ b/lib/features/v2/device_list/domain/repositories/bind_device_repository.dart @@ -16,4 +16,13 @@ abstract class BindDeviceRepository { int? siteId, int? userId, }); + + /// 批量解绑设备。 + /// + /// [scope] 指定从归属链的哪一级摘除(负责人/场站/组织), + /// 由 UseCase 保证设备当前挂载层级与 scope 匹配后再下发。 + Future> unbindDevice({ + required List deviceIds, + required UnbindScope scope, + }); } diff --git a/lib/features/v2/device_list/domain/usecases/bind_device_usecases.dart b/lib/features/v2/device_list/domain/usecases/bind_device_usecases.dart index b07c98f3..4da900d2 100644 --- a/lib/features/v2/device_list/domain/usecases/bind_device_usecases.dart +++ b/lib/features/v2/device_list/domain/usecases/bind_device_usecases.dart @@ -48,3 +48,29 @@ class IsDeviceAtSiteUseCase { required int siteId, }) => _repository.isDeviceAtSite(deviceId: deviceId, siteId: siteId); } + +/// 批量解绑设备用例(后端 /iot/device/unbind) +/// +/// 归属层级:组织 → 场站 → 负责人,[scope] 需与设备当前挂载的层级一致, +/// 传错会把设备从错误的层级摘除且不可逆,所以这里只做入参清理,不猜测 scope。 +class UnbindDeviceV2UseCase { + final BindDeviceRepository _repository; + UnbindDeviceV2UseCase(this._repository); + + Future> call({ + required List deviceIds, + required UnbindScope scope, + }) { + // 去空串、去重(保留调用方顺序):空列表发给后端可能被当成“整个层级清空” + final ids = []; + for (final raw in deviceIds) { + final id = raw.trim(); + if (id.isEmpty || ids.contains(id)) continue; + ids.add(id); + } + if (ids.isEmpty) { + return Future.value(Left(Failure('请选择需要解绑的设备'))); + } + return _repository.unbindDevice(deviceIds: ids, scope: scope); + } +} diff --git a/lib/features/v2/device_list/presentation/cubit/bind_device_cubit.dart b/lib/features/v2/device_list/presentation/cubit/bind_device_cubit.dart index b20e25b4..24545ea8 100644 --- a/lib/features/v2/device_list/presentation/cubit/bind_device_cubit.dart +++ b/lib/features/v2/device_list/presentation/cubit/bind_device_cubit.dart @@ -304,12 +304,20 @@ class BindDeviceCubit extends Cubit { } Future submitBind(String deviceId) async { + debugPrint( + '>>> [BindDevice] submitBind 入参 deviceId=$deviceId roleKey=$roleKey ' + 'orgId=${state.selectedOrgId} siteId=${state.selectedSiteId} ' + 'userId=${state.selectedUserId}', + ); + if (state.selectedOrgId == null) { + debugPrint('>>> [BindDevice] 前置拦截:selectedOrgId 为空,未发请求'); emit(state.copyWith(errorMessage: '请选择所属组织')); return false; } if (roleKey == 'user' && state.selectedUserId == null) { + debugPrint('>>> [BindDevice] 前置拦截:user 角色未选负责人,未发请求'); emit(state.copyWith(errorMessage: '请选择负责人')); return false; } @@ -323,6 +331,10 @@ class BindDeviceCubit extends Cubit { ); final isAtSite = existResult.fold((failure) => false, (exists) => exists); + debugPrint( + '>>> [BindDevice] 场站查重 deviceId=$deviceId siteId=${state.selectedSiteId} ' + 'isAtSite=$isAtSite', + ); if (isAtSite) { emit( @@ -332,6 +344,7 @@ class BindDeviceCubit extends Cubit { } } + debugPrint('>>> [BindDevice] 前置校验通过,开始调用绑定用例'); final result = await _bindDeviceUseCase( deviceIds: [deviceId], orgId: state.selectedOrgId!, diff --git a/lib/features/v2/device_list/presentation/cubit/device_ownership_action_cubit.dart b/lib/features/v2/device_list/presentation/cubit/device_ownership_action_cubit.dart new file mode 100644 index 00000000..484b8677 --- /dev/null +++ b/lib/features/v2/device_list/presentation/cubit/device_ownership_action_cubit.dart @@ -0,0 +1,497 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; +import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart'; + +import '../../domain/entities/bind_device_entities.dart'; +import '../../domain/usecases/bind_device_usecases.dart'; +import 'device_ownership_action_state.dart'; + +/// 设备归属操作:转交(= 绑定接口)与解绑(= 解绑接口)共用一套流程。 +/// +/// 两条链路都是「扫码核验 → 按角色选择组织/场站/负责人 → 下发」, +/// 三级选择与角色规则逐行对齐绑定流程([BindDeviceCubit]), +/// 差别只在提交:转交走 [BindDeviceV2UseCase],解绑走 [UnbindDeviceV2UseCase]。 +/// +/// 之所以不直接复用 [BindDeviceCubit],是因为绑定页还带着"扫码即绑定"的 +/// 独立页面流程,这里只借用它的层级加载与角色口径,不改动它本身。 +class DeviceOwnershipActionCubit extends Cubit { + final GetOrgListUseCase _getOrgListUseCase; + final GetSitesByOrgUseCase _getSitesByOrgUseCase; + final GetUsersBySiteUseCase _getUsersBySiteUseCase; + final BindDeviceV2UseCase _bindDeviceUseCase; + final UnbindDeviceV2UseCase _unbindDeviceUseCase; + final AppUserCubit _appUserCubit; + + DeviceOwnershipActionCubit( + this._getOrgListUseCase, + this._getSitesByOrgUseCase, + this._getUsersBySiteUseCase, + this._bindDeviceUseCase, + this._unbindDeviceUseCase, + this._appUserCubit, + ) : super(const DeviceOwnershipActionState()); + + UserEntity? get _currentUser => _appUserCubit.state.user; + String get roleKey => state.roleKey; + + /// 角色可编辑范围(与绑定页保持一致) + bool get isOrgEnabled => roleKey == 'admin'; + bool get isSiteEnabled => roleKey == 'admin' || roleKey == 'manager'; + + /// 负责人: + /// - 普通用户 / 个人角色只能选自己,所以 UI 锁定(选中值默认就是自己); + /// - 场站负责人及以上可以选场站内的人。 + bool get isUserEnabled => + roleKey == 'admin' || roleKey == 'manager' || roleKey == 'siteManager'; + + /// 当前角色能触达的最深一级,口径与可编辑范围一一对应: + /// admin 可动到组织(含场站/负责人),manager 只到场站, + /// siteManager 与普通用户只到负责人(他们能动的只有人这一层)。 + UnbindScope get roleMaxScope { + if (isOrgEnabled) return UnbindScope.org; + if (isSiteEnabled) return UnbindScope.site; + return UnbindScope.user; + } + + /// 解绑实际下发的清除范围:所选层级不得越过角色上限。 + /// UnbindScope 的值越大解绑得越彻底(1 人 < 2 站 < 3 组织),所以取较小者。 + UnbindScope get effectiveScope { + final picked = state.scopePreview; + final max = roleMaxScope; + return picked.value <= max.value ? picked : max; + } + + /// 能否用「不指定」把负责人清空(清空后由场站决定层级) + bool get canClearUser => roleMaxScope.value >= UnbindScope.site.value; + + /// 能否用「不指定」把场站清空(清空后由组织决定层级) + bool get canClearSite => roleMaxScope.value >= UnbindScope.org.value; + + // ── 去重 / 兜底辅助(与绑定页同口径)── + List _uniqueOrgs(List list) { + final seen = {}; + return list.where((e) => seen.add(e.id)).toList(); + } + + List _uniqueSites(List list) { + final seen = {}; + return list.where((e) => seen.add(e.id)).toList(); + } + + List _uniqueUsers(List list) { + final seen = {}; + return list.where((e) => seen.add(e.id)).toList(); + } + + int? _validSelected(int? id, List list) { + if (id == null) return null; + final ids = list.map((e) => e.id as int).toSet(); + return ids.contains(id) ? id : null; + } + + List _ensureOrgInList(List orgs, int orgId) { + if (orgs.any((e) => e.id == orgId)) return orgs; + return [...orgs, OrgEntity(id: orgId, name: '组织 #$orgId')]; + } + + List _ensureSiteInList(List sites, int? siteId) { + if (siteId == null) return sites; + if (sites.any((e) => e.id == siteId)) return sites; + return [...sites, SiteEntity(id: siteId, name: '场站 #$siteId')]; + } + + List _ensureUserInList( + List users, + UserEntity user, + ) { + final uid = int.tryParse(user.userId) ?? 0; + if (users.any((e) => e.id == uid)) return users; + return [...users, UserSimpleEntity(id: uid, name: user.nickname)]; + } + + /// 按角色初始化。 + /// + /// - 转交(绑定):需要加载组织/场站/负责人三级列表; + /// - 解绑:不需要任何列表 —— 只选「个人 / 场站 / 组织」一级, + /// 而这一级由角色决定上限,所以直接定好默认值就能用。 + Future init({required OwnershipAction action}) async { + final user = _currentUser; + if (user == null) { + debugPrint( + '>>> [OwnershipAction] ERROR: _currentUser is null,无法加载归属层级', + ); + return; + } + + debugPrint( + '>>> [OwnershipAction] action=$action roleKey=${user.roleKey}, ' + 'orgId=${user.orgId}, siteId=${user.siteId}, userId=${user.userId}', + ); + + emit( + state.copyWith( + action: action, + isLoading: true, + roleKey: user.roleKey ?? 'user', + // 解绑默认选最轻的一级(个人),不预设更彻底的清除 + selectedScope: UnbindScope.user, + ), + ); + + // 解绑不依赖三级列表,直接结束 loading + if (action == OwnershipAction.unbind) { + debugPrint( + '>>> [OwnershipAction] 解绑模式:roleMax=${roleMaxScope.value}' + '(${roleMaxScope.label}),跳过组织/场站/人员加载', + ); + emit(state.copyWith(isLoading: false)); + return; + } + + switch (roleKey) { + case 'admin': + await _loadAllOrgs(); + break; + case 'manager': + await _loadForManager(user); + break; + case 'siteManager': + await _loadForSiteManager(user); + break; + default: + await _loadForUser(user); + } + + emit(state.copyWith(isLoading: false)); + } + + /// 解绑:直接选清除层级(个人 / 场站 / 组织)。 + /// 超出当前角色上限的层级不响应,UI 也会置灰。 + void onScopeSelected(UnbindScope scope) { + if (scope.value > roleMaxScope.value) { + debugPrint( + '>>> [OwnershipAction] 拦截越权清除:picked=${scope.value} ' + 'roleMax=${roleMaxScope.value}', + ); + return; + } + emit(state.copyWith(selectedScope: scope, clearError: true)); + debugPrint( + '>>> [OwnershipAction] 清除层级选中 scope=${scope.value}(${scope.label})', + ); + } + + Future _loadAllOrgs() async { + final result = await _getOrgListUseCase(); + result.fold( + (failure) => emit(state.copyWith(errorMessage: failure.message)), + (orgs) { + final unique = _uniqueOrgs(orgs); + emit( + state.copyWith( + orgList: unique, + selectedOrgId: _validSelected(state.selectedOrgId, unique), + ), + ); + }, + ); + } + + Future _loadForManager(UserEntity user) async { + final result = await _getOrgListUseCase(); + result.fold( + (failure) => emit(state.copyWith(errorMessage: failure.message)), + (orgs) async { + final unique = _ensureOrgInList(_uniqueOrgs(orgs), user.orgId); + emit(state.copyWith(orgList: unique, selectedOrgId: user.orgId)); + await _loadSitesByOrgForManager(user.orgId, user.siteId); + }, + ); + } + + Future _loadForSiteManager(UserEntity user) async { + final result = await _getOrgListUseCase(); + result.fold( + (failure) => emit(state.copyWith(errorMessage: failure.message)), + (orgs) async { + final unique = _ensureOrgInList(_uniqueOrgs(orgs), user.orgId); + emit(state.copyWith(orgList: unique, selectedOrgId: user.orgId)); + await _loadSitesAndUsers(user); + }, + ); + } + + Future _loadForUser(UserEntity user) async { + final result = await _getOrgListUseCase(); + result.fold( + (failure) => emit(state.copyWith(errorMessage: failure.message)), + (orgs) async { + final unique = _ensureOrgInList(_uniqueOrgs(orgs), user.orgId); + emit(state.copyWith(orgList: unique, selectedOrgId: user.orgId)); + await _loadSitesAndUsers(user); + }, + ); + } + + Future _loadSitesByOrgForManager(int orgId, int? siteId) async { + final result = await _getSitesByOrgUseCase(orgId); + result.fold( + (failure) => emit(state.copyWith(errorMessage: failure.message)), + (sites) { + final unique = _ensureSiteInList(_uniqueSites(sites), siteId); + emit( + state.copyWith( + siteList: unique, + selectedSiteId: siteId, + clearSite: siteId == null, + clearUser: true, + ), + ); + }, + ); + } + + Future _loadSitesAndUsers(UserEntity user) async { + final sitesResult = await _getSitesByOrgUseCase(user.orgId); + sitesResult.fold( + (failure) => emit(state.copyWith(errorMessage: failure.message)), + (sites) async { + final unique = _ensureSiteInList(_uniqueSites(sites), user.siteId); + emit(state.copyWith(siteList: unique, selectedSiteId: user.siteId)); + if (user.siteId != null) { + await _loadUsers(user); + } else { + final currentUser = UserSimpleEntity( + id: int.tryParse(user.userId) ?? 0, + name: user.nickname, + ); + emit( + state.copyWith( + userList: [currentUser], + selectedUserId: currentUser.id, + ), + ); + } + }, + ); + } + + Future _loadUsers(UserEntity user) async { + final result = await _getUsersBySiteUseCase(user.siteId!); + final currentUser = UserSimpleEntity( + id: int.tryParse(user.userId) ?? 0, + name: user.nickname, + ); + result.fold( + (failure) { + emit( + state.copyWith( + userList: _ensureUserInList([], user), + selectedUserId: currentUser.id, + ), + ); + }, + (users) { + emit( + state.copyWith( + userList: _ensureUserInList(_uniqueUsers(users), user), + selectedUserId: currentUser.id, + ), + ); + }, + ); + } + + /// 换组织:清空场站与负责人,重新拉场站 + Future onOrgChanged(int orgId) async { + emit( + state.copyWith( + selectedOrgId: orgId, + siteList: const [], + userList: const [], + clearSite: true, + clearUser: true, + clearError: true, + ), + ); + + final result = await _getSitesByOrgUseCase(orgId); + result.fold( + (failure) => emit(state.copyWith(errorMessage: failure.message)), + (sites) => emit(state.copyWith(siteList: _uniqueSites(sites))), + ); + } + + /// 换场站:清空负责人,重新拉人员;[siteId] 传 null 表示不限定场站 + Future onSiteChanged(int? siteId) async { + emit( + state.copyWith( + selectedSiteId: siteId, + clearSite: siteId == null, + userList: const [], + clearUser: true, + clearError: true, + ), + ); + + if (siteId == null) return; + + final result = await _getUsersBySiteUseCase(siteId); + result.fold( + (failure) => emit(state.copyWith(errorMessage: failure.message)), + (users) => emit(state.copyWith(userList: _uniqueUsers(users))), + ); + } + + void onUserChanged(int? userId) { + emit( + state.copyWith( + selectedUserId: userId, + clearUser: userId == null, + clearError: true, + ), + ); + } + + /// 清空已消费的错误提示,避免 BlocListener 重复弹出 + void clearError() { + if (state.errorMessage == null) return; + emit(state.copyWith(clearError: true)); + } + + /// 记录扫码值,并返回它与当前设备是否一致。 + /// + /// 二维码里可能是纯编码、带前缀的 URL 或一串文本,所以做宽松匹配: + /// 归一化(去空白 + 大写)后只要有一方包含另一方就算同一台设备。 + bool recordScan({ + required String scanned, + required String deviceCode, + required String deviceId, + }) { + final code = scanned.trim(); + emit(state.copyWith(scannedValue: code)); + + final normalized = code.replaceAll(RegExp(r'\s'), '').toUpperCase(); + if (normalized.isEmpty) return false; + + final candidates = [deviceCode, deviceId] + .map((e) => e.replaceAll(RegExp(r'\s'), '').toUpperCase()) + .where((e) => e.isNotEmpty); + + final matched = candidates.any( + (e) => normalized == e || normalized.contains(e) || e.contains(normalized), + ); + debugPrint( + '>>> [OwnershipAction] 扫码核验 scanned=$code deviceCode=$deviceCode ' + 'deviceId=$deviceId matched=$matched', + ); + return matched; + } + + /// 提交归属操作,按 [DeviceOwnershipActionState.isTransfer] 分流。 + /// + /// [deviceIds] 由页面决定(当前设备编码,MC700- 开头)。 + Future submit({required List deviceIds}) { + return state.isTransfer + ? _submitTransfer(deviceIds) + : _submitUnbind(deviceIds); + } + + /// 转交设备 = 绑定接口:把设备挂到选定的组织/场站/负责人下。 + /// 校验口径与绑定页保持一致,不在这里额外加规则。 + Future _submitTransfer(List deviceIds) async { + final orgId = state.selectedOrgId; + debugPrint( + '>>> [OwnershipAction] transfer 入参 deviceIds=$deviceIds orgId=$orgId ' + 'siteId=${state.selectedSiteId} userId=${state.selectedUserId} ' + 'roleKey=$roleKey', + ); + + if (orgId == null || orgId <= 0) { + debugPrint('>>> [OwnershipAction] 前置拦截:组织为空,未发请求'); + emit(state.copyWith(errorMessage: '请选择所属组织')); + return false; + } + // 转交只开放给「普通用户以上」的角色;普通用户 / 个人角色只能选自己, + // 入口已在归属页隐藏,这里再做一道兜底。 + // 负责人不强制:只挂到组织或场站也是合法挂靠(与绑定页口径一致)。 + if (!isUserEnabled) { + debugPrint('>>> [OwnershipAction] 前置拦截:$roleKey 无转交权限'); + emit(state.copyWith(errorMessage: '当前角色无转交设备的权限')); + return false; + } + + emit(state.copyWith(isSubmitting: true, clearError: true)); + + final result = await _bindDeviceUseCase( + deviceIds: deviceIds, + orgId: orgId, + siteId: state.selectedSiteId, + userId: state.selectedUserId, + ); + + return result.fold( + (failure) { + debugPrint('>>> [OwnershipAction] ❌ 转交失败 ${failure.message}'); + emit( + state.copyWith(isSubmitting: false, errorMessage: failure.message), + ); + return false; + }, + (_) { + debugPrint('>>> [OwnershipAction] ✅ 转交成功'); + emit( + state.copyWith( + isSubmitting: false, + isSuccess: true, + clearError: true, + ), + ); + return true; + }, + ); + } + + /// 解绑设备 = 解绑接口。 + /// + /// 交互与绑定无关:用户只选「个人 / 场站 / 组织」一级, + /// 下发只有 deviceIds + type(见 [UnbindScope]),不需要组织/场站/负责人 ID。 + Future _submitUnbind(List deviceIds) async { + // 下发时一律用角色钳制后的范围,避免前端被改出越权 type + final scope = effectiveScope; + debugPrint( + '>>> [OwnershipAction] unbind 入参 deviceIds=$deviceIds ' + 'scope=${scope.value}(${scope.label}) roleKey=$roleKey ' + 'roleMax=${roleMaxScope.value}', + ); + + emit(state.copyWith(isSubmitting: true, clearError: true)); + + final result = await _unbindDeviceUseCase( + deviceIds: deviceIds, + scope: scope, + ); + + return result.fold( + (failure) { + debugPrint('>>> [OwnershipAction] ❌ 解绑失败 ${failure.message}'); + emit( + state.copyWith(isSubmitting: false, errorMessage: failure.message), + ); + return false; + }, + (_) { + debugPrint('>>> [OwnershipAction] ✅ 解绑成功'); + emit( + state.copyWith( + isSubmitting: false, + isSuccess: true, + clearError: true, + ), + ); + return true; + }, + ); + } +} diff --git a/lib/features/v2/device_list/presentation/cubit/device_ownership_action_state.dart b/lib/features/v2/device_list/presentation/cubit/device_ownership_action_state.dart new file mode 100644 index 00000000..03beb715 --- /dev/null +++ b/lib/features/v2/device_list/presentation/cubit/device_ownership_action_state.dart @@ -0,0 +1,139 @@ +import 'package:equatable/equatable.dart'; + +import '../../domain/entities/bind_device_entities.dart'; + +/// 设备归属操作页状态。 +/// +/// 转交与解绑共用一份状态,但交互完全不同: +/// - 转交(绑定):逐级选组织 → 场站 → 负责人([orgList] / [siteList] / [userList]); +/// - 解绑:不选具体对象,只选清除到哪一级([selectedScope],对应后端 type 1/2/3)。 +class DeviceOwnershipActionState extends Equatable { + final OwnershipAction action; + + /// 转交用:三级选择列表 + final List orgList; + final List siteList; + final List userList; + final int? selectedOrgId; + final int? selectedSiteId; + final int? selectedUserId; + + /// 解绑用:用户选中的清除层级(个人 / 场站 / 组织) + final UnbindScope selectedScope; + + final String roleKey; + final bool isLoading; + final bool isSubmitting; + final String? errorMessage; + final bool isSuccess; + + /// 扫码得到的原始值;null 表示还没扫码(或扫码页被跳过) + final String? scannedValue; + + const DeviceOwnershipActionState({ + this.action = OwnershipAction.unbind, + this.orgList = const [], + this.siteList = const [], + this.userList = const [], + this.selectedOrgId, + this.selectedSiteId, + this.selectedUserId, + this.selectedScope = UnbindScope.user, + this.roleKey = 'user', + this.isLoading = false, + this.isSubmitting = false, + this.errorMessage, + this.isSuccess = false, + this.scannedValue, + }); + + bool get isTransfer => action == OwnershipAction.transfer; + + /// 解绑直接返回用户选中的层级;转交按「选到哪一级」由下级往上推导, + /// 仅供结果提示条展示。 + UnbindScope get scopePreview { + if (!isTransfer) return selectedScope; + final uid = selectedUserId; + if (uid != null && uid > 0) return UnbindScope.user; + final sid = selectedSiteId; + if (sid != null && sid > 0) return UnbindScope.site; + return UnbindScope.org; + } + + /// 截止到 [scope] 的归属链路,用于提示条: + /// 解绑时只展示会被清除的层级,转交时展示要挂靠的完整链路。 + String pathTextUpTo(UnbindScope scope) { + final orgName = orgList + .where((e) => e.id == selectedOrgId) + .fold(null, (_, e) => e.name); + final siteName = scope.value >= UnbindScope.site.value + ? siteList.where((e) => e.id == selectedSiteId).fold(null, ( + _, + e, + ) => e.name) + : null; + final userName = scope.value >= UnbindScope.user.value + ? userList.where((e) => e.id == selectedUserId).fold(null, ( + _, + e, + ) => e.name) + : null; + return [?orgName, ?siteName, ?userName].join(' / '); + } + + DeviceOwnershipActionState copyWith({ + OwnershipAction? action, + List? orgList, + List? siteList, + List? userList, + int? selectedOrgId, + int? selectedSiteId, + int? selectedUserId, + UnbindScope? selectedScope, + String? roleKey, + bool? isLoading, + bool? isSubmitting, + String? errorMessage, + bool? isSuccess, + String? scannedValue, + bool clearSite = false, + bool clearUser = false, + bool clearError = false, + }) { + return DeviceOwnershipActionState( + action: action ?? this.action, + orgList: orgList ?? this.orgList, + siteList: siteList ?? this.siteList, + userList: userList ?? this.userList, + selectedOrgId: selectedOrgId ?? this.selectedOrgId, + // 换组织/换场站时必须能真正清空下级,不能用 ?? 保留旧值 + selectedSiteId: clearSite ? null : (selectedSiteId ?? this.selectedSiteId), + selectedUserId: clearUser ? null : (selectedUserId ?? this.selectedUserId), + selectedScope: selectedScope ?? this.selectedScope, + roleKey: roleKey ?? this.roleKey, + isLoading: isLoading ?? this.isLoading, + isSubmitting: isSubmitting ?? this.isSubmitting, + errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), + isSuccess: isSuccess ?? this.isSuccess, + scannedValue: scannedValue ?? this.scannedValue, + ); + } + + @override + List get props => [ + action, + orgList, + siteList, + userList, + selectedOrgId, + selectedSiteId, + selectedUserId, + selectedScope, + roleKey, + isLoading, + isSubmitting, + errorMessage, + isSuccess, + scannedValue, + ]; +} diff --git a/lib/features/v2/device_list/presentation/pages/bind_activate_page_v2.dart b/lib/features/v2/device_list/presentation/pages/bind_activate_page_v2.dart index 4e0b3b4a..84ac6660 100644 --- a/lib/features/v2/device_list/presentation/pages/bind_activate_page_v2.dart +++ b/lib/features/v2/device_list/presentation/pages/bind_activate_page_v2.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:maibu_satabot_v2/core/di/injection.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import '../cubit/bind_device_cubit.dart'; import '../cubit/bind_device_state.dart'; @@ -52,9 +53,15 @@ class _BindActivatePageV2State extends State { ).showSnackBar(SnackBar(content: Text(state.errorMessage!))); } if (state.isSuccess) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('绑定成功'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of( + context, + ).translate('robot_bind_v2.bind_success'), + ), + ), + ); Navigator.of(context).pop(); } }, @@ -119,9 +126,9 @@ class _BindActivatePageV2State extends State { ), ), const SizedBox(width: 12), - const Text( - '绑定激活', - style: TextStyle( + Text( + AppLocalizations.of(context).translate('robot_bind_v2.title'), + style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: _navy, @@ -134,16 +141,29 @@ class _BindActivatePageV2State extends State { // ===== 步骤条:1/2 已完成 / 3 当前 ===== Widget _buildSteps() { + final l10n = AppLocalizations.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 28), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _stepNode(index: 1, label: '识别设备', mode: 'done'), + _stepNode( + index: 1, + label: l10n.translate('robot_bind_v2.step_identify'), + mode: 'done', + ), _stepLine(), - _stepNode(index: 2, label: '确认信息', mode: 'done'), + _stepNode( + index: 2, + label: l10n.translate('robot_bind_v2.step_confirm'), + mode: 'done', + ), _stepLine(), - _stepNode(index: 3, label: '绑定激活', mode: 'active'), + _stepNode( + index: 3, + label: l10n.translate('robot_bind_v2.step_bind'), + mode: 'active', + ), ], ), ); @@ -224,6 +244,7 @@ class _BindActivatePageV2State extends State { // ===== 表单卡:逻辑与旧页一致(级联下拉 + 角色必填) ===== Widget _buildFormCard(BindDeviceState state) { + final l10n = AppLocalizations.of(context); return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -242,19 +263,25 @@ class _BindActivatePageV2State extends State { children: [ _buildRoleTag(state.roleKey), const SizedBox(height: 16), - _buildLabel('已选装备 ID', required: false), + _buildLabel( + l10n.translate('robot_bind_v2.equip_id'), + required: false, + ), const SizedBox(height: 8), _buildReadonlyField(widget.scanResult), const SizedBox(height: 16), - _buildLabel('所属组织', required: true), + _buildLabel(l10n.translate('robot_bind_v2.org'), required: true), const SizedBox(height: 8), _buildOrgDropdown(state), const SizedBox(height: 16), - _buildLabel('所属场站', required: false), + _buildLabel(l10n.translate('robot_bind_v2.site'), required: false), const SizedBox(height: 8), _buildSiteDropdown(state), const SizedBox(height: 16), - _buildLabel('负责人', required: _cubit.roleKey == 'user'), + _buildLabel( + l10n.translate('robot_bind_v2.owner'), + required: _cubit.roleKey == 'user', + ), const SizedBox(height: 8), _buildUserDropdown(state), ], @@ -263,14 +290,16 @@ class _BindActivatePageV2State extends State { } Widget _buildRoleTag(String roleKey) { - final roleLabel = - const { - 'admin': '管理员', - 'manager': '组织管理员', - 'siteManager': '场站管理员', - 'user': '普通用户', - }[roleKey] ?? - '普通用户'; + final l10n = AppLocalizations.of(context); + const roleKeyMap = { + 'admin': 'robot_bind_v2.role_admin', + 'manager': 'robot_bind_v2.role_manager', + 'siteManager': 'robot_bind_v2.role_site_manager', + 'user': 'robot_bind_v2.role_user', + }; + final roleLabel = l10n.translate( + roleKeyMap[roleKey] ?? 'robot_bind_v2.role_user', + ); return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( @@ -278,7 +307,10 @@ class _BindActivatePageV2State extends State { borderRadius: BorderRadius.circular(8), ), child: Text( - '当前角色:$roleLabel($roleKey)', + l10n + .translate('robot_bind_v2.role_current') + .replaceFirst('%s', roleLabel) + .replaceFirst('%s', roleKey), style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, @@ -351,14 +383,21 @@ class _BindActivatePageV2State extends State { : null; if (!enabled) { - return _buildReadonlyField(selectedName ?? '未分配组织'); + return _buildReadonlyField( + selectedName ?? + AppLocalizations.of( + context, + ).translate('robot_bind_v2.unassigned_org'), + ); } final validValue = _validValue(state.orgList, state.selectedOrgId); return DropdownButtonFormField( value: validValue, - decoration: _dropdownDecoration('请选择'), + decoration: _dropdownDecoration( + AppLocalizations.of(context).translate('robot_bind_v2.please_select'), + ), icon: const Icon(Icons.expand_more, color: _gray), items: state.orgList.map((org) { return DropdownMenuItem( @@ -385,10 +424,18 @@ class _BindActivatePageV2State extends State { : null; if (!enabled) { - return _buildReadonlyField(selectedName ?? '未分配场站'); + return _buildReadonlyField( + selectedName ?? + AppLocalizations.of( + context, + ).translate('robot_bind_v2.unassigned_site'), + ); } - final hintText = state.selectedOrgId == null ? '请先选择组织' : '请选择'; + final l10n = AppLocalizations.of(context); + final hintText = state.selectedOrgId == null + ? l10n.translate('robot_bind_v2.select_org_first') + : l10n.translate('robot_bind_v2.please_select'); final validValue = _validValue(state.siteList, state.selectedSiteId); return DropdownButtonFormField( @@ -420,10 +467,18 @@ class _BindActivatePageV2State extends State { : null; if (!enabled) { - return _buildReadonlyField(selectedName ?? '未分配负责人'); + return _buildReadonlyField( + selectedName ?? + AppLocalizations.of( + context, + ).translate('robot_bind_v2.unassigned_owner'), + ); } - final hintText = state.selectedSiteId == null ? '请先选择场站' : '请选择'; + final l10n = AppLocalizations.of(context); + final hintText = state.selectedSiteId == null + ? l10n.translate('robot_bind_v2.select_site_first') + : l10n.translate('robot_bind_v2.please_select'); final validValue = _validValue(state.userList, state.selectedUserId); return DropdownButtonFormField( @@ -500,12 +555,14 @@ class _BindActivatePageV2State extends State { ), ), ] - : const [ - Icon(Icons.check, color: Colors.white, size: 20), - SizedBox(width: 10), + : [ + const Icon(Icons.check, color: Colors.white, size: 20), + const SizedBox(width: 10), Text( - '确认绑定', - style: TextStyle( + AppLocalizations.of( + context, + ).translate('robot_bind_v2.submit'), + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white, diff --git a/lib/features/v2/device_list/presentation/pages/bind_device_page_v2.dart b/lib/features/v2/device_list/presentation/pages/bind_device_page_v2.dart index 4401c06f..06283e5b 100644 --- a/lib/features/v2/device_list/presentation/pages/bind_device_page_v2.dart +++ b/lib/features/v2/device_list/presentation/pages/bind_device_page_v2.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import 'bind_activate_page_v2.dart'; @@ -27,7 +28,6 @@ class _BindDevicePageV2State extends State { static const _greenDark = Color(0xFF1D9E57); static const _greenBg = Color(0xFFE5F8EC); static const _divider = Color(0xFFF0F4FA); - static const _unknown = '未知'; static const _btnGradient = LinearGradient( colors: [Color(0xFF2BAFFE), Color(0xFF1C96F6), Color(0xFF0B7DEE)], ); @@ -93,9 +93,11 @@ class _BindDevicePageV2State extends State { ), ), const SizedBox(width: 12), - const Text( - '确认设备信息', - style: TextStyle( + Text( + AppLocalizations.of( + context, + ).translate('device_pages_v2.bind_title'), + style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: _navy, @@ -108,16 +110,29 @@ class _BindDevicePageV2State extends State { // ===== 步骤条:1 已完成 / 2 当前 / 3 待进行 ===== Widget _buildSteps() { + final l10n = AppLocalizations.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 28), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _stepNode(index: 1, label: '识别设备', mode: 'done'), + _stepNode( + index: 1, + label: l10n.translate('device_pages_v2.step_scan'), + mode: 'done', + ), _stepLine(), - _stepNode(index: 2, label: '确认信息', mode: 'active'), + _stepNode( + index: 2, + label: l10n.translate('device_pages_v2.step_confirm'), + mode: 'active', + ), _stepLine(), - _stepNode(index: 3, label: '绑定激活', mode: 'wait'), + _stepNode( + index: 3, + label: l10n.translate('device_pages_v2.step_bind'), + mode: 'wait', + ), ], ), ); @@ -210,7 +225,9 @@ class _BindDevicePageV2State extends State { const SizedBox(width: 8), Expanded( child: Text( - '已识别设备,请确认信息与归属', + AppLocalizations.of( + context, + ).translate('device_pages_v2.bind_banner'), style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, @@ -225,6 +242,8 @@ class _BindDevicePageV2State extends State { // ===== 设备信息卡:扫出来的名字填名称/序列号,其余未知 ===== Widget _buildDeviceCard() { + final l10n = AppLocalizations.of(context); + final unknown = l10n.translate('device_detail_v2.unknown_version'); return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -274,22 +293,34 @@ class _BindDevicePageV2State extends State { ), ), const SizedBox(width: 10), - _buildBadge(_unknown), + _buildBadge(unknown), ], ), ), Padding( padding: const EdgeInsets.only(top: 6), child: Text( - _unknown, + unknown, style: const TextStyle(fontSize: 13, color: _gray), ), ), const Divider(height: 28, thickness: 1, color: _divider), - _infoRow('设备序列号', widget.scanResult), - _infoRow('设备绑定状态', _unknown), - _infoRow('设备连接', _unknown), - _infoRow('服务区域', _unknown), + _infoRow(l10n.translate('device_pages_v2.bind_sn'), widget.scanResult), + _infoRow( + l10n.translate('device_pages_v2.bind_status'), + unknown, + muted: true, + ), + _infoRow( + l10n.translate('device_pages_v2.bind_conn'), + unknown, + muted: true, + ), + _infoRow( + l10n.translate('device_pages_v2.bind_area'), + unknown, + muted: true, + ), ], ), ); @@ -313,8 +344,7 @@ class _BindDevicePageV2State extends State { ); } - Widget _infoRow(String label, String value) { - final isUnknown = value == _unknown; + Widget _infoRow(String label, String value, {bool muted = false}) { return Padding( padding: const EdgeInsets.symmetric(vertical: 9), child: Row( @@ -328,7 +358,7 @@ class _BindDevicePageV2State extends State { style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, - color: isUnknown ? _gray : _navy, + color: muted ? _gray : _navy, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -357,7 +387,9 @@ class _BindDevicePageV2State extends State { const Icon(Icons.verified_user_outlined, color: _blue, size: 20), const SizedBox(width: 8), Text( - '设备归属验证', + AppLocalizations.of( + context, + ).translate('device_pages_v2.bind_verify_title'), style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w700, @@ -370,8 +402,10 @@ class _BindDevicePageV2State extends State { Padding( padding: const EdgeInsets.only(left: 28), child: Text( - '仅扫描 SN 不代表已获得控制权。绑定前需要激活凭证或设备本地确认。', - style: TextStyle(fontSize: 13, color: _gray, height: 1.6), + AppLocalizations.of( + context, + ).translate('device_pages_v2.bind_verify_desc'), + style: const TextStyle(fontSize: 13, color: _gray, height: 1.6), ), ), ], @@ -403,14 +437,16 @@ class _BindDevicePageV2State extends State { ), ), borderRadius: BorderRadius.circular(14), - child: const Row( + child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.arrow_forward, color: Colors.white, size: 20), - SizedBox(width: 10), + const Icon(Icons.arrow_forward, color: Colors.white, size: 20), + const SizedBox(width: 10), Text( - '验证归属并继续', - style: TextStyle( + AppLocalizations.of( + context, + ).translate('device_pages_v2.bind_next'), + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white, diff --git a/lib/features/v2/device_list/presentation/pages/ble_device_detail_page.dart b/lib/features/v2/device_list/presentation/pages/ble_device_detail_page.dart index 88cb216d..006c1a8b 100644 --- a/lib/features/v2/device_list/presentation/pages/ble_device_detail_page.dart +++ b/lib/features/v2/device_list/presentation/pages/ble_device_detail_page.dart @@ -11,6 +11,7 @@ import '../../../../../core/bluetooth/mc700_device_config.dart'; import '../../../../../core/bluetooth/protocol_parser.dart'; import '../../../../../core/protocol/machine_protocol_constants.dart'; import 'package:get_it/get_it.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import '../../../../../core/services/device_permission_service.dart'; import '../../domain/entities/device_entity.dart'; import '../../domain/usecases/get_device_by_spilt_code_usecase.dart'; @@ -36,6 +37,8 @@ class BleDeviceDetailPage extends StatefulWidget { } class _BleDeviceDetailPageState extends State { + AppLocalizations get _l10n => AppLocalizations.of(context); + bool _isConnecting = false; bool _isConnected = false; List _services = []; @@ -215,12 +218,16 @@ class _BleDeviceDetailPageState extends State { showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('已有设备连接'), - content: Text('当前已连接设备:$connName\n\n请先断开当前设备后再连接新设备。'), + title: Text(_l10n.translate('ble_detail_v2.dlg_occupied_title')), + content: Text( + _l10n + .translate('ble_detail_v2.dlg_occupied_msg') + .replaceFirst('%s', connName), + ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('知道了'), + child: Text(_l10n.translate('ble_detail_v2.dlg_ok')), ), ], ), @@ -289,9 +296,9 @@ class _BleDeviceDetailPageState extends State { Future _bindDevice() async { final bleName = _bleDeviceName; if (bleName.isEmpty) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('无法获取蓝牙设备名称'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(_l10n.translate('ble_detail_v2.err_no_name'))), + ); return; } @@ -324,18 +331,28 @@ class _BleDeviceDetailPageState extends State { // 接口未查到(返回无 data)→ 与之前一致弹窗提示 if (matchedDevice == null || matchedDevice.name.isEmpty) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('未找到名称包含「$bleName」的设备'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _l10n + .translate('ble_detail_v2.err_no_match') + .replaceFirst('%s', bleName), + ), + ), + ); } return; } if (matchedDevice.deviceId.isEmpty) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('匹配到的设备缺少设备ID'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _l10n.translate('ble_detail_v2.err_no_device_id'), + ), + ), + ); } return; } @@ -354,9 +371,15 @@ class _BleDeviceDetailPageState extends State { } debugPrint('>>> [BindDevice] error: $e'); if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('绑定设备失败: $e'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _l10n + .translate('ble_detail_v2.err_bind_failed') + .replaceFirst('%s', '$e'), + ), + ), + ); } } } @@ -493,31 +516,44 @@ class _BleDeviceDetailPageState extends State { .join(' '); } - String _commandName(int command) { + String _commandKey(int command) { switch (command) { case MachineProtocolConstants.cmdRemoteControl: - return '远程遥控'; + return 'ble_detail_v2.cmd_remote'; case MachineProtocolConstants.cmdPathPlanning: - return '路径规划'; + return 'ble_detail_v2.cmd_path'; case MachineProtocolConstants.cmdStatusInfo: - return '状态信息'; + return 'ble_detail_v2.cmd_status'; case MachineProtocolConstants.cmdGetId: - return '查询ID'; + return 'ble_detail_v2.cmd_get_id'; case MachineProtocolConstants.cmdGetAuth: - return '获取授权'; + return 'ble_detail_v2.cmd_get_auth'; case MachineProtocolConstants.cmdReadConfig: - return '读配置'; + return 'ble_detail_v2.cmd_read_cfg'; case MachineProtocolConstants.cmdWriteConfig: - return '写配置'; + return 'ble_detail_v2.cmd_write_cfg'; case MachineProtocolConstants.cmdObstacleAvoid: - return '避障'; + return 'ble_detail_v2.cmd_obstacle'; case MachineProtocolConstants.cmdHeartbeat: - return '心跳包'; + return 'ble_detail_v2.cmd_heartbeat'; default: - return '未知(0x${command.toRadixString(16).toUpperCase().padLeft(2, '0')})'; + return 'ble_detail_v2.cmd_unknown'; } } + /// 命令名称:未知命令需要带上十六进制命令码 + String _commandName(int command) { + final key = _commandKey(command); + final label = _l10n.translate(key); + if (key == 'ble_detail_v2.cmd_unknown') { + return label.replaceFirst( + '%s', + command.toRadixString(16).toUpperCase().padLeft(2, '0'), + ); + } + return label; + } + @override Widget build(BuildContext context) { final device = widget.device; @@ -535,7 +571,9 @@ class _BleDeviceDetailPageState extends State { ? advData!.localName! : (device.advName.isNotEmpty ? device.advName - : '未知设备')))); + : _l10n.translate( + 'ble_detail_v2.unknown_device', + ))))); final scanResult = widget.scanResult; return Scaffold( @@ -547,7 +585,10 @@ class _BleDeviceDetailPageState extends State { icon: const Icon(Icons.arrow_back, color: Colors.white), onPressed: () => Navigator.pop(context), ), - title: const Text('设备详情', style: TextStyle(color: Colors.white)), + title: Text( + _l10n.translate('ble_detail_v2.title'), + style: const TextStyle(color: Colors.white), + ), centerTitle: true, ), body: SingleChildScrollView( @@ -623,23 +664,31 @@ class _BleDeviceDetailPageState extends State { const SizedBox(height: 16), Divider(height: 1, color: context.appColors.divider), const SizedBox(height: 12), - _buildInfoRow('设备名称', name), + _buildInfoRow( + _l10n.translate('ble_detail_v2.row_name'), + name, + ), if (scanResult != null) ...[ - _buildInfoRow('信号强度', '${scanResult.rssi} dBm'), _buildInfoRow( - '广播名称', + _l10n.translate('ble_detail_v2.row_rssi'), + '${scanResult.rssi} dBm', + ), + _buildInfoRow( + _l10n.translate('ble_detail_v2.row_adv_name'), scanResult.device.advName.isEmpty ? '-' : scanResult.device.advName, ), ], _buildInfoRow( - '连接状态', + _l10n.translate('ble_detail_v2.row_conn'), _isConnected - ? '已连接' + ? _l10n.translate('ble_detail_v2.state_connected') : _isConnecting - ? '连接中 ${_connectingCountdown}s...' - : '未连接', + ? _l10n + .translate('ble_detail_v2.state_connecting') + .replaceFirst('%s', '$_connectingCountdown') + : _l10n.translate('ble_detail_v2.state_disconnected'), ), ], ), @@ -655,7 +704,9 @@ class _BleDeviceDetailPageState extends State { borderRadius: BorderRadius.circular(4), ), child: Text( - '连接中 ${_connectingCountdown}s', + _l10n + .translate('ble_detail_v2.state_connecting_short') + .replaceFirst('%s', '${_connectingCountdown}s'), style: TextStyle(fontSize: 12, color: context.appColors.warning), ), ); @@ -668,7 +719,7 @@ class _BleDeviceDetailPageState extends State { borderRadius: BorderRadius.circular(4), ), child: Text( - '已连接', + _l10n.translate('ble_detail_v2.state_connected'), style: TextStyle(fontSize: 12, color: context.appColors.success), ), ); @@ -680,7 +731,7 @@ class _BleDeviceDetailPageState extends State { borderRadius: BorderRadius.circular(4), ), child: Text( - '未连接', + _l10n.translate('ble_detail_v2.state_disconnected'), style: TextStyle(fontSize: 12, color: context.appColors.textTertiary), ), ); @@ -734,7 +785,7 @@ class _BleDeviceDetailPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '操作', + _l10n.translate('ble_detail_v2.sec_actions'), style: TextStyle( fontSize: 15, fontWeight: FontWeight.bold, @@ -762,14 +813,22 @@ class _BleDeviceDetailPageState extends State { ), child: _isConnecting ? Text( - '连接中 ${_connectingCountdown}s', + _l10n + .translate('ble_detail_v2.state_connecting_short') + .replaceFirst('%s', '${_connectingCountdown}s'), style: const TextStyle( color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500, ), ) - : Text(_isConnected ? '断开连接' : '连接设备'), + : Text( + _l10n.translate( + _isConnected + ? 'ble_detail_v2.btn_disconnect' + : 'ble_detail_v2.btn_connect', + ), + ), ), ), const SizedBox(width: 12), @@ -790,7 +849,7 @@ class _BleDeviceDetailPageState extends State { ), ), child: Text( - '绑定设备', + _l10n.translate('ble_detail_v2.btn_bind'), style: TextStyle( fontSize: 15, fontWeight: FontWeight.w500, @@ -807,7 +866,7 @@ class _BleDeviceDetailPageState extends State { child: ElevatedButton.icon( onPressed: _isConnected ? _sendReadConfig : null, icon: const Icon(Icons.settings, size: 18), - label: const Text('读配置'), + label: Text(_l10n.translate('ble_detail_v2.btn_read_cfg')), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 14), backgroundColor: _isConnected @@ -847,7 +906,7 @@ class _BleDeviceDetailPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '服务列表', + _l10n.translate('ble_detail_v2.sec_services'), style: TextStyle( fontSize: 15, fontWeight: FontWeight.bold, @@ -984,7 +1043,7 @@ class _BleDeviceDetailPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '发送指令', + _l10n.translate('ble_detail_v2.sec_commands'), style: TextStyle( fontSize: 15, fontWeight: FontWeight.bold, @@ -997,7 +1056,7 @@ class _BleDeviceDetailPageState extends State { child: ElevatedButton.icon( onPressed: _sendReadConfig, icon: const Icon(Icons.settings, size: 18), - label: const Text('读配置'), + label: Text(_l10n.translate('ble_detail_v2.btn_read_cfg')), style: ElevatedButton.styleFrom( backgroundColor: context.appColors.primary, foregroundColor: Colors.white, @@ -1038,7 +1097,7 @@ class _BleDeviceDetailPageState extends State { Row( children: [ Text( - '数据推送', + _l10n.translate('ble_detail_v2.sec_push'), style: TextStyle( fontSize: 15, fontWeight: FontWeight.bold, @@ -1077,7 +1136,7 @@ class _BleDeviceDetailPageState extends State { BleManager.instance.clearReceivedPackets(); }, child: Text( - '清空', + _l10n.translate('ble_detail_v2.clear'), style: TextStyle( fontSize: 13, color: context.appColors.primary, @@ -1092,7 +1151,7 @@ class _BleDeviceDetailPageState extends State { padding: const EdgeInsets.symmetric(vertical: 20), child: Center( child: Text( - '等待接收数据...', + _l10n.translate('ble_detail_v2.waiting'), style: TextStyle( fontSize: 13, color: context.appColors.textTertiary, @@ -1239,7 +1298,7 @@ class _BleDeviceDetailPageState extends State { children: [ Expanded( child: Text( - '设备配置', + _l10n.translate('ble_detail_v2.sec_config'), style: TextStyle( fontSize: 15, fontWeight: FontWeight.bold, @@ -1264,7 +1323,11 @@ class _BleDeviceDetailPageState extends State { ) : const Icon(Icons.cloud_upload, size: 14), label: Text( - _writeConfigLoading ? '写入中...' : '写配置', + _l10n.translate( + _writeConfigLoading + ? 'ble_detail_v2.writing' + : 'ble_detail_v2.write_cfg', + ), style: const TextStyle(fontSize: 12), ), style: ElevatedButton.styleFrom( @@ -1296,9 +1359,11 @@ class _BleDeviceDetailPageState extends State { _showEditDialog(label, currentValue); } else { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('暂不支持更改'), - duration: Duration(seconds: 1), + SnackBar( + content: Text( + _l10n.translate('ble_detail_v2.toast_not_editable'), + ), + duration: const Duration(seconds: 1), ), ); } @@ -1393,7 +1458,9 @@ class _BleDeviceDetailPageState extends State { context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, setDialogState) => AlertDialog( - title: Text('修改 $label'), + title: Text( + _l10n.translate('ble_detail_v2.edit_title').replaceFirst('%s', label), + ), content: Form( key: formKey, child: TextField( @@ -1401,9 +1468,11 @@ class _BleDeviceDetailPageState extends State { autofocus: true, keyboardType: TextInputType.text, decoration: InputDecoration( - hintText: '输入新值', + hintText: _l10n.translate('ble_detail_v2.edit_hint'), border: const OutlineInputBorder(), - suffixText: '当前: $currentValue', + suffixText: _l10n + .translate('ble_detail_v2.edit_current') + .replaceFirst('%s', currentValue), suffixStyle: TextStyle( fontSize: 11, color: context.appColors.textTertiary, @@ -1415,7 +1484,7 @@ class _BleDeviceDetailPageState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('取消'), + child: Text(_l10n.translate('ble_detail_v2.cancel')), ), TextButton( onPressed: () { @@ -1445,7 +1514,7 @@ class _BleDeviceDetailPageState extends State { }); Navigator.pop(ctx); }, - child: const Text('确定'), + child: Text(_l10n.translate('ble_detail_v2.confirm')), ), ], ), @@ -1465,7 +1534,7 @@ class _BleDeviceDetailPageState extends State { if (chipUid.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('未获取到设备序列号,请先读配置'), + content: Text(_l10n.translate('ble_detail_v2.err_no_chip')), backgroundColor: context.appColors.warning, duration: const Duration(seconds: 2), ), @@ -1480,7 +1549,7 @@ class _BleDeviceDetailPageState extends State { if (!hasPermission) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('权限校验未通过,无法写入配置'), + content: Text(_l10n.translate('ble_detail_v2.err_no_permission')), backgroundColor: context.appColors.danger, duration: const Duration(seconds: 2), ), @@ -1556,7 +1625,7 @@ class _BleDeviceDetailPageState extends State { if (readbackPayload == null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('回读超时,请手动读配置确认'), + content: Text(_l10n.translate('ble_detail_v2.err_readback_timeout')), backgroundColor: context.appColors.warning, ), ); @@ -1609,7 +1678,12 @@ class _BleDeviceDetailPageState extends State { } ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('校验不一致: ${mismatched.join('、')},已恢复为设备实际值'), + content: Text( + _l10n.translate('ble_detail_v2.err_mismatch').replaceFirst( + '%s', + mismatched.join(_l10n.translate('ble_detail_v2.list_sep')), + ), + ), backgroundColor: context.appColors.warning, duration: const Duration(seconds: 3), ), @@ -1640,7 +1714,7 @@ class _BleDeviceDetailPageState extends State { }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('写配置成功,已回读确认'), + content: Text(_l10n.translate('ble_detail_v2.write_ok')), backgroundColor: context.appColors.success, ), ); @@ -1649,7 +1723,11 @@ class _BleDeviceDetailPageState extends State { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('写配置异常: $e'), + content: Text( + _l10n + .translate('ble_detail_v2.write_error') + .replaceFirst('%s', '$e'), + ), backgroundColor: context.appColors.danger, ), ); diff --git a/lib/features/v2/device_list/presentation/pages/device_all_functions_page_v2.dart b/lib/features/v2/device_list/presentation/pages/device_all_functions_page_v2.dart index 026cec22..40a3c3b9 100644 --- a/lib/features/v2/device_list/presentation/pages/device_all_functions_page_v2.dart +++ b/lib/features/v2/device_list/presentation/pages/device_all_functions_page_v2.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:maibu_satabot_v2/components/capsule_toast.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import '../../../../devices/domain/entities/device_entity.dart'; import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart'; @@ -39,10 +40,12 @@ class DeviceAllFunctionsPageV2 extends StatefulWidget { class _FuncItem { final IconData icon; - final String label; + + /// 语言包 key(device_pages_v2.xxx),渲染与搜索时再取词 + final String labelKey; final VoidCallback? onTap; - const _FuncItem(this.icon, this.label, [this.onTap]); + const _FuncItem(this.icon, this.labelKey, [this.onTap]); } class _DeviceAllFunctionsPageV2State extends State { @@ -73,7 +76,10 @@ class _DeviceAllFunctionsPageV2State extends State { return (alias != null && alias.isNotEmpty) ? alias : widget.robot.name; } - void _todo() => CapsuleToast.show('功能暂未开发', showCheck: false); + void _todo() => CapsuleToast.show( + AppLocalizations.of(context).translate('device_list_v2.feature_todo'), + showCheck: false, + ); /// 机器人信息:进「机器人完整信息」页(W02 设计稿) void _openRobotInfo() { @@ -196,32 +202,76 @@ class _DeviceAllFunctionsPageV2State extends State { List<(String, List<_FuncItem>)> _sections() { return [ ( - '视频与操作', + 'device_pages_v2.group_video', [ - _FuncItem(Icons.videocam_outlined, '实时图传', _openLiveVideo), - _FuncItem(Icons.sports_esports_outlined, '远程遥控', _openLegacyPage), - _FuncItem(Icons.location_on_outlined, '一键寻车', _openFindRobot), - _FuncItem(Icons.mic_outlined, '语音喊话', _openVoiceBroadcast), - _FuncItem(Icons.power_settings_new, 'APP开关机', _openAppPower), - _FuncItem(Icons.person_outline, '人员跟随'), - _FuncItem(Icons.home_outlined, '一键返航'), + _FuncItem( + Icons.videocam_outlined, + 'device_pages_v2.live_video', + _openLiveVideo, + ), + _FuncItem( + Icons.sports_esports_outlined, + 'device_pages_v2.remote', + _openLegacyPage, + ), + _FuncItem( + Icons.location_on_outlined, + 'device_pages_v2.find', + _openFindRobot, + ), + _FuncItem( + Icons.mic_outlined, + 'device_pages_v2.voice', + _openVoiceBroadcast, + ), + _FuncItem( + Icons.power_settings_new, + 'device_pages_v2.app_power', + _openAppPower, + ), + _FuncItem(Icons.person_outline, 'device_pages_v2.follow'), + _FuncItem(Icons.home_outlined, 'device_pages_v2.return_home'), ], ), ( - '设备信息', + 'device_pages_v2.group_info', [ - _FuncItem(Icons.smart_toy_outlined, '机器人信息', _openRobotInfo), - _FuncItem(Icons.location_on_outlined, '位置与地图', _openLocationMap), + _FuncItem( + Icons.smart_toy_outlined, + 'device_pages_v2.robot_info', + _openRobotInfo, + ), + _FuncItem( + Icons.location_on_outlined, + 'device_pages_v2.location_map', + _openLocationMap, + ), // 预设语音管理也放在设备信息里(与「语音喊话」磁贴同一页) - _FuncItem(Icons.record_voice_over_outlined, '预设语音', _openVoicePreset), - _FuncItem(Icons.bar_chart, '运行记录', _openRunRecord), + _FuncItem( + Icons.record_voice_over_outlined, + 'device_pages_v2.voice_preset', + _openVoicePreset, + ), + _FuncItem( + Icons.bar_chart, + 'device_pages_v2.run_record', + _openRunRecord, + ), ], ), ( - '设备监测', + 'device_pages_v2.group_monitor', [ - _FuncItem(Icons.sensors_outlined, '传感器监测', _openSensor), - _FuncItem(Icons.warning_amber_outlined, '故障诊断', _openFaultCenter), + _FuncItem( + Icons.sensors_outlined, + 'device_pages_v2.sensor', + _openSensor, + ), + _FuncItem( + Icons.warning_amber_outlined, + 'device_pages_v2.fault', + _openFaultCenter, + ), ], ), ]; @@ -229,12 +279,20 @@ class _DeviceAllFunctionsPageV2State extends State { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); final sections = _sections() .map( (s) => ( - s.$1, + l10n.translate(s.$1), s.$2 - .where((e) => _query.isEmpty || e.label.contains(_query)) + .where( + (e) => + _query.isEmpty || + l10n + .translate(e.labelKey) + .toLowerCase() + .contains(_query.toLowerCase()), + ) .toList(), ), ) @@ -269,12 +327,12 @@ class _DeviceAllFunctionsPageV2State extends State { _buildSearch(), const SizedBox(height: 16), if (sections.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(vertical: 40), + Padding( + padding: const EdgeInsets.symmetric(vertical: 40), child: Text( - '未找到匹配功能', + l10n.translate('device_pages_v2.empty'), textAlign: TextAlign.center, - style: TextStyle(fontSize: 13, color: _gray), + style: const TextStyle(fontSize: 13, color: _gray), ), ) else @@ -329,9 +387,11 @@ class _DeviceAllFunctionsPageV2State extends State { ), ), const SizedBox(width: 12), - const Text( - '全部功能中心', - style: TextStyle( + Text( + AppLocalizations.of( + context, + ).translate('device_pages_v2.title'), + style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: _navy, @@ -418,7 +478,13 @@ class _DeviceAllFunctionsPageV2State extends State { borderRadius: BorderRadius.circular(12), ), child: Text( - _online ? '在线' : '离线', + _online + ? AppLocalizations.of( + context, + ).translate('device_list_v2.online') + : AppLocalizations.of( + context, + ).translate('device_list_v2.offline'), style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, @@ -430,7 +496,7 @@ class _DeviceAllFunctionsPageV2State extends State { ), const SizedBox(height: 4), Text( - '${widget.robot.type} · ${DeviceV2Display.editionOf(widget.robot.name)}', + '${widget.robot.type} · ${DeviceV2Display.editionOf(widget.robot.name, AppLocalizations.of(context))}', style: const TextStyle(fontSize: 12, color: _gray), ), const SizedBox(height: 4), @@ -448,8 +514,9 @@ class _DeviceAllFunctionsPageV2State extends State { // ===== 说明卡:当前设备 + 能力边界 ===== Widget _buildNotice() { + final l10n = AppLocalizations.of(context); final device = - '${widget.robot.type} · ${DeviceV2Display.editionOf(widget.robot.name)}'; + '${widget.robot.type} · ${DeviceV2Display.editionOf(widget.robot.name, l10n)}'; return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( @@ -465,12 +532,12 @@ class _DeviceAllFunctionsPageV2State extends State { height: 1.5, ), children: [ - const TextSpan(text: '当前设备:'), + TextSpan(text: l10n.translate('device_pages_v2.notice_prefix')), TextSpan( text: device, style: const TextStyle(fontWeight: FontWeight.w700, color: _navy), ), - const TextSpan(text: '。仅展示设备实际支持的功能;本版不提供自主规划、任务下发及一键返航。'), + TextSpan(text: l10n.translate('device_pages_v2.notice_suffix')), ], ), ), @@ -497,15 +564,20 @@ class _DeviceAllFunctionsPageV2State extends State { onChanged: (v) => setState(() => _query = v.trim()), style: const TextStyle(fontSize: 14, color: _navy), cursorColor: _blue, - decoration: const InputDecoration( + decoration: InputDecoration( isDense: true, border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, disabledBorder: InputBorder.none, contentPadding: EdgeInsets.zero, - hintText: '搜索功能名称', - hintStyle: TextStyle(fontSize: 14, color: Color(0xFFB9C2CF)), + hintText: AppLocalizations.of( + context, + ).translate('device_pages_v2.search_hint'), + hintStyle: const TextStyle( + fontSize: 14, + color: Color(0xFFB9C2CF), + ), ), ), ), @@ -557,7 +629,7 @@ class _DeviceAllFunctionsPageV2State extends State { Icon(item.icon, size: 26, color: _blue), const SizedBox(height: 8), Text( - item.label, + AppLocalizations.of(context).translate(item.labelKey), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( diff --git a/lib/features/v2/device_list/presentation/pages/device_detail_page_v2.dart b/lib/features/v2/device_list/presentation/pages/device_detail_page_v2.dart index d4cfc839..f5689510 100644 --- a/lib/features/v2/device_list/presentation/pages/device_detail_page_v2.dart +++ b/lib/features/v2/device_list/presentation/pages/device_detail_page_v2.dart @@ -1,17 +1,26 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; import 'package:maibu_satabot_v2/components/capsule_toast.dart'; +import 'package:maibu_satabot_v2/components/control_mode_text.dart'; +import 'package:maibu_satabot_v2/components/device_status_modal.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import 'package:maibu_satabot_v2/features/pv_control/presentation/pages/pv_control_page.dart'; import 'package:maibu_satabot_v2/features/pv_control/presentation/widgets/pv_work_map.dart'; import '../../../../devices/domain/entities/device_entity.dart'; +import '../../../../devices/presentation/bloc/device_status_bloc.dart'; +import '../../../../devices/presentation/bloc/device_status_state.dart'; import '../../../../devices/presentation/bloc/devices_cubit.dart'; import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart'; import '../../../device_run_param/presentation/pages/robot_param_settings_page.dart'; import '../../../local_camera/presentation/pages/local_camera_page.dart'; import '../../data/models/robot_data_model.dart'; +import '../../domain/entities/device_model_info.dart'; +import '../../domain/usecases/update_device_alias_usecase.dart'; import '../widgets/device_card_v2.dart'; import '../widgets/find_robot_sheet.dart'; +import '../widgets/rename_device_dialog.dart'; import '../widgets/voice_broadcast_sheet.dart'; import 'device_all_functions_page_v2.dart'; import 'robot_app_power_page_v2.dart'; @@ -23,12 +32,13 @@ import 'robot_light_sound_page_v2.dart'; import 'robot_network_location_page_v2.dart'; import 'robot_run_record_page_v2.dart'; import 'robot_sensor_page_v2.dart'; +import 'route_editor_page_v2.dart'; /// 设备详情页 V2:点设备「直接进入」分支的新页面(STD 概览设计稿)。 /// -/// 入口约定(前置判定一行未动): -/// - 列表点击 hasHost=false → 仍走蓝牙连接流程(旧逻辑); -/// - 列表点击 hasHost=true → 直接进入本页; +/// 入口约定: +/// - 【暂时停用】hasHost 分层判定:hasHost=false 时原本走蓝牙连接流程,现统一直进本页; +/// - 列表点击 hasHost=true / false 均直接进入本页; /// - 本页「远程遥控」磁贴 / 控制 tab「进入远程遥控」→ 一键直进全功能模式的手动控制 /// HUD(不再经过旧控制详情页与选模式弹窗);需要旧页的入口(如铃铛、全部功能页) /// 仍跳 RobotControlPage,跳转前置(setTargetDevice + robotMap)与旧直进分支逐行一致,方便新旧对照。 @@ -110,10 +120,23 @@ class _DeviceDetailPageV2State extends State { static const _offlineGray = Color(0xFF6B7280); static const _offlineBg = Color(0xFFF0F2F5); static const _danger = Color(0xFFF0483E); - static const _tabs = ['概览', '监测', '控制', '记录', '设置']; + static const _tabKeys = [ + 'tab_overview', + 'tab_monitor', + 'tab_control', + 'tab_record', + 'tab_settings', + ]; int _tab = 0; + /// 当前机型的版本(来自列表接口 productModel);null/空串时为 unknown + DeviceEdition get _edition => widget.robot.modelInfo.edition; + + /// 机型号文案:`MC700 · PRO 旗舰版`;productModel 为 null/空串时显示未知 + String _modelLabelOf(AppLocalizations l10n) => + widget.robot.modelLabelOf(l10n); + // 安全设置里的两个开关(后端未下发口径,先做本地交互态) bool _autoStopBlade = true; bool _autoResumeBlade = true; @@ -131,19 +154,88 @@ class _DeviceDetailPageV2State extends State { return DeviceV2Status.working; } + /// 本页改名后的名称(接口只写 deviceAlias,不动 deviceName) + String? _aliasOverride; + String get _displayName { + final current = _aliasOverride?.trim(); + if (current != null && current.isNotEmpty) return current; final alias = widget.robot.alias; return (alias != null && alias.isNotEmpty) ? alias : widget.robot.name; } - String get _workLabel => switch (_status) { - DeviceV2Status.working => '作业中', - DeviceV2Status.online => '待机', - DeviceV2Status.offline => '离线', + String _workLabelOf(AppLocalizations l10n) => switch (_status) { + DeviceV2Status.working => l10n.translate('device_list_v2.stat_working'), + DeviceV2Status.online => l10n.translate( + 'device_list_v2.work_text_online', + ), + DeviceV2Status.offline => l10n.translate('device_list_v2.offline'), }; + /// 版本短文案:`PRO 旗舰版`;未知版本时显示「未知」 + String _editionLabelOf(AppLocalizations l10n) => + _edition == DeviceEdition.unknown + ? l10n.translate('device_detail_v2.unknown_version') + : widget.robot.modelInfo.editionLabelOf(l10n); + + /// 头部铅笔:修改设备名称 + /// + /// 与设备列表卡片同一套底部弹窗与接口: + /// deviceId 用设备编码(deviceName,MC700- 开头),不是数字主键; + /// 成功后只更新本页展示名,返回列表时通过 pop(true) 触发重拉。 + Future _renameDevice() async { + final l10n = AppLocalizations.of(context); + final oldName = _displayName; + final newName = await showRenameDeviceDialog( + context, + currentName: oldName, + modelText: _modelLabelOf(l10n), + snKey: widget.robot.name.isNotEmpty ? widget.robot.name : widget.robot.id, + ); + // 取消 / 名称未变:不调接口 + if (newName == null || newName == oldName || !mounted) return; + + final targetDeviceId = widget.robot.name.isNotEmpty + ? widget.robot.name + : widget.robot.id; + debugPrint( + '>>> [DetailRename] 下发 deviceId=$targetDeviceId alias=$newName', + ); + + final result = await GetIt.I().execute( + deviceId: targetDeviceId, + deviceAlias: newName, + ); + if (!mounted) return; + + result.fold( + (failure) => CapsuleToast.show( + failure.message.isEmpty + ? l10n.translate('device_list_v2.rename_failed') + : failure.message, + showCheck: false, + ), + (updated) { + if (!updated) { + CapsuleToast.show( + l10n.translate('device_list_v2.rename_failed'), + showCheck: false, + ); + return; + } + setState(() => _aliasOverride = newName); + CapsuleToast.show( + l10n + .translate('device_list_v2.rename_success') + .replaceAll('%s', newName), + ); + }, + ); + } + @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); return Scaffold( backgroundColor: const Color(0xFFEDF6FD), body: Container( @@ -189,8 +281,12 @@ class _DeviceDetailPageV2State extends State { const SizedBox(height: 8), Text( _tab == 4 - ? '两套机型的设置项暂全部展示,拿到机型字段后将自动过滤' - : '示例数据;常用功能只是导航入口,不会重复创建功能页面', + ? l10n + .translate( + 'device_detail_v2.footer_settings', + ) + .replaceAll('%s', _editionLabelOf(l10n)) + : l10n.translate('device_detail_v2.footer_demo'), textAlign: TextAlign.center, style: const TextStyle( fontSize: 10, @@ -215,7 +311,8 @@ class _DeviceDetailPageV2State extends State { child: Row( children: [ GestureDetector( - onTap: () => Navigator.of(context).pop(), + // 改过名就回传 true,由列表页重拉设备卡片,否则卡片还是旧名字 + onTap: () => Navigator.of(context).pop(_aliasOverride != null), behavior: HitTestBehavior.opaque, child: Container( width: 36, @@ -235,9 +332,11 @@ class _DeviceDetailPageV2State extends State { ), ), const SizedBox(width: 12), - const Text( - '设备详情', - style: TextStyle( + Text( + AppLocalizations.of( + context, + ).translate('device_detail_v2.title'), + style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: _navy, @@ -292,38 +391,40 @@ class _DeviceDetailPageV2State extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Flexible( - child: Text( - _displayName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.w800, - color: _navy, + // 点名称整行都能改:只有小铅笔可点的话手指点不中 + GestureDetector( + onTap: _renameDevice, + behavior: HitTestBehavior.opaque, + child: Row( + children: [ + Flexible( + child: Text( + _displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w800, + color: _navy, + ), ), ), - ), - // 编辑(改名):后端能力未接,统一提示 - GestureDetector( - onTap: () => - CapsuleToast.show('功能暂未开发', showCheck: false), - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 6), + // 编辑(改名):与列表卡片同一套弹窗 + updateDeviceAlias 接口 + const Padding( + padding: EdgeInsets.fromLTRB(8, 6, 8, 6), child: Icon( Icons.edit_outlined, - size: 14, + size: 15, color: _blue, ), ), - ), - ], + ], + ), ), const SizedBox(height: 4), Text( - '${widget.robot.type} · ${DeviceV2Display.editionOf(widget.robot.name)}', + // 型号 · 机型:由接口 productModel 解析,不再用伪随机版本 + _modelLabelOf(AppLocalizations.of(context)), style: const TextStyle(fontSize: 12, color: _gray), ), const SizedBox(height: 4), @@ -346,7 +447,7 @@ class _DeviceDetailPageV2State extends State { borderRadius: BorderRadius.circular(12), ), child: Text( - _workLabel, + _workLabelOf(AppLocalizations.of(context)), style: const TextStyle( fontSize: 10, fontWeight: FontWeight.w600, @@ -415,7 +516,13 @@ class _DeviceDetailPageV2State extends State { ), const SizedBox(width: 4), Text( - offline ? '离线' : '在线', + offline + ? AppLocalizations.of( + context, + ).translate('device_list_v2.offline') + : AppLocalizations.of( + context, + ).translate('device_list_v2.online'), style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, @@ -437,7 +544,7 @@ class _DeviceDetailPageV2State extends State { ), child: Row( children: [ - for (int i = 0; i < _tabs.length; i++) ...[ + for (int i = 0; i < _tabKeys.length; i++) ...[ if (i > 0) const SizedBox(width: 2), Expanded(child: _tabSegment(i)), ], @@ -468,7 +575,9 @@ class _DeviceDetailPageV2State extends State { ), alignment: Alignment.center, child: Text( - _tabs[i], + AppLocalizations.of( + context, + ).translate('device_detail_v2.${_tabKeys[i]}'), style: TextStyle( fontSize: 13, fontWeight: selected ? FontWeight.w700 : FontWeight.w400, @@ -479,76 +588,171 @@ class _DeviceDetailPageV2State extends State { ); } - // ===== 概览:运行概况六宫格 ===== + // ===== 概览:运行概况六宫格(数据取自「设备数据监控」,点击弹原弹窗) ===== Widget _buildOverviewSection() { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( + final l10n = AppLocalizations.of(context); + return BlocBuilder( + builder: (context, state) { + // 监控实时数据;未收到(如离线)时逐项显示 -- + final st = state is DeviceStatusUpdated ? state.status : null; + + // 能源:监控电量优先,回退列表接口的电池 + final liveBattery = (st?.battery ?? '').trim(); + final batteryText = liveBattery.isNotEmpty + ? (liveBattery.endsWith('%') ? liveBattery : '$liveBattery%') + : (widget.robot.isOnline && widget.robot.battery > 0 + ? '${widget.robot.battery.round()}%' + : '--'); + + // 控制模式:统一走 lib/components/control_mode_text.dart(与「设备数据监控」同口径)。 + // 后端没带 control_mode 时给 '--',不能再一律兜成「本地」(协议:0=未知 3=远程遥控) + final modeText = (st == null || st.controlMode.trim().isEmpty) + ? '--' + : controlModeDisplayText(l10n, st.controlMode); + + final knife = (st?.knifeCuttingSpeed ?? '').trim(); + final knifeText = (knife.isEmpty || knife == '0') ? '--' : knife; + + final hasFix = + st != null && !(st.latitude == 0 && st.longitude == 0); + final gnssText = hasFix + ? '${st.latitude.toStringAsFixed(2)},${st.longitude.toStringAsFixed(2)}' + : '--'; + + final tiles = [ + _statCard( + Icons.battery_std_outlined, + batteryText, + l10n.translate('device_detail_v2.stat_energy'), + ), + _statCard( + Icons.monitor_heart_outlined, + modeText, + l10n.translate('device_detail_v2.stat_control_mode'), + ), + _statCard( + Icons.thermostat_outlined, + st == null ? '--' : '${st.chipTemp.toStringAsFixed(1)}°', + l10n.translate('device_detail_v2.stat_chip_temp'), + ), + _statCard( + Icons.content_cut_outlined, + knifeText, + l10n.translate('device_detail_v2.stat_blade_speed'), + valueFontSize: 12, + ), + _statCard( + Icons.bolt_outlined, + st == null ? '--' : '${st.voltage.toStringAsFixed(1)}V', + l10n.translate('device_detail_v2.stat_voltage'), + ), + _statCard( + Icons.location_on_outlined, + gnssText, + l10n.translate('device_detail_v2.stat_gnss'), + valueFontSize: 11, + ), + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Text( - '机器人运行概况', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w800, - color: _navy, - ), + Row( + children: [ + Text( + l10n.translate('device_detail_v2.overview_title'), + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w800, + color: _navy, + ), + ), + const Spacer(), + GestureDetector( + onTap: _showDeviceDataMonitor, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.translate('device_detail_v2.view_detail'), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _blue, + ), + ), + const Icon( + Icons.arrow_forward, + size: 13, + color: _blue, + ), + ], + ), + ), + ], ), - const Spacer(), - const Text('状态更新:--', style: TextStyle(fontSize: 11, color: _gray)), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: _statCard( - Icons.battery_std_outlined, - _status == DeviceV2Status.offline - ? '--' - : '${widget.robot.battery.round()}%', - '能源', + const SizedBox(height: 8), + GestureDetector( + onTap: _showDeviceDataMonitor, + behavior: HitTestBehavior.opaque, + child: Column( + children: [ + Row( + children: [ + Expanded(child: tiles[0]), + const SizedBox(width: 10), + Expanded(child: tiles[1]), + const SizedBox(width: 10), + Expanded(child: tiles[2]), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded(child: tiles[3]), + const SizedBox(width: 10), + Expanded(child: tiles[4]), + const SizedBox(width: 10), + Expanded(child: tiles[5]), + ], + ), + ], ), ), - const SizedBox(width: 10), - Expanded( - child: _statCard( - Icons.monitor_heart_outlined, - _workLabel, - '设备状态', - ), - ), - const SizedBox(width: 10), - Expanded( - child: _statCard(Icons.location_on_outlined, '--', 'GNSS定位'), - ), ], - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: _statCard( - Icons.wifi_outlined, - widget.robot.isOnline ? '正常' : '离线', - '云端通信', - ), - ), - const SizedBox(width: 10), - Expanded( - child: _statCard(Icons.content_cut_outlined, '--', '割刀状态'), - ), - const SizedBox(width: 10), - Expanded( - child: _statCard(Icons.verified_user_outlined, '--', '当前故障'), - ), - ], - ), - ], + ); + }, ); } - Widget _statCard(IconData icon, String value, String label) { + /// 弹出「设备数据监控」原弹窗(与首页/列表页同一个,数据源为全局 DeviceStatusBloc) + void _showDeviceDataMonitor() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + builder: (_) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: context.read()), + BlocProvider.value(value: GetIt.I()), + ], + child: const DeviceStatusModal(), + ), + ); + } + + Widget _statCard( + IconData icon, + String value, + String label, { + double valueFontSize = 16, + }) { return Container( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4), decoration: BoxDecoration( @@ -564,8 +768,8 @@ class _DeviceDetailPageV2State extends State { value, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 16, + style: TextStyle( + fontSize: valueFontSize, fontWeight: FontWeight.w800, color: _navy, ), @@ -579,15 +783,16 @@ class _DeviceDetailPageV2State extends State { // ===== 位置与当前任务:地图卡 ===== Widget _buildMapSection() { - final location = DeviceV2Display.locationOf(widget.robot.name); + final l10n = AppLocalizations.of(context); + final location = DeviceV2Display.locationOf(widget.robot.name, l10n); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ - const Text( - '位置与当前任务', - style: TextStyle( + Text( + l10n.translate('device_detail_v2.location_task_title'), + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w800, color: _navy, @@ -595,19 +800,22 @@ class _DeviceDetailPageV2State extends State { ), const Spacer(), GestureDetector( - onTap: () => CapsuleToast.show('功能暂未开发', showCheck: false), - child: const Row( + onTap: () => CapsuleToast.show( + l10n.translate('device_list_v2.feature_todo'), + showCheck: false, + ), + child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( - '查看地图', - style: TextStyle( + l10n.translate('device_detail_v2.view_map'), + style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: _blue, ), ), - Icon(Icons.arrow_forward, size: 13, color: _blue), + const Icon(Icons.arrow_forward, size: 13, color: _blue), ], ), ), @@ -678,7 +886,11 @@ class _DeviceDetailPageV2State extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '当前位置:$location', + l10n + .translate( + 'device_detail_v2.current_location', + ) + .replaceAll('%s', location), style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, @@ -686,9 +898,14 @@ class _DeviceDetailPageV2State extends State { ), ), const SizedBox(height: 4), - const Text( - '仅显示当前位置 · 任务以控制页为准', - style: TextStyle(fontSize: 11, color: _gray), + Text( + l10n.translate( + 'device_detail_v2.location_hint', + ), + style: const TextStyle( + fontSize: 11, + color: _gray, + ), ), ], ), @@ -732,11 +949,12 @@ class _DeviceDetailPageV2State extends State { // 图 1-1:实时监测标题 + 数据更新时间 Widget _buildLiveMonitorHeader() { + final l10n = AppLocalizations.of(context); return Row( children: [ - const Text( - '实时监测', - style: TextStyle( + Text( + l10n.translate('device_detail_v2.live_monitor'), + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w800, color: _navy, @@ -761,9 +979,11 @@ class _DeviceDetailPageV2State extends State { ), ), const SizedBox(width: 5), - const Text( - '数据更新:2秒前', - style: TextStyle( + Text( + l10n + .translate('device_detail_v2.data_update_sec') + .replaceAll('%s', '2'), + style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: Color(0xFF1D9E57), @@ -779,26 +999,26 @@ class _DeviceDetailPageV2State extends State { // 图 1-2:传感器状态 Widget _buildSensorStatusCard() { return _monitorCard( - title: '传感器状态', - trailingText: '基于当前 SN 的配置', + title: 'device_detail_v2.sensor_status', + trailingText: 'device_detail_v2.sensor_status_trailing', rows: const [ _MonitorRow( icon: Icons.location_on_outlined, - title: 'GNSS基础定位', - subtitle: '位置有效 · 基础定位(非RTK)', - status: '有效', + title: 'device_detail_v2.m_gnss_title', + subtitle: 'device_detail_v2.m_gnss_sub', + status: 'device_detail_v2.st_valid', ), _MonitorRow( icon: Icons.sensors_outlined, - title: '超声波障碍检测', - subtitle: '4路传感器 · 当前未触发障碍告警', - status: '正常', + title: 'device_detail_v2.m_ultrasonic_title', + subtitle: 'device_detail_v2.m_ultrasonic_sub', + status: 'device_detail_v2.st_normal', ), _MonitorRow( icon: Icons.monitor_heart_outlined, - title: '车身IMU', - subtitle: '姿态上报正常 · 仅作运行监测', - status: '正常', + title: 'device_detail_v2.m_imu_title', + subtitle: 'device_detail_v2.m_imu_sub', + status: 'device_detail_v2.st_normal', ), ], ); @@ -806,22 +1026,26 @@ class _DeviceDetailPageV2State extends State { // 图 1-3:通信链路(云端状态取真实在线态) Widget _buildCommSection() { + final l10n = AppLocalizations.of(context); final online = widget.robot.isOnline; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( - children: const [ + children: [ Text( - '通信链路', - style: TextStyle( + l10n.translate('device_detail_v2.comm_link'), + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w800, color: _navy, ), ), - Spacer(), - Text('通信与视频链路分开显示', style: TextStyle(fontSize: 11, color: _gray)), + const Spacer(), + Text( + l10n.translate('device_detail_v2.comm_link_hint'), + style: const TextStyle(fontSize: 11, color: _gray), + ), ], ), const SizedBox(height: 10), @@ -829,29 +1053,31 @@ class _DeviceDetailPageV2State extends State { rows: [ _MonitorRow( icon: Icons.cloud_outlined, - title: '云端数据连接', - subtitle: '设备状态与告警的轻量数据通道', - status: online ? '在线' : '离线', + title: 'device_detail_v2.m_cloud_title', + subtitle: 'device_detail_v2.m_cloud_sub', + status: online + ? 'device_list_v2.online' + : 'device_list_v2.offline', ok: online, ), const _MonitorRow( icon: Icons.sports_esports_outlined, - title: '实体遥控器链路', - subtitle: '接收机在线 · 当前未占用控制权', - status: '正常', + title: 'device_detail_v2.m_rc_title', + subtitle: 'device_detail_v2.m_rc_sub', + status: 'device_detail_v2.st_normal', ), const _MonitorRow( icon: Icons.videocam_outlined, - title: '图传视频会话', - subtitle: '用户尚未进入视频界面,无视频流', - status: '未建立', + title: 'device_detail_v2.m_video_title', + subtitle: 'device_detail_v2.m_video_sub', + status: 'device_detail_v2.st_not_established', ok: false, ), const _MonitorRow( icon: Icons.memory, - title: '下位机与继电器板', - subtitle: '关键控制通讯状态', - status: '正常', + title: 'device_detail_v2.m_mcu_title', + subtitle: 'device_detail_v2.m_mcu_sub', + status: 'device_detail_v2.st_normal', ), ], ), @@ -861,14 +1087,15 @@ class _DeviceDetailPageV2State extends State { // 图 1-4:执行机构与当前故障 Widget _buildActuatorSection() { + final l10n = AppLocalizations.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ - const Text( - '执行机构与当前故障', - style: TextStyle( + Text( + l10n.translate('device_detail_v2.actuator_title'), + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w800, color: _navy, @@ -876,19 +1103,22 @@ class _DeviceDetailPageV2State extends State { ), const Spacer(), GestureDetector( - onTap: () => CapsuleToast.show('功能暂未开发', showCheck: false), - child: const Row( + onTap: () => CapsuleToast.show( + l10n.translate('device_list_v2.feature_todo'), + showCheck: false, + ), + child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( - '故障详情', - style: TextStyle( + l10n.translate('device_detail_v2.fault_detail'), + style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: _blue, ), ), - Icon(Icons.arrow_forward, size: 13, color: _blue), + const Icon(Icons.arrow_forward, size: 13, color: _blue), ], ), ), @@ -899,15 +1129,15 @@ class _DeviceDetailPageV2State extends State { rows: const [ _MonitorRow( icon: Icons.content_cut_outlined, - title: '割刀执行反馈', - subtitle: '已停转 · 安全互锁正常', - status: '正常', + title: 'device_detail_v2.m_blade_title', + subtitle: 'device_detail_v2.m_blade_sub', + status: 'device_detail_v2.st_normal', ), _MonitorRow( icon: Icons.verified_user_outlined, - title: '当前活动故障', - subtitle: '0项 · 未检出当前活动告警', - status: '无故障', + title: 'device_detail_v2.m_active_fault_title', + subtitle: 'device_detail_v2.m_active_fault_sub', + status: 'device_detail_v2.st_no_fault', ), ], ), @@ -917,24 +1147,80 @@ class _DeviceDetailPageV2State extends State { // 图 2-1:传感器与通信监测九宫格 Widget _buildMonitorGridSection(double tileWidth) { + final l10n = AppLocalizations.of(context); final online = widget.robot.isOnline; - final tiles = <(IconData, String, String, String)>[ - (Icons.videocam_outlined, '感知摄像头', '正常', '4路视频 · 画面正常'), - (Icons.sensors_outlined, '超声波障碍检测', '正常', '检测正常 · 无障碍'), - (Icons.location_on_outlined, 'GNSS/RTK', '有效', '固定解 · 误差<2cm'), - (Icons.monitor_heart_outlined, '运动状态', '正常', '姿态、速度正常'), - (Icons.memory, 'MCU通信', '正常', '控制器连接正常'), - (Icons.cloud_outlined, '云端数据连接', online ? '在线' : '离线', '数据上报正常'), - (Icons.person_search_outlined, '基础人/车识别', '正常', '识别模块工作正常'), - (Icons.sports_esports_outlined, '远程智控链路', '正常', '视频流与控制通道正常'), - (Icons.favorite_border, '设备心跳', '正常', '最近上报:2秒前'), + // (icon, 标题key, 状态key, 副标题key, 是否正常) + final tiles = <(IconData, String, String, String, bool)>[ + ( + Icons.videocam_outlined, + 'device_detail_v2.g_camera_title', + 'device_detail_v2.st_normal', + 'device_detail_v2.g_camera_sub', + true, + ), + ( + Icons.sensors_outlined, + 'device_detail_v2.m_ultrasonic_title', + 'device_detail_v2.st_normal', + 'device_detail_v2.g_ultrasonic_sub', + true, + ), + ( + Icons.location_on_outlined, + 'device_detail_v2.g_gnss_title', + 'device_detail_v2.st_valid', + 'device_detail_v2.g_gnss_sub', + true, + ), + ( + Icons.monitor_heart_outlined, + 'device_detail_v2.g_motion_title', + 'device_detail_v2.st_normal', + 'device_detail_v2.g_motion_sub', + true, + ), + ( + Icons.memory, + 'device_detail_v2.g_mcu_title', + 'device_detail_v2.st_normal', + 'device_detail_v2.g_mcu_sub', + true, + ), + ( + Icons.cloud_outlined, + 'device_detail_v2.m_cloud_title', + online ? 'device_list_v2.online' : 'device_list_v2.offline', + 'device_detail_v2.g_cloud_sub', + online, + ), + ( + Icons.person_search_outlined, + 'device_detail_v2.g_ai_title', + 'device_detail_v2.st_normal', + 'device_detail_v2.g_ai_sub', + true, + ), + ( + Icons.sports_esports_outlined, + 'device_detail_v2.g_rc_title', + 'device_detail_v2.st_normal', + 'device_detail_v2.g_rc_sub', + true, + ), + ( + Icons.favorite_border, + 'device_detail_v2.g_heartbeat_title', + 'device_detail_v2.st_normal', + 'device_detail_v2.g_heartbeat_sub', + true, + ), ]; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Text( - '传感器与通信监测', - style: TextStyle( + Text( + l10n.translate('device_detail_v2.grid_title'), + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w800, color: _navy, @@ -953,10 +1239,14 @@ class _DeviceDetailPageV2State extends State { ); } - Widget _monitorMiniTile((IconData, String, String, String) t) { - final (icon, label, status, subtitle) = t; + Widget _monitorMiniTile((IconData, String, String, String, bool) t) { + final l10n = AppLocalizations.of(context); + final (icon, labelKey, statusKey, subtitleKey, ok) = t; return GestureDetector( - onTap: () => CapsuleToast.show('功能暂未开发', showCheck: false), + onTap: () => CapsuleToast.show( + l10n.translate('device_list_v2.feature_todo'), + showCheck: false, + ), child: Container( padding: const EdgeInsets.all(9), decoration: BoxDecoration( @@ -973,7 +1263,7 @@ class _DeviceDetailPageV2State extends State { const SizedBox(width: 5), Expanded( child: Text( - label, + l10n.translate(labelKey), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( @@ -994,19 +1284,17 @@ class _DeviceDetailPageV2State extends State { Row( children: [ Text( - status, + l10n.translate(statusKey), style: TextStyle( fontSize: 9.5, fontWeight: FontWeight.w700, - color: status == '离线' - ? _offlineGray - : const Color(0xFF1D9E57), + color: ok ? const Color(0xFF1D9E57) : _offlineGray, ), ), const SizedBox(width: 4), Expanded( child: Text( - subtitle, + l10n.translate(subtitleKey), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 9, color: _gray), @@ -1022,12 +1310,13 @@ class _DeviceDetailPageV2State extends State { // 图 2-3:快捷诊断(设备自检 + 立即检测) Widget _buildQuickDiagSection() { + final l10n = AppLocalizations.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Text( - '快捷诊断', - style: TextStyle( + Text( + l10n.translate('device_detail_v2.quick_diag'), + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w800, color: _navy, @@ -1059,24 +1348,24 @@ class _DeviceDetailPageV2State extends State { ), ), const SizedBox(width: 10), - const Expanded( + Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '设备自检', - style: TextStyle( + l10n.translate('device_detail_v2.self_check'), + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: _navy, ), ), - SizedBox(height: 2), + const SizedBox(height: 2), Text( - '上次自检正常 · 2024-10-19 09:32', + l10n.translate('device_detail_v2.self_check_sub'), maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 11, color: _gray), + style: const TextStyle(fontSize: 11, color: _gray), ), ], ), @@ -1091,9 +1380,9 @@ class _DeviceDetailPageV2State extends State { color: const Color(0xFFE7F3FE), borderRadius: BorderRadius.circular(12), ), - child: const Text( - '立即检测', - style: TextStyle( + child: Text( + l10n.translate('device_detail_v2.check_now'), + style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w700, color: _blue, @@ -1120,6 +1409,7 @@ class _DeviceDetailPageV2State extends State { String? trailingText, required List<_MonitorRow> rows, }) { + final l10n = AppLocalizations.of(context); return Container( padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( @@ -1136,7 +1426,7 @@ class _DeviceDetailPageV2State extends State { child: Row( children: [ Text( - title, + l10n.translate(title), style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w800, @@ -1146,7 +1436,7 @@ class _DeviceDetailPageV2State extends State { const Spacer(), if (trailingText != null) Text( - trailingText, + l10n.translate(trailingText), style: const TextStyle(fontSize: 11, color: _gray), ), ], @@ -1167,6 +1457,7 @@ class _DeviceDetailPageV2State extends State { } Widget _buildMonitorRow(_MonitorRow row) { + final l10n = AppLocalizations.of(context); return Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Row( @@ -1186,7 +1477,7 @@ class _DeviceDetailPageV2State extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - row.title, + l10n.translate(row.title), style: const TextStyle( fontSize: 13.5, fontWeight: FontWeight.w700, @@ -1195,7 +1486,7 @@ class _DeviceDetailPageV2State extends State { ), const SizedBox(height: 2), Text( - row.subtitle, + l10n.translate(row.subtitle), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: _gray), @@ -1204,7 +1495,7 @@ class _DeviceDetailPageV2State extends State { ), ), const SizedBox(width: 8), - _monitorPill(row.status, ok: row.ok), + _monitorPill(l10n.translate(row.status), ok: row.ok), const SizedBox(width: 2), const Icon(Icons.chevron_right, size: 18, color: Color(0xFFC9CDD4)), ], @@ -1232,13 +1523,14 @@ class _DeviceDetailPageV2State extends State { // ===== 控制 tab:视频与远程控制 / 任务与作业控制 / 常用控制操作 ===== Widget _buildControlTab() { + final l10n = AppLocalizations.of(context); final tileWidth = (MediaQuery.of(context).size.width - 32 - 20) / 3; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildSectionTitle( - '视频与远程控制', - trailing: '按需连接', + l10n.translate('device_detail_v2.ctrl_video_remote'), + trailing: l10n.translate('device_detail_v2.ctrl_on_demand'), trailingIcon: Icons.lock_outline, ), const SizedBox(height: 10), @@ -1247,50 +1539,52 @@ class _DeviceDetailPageV2State extends State { Expanded( child: _buildActionCard( icon: Icons.videocam_outlined, - title: '进入实时视频', - subtitle: '查看实时画面', - hint: '需要手动进入并申请权限', - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const LocalCameraPage()), + title: l10n.translate('device_detail_v2.ctrl_realtime_video'), + subtitle: l10n.translate( + 'device_detail_v2.ctrl_realtime_video_sub', ), + hint: l10n.translate('device_detail_v2.ctrl_need_permission'), + onTap: _openRealtimeVideo, ), ), const SizedBox(width: 10), Expanded( child: _buildActionCard( icon: Icons.sports_esports_outlined, - title: '进入远程遥控', - subtitle: '远程控制作业', - hint: '需要手动进入并申请权限', + title: l10n.translate('device_detail_v2.ctrl_remote'), + subtitle: l10n.translate('device_detail_v2.ctrl_remote_sub'), + hint: l10n.translate('device_detail_v2.ctrl_need_permission'), onTap: _openManualControlHud, ), ), ], ), const SizedBox(height: 10), - _buildControlNote('实时图传与远程遥控均需进入专用页面后启动,不会自动开启。'), + _buildControlNote(l10n.translate('device_detail_v2.ctrl_note')), const SizedBox(height: 16), - _buildSectionTitle('任务与作业控制'), + _buildSectionTitle(l10n.translate('device_detail_v2.ctrl_task')), const SizedBox(height: 10), Row( children: [ Expanded( child: _buildActionCard( icon: Icons.route_outlined, - title: '进入路径规划', - subtitle: '设置作业路线', - hint: '支持自动规划与编辑', - onTap: () => CapsuleToast.show('功能暂未开发', showCheck: false), + title: l10n.translate('device_detail_v2.ctrl_route'), + subtitle: l10n.translate('device_detail_v2.ctrl_route_sub'), + hint: l10n.translate('device_detail_v2.ctrl_route_hint'), + onTap: _openRouteEditor, ), ), const SizedBox(width: 10), Expanded( child: _buildActionCard( icon: Icons.assignment_outlined, - title: '任务管理', - subtitle: '新建与执行割草任务', - hint: '支持定时任务', - onTap: () => CapsuleToast.show('功能暂未开发', showCheck: false), + title: l10n.translate('device_detail_v2.ctrl_task_mgr'), + subtitle: l10n.translate( + 'device_detail_v2.ctrl_task_mgr_sub', + ), + hint: l10n.translate('device_detail_v2.ctrl_task_mgr_hint'), + onTap: _openTaskManager, ), ), ], @@ -1444,21 +1738,67 @@ class _DeviceDetailPageV2State extends State { // 常用控制操作:三列小磁贴(图 2 的四项已归纳进来,一键寻车去重) Widget _buildControlOpsSection(double tileWidth) { + final l10n = AppLocalizations.of(context); final ops = <(IconData, String, String, VoidCallback?)>[ - (Icons.play_arrow_outlined, '任务启动', '开始执行作业', _openAutonomousWork), - (Icons.pause_circle_outline, '任务暂停', '暂停当前任务', null), - (Icons.home_outlined, '一键返航', '返回充电站', null), - (Icons.location_on_outlined, '一键寻车', '快速定位机器人', _openFindRobot), - (Icons.toggle_on_outlined, '手动/智能切换', '切换控制模式', null), - (Icons.smart_toy_outlined, '机器人信息', '查看详细配置', _openParamSettings), - (Icons.power_settings_new, 'APP开关机', '远程开关机控制', _openAppPower), - (Icons.mic_outlined, '语音喊话', '远程语音播报', _openVoiceBroadcast), - (Icons.lightbulb_outline, '灯光与声光', '控制灯光与警示', _openLightSound), + ( + Icons.play_arrow_outlined, + 'device_detail_v2.op_task_start', + 'device_detail_v2.op_task_start_sub', + _openAutonomousWork, + ), + ( + Icons.pause_circle_outline, + 'device_detail_v2.op_task_pause', + 'device_detail_v2.op_task_pause_sub', + null, + ), + ( + Icons.home_outlined, + 'device_detail_v2.op_return_home', + 'device_detail_v2.op_return_home_sub', + _openRouteEditor, + ), + ( + Icons.location_on_outlined, + 'device_detail_v2.op_find', + 'device_detail_v2.op_find_sub', + _openFindRobot, + ), + ( + Icons.toggle_on_outlined, + 'device_detail_v2.op_manual_switch', + 'device_detail_v2.op_manual_switch_sub', + null, + ), + ( + Icons.smart_toy_outlined, + 'device_detail_v2.op_robot_info', + 'device_detail_v2.op_robot_info_sub', + _openParamSettings, + ), + ( + Icons.power_settings_new, + 'device_detail_v2.op_app_power', + 'device_detail_v2.op_app_power_sub', + _openAppPower, + ), + ( + Icons.mic_outlined, + 'device_detail_v2.op_voice', + 'device_detail_v2.op_voice_sub', + _openVoiceBroadcast, + ), + ( + Icons.lightbulb_outline, + 'device_detail_v2.op_light', + 'device_detail_v2.op_light_sub', + _openLightSound, + ), ]; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildSectionTitle('常用控制操作'), + _buildSectionTitle(l10n.translate('device_detail_v2.ctrl_ops')), const SizedBox(height: 10), Wrap( spacing: 10, @@ -1473,9 +1813,15 @@ class _DeviceDetailPageV2State extends State { } Widget _controlOpTile((IconData, String, String, VoidCallback?) op) { - final (icon, title, subtitle, onTap) = op; + final l10n = AppLocalizations.of(context); + final (icon, titleKey, subtitleKey, onTap) = op; return GestureDetector( - onTap: onTap ?? () => CapsuleToast.show('功能暂未开发', showCheck: false), + onTap: + onTap ?? + () => CapsuleToast.show( + l10n.translate('device_list_v2.feature_todo'), + showCheck: false, + ), behavior: HitTestBehavior.opaque, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), @@ -1493,7 +1839,7 @@ class _DeviceDetailPageV2State extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - title, + l10n.translate(titleKey), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( @@ -1504,7 +1850,7 @@ class _DeviceDetailPageV2State extends State { ), const SizedBox(height: 2), Text( - subtitle, + l10n.translate(subtitleKey), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 9, color: _gray), @@ -1520,14 +1866,21 @@ class _DeviceDetailPageV2State extends State { // ===== 记录 tab:故障与预警记录 + 作业记录 ===== Widget _buildRecordTab() { + final l10n = AppLocalizations.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildLinkHeader('故障与预警记录', onTap: _openFaultCenter), + _buildLinkHeader( + l10n.translate('device_detail_v2.rec_fault_title'), + onTap: _openFaultCenter, + ), const SizedBox(height: 10), _buildFaultRecordCard(), const SizedBox(height: 16), - _buildLinkHeader('作业记录', onTap: _openRunRecord), + _buildLinkHeader( + l10n.translate('device_detail_v2.rec_work_title'), + onTap: _openRunRecord, + ), const SizedBox(height: 10), _buildWorkRecordCard(), ], @@ -1537,9 +1890,10 @@ class _DeviceDetailPageV2State extends State { // 分组标题 + 右侧蓝色「查看全部 →」 Widget _buildLinkHeader( String title, { - String link = '查看全部', + String? link, VoidCallback? onTap, }) { + final l10n = AppLocalizations.of(context); return Row( children: [ Text( @@ -1552,13 +1906,18 @@ class _DeviceDetailPageV2State extends State { ), const Spacer(), GestureDetector( - onTap: onTap ?? () => CapsuleToast.show('功能暂未开发', showCheck: false), + onTap: + onTap ?? + () => CapsuleToast.show( + l10n.translate('device_list_v2.feature_todo'), + showCheck: false, + ), behavior: HitTestBehavior.opaque, child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( - link, + link ?? l10n.translate('device_detail_v2.view_all'), style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, @@ -1576,6 +1935,7 @@ class _DeviceDetailPageV2State extends State { // 故障与预警记录卡:警告红图标 + 汇总文案 + 红色条数药丸 Widget _buildFaultRecordCard() { + final l10n = AppLocalizations.of(context); return GestureDetector( onTap: _openFaultCenter, behavior: HitTestBehavior.opaque, @@ -1602,26 +1962,26 @@ class _DeviceDetailPageV2State extends State { ), ), const SizedBox(width: 10), - const Expanded( + Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '近7天故障/预警', + l10n.translate('device_detail_v2.rec_7d'), maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle( + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: _navy, ), ), - SizedBox(height: 3), + const SizedBox(height: 3), Text( - '共 3 条记录(故障 1 条,预警 2 条)', + l10n.translate('device_detail_v2.rec_7d_sub'), maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 11, color: _gray), + style: const TextStyle(fontSize: 11, color: _gray), ), ], ), @@ -1633,9 +1993,11 @@ class _DeviceDetailPageV2State extends State { color: const Color(0xFFFDECEC), borderRadius: BorderRadius.circular(12), ), - child: const Text( - '3 条', - style: TextStyle( + child: Text( + l10n + .translate('device_detail_v2.rec_count') + .replaceAll('%s', '3'), + style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFFF0483E), @@ -1652,6 +2014,7 @@ class _DeviceDetailPageV2State extends State { // 作业记录卡:今日作业 + 作业中药丸 + 三列指标 Widget _buildWorkRecordCard() { + final l10n = AppLocalizations.of(context); final now = DateTime.now(); return GestureDetector( onTap: _openRunRecord, @@ -1686,9 +2049,9 @@ class _DeviceDetailPageV2State extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - '今日作业', - style: TextStyle( + Text( + l10n.translate('device_detail_v2.rec_today'), + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: _navy, @@ -1696,7 +2059,10 @@ class _DeviceDetailPageV2State extends State { ), const SizedBox(height: 2), Text( - '${now.month}月${now.day}日', + l10n + .translate('device_detail_v2.rec_date') + .replaceAll('%s', '${now.month}') + .replaceAll('%s', '${now.day}'), style: const TextStyle(fontSize: 11, color: _gray), ), ], @@ -1712,7 +2078,7 @@ class _DeviceDetailPageV2State extends State { borderRadius: BorderRadius.circular(12), ), child: Text( - _workLabel, + _workLabelOf(AppLocalizations.of(context)), style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w700, @@ -1727,11 +2093,26 @@ class _DeviceDetailPageV2State extends State { const SizedBox(height: 10), Row( children: [ - Expanded(child: _recordMetric('作业面积', '2.38 ha')), + Expanded( + child: _recordMetric( + l10n.translate('device_detail_v2.rec_area'), + '2.38 ha', + ), + ), _metricDivider(), - Expanded(child: _recordMetric('作业时长', '56 min')), + Expanded( + child: _recordMetric( + l10n.translate('device_detail_v2.rec_duration'), + '56 min', + ), + ), _metricDivider(), - Expanded(child: _recordMetric('作业效率', '2.55 ha/h')), + Expanded( + child: _recordMetric( + l10n.translate('device_detail_v2.rec_efficiency'), + '2.55 ha/h', + ), + ), const Icon( Icons.chevron_right, size: 18, @@ -1793,6 +2174,7 @@ class _DeviceDetailPageV2State extends State { // ===== 解绑设备:置于设置页最底部(白底细边,主色改为警示红) ===== Widget _buildUnbindButton() { + final l10n = AppLocalizations.of(context); return GestureDetector( onTap: _isUnbinding ? null : _confirmUnbind, behavior: HitTestBehavior.opaque, @@ -1809,7 +2191,9 @@ class _DeviceDetailPageV2State extends State { const Icon(Icons.link_off, size: 16, color: _danger), const SizedBox(width: 6), Text( - _isUnbinding ? '解绑中…' : '解绑设备', + _isUnbinding + ? l10n.translate('device_detail_v2.unbinding') + : l10n.translate('device_detail_v2.unbind'), style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w700, @@ -1823,21 +2207,24 @@ class _DeviceDetailPageV2State extends State { } Future _confirmUnbind() async { + final l10n = AppLocalizations.of(context); final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( backgroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: const Text( - '解绑设备', - style: TextStyle( + title: Text( + l10n.translate('device_detail_v2.unbind'), + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w800, color: _navy, ), ), content: Text( - '解绑后将无法继续查看「$_displayName」的监测与控制,需重新绑定才能使用。确定解绑?', + l10n + .translate('device_detail_v2.unbind_confirm_body') + .replaceAll('%s', _displayName), style: const TextStyle( fontSize: 13, height: 1.5, @@ -1847,16 +2234,16 @@ class _DeviceDetailPageV2State extends State { actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext, false), - child: const Text( - '取消', - style: TextStyle(fontSize: 13, color: _gray), + child: Text( + l10n.translate('device_detail_v2.set_cancel'), + style: const TextStyle(fontSize: 13, color: _gray), ), ), TextButton( onPressed: () => Navigator.pop(dialogContext, true), - child: const Text( - '确定解绑', - style: TextStyle( + child: Text( + l10n.translate('device_detail_v2.unbind_confirm_ok'), + style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: _danger, @@ -1874,29 +2261,41 @@ class _DeviceDetailPageV2State extends State { /// DevicesCubit.unbindDevice(deviceId, deviceName),成功后回列表 Future _executeUnbind() async { setState(() => _isUnbinding = true); + final l10n = AppLocalizations.of(context); final cubit = GetIt.I(); try { await cubit.unbindDevice(widget.robot.id, widget.robot.name); if (!mounted) return; final error = cubit.state.errorMessage; if (error == null || error.isEmpty) { - CapsuleToast.show('已解绑 $_displayName'); + CapsuleToast.show( + l10n.translate('device_detail_v2.unbind_success').replaceAll( + '%s', + _displayName, + ), + ); Navigator.of(context).pop(); } else { CapsuleToast.show(error, showCheck: false); } } catch (e) { - if (mounted) CapsuleToast.show('解绑异常:$e', showCheck: false); + if (mounted) { + CapsuleToast.show( + l10n.translate('device_detail_v2.unbind_error').replaceAll('%s', '$e'), + showCheck: false, + ); + } } finally { if (mounted) setState(() => _isUnbinding = false); } } Widget _buildSettingGroupHeader(_SettingGroup group) { + final l10n = AppLocalizations.of(context); return Row( children: [ Text( - group.title, + l10n.translate(group.title), style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w800, @@ -1908,7 +2307,7 @@ class _DeviceDetailPageV2State extends State { const Icon(Icons.lock_outline, size: 12, color: _blue), const SizedBox(width: 3), Text( - group.trailingText!, + l10n.translate(group.trailingText!), style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, @@ -1946,11 +2345,16 @@ class _DeviceDetailPageV2State extends State { } Widget _buildSettingRow(_SettingItem item) { + final l10n = AppLocalizations.of(context); final hasSwitch = item.switchValue != null; return GestureDetector( onTap: hasSwitch ? null - : (item.onTap ?? () => CapsuleToast.show('功能暂未开发', showCheck: false)), + : (item.onTap ?? + () => CapsuleToast.show( + l10n.translate('device_list_v2.feature_todo'), + showCheck: false, + )), behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.symmetric(vertical: 10), @@ -1971,7 +2375,7 @@ class _DeviceDetailPageV2State extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - item.title, + l10n.translate(item.title), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( @@ -1982,7 +2386,7 @@ class _DeviceDetailPageV2State extends State { ), const SizedBox(height: 2), Text( - item.subtitle, + l10n.translate(item.subtitle), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: _gray), @@ -2008,7 +2412,7 @@ class _DeviceDetailPageV2State extends State { ) else ...[ if (item.badge != null) ...[ - _settingBadge(item.badge!, green: item.badgeGreen), + _settingBadge(l10n.translate(item.badge!), green: item.badgeGreen), const SizedBox(width: 4), ], const Icon( @@ -2041,162 +2445,163 @@ class _DeviceDetailPageV2State extends State { ); } - /// 设置分组数据:机型区分字段未下发前,A/B 两套设置项全部展示; - /// 后续拿到区分字段只需在这里按机型过滤后返回,UI 不用动。 + /// 设置分组数据:所有设置项当前全机型展示。 + /// title/subtitle/badge 均存语言包 key,由渲染处 translate。 + /// 后续要按机型屏蔽某项,用 `_edition`(std/plus/pro)在这里判断即可,UI 不用动。 List<_SettingGroup> _settingGroups() { return [ _SettingGroup( - title: '设备设置升级', + title: 'device_detail_v2.set_group_device', items: [ _SettingItem( icon: Icons.tune, - title: '设备参数设置', - subtitle: '作业参数、底盘参数、割刀参数等设置', + title: 'device_detail_v2.set_param', + subtitle: 'device_detail_v2.set_param_sub', onTap: _openParamSettings, ), _SettingItem( icon: Icons.system_update_alt, - title: '固件与OTA升级', - subtitle: '当前版本 V1.1.2 · 可检查新版本', - badge: '检查升级', + title: 'device_detail_v2.set_firmware', + subtitle: 'device_detail_v2.set_firmware_sub', + badge: 'device_detail_v2.set_check_update', onTap: _openFirmware, ), ], ), const _SettingGroup( - title: '作业与割刀策略', - trailingText: '管理权限', + title: 'device_detail_v2.set_group_blade', + trailingText: 'device_detail_v2.set_manage_perm', items: [ _SettingItem( icon: Icons.content_cut_outlined, - title: '障碍物联动停刀', - subtitle: '已启用 · 障碍触发时按策略停止割刀', - badge: '已启用', + title: 'device_detail_v2.set_obstacle_stop', + subtitle: 'device_detail_v2.set_obstacle_stop_sub', + badge: 'device_detail_v2.set_enabled', badgeGreen: true, ), _SettingItem( icon: Icons.shield_outlined, - title: '割刀恢复策略', - subtitle: '人工确认恢复(推荐)', - badge: '人工确认', + title: 'device_detail_v2.set_blade_resume', + subtitle: 'device_detail_v2.set_blade_resume_sub', + badge: 'device_detail_v2.set_manual_confirm', ), _SettingItem( icon: Icons.show_chart, - title: '底盘升降参数', - subtitle: '按实际车型及限位信号配置', - badge: '查看', + title: 'device_detail_v2.set_chassis', + subtitle: 'device_detail_v2.set_chassis_sub', + badge: 'device_detail_v2.set_view', ), ], ), _SettingGroup( - title: '通信与图传设置', + title: 'device_detail_v2.set_group_comm', items: [ _SettingItem( icon: Icons.wifi, - title: '网络与通信参数', - subtitle: '4G/5G/Wi-Fi配置、GNSS通信配置', + title: 'device_detail_v2.set_network', + subtitle: 'device_detail_v2.set_network_sub', onTap: _openNetworkLocation, ), const _SettingItem( icon: Icons.videocam_outlined, - title: '视频图传设置', - subtitle: '画质、通道、车道辅助线显示', + title: 'device_detail_v2.set_video', + subtitle: 'device_detail_v2.set_video_sub', ), ], ), const _SettingGroup( - title: '定位与地图设置', + title: 'device_detail_v2.set_group_map', items: [ _SettingItem( icon: Icons.map_outlined, - title: '定位与地图参数', - subtitle: 'GNSS定位参数、地图与作业区域设置', + title: 'device_detail_v2.set_map', + subtitle: 'device_detail_v2.set_map_sub', ), ], ), _SettingGroup( - title: '安全设置', + title: 'device_detail_v2.set_group_safety', items: [ _SettingItem( icon: Icons.content_cut_outlined, - title: '遥障自动关闭割刀', - subtitle: '检测到障碍物时自动停止割刀,提升作业安全', + title: 'device_detail_v2.set_auto_stop', + subtitle: 'device_detail_v2.set_auto_stop_sub', switchValue: _autoStopBlade, onSwitchChanged: (v) => setState(() => _autoStopBlade = v), ), _SettingItem( icon: Icons.autorenew, - title: '障碍清除后自动开启割刀', - subtitle: '障碍物移除后自动恢复割刀作业', + title: 'device_detail_v2.set_auto_resume', + subtitle: 'device_detail_v2.set_auto_resume_sub', switchValue: _autoResumeBlade, onSwitchChanged: (v) => setState(() => _autoResumeBlade = v), ), _SettingItem( icon: Icons.lightbulb_outline, - title: '声光报警设置', - subtitle: '报警音量、灯光提示、异常提醒方式', + title: 'device_detail_v2.set_alarm', + subtitle: 'device_detail_v2.set_alarm_sub', onTap: _openLightSound, ), ], ), const _SettingGroup( - title: '控制与视频配置', + title: 'device_detail_v2.set_group_control', items: [ _SettingItem( icon: Icons.sports_esports_outlined, - title: '远程遥控偏好', - subtitle: '操作布局、速度档位上限', - badge: '配置', + title: 'device_detail_v2.set_rc_pref', + subtitle: 'device_detail_v2.set_rc_pref_sub', + badge: 'device_detail_v2.set_config', ), _SettingItem( icon: Icons.videocam_outlined, - title: '视频显示偏好', - subtitle: '画质、通道、车道辅助线显示', - badge: '配置', + title: 'device_detail_v2.set_video_pref', + subtitle: 'device_detail_v2.set_video_sub', + badge: 'device_detail_v2.set_config', ), _SettingItem( icon: Icons.lightbulb_outline, - title: '声光提示偏好', - subtitle: '仅非关键提示可调整', - badge: '配置', + title: 'device_detail_v2.set_light_pref', + subtitle: 'device_detail_v2.set_light_pref_sub', + badge: 'device_detail_v2.set_config', ), ], ), const _SettingGroup( - title: '高级能力设置', + title: 'device_detail_v2.set_group_advanced', items: [ _SettingItem( icon: Icons.smart_toy_outlined, - title: 'AI识别设置', - subtitle: '人员/车辆识别开关、敏感度、识别区域设置', + title: 'device_detail_v2.set_ai', + subtitle: 'device_detail_v2.set_ai_sub', ), _SettingItem( icon: Icons.person_outline, - title: '人员跟随设置', - subtitle: '目标锁定、跟随模式、跟随距离设置', + title: 'device_detail_v2.set_follow', + subtitle: 'device_detail_v2.set_follow_sub', ), _SettingItem( icon: Icons.sports_esports_outlined, - title: '远程智控设置', - subtitle: '远程控制偏好、操作布局、图传延迟设置', + title: 'device_detail_v2.set_remote_ctrl', + subtitle: 'device_detail_v2.set_remote_ctrl_sub', ), ], ), _SettingGroup( - title: '网络与系统维护', + title: 'device_detail_v2.set_group_maintain', items: [ _SettingItem( icon: Icons.wifi, - title: '网络与定位参数', - subtitle: '4G/Wi-Fi配置、GNSS通信配置', - badge: '查看', + title: 'device_detail_v2.set_net_loc', + subtitle: 'device_detail_v2.set_net_loc_sub', + badge: 'device_detail_v2.set_view', onTap: _openNetworkLocation, ), _SettingItem( icon: Icons.system_update_alt, - title: '固件与OTA升级', - subtitle: '当前版本 V0.9.7 · 需停机检查', - badge: '检查升级', + title: 'device_detail_v2.set_firmware', + subtitle: 'device_detail_v2.set_firmware_sub_old', + badge: 'device_detail_v2.set_check_update', onTap: _openFirmware, ), ], @@ -2206,14 +2611,15 @@ class _DeviceDetailPageV2State extends State { // ===== 故障状态:当前无故障(位于常用功能之上) ===== Widget _buildFaultSection() { + final l10n = AppLocalizations.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ - const Text( - '故障状态', - style: TextStyle( + Text( + l10n.translate('device_detail_v2.fault_state'), + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w800, color: _navy, @@ -2221,19 +2627,22 @@ class _DeviceDetailPageV2State extends State { ), const Spacer(), GestureDetector( - onTap: () => CapsuleToast.show('功能暂未开发', showCheck: false), - child: const Row( + onTap: () => CapsuleToast.show( + l10n.translate('device_list_v2.feature_todo'), + showCheck: false, + ), + child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( - '查看详情', - style: TextStyle( + l10n.translate('device_detail_v2.view_detail'), + style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: _blue, ), ), - Icon(Icons.arrow_forward, size: 13, color: _blue), + const Icon(Icons.arrow_forward, size: 13, color: _blue), ], ), ), @@ -2241,7 +2650,10 @@ class _DeviceDetailPageV2State extends State { ), const SizedBox(height: 10), GestureDetector( - onTap: () => CapsuleToast.show('功能暂未开发', showCheck: false), + onTap: () => CapsuleToast.show( + l10n.translate('device_list_v2.feature_todo'), + showCheck: false, + ), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), decoration: BoxDecoration( @@ -2275,22 +2687,22 @@ class _DeviceDetailPageV2State extends State { ), ), const SizedBox(width: 12), - const Expanded( + Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '当前无故障', - style: TextStyle( + l10n.translate('device_detail_v2.fault_none'), + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w800, color: _navy, ), ), - SizedBox(height: 2), + const SizedBox(height: 2), Text( - '设备运行正常,未检测到异常', - style: TextStyle(fontSize: 12, color: _gray), + l10n.translate('device_detail_v2.fault_none_sub'), + style: const TextStyle(fontSize: 12, color: _gray), ), ], ), @@ -2310,29 +2722,50 @@ class _DeviceDetailPageV2State extends State { // ===== 常用功能:六磁贴(导航入口,不重建功能页) ===== Widget _buildFunctionSection() { + final l10n = AppLocalizations.of(context); final tiles = <(IconData, String, VoidCallback)>[ ( Icons.videocam_outlined, - '实时图传', + l10n.translate('device_detail_v2.fn_realtime_video'), () => Navigator.of( context, ).push(MaterialPageRoute(builder: (_) => const LocalCameraPage())), ), // 远程遥控:一键直进手动控制 HUD(旧控制详情页 + 选模式弹窗不再在这条链路里) - (Icons.sports_esports_outlined, '远程遥控', _openManualControlHud), - (Icons.my_location_outlined, '一键寻车', _openFindRobot), - (Icons.power_settings_new, 'APP开关机', _openAppPower), - (Icons.warning_amber_outlined, '故障诊断', _openFaultCenter), - (Icons.sensors_outlined, '传感器监测', _openSensor), + ( + Icons.sports_esports_outlined, + l10n.translate('device_detail_v2.fn_remote'), + _openManualControlHud, + ), + ( + Icons.my_location_outlined, + l10n.translate('device_detail_v2.fn_find'), + _openFindRobot, + ), + ( + Icons.power_settings_new, + l10n.translate('device_detail_v2.fn_app_power'), + _openAppPower, + ), + ( + Icons.warning_amber_outlined, + l10n.translate('device_detail_v2.fn_fault'), + _openFaultCenter, + ), + ( + Icons.sensors_outlined, + l10n.translate('device_detail_v2.fn_sensor'), + _openSensor, + ), ]; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ - const Text( - '常用功能', - style: TextStyle( + Text( + l10n.translate('device_detail_v2.fn_title'), + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w800, color: _navy, @@ -2340,15 +2773,18 @@ class _DeviceDetailPageV2State extends State { ), const Spacer(), GestureDetector( - onTap: () => CapsuleToast.show('功能暂未开发', showCheck: false), - child: const Row( + onTap: () => CapsuleToast.show( + l10n.translate('device_list_v2.feature_todo'), + showCheck: false, + ), + child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.edit_outlined, size: 13, color: _blue), - SizedBox(width: 4), + const Icon(Icons.edit_outlined, size: 13, color: _blue), + const SizedBox(width: 4), Text( - '编辑排序', - style: TextStyle( + l10n.translate('device_detail_v2.fn_edit_order'), + style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: _blue, @@ -2444,6 +2880,20 @@ class _DeviceDetailPageV2State extends State { ); } + /// 任务管理:进自主作业中心并直接展开「多区域连续任务队列」 + /// (队列/取消/切换均走 AutonomousWorkGateway 真实数据,与任务启动入口共用同一页)。 + void _openTaskManager() { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => RobotAutonomousWorkPageV2( + robot: widget.robot, + entryPanel: RobotWorkEntryPanel.taskQueue, + ), + ), + ); + } + /// 机器人信息:进旧「设备参数设置」页(原设备详情页「在线」旁的齿轮图标入口) void _openParamSettings() { Navigator.push( @@ -2526,19 +2976,21 @@ class _DeviceDetailPageV2State extends State { borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFA9D1F6)), ), - child: const Row( + child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - '查看全部功能', - style: TextStyle( + AppLocalizations.of( + context, + ).translate('device_detail_v2.all_functions'), + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: _blue, ), ), - SizedBox(width: 8), - Icon(Icons.arrow_forward, size: 16, color: _blue), + const SizedBox(width: 8), + const Icon(Icons.arrow_forward, size: 16, color: _blue), ], ), ), @@ -2551,7 +3003,8 @@ class _DeviceDetailPageV2State extends State { return { 'name': robot.name, 'id': robot.id, - 'alias': robot.alias, + // 本页改过名就用新名字,否则旧页会显示改名前的名称 + 'alias': _aliasOverride ?? robot.alias, 'type': robot.type, 'status': robot.status, 'battery': robot.battery, @@ -2577,6 +3030,30 @@ class _DeviceDetailPageV2State extends State { return device; } + /// 「进入路径规划」→ 地图与区域编辑(横屏,淡入 + 极轻缩放转场)。 + /// 与本页其它能力一致:只做导航,不重建功能页(编辑器内部自己处理圈地/保存)。 + void _openRouteEditor() { + Navigator.of( + context, + ).push(RouteEditorPageV2.route(deviceSn: widget.robot.name)); + } + + /// 「进入实时视频」:直接进新版远程遥控(实时视频大屏)。 + /// 不再先过「本地视频」页;摇杆默认收起,进全屏手动控制时也是关的(设置可开)。 + void _openRealtimeVideo() { + final device = _prepareControlTarget(); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PvControlPage( + deviceId: device.deviceName, + deviceName: _displayName, + initialShowJoysticks: false, + ), + ), + ); + } + /// 远程遥控:一键直进「全功能模式」的手动控制 HUD。 /// 跳过旧控制详情页与「选择控制模式」弹窗;HUD 的折叠按钮仍可收起看大屏。 void _openManualControlHud() { @@ -2586,7 +3063,8 @@ class _DeviceDetailPageV2State extends State { MaterialPageRoute( builder: (_) => PvControlPage( deviceId: device.deviceName, - deviceName: device.deviceName, + // 仅用于远程页顶栏展示:有别名显示别名,与列表卡标题同一口径 + deviceName: _displayName, initialMode: PvViewMode.manualControl, ), ), diff --git a/lib/features/v2/device_list/presentation/pages/device_ownership_action_page_v2.dart b/lib/features/v2/device_list/presentation/pages/device_ownership_action_page_v2.dart new file mode 100644 index 00000000..042388b9 --- /dev/null +++ b/lib/features/v2/device_list/presentation/pages/device_ownership_action_page_v2.dart @@ -0,0 +1,959 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:maibu_satabot_v2/components/capsule_toast.dart'; +import 'package:maibu_satabot_v2/core/di/injection.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; + +import '../../data/models/robot_data_model.dart'; +import '../../domain/entities/bind_device_entities.dart'; +import '../cubit/device_ownership_action_cubit.dart'; +import '../cubit/device_ownership_action_state.dart'; +import '../widgets/robot_summary_card_v2.dart'; + +/// 设备归属操作页 V2(入口:设备归属与安全页上的两个操作)。 +/// +/// 一个页面承载两个动作,因为它们的交互完全一致,差别只在提交: +/// - [OwnershipAction.transfer] 转交设备 = 调绑定接口,把设备挂到选定层级; +/// - [OwnershipAction.unbind] 解绑设备 = 调解绑接口,把设备从选定层级摘除。 +/// +/// 视觉沿用 [RobotOwnershipPageV2]:浅蓝渐变底 + 白卡 + KV 行 + 页脚提示; +/// 业务沿用绑定流程的三级级联:组织 → 场站 → 负责人逐级加载不可越级, +/// 角色决定哪一级可编辑(admin 全可选、manager 锁组织、siteManager 锁组织+场站、 +/// user / personal 三级全锁、只能是自己)。 +class DeviceOwnershipActionPageV2 extends StatefulWidget { + final RobotDataModel robot; + final OwnershipAction action; + + const DeviceOwnershipActionPageV2({ + super.key, + required this.robot, + required this.action, + }); + + @override + State createState() => + _DeviceOwnershipActionPageV2State(); +} + +class _DeviceOwnershipActionPageV2State + extends State { + static const Color _navy = Color(0xFF1A2B4A); + static const Color _gray = Color(0xFF86909C); + static const Color _hint = Color(0xFFA8B3C5); + static const Color _blue = Color(0xFF1C96F6); + static const Color _danger = Color(0xFFF0483E); + static const Color _cardBorder = Color(0xFFE5EDF7); + static const Color _softBlue = Color(0xFFEAF3FE); + static const Color _softRed = Color(0xFFFDEDEC); + static const Color _softGray = Color(0xFFF2F4F8); + static const Color _divider = Color(0xFFF0F4FA); + + static const LinearGradient _dangerGradient = LinearGradient( + colors: [Color(0xFFFF7A6B), Color(0xFFF0483E), Color(0xFFD93A30)], + ); + + static const LinearGradient _primaryGradient = LinearGradient( + colors: [Color(0xFF4FAEFB), Color(0xFF1C96F6), Color(0xFF0B7BDC)], + ); + + late final DeviceOwnershipActionCubit _cubit; + + bool get _isTransfer => widget.action == OwnershipAction.transfer; + + /// i18n 命名空间:两个动作共用同一段 key,靠后缀区分文案 + static const String _ns = 'device_ownership_action_v2.'; + + /// 拼接本页的 i18n key + String _k(String suffix) => '$_ns$suffix'; + + @override + void initState() { + super.initState(); + _cubit = sl(); + _cubit.init(action: widget.action); + } + + @override + void dispose() { + _cubit.close(); + super.dispose(); + } + + /// 下发的设备标识:设备编码(deviceName,MC700- 开头),不是数字主键 id。 + List _targetDeviceIds() { + final code = widget.robot.name.trim(); + return [code.isNotEmpty ? code : widget.robot.id]; + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return BlocProvider.value( + value: _cubit, + child: BlocListener( + listener: (context, state) { + final message = state.errorMessage; + if (message != null && message.isNotEmpty) { + CapsuleToast.show(message, showCheck: false); + _cubit.clearError(); + } + }, + child: Scaffold( + backgroundColor: const Color(0xFFEDF6FD), + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFD9EBFA), Color(0xFFEDF6FD)], + ), + ), + child: SafeArea( + child: + BlocBuilder( + builder: (context, state) { + return Column( + children: [ + _buildTopBar(), + Expanded( + child: state.isLoading + ? const Center( + child: CircularProgressIndicator( + color: _blue, + ), + ) + : SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + 16, + 4, + 16, + 16, + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + RobotSummaryCardV2(robot: widget.robot), + const SizedBox(height: 14), + // 转交:三级级联(绑定逻辑); + // 解绑:只选清除到哪一级,不是绑定表单 + if (_isTransfer) + _buildScopeCard(state) + else + _buildScopeSelectCard(state), + const SizedBox(height: 12), + _buildPreviewBar(state), + const SizedBox(height: 14), + _buildFooterNote(l10n), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: _buildSubmitButton(state), + ), + ], + ); + }, + ), + ), + ), + ), + ), + ); + } + + // ===== 顶栏:返回 + 居中标题(与归属页一致)===== + Widget _buildTopBar() { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 16, 10), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.of(context).pop(), + behavior: HitTestBehavior.opaque, + child: const SizedBox( + width: 36, + height: 36, + child: Icon( + Icons.arrow_back_ios_new_rounded, + color: _navy, + size: 20, + ), + ), + ), + Expanded( + child: Text( + AppLocalizations.of(context).translate( + _k(_isTransfer ? 'title_transfer' : 'title_unbind'), + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + color: _navy, + ), + ), + ), + const SizedBox(width: 36), + ], + ), + ); + } + + Widget _buildCardTitle(String title, {Color color = _navy}) { + return Text( + title, + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w800, color: color), + ); + } + + Widget _buildCard({required List children}) { + return Container( + padding: const EdgeInsets.fromLTRB(14, 16, 14, 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _cardBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ); + } + + // ===== 归属层级卡:三级级联,角色决定可编辑范围 ===== + Widget _buildScopeCard(DeviceOwnershipActionState state) { + final l10n = AppLocalizations.of(context); + return _buildCard( + children: [ + Row( + children: [ + Expanded( + child: _buildCardTitle( + l10n.translate( + _k(_isTransfer ? 'card_scope_transfer' : 'card_scope_unbind'), + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: _softBlue, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + l10n + .translate(_k('role_current')) + .replaceFirst('%s', l10n.translate(_roleLabelKey(state.roleKey))), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: _blue, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + // 把“这个角色能触达哪一级”直接写在卡头,不让用户去猜锁图标 + Text( + l10n + .translate( + _k(_isTransfer ? 'scope_max_transfer' : 'scope_max_unbind'), + ) + .replaceFirst( + '%s', + l10n.translate(_scopeLabelKey(_cubit.roleMaxScope)), + ), + style: const TextStyle(fontSize: 11.5, color: _hint), + ), + const SizedBox(height: 2), + _buildPickerRow( + label: l10n.translate(_k('scope_org')), + value: _nameOf(state.orgList, state.selectedOrgId), + placeholder: l10n.translate(_k('unassigned_org')), + enabled: _cubit.isOrgEnabled, + onTap: () => _pickOrg(state), + ), + const Divider(height: 1, thickness: 0.6, color: _divider), + _buildPickerRow( + label: l10n.translate(_k('scope_site')), + value: _nameOf(state.siteList, state.selectedSiteId), + placeholder: l10n.translate(_k('unassigned_site')), + // 必须先有组织才能选场站(不可越级) + enabled: _cubit.isSiteEnabled && state.selectedOrgId != null, + onTap: () => _pickSite(state), + ), + const Divider(height: 1, thickness: 0.6, color: _divider), + _buildPickerRow( + label: l10n.translate(_k('scope_user')), + value: _nameOf(state.userList, state.selectedUserId), + placeholder: l10n.translate(_k('unassigned_user')), + // 必须先有场站才能选负责人(不可越级) + enabled: _cubit.isUserEnabled && state.selectedSiteId != null, + onTap: () => _pickUser(state), + ), + Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: _softBlue, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.info_outline_rounded, size: 16, color: _blue), + const SizedBox(width: 8), + Expanded( + child: Text( + l10n.translate( + _k(_isTransfer ? 'scope_tip_transfer' : 'scope_tip_unbind'), + ), + style: const TextStyle( + fontSize: 11.5, + height: 1.5, + color: Color(0xFF5C6B84), + ), + ), + ), + ], + ), + ), + ], + ); + } + + // ===== 解绑:三个选项(个人 / 场站 / 组织),不做三级级联 ===== + Widget _buildScopeSelectCard(DeviceOwnershipActionState state) { + final l10n = AppLocalizations.of(context); + + return _buildCard( + children: [ + Row( + children: [ + Expanded( + child: _buildCardTitle(l10n.translate(_k('card_scope_unbind'))), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: _softBlue, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + l10n + .translate(_k('role_current')) + .replaceFirst( + '%s', + l10n.translate(_roleLabelKey(state.roleKey)), + ), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: _blue, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + l10n + .translate(_k('scope_max_unbind')) + .replaceFirst( + '%s', + l10n.translate(_scopeOptionKey(_cubit.roleMaxScope)), + ), + style: const TextStyle(fontSize: 11.5, color: _hint), + ), + const SizedBox(height: 12), + _buildScopeOption(UnbindScope.user, state), + const SizedBox(height: 8), + _buildScopeOption(UnbindScope.site, state), + const SizedBox(height: 8), + _buildScopeOption(UnbindScope.org, state), + Container( + margin: const EdgeInsets.only(top: 12, bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: _softBlue, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.info_outline_rounded, size: 16, color: _blue), + const SizedBox(width: 8), + Expanded( + child: Text( + l10n.translate(_k('scope_select_tip')), + style: const TextStyle( + fontSize: 11.5, + height: 1.5, + color: Color(0xFF5C6B84), + ), + ), + ), + ], + ), + ), + ], + ); + } + + /// 单个清除层级选项:选中蓝框(组织为红框),越权置灰并锁住 + Widget _buildScopeOption(UnbindScope scope, DeviceOwnershipActionState state) { + final l10n = AppLocalizations.of(context); + final enabled = scope.value <= _cubit.roleMaxScope.value; + final selected = state.selectedScope == scope; + final isDeepest = scope == UnbindScope.org; + final accent = isDeepest ? _danger : _blue; + final titleColor = !enabled + ? _hint + : selected + ? accent + : _navy; + + return GestureDetector( + onTap: enabled ? () => _cubit.onScopeSelected(scope) : _notifyLockedScope, + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13), + decoration: BoxDecoration( + color: selected + ? (isDeepest ? _softRed : _softBlue) + : (enabled ? Colors.white : _softGray), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: selected ? accent : _cardBorder, + width: selected ? 1.4 : 1, + ), + ), + child: Row( + children: [ + _buildRadio(selected, enabled, accent), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.translate(_scopeOptionKey(scope)), + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w800, + color: titleColor, + ), + ), + const SizedBox(height: 3), + Text( + // _scopeOptionKey 已含命名空间前缀,直接拼 _sub,不能再走 _k() + l10n.translate('${_scopeOptionKey(scope)}_sub'), + style: const TextStyle(fontSize: 11.5, color: _gray), + ), + ], + ), + ), + if (!enabled) + _buildStatusPill( + l10n.translate(_k('tag_locked')), + _hint, + soft: true, + ), + ], + ), + ), + ); + } + + Widget _buildRadio(bool selected, bool enabled, Color accent) { + return Container( + width: 18, + height: 18, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected ? accent : Colors.transparent, + border: Border.all( + color: selected + ? accent + : (enabled ? const Color(0xFFC9CDD4) : _hint), + width: 1.5, + ), + ), + child: selected + ? const Icon(Icons.check_rounded, size: 12, color: Colors.white) + : null, + ); + } + + /// 小胶囊标签(与归属页同款) + Widget _buildStatusPill(String text, Color color, {bool soft = false}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: soft ? 0.1 : 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + text, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ); + } + + void _notifyLockedScope() => CapsuleToast.show( + AppLocalizations.of(context).translate(_k('scope_locked_hint')), + showCheck: false, + ); + + String _roleLabelKey(String roleKey) { + switch (roleKey) { + case 'admin': + return _k('role_admin'); + case 'manager': + return _k('role_manager'); + case 'siteManager': + return _k('role_site_manager'); + case 'personal': + return _k('role_personal'); + default: + // user 在归属操作里与 personal 同口径:只能是自己 + return _k('role_user'); + } + } + + String _scopeLabelKey(UnbindScope scope) { + switch (scope) { + case UnbindScope.user: + return _k('scope_user'); + case UnbindScope.site: + return _k('scope_site'); + case UnbindScope.org: + return _k('scope_org'); + } + } + + /// 解绑三个选项的文案 key(个人 / 场站 / 组织,比「负责人」更口语) + String _scopeOptionKey(UnbindScope scope) { + switch (scope) { + case UnbindScope.user: + return _k('scope_opt_user'); + case UnbindScope.site: + return _k('scope_opt_site'); + case UnbindScope.org: + return _k('scope_opt_org'); + } + } + + String? _nameOf(List list, int? id) { + if (id == null) return null; + for (final item in list) { + if (item.id == id) return item.name as String; + } + return null; + } + + // ===== 可点选 KV 行:可编辑时蓝字 + 箭头,锁定时灰字 + 锁图标 ===== + Widget _buildPickerRow({ + required String label, + required String? value, + required String placeholder, + required bool enabled, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: enabled ? onTap : null, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.only(top: 14, bottom: 14), + child: Row( + children: [ + Text(label, style: const TextStyle(fontSize: 13.5, color: _hint)), + const SizedBox(width: 12), + Expanded( + child: Text( + value ?? placeholder, + textAlign: TextAlign.right, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.5, + fontWeight: FontWeight.w700, + color: value == null + ? _hint + : enabled + ? _blue + : _navy, + ), + ), + ), + const SizedBox(width: 4), + Icon( + enabled + ? Icons.chevron_right_rounded + : Icons.lock_outline_rounded, + size: enabled ? 20 : 15, + color: enabled ? const Color(0xFFC9CDD4) : _hint, + ), + ], + ), + ), + ); + } + + // ===== 结果提示条:把内部层级翻译成用户能看懂的一句话 ===== + Widget _buildPreviewBar(DeviceOwnershipActionState state) { + final l10n = AppLocalizations.of(context); + // 解绑用角色钳制后的范围,与真正下发的 type 保持一致; + // 转交没有 type 概念,直接按已选到的最深一级展示挂靠点。 + final scope = _isTransfer ? state.scopePreview : _cubit.effectiveScope; + final path = state.pathTextUpTo(scope); + // 转交用层级名(组织/场站/负责人),解绑用选项名(个人/场站/组织) + final scopeText = l10n.translate( + _isTransfer ? _scopeLabelKey(scope) : _scopeOptionKey(scope), + ); + + final prefix = l10n.translate( + _k(_isTransfer ? 'preview_transfer' : 'preview_unbind'), + ); + final text = path.isEmpty ? '$prefix $scopeText' : '$prefix $scopeText · $path'; + + final accent = _isTransfer ? _blue : _danger; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _cardBorder), + ), + child: Row( + children: [ + Icon( + _isTransfer ? Icons.link_rounded : Icons.link_off_rounded, + size: 16, + color: accent, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + height: 1.5, + fontWeight: FontWeight.w600, + color: _navy, + ), + ), + ), + ], + ), + ); + } + + Widget _buildFooterNote(AppLocalizations l10n) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), + decoration: BoxDecoration( + color: _softBlue, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.verified_user_rounded, size: 16, color: _blue), + const SizedBox(width: 8), + Expanded( + child: Text( + l10n.translate( + _k(_isTransfer ? 'footer_transfer' : 'footer_unbind'), + ), + style: const TextStyle( + fontSize: 11.5, + height: 1.55, + color: Color(0xFF5C6B84), + ), + ), + ), + ], + ), + ); + } + + // ===== 底部主按钮:转交蓝色、解绑红色 ===== + Widget _buildSubmitButton(DeviceOwnershipActionState state) { + final l10n = AppLocalizations.of(context); + final orgOk = (state.selectedOrgId ?? 0) > 0; + + final bool enabled; + if (_isTransfer) { + // 转交:组织必填(负责人可不选,表示只挂到组织或场站级) + enabled = orgOk && !state.isSubmitting; + } else { + // 解绑:默认已选最轻的「个人」,只要不在提交中就能点 + enabled = !state.isSubmitting; + } + + final gradient = _isTransfer ? _primaryGradient : _dangerGradient; + final accent = _isTransfer ? _blue : _danger; + + return Opacity( + opacity: enabled ? 1 : 0.55, + child: Container( + height: 52, + decoration: BoxDecoration( + gradient: gradient, + borderRadius: BorderRadius.circular(14), + boxShadow: enabled + ? [ + BoxShadow( + color: accent.withValues(alpha: 0.28), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ] + : null, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: enabled ? () => _handleSubmit(state) : null, + borderRadius: BorderRadius.circular(14), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: state.isSubmitting + ? const [ + SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ), + ] + : [ + Icon( + _isTransfer + ? Icons.link_rounded + : Icons.link_off_rounded, + color: Colors.white, + size: 20, + ), + const SizedBox(width: 10), + Text( + l10n.translate( + _k(_isTransfer ? 'submit_transfer' : 'submit_unbind'), + ), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ), + ); + } + + Future _handleSubmit(DeviceOwnershipActionState state) async { + final l10n = AppLocalizations.of(context); + + final success = await _cubit.submit(deviceIds: _targetDeviceIds()); + if (!success || !mounted) return; + + CapsuleToast.show( + l10n.translate(_k(_isTransfer ? 'toast_transfer' : 'toast_unbind')), + ); + Navigator.of(context).pop(true); + } + + // ===== 层级选择器:白卡列表 + 勾选,视觉对齐归属页 ===== + Future _pickOrg(DeviceOwnershipActionState state) async { + final picked = await _showPicker( + title: AppLocalizations.of(context).translate(_k('scope_org')), + items: [for (final e in state.orgList) (id: e.id, name: e.name)], + selectedId: state.selectedOrgId, + allowClear: false, + ); + if (picked != null) await _cubit.onOrgChanged(picked); + } + + Future _pickSite(DeviceOwnershipActionState state) async { + final picked = await _showPicker( + title: AppLocalizations.of(context).translate(_k('scope_site')), + items: [for (final e in state.siteList) (id: e.id, name: e.name)], + selectedId: state.selectedSiteId, + allowClear: _cubit.canClearSite, + ); + if (picked == null) return; + if (picked == _clearValue) { + await _cubit.onSiteChanged(null); + return; + } + await _cubit.onSiteChanged(picked); + } + + Future _pickUser(DeviceOwnershipActionState state) async { + final picked = await _showPicker( + title: AppLocalizations.of(context).translate(_k('scope_user')), + items: [for (final e in state.userList) (id: e.id, name: e.name)], + selectedId: state.selectedUserId, + allowClear: _cubit.canClearUser, + ); + if (picked == null) return; + if (picked == _clearValue) { + _cubit.onUserChanged(null); + return; + } + _cubit.onUserChanged(picked); + } + + /// 选择器里「不指定」的哨兵值:把该级清空,让归属层级上移到上一级 + static const int _clearValue = -1; + + Future _showPicker({ + required String title, + required List<({int id, String name})> items, + required int? selectedId, + required bool allowClear, + }) { + final l10n = AppLocalizations.of(context); + final rows = <({int id, String name})>[ + if (allowClear) + ( + id: _clearValue, + name: l10n.translate( + _k(_isTransfer ? 'picker_clear_transfer' : 'picker_clear_unbind'), + ), + ), + ...items, + ]; + + return showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (sheetCtx) => Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(sheetCtx).size.height * 0.6, + ), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 12, 8), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w800, + color: _navy, + ), + ), + ), + GestureDetector( + onTap: () => Navigator.pop(sheetCtx), + behavior: HitTestBehavior.opaque, + child: const Padding( + padding: EdgeInsets.all(4), + child: Icon(Icons.close_rounded, size: 20, color: _gray), + ), + ), + ], + ), + ), + const Divider(height: 1, thickness: 0.6, color: _divider), + if (rows.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Text( + l10n.translate(_k('picker_empty')), + style: const TextStyle(fontSize: 13.5, color: _hint), + ), + ) + else + Flexible( + child: ListView.separated( + shrinkWrap: true, + itemCount: rows.length, + separatorBuilder: (_, _) => const Divider( + height: 1, + thickness: 0.6, + indent: 20, + endIndent: 20, + color: _divider, + ), + itemBuilder: (_, index) { + final item = rows[index]; + final isClear = item.id == _clearValue; + final selected = item.id == selectedId; + return InkWell( + onTap: () => Navigator.pop(sheetCtx, item.id), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 15, + ), + child: Row( + children: [ + Expanded( + child: Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: selected + ? FontWeight.w700 + : FontWeight.w500, + color: isClear + ? _gray + : selected + ? _blue + : _navy, + ), + ), + ), + if (selected) + const Icon( + Icons.check_rounded, + size: 20, + color: _blue, + ), + ], + ), + ), + ); + }, + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } +} diff --git a/lib/features/v2/device_list/presentation/widgets/bluetooth_scan_modal.dart b/lib/features/v2/device_list/presentation/widgets/bluetooth_scan_modal.dart index a4858dfe..83c22cd2 100644 --- a/lib/features/v2/device_list/presentation/widgets/bluetooth_scan_modal.dart +++ b/lib/features/v2/device_list/presentation/widgets/bluetooth_scan_modal.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../../../../core/bluetooth/ble_manager.dart'; +import '../../../../../core/localization/app_localizations.dart'; import '../pages/ble_device_detail_page.dart'; class BluetoothScanModal extends StatefulWidget { @@ -203,15 +204,21 @@ class _BluetoothScanModalState extends State { ? currentConnected.platformName : '${currentConnected.remoteId}'; if (!mounted) return; + final l10n = AppLocalizations.of(context); showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('已有设备连接'), - content: Text('当前已连接设备:$connName\n\n请先断开当前设备后再连接新设备。'), + title: Text(l10n.translate('ble_v2.already_connected_title')), + content: Text( + l10n.translate('ble_v2.already_connected_msg').replaceAll( + '%s', + connName, + ), + ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('知道了'), + child: Text(l10n.translate('ble_v2.got_it')), ), ], ), @@ -258,7 +265,14 @@ class _BluetoothScanModalState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - '已连接 ${result.device.platformName.isEmpty ? result.device.remoteId : result.device.platformName}', + AppLocalizations.of(context) + .translate('ble_v2.connected_toast') + .replaceAll( + '%s', + result.device.platformName.isEmpty + ? result.device.remoteId.toString() + : result.device.platformName, + ), ), ), ); @@ -339,6 +353,7 @@ class _BluetoothScanModalState extends State { } Widget _buildHeader(BuildContext context) { + final l10n = AppLocalizations.of(context); return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: const BoxDecoration( @@ -355,9 +370,9 @@ class _BluetoothScanModalState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - '蓝牙设备', - style: TextStyle( + Text( + l10n.translate('ble_v2.title'), + style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, @@ -366,10 +381,15 @@ class _BluetoothScanModalState extends State { const SizedBox(height: 4), Text( _connectedDevice != null - ? '当前已连接: ${_connectedDevice!.platformName.isEmpty ? _connectedDevice!.remoteId : _connectedDevice!.platformName}' + ? l10n.translate('ble_v2.connected_current').replaceAll( + '%s', + _connectedDevice!.platformName.isEmpty + ? _connectedDevice!.remoteId.toString() + : _connectedDevice!.platformName, + ) : _isBluetoothOn - ? '正在扫描周边设备...' - : '请先打开蓝牙', + ? l10n.translate('ble_v2.scanning_nearby') + : l10n.translate('ble_v2.turn_on_bt_first'), style: const TextStyle(color: Colors.white70, fontSize: 12), ), ], @@ -392,6 +412,7 @@ class _BluetoothScanModalState extends State { } Widget _buildStatusCard() { + final l10n = AppLocalizations.of(context); final isOn = _isBluetoothOn; final hasConnected = _connectedDevice != null; return Container( @@ -416,7 +437,9 @@ class _BluetoothScanModalState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - isOn ? '蓝牙已开启' : '蓝牙未打开', + isOn + ? l10n.translate('ble_v2.bt_on') + : l10n.translate('ble_v2.bt_off'), style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, @@ -426,10 +449,10 @@ class _BluetoothScanModalState extends State { const SizedBox(height: 4), Text( hasConnected - ? '已连接设备' + ? l10n.translate('ble_v2.connected_device') : isOn - ? '正在扫描周边设备' - : '请打开蓝牙以扫描附近设备', + ? l10n.translate('ble_v2.scanning_nearby_short') + : l10n.translate('ble_v2.turn_on_bt_to_scan'), style: const TextStyle( fontSize: 12, color: Color(0xFF86909C), @@ -458,9 +481,9 @@ class _BluetoothScanModalState extends State { color: const Color(0xFF165DFF), borderRadius: BorderRadius.circular(6), ), - child: const Text( - '去打开', - style: TextStyle( + child: Text( + l10n.translate('ble_v2.go_turn_on'), + style: const TextStyle( color: Colors.white, fontSize: 13, fontWeight: FontWeight.w500, @@ -474,6 +497,7 @@ class _BluetoothScanModalState extends State { } Widget _buildDeviceList() { + final l10n = AppLocalizations.of(context); // 过滤掉已连接设备(它已在状态卡片下方单独显示) var otherResults = _connectedDevice != null ? _scanResults @@ -494,9 +518,9 @@ class _BluetoothScanModalState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - '附近设备', - style: TextStyle( + Text( + l10n.translate('ble_v2.nearby_devices'), + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1D2129), @@ -520,15 +544,15 @@ class _BluetoothScanModalState extends State { onChanged: (value) { setState(() => _searchQuery = value); }, - decoration: const InputDecoration( - hintText: '搜索设备名称或MAC', - hintStyle: TextStyle( + decoration: InputDecoration( + hintText: l10n.translate('ble_v2.search_hint'), + hintStyle: const TextStyle( fontSize: 13, color: Color(0xFF86909C), ), border: InputBorder.none, isDense: true, - contentPadding: EdgeInsets.symmetric(vertical: 10), + contentPadding: const EdgeInsets.symmetric(vertical: 10), ), style: const TextStyle( fontSize: 13, @@ -551,20 +575,20 @@ class _BluetoothScanModalState extends State { ), ), if (!_isBluetoothOn) - const Padding( - padding: EdgeInsets.symmetric(vertical: 24), + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), child: Center( child: Column( children: [ - Icon( + const Icon( Icons.bluetooth_disabled, size: 48, color: Color(0xFF86909C), ), - SizedBox(height: 12), + const SizedBox(height: 12), Text( - '蓝牙未开启,无法扫描设备', - style: TextStyle(color: Color(0xFF86909C)), + l10n.translate('ble_v2.bt_off_cannot_scan'), + style: const TextStyle(color: Color(0xFF86909C)), ), ], ), @@ -583,7 +607,9 @@ class _BluetoothScanModalState extends State { ), const SizedBox(height: 12), Text( - _searchQuery.isNotEmpty ? '未找到匹配的设备' : '正在扫描附近设备...', + _searchQuery.isNotEmpty + ? l10n.translate('ble_v2.no_match') + : l10n.translate('ble_v2.scanning_dots'), style: const TextStyle(color: Color(0xFF86909C)), ), ], @@ -601,6 +627,7 @@ class _BluetoothScanModalState extends State { /// 已连接设备卡片(始终显示在状态卡片下方,不依赖扫描结果) Widget _buildConnectedDeviceCard() { + final l10n = AppLocalizations.of(context); final device = _connectedDevice!; final name = device.platformName.isNotEmpty ? device.platformName @@ -663,9 +690,12 @@ class _BluetoothScanModalState extends State { color: const Color(0xFF00B42A), borderRadius: BorderRadius.circular(4), ), - child: const Text( - '已连接', - style: TextStyle(fontSize: 10, color: Colors.white), + child: Text( + l10n.translate('ble_v2.connected'), + style: const TextStyle( + fontSize: 10, + color: Colors.white, + ), ), ), ], @@ -692,9 +722,9 @@ class _BluetoothScanModalState extends State { color: const Color(0xFF00B42A), borderRadius: BorderRadius.circular(6), ), - child: const Text( - '断开', - style: TextStyle( + child: Text( + l10n.translate('ble_v2.disconnect'), + style: const TextStyle( color: Colors.white, fontSize: 13, fontWeight: FontWeight.w500, @@ -709,6 +739,7 @@ class _BluetoothScanModalState extends State { } Widget _buildDeviceItem(ScanResult result) { + final l10n = AppLocalizations.of(context); final device = result.device; final advData = result.advertisementData; // 优先使用广告数据中的名称(含扫描响应),手机蓝牙列表也是这样显示的 @@ -808,7 +839,9 @@ class _BluetoothScanModalState extends State { ), ) : Text( - isConnected ? '已连接' : '连接', + isConnected + ? l10n.translate('ble_v2.connected') + : l10n.translate('ble_v2.connect'), style: const TextStyle( color: Colors.white, fontSize: 13, @@ -830,14 +863,17 @@ class _BluetoothScanModalState extends State { color: const Color(0xFFFFF7E6), borderRadius: BorderRadius.circular(8), ), - child: const Row( + child: Row( children: [ - Icon(Icons.info_outline, color: Color(0xFFFF7D00), size: 16), - SizedBox(width: 8), + const Icon(Icons.info_outline, color: Color(0xFFFF7D00), size: 16), + const SizedBox(width: 8), Expanded( child: Text( - '请选择现场本机设备,避免连接无关设备', - style: TextStyle(fontSize: 12, color: Color(0xFFFF7D00)), + AppLocalizations.of(context).translate('ble_v2.tip'), + style: const TextStyle( + fontSize: 12, + color: Color(0xFFFF7D00), + ), ), ), ], diff --git a/lib/features/v2/device_list/presentation/widgets/device_card_v2.dart b/lib/features/v2/device_list/presentation/widgets/device_card_v2.dart index a29e5c87..4f3c49c3 100644 --- a/lib/features/v2/device_list/presentation/widgets/device_card_v2.dart +++ b/lib/features/v2/device_list/presentation/widgets/device_card_v2.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:maibu_satabot_v2/components/capsule_toast.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; /// 设备状态 V2 —— 卡片视图设计稿组件。 /// @@ -16,23 +17,36 @@ enum DeviceV2Status { working, online, offline } class DeviceV2Display { DeviceV2Display._(); - static const _editions = ['PLUS 智能版', 'STD 标准版', 'PRO 旗舰版']; + /// 版本代码:plus / std / pro,文案由调用方取 device_list_v2.edition_ + static const _editionCodes = ['plus', 'std', 'pro']; static int _seed(String key) => key.hashCode & 0x7FFFFFFF; /// 版本后缀:PLUS 智能版 / STD 标准版 / PRO 旗舰版 - static String editionOf(String key) => - _editions[_seed(key) % _editions.length]; + /// + /// 列表卡已改用后端 productModel 真值(RobotDataModel.modelLabel), + /// 此处仅详情页/全功能页临时兜底,待这两处也接入机型字段后删除。 + static String editionOf(String key, AppLocalizations l10n) { + final code = _editionCodes[_seed(key) % _editionCodes.length]; + return l10n.translate('device_list_v2.edition_$code'); + } - /// 版本占位:v2.x.y(后端暂无版本字段,集中概览表格用) - static String versionOf(String key) => - 'v2.${_seed(key) % 4}.${_seed(key) ~/ 4 % 10}'; + // Deleted:static String versionOf(String key) => + // Deleted: 'v2.${_seed(key) % 4}.${_seed(key) ~/ 4 % 10}'; - static const _locations = ['1号厂房', '2号厂房', '原料区', '综合楼']; + /// 位置代码:plant_1 / plant_2 / raw_material / office_building + static const _locationCodes = [ + 'plant_1', + 'plant_2', + 'raw_material', + 'office_building', + ]; /// 位置占位:后端暂无位置字段,集中概览表格用 - static String locationOf(String key) => - _locations[_seed(key) % _locations.length]; + static String locationOf(String key, AppLocalizations l10n) { + final code = _locationCodes[_seed(key) % _locationCodes.length]; + return l10n.translate('device_list_v2.location_$code'); + } /// SN 掩码展示:前4 · · · 后2 static String maskedSn(String key) { @@ -42,11 +56,15 @@ class DeviceV2Display { } /// 底部作业态文案 - static String workTextOf(DeviceV2Status status) { + static String workTextOf(AppLocalizations l10n, DeviceV2Status status) { return switch (status) { - DeviceV2Status.working => '自主作业中', - DeviceV2Status.online => '待机', - DeviceV2Status.offline => '暂时无法连接', + DeviceV2Status.working => l10n.translate( + 'device_list_v2.work_text_working', + ), + DeviceV2Status.online => l10n.translate('device_list_v2.work_text_online'), + DeviceV2Status.offline => l10n.translate( + 'device_list_v2.work_text_offline', + ), }; } } @@ -102,6 +120,7 @@ class DeviceCardV2 extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); final offline = status == DeviceV2Status.offline; final battery = offline ? null : batteryLevel; @@ -147,15 +166,19 @@ class DeviceCardV2 extends StatelessWidget { ), ), // 编辑(改名):默认未开发,由外部注入(如修改设备名称弹窗) + // 热区撑到 31x31:原来只包 15px 图标,手指经常点不中 GestureDetector( onTap: onRename ?? () => CapsuleToast.show( - '功能暂未开发', + l10n.translate( + 'device_list_v2.feature_todo', + ), showCheck: false, ), + behavior: HitTestBehavior.opaque, child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 6), + padding: EdgeInsets.fromLTRB(9, 8, 9, 8), child: Icon( Icons.edit_outlined, size: 15, @@ -163,7 +186,7 @@ class DeviceCardV2 extends StatelessWidget { ), ), ), - _buildBadge(), + _buildBadge(l10n), ], ), const SizedBox(height: 6), @@ -191,7 +214,9 @@ class DeviceCardV2 extends StatelessWidget { await Clipboard.setData( ClipboardData(text: snKey), ); - CapsuleToast.show('SN 已复制到剪贴板'); + CapsuleToast.show( + l10n.translate('device_list_v2.sn_copied'), + ); }, child: const Padding( padding: EdgeInsets.symmetric(horizontal: 6), @@ -213,7 +238,10 @@ class DeviceCardV2 extends StatelessWidget { GestureDetector( onTap: onMore ?? - () => CapsuleToast.show('功能暂未开发', showCheck: false), + () => CapsuleToast.show( + l10n.translate('device_list_v2.feature_todo'), + showCheck: false, + ), child: Container( width: 32, height: 32, @@ -251,7 +279,7 @@ class DeviceCardV2 extends StatelessWidget { ), const SizedBox(width: 6), Text( - DeviceV2Display.workTextOf(status), + DeviceV2Display.workTextOf(l10n, status), style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, @@ -308,11 +336,23 @@ class DeviceCardV2 extends StatelessWidget { } /// 状态徽章:作业中(橙) / 在线(绿) / 离线(灰) - Widget _buildBadge() { + Widget _buildBadge(AppLocalizations l10n) { final (color, bg, label) = switch (status) { - DeviceV2Status.working => (_workingOrange, _workingBg, '作业中'), - DeviceV2Status.online => (_onlineGreen, _onlineBg, '在线'), - DeviceV2Status.offline => (_offlineGray, _offlineBg, '离线'), + DeviceV2Status.working => ( + _workingOrange, + _workingBg, + l10n.translate('device_list_v2.stat_working'), + ), + DeviceV2Status.online => ( + _onlineGreen, + _onlineBg, + l10n.translate('device_list_v2.online'), + ), + DeviceV2Status.offline => ( + _offlineGray, + _offlineBg, + l10n.translate('device_list_v2.offline'), + ), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), diff --git a/lib/features/v2/device_list/presentation/widgets/device_confirm_dialog.dart b/lib/features/v2/device_list/presentation/widgets/device_confirm_dialog.dart index dba10d7a..ecd134be 100644 --- a/lib/features/v2/device_list/presentation/widgets/device_confirm_dialog.dart +++ b/lib/features/v2/device_list/presentation/widgets/device_confirm_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; /// 通用设备操作确认弹窗(设计稿:远程开机确认 / 关机确认 / 急停确认等) /// @@ -21,10 +22,11 @@ Future showDeviceConfirmDialog( required String title, required String message, String? note, - String confirmText = '确认', - String cancelText = '取消', + String? confirmText, + String? cancelText, bool danger = false, }) async { + final l10n = AppLocalizations.of(context); final result = await showDialog( context: context, barrierColor: const Color(0x8C0B1B2B), @@ -34,8 +36,9 @@ Future showDeviceConfirmDialog( title: title, message: message, note: note, - confirmText: confirmText, - cancelText: cancelText, + confirmText: + confirmText ?? l10n.translate('device_list_v2.action_confirm'), + cancelText: cancelText ?? l10n.translate('device_list_v2.action_cancel'), danger: danger, ); }, diff --git a/lib/features/v2/device_list/presentation/widgets/device_item_widget.dart b/lib/features/v2/device_list/presentation/widgets/device_item_widget.dart index 8d2f780d..6ce93f02 100644 --- a/lib/features/v2/device_list/presentation/widgets/device_item_widget.dart +++ b/lib/features/v2/device_list/presentation/widgets/device_item_widget.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../domain/entities/device_entity.dart'; +import '../../domain/entities/device_model_info.dart'; import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import 'package:maibu_satabot_v2/core/theme/AppTheme.dart'; @@ -14,6 +15,13 @@ class DeviceItemWidget extends StatelessWidget { required this.onTap, }); + /// 型号文案:productModel 解析优先(MC700 · PRO 旗舰版), + /// productModel 为 null / 空串时统一显示「未知」 + String _modelTextOf(BuildContext context) => + DeviceModelInfo.parse(device.productModel).labelOf( + AppLocalizations.of(context), + ); + @override Widget build(BuildContext context) { return GestureDetector( @@ -64,6 +72,16 @@ class DeviceItemWidget extends StatelessWidget { color: context.appColors.textTertiary, ), ), + const SizedBox(height: 2), + Text( + _modelTextOf(context), + style: TextStyle( + fontSize: 12, + color: context.appColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ], ), ), diff --git a/packages/cc_ui_kit/lib/src/cc_joystick.dart b/packages/cc_ui_kit/lib/src/cc_joystick.dart index e7330710..efe3e075 100644 --- a/packages/cc_ui_kit/lib/src/cc_joystick.dart +++ b/packages/cc_ui_kit/lib/src/cc_joystick.dart @@ -126,13 +126,16 @@ class _CustomJoystickState extends State @override Widget build(BuildContext context) { return Listener( - // 🔥 兜底:监听全局指针抬起事件,防止 onPanEnd 丢失 + // 原始触点结束时立即停车;多指情况下 Pan 手势可能仍会继续回调。 onPointerUp: (_) async { if (_isPanning) { debugPrint('🛡️ [兜底] 全局指针抬起,强制归零'); await _handlePanEnd(); } }, + onPointerCancel: (_) async { + await _handlePanEnd(); + }, child: GestureDetector( behavior: HitTestBehavior.opaque, onPanStart: (_) { @@ -140,6 +143,10 @@ class _CustomJoystickState extends State widget.onPress?.call(); }, onPanUpdate: (details) { + // 另一根手指抬起已触发停车时,识别器可能继续派发剩余手指的移动。 + // 必须等下一次 onPanStart 才接受输入,否则非零值会覆盖停车结果, + // 而最终 onPanEnd 因 _isPanning=false 跳过停车,控制循环就会一直发旧值。 + if (!_isPanning) return; setState(() { Offset newOffset = _offset + details.delta; double distance = newOffset.distance; diff --git a/test/capsule_toast_test.dart b/test/capsule_toast_test.dart new file mode 100644 index 00000000..6916945e --- /dev/null +++ b/test/capsule_toast_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:maibu_satabot_v2/components/capsule_toast.dart'; + +void main() { + testWidgets( + '旧提示的自动消失定时器不能误杀后弹出的新提示', + (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: Text('page'))), + ); + + // 第一条:显示 2 秒 + CapsuleToast.show('A-第一条', duration: const Duration(seconds: 2)); + await tester.pumpAndSettle(); // postFrame 插入 + Overlay 重建 + expect(find.text('A-第一条'), findsOne); + + // 1 秒后第二条插入(模拟用户连点触发的新 toast) + await tester.pump(const Duration(seconds: 1)); + CapsuleToast.show('B-第二条', duration: const Duration(seconds: 2)); + await tester.pumpAndSettle(); + expect(find.text('B-第二条'), findsOne); + expect(find.text('A-第一条'), findsNothing); + + // 到点:第一条的 2 秒定时器到期,第二条只应已显示 1 秒,不能被移除 + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + expect(find.text('B-第二条'), findsOne); + + // 第二条自己的 2 秒到点后才消失 + await tester.pump(const Duration(milliseconds: 1100)); + await tester.pumpAndSettle(); + expect(find.text('B-第二条'), findsNothing); + }, + ); +} diff --git a/test/cc_joystick_safety_test.dart b/test/cc_joystick_safety_test.dart new file mode 100644 index 00000000..12479c4e --- /dev/null +++ b/test/cc_joystick_safety_test.dart @@ -0,0 +1,79 @@ +import 'package:cc_ui_kit/cc_ui_kit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + for (final axis in AxisHint.values) { + for (final cancel in [false, true]) { + testWidgets( + '$axis extra finger ${cancel ? 'cancel' : 'release'} stays stopped', + (tester) async { + var drive = 0; + var stops = 0; + await tester.pumpWidget( + MaterialApp( + home: Center( + child: CCJoystick( + axisHint: axis, + onValueChanged: (value) => drive = + axis == AxisHint.forwardBackward ? value.y : value.x, + onPanEnd: () async { + drive = 0; + stops++; + }, + ), + ), + ), + ); + final center = tester.getCenter(find.byType(CCJoystick)); + final direction = axis == AxisHint.forwardBackward + ? const Offset(0, -1) + : const Offset(1, 0); + final first = await tester.startGesture(center, pointer: 1); + await first.moveBy(direction * 30); + await first.moveBy(direction * 20); + await tester.pump(); + expect(drive, greaterThan(0)); + + final second = await tester.startGesture(center, pointer: 2); + if (cancel) { + await second.cancel(); + } else { + await second.up(); + } + await tester.pump(); + expect(drive, 0); + await first.moveBy(direction * 20); + await tester.pump(); + expect( + drive, + 0, + reason: 'ended interaction must ignore remaining moves', + ); + await first.up(); + await tester.pump(const Duration(seconds: 1)); + + expect(stops, greaterThan(0)); + expect( + drive, + 0, + reason: 'released joystick must leave zero for the timer', + ); + expect(stops, 1, reason: 'pointer and pan end must not stop twice'); + + // A new gesture must still work after the interrupted interaction. + final next = await tester.startGesture(center, pointer: 3); + await next.moveBy(direction * 30); + await next.moveBy(direction * 20); + await tester.pump(); + expect(drive, greaterThan(0)); + await next.up(); + await tester.pump(const Duration(seconds: 1)); + expect(drive, 0); + expect(stops, 2); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + } + } +} diff --git a/test/control_mode_entry_widget_test.dart b/test/control_mode_entry_widget_test.dart new file mode 100644 index 00000000..e4b07841 --- /dev/null +++ b/test/control_mode_entry_widget_test.dart @@ -0,0 +1,254 @@ +import 'dart:async'; +import 'package:fpdart/fpdart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:maibu_satabot_v2/core/di/injection.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; +import 'package:maibu_satabot_v2/features/device_control_mode/domain/entities/device_control_mode.dart'; +import 'package:maibu_satabot_v2/features/device_control_mode/domain/usecases/device_control_mode_usecases.dart'; +import 'package:maibu_satabot_v2/features/device_control_mode/presentation/cubit/control_mode_entry_cubit.dart'; +import 'package:maibu_satabot_v2/features/device_control_mode/presentation/widgets/control_mode_entry_gate.dart'; +import 'support/control_mode_fakes.dart'; + +final _translations = {}; + +class _LoadedTranslations extends LocalizationsDelegate { + const _LoadedTranslations(); + @override + bool isSupported(Locale locale) => + _translations.containsKey(locale.toString()); + @override + Future load(Locale locale) => + SynchronousFuture(_translations[locale.toString()]!); + @override + bool shouldReload(_LoadedTranslations old) => false; +} + +Widget app(Widget home, {Locale locale = const Locale('zh', 'CN')}) => + MaterialApp( + locale: locale, + supportedLocales: const [Locale('zh', 'CN'), Locale('en', 'US')], + localizationsDelegates: const [ + _LoadedTranslations(), + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: home, + ); + +Future openDialog( + WidgetTester tester, + ControlModeEntryCubit cubit, + ValueChanged result, { + Locale locale = const Locale('zh', 'CN'), + Size size = const Size(800, 360), +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + app( + Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () async => result( + await showControlModeEntryDialog( + context, + deviceId: cubit.deviceId, + target: cubit.target, + deviceName: '测试车', + cubit: cubit, + ), + ), + child: const Text('open'), + ), + ), + ), + locale: locale, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('open')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() async { + // 真实资产I/O在测试假时钟外完成,弹窗测试使用同一套真实中英文文案。 + for (final locale in [const Locale('zh', 'CN'), const Locale('en', 'US')]) { + final translations = AppLocalizations(locale); + await translations.load(); + _translations[locale.toString()] = translations; + await GlobalMaterialLocalizations.delegate.load(locale); + await GlobalCupertinoLocalizations.delegate.load(locale); + } + }); + late FakeModes repo; + late ControlModeEntryCubit cubit; + setUp(() { + repo = FakeModes(); + cubit = modeCubit(repo); + }); + tearDown(() async { + if (!cubit.isClosed) await cubit.close(); + await repo.reports.close(); + await sl.reset(); + }); + + testWidgets('进入弹窗显示实时模式,已匹配时确定仅进入、不调用切换', (tester) async { + repo.initial = '2'; + bool? result; + await openDialog(tester, cubit, (value) => result = value); + expect(find.text('控制模式确认'), findsOneWidget); + expect(find.textContaining('当前实时模式:自主任务'), findsOneWidget); + expect(repo.requests, isEmpty); + expect(result, isNull); + await tester.tap(find.text('确定')); + await tester.pumpAndSettle(); + expect(result, isTrue); + expect(repo.requests, isEmpty); + expect(cubit.isClosed, isTrue); + }); + + testWidgets('确定才下发,接口成功立即关闭,不等模式回推', (tester) async { + repo.initial = '3'; + repo.pending = Completer(); + bool? result; + await openDialog(tester, cubit, (value) => result = value); + expect(repo.requests, isEmpty); + await tester.tap(find.text('确定')); + await tester.pump(); + expect(repo.requests.single, ('SN002', ControlModeTarget.autonomous)); + expect(result, isNull); + repo.pending!.complete(const Right(unit)); + await tester.pumpAndSettle(); + expect(result, isTrue); + expect(cubit.state.reading!.value, 3); + }); + + testWidgets('返回不会下发切换,关闭后后续实时数据不会触发进入', (tester) async { + repo.initial = '1'; + bool? result; + await openDialog(tester, cubit, (value) => result = value); + await tester.tap(find.text('返回')); + await tester.pumpAndSettle(); + repo.report('2'); + await tester.pump(); + expect(result, isFalse); + expect(repo.requests, isEmpty); + expect(cubit.isClosed, isTrue); + }); + + testWidgets('无模式数据不转圈且确定可点击,成功后直接进入', (tester) async { + bool? result; + await openDialog(tester, cubit, (value) => result = value); + expect(find.byType(LinearProgressIndicator), findsNothing); + expect(find.textContaining('暂未收到当前模式'), findsOneWidget); + expect( + tester.widget(find.byType(FilledButton)).onPressed, + isNotNull, + ); + await tester.tap(find.text('确定')); + await tester.pumpAndSettle(); + expect(result, isTrue); + expect(repo.requests.single, ('SN002', ControlModeTarget.autonomous)); + }); + + testWidgets('英文小横屏可以滚动查看内容且无布局溢出', (tester) async { + repo.initial = '1'; + await openDialog( + tester, + cubit, + (_) {}, + locale: const Locale('en', 'US'), + size: const Size(560, 280), + ); + expect(find.text('Confirm control mode'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Back')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets('遥控入口只在模式确认后激活一次,确认前页面动作被阻止', (tester) async { + repo.initial = '2'; + repo.pending = Completer(); + sl.registerSingleton(WatchDeviceControlModeUseCase(repo)); + sl.registerSingleton(ChangeDeviceControlModeUseCase(repo)); + var activations = 0; + var drives = 0; + await tester.pumpWidget( + app( + ControlModeEntryGate( + deviceId: 'SN002', + target: ControlModeTarget.remote, + onReady: () => activations++, + child: Scaffold( + body: TextButton( + key: const Key('drive'), + onPressed: () => drives++, + child: const Text('drive'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(activations, 0); + await tester.tap(find.byKey(const Key('drive')), warnIfMissed: false); + expect(drives, 0); + await tester.tap(find.text('确定')); + await tester.pump(); + expect(repo.requests.single, ('SN002', ControlModeTarget.remote)); + expect(activations, 0); + repo.pending!.complete(const Right(unit)); + await tester.pumpAndSettle(); + expect(activations, 1); + expect(drives, 0); + await tester.tap(find.byKey(const Key('drive'))); + expect(drives, 1); + }); + + testWidgets('取消遥控模式确认返回来源页,不激活控制或下发切换', (tester) async { + repo.initial = '2'; + sl.registerSingleton(WatchDeviceControlModeUseCase(repo)); + sl.registerSingleton(ChangeDeviceControlModeUseCase(repo)); + var activations = 0; + await tester.pumpWidget( + app( + Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ControlModeEntryGate( + deviceId: 'SN002', + target: ControlModeTarget.remote, + onReady: () => activations++, + child: const Scaffold(body: Text('remote')), + ), + ), + ), + child: const Text('open remote'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('open remote')); + await tester.pumpAndSettle(); + await tester.tap(find.text('返回')); + await tester.pumpAndSettle(); + expect(find.text('open remote'), findsOneWidget); + expect(find.text('remote'), findsNothing); + expect(activations, 0); + expect(repo.requests, isEmpty); + }); +} diff --git a/test/device_control_mode_test.dart b/test/device_control_mode_test.dart new file mode 100644 index 00000000..f729ad53 --- /dev/null +++ b/test/device_control_mode_test.dart @@ -0,0 +1,251 @@ +import 'dart:async'; +import 'support/control_mode_fakes.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:maibu_satabot_v2/core/di/adapters/device_control_mode_realtime_adapter.dart'; +import 'package:maibu_satabot_v2/core/error/failure.dart'; +import 'package:maibu_satabot_v2/core/logging/i_logger_service.dart'; +import 'package:maibu_satabot_v2/features/device_control_mode/data/datasources/control_mode_remote_datasource.dart'; +import 'package:maibu_satabot_v2/features/device_control_mode/data/repositories/device_control_mode_repository_impl.dart'; +import 'package:maibu_satabot_v2/features/device_control_mode/domain/entities/device_control_mode.dart'; +import 'package:maibu_satabot_v2/features/device_control_mode/domain/usecases/device_control_mode_usecases.dart'; +import 'package:maibu_satabot_v2/features/device_control_mode/presentation/cubit/control_mode_entry_cubit.dart'; +import 'package:maibu_satabot_v2/features/devices/domain/entities/gps_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'; + +class _Logger implements ILoggerService { + final lines = []; + @override + void logWithLevel( + String message, { + String level = 'INFO', + Map? data, + bool shouldLog = false, + }) { + if (shouldLog) lines.add(message); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _StatusBloc extends Cubit implements DeviceStatusBloc { + _StatusBloc(String sn, String raw) + : listeningSn = sn, + super( + DeviceStatusUpdated( + RunningStatusEntity(controlMode: raw), + GPSEntity(0, 0), + ), + ); + String listeningSn; + final starts = []; + @override + String? get listeningDeviceSn => listeningSn; + @override + void startMqttRealtimeListening(String sn) { + starts.add(sn); + if (listeningSn != sn) { + listeningSn = sn; + emit(DeviceStatusInitial()); + } + } + + void report(String raw) => emit( + DeviceStatusUpdated(RunningStatusEntity(controlMode: raw), GPSEntity(0, 0)), + ); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + test('接口按文档GET下发编码和整数模式,数字data=1不做bool强转', () async { + final dio = Dio(); + final requests = []; + final logger = _Logger(); + Map response = {'code': 200, 'msg': '操作成功', 'data': 1}; + dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + requests.add(options); + handler.resolve( + Response(requestOptions: options, statusCode: 200, data: response), + ); + }, + ), + ); + final repo = DeviceControlModeRepositoryImpl( + realtime: (_) => const Stream.empty(), + remote: ControlModeRemoteDatasource(dio, logger), + ); + final change = ChangeDeviceControlModeUseCase(repo); + for (final target in ControlModeTarget.values) { + expect((await change(' SN002 ', target)).isRight(), isTrue); + expect(requests.last.method, 'GET'); + expect(requests.last.uri.path, '/iot/host/modeChage'); + expect(requests.last.queryParameters, { + 'deviceId': 'SN002', + 'controlMode': target.value, + }); + expect(requests.last.data, isNull); + } + response = {'code': 500, 'msg': '设备不在线'}; + final failed = await change('SN002', ControlModeTarget.remote); + expect( + failed.fold((failure) => failure.message, (_) => ''), + contains('设备不在线'), + ); + expect((await change(' ', ControlModeTarget.remote)).isLeft(), isTrue); + expect(requests, hasLength(3)); + expect(logger.lines.any((s) => s.contains('"controlMode":3')), isTrue); + expect(logger.lines.any((s) => s.contains('HTTP 200')), isTrue); + dio.close(); + }); + + test('实时适配器与设备详情共用Bloc,同设备复用快照,切换设备不串模式', () async { + final bloc = _StatusBloc('old', '3'); + final readings = []; + final source = DeviceControlModeRealtimeAdapter(bloc); + var sub = source.watch('new').listen(readings.add); + await flushModes(); + expect(bloc.starts, ['new']); + expect(readings.every((reading) => reading.rawMode == null), isTrue); + bloc.report('2'); + await flushModes(); + expect(readings.last.deviceId, 'new'); + expect(readings.last.value, 2); + await sub.cancel(); + readings.clear(); + sub = source.watch('new').listen(readings.add); + await flushModes(); + expect(readings.last.value, 2); + bloc.startMqttRealtimeListening('other'); + bloc.report('3'); + await flushModes(); + expect(readings.last.rawMode, isNull); + await sub.cancel(); + await bloc.close(); + }); + + group('入口模式确认', () { + late FakeModes repo; + late ControlModeEntryCubit cubit; + setUp(() { + repo = FakeModes(); + cubit = modeCubit(repo); + }); + tearDown(() async { + if (!cubit.isClosed) await cubit.close(); + await repo.reports.close(); + }); + Future start(String? mode) async { + repo.initial = mode; + await cubit.start(); + await flushModes(); + } + + test('无模式上报也允许用户确定下发,不等待topic', () async { + await start(null); + expect(cubit.state.canConfirm, isTrue); + expect(repo.requests, isEmpty); + await cubit.confirm(); + expect(repo.requests.single, ('SN002', ControlModeTarget.autonomous)); + expect(cubit.state.phase, ControlModeEntryPhase.confirmed); + expect(cubit.state.reading?.rawMode, isNull); + }); + test('已匹配时确定直接进入,不重复切换', () async { + await start('2'); + await cubit.confirm(); + expect(cubit.state.phase, ControlModeEntryPhase.confirmed); + expect(repo.requests, isEmpty); + }); + test('文本模式可展示也可确定,不因非整数被禁用', () async { + await start('本地控制'); + expect(cubit.state.reading!.hasReportedMode, isTrue); + expect(cubit.state.canConfirm, isTrue); + await cubit.confirm(); + expect(cubit.state.phase, ControlModeEntryPhase.confirmed); + expect(cubit.state.reading!.rawMode, '本地控制'); + }); + test('接口成功立即进入,保持真实上报值而不伪造目标模式', () async { + await start('1'); + await cubit.confirm(); + expect(cubit.state.phase, ControlModeEntryPhase.confirmed); + expect(cubit.state.reading!.value, 1); + }); + test('发送过程中重复点击只请求一次,成功后无需到达目标模式上报', () async { + await start('1'); + repo.pending = Completer(); + final first = cubit.confirm(); + await cubit.confirm(); + expect(cubit.state.busy, isTrue); + expect(repo.requests, hasLength(1)); + repo.pending!.complete(const Right(unit)); + await first; + expect(cubit.state.phase, ControlModeEntryPhase.confirmed); + }); + test('失败后允许重试,同模式实时帧不抹掉失败信息', () async { + await start('1'); + repo.failure = Failure('设备拒绝切换'); + await cubit.confirm(); + repo.report('1'); + await flushModes(); + expect(cubit.state.error, '设备拒绝切换'); + expect(cubit.state.canConfirm, isTrue); + repo.failure = null; + await cubit.confirm(); + expect(repo.requests, hasLength(2)); + expect(cubit.state.phase, ControlModeEntryPhase.confirmed); + }); + test('关闭后迟到HTTP响应不触发进入', () async { + await start('1'); + repo.pending = Completer(); + final request = cubit.confirm(); + await cubit.close(); + repo.pending!.complete(const Right(unit)); + await request; + expect(cubit.state.phase, ControlModeEntryPhase.submitting); + }); + test('远程入口下发3,接口成功后直接进入', () async { + await cubit.close(); + cubit = modeCubit(repo, target: ControlModeTarget.remote); + await start('2'); + await cubit.confirm(); + expect(repo.requests.single, ('SN002', ControlModeTarget.remote)); + expect(cubit.state.phase, ControlModeEntryPhase.confirmed); + expect(cubit.state.reading!.value, 2); + }); + test('未知或协议外模式仍可手动确定下发', () async { + await start('0'); + repo.report('6'); + await flushModes(); + expect(cubit.state.canConfirm, isTrue); + await cubit.confirm(); + expect(repo.requests.single, ('SN002', ControlModeTarget.autonomous)); + }); + test('其他设备上报不改变本设备模式', () async { + await start('1'); + repo.report('2', deviceId: 'other'); + await flushModes(); + expect(cubit.state.reading!.value, 1); + await cubit.confirm(); + expect(repo.requests, hasLength(1)); + }); + test('请求超时恢复确定按钮,不把迟到响应当成成功', () async { + await cubit.close(); + cubit = modeCubit(repo, timeout: const Duration(milliseconds: 40)); + await start('1'); + repo.pending = Completer(); + await cubit.confirm(); + expect(cubit.state.errorKey, 'request_timeout'); + expect(cubit.state.canConfirm, isTrue); + repo.pending!.complete(const Right(unit)); + await flushModes(); + expect(cubit.state.phase, ControlModeEntryPhase.failed); + }); + }); +} diff --git a/test/support/control_mode_fakes.dart b/test/support/control_mode_fakes.dart new file mode 100644 index 00000000..7bd4e822 --- /dev/null +++ b/test/support/control_mode_fakes.dart @@ -0,0 +1,60 @@ +import 'dart:async'; +import 'package:fpdart/fpdart.dart'; +import 'package:maibu_satabot_v2/core/error/failure.dart'; +import 'package:maibu_satabot_v2/features/device_control_mode/domain/entities/device_control_mode.dart'; +import 'package:maibu_satabot_v2/features/device_control_mode/domain/repositories/device_control_mode_repository.dart'; +import 'package:maibu_satabot_v2/features/device_control_mode/domain/usecases/device_control_mode_usecases.dart'; +import 'package:maibu_satabot_v2/features/device_control_mode/presentation/cubit/control_mode_entry_cubit.dart'; + +class FakeModes implements DeviceControlModeRepository { + final reports = StreamController.broadcast( + sync: true, + ); + final requests = <(String, ControlModeTarget)>[]; + String? initial; + Completer>? pending; + Failure? failure; + @override + Stream watch(String deviceId) { + final cached = initial; + if (cached != null) { + scheduleMicrotask(() { + if (reports.isClosed) return; + reports.add( + DeviceControlModeReading(deviceId: deviceId, rawMode: cached), + ); + }); + } + return reports.stream; + } + + void report(String? mode, {String deviceId = 'SN002'}) => + reports.add(DeviceControlModeReading(deviceId: deviceId, rawMode: mode)); + @override + Future> change( + String deviceId, + ControlModeTarget target, + ) async { + requests.add((deviceId, target)); + return pending?.future ?? + (failure == null ? const Right(unit) : Left(failure!)); + } +} + +Future flushModes() async { + for (var i = 0; i < 4; i++) { + await Future.delayed(Duration.zero); + } +} + +ControlModeEntryCubit modeCubit( + FakeModes repo, { + ControlModeTarget target = ControlModeTarget.autonomous, + Duration timeout = const Duration(seconds: 12), +}) => ControlModeEntryCubit( + deviceId: 'SN002', + target: target, + watchMode: WatchDeviceControlModeUseCase(repo), + changeMode: ChangeDeviceControlModeUseCase(repo), + timeout: timeout, +);