From 8eea42b75dee9c402469d3eaed1d4cc9cedce619 Mon Sep 17 00:00:00 2001 From: Songzex <2402265378@qq.com> Date: Fri, 6 Nov 2026 08:44:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E5=8C=BA=E7=9B=B8=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/pages/register_page.dart | 57 +- .../gateways/path_planning_work_gateway.dart | 586 ++++++++++-------- .../datasources/region_remote_datasource.dart | 117 ++++ .../regions/data/models/region_model.dart | 45 ++ .../repositories/region_repository_impl.dart | 59 ++ .../regions/domain/entities/region_draft.dart | 97 +++ .../repositories/region_repository.dart | 10 + .../domain/usecases/region_usecases.dart | 45 ++ .../cubit/region_editor_cubit.dart | 190 ++++++ .../presentation/cubit/region_list_cubit.dart | 183 ++++++ .../widgets/region_boundary_layer.dart | 98 +++ .../widgets/region_form_dialog.dart | 427 +++++++++++++ .../widgets/region_list_panel.dart | 296 +++++++++ .../remote_control_repository_impl.dart | 120 ++-- .../remote_control_repository.dart | 8 +- .../bloc/remote_control_cubit.dart | 343 +++++++++- .../pages/remote_control_page.dart | 108 ++-- .../pages/qr_scanner_page_v2.dart | 103 +-- .../pages/profile_detail_page.dart | 35 +- test/region_editor_test.dart | 396 ++++++++++++ test/region_list_test.dart | 361 +++++++++++ 21 files changed, 3245 insertions(+), 439 deletions(-) create mode 100644 lib/features/regions/data/datasources/region_remote_datasource.dart create mode 100644 lib/features/regions/data/models/region_model.dart create mode 100644 lib/features/regions/data/repositories/region_repository_impl.dart create mode 100644 lib/features/regions/domain/entities/region_draft.dart create mode 100644 lib/features/regions/domain/repositories/region_repository.dart create mode 100644 lib/features/regions/domain/usecases/region_usecases.dart create mode 100644 lib/features/regions/presentation/cubit/region_editor_cubit.dart create mode 100644 lib/features/regions/presentation/cubit/region_list_cubit.dart create mode 100644 lib/features/regions/presentation/widgets/region_boundary_layer.dart create mode 100644 lib/features/regions/presentation/widgets/region_form_dialog.dart create mode 100644 lib/features/regions/presentation/widgets/region_list_panel.dart create mode 100644 test/region_editor_test.dart create mode 100644 test/region_list_test.dart diff --git a/lib/features/auth/presentation/pages/register_page.dart b/lib/features/auth/presentation/pages/register_page.dart index 1905b9d4..f20a6bf7 100644 --- a/lib/features/auth/presentation/pages/register_page.dart +++ b/lib/features/auth/presentation/pages/register_page.dart @@ -1,6 +1,7 @@ import 'package:cc_ui_kit/cc_ui_kit.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import 'package:maibu_satabot_v2/core/router/route_paths.dart'; import 'package:maibu_satabot_v2/core/theme/AppTheme.dart'; @@ -29,6 +30,7 @@ class _RegisterPageState extends State { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); return Scaffold( backgroundColor: context.appColors.pageBackground, appBar: AppBar( @@ -46,7 +48,7 @@ class _RegisterPageState extends State { children: [ const SizedBox(height: 40), Text( - "账号注册", + l10n.translate('register.title'), style: TextStyle( fontSize: 28, fontWeight: FontWeight.bold, @@ -55,29 +57,31 @@ class _RegisterPageState extends State { ), const SizedBox(height: 12), Text( - "请填写以下信息完成注册", + l10n.translate('register.subtitle'), style: TextStyle(fontSize: 15, color: context.appColors.textTertiary), ), const SizedBox(height: 40), - _buildInputLabel("用户名"), + _buildInputLabel(l10n.translate('register.username')), TextField( controller: _userCtrl, style: TextStyle(fontSize: 14, color: context.appColors.textPrimary), cursorColor: context.appColors.textPrimary, - decoration: _inputDecoration(hint: "请设置用户名"), + decoration: _inputDecoration( + hint: l10n.translate('register.username_hint'), + ), ), const SizedBox(height: 24), - _buildInputLabel("密码"), + _buildInputLabel(l10n.translate('register.password')), TextField( controller: _pwdCtrl, obscureText: _obscurePwd, style: TextStyle(fontSize: 14, color: context.appColors.textPrimary), cursorColor: context.appColors.textPrimary, decoration: _inputDecoration( - hint: "请设置密码", + hint: l10n.translate('register.password_hint'), suffixIcon: IconButton( icon: Icon( _obscurePwd @@ -93,14 +97,14 @@ class _RegisterPageState extends State { const SizedBox(height: 24), - _buildInputLabel("确认密码"), + _buildInputLabel(l10n.translate('register.confirm_password')), TextField( controller: _confirmPwdCtrl, obscureText: _obscureConfirmPwd, style: TextStyle(fontSize: 14, color: context.appColors.textPrimary), cursorColor: context.appColors.textPrimary, decoration: _inputDecoration( - hint: "请再次输入密码", + hint: l10n.translate('register.confirm_password_hint'), suffixIcon: IconButton( icon: Icon( _obscureConfirmPwd @@ -126,7 +130,7 @@ class _RegisterPageState extends State { width: double.infinity, child: CCPrimaryButton( onPressed: _isAgreed ? _handleRegister : null, - text: '注 册', + text: l10n.translate('register.submit'), ), ), @@ -137,13 +141,13 @@ class _RegisterPageState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - "已有账号?", + l10n.translate('register.has_account'), style: TextStyle(fontSize: 14, color: context.appColors.textTertiary), ), GestureDetector( onTap: () => context.go(RoutePaths.login), child: Text( - "立即登录", + l10n.translate('register.login_now'), style: TextStyle( fontSize: 14, color: context.appColors.textPrimary, @@ -163,22 +167,23 @@ class _RegisterPageState extends State { } void _handleRegister() { + final l10n = AppLocalizations.of(context); if (_userCtrl.text.isEmpty || _pwdCtrl.text.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('请填写完整信息')), + SnackBar(content: Text(l10n.translate('register.incomplete'))), ); return; } if (_pwdCtrl.text != _confirmPwdCtrl.text) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('两次输入的密码不一致')), + SnackBar(content: Text(l10n.translate('register.password_mismatch'))), ); return; } ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('注册功能开发中...')), + SnackBar(content: Text(l10n.translate('register.developing'))), ); } @@ -216,6 +221,7 @@ class _RegisterPageState extends State { } Widget _buildProtocolSection() { + final l10n = AppLocalizations.of(context); return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -238,12 +244,21 @@ class _RegisterPageState extends State { child: Wrap( children: [ Text( - "我已阅读并同意 ", + l10n.translate('login.agree_prefix'), style: TextStyle(fontSize: 13, color: context.appColors.textTertiary), ), - _protocolText("《用户协议》", () => _showProtocolDetail("用户协议")), - Text(" 和 ", style: TextStyle(fontSize: 13, color: context.appColors.textTertiary)), - _protocolText("《隐私政策》", () => _showProtocolDetail("隐私政策")), + _protocolText( + l10n.translate('login.user_agreement_link'), + () => _showProtocolDetail(l10n.translate('my.user_agreement')), + ), + Text( + l10n.translate('login.agree_and'), + style: TextStyle(fontSize: 13, color: context.appColors.textTertiary), + ), + _protocolText( + l10n.translate('login.privacy_policy_link'), + () => _showProtocolDetail(l10n.translate('my.privacy_policy')), + ), ], ), ), @@ -266,6 +281,7 @@ class _RegisterPageState extends State { } void _showProtocolDetail(String title) { + final l10n = AppLocalizations.of(context); showModalBottomSheet( context: context, isScrollControlled: true, @@ -286,7 +302,8 @@ class _RegisterPageState extends State { Expanded( child: SingleChildScrollView( child: Text( - "此处放置您的${title}详细内容...\n" * 20, + '${l10n.translate('login.protocol_detail_text').replaceAll('%s', title)}\n' * + 20, style: TextStyle(color: context.appColors.textSecondary, height: 1.5), ), ), @@ -296,7 +313,7 @@ class _RegisterPageState extends State { width: double.infinity, child: CCPrimaryButton( onPressed: () => Navigator.pop(context), - text: "我已了解", + text: l10n.translate('login.understood'), ), ), ], diff --git a/lib/features/autonomous_work/data/gateways/path_planning_work_gateway.dart b/lib/features/autonomous_work/data/gateways/path_planning_work_gateway.dart index bfe6dc8a..fc59ce07 100644 --- a/lib/features/autonomous_work/data/gateways/path_planning_work_gateway.dart +++ b/lib/features/autonomous_work/data/gateways/path_planning_work_gateway.dart @@ -1,54 +1,68 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:latlong2/latlong.dart'; import '../../../../core/app/app_user_cubit.dart'; import '../../../../core/consts/http_api_consts.dart'; import '../../../../core/di/injection.dart'; -import '../../../devices/domain/entities/device_task_entity.dart'; +import '../../../devices/data/models/work_record_entity.dart'; +import '../../../devices/domain/repositories/device_task_repository.dart'; +import '../../../devices/domain/usecases/get_work_records_by_site_id_usecase.dart'; +import '../../../devices/domain/usecases/select_work_record_usecase.dart'; +import '../v2_work_task_tracker.dart'; +import '../v2_work_store.dart'; import '../../../devices/domain/usecases/create_device_task_usecase.dart'; -import '../../../devices/presentation/bloc/device_task_cubit.dart'; -import '../../../devices/presentation/bloc/devices_cubit.dart'; import '../../../v2/site/presentation/cubit/site_cubit.dart'; import '../../domain/entities/work_session_models.dart'; +import '../../domain/entities/return_plan_models.dart'; import '../../domain/repositories/autonomous_work_gateway.dart'; -/// A 环境实现:完全复用「路径规划」那条链路 -/// 作业记录 selectBySiteId → 任务池 getDeviceTaskPool → createDeviceTask → -/// pause/recovery/cancel + MQTT arrive 监听。 -/// -/// 这里刻意复用全局的 [DevicesCubit] / [DeviceTaskCubit](均为 lazySingleton), -/// 保证与路径规划页共享同一份任务状态与同一条推送流,不会出现"两边状态不一致"。 -class PathPlanningWorkGateway implements AutonomousWorkGateway { - PathPlanningWorkGateway({ - DevicesCubit? devices, - DeviceTaskCubit? tasks, - CreateDeviceTaskUseCase? createTask, - }) : _devices = devices ?? sl(), - _tasks = tasks ?? sl(), - _createTask = createTask ?? sl(); - - final DevicesCubit _devices; - final DeviceTaskCubit _tasks; - final CreateDeviceTaskUseCase _createTask; +/// 新版作业门面:沿用旧版协议和 HTTP 接口,独立维护任务状态与 MQTT 订阅。 +/// 不写旧版 Cubit 或缓存。 +class PathPlanningWorkGateway + implements + AutonomousWorkGateway, + WorkTaskHistorySource, + WorkTaskQueueSource { + PathPlanningWorkGateway({V2WorkTaskTracker? tracker}) + : _injectedTracker = tracker; + final V2WorkTaskTracker? _injectedTracker; + V2WorkTaskTracker? _ownedTracker; + String? _trackerOwner; + V2WorkTaskTracker get _tracker { + if (_injectedTracker != null) return _injectedTracker; + final owner = sl().state.user?.userId ?? ''; + if (_ownedTracker == null || _trackerOwner != owner) { + _trackerOwner = owner; + _ownedTracker = V2WorkTaskTracker( + repository: sl(), + createTask: sl(), + resolveScope: (deviceId) => resolveScope(deviceId: deviceId), + userId: () => sl().state.user?.userId ?? '', + store: V2WorkStore(owner), + ); + } + return _ownedTracker!; + } @override String get gatewayId => 'path_planning'; @override WorkSceneCapabilities get capabilities => const WorkSceneCapabilities( - routeLibrary: true, - taskQueue: true, - pause: true, - resume: true, - stop: true, - cancelQueued: true, - schedule: false, // 定时计划后端未提供 - routeEditing: true, // 走 RouteEditorPageV2 - progress: false, // 任务池不返回进度,页面不臆造百分比 - multiActiveTaskResolve: true, - ); + routeLibrary: true, + taskQueue: true, + pause: true, + resume: true, + stop: true, + cancelQueued: true, + schedule: false, // 定时计划走作业计划创建页(SaveDeviceSchedulePlanUseCase),不属本页能力 + routeEditing: true, // 走 RouteEditorPageV2 + progress: false, // 任务池不返回进度,页面不臆造百分比 + multiActiveTaskResolve: true, + ); @override Future resolveScope({required String deviceId}) async { @@ -60,23 +74,39 @@ class PathPlanningWorkGateway implements AutonomousWorkGateway { Future>> loadRoutes({ required int siteId, }) async { + debugPrint('[路线库] A 环境 → /iot/workRecord/selectBySiteId?siteId=$siteId'); try { - await _devices.loadWorkRecordsBySiteId(siteId); - final records = - _devices.state.workRecords ?? const >[]; - final routes = []; - for (final record in records) { - final option = _routeFromRecord(record); - if (option != null) routes.add(option); - } - if (routes.isEmpty) { - final error = _devices.state.errorMessage; - return WorkResult.fail( - (error != null && error.isNotEmpty) ? error : '该场站下暂无作业路线', - ); - } - return WorkResult.ok(routes); + final result = await sl()(siteId); + return result.fold((failure) { + debugPrint('[路线库] ← 接口失败: ${failure.message}'); + return WorkResult.fail(failure.message); + }, ( + records, + ) { + debugPrint('[路线库] ← 作业记录条数=${records.length}'); + final routes = []; + for (final record in records) { + final json = record.jsonData; + final option = _routeFromRecord({ + 'id': record.id, + 'workName': record.workName, + 'imgUrl': record.imgUrl, + 'jsonData': json is WorkRecordJsonData + ? { + 'path': json.path, + 'outer': json.outer, + 'planModel': json.planModel, + 'img': json.img, + } + : json, + }); + if (option != null) routes.add(option); + } + debugPrint('[路线库] 解析出可绘制路线=${routes.length}'); + return WorkResult.ok(routes); + }); } catch (e) { + debugPrint('[路线库] ❌ 加载异常: $e'); return WorkResult.fail('路线库加载失败:$e'); } } @@ -91,8 +121,13 @@ class PathPlanningWorkGateway implements AutonomousWorkGateway { try { // 与路径规划完全一致:按作业名回查记录详情, // selectBySiteId 的 jsonData 可能是空标签,真实 path/outer/planModel 在记录顶层 - await _devices.loadSelectedPath(route.name); - final records = _devices.state.pathData; + final result = await sl()(route.name); + String? error; + final records = result.fold>?>((failure) { + error = failure.message; + return null; + }, (data) => data); + if (error != null) return WorkResult.fail(error!); if (records == null || records.isEmpty) { return WorkResult.fail('「${route.name}」没有可用的路径数据'); } @@ -118,21 +153,11 @@ class PathPlanningWorkGateway implements AutonomousWorkGateway { @override Future>> loadTasks({ required String deviceId, - }) async { - try { - await _tasks.fetchAndFilterTask(deviceId); - final error = _tasks.state.errorMessage; - final tasks = _tasks.state.activeTasks - .map((task) => _snapshotFromEntity(task, deviceId)) - .toList(); - if (tasks.isEmpty && error != null && error.isNotEmpty) { - return WorkResult.fail(error); - } - return WorkResult.ok(tasks); - } catch (e) { - return WorkResult.fail('任务队列加载失败:$e'); - } - } + }) => _tracker.load(deviceId); + @override + Future>> loadTaskHistory({ + required String deviceId, + }) async => WorkResult.ok(await _tracker.history(deviceId)); /// 1:1 复刻路径规划「保存地块」:multipart 提交 /iot/workRecord/add。 /// @@ -146,6 +171,7 @@ class PathPlanningWorkGateway implements AutonomousWorkGateway { required List path, required List outer, required int planModel, + required String routeType, String? imageBase64, }) async { // 场站与 orgId 必须同源(selectBySiteId 的查询条件),不要取登录用户的 orgId @@ -156,7 +182,8 @@ class PathPlanningWorkGateway implements AutonomousWorkGateway { } final orgId = site?.orgId; - List> toWgs(List points) => >[ + List> toWgs(List points) => + >[ for (final point in points) {'lng': point.longitude, 'lat': point.latitude}, ]; @@ -174,8 +201,12 @@ class PathPlanningWorkGateway implements AutonomousWorkGateway { // jsonData 必须是对象,不能是字符串(后端 PlanPath 类型要求) 'jsonData': savePath, 'mode': coverage ? 'coverage' : 'single_point', + // 后端必填的规划类型(Java 枚举 RouteType.name()),与 mode 平级 + 'routeType': routeType, }; if (orgId != null) workRecord['orgId'] = orgId; + // 与路径规划页一致:打印后端实际接收的完整报文,便于排查字段缺失 + debugPrint('[区域保存] workRecord 完整JSON: ${jsonEncode(workRecord)}'); final http.MultipartRequest request = http.MultipartRequest( 'POST', @@ -233,236 +264,279 @@ class PathPlanningWorkGateway implements AutonomousWorkGateway { } final code = resp['code']; if (code != null && code.toString() != '200') { - return WorkResult.fail( - '${resp['msg'] ?? resp['message'] ?? '保存失败'}', - ); + return WorkResult.fail('${resp['msg'] ?? resp['message'] ?? '保存失败'}'); } // 与路径规划一致:保存成功后刷新该场站的作业记录 - await _devices.loadWorkRecordsBySiteId(siteId); return const WorkResult.ok(true); } catch (e) { return WorkResult.fail('保存失败:$e'); } } + /// 一键返航方案落库:POST /system/siteMapInfo/save(JSON)。 + /// 严格对齐后端字段:restPoints / tempObstaclePoints / siteId / orgId / id。 @override - Stream watchTask({required String deviceId}) { - return _tasks.stream.map((state) { - final task = state.currentTask; - if (task == null) return null; - if (task.deviceId.isNotEmpty && task.deviceId != deviceId) return null; - return _snapshotFromEntity(task, deviceId); - }).distinct((a, b) { - if (a == null || b == null) return a == b; - return a.taskId == b.taskId && - a.rawStatus == b.rawStatus && - a.state == b.state; - }); - } - - @override - Future> startWork( - WorkStartRequest request, - ) async { - final scope = await resolveScope(deviceId: request.deviceId); - final siteId = request.siteId ?? scope.siteId; - final orgId = request.orgId ?? scope.orgId ?? 0; - if (siteId == null) { - return WorkResult.fail('未选择场站,无法下发作业'); + Future> saveReturnPlan(ReturnPlanDraft draft) async { + if (draft.isEmpty) { + return const WorkResult.fail('没有可保存的返航点或障碍'); } - // ① 复用设备上已有的活跃任务 - final reuseId = request.reuseTaskId; - if (reuseId != null) { - final entity = _findEntity(reuseId); - if (entity != null) { - _tasks.selectTask(entity); - await _devices.startListeningMqttArrive( - deviceId: request.deviceId, - taskId: entity.id, + Map wgs(LatLng p) => + {'lat': p.latitude, 'lng': p.longitude}; + + // 尺寸归一化:米级宽度从经纬度几何反算会带 1e-9 量级的浮点残渣 + // (如 3.0000000009815784),后端字段是普通 double,这里抹到微米精度(1e-6) + // 即可去掉残渣,又不丢失用户真实录入的尺寸。 + double normM(double v) => (v * 1e6).roundToDouble() / 1e6; + + final Map payload = { + 'restPoints': >[ + for (final p in draft.restPoints) + { + 'position': wgs(p.position), + 'bindDeviceId': p.bindDeviceId, + 'alias': p.alias, + }, + ], + 'tempObstaclePoints': >[ + for (final o in draft.obstacles) + { + 'center': wgs(o.center), + // 后端要求 position 与 center 一致 + 'position': wgs(o.center), + 'shapeType': o.shapeType, + 'alias': o.alias, + 'source': o.source, + 'size_x': normM(o.sizeX), + 'size_y': normM(o.sizeY), + }, + ], + 'siteId': draft.siteId, + 'orgId': draft.orgId, + // 新增不传 id、修改才传:只有拿到真实主键(上次保存回写)才带上走更新 + if (draft.id != null) 'id': draft.id, + }; + + final String url = '${HttpApiConsts.baseUrl}/system/siteMapInfo/save'; + // 提交前先打印真实发出的报文,便于排查后端字段/坐标问题 + debugPrint('[一键返航] → POST $url'); + debugPrint('[一键返航] 提交数据: ${jsonEncode(payload)}'); + + final http.Request request = http.Request('POST', Uri.parse(url)) + ..headers['Content-Type'] = 'application/json;charset=utf-8'; + final userToken = sl().state.user?.token; + if (userToken != null) { + request.headers['Authorization'] = 'Bearer $userToken'; + } + request.body = jsonEncode(payload); + + try { + final http.StreamedResponse response = await request.send(); + final String body = await response.stream.bytesToString(); + debugPrint( + '[一键返航] ← HTTP ${response.statusCode} 响应: $body', + ); + if (response.statusCode != 200) { + return WorkResult.fail( + '保存失败(HTTP ${response.statusCode}):$body', ); - return WorkResult.ok(_snapshotFromEntity(entity, request.deviceId)); } + Map? resp; + try { + resp = jsonDecode(body) as Map; + } catch (_) { + return WorkResult.fail('保存失败:响应体非 JSON,$body'); + } + final code = resp['code']; + if (code != null && code.toString() != '200') { + return WorkResult.fail('${resp['msg'] ?? resp['message'] ?? '保存失败'}'); + } + // 若后端回写主键则回填,用于下一次「更新」;否则 null(每次新建) + final data = resp['data']; + final int? savedId = data is num + ? data.toInt() + : int.tryParse('${data ?? ''}'); + debugPrint('[一键返航] ✅ 保存成功,siteMapInfo id=$savedId'); + return WorkResult.ok(savedId); + } catch (e) { + debugPrint('[一键返航] ❌ 保存异常: $e'); + return WorkResult.fail('保存失败:$e'); + } + } + + /// 查询某场站已有的一键返航方案:GET /system/siteMapInfo/selectBySiteId?siteId=X。 + /// + /// 一个场站业务上只允许一条,data 为带完整几何的单体(也可能为 null=无方案)。 + /// 返回结构已确认为 {code,msg,data:{id,siteId,orgId,restPoints[],tempObstaclePoints[]}}, + /// 解析仍保留少量字段别名做容错,坐标/尺寸缺失则跳过该条、不臆造。 + @override + Future> fetchReturnPlanBySiteId({ + required int siteId, + }) async { + final String url = + '${HttpApiConsts.baseUrl}/system/siteMapInfo/selectBySiteId?siteId=$siteId'; + debugPrint('[一键返航] → GET $url'); + try { + final http.Response res = await http.get( + Uri.parse(url), + headers: _returnAuthHeaders(), + ); + debugPrint( + '[一键返航] ← HTTP ${res.statusCode} selectBySiteId 响应: ${res.body}', + ); + if (res.statusCode != 200) { + return WorkResult.fail('返航方案加载失败(HTTP ${res.statusCode})'); + } + dynamic decoded; + try { + decoded = jsonDecode(res.body); + } catch (_) { + return WorkResult.fail('返航方案加载失败:响应体非 JSON'); + } + if (decoded is! Map) { + return WorkResult.fail('返航方案响应格式异常'); + } + final code = decoded['code']; + if (code != null && code.toString() != '200') { + return WorkResult.fail( + '${decoded['msg'] ?? decoded['message'] ?? '请求失败'}', + ); + } + final data = decoded['data']; + if (data is! Map || data.isEmpty) { + // 该场站暂无一键返航方案 → 新建态 + return WorkResult.ok(null); + } + return WorkResult.ok( + _returnDraftFromJson(Map.from(data)), + ); + } catch (e) { + debugPrint('[一键返航] ❌ 查询异常: $e'); + return WorkResult.fail('返航方案加载失败:$e'); + } + } + + /// GET 头:Content-Type + Bearer token(与保存接口同源) + Map _returnAuthHeaders() { + final headers = { + 'Content-Type': 'application/json;charset=utf-8', + }; + final token = sl().state.user?.token; + if (token != null) headers['Authorization'] = 'Bearer $token'; + return headers; + } + + /// selectBySiteId 的 data 对象 → [ReturnPlanDraft]。 + /// 字段名对齐后端:restPoints.position{lat,lng}/alias/bindDeviceId, + /// tempObstaclePoints.center{lat,lng}/size_x/size_y/source。 + ReturnPlanDraft _returnDraftFromJson(Map m) { + LatLng? parseLatLng(dynamic node) { + if (node is! Map) return null; + final lat = node['lat'] ?? node['latitude']; + final lng = node['lng'] ?? node['lon'] ?? node['longitude']; + if (lat is! num || lng is! num) return null; + return LatLng(lat.toDouble(), lng.toDouble()); } - // ② 新建任务 - final routeId = int.tryParse(request.route.id); - if (routeId == null) { - return WorkResult.fail('路线 ID 非法:${request.route.id}'); + double asSize(dynamic v) { + if (v is num) return v.toDouble(); + return double.tryParse('${v ?? ''}') ?? 0; } - final created = await _createTask.execute( - deviceId: request.deviceId, - routeId: routeId, - siteId: siteId, - orgId: orgId, - ); - - String? failure; - final taskId = created.fold((f) { - failure = f.message; - return null; - }, (id) => id); - if (taskId == null) { - return WorkResult.fail(failure ?? '任务下发失败'); + int asId(dynamic v) { + if (v is num) return v.toInt(); + return int.tryParse('${v ?? ''}') ?? 0; } - await _devices.startListeningMqttArrive( - deviceId: request.deviceId, - taskId: taskId, - ); - - // 刷新任务池拿真实状态;拿不到就返回本地快照,交给 watchTask 后续校正 - await _tasks.fetchAndFilterTask(request.deviceId); - final entity = _findEntity('$taskId'); - if (entity != null) { - _tasks.selectTask(entity); - return WorkResult.ok(_snapshotFromEntity(entity, request.deviceId)); + final List restRaw = + m['restPoints'] is List ? m['restPoints'] as List : const []; + final restPoints = []; + for (final e in restRaw) { + if (e is! Map) continue; + final pos = + parseLatLng(e['position']) ?? + parseLatLng(e['center']) ?? + parseLatLng(e); + if (pos == null) continue; + final alias = (e['alias'] ?? e['name'] ?? '').toString(); + final bind = + (e['bindDeviceId'] ?? e['deviceSn'] ?? e['deviceId'])?.toString(); + restPoints.add( + ReturnRestPoint( + position: pos, + alias: alias, + bindDeviceId: (bind != null && bind.isNotEmpty) ? bind : null, + ), + ); } - _tasks.updateCurrentTaskId(taskId); - return WorkResult.ok( - WorkTaskSnapshot( - taskId: '$taskId', - deviceId: request.deviceId, - state: WorkRunState.queued, - routeId: request.route.id, - routeName: request.route.name, - rawStatus: 'NEW', - ), + + final List obsRaw = m['tempObstaclePoints'] is List + ? m['tempObstaclePoints'] as List + : const []; + final obstacles = []; + for (final e in obsRaw) { + if (e is! Map) continue; + final center = + parseLatLng(e['center']) ?? + parseLatLng(e['position']) ?? + parseLatLng(e); + if (center == null) continue; + obstacles.add( + ReturnObstaclePoint( + center: center, + alias: (e['alias'] ?? e['name'] ?? '').toString(), + sizeX: asSize(e['size_x'] ?? e['sizeX'] ?? e['width']), + sizeY: asSize(e['size_y'] ?? e['sizeY'] ?? e['height']), + source: (e['source'] ?? 'platform').toString(), + ), + ); + } + + return ReturnPlanDraft( + siteId: asId(m['siteId']), + orgId: asId(m['orgId']), + id: m['id'] == null ? null : asId(m['id']), + restPoints: restPoints, + obstacles: obstacles, ); } + @override + Stream watchTask({required String deviceId}) => + _tracker.watch(deviceId); + @override + Stream watchTaskChanges({required String deviceId}) => + _tracker.watch(deviceId, allTasks: true); + @override + Future> startWork(WorkStartRequest request) => + _tracker.start(request); @override Future> pauseWork({ required String deviceId, required WorkTaskSnapshot task, - }) async { - _bindTask(task); - await _tasks.pauseTask(deviceId); - final error = _tasks.state.errorMessage; - if (error != null && error.isNotEmpty) return WorkResult.fail(error); - return WorkResult.ok( - task.copyWith(state: WorkRunState.paused, rawStatus: 'PAUSE'), - ); - } - + }) => _tracker.command(deviceId, task, 'pause'); @override Future> resumeWork({ required String deviceId, required WorkTaskSnapshot task, - }) async { - _bindTask(task); - await _tasks.recoveryTask(deviceId); - final error = _tasks.state.errorMessage; - if (error != null && error.isNotEmpty) return WorkResult.fail(error); - return WorkResult.ok( - task.copyWith(state: WorkRunState.running, rawStatus: 'EXECUTING'), - ); - } - + }) => _tracker.command(deviceId, task, 'resume'); @override Future> stopWork({ required String deviceId, required WorkTaskSnapshot task, - }) async { - _bindTask(task); - await _tasks.cancelTask(deviceId, forceCancel: true); - final error = _tasks.state.errorMessage; - if (error != null && error.isNotEmpty) return WorkResult.fail(error); - await _devices.stopListeningMqttArrive(); - return WorkResult.ok( - task.copyWith(state: WorkRunState.finished, rawStatus: 'CANCELED'), - ); - } - + }) => _tracker.command(deviceId, task, 'cancel'); @override Future> cancelQueued({ required String deviceId, required WorkTaskSnapshot task, - }) async { - _bindTask(task); - await _tasks.cancelTask(deviceId, forceCancel: false); - final error = _tasks.state.errorMessage; - if (error != null && error.isNotEmpty) return WorkResult.fail(error); - _tasks.clearCurrentTask(); - return WorkResult.ok( - task.copyWith(state: WorkRunState.finished, rawStatus: 'CANCELED'), - ); - } - + }) => _tracker.command(deviceId, task, 'cancel', forceCancel: false); @override Future> activateTask({ required String deviceId, required WorkTaskSnapshot task, }) async { - final entity = _findEntity(task.taskId); - if (entity == null) { - return WorkResult.fail('该任务已不在任务池中,请刷新后重试'); - } - _tasks.selectTask(entity); - await _devices.startListeningMqttArrive( - deviceId: deviceId, - taskId: entity.id, - ); - return WorkResult.ok(_snapshotFromEntity(entity, deviceId)); - } - - // ===== 内部:状态映射 / 记录解析 ===== - - /// 让 DeviceTaskCubit 的操作作用在页面选中的那条任务上 - void _bindTask(WorkTaskSnapshot task) { - final entity = _findEntity(task.taskId); - if (entity != null) { - _tasks.selectTask(entity); - return; - } - final id = int.tryParse(task.taskId); - if (id != null) _tasks.updateCurrentTaskId(id); - } - - DeviceTaskEntity? _findEntity(String taskId) { - for (final entity in _tasks.state.activeTasks) { - if ('${entity.id}' == taskId) return entity; - } - return null; - } - - WorkTaskSnapshot _snapshotFromEntity( - DeviceTaskEntity task, - String deviceId, - ) { - return WorkTaskSnapshot( - taskId: '${task.id}', - deviceId: task.deviceId.isEmpty ? deviceId : task.deviceId, - state: _stateFromStatus(task.taskStatus), - routeId: task.routeId?.toString(), - rawStatus: task.taskStatus, - createdAt: task.createTime, - extras: { - 'routeId': task.routeId, - 'siteId': task.siteId, - 'orgId': task.orgId, - 'statusText': task.taskStatusTranslate, - }, - ); - } - - /// 后端状态码 → 统一运行态。新增状态码只改这里。 - WorkRunState _stateFromStatus(String status) { - switch (status) { - case 'NEW': - return WorkRunState.queued; - case 'EXECUTING': - return WorkRunState.running; - case 'PAUSE': - return WorkRunState.paused; - case 'FAILED': - return WorkRunState.failed; - case 'CANCELING': - return WorkRunState.finished; - default: - return WorkRunState.queued; - } + if (task.deviceId != deviceId) return const WorkResult.fail('设备不匹配'); + return _tracker.select(task); } /// 作业记录 → 路线选项:字段解析集中在这里, @@ -494,8 +568,9 @@ class PathPlanningWorkGateway implements AutonomousWorkGateway { // 真实 path/outer/planModel 也可能直接挂在记录顶层,两处都读。 final path = _points(data['path'] ?? record['path']); var outer = _points(data['outer'] ?? record['outer']); - final planModel = - int.tryParse('${data['planModel'] ?? record['planModel'] ?? ''}'); + final planModel = int.tryParse( + '${data['planModel'] ?? record['planModel'] ?? ''}', + ); final imgUrl = record['imgUrl']?.toString() ?? ''; // 后端可能返回 outer 经度全为 0 的脏数据:覆盖模式下用 path 兜底, @@ -517,8 +592,9 @@ class PathPlanningWorkGateway implements AutonomousWorkGateway { pathPoints: path, outerPoints: outer, coverageMode: coverageMode, - planModeLabel: - planModel == null ? null : (coverageMode ? '覆盖模式' : '自定义模式'), + planModeLabel: planModel == null + ? null + : (coverageMode ? '覆盖模式' : '自定义模式'), imageUrl: imgUrl.isNotEmpty ? imgUrl : (data['img'] is String ? data['img'] as String : null), diff --git a/lib/features/regions/data/datasources/region_remote_datasource.dart b/lib/features/regions/data/datasources/region_remote_datasource.dart new file mode 100644 index 00000000..05eaef85 --- /dev/null +++ b/lib/features/regions/data/datasources/region_remote_datasource.dart @@ -0,0 +1,117 @@ +import 'dart:convert'; +import 'package:dio/dio.dart'; +import '../../../../core/consts/http_api_consts.dart'; +import '../../../../core/logging/i_logger_service.dart'; +import '../../domain/entities/region_draft.dart'; +import '../models/region_model.dart'; + +class RegionRemoteDatasource { + RegionRemoteDatasource(this.dio, this.logger); + final Dio dio; + final ILoggerService logger; + + Future> loadRegions(int siteId) async { + logger.logWithLevel( + '[分区列表] 请求: GET ${HttpApiConsts.regionsBySite}?siteId=$siteId', + shouldLog: true, + ); + final response = await dio.get( + HttpApiConsts.regionsBySite, + queryParameters: {'siteId': siteId}, + ); + final data = response.data; + logger.logWithLevel( + '[分区列表] 响应: HTTP ${response.statusCode}, ${jsonEncode(data)}', + shouldLog: true, + ); + if (response.statusCode != 200 || + data is! Map || + '${data['code']}' != '200') { + throw Exception(data is Map ? data['msg'] ?? '分区加载失败' : '分区列表响应格式错误'); + } + final rows = data['data'] ?? const []; + if (rows is! List) throw const FormatException('分区列表 data 必须为数组'); + final result = {}; + for (final row in rows) { + if (row is! Map) continue; + if (row['delFlag'] == true || + row['type'] != null && row['type'] != 'region') { + continue; + } + final region = RegionModel.fromJson(Map.from(row)); + if (region.siteId == siteId) result[region.id!] = region; + } + return result.values.toList(); + } + + Future deleteRegions(List ids) async { + logger.logWithLevel( + '[分区删除] 请求: POST ${HttpApiConsts.deleteRegions}, 参数: ${jsonEncode(ids)}', + shouldLog: true, + ); + final response = await dio.post(HttpApiConsts.deleteRegions, data: ids); + final data = response.data; + logger.logWithLevel( + '[分区删除] 响应: HTTP ${response.statusCode}, ${jsonEncode(data)}', + shouldLog: true, + ); + if (response.statusCode != 200 || + data is! Map || + '${data['code']}' != '200') { + throw Exception(data is Map ? data['msg'] ?? '分区删除失败' : '分区删除响应格式错误'); + } + final result = data['data']; + if (result == false || result is num && result <= 0) { + throw Exception('分区未删除,请重试'); + } + return true; + } + + Future save(RegionDraft draft) async { + final body = { + if (draft.id != null) 'id': draft.id, + 'regionName': draft.name.trim(), + 'regionCode': draft.code.trim(), + 'siteId': draft.siteId, + 'type': 'region', + 'remark': draft.remark, + 'scope': [ + for (final point in draft.scope) {'lat': point.lat, 'lng': point.lng}, + ], + 'status': draft.enabled ? 1 : 0, + 'defaultRoute': draft.defaultRouteId, + 'bindRoutes': draft.boundRouteIds.toSet().toList()..sort(), + }; + final url = HttpApiConsts.saveRegion; + logger.logWithLevel('[分区保存] 请求: POST $url', shouldLog: true); + logger.logWithLevel('[分区保存] 参数: ${jsonEncode(body)}', shouldLog: true); + try { + final response = await dio.post(url, data: body); + logger.logWithLevel( + '[分区保存] 响应: HTTP ${response.statusCode}, ${jsonEncode(response.data)}', + shouldLog: true, + ); + final data = response.data; + if (response.statusCode != 200 || + data is! Map || + '${data['code']}' != '200') { + throw Exception(data is Map ? data['msg'] ?? '分区保存失败' : '分区保存响应格式错误'); + } + final result = data['data']; + if (result == false || result is num && result <= 0) { + throw Exception('分区未保存,请重试'); + } + // 数字 data 视为保存数量;只有对象内明确的 id 才能用作后续更新主键。 + final returnedId = result is Map ? int.tryParse('${result['id']}') : null; + final id = returnedId != null && returnedId > 0 ? returnedId : draft.id; + logger.logWithLevel( + '[分区保存] 成功,${draft.id == null ? '新建' : '修改'}分区,id=$id', + shouldLog: true, + ); + return RegionSaveResult(id: id); + } catch (error) { + logger.logWithLevel('[分区保存] 异常: $error', shouldLog: true); + rethrow; + } + } +} diff --git a/lib/features/regions/data/models/region_model.dart b/lib/features/regions/data/models/region_model.dart new file mode 100644 index 00000000..b2b5abac --- /dev/null +++ b/lib/features/regions/data/models/region_model.dart @@ -0,0 +1,45 @@ +import '../../domain/entities/region_draft.dart'; + +/// 列表接口字段可为数字或字符串,空编码/备注在领域内统一为空串。 +class RegionModel { + static RegionDraft fromJson(Map json) { + int? integer(dynamic value) => int.tryParse('$value'); + double? number(dynamic value) => + value is num ? value.toDouble() : double.tryParse('$value'); + final id = integer(json['id']); + final siteId = integer(json['siteId']); + if (id == null || id <= 0 || siteId == null || siteId <= 0) { + throw const FormatException('分区 ID 或场站 ID 无效'); + } + final points = []; + for (final raw + in json['scope'] is List ? json['scope'] as List : const []) { + if (raw is! Map) continue; + final lat = number(raw['lat']); + final lng = number(raw['lng']); + if (lat == null || lng == null) continue; + final point = RegionPoint(lat: lat, lng: lng); + if (point.isValid) points.add(point); + } + return RegionDraft( + id: id, + siteId: siteId, + name: json['regionName']?.toString() ?? '', + code: json['regionCode']?.toString() ?? '', + remark: json['remark']?.toString() ?? '', + scope: points, + enabled: + json['status'] == null || + json['status'] == true || + '${json['status']}' == '1', + defaultRouteId: integer(json['defaultRoute']), + boundRouteIds: [ + for (final raw + in json['bindRoutes'] is List + ? json['bindRoutes'] as List + : const []) + if (integer(raw) != null && integer(raw)! > 0) integer(raw)!, + ], + ); + } +} diff --git a/lib/features/regions/data/repositories/region_repository_impl.dart b/lib/features/regions/data/repositories/region_repository_impl.dart new file mode 100644 index 00000000..ba25eb93 --- /dev/null +++ b/lib/features/regions/data/repositories/region_repository_impl.dart @@ -0,0 +1,59 @@ +import 'package:fpdart/fpdart.dart'; +import '../../../../core/error/failure.dart'; +import '../../../autonomous_work/domain/repositories/autonomous_work_gateway.dart'; +import '../../domain/entities/region_draft.dart'; +import '../../domain/repositories/region_repository.dart'; +import '../datasources/region_remote_datasource.dart'; + +class RegionRepositoryImpl implements RegionRepository { + RegionRepositoryImpl({required this.remote, required this.routes}); + final RegionRemoteDatasource remote; + final AutonomousWorkGateway routes; + + @override + Future>> loadRegions(int siteId) async { + try { + return Right(await remote.loadRegions(siteId)); + } catch (error) { + return Left(Failure(error.toString())); + } + } + + @override + Future> deleteRegions(List ids) async { + try { + return Right(await remote.deleteRegions(ids)); + } catch (error) { + return Left(Failure(error.toString())); + } + } + + @override + Future> save(RegionDraft draft) async { + try { + return Right(await remote.save(draft)); + } catch (error) { + return Left(Failure(error.toString())); + } + } + + @override + Future>> loadRoutes( + int siteId, + ) async { + try { + final result = await routes.loadRoutes(siteId: siteId); + if (!result.isOk) return Left(Failure(result.error ?? '路线加载失败')); + final options = {}; + for (final route in result.data ?? []) { + final id = int.tryParse(route.id); + if (id != null && id > 0) { + options[id] = RegionRouteOption(id: id, name: route.name); + } + } + return Right(options.values.toList()); + } catch (error) { + return Left(Failure(error.toString())); + } + } +} diff --git a/lib/features/regions/domain/entities/region_draft.dart b/lib/features/regions/domain/entities/region_draft.dart new file mode 100644 index 00000000..f92587a3 --- /dev/null +++ b/lib/features/regions/domain/entities/region_draft.dart @@ -0,0 +1,97 @@ +class RegionPoint { + const RegionPoint({required this.lat, required this.lng}); + final double lat; + final double lng; + + bool get isValid => + lat.isFinite && + lng.isFinite && + lat >= -90 && + lat <= 90 && + lng >= -180 && + lng <= 180; +} + +/// 分区的业务数据;JSON 字段、type 和 status 的接口格式由数据层负责。 +class RegionDraft { + RegionDraft({ + this.id, + required this.siteId, + this.name = '', + this.code = '', + this.remark = '', + this.enabled = true, + this.defaultRouteId, + List boundRouteIds = const [], + List scope = const [], + }) : boundRouteIds = List.unmodifiable(boundRouteIds), + scope = List.unmodifiable(scope); + + final int? id; + final int siteId; + final String name; + final String code; + final String remark; + final bool enabled; + final int? defaultRouteId; + final List boundRouteIds; + final List scope; + + RegionDraft withScope(List points) => RegionDraft( + id: id, + siteId: siteId, + name: name, + code: code, + remark: remark, + enabled: enabled, + defaultRouteId: defaultRouteId, + boundRouteIds: boundRouteIds, + scope: points, + ); + + RegionDraft withId(int? value) => RegionDraft( + id: value, + siteId: siteId, + name: name, + code: code, + remark: remark, + enabled: enabled, + defaultRouteId: defaultRouteId, + boundRouteIds: boundRouteIds, + scope: scope, + ); + + String? get scopeError { + if (scope.length != 4) return '分区范围需要恰好 4 个打点'; + if (scope.any((p) => !p.isValid)) return '分区范围包含无效经纬度'; + if (scope.map((p) => (p.lat, p.lng)).toSet().length != 4) { + return '分区的 4 个打点不能重复'; + } + return null; + } + + String? get validationError { + if (id != null && id! <= 0) return '分区 ID 无效'; + if (siteId <= 0) return '请先选择场站'; + if (name.trim().isEmpty) return '请填写分区名称'; + if (scopeError != null) return scopeError; + if (defaultRouteId != null && defaultRouteId! <= 0 || + boundRouteIds.any((id) => id <= 0)) { + return '请选择有效路线'; + } + return null; + } +} + +class RegionRouteOption { + const RegionRouteOption({required this.id, required this.name}); + final int id; + final String name; +} + +class RegionSaveResult { + const RegionSaveResult({this.id}); + + /// 仅为明确返回的分区主键。data: 1 的影响行数不能作为分区 ID。 + final int? id; +} diff --git a/lib/features/regions/domain/repositories/region_repository.dart b/lib/features/regions/domain/repositories/region_repository.dart new file mode 100644 index 00000000..06e89b32 --- /dev/null +++ b/lib/features/regions/domain/repositories/region_repository.dart @@ -0,0 +1,10 @@ +import 'package:fpdart/fpdart.dart'; +import '../../../../core/error/failure.dart'; +import '../entities/region_draft.dart'; + +abstract class RegionRepository { + Future> save(RegionDraft draft); + Future>> loadRoutes(int siteId); + Future>> loadRegions(int siteId); + Future> deleteRegions(List ids); +} diff --git a/lib/features/regions/domain/usecases/region_usecases.dart b/lib/features/regions/domain/usecases/region_usecases.dart new file mode 100644 index 00000000..4bbbd2c9 --- /dev/null +++ b/lib/features/regions/domain/usecases/region_usecases.dart @@ -0,0 +1,45 @@ +import 'package:fpdart/fpdart.dart'; +import '../../../../core/error/failure.dart'; +import '../entities/region_draft.dart'; +import '../repositories/region_repository.dart'; + +class SaveRegionUseCase { + SaveRegionUseCase(this.repository); + final RegionRepository repository; + + Future> call(RegionDraft draft) async { + final error = draft.validationError; + if (error != null) return Left(Failure(error)); + return repository.save(draft); + } +} + +class LoadRegionRoutesUseCase { + LoadRegionRoutesUseCase(this.repository); + final RegionRepository repository; + + Future>> call(int siteId) async { + if (siteId <= 0) return Left(Failure('请先选择场站')); + return repository.loadRoutes(siteId); + } +} + +class LoadRegionsUseCase { + LoadRegionsUseCase(this.repository); + final RegionRepository repository; + Future>> call(int siteId) async { + if (siteId <= 0) return Left(Failure('请先选择场站')); + return repository.loadRegions(siteId); + } +} + +class DeleteRegionsUseCase { + DeleteRegionsUseCase(this.repository); + final RegionRepository repository; + Future> call(List ids) async { + if (ids.isEmpty || ids.any((id) => id <= 0)) { + return Left(Failure('请选择有效分区')); + } + return repository.deleteRegions(ids.toSet().toList()); + } +} diff --git a/lib/features/regions/presentation/cubit/region_editor_cubit.dart b/lib/features/regions/presentation/cubit/region_editor_cubit.dart new file mode 100644 index 00000000..bc48b0ee --- /dev/null +++ b/lib/features/regions/presentation/cubit/region_editor_cubit.dart @@ -0,0 +1,190 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../domain/entities/region_draft.dart'; +import '../../domain/usecases/region_usecases.dart'; + +enum RegionEditorStatus { editing, saving, saved, failure } + +enum RegionRoutesStatus { idle, loading, ready, failure } + +class RegionEditorState { + RegionEditorState({ + required this.draft, + this.status = RegionEditorStatus.editing, + this.routesStatus = RegionRoutesStatus.idle, + this.routes = const [], + this.error, + this.routesError, + }); + final RegionDraft draft; + final RegionEditorStatus status; + final RegionRoutesStatus routesStatus; + final List routes; + final String? error; + final String? routesError; + bool get isSaving => status == RegionEditorStatus.saving; + bool get savedWithoutId => + status == RegionEditorStatus.saved && draft.id == null; + + RegionEditorState copyWith({ + RegionDraft? draft, + RegionEditorStatus? status, + RegionRoutesStatus? routesStatus, + List? routes, + String? error, + String? routesError, + bool clearError = false, + bool clearRoutesError = false, + }) => RegionEditorState( + draft: draft ?? this.draft, + status: status ?? this.status, + routesStatus: routesStatus ?? this.routesStatus, + routes: routes ?? this.routes, + error: clearError ? null : error ?? this.error, + routesError: clearRoutesError ? null : routesError ?? this.routesError, + ); +} + +/// 可被地图编辑器及分区管理复用;点位与路线编辑草稿、障碍物、返航点完全独立。 +class RegionEditorCubit extends Cubit { + RegionEditorCubit(this._save, this._loadRoutes) + : super(RegionEditorState(draft: RegionDraft(siteId: 0))); + final SaveRegionUseCase _save; + final LoadRegionRoutesUseCase _loadRoutes; + int _generation = 0; + + void initialize(RegionDraft draft) { + if (state.isSaving) return; + ++_generation; + emit(RegionEditorState(draft: draft)); + } + + String? addPoint(RegionPoint point) { + if (state.isSaving) return '分区正在保存'; + if (state.savedWithoutId) return '分区已保存,请点「新建」绘制下一个分区'; + if (!point.isValid) return '打点经纬度无效'; + if (state.draft.scope.length >= 4) return '分区只需要 4 个打点,请撤回或删除后重打'; + if (state.draft.scope.any( + (p) => p.lat == point.lat && p.lng == point.lng, + )) { + return '该位置已打点,请移动到下一个位置'; + } + emit( + state.copyWith( + draft: state.draft.withScope([...state.draft.scope, point]), + status: RegionEditorStatus.editing, + clearError: true, + ), + ); + return null; + } + + void removePoint(int index) { + if (state.isSaving || + state.savedWithoutId || + index < 0 || + index >= state.draft.scope.length) { + return; + } + final points = [...state.draft.scope]..removeAt(index); + emit( + state.copyWith( + draft: state.draft.withScope(points), + status: RegionEditorStatus.editing, + clearError: true, + ), + ); + } + + Future loadRoutes() async { + if (state.routesStatus == RegionRoutesStatus.loading || isClosed) return; + final generation = _generation; + final siteId = state.draft.siteId; + emit( + state.copyWith( + routesStatus: RegionRoutesStatus.loading, + clearRoutesError: true, + ), + ); + try { + final result = await _loadRoutes(siteId); + if (isClosed || generation != _generation) return; + result.fold( + (failure) => emit( + state.copyWith( + routesStatus: RegionRoutesStatus.failure, + routesError: failure.message, + ), + ), + (routes) => emit( + state.copyWith( + routesStatus: RegionRoutesStatus.ready, + routes: List.unmodifiable(routes), + clearRoutesError: true, + ), + ), + ); + } catch (error) { + if (!isClosed && generation == _generation) { + emit( + state.copyWith( + routesStatus: RegionRoutesStatus.failure, + routesError: '$error', + ), + ); + } + } + } + + Future save(RegionDraft draft) async { + if (state.isSaving || state.savedWithoutId) return false; + if (draft.siteId != state.draft.siteId || draft.id != state.draft.id) { + emit( + state.copyWith( + status: RegionEditorStatus.failure, + error: '分区或场站已切换,请重新打开编辑', + ), + ); + return false; + } + final generation = _generation; + emit( + state.copyWith( + draft: draft, + status: RegionEditorStatus.saving, + clearError: true, + ), + ); + try { + final result = await _save(draft); + if (isClosed || generation != _generation) return false; + return result.fold( + (failure) { + emit( + state.copyWith( + status: RegionEditorStatus.failure, + error: failure.message, + ), + ); + return false; + }, + (receipt) { + emit( + state.copyWith( + draft: draft.withId(receipt.id ?? draft.id), + status: RegionEditorStatus.saved, + clearError: true, + ), + ); + return true; + }, + ); + } catch (error) { + if (!isClosed && generation == _generation) { + emit( + state.copyWith(status: RegionEditorStatus.failure, error: '$error'), + ); + } + return false; + } + } +} diff --git a/lib/features/regions/presentation/cubit/region_list_cubit.dart b/lib/features/regions/presentation/cubit/region_list_cubit.dart new file mode 100644 index 00000000..96997ba6 --- /dev/null +++ b/lib/features/regions/presentation/cubit/region_list_cubit.dart @@ -0,0 +1,183 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../domain/entities/region_draft.dart'; +import '../../domain/usecases/region_usecases.dart'; + +enum RegionListStatus { idle, loading, ready, failure, noSite, noUser } + +class RegionListState { + const RegionListState({ + this.status = RegionListStatus.idle, + this.regions = const [], + this.siteId, + this.selectedId, + this.deletingId, + this.error, + this.operationError, + }); + final RegionListStatus status; + final List regions; + final int? siteId; + final int? selectedId; + final int? deletingId; + final String? error; + final String? operationError; + + RegionListState copyWith({ + List? regions, + int? selectedId, + bool clearSelection = false, + int? deletingId, + bool clearDeleting = false, + String? operationError, + }) => RegionListState( + status: status, + siteId: siteId, + regions: regions ?? this.regions, + selectedId: clearSelection ? null : selectedId ?? this.selectedId, + deletingId: clearDeleting ? null : deletingId ?? this.deletingId, + error: error, + operationError: operationError, + ); +} + +class RegionListCubit extends Cubit { + RegionListCubit(this._load, this._delete) : super(const RegionListState()); + final LoadRegionsUseCase _load; + final DeleteRegionsUseCase _delete; + (String, int)? _scope; + int _generation = 0; + Future? _pending; + Object? get scopeKey => _scope; + + Future load({ + required int? siteId, + required String? ownerId, + bool force = false, + }) { + if (isClosed) return Future.value(); + if (ownerId == null || ownerId.isEmpty || siteId == null || siteId <= 0) { + ++_generation; + _scope = null; + _pending = null; + emit( + RegionListState( + status: ownerId == null || ownerId.isEmpty + ? RegionListStatus.noUser + : RegionListStatus.noSite, + ), + ); + return Future.value(); + } + final scope = (ownerId, siteId); + if (_scope == scope && _pending != null && !force) return _pending!; + if (_scope == scope && state.deletingId != null) { + return Future.value(); + } + final selectedId = _scope == scope ? state.selectedId : null; + final generation = ++_generation; + _scope = scope; + emit(RegionListState(status: RegionListStatus.loading, siteId: siteId)); + return _pending = _fetch(scope, generation, selectedId); + } + + Future _fetch( + (String, int) scope, + int generation, + int? selectedId, + ) async { + try { + final result = await _load(scope.$2); + if (isClosed || generation != _generation) return; + result.fold( + (failure) => emit( + RegionListState( + status: RegionListStatus.failure, + siteId: scope.$2, + error: failure.message, + ), + ), + (regions) => emit( + RegionListState( + status: RegionListStatus.ready, + siteId: scope.$2, + regions: List.unmodifiable(regions), + selectedId: regions.any((r) => r.id == selectedId) + ? selectedId + : null, + ), + ), + ); + } catch (error) { + if (!isClosed && generation == _generation) { + emit( + RegionListState( + status: RegionListStatus.failure, + siteId: scope.$2, + error: '$error', + ), + ); + } + } finally { + if (generation == _generation) _pending = null; + } + } + + void select(int? id) { + if (isClosed || state.status != RegionListStatus.ready) return; + if (id == null) { + emit(state.copyWith(clearSelection: true)); + return; + } + if (state.regions.any((r) => r.id == id)) { + emit(state.copyWith(selectedId: id)); + } + } + + Future deleteRegion(RegionDraft region) async { + if (isClosed || + state.deletingId != null || + region.id == null || + region.siteId != state.siteId || + !state.regions.any((r) => r.id == region.id)) { + return false; + } + final generation = _generation; + emit(state.copyWith(deletingId: region.id)); + try { + final result = await _delete([region.id!]); + if (isClosed || generation != _generation) return false; + return result.fold( + (failure) { + emit( + state.copyWith( + clearDeleting: true, + operationError: failure.message, + ), + ); + return false; + }, + (deleted) { + if (!deleted) { + emit( + state.copyWith(clearDeleting: true, operationError: '分区未删除,请重试'), + ); + return false; + } + emit( + state.copyWith( + regions: state.regions.where((r) => r.id != region.id).toList(), + clearDeleting: true, + clearSelection: state.selectedId == region.id, + ), + ); + return true; + }, + ); + } catch (error) { + if (!isClosed && generation == _generation) { + emit(state.copyWith(clearDeleting: true, operationError: '$error')); + } + return false; + } + } +} diff --git a/lib/features/regions/presentation/widgets/region_boundary_layer.dart b/lib/features/regions/presentation/widgets/region_boundary_layer.dart new file mode 100644 index 00000000..acbc6bc8 --- /dev/null +++ b/lib/features/regions/presentation/widgets/region_boundary_layer.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +/// 独立分区图层,不复用或修改圈地路线的草稿点、障碍和返航线。 +class RegionBoundaryLayer extends StatelessWidget { + const RegionBoundaryLayer({ + super.key, + required this.points, + this.selected = true, + this.enabled = true, + this.showVertices = true, + this.label, + }); + final List points; + final bool selected; + final bool enabled; + final bool showVertices; + final String? label; + Color get _color => enabled ? const Color(0xFF1C96F6) : Colors.grey; + + @override + Widget build(BuildContext context) => Stack( + fit: StackFit.expand, + children: [ + if (points.length == 4) + PolygonLayer( + polygons: [ + Polygon( + points: points, + color: _color.withValues(alpha: selected ? 0.28 : 0.12), + borderColor: _color, + borderStrokeWidth: selected ? 3 : 1.5, + ), + ], + ), + if (points.length > 1 && points.length < 4) + PolylineLayer( + polylines: [Polyline(points: points, color: _color, strokeWidth: 2)], + ), + MarkerLayer( + markers: [ + if (showVertices) + for (var index = 0; index < points.length; index++) + Marker( + point: points[index], + width: 24, + height: 24, + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: _color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: Text( + '${index + 1}', + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (label != null && label!.isNotEmpty && points.isNotEmpty) + Marker( + point: LatLngBounds.fromPoints(points).center, + width: 140, + height: 32, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: _color.withValues(alpha: selected ? 0.95 : 0.75), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + label!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 11, + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + ], + ); +} diff --git a/lib/features/regions/presentation/widgets/region_form_dialog.dart b/lib/features/regions/presentation/widgets/region_form_dialog.dart new file mode 100644 index 00000000..8a458f6d --- /dev/null +++ b/lib/features/regions/presentation/widgets/region_form_dialog.dart @@ -0,0 +1,427 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../domain/entities/region_draft.dart'; +import '../cubit/region_editor_cubit.dart'; + +/// 地图编辑器、分区列表等入口共用同一份表单与保存链路。 +Future showRegionFormDialog( + BuildContext context, { + required RegionEditorCubit cubit, + required bool Function() isScopeCurrent, +}) => showDialog( + context: context, + barrierDismissible: false, + builder: (_) => + RegionFormDialog(cubit: cubit, isScopeCurrent: isScopeCurrent), +); + +class RegionFormDialog extends StatefulWidget { + const RegionFormDialog({ + super.key, + required this.cubit, + required this.isScopeCurrent, + }); + final RegionEditorCubit cubit; + final bool Function() isScopeCurrent; + + @override + State createState() => _RegionFormDialogState(); +} + +class _RegionFormDialogState extends State { + final _form = GlobalKey(); + late final TextEditingController _name; + late final TextEditingController _code; + late final TextEditingController _remark; + late Set _bound; + int? _defaultRoute; + String? _scopeError; + + @override + void initState() { + super.initState(); + final draft = widget.cubit.state.draft; + _name = TextEditingController(text: draft.name); + _code = TextEditingController(text: draft.code); + _remark = TextEditingController(text: draft.remark); + _defaultRoute = draft.defaultRouteId; + _bound = draft.boundRouteIds.toSet(); + widget.cubit.loadRoutes(); + } + + @override + void dispose() { + _name.dispose(); + _code.dispose(); + _remark.dispose(); + super.dispose(); + } + + Future _submit() async { + final state = widget.cubit.state; + if (state.isSaving || !_form.currentState!.validate()) return; + if (!widget.isScopeCurrent()) { + setState(() => _scopeError = '场站或账号已切换,请重新进入编辑器后保存'); + return; + } + setState(() => _scopeError = null); + final draft = state.draft; + final saved = await widget.cubit.save( + RegionDraft( + id: draft.id, + siteId: draft.siteId, + name: _name.text, + code: _code.text, + remark: _remark.text, + enabled: draft.enabled, + defaultRouteId: _defaultRoute, + boundRouteIds: _bound.toList()..sort(), + scope: draft.scope, + ), + ); + if (!mounted) return; + if (saved) Navigator.of(context).pop(true); + } + + Map _options(RegionEditorState state) { + final result = {for (final route in state.routes) route.id: route.name}; + for (final id in [..._bound, ?_defaultRoute]) { + result.putIfAbsent(id, () => '路线 #$id'); + } + return result; + } + + Future _pickBoundRoutes(Map options) async { + FocusScope.of(context).unfocus(); + final selected = {..._bound}; + final result = await showDialog>( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (_, update) => AlertDialog( + title: const Text('绑定路线'), + content: SizedBox( + width: 420, + height: (MediaQuery.sizeOf(dialogContext).height * 0.5).clamp( + 80.0, + 300.0, + ), + child: options.isEmpty + ? const Center(child: Text('当前场站暂无可绑定路线')) + : ListView( + children: [ + for (final route in options.entries) + CheckboxListTile( + key: ValueKey('region_bind_${route.key}'), + value: selected.contains(route.key), + title: Text(route.value), + controlAffinity: ListTileControlAffinity.leading, + onChanged: (checked) => update(() { + if (checked == true) { + selected.add(route.key); + } else { + selected.remove(route.key); + } + }), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(selected), + child: const Text('确定'), + ), + ], + ), + ), + ); + if (result != null && mounted) setState(() => _bound = result); + } + + InputDecoration _decoration(String label) => InputDecoration( + labelText: label, + isDense: true, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ); + + @override + Widget build( + BuildContext context, + ) => BlocBuilder( + bloc: widget.cubit, + builder: (context, state) { + final options = _options(state); + final loading = state.routesStatus == RegionRoutesStatus.loading; + final available = + (MediaQuery.sizeOf(context).height - + MediaQuery.viewInsetsOf(context).bottom - + 32) + .clamp(180.0, 640.0); + return PopScope( + canPop: !state.isSaving, + child: Dialog( + insetPadding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 640, maxHeight: available), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 8, 0), + child: Row( + children: [ + Expanded( + child: Text( + state.draft.id == null ? '新建分区' : '编辑分区', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + onPressed: state.isSaving + ? null + : () => Navigator.of(context).pop(false), + icon: const Icon(Icons.close_rounded), + ), + ], + ), + ), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 8), + child: AbsorbPointer( + absorbing: state.isSaving, + child: Form( + key: _form, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + key: const ValueKey('region_name'), + controller: _name, + decoration: _decoration('分区名称 *'), + validator: (value) => + value == null || value.trim().isEmpty + ? '请填写分区名称' + : null, + ), + const SizedBox(height: 16), + TextFormField( + key: const ValueKey('region_code'), + controller: _code, + decoration: _decoration('分区编码'), + ), + const SizedBox(height: 16), + if (loading) ...[ + const LinearProgressIndicator(minHeight: 2), + const SizedBox(height: 10), + ], + if (state.routesStatus == + RegionRoutesStatus.failure) + Row( + children: [ + Expanded( + child: Text( + state.routesError ?? '路线加载失败', + style: const TextStyle( + color: Colors.red, + fontSize: 12, + ), + ), + ), + TextButton( + onPressed: widget.cubit.loadRoutes, + child: const Text('重新加载路线'), + ), + ], + ), + InputDecorator( + decoration: _decoration('默认路线'), + child: Row( + children: [ + Expanded( + child: DropdownButtonHideUnderline( + child: DropdownButton( + key: const ValueKey( + 'region_default_route', + ), + value: _defaultRoute, + isExpanded: true, + isDense: true, + menuMaxHeight: 260, + hint: Text( + loading ? '路线加载中' : '请选择默认路线(可选)', + style: const TextStyle(fontSize: 13), + ), + items: [ + for (final route in options.entries) + DropdownMenuItem( + value: route.key, + child: Text( + route.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + ), + ), + ), + ], + onChanged: loading + ? null + : (value) => setState( + () => _defaultRoute = value, + ), + ), + ), + ), + if (_defaultRoute != null) + InkWell( + onTap: () => + setState(() => _defaultRoute = null), + child: const Padding( + padding: EdgeInsets.only(left: 8), + child: Icon( + Icons.close_rounded, + size: 17, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + InkWell( + key: const ValueKey('region_bound_routes'), + onTap: loading + ? null + : () => _pickBoundRoutes(options), + child: InputDecorator( + decoration: _decoration('绑定路线'), + child: Wrap( + spacing: 6, + runSpacing: 4, + children: [ + if (_bound.isEmpty) + const Padding( + padding: EdgeInsets.symmetric( + vertical: 4, + ), + child: Text( + '请选择绑定路线(可多选)', + style: TextStyle( + fontSize: 13, + color: Colors.grey, + ), + ), + ), + for (final id in _bound) + InputChip( + label: Text( + options[id] ?? '路线 #$id', + style: const TextStyle(fontSize: 12), + ), + onDeleted: () => + setState(() => _bound.remove(id)), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + TextFormField( + key: const ValueKey('region_remark'), + controller: _remark, + minLines: 2, + maxLines: 3, + decoration: _decoration('备注'), + ), + const SizedBox(height: 12), + Text( + '当前区域范围点数:${state.draft.scope.length}/4', + style: TextStyle( + fontSize: 12, + color: state.draft.scopeError == null + ? Colors.grey + : Colors.red, + ), + ), + if (state.draft.scopeError != null) + Text( + state.draft.scopeError!, + style: const TextStyle( + fontSize: 12, + color: Colors.red, + ), + ), + if (_scopeError != null || state.error != null) + Padding( + padding: const EdgeInsets.only(top: 10), + child: Text( + _scopeError ?? state.error!, + key: const ValueKey('region_save_error'), + style: const TextStyle( + color: Colors.red, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: state.isSaving + ? null + : () => Navigator.of(context).pop(false), + child: const Text('取消'), + ), + const SizedBox(width: 12), + FilledButton( + key: const ValueKey('region_save'), + onPressed: + state.isSaving || + state.savedWithoutId || + state.draft.scopeError != null + ? null + : _submit, + child: state.isSaving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('确认保存'), + ), + ], + ), + ), + ], + ), + ), + ), + ); + }, + ); +} diff --git a/lib/features/regions/presentation/widgets/region_list_panel.dart b/lib/features/regions/presentation/widgets/region_list_panel.dart new file mode 100644 index 00000000..3514edc6 --- /dev/null +++ b/lib/features/regions/presentation/widgets/region_list_panel.dart @@ -0,0 +1,296 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../components/capsule_toast.dart'; +import '../../domain/entities/region_draft.dart'; +import '../cubit/region_list_cubit.dart'; + +/// 地图分区入口、竖屏区域管理共享的真实列表与操作界面。 +class RegionListPanel extends StatelessWidget { + const RegionListPanel({ + super.key, + required this.cubit, + required this.onCreate, + required this.onEdit, + required this.onRefresh, + required this.currentScope, + this.onSelect, + this.onDeleted, + this.onClose, + }); + final RegionListCubit cubit; + final VoidCallback onCreate; + final ValueChanged onEdit; + final ValueChanged? onSelect; + final ValueChanged? onDeleted; + final VoidCallback onRefresh; + final VoidCallback? onClose; + final Object? Function() currentScope; + + Future _delete(BuildContext context, RegionDraft region) async { + final scope = currentScope(); + if (scope != cubit.scopeKey) return; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('删除分区'), + content: Text( + '确定删除「${region.name.isEmpty ? '分区 #${region.id}' : region.name}」?删除后无法恢复。', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('取消'), + ), + FilledButton( + key: const ValueKey('region_delete_confirm'), + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('删除'), + ), + ], + ), + ); + if (!context.mounted || confirmed != true) return; + if (currentScope() != scope || cubit.scopeKey != scope) { + CapsuleToast.show('场站或账号已切换,请重新选择分区', showCheck: false); + return; + } + final deleted = await cubit.deleteRegion(region); + if (!context.mounted || !deleted) return; + onDeleted?.call(region); + CapsuleToast.show('分区已删除'); + } + + @override + Widget build( + BuildContext context, + ) => BlocBuilder( + bloc: cubit, + builder: (context, state) { + final busy = state.deletingId != null; + final scoped = state.siteId != null && currentScope() == cubit.scopeKey; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 4, 4), + child: Row( + children: [ + Expanded( + child: Text( + state.status == RegionListStatus.ready + ? '分区列表(${state.regions.length})' + : '分区列表', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w800, + color: Color(0xFF1A2B4A), + ), + ), + ), + TextButton.icon( + key: const ValueKey('region_list_create'), + onPressed: !scoped || busy ? null : onCreate, + icon: const Icon(Icons.add_rounded, size: 17), + label: const Text('新增'), + ), + IconButton( + tooltip: '刷新分区', + visualDensity: VisualDensity.compact, + onPressed: busy || state.status == RegionListStatus.loading + ? null + : onRefresh, + icon: const Icon(Icons.refresh_rounded, size: 19), + ), + if (onClose != null) + IconButton( + tooltip: '关闭列表', + visualDensity: VisualDensity.compact, + onPressed: busy ? null : onClose, + icon: const Icon(Icons.close_rounded, size: 19), + ), + ], + ), + ), + if (state.operationError != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Text( + state.operationError!, + key: const ValueKey('region_delete_error'), + style: const TextStyle(fontSize: 12, color: Colors.red), + ), + ), + Expanded( + child: switch (state.status) { + RegionListStatus.loading || RegionListStatus.idle => const Center( + child: CircularProgressIndicator(strokeWidth: 2), + ), + RegionListStatus.noUser => const Center(child: Text('请先登录')), + RegionListStatus.noSite => const Center( + child: Text('请选择场站后查看分区'), + ), + RegionListStatus.failure => Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + state.error ?? '分区加载失败', + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + TextButton( + onPressed: onRefresh, + child: const Text('重新加载分区'), + ), + ], + ), + ), + ), + RegionListStatus.ready => + state.regions.isEmpty + ? const Center(child: Text('当前场站暂无分区,可点击「新增」')) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(10, 4, 10, 16), + itemCount: state.regions.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final region = state.regions[index]; + final selected = state.selectedId == region.id; + return Material( + key: ValueKey('region_row_${region.id}'), + color: selected + ? const Color(0xFFEAF3FE) + : Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: selected + ? const Color(0xFF1C96F6) + : const Color(0xFFE5EDF7), + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: !scoped || busy + ? null + : () { + cubit.select(region.id); + onSelect?.call(region); + }, + child: Padding( + padding: const EdgeInsets.fromLTRB( + 12, + 10, + 8, + 4, + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + region.name.isEmpty + ? '分区 #${region.id}' + : region.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w800, + fontSize: 14, + ), + ), + ), + const SizedBox(width: 6), + Text( + region.enabled ? '启用' : '停用', + style: TextStyle( + fontSize: 11, + color: region.enabled + ? const Color(0xFF1D9E57) + : Colors.grey, + ), + ), + ], + ), + if (region.code.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + '编码:${region.code}', + style: const TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + ], + const SizedBox(height: 6), + Text( + '${region.scope.length} 个范围点 · ${region.boundRouteIds.length} 条绑定路线', + style: const TextStyle( + fontSize: 11.5, + color: Colors.grey, + ), + ), + if (region.scopeError != null) + const Text( + '范围需重新打点', + style: TextStyle( + fontSize: 11, + color: Colors.orange, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + key: ValueKey( + 'region_edit_${region.id}', + ), + onPressed: !scoped || busy + ? null + : () => onEdit(region), + child: const Text('修改'), + ), + TextButton( + key: ValueKey( + 'region_delete_${region.id}', + ), + onPressed: !scoped || busy + ? null + : () => _delete(context, region), + child: state.deletingId == region.id + ? const SizedBox( + width: 14, + height: 14, + child: + CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Text( + '删除', + style: TextStyle( + color: Color(0xFFD54941), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ), + }, + ), + ], + ); + }, + ); +} diff --git a/lib/features/remote_control/data/repositories/remote_control_repository_impl.dart b/lib/features/remote_control/data/repositories/remote_control_repository_impl.dart index 91f66022..0bb75221 100644 --- a/lib/features/remote_control/data/repositories/remote_control_repository_impl.dart +++ b/lib/features/remote_control/data/repositories/remote_control_repository_impl.dart @@ -34,38 +34,58 @@ class RemoteControlRepositoryImpl implements RemoteControlRepository { } @override - void sendControlMachineCmd(MachineControlStatusEntity status) { - // 1. 调用算法:将摇杆坐标 (x, y) 转换为线速度/角速度(物理量) - final speeds = _diffSteer.calculate(status.originX, status.originY); - final linear = speeds['linear']!; - final angular = speeds['angular']!; + bool sendControlMachineCmd(MachineControlStatusEntity status) { + // 🔥 整个“换算 + 编码 + 写 socket”都包在 try 里:本方法被 200ms 控制循环 + // 和停止风暴(一次 30 帧)高频调用,任何一帧抛异常都会变成 Timer 回调 + // 里的未捕获异常(每 200ms 刷一次),更糟的是会让 stopAllMovement 的 + // 停止帧中途断掉——车就停不下来。失败一律返回 false,由调用方统计 + // “一共出网了几帧”(控制循环里本来就不关心返回值)。 + try { + // 1. 调用算法:将摇杆坐标 (x, y) 转换为线速度/角速度(物理量) + final speeds = _diffSteer.calculate(status.originX, status.originY); + final linear = speeds['linear']!; + final angular = speeds['angular']!; - // 2. 调用 Codec:生成协议要求的 20 字节 Payload(线速度/角速度 double 直传) - final payload = MachineProtocolCodec.encodeRemoteControlPayload( - linear: linear, - angular: angular, - lift: status.chassisLift, - mower: status.mowerSpeed, - ignition: status.ignitionStatus, - emergency: status.isEmergency ? 1 : 0, - ); + // 2. 调用 Codec:生成协议要求的 20 字节 Payload(线速度/角速度 double 直传) + final payload = MachineProtocolCodec.encodeRemoteControlPayload( + linear: linear, + angular: angular, + lift: status.chassisLift, + mower: status.mowerSpeed, + ignition: status.ignitionStatus, + emergency: status.isEmergency ? 1 : 0, + ); - // 联调日志:三段式打印 ①滑块/摇杆原始数据 ②乘完系数的发送值 ③包装后的整帧字节(联调结束可移除) - _logger.logWithLevel( - '🔗 [联调-0x00] 遥控发送明细\n' - '① 滑块/摇杆原始数据: x=${status.originX} y=${status.originY} ' - 'lift=${status.chassisLift} mower=${status.mowerSpeed} ' - 'ignition=${status.ignitionStatus} emergency=${status.isEmergency ? 1 : 0}\n' - '② 乘后发送值: linear=${linear.toStringAsFixed(3)} m/s, angular=${angular.toStringAsFixed(3)} rad/s\n' - '③ 包装后整帧(27字节): ab aa 00 | ${_hex(payload.sublist(0, 8))} | ' - '${_hex(payload.sublist(8, 16))} | ${_hex(payload.sublist(16, 20))} | 00 00 | aa ab\n' - ' 包头+cmd | linear@帧[3..10] | angular@帧[11..18] | ' - 'lift,mower,ignition,emergency@帧[19..22] | CRC | 包尾', - shouldLog: true, - ); + // 联调日志:三段式打印 ①滑块/摇杆原始数据 ②乘完系数的发送值 ③包装后的整帧字节(联调结束可移除) + _logger.logWithLevel( + '🔗 [联调-0x00] 遥控发送明细\n' + '① 滑块/摇杆原始数据: x=${status.originX} y=${status.originY} ' + 'lift=${status.chassisLift} mower=${status.mowerSpeed} ' + 'ignition=${status.ignitionStatus} emergency=${status.isEmergency ? 1 : 0}\n' + '② 乘后发送值: linear=${linear.toStringAsFixed(3)} m/s, angular=${angular.toStringAsFixed(3)} rad/s\n' + '③ 包装后整帧(27字节): ab aa 00 | ${_hex(payload.sublist(0, 8))} | ' + '${_hex(payload.sublist(8, 16))} | ${_hex(payload.sublist(16, 20))} | 00 00 | aa ab\n' + ' 包头+cmd | linear@帧[3..10] | angular@帧[11..18] | ' + 'lift,mower,ignition,emergency@帧[19..22] | CRC | 包尾', + shouldLog: true, + ); - // 3. 调用 TcpClient 发送指令 - _tcpClient.sendRaw(MachineProtocolConstants.cmdRemoteControl, payload); + // 3. 调用 TcpClient 发送指令 + // 🔥 把写入结果如实上抛:false 代表本帧没交给系统 socket(未连接/写入异常), + // 调用方(尤其 stopAllMovement 的停止风暴)需要知道“发出去了几个”。 + return _tcpClient.sendRaw( + MachineProtocolConstants.cmdRemoteControl, + payload, + ); + } catch (e, s) { + debugPrint('❌ [RemoteControl] 遥控指令构造/发送异常: $e'); + _logger.logWithLevel( + '❌ [RemoteControl] 遥控指令构造/发送异常: $e\n$s', + level: 'ERROR', + shouldLog: true, + ); + return false; + } } /// 字节转十六进制字符串,供联调对包使用 @@ -77,8 +97,25 @@ class RemoteControlRepositoryImpl implements RemoteControlRepository { @override void sendTcpPermissionRequest(String deviceName) { - _remoteTcp.sendSwitchControlRequest(deviceName); - _logger.logWithLevel('🔑 [RemoteControl] TCP已发送权限请求', shouldLog: true); + // 🔥 RemoteTcpDatasource.sendSwitchControlRequest 在 TCP 未连接时是 throw 的, + // 而本方法是在 requestControlPermissionS 的 result.fold(async 回调) 里调的: + // fold 不会 await 那个回调的 Future,异常从这条路径出去就是彻底的未捕获 + // 异步异常(点“获取控制权”时断着 TCP 就会撞上)。抢占请求发不出去最坏 + // 只是等不到 0x12 回包,不该崩,故就地吞掉并留 ERROR 痕迹。 + try { + _remoteTcp.sendSwitchControlRequest(deviceName); + _logger.logWithLevel( + '🔑 [RemoteControl] TCP已发送权限请求', + shouldLog: true, + ); + } catch (e) { + debugPrint('❌ [RemoteControl] TCP 权限请求发送失败: $e'); + _logger.logWithLevel( + '❌ [RemoteControl] TCP 权限请求发送失败(未连接): $e', + level: 'ERROR', + shouldLog: true, + ); + } } @override @@ -177,9 +214,22 @@ class RemoteControlRepositoryImpl implements RemoteControlRepository { required String deviceId, required int type, }) async { - return await _remoteHttp.releaseRemoteControlViaHttp( - deviceId: deviceId, - type: type, - ); + // 🔥 datasource 内部只包住了 dio.post,前面的 _userStorage.getUser() 仍会抛; + // 而调用方多在页面 dispose 里 `.then()` 发就不管(没有 onError),抛出来 + // 就是未捕获异常。释放失败最坏是服务端权限挂着等超时,不该崩,收敛成 false。 + try { + return await _remoteHttp.releaseRemoteControlViaHttp( + deviceId: deviceId, + type: type, + ); + } catch (e) { + debugPrint('❌ [RemoteControl] 释放控制权异常: $e'); + _logger.logWithLevel( + '❌ [RemoteControl] 释放控制权异常: $e', + level: 'ERROR', + shouldLog: true, + ); + return false; + } } } diff --git a/lib/features/remote_control/domain/repositories/remote_control_repository.dart b/lib/features/remote_control/domain/repositories/remote_control_repository.dart index d6410fdd..94b60ad3 100644 --- a/lib/features/remote_control/domain/repositories/remote_control_repository.dart +++ b/lib/features/remote_control/domain/repositories/remote_control_repository.dart @@ -2,9 +2,13 @@ import '../../../../core/network/protocol_decoder.dart'; import '../../domain/entities/machine_control_status_entity.dart'; abstract class RemoteControlRepository { - /// 高频发送遥控指令 (被 Cubit 的 100ms 定时器调用) + /// 高频发送遥控指令 (被 Cubit 的 200ms 定时器调用) /// 负责将 Entity 转换为底层的 0x00 指令并发出 - void sendControlMachineCmd(MachineControlStatusEntity status); + /// + /// 返回 `false` = 本帧未交给系统 socket(未连接或写入失败)。 + /// 🔥 安全路径(stopAllMovement / 急停帧)必须据此判断指令是否真出网, + /// 不可再假定「调用了就等于设备收到了」。 + bool sendControlMachineCmd(MachineControlStatusEntity status); /// 暴露解析后的回包流 (如 0x12 权限申请) /// UI 或 Cubit 监听此流来处理机器人主动推送的消息 diff --git a/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart b/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart index b50224dd..b0eec3c0 100644 --- a/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart +++ b/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart @@ -28,7 +28,15 @@ import '../../domain/entities/reverse_camera_data.dart'; import '../../domain/repositories/remote_control_repository.dart'; import '../../domain/usecase/remote_control_usecase.dart'; -class RemoteControlCubit extends Cubit { +/// 远程控制核心 Cubit(全局单例,极简模式远程遥控页与新版 HUD 共用)。 +/// +/// 🔥 自带前后台生命周期([WidgetsBindingObserver]):200ms 控制循环是 Dart VM 层的 +/// Timer,退后台 / 锁屏后照旧跟发,而 main.dart 的全局回调只管会话标记与 TCP +/// 重连、不碰循环,所以“切后台那一刻手指还压在摇杆上”就会留下非零轴值; +/// 回前台 TCP 重连后车能自己走。本类自己接管:退后台挂起循环 + 归零, +/// 回前台且仍在控制态则重启(与 LocalCameraCubit 的自管生命周期同一取向)。 +class RemoteControlCubit extends Cubit + with WidgetsBindingObserver { final RemoteControlRepository _repository; final RequestControlPermissionUseCase _requestControlPermissionUseCase; final DeviceRepository _deviceRepository; // 🔥 注入设备仓库 @@ -47,6 +55,15 @@ class RemoteControlCubit extends Cubit { int _currentOriginX = 0; int _currentOriginY = 0; + /// 业务侧是否期望处于“控制态”:[startControlLoop] 置真、[stopControlLoop] 置假。 + /// 它代表“意图”,与定时器的实时存在与否解耦:被后台挂起时仍为真, + /// 回前台靠它判断要不要自动重启循环;stopAllMovement 末尾也靠它判断该不该重调。 + bool _controlWanted = false; + + /// 因退到后台(inactive/paused/hidden)而临时挂起控制循环。 + /// 挂起期间所有 startControlLoop 调用只记意图、不起定时器,回前台再恢复。 + bool _suspendedByLifecycle = false; + // 🔥 权限请求处理标志位 - 防止竞态条件,避免重复显示弹窗 bool _isProcessingPermissionRequest = false; @@ -83,6 +100,9 @@ class RemoteControlCubit extends Cubit { // 回滚:注释下行、改回 _initDeviceStatusListener() 即可。 _initTcpStatusListener(); // _initDeviceStatusListener(); // 🔥 旧:订阅 DeviceStatusBloc(MQTT),已停用 + // 生命周期观察与本类同生同灭(close() 里成对移除):单例常驻, + // 不挂在某个页面上,所以从别的路径进控态也能被护住。 + WidgetsBinding.instance.addObserver(this); } // 🔥 新增:订阅 DeviceStatusBloc 的状态流,而不是直接监听TCP @@ -105,9 +125,8 @@ class RemoteControlCubit extends Cubit { // 第一步:所有数据先存入缓存,不管来多频繁都存最新值 final voltage = deviceState.status.voltage; final battery = deviceState.status.battery; - final controlMode = deviceState.status.controlMode == '3' - ? '远程模式' - : '本地模式'; + // 只存协议原始值('3'=远程/'0'=本地等),展示文案由 UI 按当前语言翻译 + final controlMode = deviceState.status.controlMode; final c = await getNetworkDelay(); _cacheVoltage = voltage.toString(); @@ -167,7 +186,8 @@ class RemoteControlCubit extends Cubit { } final status = RunningStatusEntity.fromFields(fields); - final controlMode = status.controlMode == '3' ? '远程模式' : '本地模式'; + // 只存协议原始值('3'=远程/'0'=本地等),展示文案由 UI 按当前语言翻译 + final controlMode = status.controlMode; // 🔥 门槛:收到 TCP 0x02 即视为推送活跃(节流前刷新,轻量无阻塞) _lastStatusPushTime = DateTime.now(); @@ -426,6 +446,12 @@ class RemoteControlCubit extends Cubit { // 🔥 修复:不强制关闭弹窗,让弹窗由用户操作控制 ), ); + // 🔥 失权即归零 + 补发全停:控制循环只是“跳过发送”, + // _currentOriginX/Y 会原样留着;只改 hasPermission 的话,重新 + // 拿回控制权的那一 tick 就会按失权前的推量原地发车。而且设备只认 + // “最后收到的运动帧”,光清本地值不会让已在跑的车停下 —— + // 故用 stopAllMovement(它的停止帧无视权限强制发送)。 + unawaited(stopAllMovement()); } } } @@ -565,13 +591,115 @@ class RemoteControlCubit extends Cubit { }); emit(state.copyWith(status: RemoteControlStatus.controlling)); }*/ - void startControlLoop() { + /// 🔥 双轴归零(静默):只清本地摇杆值并同步 UI,**不发停止帧**。 + /// 需要连发停止帧压住 TCP 积压时用 [stopAllMovement];这里用于“只是不能让 + /// 残留值下次复活”的场景(失权 / 急停 / 进控态)。 + void _zeroAxesQuietly() { + if (isClosed) return; + if (_currentOriginX == 0 && _currentOriginY == 0) return; + _currentOriginX = 0; + _currentOriginY = 0; + emit( + state.copyWith(controlEntity: state.controlEntity.copyWith(x: 0, y: 0)), + ); + _syncReverseCameraFromJoystick(); + } + + /// 回前台重启循环的延迟定时器(避开 TCP 重连窗口),close() 需成对取消。 + Timer? _resumeTimer; + + /// 🔥 退后台挂起控制循环:停定时器 + 归零 + 尽力补发一次全停。 + /// + /// 为什么不能只做“跳过发送”:系统不保证会给 [GestureDetector] 发 ACTION_CANCEL, + /// 下拉通知栏 / 进多任务预览(只到 inactive)时摇杆的三重松手兜底可能一个都没 + /// 触发,非零轴值就这样留在单例上;回前台 reconnectAfterResume 把 socket 重建后, + /// 循环下一 tick 就会用这个没人碰的残留值发车。 + void _suspendControlLoop() { + if (!_controlWanted || _suspendedByLifecycle) return; + // 先置挂起位:下方 stopAllMovement 末尾会调 startControlLoop,必须被拦住。 + _suspendedByLifecycle = true; + _resumeTimer?.cancel(); + _resumeTimer = null; _timer?.cancel(); + _timer = null; + debugPrint('🌙 [RemoteControl] [生命周期] 退后台:挂起控制循环 + 双轴归零'); + _logger.logWithLevel( + '🌙 [RemoteControl] [生命周期] 退后台:挂起控制循环 + 双轴归零', + shouldLog: true, + ); + // best-effort:socket 已被冻住时这些帧会被 sendRaw 如实判为失败(已有节流日志), + // 但本地轴值已归零,回前台重启循环也只会发零速帧。 + unawaited(stopAllMovement()); + } + + /// 🔥 回前台恢复控制循环:仅当业务侧仍处于控制态。 + void _resumeControlLoop() { + if (!_suspendedByLifecycle) return; + _suspendedByLifecycle = false; + if (!_controlWanted) { + // 后台期间用户已退页 / 折叠(stopControlLoop 已把意图关掉),不复活。 + debugPrint('☀️ [RemoteControl] [生命周期] 回前台:已不在控制态,不重启循环'); + return; + } + debugPrint('☀️ [RemoteControl] [生命周期] 回前台:延迟重启控制循环'); + // 错开重连窗口:main.dart 的 _handleResume 要先等 500ms 才 + // reconnectAfterResume();立刻起循环只会把首批指令打在空 socket 上被丢弃。 + _resumeTimer?.cancel(); + _resumeTimer = Timer(const Duration(milliseconds: 800), () { + if (isClosed || !_controlWanted || _suspendedByLifecycle) return; + startControlLoop(); + }); + } + + @override + // 形参不跟父类叫 state:在 Cubit 里它会遮蔽 cubit 自己的 state getter, + // 后来人误用会拿到 AppLifecycleState 而不是 RemoteControlState。 + // ignore: avoid_renaming_method_parameters + void didChangeAppLifecycleState(AppLifecycleState lifecycle) { + switch (lifecycle) { + case AppLifecycleState.inactive: + case AppLifecycleState.paused: + case AppLifecycleState.hidden: + _suspendControlLoop(); + break; + case AppLifecycleState.resumed: + _resumeControlLoop(); + break; + case AppLifecycleState.detached: + // 进程即将销毁,无需也不应在这里做异步 IO。 + break; + } + } + + /// [resetAxes] 是否在起循环前把双轴静默归零。默认 true(冷启动:路由 / 旧页 + /// initState / HUD activate / 回前台恢复);[stopAllMovement] 末尾的热重启必须 + /// 传 false,理由见那里。 + void startControlLoop({bool resetAxes = true}) { + // 🔥 已 close 的循环既不该起也不该写状态(定时器 tick 里另有 isClosed 兜底)。 + if (isClosed) return; + _timer?.cancel(); + // 进控制态的唯一入口:记下意图(后台挂起期间也记,回前台靠它恢复)。 + _controlWanted = true; + if (_suspendedByLifecycle) { + // 🔥 后台挂起期间任何人调进来都只记意图、不起定时器:否则 + // stopAllMovement 末尾、或后台里走完的权限流程会把循环重新点着。 + debugPrint('🌙 [RemoteControl] [控制循环] 已挂起,仅记录控制意图不起定时器'); + return; + } final logPrefix = '⏰ [RemoteControl] [控制循环]'; //debugPrint('$logPrefix ========================================='); //debugPrint('$logPrefix 启动控制循环,间隔: 200ms'); ///debugPrint('$logPrefix ========================================='); + // 🔥 安全兼容:进控制态一律从“零速”起步。 + // originX/Y 存在本单例的成员变量上,上一次会话若是“按住摇杆直接退页 / + // 切视图”(没走松手归零路径),残留的非零值会让本次刚进页面就持续 + // 下发运动指令 —— 车自己开走。冷启动入口(路由 create / 旧页 initState / + // HUD activate)都经这里,故在源头清一次。 + if (resetAxes) { + _zeroAxesQuietly(); + } + // 🔥 启动模拟设备状态推送(用于测试) // startSimulation(); @@ -582,6 +710,16 @@ class RemoteControlCubit extends Cubit { return; } + // 🔥 锁定态门控(与 hasPermission / isEmergency 同层):旧版只在摇杆区域套 + // IgnorePointer(ignoring: isLocked),靠 UI 遮挡;但 isLocked 住在单例里,会 + // 跨页面残留,而新版 HUD 没有锁定入口也不读这个位。锁上一律不发运动帧, + // 两版行为一致且不依赖 UI。(退页时 resetPermissionState 会把它清掉, + // 不会出现“带着锁定进 HUD 导致摇杆失灵”。) + if (state.isLocked) { + // debugPrint('$logPrefix 🔒 锁定状态,跳过发送 - isLocked=true'); + return; + } + // 🔥 参考Android版:直接读取成员变量,避免state竞态 final snapshot = MachineControlStatusEntity( originX: _currentOriginX, @@ -656,6 +794,9 @@ class RemoteControlCubit extends Cubit { void updateOriginY(int y) { // debugPrint('📥 [updateOriginY] 被调用- y=$y'); + // 🔥 页面销毁后可能还有迟到的 pointer 回调(CCJoystick 内部有 await), + // 不拦住就会在已 close 的对象上 emit。 + if (isClosed) return; // 🔥 参考Android版:直接更新成员变量 _currentOriginY = y; @@ -671,6 +812,8 @@ class RemoteControlCubit extends Cubit { void updateOriginX(int x) { // debugPrint('📥 [updateOriginX] 被调用- x=$x'); + // 🔥 同 updateOriginY:防页面销毁后迟到的摇杆回调打到已 close 的对象。 + if (isClosed) return; // 🔥 参考Android版:直接更新成员变量 _currentOriginX = x; @@ -714,6 +857,14 @@ class RemoteControlCubit extends Cubit { /// 🔥 安全方法:同时清零双轴,确保只发送一次完全停止指令 Future stopAllMovement() async { + // 🔥 cubit 已 close(退登录/销毁单例)时:只清本地值就返回。 + // 本方法多处被 unawaited() 调走(后台挂起 / 失权 / 松手),它的 await 链 + // 比页面活得久,不拦住就会在已关闭对象上 emit,抛未捕获的 StateError。 + if (isClosed) { + _currentOriginX = 0; + _currentOriginY = 0; + return; + } // debugPrint( // '🛑 [stopAllMovement] 开始执行- 当前成员变量: originX=$_currentOriginX, originY=$_currentOriginY', // ); @@ -736,29 +887,58 @@ class RemoteControlCubit extends Cubit { // 🔥 安全底线:停止指令必须无视权限强制发送! // debugPrint('🛑 [stopAllMovement] 双轴归零,发送停止指令- originX=0, originY=0'); - await _sendStopCommandRepeatedly(updatedEntity); // 等待所有指令发送完成 + final sentFrames = await _sendStopCommandRepeatedly( + updatedEntity, + ); // 等待所有指令发送完成 + if (sentFrames == 0) { + // 一帧都没出网(TCP 未连接):不能当“已停住”。本地轴值已归零, + // 重连后由控制循环发零速帧接管;这里留下一条可查证的 ERROR 痕迹。 + debugPrint('🛑 [stopAllMovement] ⚠️ 停止指令全部未出网(TCP 未连接)'); + _logger.logWithLevel( + '🛑 [stopAllMovement] ⚠️ 30 帧停止指令全部未出网(_socket=null),' + '本地轴值已归零,重连后由控制循环补发零速帧', + level: 'ERROR', + shouldLog: true, + ); + } - // 🔥 恢复定时器 + // 🔥 恢复定时器:仅当业务侧仍处于控制态。 + // 上面这 ~400ms 窗口里页面可能已 dispose(stopControlLoop 把意图关掉了), + // 无条件重启会让循环在没人控制的页面上空转到 cubit close;后台挂起期间 + // startControlLoop 自身也会拦住定时器,只留意图给回前台恢复。 await Future.delayed(const Duration(milliseconds: 200)); - startControlLoop(); + if (_controlWanted) { + // 🔥 热重启不清轴:上面这 ~450ms 里用户完全可能又把摇杆压回去了 + // (updateOriginX/Y 会把非零值写回 _currentOriginX/Y)。若在这里再 + // 调 _zeroAxesQuietly(),就会抹掉他手指正压着的目标量;而 UI 侧的 + // 去重基准(HUD 的 _lastDriveY / 旧版摇杆的 _lastSentY)仍是那个非零值, + // 于是“手指明明压在摇杆上、车却不动”,得再推一下才恢复。 + // 跨会话残留值的清理只在冷启动入口做(那里 resetAxes 默认 true)。 + startControlLoop(resetAxes: false); + } else { + debugPrint('🛑 [stopAllMovement] 已不在控制态,不重启循环'); + } // debugPrint('>>> [stopAllMovement] 执行完成'); } - /// 🔥 统一方法:连续发送10次停止指令,彻底清空TCP缓冲区 - Future _sendStopCommandRepeatedly( + /// 🔥 统一方法:连续发送停止指令,彻底清空TCP缓冲区。 + /// + /// 返回真正写入 socket 的帧数:sendRaw 在未连接时会直接失败, + /// 调用方据此判断“停止指令是不是真的发出去了”(安全路径必须知道这一点)。 + Future _sendStopCommandRepeatedly( MachineControlStatusEntity stopEntity, ) async { // debugPrint( - // '🛑 [紧急停止] 开始连续发送10次停止指令- originX=${stopEntity.originX}, originY=${stopEntity.originY}', + // '🛑 [紧急停止] 开始连续发送 10 次停止指令- originX=${stopEntity.originX}, originY=${stopEntity.originY}', // ); - // 🔥 注意:_lastStopTime 已经在stopAllMovement() 中设置了,这里不需要再设置 + // 🔥 本方法不做任何节流:停止帧必须一帧不少地发完。(旧注释写的“_lastStopTime + // 已在 stopAllMovement 里设置”是假信息 —— 全项目并没有这个变量,勿据此加节流。) int sentCount = 0; // 🔥 关键修复:每次发送间隔5ms,避免TCP合并/丢弃 for (int i = 0; i < 20; i++) { - _repository.sendControlMachineCmd(stopEntity); - sentCount++; + if (_repository.sendControlMachineCmd(stopEntity)) sentCount++; if (i % 5 == 0) { // debugPrint('🛑 [紧急停止] 已发送第${i + 1}次'); } @@ -770,25 +950,38 @@ class RemoteControlCubit extends Cubit { // 🔥 延迟后再发10次(双重保险,对抗网络抖动) await Future.delayed(const Duration(milliseconds: 100)); for (int i = 0; i < 10; i++) { - _repository.sendControlMachineCmd(stopEntity); - sentCount++; + if (_repository.sendControlMachineCmd(stopEntity)) sentCount++; if (i < 9) { await Future.delayed(const Duration(milliseconds: 5)); } } // debugPrint('>>> [紧急停止] 所有20次停止指令已发出'); + return sentCount; } // 5. 停止控制循环 void stopControlLoop() { + // 🔥 退出控制态:意图一并关掉,否则 pending 中的 stopAllMovement 末尾 + // 会把循环重新点着,回前台时也会错误地恢复一个已退页的控制循环。 + _controlWanted = false; + _resumeTimer?.cancel(); + _resumeTimer = null; _timer?.cancel(); _timer = null; emit(state.copyWith(status: RemoteControlStatus.initial)); } void toggleLock() { - emit(state.copyWith(isLocked: !state.isLocked)); + final willLock = !state.isLocked; + emit(state.copyWith(isLocked: willLock)); + // 🔥 锁定瞬间归零 + 连发停止帧(与失权 / 急停同一道理): + // IgnorePointer 只拦住以后的推杆,手指正压在摇杆上那一划的 + // _currentOriginX/Y 仍会留在单例上;而设备只认“最后收到的运动帧”, + // 光拦不发不会让已在跑的车停下。 + if (willLock) { + unawaited(stopAllMovement()); + } } void togglePermissionDialog(bool show) { @@ -805,24 +998,61 @@ class RemoteControlCubit extends Cubit { emit(state.copyWith(showRightPip: !state.showRightPip)); // 🔥 切换前后视角(双击屏幕触发) + // + // 这是**纯 UI 层面的翻转**:WebRTCLocalPlayer 用 `isFrontMain: originY >= 0` + // 决定主画面算“前视”还是“后视”(见 remote_control_page 的双击入口)。 + // + // 🔥 以前它还会 `_repository.sendControlMachineCmd(updatedEntity)` 直发一帧: + // ① 它绕过了控制循环的 hasPermission / isEmergency 两道门控,无权限或急停时 + // 也能把指令弄到设备上; + // ② y=±1 经 diffSteer 换算后是非零线速度,等于双击一下屏幕车会动一下; + // ③ 控制循环读的是成员变量 _currentOriginY,不认这个改动,发出去也是脏值。 + // 故只保留状态更新(视角照常生效),不再往设备发帧。 void toggleCameraView() { + if (isClosed) return; final currentOriginY = state.controlEntity.originY; // 🔥 originY >= 0 表示前视角,< 0 表示后视角 // 切换逻辑:如果当前是前视角(>=0),切换到后视角(-1);反之亦然 final newOriginY = currentOriginY >= 0 ? -1 : 1; - + debugPrint('📷 [RemoteControlCubit] 切换前后视角: $currentOriginY -> $newOriginY'); - + final updatedEntity = state.controlEntity.copyWith(y: newOriginY); emit(state.copyWith(controlEntity: updatedEntity)); - - // 🔥 发送 TCP 指令到设备 - _repository.sendControlMachineCmd(updatedEntity); + } + + // 🔥 全局护栏:close 之后的 emit 一律吃下,不让它抛 StateError。 + // + // bloc 9.2.1 的 Cubit.emit 在已关闭时会 `throw StateError('Cannot emit new + // states after calling close')`(见 bloc_base.dart)。本 Cubit 是单例,而且 + // 到处都是“比页面活得久”的异步回调(0x12 监听 / TCP 重连 / stopAllMovement 的 + // await 链 / unawaited 全停),靠逐个加 isClosed 守卫总会有漏网的一条: + // 退登录把单例关掉后,一条迟到的回包就能在控制台抛未捕获异常(调试时直接红屏)。 + // 因此这里集中拦:状态丢了就丢了(对象已无人监听),但绝不让异常外渗。 + @override + @protected + @visibleForTesting + void emit(RemoteControlState state) { + if (isClosed) { + debugPrint( + '⚠️ [RemoteControl] Cubit 已关闭,忽略一次 emit(异步回调迟到)', + ); + return; + } + super.emit(state); } @override Future close() { + // 与构造函数里的 addObserver 成对移除,否则回调会打在已关闭的对象上。 + WidgetsBinding.instance.removeObserver(this); _timer?.cancel(); + _kickOutSub?.cancel(); // 🔥 0x12 监听:不取消会留着回调打在已 close 的对象上 + _kickOutSub = null; // 且下次重新 init 后会叠出第二个监听器(重复弹窗) + _resumeTimer?.cancel(); + _resumeTimer = null; + _controlWanted = false; + _suspendedByLifecycle = false; _deviceStatusSub?.cancel(); // 🔥 取消订阅 DeviceStatusBloc _tcpStatusSub?.cancel(); // 🔥 取消订阅 TCP 0x02 门槛监听 _reverseCameraSub?.cancel(); // 🔥 取消订阅倒车影像数据流 @@ -858,6 +1088,11 @@ class RemoteControlCubit extends Cubit { void updateEmergency(bool on) { // debugPrint('🚨 [急停] ${on ? "触发急停!" : "解除急停"}'); + // 🔥 先归零双轴:急停只是让循环“跳过发送”,_currentOriginX/Y 仍原样留着; + // 不归零的话,updateEmergency(false) 解除的那一 tick 就会按急停前的推量 + // 原地复活(“急停→解除→车又自己走”)。触发与解除都清,语义变成 + // “一旦碰过急停就必须重新推杆”。下面的全零急停帧会把已在跑的车压停。 + _zeroAxesQuietly(); // ① 先置状态:急停时让 200ms 控制循环下一 tick 立刻停发运动指令; // 解除时循环恢复正常发送。 updateFunction(emergency: on); @@ -880,13 +1115,24 @@ class RemoteControlCubit extends Cubit { ignitionStatus: 0, isEmergency: emergency, ); + int sentFrames = 0; for (int i = 0; i < 10; i++) { if (isClosed) return; - _repository.sendControlMachineCmd(frame); + if (_repository.sendControlMachineCmd(frame)) sentFrames++; if (i < 9) { await Future.delayed(const Duration(milliseconds: 5)); } } + // 🔥 急停帧也是安全路径:一帧都没出网时必须留下痕迹,否则“按了急停但 + // 设备没收到”会被当成已停住(与 stopAllMovement 同一口径)。 + if (sentFrames == 0) { + debugPrint('🚨 [急停] ⚠️ 急停帧全部未出网(TCP 未连接)emergency=$emergency'); + _logger.logWithLevel( + '🚨 [急停] ⚠️ 10 帧急停指令全部未出网(_socket=null),emergency=$emergency', + level: 'ERROR', + shouldLog: true, + ); + } } void respondPermission(bool agreed, String deviceId) { @@ -894,13 +1140,25 @@ class RemoteControlCubit extends Cubit { emit(state.copyWith(showPermissionRequestDialog: false)); // 2. 发送响应到服务器 + // 🔥 不 rethrow:这里处于权限弹窗的按钮回调中间,异常一旦飞出去, + // 调用方的 `resetPermissionCoolDown()` / `togglePermissionDialog(false)` / + // `Navigator.pop()` 全都不会执行,barrierDismissible:false 的模态弹窗就 + // 死在屏幕上(只能物理返回),同时附带一条未捕获异步异常。 + // 发送失败时只做到这里为止:弹窗标志已复位(第 1 步),而权限态 + // 不往下改——响应根本没出网,服务端仍认为本端持有控制权,保持原值才是 + // 两边一致的状态;下次 activate / 走仅查询会从 HTTP 拿到真实结果。 try { _repository.respondPermission(agreed, deviceId); } catch (e) { - debugPrint(' ❌ TCP权限响应指令发送失败: $e'); + debugPrint(' ❌ TCP权限响应指令发送失败(不抛出,保证弹窗能关闭): $e'); debugPrint(' ❌ 错误类型: ${e.runtimeType}'); - _logger.logWithLevel(' TCP权限响应指令发送失败 $e', shouldLog: true); - rethrow; + _logger.logWithLevel( + ' ❌ TCP权限响应指令发送失败(不抛出,保证弹窗能关闭): $e', + level: 'ERROR', + shouldLog: true, + ); + debugPrint('${_getTimePrefix()} ====权限弹窗响应提前结束===='); + return; } // 3. 根据用户选择更新控制状态 @@ -911,6 +1169,9 @@ class RemoteControlCubit extends Cubit { showPermissionRequestDialog: false, ), ); + // 🔥 主动让权同样要归零 + 全停(理由同 0x12 失权分支):用户“同意”的瞬间 + // 可能手指还压在摇杆上,不归零则下次拿回控制权时会按旧推量复活。 + unawaited(stopAllMovement()); } else { // 用户拒绝 APP 继续保持控制权 // 🔥 必须同时设置 showPermissionRequestDialog: false,防止状态回退 @@ -1047,18 +1308,34 @@ class RemoteControlCubit extends Cubit { ); } - /// 🔥 退出遥控页时重置权限状态,避免单例状态残留导致下次进入不再查询接口 + /// 🔥 退出遥控页时重置“会话级”状态(权限 / 弹窗 / 锁定 / 急停),避免单例残留 void resetPermissionState() { if (!isClosed) { emit( state.copyWith( hasPermission: false, showPermissionRequestDialog: false, + // 🔥 锁定位一并复位:它是页面内的一次性 UI 态,残留到下一次会话 + // 会让控制循环被上面的 isLocked 门控整体拦住(新版 HUD 没有 + // 解锁按钮,到时候就是“推了不动”且无法自救)。 + isLocked: false, + // 🔥 急停位同样复位(只改状态,不再补发帧):旧版“点急停直接退页” + // 会把 isEmergency=true 留在单例上,而新版 HUD 的急停按钮是页面 + // 本地变量(_startControl 里置 false)——两边不一致时,HUD 显示 + // “未急停”但循环被 isEmergency 拦住,同样是“推了不动”。 + // 与新版 deactivate() 里的 updateEmergency(false) 同一口径;设备侧 + // 已收到退页时的全停帧,下次会话循环一开就会发 emergency=0 帧。 + // controlEntity 里的 emergency 位也要一并清:stopAllMovement 的停止 + // 帧用的是这个实体,残留会让下次会话松手时错发一串急停帧。 + isEmergency: false, + controlEntity: state.controlEntity.copyWith(emergency: false), ), ); - debugPrint('🔑 [RemoteControl] 已重置权限状态 hasPermission=false'); + debugPrint( + '🔑 [RemoteControl] 已重置会话态 hasPermission=false, isLocked=false, isEmergency=false', + ); _logger.logWithLevel( - '🔑 [RemoteControl] 已重置权限状态 hasPermission=false', + '🔑 [RemoteControl] 已重置会话态 hasPermission=false, isLocked=false, isEmergency=false', shouldLog: true, ); } @@ -1299,6 +1576,12 @@ class RemoteControlCubit extends Cubit { void clearAll() { _timer?.cancel(); _timer = null; + _resumeTimer?.cancel(); + _resumeTimer = null; + // 🔥 退出登录:控制意图与后台挂起位一并清掉,否则回前台会恢复一个 + // 已不属于任何页面的控制循环。 + _controlWanted = false; + _suspendedByLifecycle = false; _cacheVoltage = null; _cacheBattery = null; diff --git a/lib/features/remote_control/presentation/pages/remote_control_page.dart b/lib/features/remote_control/presentation/pages/remote_control_page.dart index a115f3eb..aa045ed8 100644 --- a/lib/features/remote_control/presentation/pages/remote_control_page.dart +++ b/lib/features/remote_control/presentation/pages/remote_control_page.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'package:maibu_satabot_v2/features/device_control_mode/domain/entities/device_control_mode.dart'; +import 'package:maibu_satabot_v2/features/device_control_mode/presentation/widgets/control_mode_entry_gate.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -37,11 +39,18 @@ class _RemoteControlPageState extends State { StreamSubscription? _permissionSubscription; // 🔥 权限监听订阅 bool _isShowingPermissionDialog = false; // 🔥 防止弹窗重复显示 bool? _lastPermissionDialogState; // 🔥 记录上次弹窗状态,检测状态变化 + bool _controlSessionStarted = false; + late final String _modeDeviceId = _cubit.state.targetDevice?.deviceName ?? ''; bool _isLoadingPermission = true; // 🔥 标记是否正在加载权限状态 + /// 缓存本地化实例:dispose 阶段 context 已失效 / 弹窗中需提前拼文案, + /// 不能依赖 `AppLocalizations.of(context)`。 + late AppLocalizations _l10n; + @override void didChangeDependencies() { super.didChangeDependencies(); + _l10n = AppLocalizations.of(context); _cubit = context.read(); _devicesCubit = context.read(); @@ -104,26 +113,6 @@ class _RemoteControlPageState extends State { ]); // 2. 隐藏状态栏和虚拟按键 SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - - WidgetsBinding.instance.addPostFrameCallback((_) { - final remoteCubit = context.read(); - - // 🔥 倒车影像:现阶段改用摇杆(滑块)数据驱动,不再启动 Mock 假数据源。 - // 恢复 Mock:取消下行注释即可(需与 cubit 里 _syncReverseCameraFromJoystick 的调用二选一)。 - // remoteCubit.startReverseCameraMock(); - - // debugPrint('🔍 [RemoteControl] initState - targetDevice: ${remoteCubit.state.targetDevice}'); - - // 🔥 检查 targetDevice 是否存在 - if (remoteCubit.state.targetDevice == null) { - // debugPrint('❌ [RemoteControl] targetDevice 为空,无法进入远程控制'); - return; - } - - // 🔥 只在控制循环未启动时才启动 - remoteCubit.startControlLoop(); - // debugPrint('✅ [RemoteControl] 控制循环已启动'); - }); } @override @@ -133,10 +122,12 @@ class _RemoteControlPageState extends State { // 释放远程控制权限(type=2:退出远程遥控页面释放) final deviceId = _cubit.state.targetDevice?.deviceName; - if (deviceId != null && deviceId.isNotEmpty) { + if (_controlSessionStarted && deviceId != null && deviceId.isNotEmpty) { _cubit.releasePermission(deviceId: deviceId, type: 2).then((success) { CapsuleToast.show( - success ? '已释放远程控制权限' : '控制权限释放失败', + success + ? _l10n.translate('remote_control.toast_released') + : _l10n.translate('remote_control.toast_release_failed'), showCheck: success, ); }); @@ -145,7 +136,17 @@ class _RemoteControlPageState extends State { _permissionSubscription?.cancel(); // 🔥 取消订阅 SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - _cubit.stopControlLoop(); + // 🔥 退页先把车真正停下:归零双轴 + 连发 30 帧停止指令。 + // 只靠 stopControlLoop() 断掉 200ms 循环是不够的:设备只认“最后收到的 + // 运动帧”,若手指还压在摇杆上就退出(没走松手归零),已下发的高速帧会 + // 让车继续跑到它自己的失联保护为止。与新版 HUD 折叠/退出的 deactivate() + // 同一口径。 + // 必须放在 stopControlLoop() 之前:stopAllMovement 末尾会按 _controlWanted + // 决定是否重启循环,这里同步把意图关掉,就不会在退页后留下无主定时器。 + if (_controlSessionStarted) { + unawaited(_cubit.stopAllMovement()); + _cubit.stopControlLoop(); + } _cubit.stopReverseCameraMock(); // 🔥 停止倒车影像 Mock,避免定时器空转 // 🔥 重置权限状态,避免单例 hasPermission 残留导致下次进入不再查询接口 _cubit.resetPermissionState(); @@ -153,7 +154,18 @@ class _RemoteControlPageState extends State { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context) => ControlModeEntryGate( + deviceId: _modeDeviceId, + target: ControlModeTarget.remote, + onReady: () { + if (_cubit.state.targetDevice?.deviceName != _modeDeviceId) return; + _controlSessionStarted = true; + _cubit.startControlLoop(); + }, + child: _buildPage(context), + ); + + Widget _buildPage(BuildContext context) { // 🔥 只监听 RemoteControlCubit,避免其他状态变化导致频繁 rebuild final remoteCubit = context.read(); final targetDevice = remoteCubit.state.targetDevice; @@ -380,12 +392,16 @@ class _RemoteControlPageState extends State { Future _showPermissionDialog(RemoteControlState state) async { debugPrint('🔔 [权限弹窗] _showPermissionDialog 被调用'); - final requestingDeviceId = state.requestingDeviceId ?? '未知设备'; - final requestingPlatform = state.requestingPlatform ?? '未知平台'; - final dialogContent = - '$requestingPlatform 端正在请求控制权\n\n' - '设备ID: $requestingDeviceId\n\n' - '是否同意释放控制权?'; + final requestingDeviceId = + state.requestingDeviceId ?? + _l10n.translate('remote_control.perm_unknown_device'); + final requestingPlatform = + state.requestingPlatform ?? + _l10n.translate('remote_control.perm_unknown_platform'); + final dialogContent = _l10n + .translate('remote_control.permission_request_body') + .replaceAll('{platform}', requestingPlatform) + .replaceAll('{deviceId}', requestingDeviceId); debugPrint( '🔔 [权限弹窗] 准备显示弹窗 - requestingPlatform=$requestingPlatform, requestingDeviceId=$requestingDeviceId', @@ -447,15 +463,16 @@ class _RemoteControlPageState extends State { // 🔥 关键修复:先重置弹窗标志位,再关闭弹窗 // 避免 Navigator.pop() 后上下文失效导致后续代码不执行 - context.read().resetPermissionCoolDown(); - context.read().emit( - context.read().state.copyWith( - showPermissionRequestDialog: false, - ), - ); + // 🔥 一律用 await 之前就捕获的 remoteCubit,不再 context.read: + // 异步回来后页面可能已销毁,在失效 Element 上 context.read 会抛。 + remoteCubit.resetPermissionCoolDown(); + remoteCubit.togglePermissionDialog(false); - // 🔥 关闭弹窗(在状态重置之后) - Navigator.pop(dialogContext); + // 🔥 关闭弹窗(在状态重置之后):弹窗可能已被上一点击关闭, + // 不守卫就会在 defunct context 上 pop 抛异常。 + if (dialogContext.mounted && Navigator.canPop(dialogContext)) { + Navigator.pop(dialogContext); + } debugPrint('🔔 [权限弹窗] 弹窗已关闭,重置标志位'); }, child: Text( @@ -519,15 +536,14 @@ class _RemoteControlPageState extends State { // 🔥 关键修复:先重置弹窗标志位,再关闭弹窗 // 避免 Navigator.pop() 后上下文失效导致后续代码不执行 - context.read().resetPermissionCoolDown(); - context.read().emit( - context.read().state.copyWith( - showPermissionRequestDialog: false, - ), - ); + // 🔥 同拒绝分支:用 await 前就捕获的 remoteCubit,不跨 await 用 context.read。 + remoteCubit.resetPermissionCoolDown(); + remoteCubit.togglePermissionDialog(false); - // 🔥 关闭弹窗(在状态重置之后) - Navigator.pop(dialogContext); + // 🔥 关闭弹窗(在状态重置之后):同样防重复点击后的 defunct pop。 + if (dialogContext.mounted && Navigator.canPop(dialogContext)) { + Navigator.pop(dialogContext); + } debugPrint('🔔 [权限弹窗] 弹窗已关闭,重置标志位'); }, child: Text( diff --git a/lib/features/v2/device_list/presentation/pages/qr_scanner_page_v2.dart b/lib/features/v2/device_list/presentation/pages/qr_scanner_page_v2.dart index a4e6e7ee..5e29b9d0 100644 --- a/lib/features/v2/device_list/presentation/pages/qr_scanner_page_v2.dart +++ b/lib/features/v2/device_list/presentation/pages/qr_scanner_page_v2.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:maibu_satabot_v2/components/capsule_toast.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; /// 添加机器人扫码页 V2 —— 按「01_添加设备_支持跳过」设计稿换皮。 /// @@ -77,7 +78,11 @@ class _QrScannerPageV2State extends State if (file == null) return; final ok = await _controller.analyzeImage(file.path); if (!ok && mounted) { - CapsuleToast.show('未识别到二维码,请换一张清晰图片'); + CapsuleToast.show( + AppLocalizations.of( + context, + ).translate('device_pages_v2.qr_err_no_code'), + ); } } @@ -86,7 +91,11 @@ class _QrScannerPageV2State extends State if (_isScanned) return; final sn = _snController.text.trim(); if (sn.isEmpty) { - CapsuleToast.show('请输入设备 SN'); + CapsuleToast.show( + AppLocalizations.of( + context, + ).translate('device_pages_v2.qr_err_sn_empty'), + ); return; } _isScanned = true; @@ -169,10 +178,12 @@ class _QrScannerPageV2State extends State ), ), const SizedBox(width: 12), - const Expanded( + Expanded( child: Text( - '添加机器人', - style: TextStyle( + AppLocalizations.of( + context, + ).translate('device_pages_v2.qr_title'), + style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: _navy, @@ -193,8 +204,10 @@ class _QrScannerPageV2State extends State child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Text( - '暂时跳过', - style: TextStyle( + AppLocalizations.of( + context, + ).translate('device_pages_v2.qr_skip'), + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: _blue, @@ -211,16 +224,17 @@ class _QrScannerPageV2State extends State // ===== 步骤条:1 识别设备(当前)→ 2 确认信息 → 3 绑定激活 ===== Widget _buildSteps() { + final l10n = AppLocalizations.of(context); return Padding( padding: const EdgeInsets.fromLTRB(28, 6, 28, 0), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildStep(1, '识别设备', true), + _buildStep(1, l10n.translate('device_pages_v2.step_scan'), true), _buildStepLine(), - _buildStep(2, '确认信息', false), + _buildStep(2, l10n.translate('device_pages_v2.step_confirm'), false), _buildStepLine(), - _buildStep(3, '绑定激活', false), + _buildStep(3, l10n.translate('device_pages_v2.step_bind'), false), ], ), ); @@ -280,17 +294,22 @@ class _QrScannerPageV2State extends State // ===== 标题区 ===== Widget _buildTitleBlock() { - return const Column( + final l10n = AppLocalizations.of(context); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '扫描设备二维码', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: _navy), + l10n.translate('device_pages_v2.qr_scan_title'), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: _navy, + ), ), - SizedBox(height: 4), + const SizedBox(height: 4), Text( - '将机器人机身上的二维码放入扫描框内', - style: TextStyle(fontSize: 12, color: _gray), + l10n.translate('device_pages_v2.qr_scan_desc'), + style: const TextStyle(fontSize: 12, color: _gray), ), ], ); @@ -298,6 +317,7 @@ class _QrScannerPageV2State extends State // ===== 扫码卡:相机预览 + 扫描框/扫描线 + 闪光灯/相册识别 ===== Widget _buildScanCard() { + final l10n = AppLocalizations.of(context); return ClipRRect( borderRadius: BorderRadius.circular(16), child: Stack( @@ -365,13 +385,13 @@ class _QrScannerPageV2State extends State children: [ _buildPill( Icons.bolt_rounded, - '闪光灯', + l10n.translate('device_pages_v2.qr_flash'), () async => _controller.toggleTorch(), ), const SizedBox(width: 12), _buildPill( Icons.photo_library_outlined, - '相册识别', + l10n.translate('device_pages_v2.qr_album'), _pickFromAlbum, ), ], @@ -456,9 +476,12 @@ class _QrScannerPageV2State extends State return Row( children: [ Expanded(child: Container(height: 1, color: const Color(0xFFDCE6F2))), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 12), - child: Text('或手动输入 SN', style: TextStyle(fontSize: 11, color: _gray)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + AppLocalizations.of(context).translate('device_pages_v2.qr_manual'), + style: const TextStyle(fontSize: 11, color: _gray), + ), ), Expanded(child: Container(height: 1, color: const Color(0xFFDCE6F2))), ], @@ -472,7 +495,7 @@ class _QrScannerPageV2State extends State style: const TextStyle(fontSize: 14, color: _navy), cursorColor: _blue, decoration: InputDecoration( - hintText: '请输入设备 SN,如 SA260002', + hintText: AppLocalizations.of(context).translate('device_pages_v2.qr_sn_hint'), hintStyle: const TextStyle(color: _gray, fontSize: 14), filled: true, fillColor: Colors.white, @@ -512,14 +535,14 @@ class _QrScannerPageV2State extends State ), ], ), - child: const Row( + child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.center_focus_strong, size: 18, color: Colors.white), - SizedBox(width: 8), + const Icon(Icons.center_focus_strong, size: 18, color: Colors.white), + const SizedBox(width: 8), Text( - '识别设备', - style: TextStyle( + AppLocalizations.of(context).translate('device_pages_v2.qr_confirm'), + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: Colors.white, @@ -557,18 +580,22 @@ class _QrScannerPageV2State extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - '机器人还没到货?', - style: TextStyle( + Text( + AppLocalizations.of( + context, + ).translate('device_pages_v2.qr_not_arrived'), + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: _navy, ), ), const SizedBox(height: 4), - const Text( - '可以先体验 APP,收到设备后再添加', - style: TextStyle(fontSize: 11, color: _gray), + Text( + AppLocalizations.of( + context, + ).translate('device_pages_v2.qr_not_arrived_desc'), + style: const TextStyle(fontSize: 11, color: _gray), ), const SizedBox(height: 8), GestureDetector( @@ -577,11 +604,13 @@ class _QrScannerPageV2State extends State Navigator.of(context).pop(); }, behavior: HitTestBehavior.opaque, - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 4), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), child: Text( - '暂时跳过,稍后添加 →', - style: TextStyle( + AppLocalizations.of( + context, + ).translate('device_pages_v2.qr_skip_later'), + style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: _blue, diff --git a/lib/features/v2/my/presentation/pages/profile_detail_page.dart b/lib/features/v2/my/presentation/pages/profile_detail_page.dart index d690db2f..8cf2e273 100644 --- a/lib/features/v2/my/presentation/pages/profile_detail_page.dart +++ b/lib/features/v2/my/presentation/pages/profile_detail_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; import 'package:maibu_satabot_v2/core/app/app_user_state.dart'; import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import 'package:maibu_satabot_v2/core/theme/AppTheme.dart'; import 'package:maibu_satabot_v2/core/utils/image_url_util.dart'; @@ -20,9 +21,9 @@ class ProfileDetailPage extends StatelessWidget { icon: Icon(Icons.arrow_back, color: context.appColors.textPrimary), onPressed: () => Navigator.pop(context), ), - title: const Text( - '个人信息', - style: TextStyle( + title: Text( + AppLocalizations.of(context).translate('my_v2.profile_title'), + style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), @@ -34,8 +35,10 @@ class ProfileDetailPage extends StatelessWidget { final user = state.user; if (user == null) { - return const Center( - child: Text('未登录'), + return Center( + child: Text( + AppLocalizations.of(context).translate('my_v2.not_logged_in'), + ), ); } @@ -145,6 +148,7 @@ class ProfileDetailPage extends StatelessWidget { /// 信息卡片 Widget _buildInfoCard(BuildContext context, UserEntity user) { + final l10n = AppLocalizations.of(context); return Container( decoration: BoxDecoration( color: context.appColors.cardBackground, @@ -162,21 +166,21 @@ class ProfileDetailPage extends StatelessWidget { _buildInfoItem( context, icon: Icons.person_outline, - label: '用户ID', + label: l10n.translate('my_v2.user_id'), value: user.userId, showDivider: true, ), _buildInfoItem( context, icon: Icons.account_circle_outlined, - label: '用户名', + label: l10n.translate('my_v2.username'), value: user.username, showDivider: true, ), _buildInfoItem( context, icon: Icons.badge_outlined, - label: '昵称', + label: l10n.translate('my_v2.nickname'), value: user.nickname, showDivider: user.email != null || user.phone != null, ), @@ -184,7 +188,7 @@ class ProfileDetailPage extends StatelessWidget { _buildInfoItem( context, icon: Icons.email_outlined, - label: '邮箱', + label: l10n.translate('my_v2.email'), value: user.email!, showDivider: user.phone != null, ), @@ -192,7 +196,7 @@ class ProfileDetailPage extends StatelessWidget { _buildInfoItem( context, icon: Icons.phone_outlined, - label: '手机号', + label: l10n.translate('my_v2.phone'), value: user.phone!, showDivider: false, ), @@ -253,6 +257,7 @@ class ProfileDetailPage extends StatelessWidget { /// 操作按钮 Widget _buildActionButtons(BuildContext context) { + final l10n = AppLocalizations.of(context); return Column( children: [ // 编辑资料按钮(预留) @@ -263,13 +268,15 @@ class ProfileDetailPage extends StatelessWidget { onPressed: () { // TODO: 跳转到编辑资料页面 ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('编辑功能开发中...')), + SnackBar( + content: Text(l10n.translate('my_v2.edit_in_progress')), + ), ); }, icon: const Icon(Icons.edit, size: 20), - label: const Text( - '编辑资料', - style: TextStyle(fontSize: 16), + label: Text( + l10n.translate('my_v2.edit_profile_action'), + style: const TextStyle(fontSize: 16), ), style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF165DFF), diff --git a/test/region_editor_test.dart b/test/region_editor_test.dart new file mode 100644 index 00000000..653731c2 --- /dev/null +++ b/test/region_editor_test.dart @@ -0,0 +1,396 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:maibu_satabot_v2/core/logging/i_logger_service.dart'; +import 'package:maibu_satabot_v2/features/autonomous_work/domain/entities/work_session_models.dart'; +import 'package:maibu_satabot_v2/features/autonomous_work/domain/repositories/autonomous_work_gateway.dart'; +import 'package:maibu_satabot_v2/features/regions/data/datasources/region_remote_datasource.dart'; +import 'package:maibu_satabot_v2/features/regions/data/repositories/region_repository_impl.dart'; +import 'package:maibu_satabot_v2/features/regions/domain/entities/region_draft.dart'; +import 'package:maibu_satabot_v2/features/regions/domain/usecases/region_usecases.dart'; +import 'package:maibu_satabot_v2/features/regions/presentation/cubit/region_editor_cubit.dart'; +import 'package:maibu_satabot_v2/features/regions/presentation/widgets/region_boundary_layer.dart'; +import 'package:maibu_satabot_v2/features/regions/presentation/widgets/region_form_dialog.dart'; + +const points = [ + RegionPoint(lat: 31.08333536934687, lng: 120.84125702784672), + RegionPoint(lat: 31.083297377312423, lng: 120.84137894536802), + RegionPoint(lat: 31.082865000940348, lng: 120.84104682313267), + RegionPoint(lat: 31.08283424555773, lng: 120.84114772002897), +]; + +RegionDraft sample({ + int? id, + String name = '测试分区', + List scope = points, +}) => RegionDraft( + id: id, + siteId: 18, + name: name, + code: '0090', + remark: '只是测试', + scope: scope, + defaultRouteId: 266, + boundRouteIds: [266], +); + +class _Logger implements ILoggerService { + final messages = []; + @override + void logWithLevel( + String message, { + String level = 'INFO', + Map? data, + bool shouldLog = false, + }) { + if (shouldLog) messages.add(message); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Routes implements AutonomousWorkGateway { + final sites = []; + Future>> Function() response = () async => + const WorkResult.ok([ + WorkRouteOption(id: '266', name: 'APP测试 自测自定义01'), + WorkRouteOption(id: '267', name: '备用路线'), + WorkRouteOption(id: 'demo-1', name: '不支持数字主键的路线'), + ]); + @override + Future>> loadRoutes({required int siteId}) { + sites.add(siteId); + return response(); + } + + // 除查询路线列表外,任何自主作业/路线保存调用都会立即让测试失败。 + @override + dynamic noSuchMethod(Invocation invocation) => + throw StateError('不应调用路线业务:${invocation.memberName}'); +} + +class _Harness { + _Harness(RegionDraft initial) { + dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) async { + requests.add(options); + handler.resolve( + Response( + requestOptions: options, + statusCode: 200, + data: await response(), + ), + ); + }, + ), + ); + final repository = RegionRepositoryImpl( + remote: RegionRemoteDatasource(dio, logger), + routes: routes, + ); + cubit = RegionEditorCubit( + SaveRegionUseCase(repository), + LoadRegionRoutesUseCase(repository), + )..initialize(initial); + } + final dio = Dio(); + final logger = _Logger(); + final routes = _Routes(); + final requests = []; + late final RegionEditorCubit cubit; + Future> Function() response = () async => { + 'code': 200, + 'msg': '操作成功', + 'data': 1, + }; + Future close() async { + await cubit.close(); + dio.close(); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('新建完整链路按用户示例发送 JSON,编码保持 0090,scope 只传四点', () async { + final h = _Harness(sample()); + addTearDown(h.close); + expect(await h.cubit.save(sample()), isTrue); + expect(h.requests.single.method, 'POST'); + expect(h.requests.single.uri.path, '/system/region/save'); + expect(h.requests.single.data, { + 'regionName': '测试分区', + 'regionCode': '0090', + 'siteId': 18, + 'type': 'region', + 'remark': '只是测试', + 'scope': [ + for (final p in points) {'lat': p.lat, 'lng': p.lng}, + ], + 'status': 1, + 'defaultRoute': 266, + 'bindRoutes': [266], + }); + expect(h.cubit.state.draft.id, isNull); // data: 1 不是分区 ID + expect(h.logger.messages.any((m) => m.contains('请求: POST')), isTrue); + expect(h.logger.messages.any((m) => m.contains('参数:')), isTrue); + expect(h.logger.messages.any((m) => m.contains('响应: HTTP 200')), isTrue); + expect(await h.cubit.save(sample()), isFalse); // 防止无主键时重复创建同一份草稿 + expect(h.requests, hasLength(1)); + }); + + test('修改保留原 ID、编码、绑定路线;数字保存数量不能覆盖 ID', () async { + final h = _Harness(sample(id: 14)); + addTearDown(h.close); + expect(await h.cubit.save(sample(id: 14, name: '测试分区之修改')), isTrue); + expect(h.requests.single.data['id'], 14); + expect(h.requests.single.data['regionName'], '测试分区之修改'); + expect(h.requests.single.data['regionCode'], '0090'); + expect(h.requests.single.data['bindRoutes'], [266]); + expect(h.cubit.state.draft.id, 14); + }); + + test('只有明确返回的对象主键用于创建后的再次修改', () async { + final h = _Harness(sample()); + addTearDown(h.close); + h.response = () async => { + 'code': 200, + 'data': {'id': 22}, + }; + expect(await h.cubit.save(sample()), isTrue); + expect(h.cubit.state.draft.id, 22); + h.response = () async => {'code': 200, 'data': true}; + expect(await h.cubit.save(sample(id: 22, name: '改名')), isTrue); + expect(h.requests.last.data['id'], 22); + expect(h.cubit.state.draft.id, 22); + }); + + test('必须四个有效且不同的点,不足/超出/重复/无效点均不发接口', () async { + final h = _Harness(sample()); + addTearDown(h.close); + for (final scope in [ + points.take(3).toList(), + [...points, points.first], + [points.first, points.first, points[2], points[3]], + [const RegionPoint(lat: double.nan, lng: 120), ...points.skip(1)], + ]) { + h.cubit.initialize(sample(scope: scope)); + expect(await h.cubit.save(sample(scope: scope)), isFalse); + expect(h.cubit.state.error, isNotEmpty); + } + expect(h.requests, isEmpty); + }); + + test('分区打点上限、撤回和重新打点独立于路线查询及保存', () async { + final h = _Harness(RegionDraft(siteId: 18)); + addTearDown(h.close); + for (final point in points) { + expect(h.cubit.addPoint(point), isNull); + } + expect( + h.cubit.addPoint(const RegionPoint(lat: 31.1, lng: 120.8)), + contains('4'), + ); + expect(h.cubit.state.draft.scope, hasLength(4)); + h.cubit.removePoint(3); + expect(h.cubit.addPoint(points.last), isNull); + expect(h.cubit.state.draft.scope, hasLength(4)); + expect(h.requests, isEmpty); + expect(h.routes.sites, isEmpty); + }); + + test('保存期间不能重复提交或覆盖草稿,失败后可重试且不会丢四点', () async { + final h = _Harness(sample(id: 14)); + addTearDown(h.close); + final response = Completer>(); + h.response = () => response.future; + final first = h.cubit.save(sample(id: 14)); + expect(h.cubit.state.isSaving, isTrue); + expect(await h.cubit.save(sample(id: 14)), isFalse); + h.cubit.initialize(RegionDraft(siteId: 19)); + expect(h.cubit.state.draft.siteId, 18); + response.complete({'code': 500, 'msg': '后台拒绝保存'}); + expect(await first, isFalse); + expect(h.cubit.state.error, contains('后台拒绝保存')); + expect(h.cubit.state.draft.scope, hasLength(4)); + expect(h.requests, hasLength(1)); + h.response = () async => {'code': 200, 'data': 1}; + expect(await h.cubit.save(sample(id: 14)), isTrue); + expect(h.requests, hasLength(2)); + }); + + test('延迟返回的路线列表不能清掉保存失败原因;数字数量 0 不能报成功', () async { + final h = _Harness(sample()); + addTearDown(h.close); + final routes = Completer>>(); + h.routes.response = () => routes.future; + final loading = h.cubit.loadRoutes(); + h.response = () async => {'code': 200, 'data': 0}; + expect(await h.cubit.save(sample()), isFalse); + routes.complete(const WorkResult.ok([])); + await loading; + expect(h.cubit.state.routesStatus, RegionRoutesStatus.ready); + expect(h.cubit.state.error, contains('未保存')); + expect(h.cubit.state.status, RegionEditorStatus.failure); + }); + + Future open( + WidgetTester tester, + _Harness h, { + bool Function()? isCurrent, + }) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showRegionFormDialog( + context, + cubit: h.cubit, + isScopeCurrent: isCurrent ?? () => true, + ), + child: const Text('打开分区'), + ), + ), + ), + ), + ); + await tester.tap(find.text('打开分区')); + await tester.pumpAndSettle(); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + + testWidgets('表单回填截图字段,使用真实路线选项、多选绑定并携带 ID 修改', (tester) async { + final h = _Harness(sample(id: 14)); + addTearDown(h.close); + await open(tester, h); + expect(find.text('编辑分区'), findsOneWidget); + expect( + tester + .widget(find.byKey(const ValueKey('region_code'))) + .controller! + .text, + '0090', + ); + expect(h.routes.sites, [18]); + expect(h.cubit.state.routes.map((r) => r.id), [266, 267]); + await tester.ensureVisible(find.byKey(const ValueKey('region_name'))); + await tester.enterText( + find.byKey(const ValueKey('region_name')), + '测试分区之修改', + ); + final bindings = find.byKey(const ValueKey('region_bound_routes')); + await tester.ensureVisible(bindings); + await tester.tap(bindings); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('region_bind_267'))); + await tester.tap(find.text('确定')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('region_save'))); + await tester.pumpAndSettle(); + expect(h.requests.single.data['id'], 14); + expect(h.requests.single.data['defaultRoute'], 266); + expect(h.requests.single.data['bindRoutes'], [266, 267]); + expect(h.requests.single.data['regionName'], '测试分区之修改'); + expect(find.byType(RegionFormDialog), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('区域保存失败停留表单,保留编码和备注并允许重试', (tester) async { + final h = _Harness(sample()); + addTearDown(h.close); + h.response = () async => {'code': 500, 'msg': '名称重复'}; + await open(tester, h); + await tester.tap(find.byKey(const ValueKey('region_save'))); + await tester.pumpAndSettle(); + expect(find.byType(RegionFormDialog), findsOneWidget); + expect(find.textContaining('名称重复'), findsOneWidget); + expect( + tester + .widget(find.byKey(const ValueKey('region_code'))) + .controller! + .text, + '0090', + ); + expect( + tester + .widget(find.byKey(const ValueKey('region_remark'))) + .controller! + .text, + '只是测试', + ); + h.response = () async => {'code': 200, 'data': 1}; + await tester.tap(find.byKey(const ValueKey('region_save'))); + await tester.pumpAndSettle(); + expect(find.byType(RegionFormDialog), findsNothing); + expect(h.requests, hasLength(2)); + }); + + testWidgets('场站或账号已切换时阻止提交;不足四点保存按钮不可点', (tester) async { + final h = _Harness(sample()); + addTearDown(h.close); + await open(tester, h, isCurrent: () => false); + await tester.tap(find.byKey(const ValueKey('region_save'))); + await tester.pumpAndSettle(); + expect(find.textContaining('场站或账号已切换'), findsOneWidget); + expect(h.requests, isEmpty); + h.cubit.initialize(sample(scope: points.take(3).toList())); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byKey(const ValueKey('region_save'))) + .onPressed, + isNull, + ); + expect(h.requests, isEmpty); + }); + + testWidgets('横屏小高度表单可滚动填写,保存操作保持可见', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(844, 390); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + final h = _Harness(sample()); + addTearDown(h.close); + await open(tester, h); + await tester.ensureVisible(find.byKey(const ValueKey('region_remark'))); + await tester.enterText( + find.byKey(const ValueKey('region_remark')), + '横屏保存测试', + ); + await tester.tap(find.byKey(const ValueKey('region_save'))); + await tester.pumpAndSettle(); + expect(h.requests.single.data['remark'], '横屏保存测试'); + expect(tester.takeException(), isNull); + }); + + testWidgets('独立分区图层绘制四点且不追加第五个闭合点', (tester) async { + final geometry = [for (final p in points) LatLng(p.lat, p.lng)]; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FlutterMap( + options: MapOptions(initialCenter: geometry.first, initialZoom: 19), + children: [RegionBoundaryLayer(points: geometry)], + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(PolygonLayer), findsOneWidget); + expect( + tester.widget(find.byType(MarkerLayer)).markers, + hasLength(4), + ); + expect(geometry, hasLength(4)); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/region_list_test.dart b/test/region_list_test.dart new file mode 100644 index 00000000..1793d77d --- /dev/null +++ b/test/region_list_test.dart @@ -0,0 +1,361 @@ +import 'dart:async'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:maibu_satabot_v2/core/logging/i_logger_service.dart'; +import 'package:maibu_satabot_v2/features/autonomous_work/domain/repositories/autonomous_work_gateway.dart'; +import 'package:maibu_satabot_v2/features/regions/data/datasources/region_remote_datasource.dart'; +import 'package:maibu_satabot_v2/features/regions/data/repositories/region_repository_impl.dart'; +import 'package:maibu_satabot_v2/features/regions/domain/entities/region_draft.dart'; +import 'package:maibu_satabot_v2/features/regions/domain/usecases/region_usecases.dart'; +import 'package:maibu_satabot_v2/features/regions/presentation/cubit/region_list_cubit.dart'; +import 'package:maibu_satabot_v2/features/regions/presentation/widgets/region_list_panel.dart'; +import 'package:maibu_satabot_v2/features/regions/presentation/widgets/no_device_crosshair_hint.dart'; + +Map row({int id = 16, int site = 18}) => { + 'id': id, + 'regionName': '测试分区', + 'regionCode': null, + 'remark': null, + 'siteId': '$site', + 'type': 'region', + 'status': 1, + 'delFlag': false, + 'defaultRoute': 266, + 'bindRoutes': [265, 266], + 'scope': [ + {'lat': 31.08342690658137, 'lng': 120.84065934046428}, + {'lat': 31.083408345015524, 'lng': 120.8409046603276}, + {'lat': 31.08329929760247, 'lng': 120.84085343939437}, + {'lat': 31.08325985510068, 'lng': 120.84065664455204}, + ], +}; + +class _Logger implements ILoggerService { + @override + void logWithLevel( + String message, { + String level = 'INFO', + Map? data, + bool shouldLog = false, + }) {} + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Routes implements AutonomousWorkGateway { + @override + dynamic noSuchMethod(Invocation invocation) => + throw StateError('分区列表/删除不应调用路线接口'); +} + +class _Harness { + _Harness() { + dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) async { + requests.add(options); + handler.resolve( + Response( + requestOptions: options, + statusCode: 200, + data: await respond(options), + ), + ); + }, + ), + ); + repository = RegionRepositoryImpl( + remote: RegionRemoteDatasource(dio, _Logger()), + routes: _Routes(), + ); + cubit = RegionListCubit( + LoadRegionsUseCase(repository), + DeleteRegionsUseCase(repository), + ); + } + final dio = Dio(); + final requests = []; + late final RegionRepositoryImpl repository; + late final RegionListCubit cubit; + Future> Function(RequestOptions) respond = + (options) async => { + 'code': 200, + 'msg': '操作成功', + 'data': options.method == 'GET' ? [row()] : 1, + }; + Future load() => cubit.load(siteId: 18, ownerId: 'u1'); + Future close() async { + await cubit.close(); + dio.close(); + } +} + +Future _mount( + WidgetTester tester, + _Harness h, { + VoidCallback? create, + ValueChanged? edit, + Object? Function()? currentScope, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 320, + height: 300, + child: RegionListPanel( + cubit: h.cubit, + currentScope: currentScope ?? () => ('u1', 18), + onCreate: create ?? () {}, + onEdit: edit ?? (_) {}, + onRefresh: () => h.load(), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('GET 按场站查询,字符串 siteId/空字段正常解析,编辑数据完整', () async { + final h = _Harness(); + addTearDown(h.close); + await h.load(); + final request = h.requests.single; + expect(request.method, 'GET'); + expect(request.uri.path, '/system/region/selectBySiteId'); + expect(request.queryParameters, {'siteId': 18}); + final region = h.cubit.state.regions.single; + expect(region.id, 16); + expect(region.siteId, 18); + expect(region.code, ''); + expect(region.remark, ''); + expect(region.scopeError, isNull); + expect(region.scope.first.lat, 31.08342690658137); + expect(region.defaultRouteId, 266); + expect(region.boundRouteIds, [265, 266]); + }); + test('列表过滤其他场站、已删除和其他区域类型,去重主键', () async { + final h = _Harness(); + addTearDown(h.close); + h.respond = (_) async => { + 'code': 200, + 'data': [ + row(), + row(), + row(id: 17, site: 19), + {...row(id: 18), 'delFlag': true}, + {...row(id: 19), 'type': 'obstacle'}, + ], + }; + await h.load(); + expect(h.cubit.state.regions.map((r) => r.id), [16]); + }); + test('删除发送 POST JSON 数组 [16],data:1 成功后移除并取消选中', () async { + final h = _Harness(); + addTearDown(h.close); + await h.load(); + h.cubit.select(16); + expect(await h.cubit.deleteRegion(h.cubit.state.regions.single), isTrue); + expect(h.requests.last.method, 'POST'); + expect(h.requests.last.uri.path, '/system/region/delete'); + expect(h.requests.last.data, [16]); + expect(h.cubit.state.regions, isEmpty); + expect(h.cubit.state.selectedId, isNull); + }); + test('删除失败保留列表、选择和可重试状态', () async { + final h = _Harness(); + addTearDown(h.close); + await h.load(); + h.cubit.select(16); + h.respond = (_) async => {'code': 500, 'msg': '分区已被绑定'}; + expect(await h.cubit.deleteRegion(h.cubit.state.regions.single), isFalse); + expect(h.cubit.state.regions.single.id, 16); + expect(h.cubit.state.selectedId, 16); + expect(h.cubit.state.deletingId, isNull); + expect(h.cubit.state.operationError, contains('已被绑定')); + }); + test('未登录/未选场站不请求;旧场站迟到的响应不会覆盖新场站', () async { + final h = _Harness(); + addTearDown(h.close); + await h.cubit.load(siteId: 18, ownerId: null); + expect(h.cubit.state.status, RegionListStatus.noUser); + await h.cubit.load(siteId: null, ownerId: 'u1'); + expect(h.cubit.state.status, RegionListStatus.noSite); + expect(h.requests, isEmpty); + final oldResponse = Completer>(); + h.respond = (o) => o.queryParameters['siteId'] == 18 + ? oldResponse.future + : Future.value({ + 'code': 200, + 'data': [row(id: 20, site: 19)], + }); + final old = h.load(); + await Future.delayed(Duration.zero); + await h.cubit.load(siteId: 19, ownerId: 'u1'); + oldResponse.complete({ + 'code': 200, + 'data': [row()], + }); + await old; + expect(h.cubit.state.regions.single.id, 20); + expect(h.cubit.state.siteId, 19); + }); + test('重复删除只发一次,退出登录不接受删除结果', () async { + final h = _Harness(); + addTearDown(h.close); + await h.load(); + final pending = Completer>(); + final started = Completer(); + h.respond = (_) { + started.complete(); + return pending.future; + }; + final region = h.cubit.state.regions.single; + final first = h.cubit.deleteRegion(region); + expect(await h.cubit.deleteRegion(region), isFalse); + await started.future; + expect(h.requests.where((r) => r.method == 'POST'), hasLength(1)); + await h.cubit.load(siteId: 18, ownerId: null); + pending.complete({'code': 200, 'data': 1}); + expect(await first, isFalse); + expect(h.cubit.state.status, RegionListStatus.noUser); + }); + test('保存后强制刷新不复用保存前的旧列表请求', () async { + final h = _Harness(); + addTearDown(h.close); + final oldResponse = Completer>(); + final started = Completer(); + h.respond = (_) { + started.complete(); + return oldResponse.future; + }; + final old = h.load(); + await started.future; + h.respond = (_) async => { + 'code': 200, + 'data': [row(id: 20)], + }; + await h.cubit.load(siteId: 18, ownerId: 'u1', force: true); + oldResponse.complete({'code': 200, 'data': []}); + await old; + expect(h.cubit.state.regions.single.id, 20); + }); + testWidgets('共享列表:选中高亮、修改传完整对象、新增独立入口', (tester) async { + final h = _Harness(); + addTearDown(h.close); + await tester.runAsync(h.load); + RegionDraft? edited; + var creates = 0; + await _mount(tester, h, create: () => creates++, edit: (r) => edited = r); + await tester.tap(find.text('测试分区')); + await tester.pump(); + expect(h.cubit.state.selectedId, 16); + await tester.tap(find.byKey(const ValueKey('region_edit_16'))); + expect(edited?.id, 16); + expect(edited?.scope, hasLength(4)); + expect(edited?.boundRouteIds, [265, 266]); + await tester.tap(find.byKey(const ValueKey('region_list_create'))); + expect(creates, 1); + expect(tester.takeException(), isNull); + }); + testWidgets('删除先确认,取消不请求,失败保留行并显示原因', (tester) async { + final h = _Harness(); + addTearDown(h.close); + await tester.runAsync(h.load); + await _mount(tester, h); + await tester.tap(find.byKey(const ValueKey('region_delete_16'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('取消')); + await tester.pumpAndSettle(); + expect(h.requests, hasLength(1)); + h.respond = (_) async => {'code': 500, 'msg': '删除失败'}; + await tester.tap(find.byKey(const ValueKey('region_delete_16'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('region_delete_confirm'))); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('region_row_16')), findsOneWidget); + expect(find.byKey(const ValueKey('region_delete_error')), findsOneWidget); + }); + testWidgets('删除成功才移除分区行并通知地图', (tester) async { + final h = _Harness(); + addTearDown(h.close); + await tester.runAsync(h.load); + RegionDraft? deleted; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 320, + height: 300, + child: RegionListPanel( + cubit: h.cubit, + currentScope: () => ('u1', 18), + onCreate: () {}, + onEdit: (_) {}, + onRefresh: () {}, + onDeleted: (r) => deleted = r, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final response = Completer>(); + h.respond = (_) => response.future; + await tester.tap(find.byKey(const ValueKey('region_delete_16'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('region_delete_confirm'))); + await tester.pump(const Duration(milliseconds: 250)); + expect(h.cubit.state.deletingId, 16); + expect(find.byKey(const ValueKey('region_row_16')), findsOneWidget); + expect(deleted, isNull); + response.complete({'code': 200, 'data': 1}); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('region_row_16')), findsNothing); + expect(deleted?.id, 16); + await tester.pump(const Duration(seconds: 3)); + }); + testWidgets('确认弹窗期间切换场站,不发送旧分区删除请求', (tester) async { + final h = _Harness(); + addTearDown(h.close); + await tester.runAsync(h.load); + Object scope = ('u1', 18); + await _mount(tester, h, currentScope: () => scope); + await tester.tap(find.byKey(const ValueKey('region_delete_16'))); + await tester.pumpAndSettle(); + scope = ('u1', 19); + await tester.tap(find.byKey(const ValueKey('region_delete_confirm'))); + await tester.pumpAndSettle(); + expect(h.requests, hasLength(1)); + expect(h.cubit.state.regions, hasLength(1)); + await tester.pump(const Duration(seconds: 3)); + }); + testWidgets('无设备提示可关闭,选择/取消选择设备后保持关闭状态', (tester) async { + Future show(bool hasDevice) => tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 280, + child: NoDeviceCrosshairHint(hasDevice: hasDevice), + ), + ), + ), + ); + await show(true); + expect(find.textContaining('准星打点'), findsNothing); + await show(false); + expect(find.textContaining('准星打点'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('dismiss_crosshair_hint'))); + await tester.pump(); + expect(find.textContaining('准星打点'), findsNothing); + await show(true); + await show(false); + expect(find.textContaining('准星打点'), findsNothing); + }); +}