机器列表和远程喊话等ui

This commit is contained in:
2026-11-06 08:47:36 +08:00
parent e6165b180b
commit 84eec7da96
25 changed files with 2662 additions and 1214 deletions

View File

@@ -1,3 +1,7 @@
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import '../../domain/entities/device_model_info.dart';
/// 机器人数据模型
class RobotDataModel {
final String name;
@@ -11,6 +15,28 @@ class RobotDataModel {
final String task;
final bool hasHost;
/// 产品型号(getSiteList 返回,如「割草机产品MC700」)
final String? productName;
/// 机型编码(getSiteList 返回,如 MC700PRO)—— 列表机型展示以此为准
final String? productModel;
/// 设备类型(getSiteList 返回的 deviceType)
///
/// 展示位置在版本号(PRO / PLUS / STD)之前:`MC700 · PRO 旗舰版`。
final String? deviceType;
/// 归属信息:当前挂在哪个组织 / 场站 / 负责人下。
///
/// 后端 getSiteList 不一定回传这些字段,因此全部可空:
/// 拿不到时页面显示「未知」,不编造。
final int? orgId;
final String? orgName;
final int? siteId;
final String? siteName;
final int? userId;
final String? userName;
const RobotDataModel({
required this.name,
required this.id,
@@ -22,6 +48,15 @@ class RobotDataModel {
required this.battery,
required this.task,
this.hasHost = false,
this.productName,
this.productModel,
this.deviceType,
this.orgId,
this.orgName,
this.siteId,
this.siteName,
this.userId,
this.userName,
});
/// 从 JSON 创建数据模型
@@ -49,9 +84,60 @@ class RobotDataModel {
battery: batteryValue,
task: json['task'] ?? json['currentTask'] ?? '待机中',
hasHost: json['hasHost'] as bool? ?? false,
productName: _pickStr(json['productName']),
productModel: _pickStr(json['productModel']),
deviceType: _pickStr(json['deviceType'] ?? json['device_type']),
orgId: _pickInt(json, const ['orgId', 'org_id', 'organizationId']),
orgName: _pickStr(
json['orgName'] ?? json['org_name'] ?? json['organizationName'],
),
siteId: _pickInt(json, const ['siteId', 'site_id']),
siteName: _pickStr(json['siteName'] ?? json['site_name']),
userId: _pickInt(json, const [
'userId',
'user_id',
'ownerId',
'owner_id',
'managerId',
]),
userName: _pickStr(
json['userName'] ??
json['user_name'] ??
json['ownerName'] ??
json['nickName'] ??
json['managerName'],
),
);
}
/// 多候选 key 里取第一个能转成整数的值
static int? _pickInt(Map<String, dynamic> json, List<String> keys) {
for (final key in keys) {
final value = json[key];
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) {
final parsed = int.tryParse(value.trim());
if (parsed != null) return parsed;
}
}
return null;
}
/// 机型信息(系列 + 版本),由 productModel 解析
DeviceModelInfo get modelInfo => DeviceModelInfo.parse(productModel);
/// 列表卡「型号 · 版本」文案:`MC700 · PRO 旗舰版`
/// productModel 为 null / 空串时统一显示未知;
/// deviceType 排在版本号之前。
String modelLabelOf(AppLocalizations l10n) =>
modelInfo.labelOf(l10n, deviceType: deviceType);
static String? _pickStr(dynamic value) {
final s = value?.toString().trim() ?? '';
return s.isEmpty ? null : s;
}
/// 转换为 JSON
Map<String, dynamic> toJson() {
return {
@@ -64,6 +150,15 @@ class RobotDataModel {
'statusCode': statusCode,
'battery': battery,
'task': task,
'productName': productName,
'productModel': productModel,
'deviceType': deviceType,
'orgId': orgId,
'orgName': orgName,
'siteId': siteId,
'siteName': siteName,
'userId': userId,
'userName': userName,
};
}

View File

@@ -52,7 +52,12 @@ class VoicePresetStore {
VoicePreset(id: 'default_polite', name: '礼貌提示', text: '请注意避让,感谢配合。'),
];
static Future<List<VoicePreset>> loadPresets(String deviceName) async {
/// 首次进入时落盘一份出厂默认;[fallback] 允许调用方传入已本地化的默认值
static Future<List<VoicePreset>> loadPresets(
String deviceName, {
List<VoicePreset>? fallback,
}) async {
final defaults = fallback ?? VoicePresetStore.defaults;
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_keyOf(deviceName));
if (raw == null || raw.isEmpty) {

View File

@@ -0,0 +1,24 @@
import 'package:fpdart/fpdart.dart';
import 'package:maibu_satabot_v2/core/error/failure.dart';
import '../repositories/device_repository.dart';
/// 一键寻车用例(设备控制 → 一键寻车 → 启动寻车提示)
///
/// 对应后端 GET /iot/host/searchDevice,query 参数 deviceId 传
/// 设备编码(deviceName,MC700- 开头),不是 getSiteList 的数字主键 id。
/// 返回 true 表示指令已成功下发。
class SearchDeviceUseCase {
final DeviceRepository repository;
const SearchDeviceUseCase({required this.repository});
/// 入参非法(空编码)时返回 message 为空的 Failure,由 UI 层套用本地化提示。
Future<Either<Failure, bool>> execute({required String deviceCode}) async {
final code = deviceCode.trim();
if (code.isEmpty) {
return Left(Failure(''));
}
return await repository.searchDevice(deviceCode: code);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:fpdart/fpdart.dart';
import 'package:maibu_satabot_v2/core/error/failure.dart';
import '../repositories/device_repository.dart';
/// 修改设备别名用例(设备列表卡片名称旁的铅笔图标)
///
/// 只写后端 deviceAlias 字段,deviceName(出厂序列号)保持不变。
/// 列表展示口径:deviceAlias 为空(首次绑定为 null)就回退显示 deviceName,
/// 因此这里拒绝空别名,避免把已有别名写成空串导致列表直接露出长序列号。
class UpdateDeviceAliasUseCase {
final DeviceRepository repository;
const UpdateDeviceAliasUseCase({required this.repository});
/// 返回 true 表示后端已成功更新别名。
/// 入参非法时返回 message 为空的 Failure,由 UI 层套用本地化提示。
Future<Either<Failure, bool>> execute({
required String deviceId,
required String deviceAlias,
}) async {
final id = deviceId.trim();
final alias = deviceAlias.trim();
if (id.isEmpty || alias.isEmpty) {
return Left(Failure(''));
}
return await repository.updateDeviceAlias(
deviceId: id,
deviceAlias: alias,
);
}
}

View File

@@ -1,8 +1,10 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../core/consts/http_api_consts.dart';
import '../../../../../core/network/error_handler.dart';
import '../../data/models/robot_data_model.dart';
import '../../domain/entities/device_model_info.dart';
import 'robot_list_event.dart';
import 'robot_list_state.dart';
@@ -50,6 +52,7 @@ class RobotListBloc extends Bloc<RobotListEvent, RobotListState> {
}
final List<dynamic> rows = responseData['rows'] ?? [];
_logProductModel(rows, scene: 'load', siteId: event.siteId);
final robots = rows.map((item) => RobotDataModel.fromJson(item)).toList();
emit(RobotListLoaded(
@@ -100,6 +103,11 @@ class RobotListBloc extends Bloc<RobotListEvent, RobotListState> {
}
final List<dynamic> rows = responseData['rows'] ?? [];
_logProductModel(
rows,
scene: 'refresh',
siteId: currentState.siteId,
);
final robots = rows.map((item) => RobotDataModel.fromJson(item)).toList();
emit(currentState.copyWith(robots: robots));
@@ -112,6 +120,103 @@ class RobotListBloc extends Bloc<RobotListEvent, RobotListState> {
}
}
/// 🔍 专项日志:只核查 getSiteList 返回体里是否带 productModel 字段
/// 整体打印 row 时内容过长会被截断,这里只摘取关键字段 + 型号相关候选字段
void _logProductModel(
List<dynamic> rows, {
required String scene,
required int? siteId,
}) {
debugPrint(
'>>> [getSiteList/$scene] siteId=$siteId 返回 ${rows.length} 条',
);
if (rows.isEmpty) {
debugPrint('>>> [getSiteList/$scene] rows 为空,无法判断 productModel 是否存在');
return;
}
for (int i = 0; i < rows.length; i++) {
final row = rows[i];
if (row is! Map) {
debugPrint(
'>>> [getSiteList/$scene] rows[$i] 非 Map 结构: ${row.runtimeType}',
);
continue;
}
final json = Map<String, dynamic>.from(row);
final head =
'deviceId=${json['deviceId']} name=${json['deviceName'] ?? json['name']}';
if (json.containsKey('productModel')) {
final value = json['productModel'];
// 同时输出解析结果,方便确认机型(STD/PLUS/PRO)是否识别正确
final info = DeviceModelInfo.parse(value?.toString());
debugPrint(
'>>> [getSiteList/$scene] rows[$i] $head 有 productModel=$value '
'(${value.runtimeType}) → 解析: series=${info.series} edition=${info.edition.name}',
);
} else {
// 字段缺失时列出所有“疑似型号”字段,便于确认后端真实字段名
final candidates = json.entries
.where((e) => _isModelLikeKey(e.key))
.map((e) => '${e.key}=${e.value}')
.join(', ');
debugPrint(
'>>> [getSiteList/$scene] rows[$i] $head 无 productModel 字段;型号候选字段: '
'${candidates.isEmpty ? '无' : candidates}',
);
// 彻底核实:打印顶层全部字段名,并递归扫描子对象/数组
_printChunked(
'>>> [getSiteList/$scene] rows[$i] 全部顶层字段(${json.length}): ',
json.keys.join('|'),
);
final nested = _findKeyPaths(json, 'productModel');
debugPrint(
'>>> [getSiteList/$scene] rows[$i] 嵌套层 productModel 命中: '
'${nested.isEmpty ? '无(子对象/数组均未找到)' : nested.join(', ')}',
);
}
}
}
/// 递归查找指定 key(含子对象与数组),返回“路径=值”列表
List<String> _findKeyPaths(dynamic node, String key, [String path = '']) {
final hits = <String>[];
if (node is Map) {
node.forEach((k, v) {
final p = path.isEmpty ? '$k' : '$path.$k';
if (k.toString() == key) {
hits.add('$p=$v');
} else {
hits.addAll(_findKeyPaths(v, key, p));
}
});
} else if (node is List) {
for (int i = 0; i < node.length; i++) {
hits.addAll(_findKeyPaths(node[i], key, '$path[$i]'));
}
}
return hits;
}
/// 长文本分段打印,避免 debugPrint 单条过长被截断
void _printChunked(String prefix, String content) {
const chunk = 700;
var head = prefix;
for (int start = 0; start < content.length; start += chunk) {
final end =
start + chunk < content.length ? start + chunk : content.length;
debugPrint('$head${content.substring(start, end)}');
head = ' ↳ ';
}
}
bool _isModelLikeKey(String key) {
final k = key.toLowerCase();
return k.contains('model') ||
k.contains('product') ||
k.contains('type') ||
k.contains('version');
}
void _onChangeType(
RobotListChangeType event,
Emitter<RobotListState> emit,

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import '../../data/models/robot_data_model.dart';
import '../widgets/device_confirm_dialog.dart';
@@ -11,12 +12,14 @@ import '../widgets/voice_broadcast_sheet.dart';
/// 一条联动操作(远程开机后才解锁)
class _LinkageItem {
final IconData icon;
/// 名称语言包 key
final String label;
/// 主操作文案
/// 主操作文案语言包 key
final String action;
/// 次操作文案,null 表示只有一个操作(如急停)
/// 次操作文案语言包 key,null 表示只有一个操作(如急停)
final String? secondary;
/// 主操作是否红色危险态
@@ -94,115 +97,144 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
bool get _online => widget.robot.isOnline;
/// 联动操作清单,顺序与设计稿一致
/// 联动操作清单,顺序与设计稿一致;label/action/secondary 均存语言包 key
static const List<_LinkageItem> _linkages = [
_LinkageItem(
icon: Icons.content_cut,
label: '割刀启停',
action: '启动',
secondary: '停止',
label: 'robot_power_v2.lk_blade',
action: 'robot_power_v2.act_start',
secondary: 'robot_power_v2.act_stop',
),
_LinkageItem(
icon: Icons.unfold_more_rounded,
label: '底盘升降',
action: '升起',
secondary: '降下',
label: 'robot_power_v2.lk_chassis',
action: 'robot_power_v2.act_raise',
secondary: 'robot_power_v2.act_lower',
),
_LinkageItem(
icon: Icons.emergency_outlined,
label: '一键急停',
action: '急停',
label: 'robot_power_v2.lk_estop',
action: 'robot_power_v2.act_estop',
danger: true,
),
_LinkageItem(
icon: Icons.lightbulb_outline,
label: '灯光',
action: '开启',
secondary: '关闭',
label: 'robot_power_v2.lk_light',
action: 'robot_power_v2.act_on',
secondary: 'robot_power_v2.act_off',
),
_LinkageItem(
icon: Icons.campaign_outlined,
label: '鸣笛/语音',
action: '鸣笛',
secondary: '语音播报',
label: 'robot_power_v2.lk_horn',
action: 'robot_power_v2.act_horn',
secondary: 'robot_power_v2.act_voice',
voiceSecondary: true,
),
];
/// 通道未接入时的统一提示(状态仍按设计稿切换,便于验收解锁态)
void _channelTip(String action) => CapsuleToast.show(
'$action指令通道未接入(需遥控控制权与点火位下发),当前状态仅作界面预览',
showCheck: false,
);
void _channelTip(String actionKey) {
final l10n = AppLocalizations.of(context);
CapsuleToast.show(
l10n
.translate('robot_power_v2.toast_channel_tip')
.replaceAll('%s', l10n.translate(actionKey)),
showCheck: false,
);
}
/// 远程开机:先弹安全确认(离线也先弹,避免点了没反应),确认后再校验下发条件
Future<void> _powerOn() async {
final l10n = AppLocalizations.of(context);
if (_powered) {
CapsuleToast.show('设备已开机', showCheck: false);
CapsuleToast.show(
l10n.translate('robot_power_v2.toast_already_on'),
showCheck: false,
);
return;
}
final ok = await showDeviceConfirmDialog(
context,
icon: Icons.power_settings_new_rounded,
title: '远程开机确认',
message: '确认向机器人发送远程开机指令?开机成功后,才可启用割刀、底盘升降、急停等联动操作。',
note: '请确认设备周边安全、网络正常。',
confirmText: '确认开机',
title: l10n.translate('robot_power_v2.power_on_title'),
message: l10n.translate('robot_power_v2.power_on_msg'),
note: l10n.translate('robot_power_v2.power_on_note'),
confirmText: l10n.translate('robot_power_v2.power_on_ok'),
);
if (!ok || !mounted) return;
if (!_online) {
CapsuleToast.show('设备离线,开机指令未下发', showCheck: false);
CapsuleToast.show(
AppLocalizations.of(context).translate('robot_power_v2.toast_offline_on'),
showCheck: false,
);
return;
}
if (widget.onPowerOn != null) {
widget.onPowerOn!();
} else {
_channelTip('远程开机');
_channelTip('robot_power_v2.btn_power_on');
}
setState(() => _powered = true);
}
/// 远程关机:同样先弹确认,确认后再校验下发条件
Future<void> _powerOff() async {
final l10n = AppLocalizations.of(context);
if (!_powered) {
CapsuleToast.show('设备当前已关机', showCheck: false);
CapsuleToast.show(
l10n.translate('robot_power_v2.toast_already_off'),
showCheck: false,
);
return;
}
final ok = await showDeviceConfirmDialog(
context,
icon: Icons.power_settings_new_rounded,
title: '远程关机确认',
message: '确认向机器人发送远程关机指令?关机后割刀、底盘、灯光、鸣笛等联动功能将立即失效。',
note: '请确认设备已停止作业并处于安全位置。',
confirmText: '确认关机',
title: l10n.translate('robot_power_v2.power_off_title'),
message: l10n.translate('robot_power_v2.power_off_msg'),
note: l10n.translate('robot_power_v2.power_off_note'),
confirmText: l10n.translate('robot_power_v2.power_off_ok'),
danger: true,
);
if (!ok || !mounted) return;
if (!_online) {
CapsuleToast.show('设备离线,关机指令未下发', showCheck: false);
CapsuleToast.show(
AppLocalizations.of(
context,
).translate('robot_power_v2.toast_offline_off'),
showCheck: false,
);
return;
}
if (widget.onPowerOff != null) {
widget.onPowerOff!();
} else {
_channelTip('远程关机');
_channelTip('robot_power_v2.btn_power_off');
}
setState(() => _powered = false);
}
/// 联动操作统一入口:未开机 → 锁定提示;已开机 → 回调或明确未接入
void _linkage(String action) {
void _linkage(String actionKey) {
final l10n = AppLocalizations.of(context);
final action = l10n.translate(actionKey);
if (!_powered) {
CapsuleToast.show('请先远程开机,开机后方可执行$action', showCheck: false);
CapsuleToast.show(
l10n.translate('robot_power_v2.toast_need_power').replaceAll('%s', action),
showCheck: false,
);
return;
}
if (widget.onLinkageAction != null) {
widget.onLinkageAction!(action);
return;
}
CapsuleToast.show('$action指令通道未接入', showCheck: false);
CapsuleToast.show(
l10n.translate('robot_power_v2.toast_channel_short').replaceAll('%s', action),
showCheck: false,
);
}
/// 次操作:语音播报走公共弹窗,其余走联动通道
@@ -212,7 +244,12 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
return;
}
if (!_powered) {
CapsuleToast.show('请先远程开机,开机后方可执行语音播报', showCheck: false);
CapsuleToast.show(
AppLocalizations.of(
context,
).translate('robot_power_v2.toast_need_power_voice'),
showCheck: false,
);
return;
}
_openVoiceBroadcast();
@@ -228,17 +265,24 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
showVoiceBroadcastSheet(context, robot: widget.robot);
}
void _openMoreUnlock() =>
CapsuleToast.show('更多解锁功能将随版本逐步开放', showCheck: false);
void _openMoreUnlock() => CapsuleToast.show(
AppLocalizations.of(context).translate('robot_power_v2.toast_more_unlock'),
showCheck: false,
);
void _openPowerHelp() =>
CapsuleToast.show('远程开机后才可执行割刀、底盘、急停、灯光、鸣笛等联动操作', showCheck: false);
void _openPowerHelp() => CapsuleToast.show(
AppLocalizations.of(context).translate('robot_power_v2.toast_power_help'),
showCheck: false,
);
void _openSafetyHelp() =>
CapsuleToast.show('远程上电与动作控制需设备端安全鉴权,确认周边无人后再操作', showCheck: false);
void _openSafetyHelp() => CapsuleToast.show(
AppLocalizations.of(context).translate('robot_power_v2.toast_safety_help'),
showCheck: false,
);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final tileWidth = (MediaQuery.of(context).size.width - 32 - 20) / 3;
return Scaffold(
backgroundColor: const Color(0xFFEDF6FD),
@@ -263,28 +307,28 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
RobotSummaryCardV2(robot: widget.robot),
const SizedBox(height: 16),
_buildSectionTitle(
'远程开关机',
l10n.translate('robot_power_v2.sec_remote_power'),
trailing: GestureDetector(
onTap: _openPowerHelp,
behavior: HitTestBehavior.opaque,
child: const Row(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
const Icon(
Icons.help_outline_rounded,
size: 14,
color: _blue,
),
SizedBox(width: 3),
const SizedBox(width: 3),
Text(
'开关机说明',
style: TextStyle(
l10n.translate('robot_power_v2.power_help'),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _blue,
),
),
Icon(
const Icon(
Icons.chevron_right_rounded,
size: 15,
color: _blue,
@@ -297,9 +341,13 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
_buildRemotePowerCard(),
const SizedBox(height: 16),
_buildSectionTitle(
'联动操作',
l10n.translate('robot_power_v2.sec_linkage'),
trailing: Text(
_powered ? '已解锁,可直接操作' : '开机后再操作',
l10n.translate(
_powered
? 'robot_power_v2.unlocked'
: 'robot_power_v2.locked_hint',
),
style: const TextStyle(fontSize: 11.5, color: _hint),
),
),
@@ -321,10 +369,10 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
),
const SizedBox(height: 16),
_buildSectionTitle(
'开机后解锁的控制',
trailing: const Text(
'远程开机成功后自动解锁',
style: TextStyle(fontSize: 11.5, color: _hint),
l10n.translate('robot_power_v2.sec_unlock'),
trailing: Text(
l10n.translate('robot_power_v2.unlock_hint'),
style: const TextStyle(fontSize: 11.5, color: _hint),
),
),
const SizedBox(height: 10),
@@ -373,12 +421,12 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
),
),
const SizedBox(width: 10),
const Expanded(
Expanded(
child: Text(
'APP开关机与联动控制',
AppLocalizations.of(context).translate('robot_power_v2.title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: _navy,
@@ -402,7 +450,11 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
),
const SizedBox(width: 4),
Text(
_powered ? '已开机' : '未开机',
AppLocalizations.of(context).translate(
_powered
? 'robot_power_v2.state_on'
: 'robot_power_v2.state_off_short',
),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
@@ -484,13 +536,17 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'当前状态',
style: TextStyle(fontSize: 12, color: _gray),
Text(
AppLocalizations.of(context).translate('robot_power_v2.cur_state'),
style: const TextStyle(fontSize: 12, color: _gray),
),
const SizedBox(height: 4),
Text(
_powered ? '已开机' : '已关机',
AppLocalizations.of(context).translate(
_powered
? 'robot_power_v2.state_on'
: 'robot_power_v2.state_off',
),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
@@ -499,9 +555,11 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
),
const SizedBox(height: 6),
Text(
_powered
? '联动操作已解锁,可执行割刀、底盘、灯光、鸣笛等操作。'
: '远程开机后,方可执行割刀、底盘、急停、灯光、鸣笛等联动操作。',
AppLocalizations.of(context).translate(
_powered
? 'robot_power_v2.powered_desc'
: 'robot_power_v2.unpowered_desc',
),
style: const TextStyle(
fontSize: 11,
height: 1.5,
@@ -516,10 +574,18 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
width: 104,
child: Column(
children: [
_buildPowerButton(label: '远程开机', filled: true, onTap: _powerOn),
_buildPowerButton(
label: AppLocalizations.of(
context,
).translate('robot_power_v2.btn_power_on'),
filled: true,
onTap: _powerOn,
),
const SizedBox(height: 8),
_buildPowerButton(
label: '远程关机',
label: AppLocalizations.of(
context,
).translate('robot_power_v2.btn_power_off'),
filled: false,
onTap: _powerOff,
),
@@ -580,6 +646,7 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
// ===== 联动磁贴:图标 + 名称 + 锁提示 + 分段按钮 =====
Widget _buildLinkageTile(_LinkageItem item) {
final l10n = AppLocalizations.of(context);
final locked = !_powered;
final accent = locked ? _hint : (item.danger ? _danger : _blue);
return Container(
@@ -606,7 +673,7 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
),
const SizedBox(height: 8),
Text(
item.label,
l10n.translate(item.label),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
@@ -624,7 +691,11 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
const SizedBox(width: 2),
],
Text(
locked ? '设备未开机' : '可操作',
l10n.translate(
locked
? 'robot_power_v2.tile_device_off'
: 'robot_power_v2.tile_operable',
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 9.5, color: _hint),
@@ -653,6 +724,7 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
required VoidCallback onPrimary,
VoidCallback? onSecondary,
}) {
final l10n = AppLocalizations.of(context);
final textColor = locked ? _lockedText : accent;
return Container(
height: 28,
@@ -669,7 +741,7 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
behavior: HitTestBehavior.opaque,
child: Center(
child: Text(
item.action,
l10n.translate(item.action),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
@@ -694,7 +766,7 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
behavior: HitTestBehavior.opaque,
child: Center(
child: Text(
item.secondary!,
l10n.translate(item.secondary!),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
@@ -738,7 +810,7 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
),
const SizedBox(height: 8),
Text(
'一键寻车',
AppLocalizations.of(context).translate('robot_power_v2.find_tile'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
@@ -756,7 +828,11 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
const SizedBox(width: 2),
],
Text(
locked ? '声光需开机' : '可操作',
AppLocalizations.of(context).translate(
locked
? 'robot_power_v2.find_locked'
: 'robot_power_v2.tile_operable',
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 9.5, color: _hint),
@@ -775,11 +851,11 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
borderRadius: BorderRadius.circular(9),
border: Border.all(color: _segBorder),
),
child: const Text(
'开始寻车',
child: Text(
AppLocalizations.of(context).translate('robot_power_v2.find_start'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
style: const TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w700,
color: _blue,
@@ -794,11 +870,12 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
// ===== 开机后解锁的控制:开关机 → 联动功能链 =====
Widget _buildUnlockCard() {
final l10n = AppLocalizations.of(context);
const items = <(IconData, String)>[
(Icons.content_cut, '割刀启停'),
(Icons.unfold_more_rounded, '底盘升降'),
(Icons.lightbulb_outline, '灯光'),
(Icons.campaign_outlined, '鸣笛/语音'),
(Icons.content_cut, 'robot_power_v2.lk_blade'),
(Icons.unfold_more_rounded, 'robot_power_v2.lk_chassis'),
(Icons.lightbulb_outline, 'robot_power_v2.lk_light'),
(Icons.campaign_outlined, 'robot_power_v2.lk_horn'),
];
return Container(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
@@ -827,7 +904,7 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
),
const SizedBox(height: 6),
Text(
'远程开机',
l10n.translate('robot_power_v2.btn_power_on'),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
@@ -836,7 +913,11 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
),
const SizedBox(height: 2),
Text(
_powered ? '已开机' : '当前状态',
l10n.translate(
_powered
? 'robot_power_v2.state_on'
: 'robot_power_v2.cur_state',
),
style: const TextStyle(fontSize: 8.5, color: _hint),
),
],
@@ -871,7 +952,7 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
),
const SizedBox(height: 5),
Text(
item.$2,
l10n.translate(item.$2),
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
@@ -883,9 +964,9 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
GestureDetector(
onTap: _openMoreUnlock,
behavior: HitTestBehavior.opaque,
child: const Column(
child: Column(
children: [
SizedBox(
const SizedBox(
width: 30,
height: 30,
child: Icon(
@@ -894,8 +975,11 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
color: _hint,
),
),
SizedBox(height: 5),
Text('更多', style: TextStyle(fontSize: 9, color: _hint)),
const SizedBox(height: 5),
Text(
l10n.translate('robot_power_v2.more'),
style: const TextStyle(fontSize: 9, color: _hint),
),
],
),
),
@@ -923,10 +1007,10 @@ class _RobotAppPowerPageV2State extends State<RobotAppPowerPageV2> {
children: [
const Icon(Icons.verified_user_rounded, size: 16, color: _blue),
const SizedBox(width: 8),
const Expanded(
Expanded(
child: Text(
'涉及远程上电与动作控制,需通过安全鉴权,确保设备与人员安全。',
style: TextStyle(
AppLocalizations.of(context).translate('robot_power_v2.footer'),
style: const TextStyle(
fontSize: 11.5,
height: 1.5,
color: Color(0xFF5C6B84),

View File

@@ -1,14 +1,21 @@
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';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:latlong2/latlong.dart';
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
import 'package:maibu_satabot_v2/core/di/injection.dart';
import 'package:maibu_satabot_v2/core/logging/work_trace_diagnostics.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/devices/domain/entities/running_status_entity.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_bloc.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_state.dart';
import 'package:maibu_satabot_v2/features/pv_control/presentation/widgets/pv_work_map.dart';
import 'package:maibu_satabot_v2/features/pv_control/presentation/models/work_trace_recorder.dart';
import '../../data/models/robot_data_model.dart';
import '../widgets/autonomous/work_confirm_dialog.dart';
@@ -35,17 +42,36 @@ import 'route_editor_page_v2.dart';
///
/// 这样换环境(A/B 两套后端)只需新增一个 gateway 实现 + 改装配层,本页零改动。
/// 进入本页时要自动展开的功能面板(用于区分入口语义):
/// - 任务启动入口:不传,停在准备态;
/// - 任务管理入口:传 [taskQueue],进页直接展开「多区域连续任务队列」。
enum RobotWorkEntryPanel { taskQueue, routeLibrary, record, settings }
class RobotAutonomousWorkPageV2 extends StatefulWidget {
const RobotAutonomousWorkPageV2({super.key, required this.robot});
const RobotAutonomousWorkPageV2({
super.key,
required this.robot,
this.entryPanel,
this.initialRoute,
});
final RobotDataModel robot;
/// 入口初始面板:null = 准备态(任务启动入口行为不变)
final RobotWorkEntryPanel? entryPanel;
/// 从「路线库」(作业 tab → 路线库页)选中带入的路线:
/// 进场即选中该路线并画线,等价抽屉面板里点「确认路线」。
final WorkRouteOption? initialRoute;
@override
State<RobotAutonomousWorkPageV2> createState() =>
_RobotAutonomousWorkPageV2State();
}
class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
final _traceDiagnostics = WorkTraceDiagnostics();
/// 与原生 MainActivity 的窗口通道(挖孔区域占用控制)
static const MethodChannel _windowChannel = MethodChannel('satabot/window');
@@ -65,25 +91,71 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
StreamSubscription<WorkTaskSnapshot?>? _taskSub;
String? _lastFinishedTaskId;
Timer? _finishTraceTimer;
/// 清轨迹信号:每次 +1 就把 PvWorkMap 内部的绿色行走轨迹清空
/// (等价路径规划页的 traceManager.reset())
int _clearTraceSignal = 0;
/// 正在拉取的 routeId:防止状态流连续推送时重复查询同一路线
String? _restoringRouteId;
int _routeLoadGeneration = 0;
bool _infoExpanded = true;
String get _deviceId => widget.robot.name;
bool get _isOnline => widget.robot.isOnline;
String get _routeName =>
_route?.name ?? _session?.routeName ?? '未选择作业路线';
List<LatLng> get _previewRoute =>
_route?.previewPoints ?? const <LatLng>[];
String get _routeName => _route?.name ?? _session?.routeName ?? '未选择作业路线';
List<LatLng> get _previewRoute => _route?.previewPoints ?? const <LatLng>[];
/// 覆盖模式的作业区域边界框(自定义模式为空,与路径规划一致)
List<LatLng> get _previewOuter =>
_route?.previewOuter ?? const <LatLng>[];
List<LatLng> get _previewOuter => _route?.previewOuter ?? const <LatLng>[];
/// 覆盖模式:地图上画灰色区域框;自定义模式画绿色边界线
bool get _previewCoverage => _route?.coverageMode ?? true;
// ===== 实时设备数据(与「远程遥控」同源:全局 DeviceStatusBloc,MQTT 500ms 节流)=====
// 顶部 RTK / 行驶速度、信息卡的 定位 / 割刀状态 / 行驶速度 均取这里,
// 数据口径与「设备数据监控」弹窗一致(qual / linearSpeed / knifeCuttingSpeed)。
DeviceStatusBloc get _statusBloc => sl<DeviceStatusBloc>();
/// 从 DeviceStatus 状态里取最新机器状态快照(未收到数据为 null)
static RunningStatusEntity? _statusOf(DeviceStatusState s) =>
s is DeviceStatusUpdated ? s.status : null;
/// 定位质量枚举 → 文案(与设备数据监控 _getLocationQualityText 同口径)
static String _qualText(int? q) => switch (q) {
null => '未知',
0 => '无定位',
1 => '单点定位',
2 => '差分定位',
3 => '惯性导航',
4 => 'RTK 固定解',
5 => 'RTK 浮点解',
6 => '单点(无航向)',
7 => '差分(无航向)',
8 => 'RTK 固定(无航向)',
9 => 'RTK 浮点(无航向)',
_ => '未知',
};
/// 是否 RTK 固定解(4=固定,8=固定无航向)——绿色高亮依据
static bool _isRtkFixed(int? q) => q == 4 || q == 8;
/// 行驶速度(m/s):仅当已收到 host 实时数据时取线性速度,否则 null(显示 '--')
static double? _speedOf(RunningStatusEntity? s) =>
(s != null && s.hostDataReceived) ? s.linearSpeed : null;
/// 割刀转速(rpm):来自机器状态 knifeCuttingSpeed;无数据为 null
static double? _knifeRpmOf(RunningStatusEntity? s) =>
s == null ? null : (double.tryParse(s.knifeCuttingSpeed) ?? 0);
/// 实时电量(%):与远程遥控同口径,仅当已收到 host 实时数据时取 status.battery;
/// 未收到时返回 null,由 UI 回退设备列表的静态电量
static double? _batteryOf(RunningStatusEntity? s) =>
(s != null && s.hostDataReceived) ? double.tryParse(s.battery) : null;
@override
void initState() {
super.initState();
@@ -95,11 +167,34 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
sl<DeviceStatusBloc>().startMqttRealtimeListening(_deviceId);
_bootstrap();
// 先还原本设备活跃任务,再应用「路线库」带入的路线(两者有先后依赖)
unawaited(_bootstrapAndApplyInitialRoute());
// 自主作业暂不弹模式确认,直接恢复对应入口面板。
_openEntryPanelIfNeeded();
});
_taskSub = _gateway.watchTask(deviceId: _deviceId).listen(_applySession);
}
/// 入口自动展开的面板:任务管理 → 任务队列;其余入口按参数展开
void _openEntryPanelIfNeeded() {
final panel = widget.entryPanel;
if (panel == null || !mounted) return;
switch (panel) {
case RobotWorkEntryPanel.taskQueue:
_openTaskQueue();
break;
case RobotWorkEntryPanel.routeLibrary:
_openRouteLibrary();
break;
case RobotWorkEntryPanel.record:
_openRecordPanel();
break;
case RobotWorkEntryPanel.settings:
_openSettingPanel();
break;
}
}
/// 进入横屏(与 PV 遥控页一致):沉浸式 + 占用挖孔区域铺满全屏
void _enterLandscape() {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
@@ -128,6 +223,7 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
@override
void dispose() {
_taskSub?.cancel();
_finishTraceTimer?.cancel();
_restoreOrientation();
super.dispose();
}
@@ -142,6 +238,43 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
if (mounted) _enterLandscape();
}
void _onWorkFinished(WorkTaskSnapshot task) {
if (_lastFinishedTaskId == task.taskId) {
_traceDiagnostics.log('结束页面去重', {
'deviceId': _deviceId,
'taskId': task.taskId,
'status': task.rawStatus,
});
return;
}
_traceDiagnostics.log('结束页面确认', {
'deviceId': _deviceId,
'taskId': task.taskId,
'status': task.rawStatus,
'points': task.completedPoints.length,
'note': '页面收到结束快照,准备显示完成提示;确认来源见结束状态落地日志',
});
_lastFinishedTaskId = task.taskId;
_finishTraceTimer?.cancel();
CapsuleToast.show(
task.rawStatus == 'FINISH' ? '作业已完成' : '作业已结束',
showCheck: true,
);
_finishTraceTimer = Timer(const Duration(seconds: 1), () {
if (mounted &&
_session?.taskId == task.taskId &&
_phase == WorkRunState.finished) {
setState(() => _clearTraceSignal++);
_traceDiagnostics.log('结束显示清理', {
'deviceId': _deviceId,
'taskId': task.taskId,
'pagePhase': _phase.name,
'clearTraceSignal': _clearTraceSignal,
});
}
});
}
// ===== 状态映射:唯一真相是 gateway 给的 WorkTaskSnapshot =====
/// 把任务快照落到界面态。
/// - queued:本页刚点过「开始」就显示执行中(后端 NEW→EXECUTING 有延迟)
@@ -149,6 +282,18 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
/// - null:任务被清空,但若是「已结束」保持结束态,不弹回准备
void _applySession(WorkTaskSnapshot? session) {
if (!mounted) return;
if (session != null && session.deviceId != _deviceId) {
_traceDiagnostics.log('页面过滤', {
'expectedDeviceId': _deviceId,
'receivedDeviceId': session.deviceId,
'taskId': session.taskId,
'reason': '任务快照设备不匹配',
});
return;
}
if (session != null && session.state == WorkRunState.finished) {
_onWorkFinished(session);
}
setState(() {
_session = session;
final state = session?.state;
@@ -159,7 +304,9 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
}
switch (state) {
case WorkRunState.queued:
_phase = _startedByUser ? WorkRunState.running : WorkRunState.queued;
_phase = _startedByUser || session?.executionStarted == true
? WorkRunState.running
: WorkRunState.queued;
case WorkRunState.running:
// 后端已确认执行:乐观标记可以撤掉了
_startedByUser = false;
@@ -176,6 +323,36 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
_phase = state;
}
});
if (session != null && session.state == WorkRunState.finished) {
_traceDiagnostics.log('结束页面落地', {
'deviceId': _deviceId,
'taskId': session.taskId,
'status': session.rawStatus,
'pagePhase': _phase.name,
'receivedPoints': session.completedPoints.length,
});
}
_traceDiagnostics.log(
'页面接收',
{
'deviceId': _deviceId,
'taskId': session?.taskId,
'status': session?.rawStatus,
'taskState': session?.state.name,
'executionStarted': session?.executionStarted,
'canRecordTrace': session?.canRecordTrace ?? false,
'pagePhase': _phase.name,
'receivedPoints': session?.completedPoints.length ?? 0,
'mapCompletedPoints':
_phase == WorkRunState.running || _phase == WorkRunState.paused
? session?.completedPoints.length ?? 0
: 0,
'recordLocationTrace': false,
'traceMode': '首点前定位累计,首点后到达点+实时车位;实际画线见地图画线日志',
},
sampleKey:
'page/$_deviceId/${session?.taskId}/${session?.state.name}/${_phase.name}/${(session?.completedPoints.length ?? 0) < 2 ? session?.completedPoints.length ?? 0 : "2+"}',
);
// 换了一条任务:把它的路线加载出来画到地图上
// (对应路径规划页点选列表项后立即画线的行为)
final routeId = session?.routeId;
@@ -190,22 +367,48 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
Future<void> _bootstrap() async {
final result = await _gateway.loadTasks(deviceId: _deviceId);
if (!mounted) return;
final tasks = result.data ?? const <WorkTaskSnapshot>[];
if (tasks.isEmpty) return;
_applySession(tasks.first);
if (!result.isOk) {
CapsuleToast.show(result.error ?? '任务恢复失败', showCheck: false);
}
}
/// 先完成进场任务还原,再应用「路线库」带入的路线:
/// 设备上有执行中的任务时以任务为准(与在抽屉里换路线的拦截口径一致),
/// 否则选中带入路线并画线;列表没带几何时走 resolveRouteGeometry 补全。
Future<void> _bootstrapAndApplyInitialRoute() async {
await _bootstrap();
final route = widget.initialRoute;
if (!mounted || route == null) return;
if (_session != null && _phase == WorkRunState.running) {
CapsuleToast.show('作业执行中,已保留当前任务路线', showCheck: false);
return;
}
if (await _selectRoute(route) && mounted) {
if (_phase == WorkRunState.finished || _phase == WorkRunState.idle) {
_finishTraceTimer?.cancel();
setState(() {
_session = null;
_phase = WorkRunState.idle;
_startedByUser = false;
_clearTraceSignal++;
});
}
CapsuleToast.show('已选择路线:${route.name}');
}
}
/// 选中一条路线:先把已知几何画上(有就立即显示),没有再去查详情补全。
/// 对应路径规划「点列表项 → 清掉旧线 → 解析 path/outer → 画线 → 定位 21 级」。
/// 返回 false 表示这条路线确实没有可画的数据。
Future<bool> _selectRoute(WorkRouteOption route) async {
final generation = ++_routeLoadGeneration;
setState(() => _route = route);
if (route.hasGeometry) return true;
final result = await _gateway.resolveRouteGeometry(
deviceId: _deviceId,
route: route,
);
if (!mounted) return false;
if (!mounted || generation != _routeLoadGeneration) return false;
final hydrated = result.data;
if (hydrated != null && hydrated.hasGeometry) {
setState(() => _route = hydrated);
@@ -220,13 +423,18 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
/// 按 routeId 从路线库取回完整几何(轨迹 + 边界框)
Future<void> _restoreRoute(String routeId) async {
final taskId = _session?.taskId;
_restoringRouteId = routeId;
try {
final scope = await _gateway.resolveScope(deviceId: _deviceId);
final siteId = scope.siteId;
if (siteId == null) return;
final result = await _gateway.loadRoutes(siteId: siteId);
if (!mounted) return;
if (!mounted ||
_session?.taskId != taskId ||
_session?.routeId != routeId) {
return;
}
for (final route in result.data ?? const <WorkRouteOption>[]) {
if (route.id == routeId) {
await _selectRoute(route);
@@ -265,15 +473,12 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
final ok = await showWorkConfirmDialog(
context,
title: '开始作业 · 二次确认',
desc: '路线:${route.name}'
desc:
'路线:${route.name}'
'${route.metaText != null ? ' · ${route.metaText}' : ''}'
'${route.hasGeometry ? '' : '(无路径数据,可能下发失败)'}'
'。确认后开始执行。',
checks: const [
'设备已上电、控制权符合要求',
'定位有效,路线及电子围栏匹配',
'作业区域安全,割刀与急停互锁有效',
],
checks: const ['设备已上电、控制权符合要求', '定位有效,路线及电子围栏匹配', '作业区域安全,割刀与急停互锁有效'],
confirmLabel: '确认开始作业',
);
if (ok != true || !mounted) return;
@@ -284,6 +489,10 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
if (!mounted) return;
WorkTaskSnapshot? reuse;
if (!pooled.isOk) {
CapsuleToast.show(pooled.error ?? '任务查询失败', showCheck: false);
return;
}
final candidates = pooled.data ?? const <WorkTaskSnapshot>[];
if (candidates.isNotEmpty) {
if (_gateway.capabilities.multiActiveTaskResolve &&
@@ -296,9 +505,7 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
}
// ② 已有任务跑的是别的路线:不静默覆盖,交给用户先处理
if (reuse != null &&
reuse.routeId != null &&
reuse.routeId != route.id) {
if (reuse != null && reuse.routeId != null && reuse.routeId != route.id) {
CapsuleToast.show(
'设备上已有其它路线的任务(${reuse.routeName ?? reuse.routeId}),请先在「任务队列」处理',
showCheck: false,
@@ -320,7 +527,7 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
return;
}
_startedByUser = true;
_startedByUser = result.data?.state == WorkRunState.queued;
_applySession(result.data);
CapsuleToast.show('作业已下发:${route.name}');
});
@@ -336,11 +543,7 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
context,
title: '暂停作业 · 确认',
desc: '暂停后保留当前任务ID、路线及作业断点;机器人需要先完成安全停止。',
checks: const [
'行驶速度降至安全状态',
'割刀按任务安全暂停策略停转',
'保存断点位置与已覆盖区域',
],
checks: const ['行驶速度降至安全状态', '割刀按任务安全暂停策略停转', '保存断点位置与已覆盖区域'],
confirmLabel: '确认暂停',
);
if (ok != true || !mounted) return;
@@ -367,11 +570,7 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
context,
title: '停止作业 · 二次确认',
desc: '本次任务将被终止,不会自动续作。已完成区域、轨迹与停止时间会保存到作业记录。',
checks: const [
'先安全停止底盘与割刀',
'记录人工停止原因与实际完成区域',
'已终止任务不能用“继续”恢复',
],
checks: const ['先安全停止底盘与割刀', '记录人工停止原因与实际完成区域', '已终止任务不能用“继续”恢复'],
confirmLabel: '确认停止作业',
danger: true,
);
@@ -402,26 +601,19 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
context,
title: '继续 / 断点续作 · 确认',
desc: '继续原任务「$_routeName」,沿用原路线与已保存的覆盖区域。',
checks: const [
'暂停或故障原因已消除',
'任务ID、地图、路线与断点匹配',
'定位质量、控制权限、割刀与底盘状态合格',
],
checks: const ['暂停或故障原因已消除', '任务ID、地图、路线与断点匹配', '定位质量、控制权限、割刀与底盘状态合格'],
confirmLabel: '确认继续作业',
);
if (ok != true || !mounted) return;
await _runBusy(() async {
final result = await _gateway.resumeWork(
deviceId: _deviceId,
task: task,
);
final result = await _gateway.resumeWork(deviceId: _deviceId, task: task);
if (!mounted) return;
if (!result.isOk) {
CapsuleToast.show(result.error ?? '继续失败', showCheck: false);
return;
}
_startedByUser = true;
_startedByUser = result.data?.state == WorkRunState.queued;
_applySession(result.data);
CapsuleToast.show('已下发继续指令');
});
@@ -517,9 +709,17 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
return;
}
// 进「地图与区域编辑」:本页已是横屏,直接推入(自定义淡入 + 极轻缩放转场)
await Navigator.of(
context,
).push(RouteEditorPageV2.route(deviceSn: _deviceId));
// exitToLandscape:本页是横屏锁定页,编辑器退出时必须把方向交还横屏,
// 否则它会恢复系统方向,本页被转成竖屏、布局全部错位。
await Navigator.of(context).push(
RouteEditorPageV2.route(deviceSn: _deviceId, exitToLandscape: true),
);
// 兜底:无论以何种方式返回,都重新锁定横屏
if (mounted) {
_enterLandscape();
_statusBloc.startMqttRealtimeListening(_deviceId);
unawaited(_bootstrap());
}
return;
}
@@ -530,6 +730,15 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
return;
}
if (await _selectRoute(route) && mounted) {
if (_phase == WorkRunState.finished || _phase == WorkRunState.idle) {
_finishTraceTimer?.cancel();
setState(() {
_session = null;
_phase = WorkRunState.idle;
_startedByUser = false;
_clearTraceSignal++;
});
}
CapsuleToast.show('已选择路线:${route.name}');
}
}
@@ -543,7 +752,7 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
}
void _openRecordPanel() {
showWorkRecordPanel(context);
showWorkRecordPanel(context, deviceId: _deviceId);
}
void _openSettingPanel() {
@@ -671,6 +880,18 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
// ===== 页面骨架 =====
@override
Widget build(BuildContext context) {
// 自主作业的控制模式弹窗暂时停用,保留代码供后续恢复。
// return ControlModeEntryGate(
// key: ValueKey('autonomous-mode/$_deviceId'),
// deviceId: _deviceId,
// target: ControlModeTarget.autonomous,
// onReady: _openEntryPanelIfNeeded,
// child: _buildPage(context),
// );
return _buildPage(context);
}
Widget _buildPage(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF26331F),
body: Stack(
@@ -683,7 +904,19 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
previewRoute: _previewRoute,
previewOuter: _previewOuter,
previewCoverage: _previewCoverage,
previewSignature: _route?.id ?? '',
previewSignature: '${_route?.id ?? ''}/${_session?.taskId ?? ''}',
expectedTaskId: _session?.taskId,
traceDiagnosticsEnabled: true,
recordLocationTrace: false,
taskTrace: WorkTraceInput(
taskId: _session?.taskId,
completedPoints: _session?.completedPoints ?? const <LatLng>[],
recording: _session?.canRecordTrace ?? false,
visible:
_phase == WorkRunState.running ||
_phase == WorkRunState.paused,
),
clearTraceSignal: _clearTraceSignal,
),
),
// 不用 SafeArea:本页横屏全屏,需铺满到挖孔/手势条区域
@@ -695,6 +928,13 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
constraints.biggest,
cutout: MediaQuery.viewPaddingOf(context),
);
// 兜底自愈:本页只支持横屏。宽 < 高说明方向被解锁后系统转成了竖屏,
// 按竖屏尺寸算出的顶栏 / 栏位会全部错位,这里把方向拉回横屏(下一帧生效)。
if (constraints.maxWidth < constraints.maxHeight) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _enterLandscape();
});
}
return Stack(
children: [
_buildTopBar(m),
@@ -721,10 +961,7 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
top: 0,
height: m.topBarH,
child: Container(
padding: EdgeInsets.only(
left: m.topPad,
right: m.topPad,
),
padding: EdgeInsets.only(left: m.topPad, right: m.topPad),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: Color(0xFFE3EAF3))),
@@ -777,16 +1014,24 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
),
),
SizedBox(width: m.topGap),
_rtkPill(m),
if (!m.topTight) ...[
SizedBox(width: m.topGap),
_signalPill(m),
],
BlocBuilder<DeviceStatusBloc, DeviceStatusState>(
bloc: _statusBloc,
builder: (context, ds) => _rtkPill(m, _statusOf(ds)?.qual),
),
if (!m.topTight) ...[SizedBox(width: m.topGap), _signalPill(m)],
SizedBox(width: m.topGap),
_batteryPill(m),
BlocBuilder<DeviceStatusBloc, DeviceStatusState>(
bloc: _statusBloc,
builder: (context, ds) =>
_batteryPill(m, _batteryOf(_statusOf(ds))),
),
if (!m.topTight) ...[
SizedBox(width: m.topGap),
_speedPill(m),
BlocBuilder<DeviceStatusBloc, DeviceStatusState>(
bloc: _statusBloc,
builder: (context, ds) =>
_speedPill(m, _speedOf(_statusOf(ds))),
),
],
SizedBox(width: m.topGap),
_statusDetailButton(m),
@@ -905,23 +1150,26 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
);
}
Widget _rtkPill(_WorkLayout m) {
Widget _rtkPill(_WorkLayout m, int? qual) {
final k = m.topK;
final fixed = _isRtkFixed(qual);
final fg = qual == null ? kWtGray2 : (fixed ? kWtGreen : kWtWarn);
final bg = qual == null ? kWtSegBg : (fixed ? kWtGreenBg : kWtWarnBg);
return Container(
height: (32 * k).clamp(22.0, 32.0),
padding: EdgeInsets.symmetric(horizontal: (13 * k).clamp(8.0, 13.0)),
alignment: Alignment.center,
decoration: BoxDecoration(
color: kWtGreenBg,
color: bg,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'RTK 固定解',
_qualText(qual),
style: TextStyle(
fontSize: (12.5 * k).clamp(9.5, 12.5),
height: 1.1,
fontWeight: FontWeight.w700,
color: kWtGreen,
color: fg,
),
),
);
@@ -951,20 +1199,23 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
);
}
Widget _batteryPill(_WorkLayout m) {
Widget _batteryPill(_WorkLayout m, double? pct) {
final k = m.topK;
final pct = widget.robot.battery.round();
// 只展实时电量(host/realTime/message);未收到数据时显示“未知”,
// 不回退设备列表的静态电量(列表值常为默认 100%,会误导)。
final fg = pct == null ? kWtMuted : (pct > 20 ? kWtGreen : kWtDanger);
final text = pct == null ? '未知' : '${pct.round()}%';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.battery_5_bar_rounded,
size: (16 * k).clamp(11.0, 16.0),
color: kWtGreen,
color: fg,
),
SizedBox(width: (4 * k).clamp(2.0, 4.0)),
Text(
'$pct%',
text,
style: TextStyle(
fontSize: m.topStatusFont,
height: 1.1,
@@ -976,10 +1227,12 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
);
}
Widget _speedPill(_WorkLayout m) {
final speed = _phase.isRunning ? '0.6 m/s' : '0.0 m/s';
Widget _speedPill(_WorkLayout m, double? speed) {
final text = speed == null
? '-- m/s'
: '${speed.abs().toStringAsFixed(1)} m/s';
return Text(
speed,
text,
style: TextStyle(
fontSize: m.topStatusFont,
height: 1.1,
@@ -1271,33 +1524,56 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
constraints: BoxConstraints(maxHeight: m.infoRowsMaxH),
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(px, m.tiny ? 3 : 6, px, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_kvRow(
m,
'设备运行',
_runText(),
color: _phase.isPaused
? kWtWarn
: (_phase.isRunning ? kWtGreen : kWtNavy),
),
_kvRow(m, '当前任务', _taskText()),
_kvRow(m, '电量', '${widget.robot.battery.round()}%'),
_kvRow(m, '定位', 'RTK 固定解', color: kWtGreen),
_kvRow(
m,
'割刀状态',
_phase.isRunning ? '运行中' : '已停止',
color: _phase.isRunning ? kWtGreen : kWtNavy,
),
_kvRow(
m,
'行驶速度',
_phase.isRunning ? '0.6 m/s' : '0.0 m/s',
),
_kvRow(m, '当前故障', '0 项', color: kWtGreen),
],
child: BlocBuilder<DeviceStatusBloc, DeviceStatusState>(
bloc: _statusBloc,
builder: (context, ds) {
final st = _statusOf(ds);
final fixed = _isRtkFixed(st?.qual);
final knife = _knifeRpmOf(st);
final speed = _speedOf(st);
final battery = _batteryOf(st);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_kvRow(
m,
'设备运行',
_runText(),
color: _phase.isPaused
? kWtWarn
: (_phase.isRunning ? kWtGreen : kWtNavy),
),
_kvRow(m, '当前任务', _taskText()),
_kvRow(
m,
'电量',
battery == null ? '未知' : '${battery.round()}%',
),
_kvRow(
m,
'定位',
_qualText(st?.qual),
color: fixed ? kWtGreen : kWtNavy,
),
_kvRow(
m,
'割刀状态',
knife == null ? '未知' : (knife > 0 ? '运行中' : '已停止'),
color: (knife != null && knife > 0)
? kWtGreen
: kWtNavy,
),
_kvRow(
m,
'行驶速度',
speed == null
? '-- m/s'
: '${speed.abs().toStringAsFixed(1)} m/s',
),
_kvRow(m, '当前故障', '0 项', color: kWtGreen),
],
);
},
),
),
),
@@ -1692,7 +1968,7 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
if (task == null) {
switch (_phase) {
case WorkRunState.finished:
return '本次作业已结束 · 作业记录已保存';
return '本次作业已结束';
case WorkRunState.running:
return '任务执行中';
default:
@@ -1722,7 +1998,11 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
case WorkRunState.failed:
return ('异常中断', kWtWarnBg, kWtWarn);
case WorkRunState.finished:
return ('已结束 · 已归档', kWtGreenBg, kWtGreen);
return (
_session?.rawStatus == 'FINISH' ? '已完成' : '已结束',
kWtGreenBg,
kWtGreen,
);
}
}
@@ -1733,12 +2013,7 @@ class _RobotAutonomousWorkPageV2State extends State<RobotAutonomousWorkPageV2> {
switch (_phase) {
case WorkRunState.idle:
return [
(
'放弃准备',
null,
2,
() => Navigator.of(context).maybePop(),
),
('放弃准备', null, 2, () => Navigator.of(context).maybePop()),
if (caps.routeLibrary) ('预览 / 选择路线', null, 2, _openRouteLibrary),
('开始作业', Icons.play_arrow_rounded, 0, _startWork),
];

View File

@@ -1,12 +1,15 @@
import 'package:flutter/material.dart';
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import '../../data/models/robot_data_model.dart';
import '../widgets/robot_summary_card_v2.dart';
import 'robot_sensor_page_v2.dart';
/// 一条故障码说明(诊断支持 → 故障码说明)
///
/// name / desc / impact / advice 均为语言包 key,渲染时再取词
class _FaultCode {
final String code;
final String name;
@@ -26,6 +29,8 @@ class _FaultCode {
}
/// 一条故障事件(已恢复 / 当前)
///
/// name / time 为语言包 key
class _FaultEvent {
final String code;
final String name;
@@ -74,19 +79,19 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
static const List<_FaultCode> _faultCodes = [
_FaultCode(
code: 'C201',
name: '4G信号短时中断',
name: 'robot_fault_v2.fc_c201_name',
icon: Icons.wifi_off_rounded,
desc: '设备与云端之间的 4G 链路在短时间内断开,重连成功后自动恢复。',
impact: '影响:远程指令下发与状态上报延迟,作业本身不受影响。',
advice: '排查:确认作业区域 4G 覆盖;检查天线连接;频繁出现时更换运营商卡。',
desc: 'robot_fault_v2.fc_c201_desc',
impact: 'robot_fault_v2.fc_c201_impact',
advice: 'robot_fault_v2.fc_c201_advice',
),
_FaultCode(
code: 'U108',
name: '超声波短时通信异常',
name: 'robot_fault_v2.fc_u108_name',
icon: Icons.settings_input_antenna_rounded,
desc: '超声波探头的串口通信出现短暂丢包,链路恢复后自动解除。',
impact: '影响:避障距离刷新滞后,期间低速作业仍可继续。',
advice: '排查:检查探头线束与接插件是否松动;清洁探头表面;必要时更换探头。',
desc: 'robot_fault_v2.fc_u108_desc',
impact: 'robot_fault_v2.fc_u108_impact',
advice: 'robot_fault_v2.fc_u108_advice',
),
];
@@ -94,14 +99,14 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
static const List<_FaultEvent> _recoveredEvents = [
_FaultEvent(
code: 'C201',
name: '4G信号短时中断',
time: '10月19日 18:23',
name: 'robot_fault_v2.fc_c201_name',
time: 'robot_fault_v2.ev_time_1',
icon: Icons.wifi_off_rounded,
),
_FaultEvent(
code: 'U108',
name: '超声波短时通信异常',
time: '10月17日 15:42',
name: 'robot_fault_v2.fc_u108_name',
time: 'robot_fault_v2.ev_time_2',
icon: Icons.settings_input_antenna_rounded,
),
];
@@ -125,7 +130,10 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
return '${two(now.hour)}:${two(now.minute)}:${two(now.second)}';
}
void _todo() => CapsuleToast.show('功能暂未开发', showCheck: false);
void _todo() => CapsuleToast.show(
AppLocalizations.of(context).translate('robot_fault_v2.todo'),
showCheck: false,
);
void _openSensorPage() {
Navigator.push(
@@ -135,7 +143,8 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
}
/// 已恢复事件 / 故障码说明共用的弹窗
void _showFaultDetail(_FaultCode code, {String? recoveredAt}) {
void _showFaultDetail(_FaultCode code, {String? recoveredAtKey}) {
final l10n = AppLocalizations.of(context);
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
@@ -168,24 +177,26 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${code.code} · ${code.name}',
'${code.code} · ${l10n.translate(code.name)}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: _navy,
),
),
if (recoveredAt != null) ...[
if (recoveredAtKey != null) ...[
const SizedBox(height: 3),
Text(
'$recoveredAt · 已恢复',
l10n
.translate('robot_fault_v2.recovered_suffix')
.replaceAll('%s', l10n.translate(recoveredAtKey)),
style: const TextStyle(fontSize: 12, color: _gray),
),
],
],
),
),
if (recoveredAt != null)
if (recoveredAtKey != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 9,
@@ -195,9 +206,9 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
color: _greenBg,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'已恢复',
style: TextStyle(
child: Text(
l10n.translate('robot_fault_v2.badge_recovered'),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: _green,
@@ -207,9 +218,15 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
],
),
const SizedBox(height: 14),
_buildSheetLine('问题说明', code.desc),
_buildSheetLine(
l10n.translate('robot_fault_v2.sheet_problem'),
l10n.translate(code.desc),
),
const SizedBox(height: 10),
_buildSheetLine('影响与建议', '${code.impact}\n${code.advice}'),
_buildSheetLine(
l10n.translate('robot_fault_v2.sheet_impact'),
'${l10n.translate(code.impact)}\n${l10n.translate(code.advice)}',
),
const SizedBox(height: 14),
GestureDetector(
onTap: () => Navigator.of(sheetCtx).pop(),
@@ -221,9 +238,9 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
color: _softBlue,
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'知道了',
style: TextStyle(
child: Text(
l10n.translate('robot_fault_v2.sheet_ok'),
style: const TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w700,
color: _blue,
@@ -260,6 +277,7 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
}
void _openFaultCodeList() {
final l10n = AppLocalizations.of(context);
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
@@ -275,18 +293,18 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'故障码说明',
style: TextStyle(
Text(
l10n.translate('robot_fault_v2.sheet_codes_title'),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: _navy,
),
),
const SizedBox(height: 4),
const Text(
'点击任意故障码查看问题说明、影响与排查建议',
style: TextStyle(fontSize: 11.5, color: _hint),
Text(
l10n.translate('robot_fault_v2.sheet_codes_hint'),
style: const TextStyle(fontSize: 11.5, color: _hint),
),
const SizedBox(height: 12),
for (var i = 0; i < _faultCodes.length; i++)
@@ -321,7 +339,7 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_faultCodes[i].code} · ${_faultCodes[i].name}',
'${_faultCodes[i].code} · ${l10n.translate(_faultCodes[i].name)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
@@ -332,7 +350,7 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
),
const SizedBox(height: 3),
Text(
_faultCodes[i].desc,
l10n.translate(_faultCodes[i].desc),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
@@ -365,6 +383,7 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: const Color(0xFFEDF6FD),
body: Container(
@@ -387,19 +406,25 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
children: [
RobotSummaryCardV2(robot: widget.robot),
const SizedBox(height: 18),
_buildSectionTitle('当前故障与预警'),
_buildSectionTitle(
l10n.translate('robot_fault_v2.sec_current'),
),
const SizedBox(height: 10),
_buildCurrentFaultCard(),
const SizedBox(height: 18),
_buildSectionTitle('故障筛选'),
_buildSectionTitle(
l10n.translate('robot_fault_v2.sec_filter'),
),
const SizedBox(height: 10),
_buildFilterBar(),
const SizedBox(height: 18),
_buildSectionTitle(_listTitle),
_buildSectionTitle(l10n.translate(_listTitleKey)),
const SizedBox(height: 10),
_buildEventList(),
const SizedBox(height: 18),
_buildSectionTitle('诊断支持'),
_buildSectionTitle(
l10n.translate('robot_fault_v2.sec_support'),
),
const SizedBox(height: 10),
_buildSupportCard(),
const SizedBox(height: 14),
@@ -446,12 +471,12 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
),
),
const SizedBox(width: 12),
const Expanded(
Expanded(
child: Text(
'故障诊断中心',
AppLocalizations.of(context).translate('robot_fault_v2.title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _navy,
@@ -514,12 +539,21 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
// ===== 当前故障卡 =====
Widget _buildCurrentFaultCard() {
final l10n = AppLocalizations.of(context);
// 设计稿规则:离线或遥测过期时不得显示「无故障」,只能标注状态未知
final title = _online ? '当前无未恢复故障' : '当前故障状态未知';
final subtitle = _online
? '最新有效上报 · $_checkedAt'
: '设备离线,遥测已中断 · 检查于 $_checkedAt';
final pillText = _online ? '正常' : '未知';
final title = l10n.translate(
_online ? 'robot_fault_v2.cur_none' : 'robot_fault_v2.cur_unknown',
);
final subtitle = l10n
.translate(
_online
? 'robot_fault_v2.cur_sub_online'
: 'robot_fault_v2.cur_sub_offline',
)
.replaceAll('%s', _checkedAt);
final pillText = l10n.translate(
_online ? 'robot_fault_v2.st_normal' : 'robot_fault_v2.st_unknown',
);
final pillColor = _online ? _green : _gray;
final pillBg = _online ? _greenBg : _softGray;
@@ -585,7 +619,14 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
// ===== 故障筛选 =====
Widget _buildFilterBar() {
final labels = ['当前(0)', '已恢复(${_recoveredEvents.length})', '全部记录'];
final l10n = AppLocalizations.of(context);
final labels = [
l10n.translate('robot_fault_v2.filter_current'),
l10n
.translate('robot_fault_v2.filter_recovered')
.replaceAll('%s', '${_recoveredEvents.length}'),
l10n.translate('robot_fault_v2.filter_all'),
];
return Container(
height: 46,
padding: const EdgeInsets.all(4),
@@ -635,15 +676,15 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
);
}
String get _listTitle {
String get _listTitleKey {
switch (_filter) {
case 1:
return '已恢复事件';
return 'robot_fault_v2.list_recovered';
case 2:
return '全部记录';
return 'robot_fault_v2.list_all';
case 0:
default:
return '最近已恢复事件';
return 'robot_fault_v2.list_recent_recovered';
}
}
@@ -664,7 +705,11 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
padding: const EdgeInsets.symmetric(vertical: 30),
child: Center(
child: Text(
_showEmptyCurrent ? '设备离线,暂无可用记录' : '暂无记录',
AppLocalizations.of(context).translate(
_showEmptyCurrent
? 'robot_fault_v2.empty_offline'
: 'robot_fault_v2.empty',
),
style: const TextStyle(fontSize: 13, color: _gray),
),
),
@@ -691,12 +736,13 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
}
Widget _buildEventRow(_FaultEvent event) {
final l10n = AppLocalizations.of(context);
final code = _faultCodes.firstWhere(
(e) => e.code == event.code,
orElse: () => _faultCodes.first,
);
return GestureDetector(
onTap: () => _showFaultDetail(code, recoveredAt: event.time),
onTap: () => _showFaultDetail(code, recoveredAtKey: event.time),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 10, 12),
@@ -717,7 +763,7 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${event.code} · ${event.name}',
'${event.code} · ${l10n.translate(event.name)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
@@ -728,7 +774,9 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
),
const SizedBox(height: 4),
Text(
'${event.time} · 已恢复',
l10n
.translate('robot_fault_v2.recovered_suffix')
.replaceAll('%s', l10n.translate(event.time)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: _gray),
@@ -743,9 +791,9 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
color: _greenBg,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'已恢复',
style: TextStyle(
child: Text(
l10n.translate('robot_fault_v2.badge_recovered'),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: _green,
@@ -762,6 +810,7 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
// ===== 诊断支持 =====
Widget _buildSupportCard() {
final l10n = AppLocalizations.of(context);
return Container(
decoration: BoxDecoration(
color: Colors.white,
@@ -772,8 +821,8 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
children: [
_buildSupportRow(
icon: Icons.description_outlined,
title: '查看故障码说明',
subtitle: '问题说明、影响和排查建议',
title: l10n.translate('robot_fault_v2.support_codes'),
subtitle: l10n.translate('robot_fault_v2.support_codes_sub'),
onTap: _openFaultCodeList,
),
const Divider(
@@ -785,8 +834,8 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
),
_buildSupportRow(
icon: Icons.show_chart_rounded,
title: '传感器与通信实时状态',
subtitle: '确认当前链路和硬件反馈',
title: l10n.translate('robot_fault_v2.support_sensor'),
subtitle: l10n.translate('robot_fault_v2.support_sensor_sub'),
onTap: _openSensorPage,
),
],
@@ -858,10 +907,9 @@ class _RobotFaultCenterPageV2State extends State<RobotFaultCenterPageV2> {
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFD6E9FC)),
),
child: const Text(
'离线或遥测过期时不得显示「无故障」,应标注「当前故障状态未知」。'
'已恢复事件为示例数据,接入故障记录接口后自动替换。',
style: TextStyle(fontSize: 11.5, height: 1.5, color: _hint),
child: Text(
AppLocalizations.of(context).translate('robot_fault_v2.note'),
style: const TextStyle(fontSize: 11.5, height: 1.5, color: _hint),
),
);
}

View File

@@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import '../../data/models/robot_data_model.dart';
import '../widgets/robot_summary_card_v2.dart';
/// 一条历史升级记录
/// 一条历史升级记录(module / time / result 均为语言包 key)
class _UpgradeRecord {
final String module;
final String time;
@@ -47,24 +48,24 @@ class _RobotFirmwarePageV2State extends State<RobotFirmwarePageV2> {
static const Color _greenBg = Color(0xFFE5F8EC);
static const Color _trackBg = Color(0xFFF1F3F6);
/// 检查状态:待检查 / 检查中 / 已是最新(示例态,无真实 OTA 服务)
String _checkState = '待检查';
/// 是否正在检查更新(无真实 OTA 服务,仅示例态)
bool _checking = false;
static const List<_UpgradeRecord> _records = [
_UpgradeRecord(
module: 'MCU v1.0.3',
module: 'robot_firmware_v2.mod_mcu',
time: '2026-10-18 10:36',
result: '成功',
result: 'robot_firmware_v2.rec_success',
),
_UpgradeRecord(
module: '通信模块 v1.1.2',
module: 'robot_firmware_v2.mod_comm',
time: '2026-09-26 15:12',
result: '成功',
result: 'robot_firmware_v2.rec_success',
),
_UpgradeRecord(
module: '图传模块 v1.0.8',
module: 'robot_firmware_v2.mod_video',
time: '2026-08-30 09:05',
result: '成功',
result: 'robot_firmware_v2.rec_success',
),
];
@@ -72,23 +73,40 @@ class _RobotFirmwarePageV2State extends State<RobotFirmwarePageV2> {
/// 检查更新:先做前置条件校验,再明确告知 OTA 通道未接入
Future<void> _checkUpdate() async {
final l10n = AppLocalizations.of(context);
if (!_online) {
CapsuleToast.show('设备离线,无法检查更新', showCheck: false);
CapsuleToast.show(
l10n.translate('robot_firmware_v2.toast_offline'),
showCheck: false,
);
return;
}
if (_checkState == '检查中…') return;
setState(() => _checkState = '检查中…');
if (_checking) return;
setState(() => _checking = true);
await Future<void>.delayed(const Duration(milliseconds: 600));
if (!mounted) return;
setState(() => _checkState = '待检查');
CapsuleToast.show('OTA 升级服务未接入,暂不返回版本检查结果', showCheck: false);
setState(() => _checking = false);
CapsuleToast.show(
AppLocalizations.of(
context,
).translate('robot_firmware_v2.toast_ota_todo'),
showCheck: false,
);
}
void _openRecord(_UpgradeRecord record) =>
CapsuleToast.show('${record.module} 升级详情暂未开放', showCheck: false);
void _openRecord(_UpgradeRecord record) {
final l10n = AppLocalizations.of(context);
CapsuleToast.show(
l10n
.translate('robot_firmware_v2.record_open_tip')
.replaceAll('%s', l10n.translate(record.module)),
showCheck: false,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: const Color(0xFFEDF6FD),
body: Container(
@@ -111,19 +129,27 @@ class _RobotFirmwarePageV2State extends State<RobotFirmwarePageV2> {
children: [
RobotSummaryCardV2(robot: widget.robot),
const SizedBox(height: 18),
_buildSectionTitle('当前版本'),
_buildSectionTitle(
l10n.translate('robot_firmware_v2.sec_version'),
),
const SizedBox(height: 10),
_buildVersionCard(),
const SizedBox(height: 18),
_buildSectionTitle('升级检查'),
_buildSectionTitle(
l10n.translate('robot_firmware_v2.sec_check'),
),
const SizedBox(height: 10),
_buildCheckCard(),
const SizedBox(height: 18),
_buildSectionTitle('升级前必须确认'),
_buildSectionTitle(
l10n.translate('robot_firmware_v2.sec_prereq'),
),
const SizedBox(height: 10),
_buildPrerequisiteCard(),
const SizedBox(height: 18),
_buildSectionTitle('历史升级记录'),
_buildSectionTitle(
l10n.translate('robot_firmware_v2.sec_history'),
),
const SizedBox(height: 10),
_buildHistoryCard(),
const SizedBox(height: 14),
@@ -170,12 +196,14 @@ class _RobotFirmwarePageV2State extends State<RobotFirmwarePageV2> {
),
),
const SizedBox(width: 12),
const Expanded(
Expanded(
child: Text(
'软件与固件升级',
AppLocalizations.of(
context,
).translate('robot_firmware_v2.title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _navy,
@@ -184,8 +212,12 @@ class _RobotFirmwarePageV2State extends State<RobotFirmwarePageV2> {
),
const SizedBox(width: 8),
GestureDetector(
onTap: () =>
CapsuleToast.show('升级需设备在线、供电稳定且处于安全停机状态', showCheck: false),
onTap: () => CapsuleToast.show(
AppLocalizations.of(
context,
).translate('robot_firmware_v2.info_tip'),
showCheck: false,
),
behavior: HitTestBehavior.opaque,
child: Container(
width: 36,
@@ -239,11 +271,12 @@ class _RobotFirmwarePageV2State extends State<RobotFirmwarePageV2> {
// ===== 当前版本:四行只读 KV =====
Widget _buildVersionCard() {
const rows = <(String, String)>[
('下位机 MCU', 'v1.0.3'),
('通信模块', 'v1.1.2'),
('图传模块', 'v1.0.8'),
('设备配置版本', 'CFG-20261018'),
final l10n = AppLocalizations.of(context);
final rows = <(String, String)>[
(l10n.translate('robot_firmware_v2.row_mcu'), 'v1.0.3'),
(l10n.translate('robot_firmware_v2.row_comm'), 'v1.1.2'),
(l10n.translate('robot_firmware_v2.row_video'), 'v1.0.8'),
(l10n.translate('robot_firmware_v2.row_cfg'), 'CFG-20261018'),
];
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
@@ -288,7 +321,8 @@ class _RobotFirmwarePageV2State extends State<RobotFirmwarePageV2> {
// ===== 升级检查:标题行 + 状态药丸 + 通栏按钮 =====
Widget _buildCheckCard() {
final checking = _checkState == '检查中…';
final l10n = AppLocalizations.of(context);
final checking = _checking;
return Container(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
decoration: BoxDecoration(
@@ -315,24 +349,24 @@ class _RobotFirmwarePageV2State extends State<RobotFirmwarePageV2> {
),
),
const SizedBox(width: 12),
const Expanded(
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'系统更新检查',
style: TextStyle(
l10n.translate('robot_firmware_v2.check_title'),
style: const TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w800,
color: _navy,
),
),
SizedBox(height: 3),
const SizedBox(height: 3),
Text(
'需确认设备在线、供电稳定且安全停机',
l10n.translate('robot_firmware_v2.check_sub'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11.5, color: _gray),
style: const TextStyle(fontSize: 11.5, color: _gray),
),
],
),
@@ -348,7 +382,11 @@ class _RobotFirmwarePageV2State extends State<RobotFirmwarePageV2> {
borderRadius: BorderRadius.circular(10),
),
child: Text(
_checkState,
l10n.translate(
checking
? 'robot_firmware_v2.check_checking'
: 'robot_firmware_v2.check_idle',
),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
@@ -377,7 +415,11 @@ class _RobotFirmwarePageV2State extends State<RobotFirmwarePageV2> {
],
),
child: Text(
checking ? '检查中…' : '检查可用更新',
l10n.translate(
checking
? 'robot_firmware_v2.check_checking'
: 'robot_firmware_v2.check_btn',
),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
@@ -393,11 +435,12 @@ class _RobotFirmwarePageV2State extends State<RobotFirmwarePageV2> {
// ===== 升级前必须确认:勾选清单 =====
Widget _buildPrerequisiteCard() {
const items = <String>[
'机器人处于安全停机/待机状态',
'割刀与底盘执行机构已停止',
'电源充足且通信稳定',
'当前账号具有升级权限',
final l10n = AppLocalizations.of(context);
const itemKeys = <String>[
'robot_firmware_v2.prereq_1',
'robot_firmware_v2.prereq_2',
'robot_firmware_v2.prereq_3',
'robot_firmware_v2.prereq_4',
];
return Container(
padding: const EdgeInsets.fromLTRB(14, 6, 14, 6),
@@ -408,7 +451,7 @@ class _RobotFirmwarePageV2State extends State<RobotFirmwarePageV2> {
),
child: Column(
children: [
for (final text in items)
for (final key in itemKeys)
Padding(
padding: const EdgeInsets.symmetric(vertical: 11),
child: Row(
@@ -417,7 +460,7 @@ class _RobotFirmwarePageV2State extends State<RobotFirmwarePageV2> {
const SizedBox(width: 9),
Expanded(
child: Text(
text,
l10n.translate(key),
style: const TextStyle(fontSize: 13, color: _navy),
),
),
@@ -431,6 +474,7 @@ class _RobotFirmwarePageV2State extends State<RobotFirmwarePageV2> {
// ===== 历史升级记录 =====
Widget _buildHistoryCard() {
final l10n = AppLocalizations.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
@@ -474,7 +518,7 @@ class _RobotFirmwarePageV2State extends State<RobotFirmwarePageV2> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_records[i].module,
l10n.translate(_records[i].module),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
@@ -485,7 +529,7 @@ class _RobotFirmwarePageV2State extends State<RobotFirmwarePageV2> {
),
const SizedBox(height: 3),
Text(
'${_records[i].time} · ${_records[i].result}',
'${_records[i].time} · ${l10n.translate(_records[i].result)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
@@ -506,9 +550,9 @@ class _RobotFirmwarePageV2State extends State<RobotFirmwarePageV2> {
color: _greenBg,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'已完成',
style: TextStyle(
child: Text(
l10n.translate('robot_firmware_v2.rec_done'),
style: const TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w600,
color: _green,
@@ -545,8 +589,7 @@ class _RobotFirmwarePageV2State extends State<RobotFirmwarePageV2> {
const SizedBox(width: 7),
Expanded(
child: Text(
'版本号与历史记录为示例口径;设备端版本接口与 OTA 通道接入后自动替换为真实数据,'
'升级动作会先做二次确认再下发。',
AppLocalizations.of(context).translate('robot_firmware_v2.footer'),
style: TextStyle(
fontSize: 11.5,
height: 1.55,

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import '../../data/models/robot_data_model.dart';
import '../widgets/robot_summary_card_v2.dart';
@@ -42,11 +43,14 @@ class RobotFullInfoPageV2 extends StatelessWidget {
}
/// SN 脱敏:保留前 4 位与后 2 位
String get _maskedSn {
String _maskedSnLabel(AppLocalizations l10n) {
final sn = robot.id;
if (sn.isEmpty) return '--';
if (sn.length <= 6) return sn;
return '${sn.substring(0, 4)} · · · · ${sn.substring(sn.length - 2)}(脱敏)';
return l10n
.translate('robot_full_info_v2.sn_masked')
.replaceFirst('%s', sn.substring(0, 4))
.replaceFirst('%s', sn.substring(sn.length - 2));
}
/// 电量:0 视为未上报
@@ -56,17 +60,27 @@ class RobotFullInfoPageV2 extends StatelessWidget {
return '${b.round()}%';
}
/// 运行模式:任务字段为“无/空”时按待机展示
/// 运行模式:任务字段为“无/空”时按待机展示(返回空串表示待机)
String get _modeText {
final task = robot.task.trim();
if (task.isEmpty || task == '无' || task == 'null') return '待机';
// '无' / 'null' 为接口约定原值,不翻译
if (task.isEmpty || task == '无' || task == 'null') return '';
return task;
}
void _todo() => CapsuleToast.show('功能暂未开发', showCheck: false);
String _modeLabel(AppLocalizations l10n) {
final mode = _modeText;
return mode.isEmpty ? l10n.translate('robot_full_info_v2.mode_idle') : mode;
}
void _todo(BuildContext context) => CapsuleToast.show(
AppLocalizations.of(context).translate('robot_full_info_v2.todo'),
showCheck: false,
);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: const Color(0xFFEDF6FD),
body: Container(
@@ -89,18 +103,23 @@ class RobotFullInfoPageV2 extends StatelessWidget {
children: [
RobotSummaryCardV2(robot: robot),
const SizedBox(height: 18),
_buildSectionTitle('机器人基础资料'),
_buildSectionTitle(
l10n.translate('robot_full_info_v2.sec_profile'),
),
const SizedBox(height: 10),
_buildProfileCard(),
_buildProfileCard(l10n),
const SizedBox(height: 18),
_buildSectionTitle('运行与控制信息'),
_buildSectionTitle(
l10n.translate('robot_full_info_v2.sec_runtime'),
),
const SizedBox(height: 10),
_buildRuntimeCard(),
_buildRuntimeCard(l10n),
const SizedBox(height: 14),
const Center(
Center(
child: Text(
'动力类型、最近上报、控制会话、故障数为示例;接入后自动替换',
style: TextStyle(fontSize: 10.5, color: _hint),
l10n.translate('robot_full_info_v2.footer'),
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 10.5, color: _hint),
),
),
],
@@ -145,17 +164,22 @@ class RobotFullInfoPageV2 extends StatelessWidget {
),
),
const SizedBox(width: 12),
const Text(
'机器人完整信息',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _navy,
// 英文标题较长:允许收缩省略,避免顶栏 Row 横向溢出
Flexible(
child: Text(
AppLocalizations.of(context).translate('robot_full_info_v2.title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _navy,
),
),
),
const Spacer(),
GestureDetector(
onTap: _todo,
onTap: () => _todo(context),
behavior: HitTestBehavior.opaque,
child: Container(
width: 36,
@@ -214,12 +238,16 @@ class RobotFullInfoPageV2 extends StatelessWidget {
),
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: _navy,
Flexible(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: _navy,
),
),
),
],
@@ -227,7 +255,7 @@ class RobotFullInfoPageV2 extends StatelessWidget {
}
// ===== 基础资料卡 =====
Widget _buildProfileCard() {
Widget _buildProfileCard(AppLocalizations l10n) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
@@ -255,7 +283,9 @@ class RobotFullInfoPageV2 extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$_model · STD标准版',
l10n
.translate('robot_full_info_v2.model_suffix')
.replaceFirst('%s', _model),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
@@ -266,15 +296,21 @@ class RobotFullInfoPageV2 extends StatelessWidget {
),
),
const SizedBox(height: 5),
const Text(
'独立图传 · 超声波声光 · 基础GNSS',
Text(
l10n.translate('robot_full_info_v2.tagline'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 10.5, color: _gray),
style: const TextStyle(fontSize: 10.5, color: _gray),
),
const SizedBox(height: 7),
_buildPill(
_online ? '在线 · $_modeText' : '离线 · $_modeText',
l10n
.translate(
_online
? 'robot_full_info_v2.pill_online'
: 'robot_full_info_v2.pill_offline',
)
.replaceFirst('%s', _modeLabel(l10n)),
_online ? _green : const Color(0xFF6B7280),
_online ? _greenBg : const Color(0xFFF0F2F5),
),
@@ -285,19 +321,42 @@ class RobotFullInfoPageV2 extends StatelessWidget {
),
),
const Divider(height: 1, color: _divider),
_buildInfoRow('设备名称', _displayName),
_buildInfoRow('设备SN', _maskedSn),
_buildInfoRow('绑定状态', robot.hasHost ? '已授权' : '未绑定'),
_buildInfoRow('所属类型', '履带式割草机器人'),
_buildInfoRow('动力类型', '示例:纯电'),
_buildInfoRow('最近上报', '示例:2026-10-20 09:41', isLast: true),
_buildInfoRow(
l10n.translate('robot_full_info_v2.row_name'),
_displayName,
),
_buildInfoRow(
l10n.translate('robot_full_info_v2.row_sn'),
_maskedSnLabel(l10n),
),
_buildInfoRow(
l10n.translate('robot_full_info_v2.row_bind'),
l10n.translate(
robot.hasHost
? 'robot_full_info_v2.val_bound'
: 'robot_full_info_v2.val_unbound',
),
),
_buildInfoRow(
l10n.translate('robot_full_info_v2.row_type'),
l10n.translate('robot_full_info_v2.val_type'),
),
_buildInfoRow(
l10n.translate('robot_full_info_v2.row_power'),
l10n.translate('robot_full_info_v2.val_power'),
),
_buildInfoRow(
l10n.translate('robot_full_info_v2.row_report'),
l10n.translate('robot_full_info_v2.val_report'),
isLast: true,
),
],
),
);
}
// ===== 运行与控制卡 =====
Widget _buildRuntimeCard() {
Widget _buildRuntimeCard(AppLocalizations l10n) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
@@ -314,23 +373,23 @@ class RobotFullInfoPageV2 extends StatelessWidget {
child: _buildStatBlock(
Icons.battery_std_rounded,
_batteryText,
'能源状态',
l10n.translate('robot_full_info_v2.stat_energy'),
),
),
const SizedBox(width: 8),
Expanded(
child: _buildStatBlock(
Icons.monitor_heart_outlined,
_modeText,
'运行模式',
_modeLabel(l10n),
l10n.translate('robot_full_info_v2.stat_mode'),
),
),
const SizedBox(width: 8),
Expanded(
child: _buildStatBlock(
Icons.sports_esports_outlined,
'无',
'控制会话',
l10n.translate('robot_full_info_v2.val_none'),
l10n.translate('robot_full_info_v2.stat_session'),
),
),
],
@@ -338,11 +397,19 @@ class RobotFullInfoPageV2 extends StatelessWidget {
),
const Divider(height: 1, color: _divider),
_buildInfoRow(
'GNSS定位',
_online ? '定位有效' : '无定位',
l10n.translate('robot_full_info_v2.row_gnss'),
l10n.translate(
_online
? 'robot_full_info_v2.val_gnss_ok'
: 'robot_full_info_v2.val_gnss_none',
),
valueColor: _online ? _green : _gray,
),
_buildInfoRow('当前故障', '0项', isLast: true),
_buildInfoRow(
l10n.translate('robot_full_info_v2.row_fault'),
l10n.translate('robot_full_info_v2.val_fault_count'),
isLast: true,
),
],
),
);

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import '../../data/models/robot_data_model.dart';
import '../widgets/robot_summary_card_v2.dart';
@@ -52,12 +53,18 @@ class _RobotLightSoundPageV2State extends State<RobotLightSoundPageV2> {
bool get _online => widget.robot.isOnline;
/// 统一的未接入提示:状态仍按操作切换,方便验收界面态
void _notifyChannel(String action) {
/// [actionKey] 为操作名语言包 key
void _notifyChannel(String actionKey) {
final l10n = AppLocalizations.of(context);
final action = l10n.translate(actionKey);
if (!_online) {
CapsuleToast.show('设备离线,$action指令未下发', showCheck: false);
CapsuleToast.show(
l10n.translate('robot_light_v2.offline_tip').replaceAll('%s', action),
showCheck: false,
);
} else {
CapsuleToast.show(
'$action指令通道未接入(设备端未开放灯光位),当前状态仅作界面预览',
l10n.translate('robot_light_v2.channel_tip').replaceAll('%s', action),
showCheck: false,
);
}
@@ -65,6 +72,7 @@ class _RobotLightSoundPageV2State extends State<RobotLightSoundPageV2> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: const Color(0xFFEDF6FD),
body: Container(
@@ -88,7 +96,7 @@ class _RobotLightSoundPageV2State extends State<RobotLightSoundPageV2> {
RobotSummaryCardV2(robot: widget.robot),
const SizedBox(height: 16),
_buildSectionTitle(
'灯光控制',
l10n.translate('robot_light_v2.sec_light'),
trailing: _buildStatePill(
on: _headlight || _warningLight || _markerLight,
),
@@ -96,11 +104,15 @@ class _RobotLightSoundPageV2State extends State<RobotLightSoundPageV2> {
const SizedBox(height: 10),
_buildLightCard(),
const SizedBox(height: 16),
_buildSectionTitle('声光提示'),
_buildSectionTitle(
l10n.translate('robot_light_v2.sec_sound'),
),
const SizedBox(height: 10),
_buildSoundCard(),
const SizedBox(height: 16),
_buildSectionTitle('自动策略'),
_buildSectionTitle(
l10n.translate('robot_light_v2.sec_auto'),
),
const SizedBox(height: 10),
_buildAutoCard(),
const SizedBox(height: 16),
@@ -146,13 +158,13 @@ class _RobotLightSoundPageV2State extends State<RobotLightSoundPageV2> {
),
),
),
const Expanded(
Expanded(
child: Text(
'灯光与声光',
AppLocalizations.of(context).translate('robot_light_v2.title'),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: _navy,
@@ -160,8 +172,10 @@ class _RobotLightSoundPageV2State extends State<RobotLightSoundPageV2> {
),
),
GestureDetector(
onTap: () =>
CapsuleToast.show('灯光与鸣笛需设备端开放控制通道,当前仅作界面预览', showCheck: false),
onTap: () => CapsuleToast.show(
AppLocalizations.of(context).translate('robot_light_v2.info_tip'),
showCheck: false,
),
behavior: HitTestBehavior.opaque,
child: Container(
width: 36,
@@ -198,15 +212,19 @@ class _RobotLightSoundPageV2State extends State<RobotLightSoundPageV2> {
),
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: _navy,
Flexible(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: _navy,
),
),
),
const Spacer(),
const SizedBox(width: 8),
?trailing,
],
);
@@ -220,7 +238,9 @@ class _RobotLightSoundPageV2State extends State<RobotLightSoundPageV2> {
borderRadius: BorderRadius.circular(10),
),
child: Text(
on ? '灯光已开' : '灯光全关',
AppLocalizations.of(context).translate(
on ? 'robot_light_v2.pill_on' : 'robot_light_v2.pill_off',
),
style: TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w700,
@@ -248,38 +268,51 @@ class _RobotLightSoundPageV2State extends State<RobotLightSoundPageV2> {
// ===== 灯光控制 =====
Widget _buildLightCard() {
final l10n = AppLocalizations.of(context);
return _buildCard(
children: [
_buildSwitchRow(
icon: Icons.lightbulb_outline,
title: '前照灯',
subtitle: '作业与夜间照明,照亮行进方向',
title: l10n.translate('robot_light_v2.headlight'),
subtitle: l10n.translate('robot_light_v2.headlight_sub'),
value: _headlight,
onChanged: (v) {
setState(() => _headlight = v);
_notifyChannel(v ? '开启前照灯' : '关闭前照灯');
_notifyChannel(
v
? 'robot_light_v2.act_on_headlight'
: 'robot_light_v2.act_off_headlight',
);
},
),
const Divider(height: 1, thickness: 0.6, color: _divider),
_buildSwitchRow(
icon: Icons.warning_amber_rounded,
title: '警示灯',
subtitle: '黄色频闪,作业时提醒周边人员',
title: l10n.translate('robot_light_v2.warning_light'),
subtitle: l10n.translate('robot_light_v2.warning_light_sub'),
value: _warningLight,
onChanged: (v) {
setState(() => _warningLight = v);
_notifyChannel(v ? '开启警示灯' : '关闭警示灯');
_notifyChannel(
v
? 'robot_light_v2.act_on_warning'
: 'robot_light_v2.act_off_warning',
);
},
),
const Divider(height: 1, thickness: 0.6, color: _divider),
_buildSwitchRow(
icon: Icons.light_mode_outlined,
title: '示廓灯',
subtitle: '车体轮廓提示,夜间让他人识别车身',
title: l10n.translate('robot_light_v2.marker_light'),
subtitle: l10n.translate('robot_light_v2.marker_light_sub'),
value: _markerLight,
onChanged: (v) {
setState(() => _markerLight = v);
_notifyChannel(v ? '开启示廓灯' : '关闭示廓灯');
_notifyChannel(
v
? 'robot_light_v2.act_on_marker'
: 'robot_light_v2.act_off_marker',
);
},
isLast: true,
),
@@ -289,39 +322,60 @@ class _RobotLightSoundPageV2State extends State<RobotLightSoundPageV2> {
// ===== 声光提示 =====
Widget _buildSoundCard() {
final l10n = AppLocalizations.of(context);
final volumeLabels = [
l10n.translate('robot_light_v2.vol_low'),
l10n.translate('robot_light_v2.vol_mid'),
l10n.translate('robot_light_v2.vol_high'),
];
final hornLabels = [
l10n.translate('robot_light_v2.dur_3'),
l10n.translate('robot_light_v2.dur_5'),
l10n.translate('robot_light_v2.dur_10'),
];
return _buildCard(
children: [
const SizedBox(height: 12),
_buildRowTitle(icon: Icons.volume_up_outlined, title: '报警音量'),
_buildRowTitle(
icon: Icons.volume_up_outlined,
title: l10n.translate('robot_light_v2.volume'),
),
const SizedBox(height: 10),
_buildSegBar(
labels: const ['低', '中', '高'],
labels: volumeLabels,
index: _volumeIdx,
onChanged: (i) {
setState(() => _volumeIdx = i);
CapsuleToast.show(
'音量档位已设为「${['低', '中', '高'][i]}」(本地偏好,未下发设备)',
l10n
.translate('robot_light_v2.volume_set')
.replaceAll('%s', volumeLabels[i]),
showCheck: false,
);
},
),
const SizedBox(height: 16),
_buildRowTitle(icon: Icons.schedule_outlined, title: '鸣笛时长'),
_buildRowTitle(
icon: Icons.schedule_outlined,
title: l10n.translate('robot_light_v2.horn_dur'),
),
const SizedBox(height: 10),
_buildSegBar(
labels: const ['3 秒', '5 秒', '10 秒'],
labels: hornLabels,
index: _hornIdx,
onChanged: (i) {
setState(() => _hornIdx = i);
CapsuleToast.show(
'鸣笛时长已设为「${['3 秒', '5 秒', '10 秒'][i]}」(本地偏好,未下发设备)',
l10n
.translate('robot_light_v2.horn_set')
.replaceAll('%s', hornLabels[i]),
showCheck: false,
);
},
),
const SizedBox(height: 16),
GestureDetector(
onTap: () => _notifyChannel('鸣笛'),
onTap: () => _notifyChannel('robot_light_v2.act_horn'),
behavior: HitTestBehavior.opaque,
child: Container(
height: 44,
@@ -337,7 +391,9 @@ class _RobotLightSoundPageV2State extends State<RobotLightSoundPageV2> {
const Icon(Icons.campaign_outlined, size: 18, color: _blue),
const SizedBox(width: 6),
Text(
'试鸣笛 ${['3 秒', '5 秒', '10 秒'][_hornIdx]}',
l10n
.translate('robot_light_v2.test_horn')
.replaceAll('%s', hornLabels[_hornIdx]),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
@@ -355,17 +411,22 @@ class _RobotLightSoundPageV2State extends State<RobotLightSoundPageV2> {
// ===== 自动策略 =====
Widget _buildAutoCard() {
final l10n = AppLocalizations.of(context);
return _buildCard(
children: [
_buildSwitchRow(
icon: Icons.nightlight_outlined,
title: '夜间自动开灯',
subtitle: '光线不足时自动开启前照灯与示廓灯',
title: l10n.translate('robot_light_v2.auto_night'),
subtitle: l10n.translate('robot_light_v2.auto_night_sub'),
value: _nightAuto,
onChanged: (v) {
setState(() => _nightAuto = v);
CapsuleToast.show(
'夜间自动开灯${v ? '已启用' : '已关闭'}(本地策略,未下发设备)',
l10n.translate(
v
? 'robot_light_v2.auto_night_on'
: 'robot_light_v2.auto_night_off',
),
showCheck: false,
);
},
@@ -373,13 +434,17 @@ class _RobotLightSoundPageV2State extends State<RobotLightSoundPageV2> {
const Divider(height: 1, thickness: 0.6, color: _divider),
_buildSwitchRow(
icon: Icons.agriculture_outlined,
title: '作业时开启警示灯',
subtitle: '任务开始自动点亮警示灯,结束后关闭',
title: l10n.translate('robot_light_v2.auto_work'),
subtitle: l10n.translate('robot_light_v2.auto_work_sub'),
value: _workWarning,
onChanged: (v) {
setState(() => _workWarning = v);
CapsuleToast.show(
'作业时开启警示灯${v ? '已启用' : '已关闭'}(本地策略,未下发设备)',
l10n.translate(
v
? 'robot_light_v2.auto_work_on'
: 'robot_light_v2.auto_work_off',
),
showCheck: false,
);
},
@@ -387,13 +452,17 @@ class _RobotLightSoundPageV2State extends State<RobotLightSoundPageV2> {
const Divider(height: 1, thickness: 0.6, color: _divider),
_buildSwitchRow(
icon: Icons.battery_alert_outlined,
title: '低电量声光提醒',
subtitle: '电量低于 20% 时鸣笛并闪烁警示灯',
title: l10n.translate('robot_light_v2.auto_battery'),
subtitle: l10n.translate('robot_light_v2.auto_battery_sub'),
value: _lowBatteryAlert,
onChanged: (v) {
setState(() => _lowBatteryAlert = v);
CapsuleToast.show(
'低电量声光提醒${v ? '已启用' : '已关闭'}(本地策略,未下发设备)',
l10n.translate(
v
? 'robot_light_v2.auto_battery_on'
: 'robot_light_v2.auto_battery_off',
),
showCheck: false,
);
},
@@ -547,8 +616,7 @@ class _RobotLightSoundPageV2State extends State<RobotLightSoundPageV2> {
const SizedBox(width: 8),
Expanded(
child: Text(
'遥控协议暂未开放灯光与鸣笛指令位:本页开关只反映界面状态,'
'音量、鸣笛时长与自动策略为本地偏好。真机控制需设备端开放对应通道。',
AppLocalizations.of(context).translate('robot_light_v2.footer'),
style: TextStyle(
fontSize: 11.5,
height: 1.55,

View File

@@ -649,6 +649,7 @@ class _RobotListViewState extends State<RobotListView> {
id: robot.id,
alias: robot.alias, // 传递别名
type: robot.type,
modelText: robot.modelLabelOf(AppLocalizations.of(context)),
status: robot.status,
battery: robot.battery,
task: robot.task,
@@ -665,33 +666,35 @@ class _RobotListViewState extends State<RobotListView> {
'🔴🔴🔴 [选中机器人] hasHost: ${robot.hasHost}',
);
// 🔥 hasHost 字段判定:false → 蓝牙连接流程,true 走原逻辑
if (!robot.hasHost) {
// hasHost == false 或接口未返回该字段 → 蓝牙连接流程
debugPrint('⚠️ [RobotListPage] hasHost=false,进入蓝牙连接流程');
debugPrint('📱 [RobotListPage] 机器人名称: ${robot.name}');
// 从设备名中提取时间戳(例如:MC700PRO-CN-JS-1751525588120-0000000A-9527)
final timestamp = _extractTimestamp(robot.name);
if (timestamp == null || timestamp.isEmpty) {
debugPrint('❌ [RobotListPage] 无法从设备名中提取时间戳: ${robot.name}');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('设备名称异常,暂时无法连接'),
backgroundColor: context.appColors.warning,
),
);
return;
}
debugPrint('✅ [RobotListPage] 提取到时间戳: $timestamp');
// 调用蓝牙连接流程
_connectToDeviceViaBluetooth(context, robot.name, timestamp);
return;
}
// ===== 【暂时停用】hasHost 分层判定 =====
// 需求:暂时不管有无宿主,点卡片一律直接进设备详情页。
// 恢复方式:取消下面整段注释即可(原逻辑:hasHost=false → 蓝牙连接流程)
// if (!robot.hasHost) {
// // hasHost == false 或接口未返回该字段 → 蓝牙连接流程
// debugPrint('⚠️ [RobotListPage] hasHost=false,进入蓝牙连接流程');
// debugPrint('📱 [RobotListPage] 机器人名称: ${robot.name}');
//
// // 从设备名中提取时间戳(例如:MC700PRO-CN-JS-1751525588120-0000000A-9527)
// final timestamp = _extractTimestamp(robot.name);
// if (timestamp == null || timestamp.isEmpty) {
// debugPrint('❌ [RobotListPage] 无法从设备名中提取时间戳: ${robot.name}');
// ScaffoldMessenger.of(context).showSnackBar(
// SnackBar(
// content: Text('设备名称异常,暂时无法连接'),
// backgroundColor: context.appColors.warning,
// ),
// );
// return;
// }
//
// debugPrint('✅ [RobotListPage] 提取到时间戳: $timestamp');
//
// // 调用蓝牙连接流程
// _connectToDeviceViaBluetooth(context, robot.name, timestamp);
// return;
// }
// hasHost == true → 走原来的逻辑:前置(setTargetDevice)不变,
// 原 hasHost == true 分支(现在所有设备都走这里):前置(setTargetDevice)不变,
// 跳转目标换成新设备详情页(V2 概览设计稿);
// 页内铃铛/远程遥控再回旧 RobotControlPage,方便新旧对照。
debugPrint('✅ [RobotListPage] hasHost=true,走正常控制流程');

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import 'package:maibu_satabot_v2/features/devices/domain/usecases/get_device_location_usecase.dart';
import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart';
@@ -92,10 +93,14 @@ class _RobotLocationPageV2State extends State<RobotLocationPageV2> {
return '${v.abs().toStringAsFixed(5)}° ${v >= 0 ? 'N' : 'S'}';
}
void _todo() => CapsuleToast.show('功能暂未开发', showCheck: false);
void _todo() => CapsuleToast.show(
AppLocalizations.of(context).translate('robot_location_v2.todo'),
showCheck: false,
);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: const Color(0xFFEDF6FD),
body: Container(
@@ -119,26 +124,36 @@ class _RobotLocationPageV2State extends State<RobotLocationPageV2> {
RobotSummaryCardV2(robot: widget.robot),
const SizedBox(height: 18),
_buildSectionHeader(
'当前位置与地图',
trailing: '定位信息',
l10n.translate('robot_location_v2.sec_current'),
trailing: l10n.translate('robot_location_v2.trailing_loc'),
onTrailingTap: _todo,
),
const SizedBox(height: 10),
_buildMapCard(),
const SizedBox(height: 18),
_buildSectionTitle('位置数据'),
_buildSectionTitle(
l10n.translate('robot_location_v2.sec_data'),
),
const SizedBox(height: 10),
_buildLocationDataCard(),
const SizedBox(height: 14),
Center(
child: Text(
_loading
? '正在获取设备定位…'
? l10n.translate('robot_location_v2.note_loading')
: (_hasLocation
? '坐标来自设备实际 GNSS 上报'
? l10n.translate(
'robot_location_v2.note_from_device',
)
: (_error != null
? '定位获取失败:$_error'
: '设备暂未上报有效定位')),
? l10n
.translate(
'robot_location_v2.note_error',
)
.replaceFirst('%s', _error!)
: l10n.translate(
'robot_location_v2.note_no_fix',
))),
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 10.5, color: _hint),
),
@@ -185,12 +200,17 @@ class _RobotLocationPageV2State extends State<RobotLocationPageV2> {
),
),
const SizedBox(width: 12),
const Text(
'位置与地图详情',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _navy,
// 英文标题较长:允许收缩省略,避免顶栏 Row 横向溢出
Flexible(
child: Text(
AppLocalizations.of(context).translate('robot_location_v2.title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _navy,
),
),
),
const Spacer(),
@@ -254,12 +274,16 @@ class _RobotLocationPageV2State extends State<RobotLocationPageV2> {
),
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: _navy,
Flexible(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: _navy,
),
),
),
],
@@ -300,6 +324,7 @@ class _RobotLocationPageV2State extends State<RobotLocationPageV2> {
// ===== 地图卡 =====
Widget _buildMapCard() {
final l10n = AppLocalizations.of(context);
return Container(
decoration: BoxDecoration(
color: Colors.white,
@@ -350,27 +375,31 @@ class _RobotLocationPageV2State extends State<RobotLocationPageV2> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'设备位置',
style: TextStyle(
Text(
l10n.translate('robot_location_v2.map_title'),
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w800,
color: _navy,
),
),
const SizedBox(height: 3),
const Text(
'地图为示意,定位点来自设备实际GNSS数据',
Text(
l10n.translate('robot_location_v2.map_sub'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 10.5, color: _gray),
style: const TextStyle(fontSize: 10.5, color: _gray),
),
],
),
),
const SizedBox(width: 8),
_buildPill(
_hasLocation ? 'GNSS有效' : '无定位',
l10n.translate(
_hasLocation
? 'robot_location_v2.pill_fix'
: 'robot_location_v2.pill_no_fix',
),
_hasLocation ? _green : const Color(0xFF6B7280),
_hasLocation ? _greenBg : const Color(0xFFF0F2F5),
),
@@ -412,6 +441,7 @@ class _RobotLocationPageV2State extends State<RobotLocationPageV2> {
// ===== 位置数据卡 =====
Widget _buildLocationDataCard() {
final l10n = AppLocalizations.of(context);
return Container(
decoration: BoxDecoration(
color: Colors.white,
@@ -420,15 +450,26 @@ class _RobotLocationPageV2State extends State<RobotLocationPageV2> {
),
child: Column(
children: [
_buildInfoRow('经度', _lngText),
_buildInfoRow('纬度', _latText),
_buildInfoRow('定位方式', '内置 GNSS'),
_buildInfoRow(l10n.translate('robot_location_v2.row_lng'), _lngText),
_buildInfoRow(l10n.translate('robot_location_v2.row_lat'), _latText),
_buildInfoRow(
'定位状态',
_hasLocation ? '有效 · 米级' : '未上报',
l10n.translate('robot_location_v2.row_mode'),
l10n.translate('robot_location_v2.val_mode'),
),
_buildInfoRow(
l10n.translate('robot_location_v2.row_fix'),
l10n.translate(
_hasLocation
? 'robot_location_v2.val_fix_ok'
: 'robot_location_v2.val_fix_none',
),
valueColor: _hasLocation ? _green : _gray,
),
_buildInfoRow('卫星信息', '仅显示设备真实上报', isLast: true),
_buildInfoRow(
l10n.translate('robot_location_v2.row_sat'),
l10n.translate('robot_location_v2.val_sat'),
isLast: true,
),
],
),
);

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import 'package:maibu_satabot_v2/features/devices/domain/usecases/get_device_location_usecase.dart';
import '../../data/models/robot_data_model.dart';
@@ -105,7 +106,10 @@ class _RobotNetworkLocationPageV2State
return '${v.abs().toStringAsFixed(5)}° ${v >= 0 ? 'E' : 'W'}';
}
void _todo() => CapsuleToast.show('配置通道未接入', showCheck: false);
void _todo() => CapsuleToast.show(
AppLocalizations.of(context).translate('robot_network_v2.todo'),
showCheck: false,
);
void _openMap() {
Navigator.push(
@@ -188,12 +192,12 @@ class _RobotNetworkLocationPageV2State
),
),
const SizedBox(width: 12),
const Expanded(
Expanded(
child: Text(
'网络与定位设置',
AppLocalizations.of(context).translate('robot_network_v2.title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _navy,
@@ -203,7 +207,7 @@ class _RobotNetworkLocationPageV2State
const SizedBox(width: 8),
GestureDetector(
onTap: () => CapsuleToast.show(
'网络配置以设备端实际能力为准,APP 侧仅展示与只读',
AppLocalizations.of(context).translate('robot_network_v2.info_tip'),
showCheck: false,
),
behavior: HitTestBehavior.opaque,
@@ -235,7 +239,11 @@ class _RobotNetworkLocationPageV2State
// ===== 双档切换:网络通信 / GNSS定位 =====
Widget _buildTabBar() {
const labels = ['网络通信', 'GNSS定位'];
final l10n = AppLocalizations.of(context);
final labels = [
l10n.translate('robot_network_v2.tab_network'),
l10n.translate('robot_network_v2.tab_gnss'),
];
return Container(
height: 46,
padding: const EdgeInsets.all(4),
@@ -285,58 +293,141 @@ class _RobotNetworkLocationPageV2State
// ===== 网络通信 =====
List<Widget> _buildNetworkTab() {
final l10n = AppLocalizations.of(context);
return [
_buildSectionTitle('当前网络'),
_buildSectionTitle(l10n.translate('robot_network_v2.sec_net_current')),
const SizedBox(height: 10),
_buildInfoCard([
('连接模式', _online ? '4G蜂窝网络' : '未连接'),
('网络状态', _online ? '在线 · 信号良好' : '离线'),
('云平台连接', _online ? '正常' : '断开'),
('最近设备心跳', _online ? '刚刚' : '无心跳'),
('独立图传视频', '未打开监看会话'),
(
l10n.translate('robot_network_v2.row_conn_mode'),
l10n.translate(
_online
? 'robot_network_v2.val_cellular'
: 'robot_network_v2.val_disconnected',
),
),
(
l10n.translate('robot_network_v2.row_net_state'),
l10n.translate(
_online
? 'robot_network_v2.val_online_good'
: 'robot_network_v2.val_offline',
),
),
(
l10n.translate('robot_network_v2.row_cloud'),
l10n.translate(
_online
? 'robot_network_v2.val_normal'
: 'robot_network_v2.val_broken',
),
),
(
l10n.translate('robot_network_v2.row_heartbeat'),
l10n.translate(
_online
? 'robot_network_v2.val_just_now'
: 'robot_network_v2.val_no_heartbeat',
),
),
(
l10n.translate('robot_network_v2.row_video_session'),
l10n.translate('robot_network_v2.val_no_session'),
),
]),
const SizedBox(height: 18),
_buildSectionTitle('网络配置'),
_buildSectionTitle(l10n.translate('robot_network_v2.sec_net_cfg')),
const SizedBox(height: 10),
_buildEntryCard([
(Icons.cell_tower_rounded, '4G模块信息', '运营商、信号强度与模块状态', _todo),
(Icons.wifi_rounded, 'Wi-Fi配置', '按设备实际Wi-Fi硬件开放', _todo),
(Icons.cloud_outlined, '云平台接入', '服务器与心跳只读/受限配置', _todo),
(
Icons.cell_tower_rounded,
l10n.translate('robot_network_v2.entry_4g'),
l10n.translate('robot_network_v2.entry_4g_sub'),
_todo,
),
(
Icons.wifi_rounded,
l10n.translate('robot_network_v2.entry_wifi'),
l10n.translate('robot_network_v2.entry_wifi_sub'),
_todo,
),
(
Icons.cloud_outlined,
l10n.translate('robot_network_v2.entry_cloud'),
l10n.translate('robot_network_v2.entry_cloud_sub'),
_todo,
),
]),
const SizedBox(height: 14),
_buildNote('连接模式与云平台状态取自设备在线态;运营商、信号强度需设备端上报后才会显示真实值。'),
_buildNote(l10n.translate('robot_network_v2.note_net')),
];
}
// ===== GNSS 定位 =====
List<Widget> _buildGnssTab() {
final l10n = AppLocalizations.of(context);
return [
_buildSectionTitle('定位状态'),
_buildSectionTitle(l10n.translate('robot_network_v2.sec_gnss_state')),
const SizedBox(height: 10),
_buildInfoCard([
('定位模块', '内置 GNSS'),
('当前定位', _loading ? '获取中…' : (_hasLocation ? '有效 · 米级' : '无定位')),
('卫星信息', '仅显示设备真实上报'),
('最近定位时间', _hasLocation ? _loadedAt : '--'),
(
l10n.translate('robot_network_v2.row_fix_module'),
l10n.translate('robot_network_v2.val_builtin_gnss'),
),
(
l10n.translate('robot_network_v2.row_cur_fix'),
l10n.translate(
_loading
? 'robot_network_v2.fix_loading'
: (_hasLocation
? 'robot_network_v2.fix_valid'
: 'robot_network_v2.fix_none'),
),
),
(
l10n.translate('robot_network_v2.row_sat'),
l10n.translate('robot_network_v2.val_sat_only'),
),
(
l10n.translate('robot_network_v2.row_fix_time'),
_hasLocation ? _loadedAt : '--',
),
]),
const SizedBox(height: 18),
_buildSectionTitle('坐标'),
_buildSectionTitle(l10n.translate('robot_network_v2.sec_coord')),
const SizedBox(height: 10),
_buildInfoCard([('纬度', _latText), ('经度', _lngText)]),
_buildInfoCard([
(l10n.translate('robot_network_v2.row_lat'), _latText),
(l10n.translate('robot_network_v2.row_lng'), _lngText),
]),
const SizedBox(height: 18),
_buildSectionTitle('定位配置与地图'),
_buildSectionTitle(l10n.translate('robot_network_v2.sec_gnss_cfg')),
const SizedBox(height: 10),
_buildEntryCard([
(Icons.map_outlined, '在地图中查看定位', '定位点、轨迹与最后更新时间', _openMap),
(Icons.explore_outlined, '定位参数', '坐标系、上报频率(设备端配置)', _todo),
(
Icons.map_outlined,
l10n.translate('robot_network_v2.entry_map'),
l10n.translate('robot_network_v2.entry_map_sub'),
_openMap,
),
(
Icons.explore_outlined,
l10n.translate('robot_network_v2.entry_gnss_param'),
l10n.translate('robot_network_v2.entry_gnss_param_sub'),
_todo,
),
]),
const SizedBox(height: 14),
_buildNote(
_loading
? '正在获取设备定位…'
? l10n.translate('robot_network_v2.note_loading')
: (_hasLocation
? '坐标来自设备实际 GNSS 上报,已同步到「位置与地图详情」'
: (_error != null ? '定位获取失败:$_error' : '设备暂未上报有效定位')),
? l10n.translate('robot_network_v2.note_from_device')
: (_error != null
? l10n
.translate('robot_network_v2.note_error')
.replaceAll('%s', _error!)
: l10n.translate('robot_network_v2.note_no_fix'))),
),
];
}

View File

@@ -1,25 +1,45 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
import 'package:maibu_satabot_v2/core/di/injection.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import '../../data/models/robot_data_model.dart';
import '../../domain/entities/bind_device_entities.dart';
import '../../domain/usecases/bind_device_usecases.dart';
import '../../domain/usecases/update_device_alias_usecase.dart';
import '../widgets/device_confirm_dialog.dart';
import '../widgets/rename_device_dialog.dart';
import '../widgets/robot_summary_card_v2.dart';
import 'device_ownership_action_page_v2.dart';
/// 设备归属与安全(设计稿:设备卡片 → 更多 → 设备归属与安全)
///
/// 二级 push 页面,无底部导航。
///
/// 数据口径:
/// - 设备名称、型号、在线态、SN 均来自 [RobotDataModel],SN 支持明文/脱敏切换;
/// - 所有者、归属类型、首次认领、管理员列表后端暂无对应接口,按设计稿展示为示例值,
/// 页脚与行内统一注明,不伪装成真实归属关系;
/// - 高风险操作(移交管理员、解绑与所有权转让)一律先过 [showDeviceConfirmDialog],
/// 未接入下发通道时只给明确提示,不执行不可逆动作。
/// 页面按「先看信息、再看自己能干什么、最后动手」的顺序分三段:
/// 1. 设备基础信息:设备卡片 + 归属快照(后端未接入的字段统一标「未同步」);
/// 2. 我的操作权限:把 roleKey 翻译成可操作层级,避免用户在操作页才被拦住;
/// 3. 归属操作:转交设备(绑定接口)与解绑设备(解绑接口),
/// 越权项置灰并说明原因,而不是静默消失。
class RobotOwnershipPageV2 extends StatefulWidget {
final RobotDataModel robot;
const RobotOwnershipPageV2({super.key, required this.robot});
/// 设备列表当前选中场站:getSiteList 按场站查设备,设备一定属于它,
/// 所以设备自身未下发 siteName 时用它兜底,不显示「未知」。
final int? siteId;
final String? siteName;
final int? orgId;
const RobotOwnershipPageV2({
super.key,
required this.robot,
this.siteId,
this.siteName,
this.orgId,
});
@override
State<RobotOwnershipPageV2> createState() => _RobotOwnershipPageV2State();
@@ -33,53 +53,222 @@ class _RobotOwnershipPageV2State extends State<RobotOwnershipPageV2> {
static const Color _danger = Color(0xFFF0483E);
static const Color _cardBorder = Color(0xFFE5EDF7);
static const Color _softBlue = Color(0xFFEAF3FE);
static const Color _softGray = Color(0xFFF2F4F8);
static const Color _divider = Color(0xFFF0F4FA);
static const Color _green = Color(0xFF2FBF6B);
/// SN 是否显示明文
bool _showSn = false;
/// 本页改名后的名称(接口只写 deviceAlias,不动 deviceName)
String? _aliasOverride;
/// 展示名:本页刚改的 > 后端 deviceAlias > deviceName
String get _displayName {
final current = _aliasOverride?.trim();
if (current != null && current.isNotEmpty) return current;
return RobotSummaryCardV2.displayNameOf(widget.robot);
}
/// 组织列表:只用于把 orgId 翻成组织名
List<OrgEntity> _orgs = const [];
bool get _online => widget.robot.isOnline;
@override
void initState() {
super.initState();
_loadOrgs();
}
Future<void> _loadOrgs() async {
final result = await sl<GetOrgListUseCase>()();
result.fold(
(failure) => debugPrint(
'>>> [Ownership] 组织列表加载失败: ${failure.message}',
),
(orgs) {
if (!mounted) return;
setState(() => _orgs = orgs);
},
);
}
// ── 真实归属链路:组织 → 场站 → 负责人(拿不到就标「未知」,不编造)──
int? get _orgId => widget.robot.orgId ?? widget.orgId;
String? get _orgName {
final direct = widget.robot.orgName?.trim();
if (direct != null && direct.isNotEmpty) return direct;
final id = _orgId;
if (id == null || id <= 0) return null;
for (final org in _orgs) {
if (org.id == id) return org.name;
}
return null;
}
String? get _siteName {
final direct = widget.robot.siteName?.trim();
if (direct != null && direct.isNotEmpty) return direct;
final fallback = widget.siteName?.trim();
return (fallback != null && fallback.isNotEmpty) ? fallback : null;
}
String? get _ownerName {
final direct = widget.robot.userName?.trim();
return (direct != null && direct.isNotEmpty) ? direct : null;
}
String get _snText =>
_showSn ? widget.robot.name : RobotSummaryCardV2.maskedSnOf(widget.robot);
void _toggleSn() => setState(() => _showSn = !_showSn);
void _openSharedMembers() =>
CapsuleToast.show('成员共享管理通道未接入', showCheck: false);
/// 改名:复用设备列表那套底部弹窗,只下发 deviceAlias
///
/// 设备标识用设备编码(deviceName,MC700- 开头),不是数字主键 id;
/// 成功后只更新本页展示名,不重新拉归属(归属关系没变)。
Future<void> _renameDevice() async {
final l10n = AppLocalizations.of(context);
final oldName = _displayName;
final newName = await showRenameDeviceDialog(
context,
currentName: oldName,
modelText: widget.robot.modelLabelOf(l10n),
snKey: widget.robot.name.isNotEmpty ? widget.robot.name : widget.robot.id,
);
// 取消 / 名称未变:不调接口
if (newName == null || newName == oldName || !mounted) return;
/// 移交管理员:变更角色不改变所有权
Future<void> _transferAdmin() async {
final targetDeviceId = widget.robot.name.isNotEmpty
? widget.robot.name
: widget.robot.id;
debugPrint(
'>>> [Ownership] 改名下发 deviceId=$targetDeviceId alias=$newName',
);
final result = await sl<UpdateDeviceAliasUseCase>().execute(
deviceId: targetDeviceId,
deviceAlias: newName,
);
if (!mounted) return;
result.fold(
(failure) => CapsuleToast.show(
failure.message.isEmpty
? l10n.translate('device_list_v2.rename_failed')
: failure.message,
showCheck: false,
),
(updated) {
if (!updated) {
CapsuleToast.show(
l10n.translate('device_list_v2.rename_failed'),
showCheck: false,
);
return;
}
setState(() => _aliasOverride = newName);
CapsuleToast.show(
l10n
.translate('device_list_v2.rename_success')
.replaceAll('%s', newName),
);
},
);
}
// ── 角色与可操作层级(口径与归属操作页一致)──
String _roleKey = 'user';
bool get _canOrg => _roleKey == 'admin';
bool get _canSite => _roleKey == 'admin' || _roleKey == 'manager';
bool get _canUser =>
_roleKey == 'admin' || _roleKey == 'manager' || _roleKey == 'siteManager';
/// 普通用户 / 个人角色只能动自己,所以负责人这一级是「仅本人」
bool get _selfOnly => !_canUser;
/// 能触达的最深一级:admin 到组织、manager 到场站、其余到负责人
UnbindScope get _maxScope {
if (_canOrg) return UnbindScope.org;
if (_canSite) return UnbindScope.site;
return UnbindScope.user;
}
// 共享管理(成员管理)暂不开放,入口已从基础信息卡注释,方法一并注释
// void _openSharedMembers() => CapsuleToast.show(
// AppLocalizations.of(context).translate('device_ownership_v2.toast_member_todo'),
// showCheck: false,
// );
void _notifyLocked() => CapsuleToast.show(
AppLocalizations.of(context).translate('device_ownership_v2.action_locked'),
showCheck: false,
);
/// 转交设备:本质就是绑定接口
///
/// 「转交」只是顺着组织 → 场站 → 负责人的层级把设备重新挂到某一级,
/// 下发走 /iot/device/bind,与扫码绑定同一条链路;
/// 普通用户只能转给自己,普通用户以上的角色才能转给其他人。
Future<void> _transferDevice() async {
final l10n = AppLocalizations.of(context);
final ok = await showDeviceConfirmDialog(
context,
icon: Icons.workspace_premium_rounded,
title: '移交管理员权限',
message: '移交后你将成为普通成员,设备所有权不变。确认继续?',
note: '建议先确认接手成员已在成员列表中。',
confirmText: '继续移交',
title: l10n.translate('device_ownership_v2.transfer_device_title'),
message: l10n.translate('device_ownership_v2.transfer_device_msg'),
note: l10n.translate('device_ownership_v2.transfer_device_note'),
confirmText: l10n.translate('device_ownership_v2.transfer_device_ok'),
);
if (!ok || !mounted) return;
CapsuleToast.show('管理员移交通道未接入', showCheck: false);
await _openActionPage(OwnershipAction.transfer);
}
/// 解绑与所有权转让:不可逆,红色确认
Future<void> _transferOwnership() async {
/// 解绑设备:确认 → 扫码核验 → 选解除归属层级 → 下发解绑
///
/// 解绑不可逆,所以保留红色二次确认,再接扫码与层级选择;
/// 成功后向上一级回传 true,由设备列表重拉统计与机器人数据。
Future<void> _unbindDevice() async {
final l10n = AppLocalizations.of(context);
final ok = await showDeviceConfirmDialog(
context,
icon: Icons.swap_horiz_rounded,
title: '设备解绑与所有权转让',
message: '解绑后你与本设备的关联将立即失效,需重新绑定才能继续使用。确认继续?',
note: '需核验所有者权限和身份,SN 不能代替身份核验。',
confirmText: '确认操作',
icon: Icons.link_off_rounded,
title: l10n.translate('device_ownership_v2.unbind_device_title'),
message: l10n.translate('device_ownership_v2.unbind_device_msg'),
note: l10n.translate('device_ownership_v2.unbind_device_note'),
confirmText: l10n.translate('device_ownership_v2.unbind_device_ok'),
danger: true,
);
if (!ok || !mounted) return;
CapsuleToast.show('所有权转让通道未接入,需所有者身份核验', showCheck: false);
await _openActionPage(OwnershipAction.unbind);
}
/// 两个动作共用跳转:不再经过扫码页,直接进归属操作页
/// (入口已由归属页本身承担设备确认,多一道扫码只会让用户点「跳过」)
Future<void> _openActionPage(OwnershipAction action) async {
final done = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) =>
DeviceOwnershipActionPageV2(robot: widget.robot, action: action),
),
);
if (!mounted) return;
// 动作已完成,归属关系变了,回到设备列表让上层重拉
if (done == true) {
Navigator.of(context).pop(true);
}
}
@override
Widget build(BuildContext context) {
_roleKey = context.watch<AppUserCubit>().state.user?.roleKey ?? 'user';
return Scaffold(
backgroundColor: const Color(0xFFEDF6FD),
body: Container(
@@ -102,11 +291,11 @@ class _RobotOwnershipPageV2State extends State<RobotOwnershipPageV2> {
children: [
RobotSummaryCardV2(robot: widget.robot),
const SizedBox(height: 14),
_buildOwnershipCard(),
_buildBasicCard(),
const SizedBox(height: 14),
_buildAdminCard(),
_buildPermissionCard(),
const SizedBox(height: 14),
_buildRiskyCard(),
_buildActionCard(),
const SizedBox(height: 14),
_buildFooterNote(),
],
@@ -127,7 +316,8 @@ class _RobotOwnershipPageV2State extends State<RobotOwnershipPageV2> {
child: Row(
children: [
GestureDetector(
onTap: () => Navigator.of(context).pop(),
// 改过名 / 做过归属操作都回传 true,由列表页重拉设备卡片
onTap: () => Navigator.of(context).pop(_aliasOverride != null),
behavior: HitTestBehavior.opaque,
child: const SizedBox(
width: 36,
@@ -139,13 +329,13 @@ class _RobotOwnershipPageV2State extends State<RobotOwnershipPageV2> {
),
),
),
const Expanded(
Expanded(
child: Text(
'设备归属与安全',
AppLocalizations.of(context).translate('device_ownership_v2.title'),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: _navy,
@@ -158,7 +348,6 @@ class _RobotOwnershipPageV2State extends State<RobotOwnershipPageV2> {
);
}
// ===== 卡内标题:18 w800 =====
Widget _buildCardTitle(String title, {Color color = _navy}) {
return Text(
title,
@@ -166,118 +355,313 @@ class _RobotOwnershipPageV2State extends State<RobotOwnershipPageV2> {
);
}
// ===== 设备归属信息 =====
Widget _buildOwnershipCard() {
Widget _buildCard({
required List<Widget> children,
Color color = Colors.white,
Color borderColor = _cardBorder,
}) {
return Container(
padding: const EdgeInsets.fromLTRB(14, 16, 14, 4),
decoration: BoxDecoration(
color: Colors.white,
color: color,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: _cardBorder),
border: Border.all(color: borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
);
}
// ===== ① 设备基础信息 =====
Widget _buildBasicCard() {
final l10n = AppLocalizations.of(context);
final unknown = l10n.translate('device_ownership_v2.unknown');
final orgName = _orgName;
final siteName = _siteName;
final ownerName = _ownerName;
return _buildCard(
children: [
Row(
children: [
Expanded(
child: _buildCardTitle(
l10n.translate('device_ownership_v2.card_basic'),
),
),
_buildStatusPill(
l10n.translate(
_online
? 'device_ownership_v2.bound_active'
: 'device_ownership_v2.bound_offline',
),
_online ? _green : _gray,
),
],
),
const SizedBox(height: 8),
_buildOwnershipPath(
orgName: orgName,
siteName: siteName,
ownerName: ownerName,
),
// 设备名称可现场改:整行都能点,不只铅笔图标
_buildKvRow(
l10n.translate('device_ownership_v2.basic_name'),
_displayName,
onTap: _renameDevice,
trailing: const Padding(
padding: EdgeInsets.only(left: 8, top: 4, bottom: 4),
child: Icon(Icons.edit_outlined, size: 18, color: _blue),
),
),
const Divider(height: 1, thickness: 0.6, color: _divider),
_buildKvRow(
l10n.translate('device_ownership_v2.basic_org'),
orgName ?? unknown,
muted: orgName == null,
),
const Divider(height: 1, thickness: 0.6, color: _divider),
_buildKvRow(
l10n.translate('device_ownership_v2.basic_site'),
siteName ?? unknown,
muted: siteName == null,
),
const Divider(height: 1, thickness: 0.6, color: _divider),
_buildKvRow(
l10n.translate('device_ownership_v2.basic_owner'),
ownerName ?? unknown,
muted: ownerName == null,
),
const Divider(height: 1, thickness: 0.6, color: _divider),
_buildKvRow(
l10n.translate('device_ownership_v2.sn'),
_snText,
trailing: GestureDetector(
onTap: _toggleSn,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.only(left: 6),
child: Icon(
_showSn
? Icons.visibility_rounded
: Icons.visibility_off_outlined,
size: 18,
color: _gray,
),
),
),
isLast: true,
),
// 共享管理(成员管理)暂不开放,先注释入口
// const Divider(height: 1, thickness: 0.6, color: _divider),
// _buildKvRow(
// l10n.translate('device_ownership_v2.member_enter'),
// '',
// trailing: GestureDetector(
// onTap: _openSharedMembers,
// behavior: HitTestBehavior.opaque,
// child: const Padding(
// padding: EdgeInsets.only(left: 6),
// child: Icon(
// Icons.chevron_right_rounded,
// size: 20,
// color: _blue,
// ),
// ),
// ),
// isLast: true,
// ),
],
);
}
/// 归属链路:组织 / 场站 / 负责人 一条线看完,缺哪级就标未知
Widget _buildOwnershipPath({
required String? orgName,
required String? siteName,
required String? ownerName,
}) {
final l10n = AppLocalizations.of(context);
final unknown = l10n.translate('device_ownership_v2.unknown');
final text = [orgName ?? unknown, siteName ?? unknown, ownerName ?? unknown]
.join(' / ');
return Container(
margin: const EdgeInsets.only(bottom: 2),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
decoration: BoxDecoration(
color: _softBlue,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
_buildCardTitle('设备归属信息'),
const SizedBox(height: 6),
_buildKvRow('设备所有者', '待归属接口上报', muted: true),
const Divider(height: 1, thickness: 0.6, color: _divider),
_buildKvRow('归属类型', '企业组织', muted: true),
const Divider(height: 1, thickness: 0.6, color: _divider),
_buildKvRow('当前绑定', _online ? '已激活 · 归属有效' : '已绑定 · 设备离线'),
const Divider(height: 1, thickness: 0.6, color: _divider),
const Icon(Icons.account_tree_rounded, size: 15, color: _blue),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12.5,
height: 1.45,
fontWeight: FontWeight.w700,
color: _navy,
),
),
),
],
),
);
}
// ===== ② 我的操作权限 =====
Widget _buildPermissionCard() {
final l10n = AppLocalizations.of(context);
return _buildCard(
children: [
Row(
children: [
Expanded(
child: _buildCardTitle(
l10n.translate('device_ownership_v2.card_permission'),
),
),
_buildStatusPill(
l10n
.translate('device_ownership_v2.role_current')
.replaceFirst('%s', l10n.translate(_roleLabelKey(_roleKey))),
_blue,
soft: true,
),
],
),
const SizedBox(height: 6),
Text(
l10n.translate('device_ownership_v2.perm_desc'),
style: const TextStyle(
fontSize: 11.5,
height: 1.55,
color: _gray,
),
),
const SizedBox(height: 12),
// 三段权限指示器:亮 = 可操作,灰 = 无权限,浅蓝 = 仅本人
Row(
children: [
_buildLevelChip(
l10n.translate('device_ownership_v2.perm_level_org'),
_canOrg ? _LevelState.full : _LevelState.none,
),
const SizedBox(width: 8),
_buildLevelChip(
l10n.translate('device_ownership_v2.perm_level_site'),
_canSite ? _LevelState.full : _LevelState.none,
),
const SizedBox(width: 8),
_buildLevelChip(
l10n.translate('device_ownership_v2.perm_level_user'),
_selfOnly ? _LevelState.self : _LevelState.full,
),
],
),
const SizedBox(height: 4),
const Divider(height: 1, thickness: 0.6, color: _divider),
// 转交入口只给普通用户以上的角色,这里也同步不展示,避免前后矛盾
if (_canUser) ...[
_buildKvRow(
'设备SN',
_snText,
trailing: GestureDetector(
onTap: _toggleSn,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.only(left: 6),
child: Icon(
_showSn
? Icons.visibility_rounded
: Icons.visibility_off_outlined,
size: 18,
color: _gray,
l10n.translate('device_ownership_v2.perm_transfer_to'),
_scopePathText(_maxScope),
),
const Divider(height: 1, thickness: 0.6, color: _divider),
],
_buildKvRow(
l10n.translate('device_ownership_v2.perm_unbind_to'),
_scopePathText(_maxScope),
isLast: true,
),
],
color: Colors.white,
borderColor: _cardBorder,
);
}
/// 层级胶囊:full 蓝底白字,self 浅蓝底蓝字,none 灰底灰字
Widget _buildLevelChip(String label, _LevelState state) {
final l10n = AppLocalizations.of(context);
final (Color bg, Color fg, IconData icon) = switch (state) {
_LevelState.full => (_blue, Colors.white, Icons.check_rounded),
_LevelState.self => (_softBlue, _blue, Icons.person_rounded),
_LevelState.none => (_softGray, _hint, Icons.lock_outline_rounded),
};
final text = state == _LevelState.self
? '${l10n.translate('device_ownership_v2.perm_level_user')}'
' · ${l10n.translate('device_ownership_v2.perm_self_short')}'
: label;
return Expanded(
child: Container(
height: 42,
alignment: Alignment.center,
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(11),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 14, color: fg),
const SizedBox(width: 5),
Flexible(
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: fg,
),
),
),
),
const Divider(height: 1, thickness: 0.6, color: _divider),
_buildKvRow('首次认领', '待归属接口上报', muted: true, isLast: true),
],
],
),
),
);
}
// ===== 管理员与授权 =====
Widget _buildAdminCard() {
return Container(
padding: const EdgeInsets.fromLTRB(14, 16, 14, 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: _cardBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildCardTitle('管理员与授权'),
const SizedBox(height: 6),
_buildKvRow('当前管理员', '待归属接口上报', muted: true),
const Divider(height: 1, thickness: 0.6, color: _divider),
GestureDetector(
onTap: _openSharedMembers,
behavior: HitTestBehavior.opaque,
child: _buildKvRow(
'成员权限管理',
'进入共享管理',
valueColor: _blue,
isLast: true,
trailing: const Icon(
Icons.chevron_right_rounded,
size: 20,
color: _hint,
),
),
),
],
),
);
}
// ===== ③ 归属操作 =====
Widget _buildActionCard() {
final l10n = AppLocalizations.of(context);
// ===== 高风险归属操作 =====
Widget _buildRiskyCard() {
return Container(
padding: const EdgeInsets.fromLTRB(14, 16, 14, 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: _cardBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildCardTitle('高风险归属操作', color: _danger),
const SizedBox(height: 4),
return _buildCard(
children: [
_buildCardTitle(l10n.translate('device_ownership_v2.card_actions')),
const SizedBox(height: 4),
// 转交只对「普通用户以上」的角色开放:普通用户/个人角色不能把设备
// 转给他人,也就不展示这个入口,避免进去后才发现无法选人
if (_canUser) ...[
_buildActionRow(
icon: Icons.workspace_premium_rounded,
title: '移交管理员权限',
subtitle: '变更管理角色,不改变所有权',
onTap: _transferAdmin,
title: l10n.translate('device_ownership_v2.risky_transfer'),
subtitle: l10n.translate('device_ownership_v2.risky_transfer_sub'),
onTap: _transferDevice,
),
const Divider(height: 1, thickness: 0.6, color: _divider),
_buildActionRow(
icon: Icons.swap_horiz_rounded,
title: '设备解绑与所有权转让',
subtitle: '需核验所有者权限和身份',
danger: true,
isLast: true,
onTap: _transferOwnership,
),
],
),
_buildActionRow(
icon: Icons.link_off_rounded,
title: l10n.translate('device_ownership_v2.risky_unbind'),
subtitle: l10n.translate('device_ownership_v2.risky_unbind_sub'),
danger: true,
isLast: true,
onTap: _unbindDevice,
),
],
);
}
@@ -286,52 +670,143 @@ class _RobotOwnershipPageV2State extends State<RobotOwnershipPageV2> {
required String title,
required String subtitle,
required VoidCallback onTap,
bool enabled = true,
bool danger = false,
bool isLast = false,
}) {
final color = danger ? _danger : _blue;
final l10n = AppLocalizations.of(context);
final accent = enabled ? (danger ? _danger : _blue) : _hint;
return GestureDetector(
onTap: onTap,
onTap: enabled ? onTap : _notifyLocked,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: EdgeInsets.only(top: 14, bottom: isLast ? 14 : 12),
child: Row(
children: [
SizedBox(width: 30, child: Icon(icon, size: 22, color: color)),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: color,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: const TextStyle(fontSize: 12, color: _gray),
),
],
child: Opacity(
opacity: enabled ? 1 : 0.75,
child: Padding(
padding: EdgeInsets.only(top: 14, bottom: isLast ? 14 : 12),
child: Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: enabled
? (danger ? const Color(0xFFFDEDEC) : _softBlue)
: _softGray,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 19, color: accent),
),
),
const SizedBox(width: 8),
const Icon(
Icons.chevron_right_rounded,
size: 20,
color: Color(0xFFC9CDD4),
),
],
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
title,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: accent,
),
),
if (!enabled) ...[
const SizedBox(width: 6),
_buildStatusPill(
l10n.translate('device_ownership_v2.tag_locked'),
_hint,
soft: true,
),
],
],
),
const SizedBox(height: 4),
Text(
subtitle,
style: const TextStyle(fontSize: 12, color: _gray),
),
],
),
),
const SizedBox(width: 8),
Icon(
enabled
? Icons.chevron_right_rounded
: Icons.lock_outline_rounded,
size: enabled ? 20 : 16,
color: enabled ? const Color(0xFFC9CDD4) : _hint,
),
],
),
),
),
);
}
// ===== 通用 KV 行 =====
// ===== 通用小组件 =====
/// 右上角状态胶囊:实心或浅底
Widget _buildStatusPill(
String text,
Color color, {
bool soft = false,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: soft ? color.withValues(alpha: 0.1) : color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text(
text,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: color,
),
),
);
}
String _roleLabelKey(String roleKey) {
switch (roleKey) {
case 'admin':
return 'device_ownership_action_v2.role_admin';
case 'manager':
return 'device_ownership_action_v2.role_manager';
case 'siteManager':
return 'device_ownership_action_v2.role_site_manager';
case 'personal':
return 'device_ownership_action_v2.role_personal';
default:
// user 在归属操作里与 personal 同口径:只能是自己
return 'device_ownership_action_v2.role_user';
}
}
/// 可操作层级文案:组织 / 场站 / 负责人 逐级列出
String _scopePathText(UnbindScope max) {
final l10n = AppLocalizations.of(context);
final names = <String>[];
for (final scope in UnbindScope.values.reversed) {
if (scope.value > max.value) continue;
names.add(l10n.translate(_scopeLabelKey(scope)));
}
return names.join(' / ');
}
String _scopeLabelKey(UnbindScope scope) {
switch (scope) {
case UnbindScope.org:
return 'device_ownership_v2.perm_level_org';
case UnbindScope.site:
return 'device_ownership_v2.perm_level_site';
case UnbindScope.user:
return 'device_ownership_v2.perm_level_user';
}
}
Widget _buildKvRow(
String label,
String value, {
@@ -339,8 +814,9 @@ class _RobotOwnershipPageV2State extends State<RobotOwnershipPageV2> {
Color? valueColor,
Widget? trailing,
bool isLast = false,
VoidCallback? onTap,
}) {
return Padding(
final row = Padding(
padding: EdgeInsets.only(top: 14, bottom: isLast ? 14 : 12),
child: Row(
children: [
@@ -363,6 +839,14 @@ class _RobotOwnershipPageV2State extends State<RobotOwnershipPageV2> {
],
),
);
// 整行都可点:只让小图标可点的话,手指很难点中
if (onTap == null) return row;
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: row,
);
}
Widget _buildFooterNote() {
@@ -381,11 +865,10 @@ class _RobotOwnershipPageV2State extends State<RobotOwnershipPageV2> {
color: _online ? _green : _blue,
),
const SizedBox(width: 8),
const Expanded(
Expanded(
child: Text(
'管理员身份不等于设备所有者;SN 仅用于识别,不能凭 SN 完成解绑。'
'归属信息需设备归属接口接入后显示真实值。',
style: TextStyle(
AppLocalizations.of(context).translate('device_ownership_v2.footer'),
style: const TextStyle(
fontSize: 11.5,
height: 1.55,
color: Color(0xFF5C6B84),
@@ -397,3 +880,6 @@ class _RobotOwnershipPageV2State extends State<RobotOwnershipPageV2> {
);
}
}
/// 权限层级的三态
enum _LevelState { full, self, none }

View File

@@ -3,11 +3,12 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import '../../data/models/robot_data_model.dart';
import '../widgets/robot_summary_card_v2.dart';
/// 时间范围档位:标题 + 柱状图数据 + 横轴标签(示例数据)
/// 时间范围档位:标题 key + 柱状图数据 + 横轴标签 key(示例数据)
class _RangeData {
final String label;
final String chartTitle;
@@ -22,7 +23,7 @@ class _RangeData {
});
}
/// 一条历史事件
/// 一条历史事件(title / time / tag 均为语言包 key)
class _RunEvent {
final String title;
final String time;
@@ -61,45 +62,86 @@ class _RobotRunRecordPageV2State extends State<RobotRunRecordPageV2> {
/// 三个可切换档位(数据为示例,仅用于展示图表形态)
static const List<_RangeData> _ranges = [
_RangeData(
label: '近7天',
chartTitle: '运行趋势(最近7天)',
label: 'robot_run_v2.range_7',
chartTitle: 'robot_run_v2.chart_7',
bars: [3.2, 4.6, 2.8, 5.1, 4.0, 4.4, 5.0],
axis: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
axis: [
'robot_run_v2.axis_mon',
'robot_run_v2.axis_tue',
'robot_run_v2.axis_wed',
'robot_run_v2.axis_thu',
'robot_run_v2.axis_fri',
'robot_run_v2.axis_sat',
'robot_run_v2.axis_sun',
],
),
_RangeData(
label: '近30天',
chartTitle: '运行趋势(最近30天)',
label: 'robot_run_v2.range_30',
chartTitle: 'robot_run_v2.chart_30',
bars: [18.4, 22.6, 20.1, 25.4, 21.9],
axis: ['第1周', '第2周', '第3周', '第4周', '第5周'],
axis: [
'robot_run_v2.axis_week_1',
'robot_run_v2.axis_week_2',
'robot_run_v2.axis_week_3',
'robot_run_v2.axis_week_4',
'robot_run_v2.axis_week_5',
],
),
_RangeData(
label: '近90天',
chartTitle: '运行趋势(最近90天)',
label: 'robot_run_v2.range_90',
chartTitle: 'robot_run_v2.chart_90',
bars: [62.5, 74.2, 81.6],
axis: ['7月', '8月', '9月'],
axis: [
'robot_run_v2.axis_month_7',
'robot_run_v2.axis_month_8',
'robot_run_v2.axis_month_9',
],
),
];
/// 历史记录与事件(示例数据:后端暂无运行事件接口)
static const List<_RunEvent> _events = [
_RunEvent(title: '机器人开机', time: '今日 08:41', tag: '运行状态'),
_RunEvent(title: 'APP遥控会话释放', time: '昨日 17:06', tag: '控制操作'),
_RunEvent(title: '超声波联动停刀触发', time: '10月18日 15:42', tag: '已解除'),
_RunEvent(title: '云端连接恢复', time: '10月18日 15:40', tag: '网络'),
_RunEvent(
title: 'robot_run_v2.ev_1_title',
time: 'robot_run_v2.ev_1_time',
tag: 'robot_run_v2.ev_1_tag',
),
_RunEvent(
title: 'robot_run_v2.ev_2_title',
time: 'robot_run_v2.ev_2_time',
tag: 'robot_run_v2.ev_2_tag',
),
_RunEvent(
title: 'robot_run_v2.ev_3_title',
time: 'robot_run_v2.ev_3_time',
tag: 'robot_run_v2.ev_3_tag',
),
_RunEvent(
title: 'robot_run_v2.ev_4_title',
time: 'robot_run_v2.ev_4_time',
tag: 'robot_run_v2.ev_4_tag',
),
];
int _rangeIndex = 0;
_RangeData get _range => _ranges[_rangeIndex];
void _todo() => CapsuleToast.show('功能暂未开发', showCheck: false);
void _todo() => CapsuleToast.show(
AppLocalizations.of(context).translate('robot_run_v2.todo'),
showCheck: false,
);
void _onCustomRange() {
CapsuleToast.show('自定义时间范围暂未开发', showCheck: false);
CapsuleToast.show(
AppLocalizations.of(context).translate('robot_run_v2.custom_todo'),
showCheck: false,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: const Color(0xFFEDF6FD),
body: Container(
@@ -124,23 +166,30 @@ class _RobotRunRecordPageV2State extends State<RobotRunRecordPageV2> {
const SizedBox(height: 14),
_buildRangeBar(),
const SizedBox(height: 18),
_buildSectionTitle('运行数据概况'),
_buildSectionTitle(
l10n.translate('robot_run_v2.sec_overview'),
),
const SizedBox(height: 10),
_buildOverviewCard(),
const SizedBox(height: 18),
_buildSectionTitle(_range.chartTitle),
_buildSectionTitle(l10n.translate(_range.chartTitle)),
const SizedBox(height: 10),
_buildChartCard(),
const SizedBox(height: 18),
_buildSectionTitle('历史记录与事件'),
_buildSectionTitle(
l10n.translate('robot_run_v2.sec_events'),
),
const SizedBox(height: 10),
_buildEventTimeline(),
const SizedBox(height: 14),
const Center(
Center(
child: Text(
'运行统计与历史事件为示例数据,接入统计接口后自动替换',
l10n.translate('robot_run_v2.footer'),
textAlign: TextAlign.center,
style: TextStyle(fontSize: 10.5, color: _hint),
style: const TextStyle(
fontSize: 10.5,
color: _hint,
),
),
),
],
@@ -185,12 +234,12 @@ class _RobotRunRecordPageV2State extends State<RobotRunRecordPageV2> {
),
),
const SizedBox(width: 12),
const Expanded(
Expanded(
child: Text(
'运行记录与健康分析',
AppLocalizations.of(context).translate('robot_run_v2.title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _navy,
@@ -257,7 +306,11 @@ class _RobotRunRecordPageV2State extends State<RobotRunRecordPageV2> {
// ===== 时间范围:近7天 / 近30天 / 近90天 / 自定义 =====
Widget _buildRangeBar() {
final labels = [..._ranges.map((e) => e.label), '自定义'];
final l10n = AppLocalizations.of(context);
final labels = [
..._ranges.map((e) => l10n.translate(e.label)),
l10n.translate('robot_run_v2.range_custom'),
];
return Container(
height: 46,
padding: const EdgeInsets.all(4),
@@ -312,6 +365,7 @@ class _RobotRunRecordPageV2State extends State<RobotRunRecordPageV2> {
// ===== 运行数据概况:三块浅蓝统计 =====
Widget _buildOverviewCard() {
final l10n = AppLocalizations.of(context);
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
@@ -324,24 +378,24 @@ class _RobotRunRecordPageV2State extends State<RobotRunRecordPageV2> {
Expanded(
child: _buildStatBlock(
icon: Icons.access_time_rounded,
value: '128.5h',
label: '累计运行',
value: l10n.translate('robot_run_v2.stat_runtime_value'),
label: l10n.translate('robot_run_v2.stat_runtime'),
),
),
const SizedBox(width: 8),
Expanded(
child: _buildStatBlock(
icon: Icons.power_settings_new_rounded,
value: '86次',
label: '启动次数',
value: l10n.translate('robot_run_v2.stat_starts_value'),
label: l10n.translate('robot_run_v2.stat_starts'),
),
),
const SizedBox(width: 8),
Expanded(
child: _buildStatBlock(
icon: Icons.show_chart_rounded,
value: '12.8km',
label: '示例里程',
value: l10n.translate('robot_run_v2.stat_mileage_value'),
label: l10n.translate('robot_run_v2.stat_mileage'),
),
),
],
@@ -401,9 +455,11 @@ class _RobotRunRecordPageV2State extends State<RobotRunRecordPageV2> {
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
const Text(
'每日运行时长',
style: TextStyle(
Text(
AppLocalizations.of(
context,
).translate('robot_run_v2.chart_daily'),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
color: _navy,
@@ -411,7 +467,7 @@ class _RobotRunRecordPageV2State extends State<RobotRunRecordPageV2> {
),
const Spacer(),
Text(
'单位:小时',
AppLocalizations.of(context).translate('robot_run_v2.chart_unit'),
style: TextStyle(
fontSize: 11,
color: _hint.withValues(alpha: 0.95),
@@ -478,7 +534,7 @@ class _RobotRunRecordPageV2State extends State<RobotRunRecordPageV2> {
for (var i = 0; i < axis.length; i++)
Expanded(
child: Text(
axis[i],
AppLocalizations.of(context).translate(axis[i]),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -527,6 +583,7 @@ class _RobotRunRecordPageV2State extends State<RobotRunRecordPageV2> {
}
Widget _buildEventRow(_RunEvent event, {required bool isLast}) {
final l10n = AppLocalizations.of(context);
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -557,7 +614,7 @@ class _RobotRunRecordPageV2State extends State<RobotRunRecordPageV2> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.title,
l10n.translate(event.title),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
@@ -568,7 +625,7 @@ class _RobotRunRecordPageV2State extends State<RobotRunRecordPageV2> {
),
const SizedBox(height: 5),
Text(
'${event.time} · ${event.tag}',
'${l10n.translate(event.time)} · ${l10n.translate(event.tag)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: _gray),

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import 'package:maibu_satabot_v2/features/devices/domain/usecases/get_device_location_usecase.dart';
import '../../data/models/robot_data_model.dart';
@@ -37,16 +38,21 @@ class _RobotSensorPageV2State extends State<RobotSensorPageV2> {
static const Color _divider = Color(0xFFF0F4FA);
static const Color _trackBg = Color(0xFFDFEBF9);
static const List<String> _tabs = ['超声波', '定位', '通信', '机构'];
static const List<String> _tabKeys = [
'robot_sensor_v2.tab_ultrasonic',
'robot_sensor_v2.tab_position',
'robot_sensor_v2.tab_comm',
'robot_sensor_v2.tab_mechanism',
];
/// 超声波 6 路测距(示例值,与设计稿一致)
/// 超声波 6 路测距(演示值,与设计稿一致):(方位 key, 距离)
static const List<(String, String)> _ultrasonicRows = [
('前左', '1.5m'),
('前中', '1.8m'),
('前右', '2.0m'),
('左侧', '1.3m'),
('右侧', '1.7m'),
('后部', '2.1m'),
('robot_sensor_v2.dir_front_left', '1.5m'),
('robot_sensor_v2.dir_front_mid', '1.8m'),
('robot_sensor_v2.dir_front_right', '2.0m'),
('robot_sensor_v2.dir_left', '1.3m'),
('robot_sensor_v2.dir_right', '1.7m'),
('robot_sensor_v2.dir_rear', '2.1m'),
];
int _tabIndex = 0;
@@ -96,7 +102,10 @@ class _RobotSensorPageV2State extends State<RobotSensorPageV2> {
return '${v.abs().toStringAsFixed(5)}° ${v >= 0 ? 'N' : 'S'}';
}
void _todo() => CapsuleToast.show('功能暂未开发', showCheck: false);
void _todo() => CapsuleToast.show(
AppLocalizations.of(context).translate('robot_sensor_v2.todo'),
showCheck: false,
);
@override
Widget build(BuildContext context) {
@@ -167,12 +176,12 @@ class _RobotSensorPageV2State extends State<RobotSensorPageV2> {
),
),
const SizedBox(width: 12),
const Expanded(
Expanded(
child: Text(
'传感器与通信详情',
AppLocalizations.of(context).translate('robot_sensor_v2.title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _navy,
@@ -218,7 +227,7 @@ class _RobotSensorPageV2State extends State<RobotSensorPageV2> {
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: List.generate(_tabs.length, (index) {
children: List.generate(_tabKeys.length, (index) {
final selected = _tabIndex == index;
return Expanded(
child: GestureDetector(
@@ -242,7 +251,7 @@ class _RobotSensorPageV2State extends State<RobotSensorPageV2> {
),
alignment: Alignment.center,
child: Text(
_tabs[index],
AppLocalizations.of(context).translate(_tabKeys[index]),
style: TextStyle(
fontSize: 12.5,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
@@ -298,10 +307,11 @@ class _RobotSensorPageV2State extends State<RobotSensorPageV2> {
// ===== 超声波 =====
Widget _buildUltrasonicTab() {
final l10n = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSectionTitle('超声波实时状态'),
_buildSectionTitle(l10n.translate('robot_sensor_v2.sec_ultrasonic')),
const SizedBox(height: 10),
Container(
decoration: BoxDecoration(
@@ -316,9 +326,9 @@ class _RobotSensorPageV2State extends State<RobotSensorPageV2> {
padding: const EdgeInsets.fromLTRB(14, 13, 14, 0),
child: Row(
children: [
const Text(
'超声波障碍检测',
style: TextStyle(
Text(
l10n.translate('robot_sensor_v2.us_card_title'),
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w800,
color: _navy,
@@ -334,9 +344,9 @@ class _RobotSensorPageV2State extends State<RobotSensorPageV2> {
color: _greenBg,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'6/6正常',
style: TextStyle(
child: Text(
l10n.translate('robot_sensor_v2.us_ok_count'),
style: const TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w600,
color: _green,
@@ -353,7 +363,7 @@ class _RobotSensorPageV2State extends State<RobotSensorPageV2> {
Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 13),
child: Text(
'以下为演示距离;实际传感器位置、数量按设备SN加载。',
l10n.translate('robot_sensor_v2.us_note'),
style: TextStyle(
fontSize: 10.5,
color: _gray.withValues(alpha: 0.95),
@@ -364,11 +374,19 @@ class _RobotSensorPageV2State extends State<RobotSensorPageV2> {
),
),
const SizedBox(height: 18),
_buildSectionTitle('传感器测距'),
_buildSectionTitle(l10n.translate('robot_sensor_v2.sec_distance')),
const SizedBox(height: 10),
_buildRowsCard(
_ultrasonicRows
.map((row) => (row.$1, '${row.$2} · 正常', _green))
.map(
(row) => (
l10n.translate(row.$1),
l10n
.translate('robot_sensor_v2.dist_normal')
.replaceAll('%s', row.$2),
_green,
),
)
.toList(),
),
],
@@ -396,11 +414,21 @@ class _RobotSensorPageV2State extends State<RobotSensorPageV2> {
child: CustomPaint(painter: _SonarRingPainter(color: _blue)),
),
// 前方距离标签
Positioned(top: 26, child: _buildDistanceTag('前:1.8m')),
Positioned(
top: 26,
child: _buildDistanceTag(
AppLocalizations.of(context).translate('robot_sensor_v2.sonar_front'),
),
),
// 中央机器人
_buildRobotBlock(),
// 后方距离标签
Positioned(bottom: 26, child: _buildDistanceTag('后:2.1m')),
Positioned(
bottom: 26,
child: _buildDistanceTag(
AppLocalizations.of(context).translate('robot_sensor_v2.sonar_rear'),
),
),
],
),
),
@@ -493,22 +521,41 @@ class _RobotSensorPageV2State extends State<RobotSensorPageV2> {
// ===== 定位 =====
Widget _buildPositionTab() {
final l10n = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSectionTitle('定位与卫星'),
_buildSectionTitle(l10n.translate('robot_sensor_v2.sec_position')),
const SizedBox(height: 10),
_buildRowsCard([
('经度', _lngText, _navy),
('纬度', _latText, _navy),
('定位方式', '内置 GNSS', _navy),
(l10n.translate('robot_sensor_v2.row_lng'), _lngText, _navy),
(l10n.translate('robot_sensor_v2.row_lat'), _latText, _navy),
(
'定位状态',
_locLoading ? '获取中…' : (_hasLocation ? '有效 · 米级' : '未上报'),
l10n.translate('robot_sensor_v2.row_fix'),
l10n.translate('robot_sensor_v2.row_fix_value'),
_navy,
),
(
l10n.translate('robot_sensor_v2.row_fix_state'),
l10n.translate(
_locLoading
? 'robot_sensor_v2.fix_loading'
: (_hasLocation
? 'robot_sensor_v2.fix_valid'
: 'robot_sensor_v2.fix_none'),
),
_hasLocation ? _green : _gray,
),
('卫星数', '示例:18 颗', _navy),
('RTK 差分', '示例:未启用', _navy),
(
l10n.translate('robot_sensor_v2.row_sats'),
l10n.translate('robot_sensor_v2.row_sats_value'),
_navy,
),
(
l10n.translate('robot_sensor_v2.row_rtk'),
l10n.translate('robot_sensor_v2.row_rtk_value'),
_navy,
),
]),
const SizedBox(height: 10),
_buildSampleNote(),
@@ -518,22 +565,47 @@ class _RobotSensorPageV2State extends State<RobotSensorPageV2> {
// ===== 通信 =====
Widget _buildCommTab() {
final l10n = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSectionTitle('通信链路'),
_buildSectionTitle(l10n.translate('robot_sensor_v2.sec_comm')),
const SizedBox(height: 10),
_buildRowsCard([
(
'设备在线',
widget.robot.isOnline ? '在线' : '离线',
l10n.translate('robot_sensor_v2.row_online'),
l10n.translate(
widget.robot.isOnline
? 'robot_sensor_v2.online'
: 'robot_sensor_v2.offline',
),
widget.robot.isOnline ? _green : _gray,
),
('通信方式', '示例:MQTT 长连接', _navy),
('网络类型', '示例:4G 全网通', _navy),
('信号强度', '示例:-68 dBm', _navy),
('链路延迟', '示例:120 ms', _navy),
('最后上报', '示例:2026-10-20 09:41', _navy),
(
l10n.translate('robot_sensor_v2.row_comm_mode'),
l10n.translate('robot_sensor_v2.row_comm_mode_value'),
_navy,
),
(
l10n.translate('robot_sensor_v2.row_net_type'),
l10n.translate('robot_sensor_v2.row_net_type_value'),
_navy,
),
(
l10n.translate('robot_sensor_v2.row_signal'),
l10n.translate('robot_sensor_v2.row_signal_value'),
_navy,
),
(
l10n.translate('robot_sensor_v2.row_latency'),
l10n.translate('robot_sensor_v2.row_latency_value'),
_navy,
),
(
l10n.translate('robot_sensor_v2.row_last_report'),
l10n.translate('robot_sensor_v2.row_last_report_value'),
_navy,
),
]),
const SizedBox(height: 10),
_buildSampleNote(),
@@ -543,18 +615,43 @@ class _RobotSensorPageV2State extends State<RobotSensorPageV2> {
// ===== 机构 =====
Widget _buildMechanismTab() {
final l10n = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSectionTitle('作业机构'),
_buildSectionTitle(l10n.translate('robot_sensor_v2.sec_mechanism')),
const SizedBox(height: 10),
_buildRowsCard([
('割盘升降', '示例:停止', _navy),
('刀盘转速', '示例:0 r/min', _navy),
('底盘倾角', '示例:正常', _green),
('障碍物检测', '示例:未触发', _green),
('行走驱动', '示例:正常', _green),
('急停开关', '示例:未按下', _green),
(
l10n.translate('robot_sensor_v2.row_blade_lift'),
l10n.translate('robot_sensor_v2.row_blade_lift_value'),
_navy,
),
(
l10n.translate('robot_sensor_v2.row_blade_rpm'),
l10n.translate('robot_sensor_v2.row_blade_rpm_value'),
_navy,
),
(
l10n.translate('robot_sensor_v2.row_tilt'),
l10n.translate('robot_sensor_v2.row_tilt_value'),
_green,
),
(
l10n.translate('robot_sensor_v2.row_obstacle'),
l10n.translate('robot_sensor_v2.row_obstacle_value'),
_green,
),
(
l10n.translate('robot_sensor_v2.row_drive'),
l10n.translate('robot_sensor_v2.row_drive_value'),
_green,
),
(
l10n.translate('robot_sensor_v2.row_estop'),
l10n.translate('robot_sensor_v2.row_estop_value'),
_green,
),
]),
const SizedBox(height: 10),
_buildSampleNote(),
@@ -563,11 +660,11 @@ class _RobotSensorPageV2State extends State<RobotSensorPageV2> {
}
Widget _buildSampleNote() {
return const Center(
return Center(
child: Text(
'标注「示例」的字段后端暂未上报,接入后自动替换',
AppLocalizations.of(context).translate('robot_sensor_v2.sample_note'),
textAlign: TextAlign.center,
style: TextStyle(fontSize: 10.5, color: _hint),
style: const TextStyle(fontSize: 10.5, color: _hint),
),
);
}

View File

@@ -1,11 +1,31 @@
import 'package:flutter/material.dart';
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import '../../data/models/robot_data_model.dart';
import '../../data/voice_preset_store.dart';
import '../widgets/robot_summary_card_v2.dart';
/// 出厂默认语音的本地化版本:首次落盘时写入当前语言,之后以本地为准
List<VoicePreset> localizedVoiceDefaults(AppLocalizations l10n) => [
VoicePreset(
id: 'default_safety',
name: l10n.translate('robot_voice_v2.def_safety_name'),
text: l10n.translate('robot_voice_v2.def_safety_text'),
),
VoicePreset(
id: 'default_start',
name: l10n.translate('robot_voice_v2.def_start_name'),
text: l10n.translate('robot_voice_v2.def_start_text'),
),
VoicePreset(
id: 'default_polite',
name: l10n.translate('robot_voice_v2.def_polite_name'),
text: l10n.translate('robot_voice_v2.def_polite_text'),
),
];
/// 预设语音管理(设计稿:全部功能中心 → 语音喊话)
///
/// 二级 push 页面,无底部导航。
@@ -40,14 +60,17 @@ class _RobotVoicePresetPageV2State extends State<RobotVoicePresetPageV2> {
List<VoicePreset> _presets = <VoicePreset>[];
bool _loading = true;
bool _loadStarted = false;
/// null 表示正在新增;否则为被编辑语音的 id
String? _editingId;
@override
void initState() {
super.initState();
_load();
void didChangeDependencies() {
super.didChangeDependencies();
if (_loadStarted) return;
_loadStarted = true;
_load(AppLocalizations.of(context));
}
@override
@@ -59,8 +82,11 @@ class _RobotVoicePresetPageV2State extends State<RobotVoicePresetPageV2> {
super.dispose();
}
Future<void> _load() async {
final presets = await VoicePresetStore.loadPresets(widget.robot.name);
Future<void> _load(AppLocalizations l10n) async {
final presets = await VoicePresetStore.loadPresets(
widget.robot.name,
fallback: localizedVoiceDefaults(l10n),
);
if (!mounted) return;
setState(() {
_presets = presets;
@@ -68,7 +94,10 @@ class _RobotVoicePresetPageV2State extends State<RobotVoicePresetPageV2> {
});
}
void _todo() => CapsuleToast.show('功能暂未开发', showCheck: false);
void _todo() => CapsuleToast.show(
AppLocalizations.of(context).translate('robot_voice_v2.todo'),
showCheck: false,
);
/// 点列表项右侧铅笔:把这条填进下方编辑卡
void _editPreset(VoicePreset preset) {
@@ -102,15 +131,22 @@ class _RobotVoicePresetPageV2State extends State<RobotVoicePresetPageV2> {
}
Future<void> _savePreset() async {
final l10n = AppLocalizations.of(context);
final name = _nameCtrl.text.trim();
final text = _textCtrl.text.trim();
if (name.isEmpty) {
CapsuleToast.show('请填写语音名称', showCheck: false);
CapsuleToast.show(
l10n.translate('robot_voice_v2.toast_name_required'),
showCheck: false,
);
_nameFocus.requestFocus();
return;
}
if (text.isEmpty) {
CapsuleToast.show('请填写语音文字', showCheck: false);
CapsuleToast.show(
l10n.translate('robot_voice_v2.toast_text_required'),
showCheck: false,
);
return;
}
@@ -132,20 +168,29 @@ class _RobotVoicePresetPageV2State extends State<RobotVoicePresetPageV2> {
await VoicePresetStore.savePresets(widget.robot.name, presets);
if (!mounted) return;
setState(() => _presets = presets);
CapsuleToast.show('预设语音已保存');
CapsuleToast.show(
AppLocalizations.of(context).translate('robot_voice_v2.toast_saved'),
);
}
void _playPreset(VoicePreset preset) {
debugPrint('🔊 [VoicePreset] 试听: ${preset.name} / ${preset.text}');
CapsuleToast.show('试听需设备端语音合成,暂未接入', showCheck: false);
CapsuleToast.show(
AppLocalizations.of(context).translate('robot_voice_v2.toast_preview'),
showCheck: false,
);
}
void _realtimeTalk() {
CapsuleToast.show('实时喊话将在第二阶段开放', showCheck: false);
CapsuleToast.show(
AppLocalizations.of(context).translate('robot_voice_v2.toast_realtime'),
showCheck: false,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: const Color(0xFFEDF6FD),
body: Container(
@@ -169,23 +214,29 @@ class _RobotVoicePresetPageV2State extends State<RobotVoicePresetPageV2> {
children: [
RobotSummaryCardV2(robot: widget.robot),
const SizedBox(height: 18),
_buildSectionHeader('预设语音列表'),
_buildSectionHeader(
l10n.translate('robot_voice_v2.sec_list'),
),
const SizedBox(height: 10),
_buildPresetList(),
const SizedBox(height: 18),
_buildSectionTitle('编辑语音内容'),
_buildSectionTitle(
l10n.translate('robot_voice_v2.sec_edit'),
),
const SizedBox(height: 10),
_buildEditorCard(),
const SizedBox(height: 18),
_buildSectionTitle('实时喊话(第二阶段)'),
_buildSectionTitle(
l10n.translate('robot_voice_v2.sec_realtime'),
),
const SizedBox(height: 10),
_buildRealtimeCard(),
const SizedBox(height: 14),
const Center(
Center(
child: Text(
'预设语音保存在本机;语音合成为设备端能力,需设备支持后才会播报',
l10n.translate('robot_voice_v2.footer'),
textAlign: TextAlign.center,
style: TextStyle(fontSize: 10.5, color: _hint),
style: const TextStyle(fontSize: 10.5, color: _hint),
),
),
],
@@ -230,12 +281,12 @@ class _RobotVoicePresetPageV2State extends State<RobotVoicePresetPageV2> {
),
),
const SizedBox(width: 12),
const Expanded(
Expanded(
child: Text(
'预设语音管理',
AppLocalizations.of(context).translate('robot_voice_v2.title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _navy,
@@ -243,7 +294,7 @@ class _RobotVoicePresetPageV2State extends State<RobotVoicePresetPageV2> {
),
),
GestureDetector(
onTap: _todo,
onTap: () => _todo(),
behavior: HitTestBehavior.opaque,
child: Container(
width: 36,
@@ -303,11 +354,11 @@ class _RobotVoicePresetPageV2State extends State<RobotVoicePresetPageV2> {
GestureDetector(
onTap: _startCreate,
behavior: HitTestBehavior.opaque,
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 2, vertical: 4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4),
child: Text(
'+ 新增',
style: TextStyle(
AppLocalizations.of(context).translate('robot_voice_v2.add'),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: _blue,
@@ -449,6 +500,7 @@ class _RobotVoicePresetPageV2State extends State<RobotVoicePresetPageV2> {
// ===== 编辑语音内容 =====
Widget _buildEditorCard() {
final l10n = AppLocalizations.of(context);
return Container(
key: _editorKey,
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
@@ -460,38 +512,47 @@ class _RobotVoicePresetPageV2State extends State<RobotVoicePresetPageV2> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('语音名称', style: TextStyle(fontSize: 12.5, color: _gray)),
Text(
l10n.translate('robot_voice_v2.name_label'),
style: const TextStyle(fontSize: 12.5, color: _gray),
),
const SizedBox(height: 8),
_buildField(
controller: _nameCtrl,
focusNode: _nameFocus,
hint: '如:安全警示',
hint: l10n.translate('robot_voice_v2.name_hint'),
),
const SizedBox(height: 14),
const Text(
'语音文字(用户可编辑)',
style: TextStyle(fontSize: 12.5, color: _gray),
Text(
l10n.translate('robot_voice_v2.text_label'),
style: const TextStyle(fontSize: 12.5, color: _gray),
),
const SizedBox(height: 8),
_buildField(
controller: _textCtrl,
hint: '输入要播报的文字内容',
hint: l10n.translate('robot_voice_v2.text_hint'),
minLines: 4,
maxLines: 6,
),
const SizedBox(height: 12),
Row(
children: [
const Text(
'语言:中文简体',
style: TextStyle(fontSize: 12.5, color: _gray),
),
const Spacer(),
Text(
'语音合成/同步需设备支持',
style: TextStyle(
fontSize: 11.5,
color: _hint.withValues(alpha: 0.95),
l10n.translate('robot_voice_v2.language'),
style: const TextStyle(fontSize: 12.5, color: _gray),
),
const SizedBox(width: 12),
// 英文文案较长:可收缩换行,避免 Row 横向溢出
Expanded(
child: Text(
l10n.translate('robot_voice_v2.sync_note'),
textAlign: TextAlign.right,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11.5,
color: _hint.withValues(alpha: 0.95),
),
),
),
],
@@ -514,9 +575,9 @@ class _RobotVoicePresetPageV2State extends State<RobotVoicePresetPageV2> {
],
),
alignment: Alignment.center,
child: const Text(
'保存预设语音',
style: TextStyle(
child: Text(
l10n.translate('robot_voice_v2.save'),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: Colors.white,
@@ -574,6 +635,7 @@ class _RobotVoicePresetPageV2State extends State<RobotVoicePresetPageV2> {
// ===== 实时喊话(第二阶段)=====
Widget _buildRealtimeCard() {
final l10n = AppLocalizations.of(context);
return GestureDetector(
onTap: _realtimeTalk,
behavior: HitTestBehavior.opaque,
@@ -596,26 +658,30 @@ class _RobotVoicePresetPageV2State extends State<RobotVoicePresetPageV2> {
child: const Icon(Icons.mic_none_rounded, size: 21, color: _gray),
),
const SizedBox(width: 12),
const Expanded(
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'通过APP麦克风实时喊话',
l10n.translate('robot_voice_v2.realtime_title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
style: const TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w800,
color: _navy,
),
),
SizedBox(height: 4),
const SizedBox(height: 4),
Text(
'通过APP麦克风采集语音,实时下发设备播放',
l10n.translate('robot_voice_v2.realtime_sub'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, height: 1.35, color: _gray),
style: const TextStyle(
fontSize: 12,
height: 1.35,
color: _gray,
),
),
],
),
@@ -627,9 +693,9 @@ class _RobotVoicePresetPageV2State extends State<RobotVoicePresetPageV2> {
color: _softGray,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'待开放',
style: TextStyle(
child: Text(
l10n.translate('robot_voice_v2.badge_todo'),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: _gray,

View File

@@ -1,111 +1,89 @@
import 'package:flutter/material.dart';
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
import 'package:maibu_satabot_v2/core/di/injection.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 'work_ui.dart';
/// 自主作业记录(设计稿:右侧抽屉「自主作业记录」+「单次作业记录」)。
///
/// 记录数据为设计稿演示口径(作业记录接口未接入,页面内已标注「示例」);
/// 面板内部在「列表 ↔ 单次详情」间切换,对应设计稿的 ‹返回记录列表。
Future<void> showWorkRecordPanel(BuildContext context, {String? initialRoute}) {
/// 展示后端任务池和本机实际收到的完成记录,不使用设计稿示例数据。
Future<void> showWorkRecordPanel(
BuildContext context, {
required String deviceId,
}) {
return showWorkRightPanel(
context,
width: 470,
builder: (close) =>
_WorkRecordPanel(onClose: close, initialRoute: initialRoute),
builder: (close) => _WorkRecordPanel(deviceId: deviceId, onClose: close),
);
}
class _WorkRecordPanel extends StatefulWidget {
const _WorkRecordPanel({required this.onClose, this.initialRoute});
const _WorkRecordPanel({required this.deviceId, required this.onClose});
final String deviceId;
final VoidCallback onClose;
final String? initialRoute;
@override
State<_WorkRecordPanel> createState() => _WorkRecordPanelState();
}
class _WorkRecordPanelState extends State<_WorkRecordPanel> {
/// (路线, 状态, 时间范围, 明细, 状态样式)
static const List<(String, String, String, String, int)>
_records = <(String, String, String, String, int)>[
('A区 · 路线01', '已完成', '10月20日 09:10–10:02', '完成2.50亩 · 52分钟 · 进度100%', 0),
('B区 · 路线01', '人工停止', '10月19日 14:20–14:57', '完成1.34亩 · 37分钟 · 进度42%', 1),
('A区 · 路线02', '异常中断', '10月19日 09:02–09:44', '完成1.20亩 · 42分钟 · 进度51%', 2),
];
int _tab = 0;
int? _detailIndex;
late Future<WorkResult<List<WorkTaskSnapshot>>> _records = _load();
Future<WorkResult<List<WorkTaskSnapshot>>> _load() async {
final gateway = sl<AutonomousWorkGateway>();
if (gateway is WorkTaskHistorySource) {
return (gateway as WorkTaskHistorySource).loadTaskHistory(
deviceId: widget.deviceId,
);
}
return const WorkResult.fail('当前环境尚未提供真实作业记录');
}
@override
Widget build(BuildContext context) {
if (_detailIndex != null) {
return _buildDetail(_detailIndex!);
}
return _buildList();
}
// ===== 列表 =====
Widget _buildList() {
return WorkPanelScaffold(
title: '自主作业记录',
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
desc: '显示任务池中的结束任务及本机收到的完成通知;未提供的面积、耗时不作估算。',
body: FutureBuilder<WorkResult<List<WorkTaskSnapshot>>>(
future: _records,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final result = snapshot.data!;
if (!result.isOk) return Text(result.error ?? '记录加载失败');
final records = result.data ?? const <WorkTaskSnapshot>[];
if (records.isEmpty) return const Text('该设备暂无可查询的作业记录');
return Column(
children: [
_tabChip('任务历史', 0),
const SizedBox(width: 8),
_tabChip('效率分析', 1),
for (final task in records)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: WorkCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.routeName ?? '任务 #${task.taskId}',
style: const TextStyle(fontWeight: FontWeight.w700),
),
const SizedBox(height: 6),
Text(task.rawStatus == 'FINISH' ? '已完成' : '已终止'),
Text('任务 ID:${task.taskId}'),
if (task.createdAt != null)
Text('下发时间:${task.createdAt}'),
Text('已接收到达点:${task.completedPoints.length} 个'),
],
),
),
),
],
),
const SizedBox(height: 14),
if (_tab == 0) ...[
const Row(
children: [
WorkStatCard(value: '3', label: '历史任务(示例)'),
SizedBox(width: 10),
WorkStatCard(value: '5.04亩', label: '累计完成面积'),
SizedBox(width: 10),
WorkStatCard(value: '1', label: '人工停止'),
],
),
const SizedBox(height: 12),
const Text(
'可按日期、区域、路线和结果筛选;手动停止的任务也必须保留已完成轨迹。',
style: TextStyle(fontSize: 11.5, height: 1.6, color: kWtGray2),
),
const SizedBox(height: 12),
for (var i = 0; i < _records.length; i++) ...[
_recordRow(i),
const SizedBox(height: 8),
],
] else ...[
const Row(
children: [
WorkStatCard(value: '2.4亩/时', label: '平均作业效率'),
SizedBox(width: 10),
WorkStatCard(value: '86%', label: '平均完成率'),
SizedBox(width: 10),
WorkStatCard(value: '12%', label: '任务中断率'),
],
),
const SizedBox(height: 12),
const WorkNoteBar(
text: '效率分析为演示口径,接入作业统计接口后替换。',
background: kWtSoftBlue,
foreground: kWtBlue,
),
],
],
);
},
),
footer: Row(
children: [
Expanded(
child: WorkButton(
label: '筛选 / 导出',
onTap: () => CapsuleToast.show('记录筛选与导出暂未开发', showCheck: false),
label: '刷新记录',
onTap: () => setState(() => _records = _load()),
),
),
const SizedBox(width: 12),
@@ -120,274 +98,4 @@ class _WorkRecordPanelState extends State<_WorkRecordPanel> {
),
);
}
Widget _tabChip(String label, int index) {
final selected = _tab == index;
return InkWell(
onTap: () => setState(() => _tab = index),
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
decoration: BoxDecoration(
color: selected ? kWtBlue : Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: selected ? Colors.transparent : kWtBorder),
),
child: Text(
label,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: selected ? Colors.white : kWtGray2,
),
),
),
);
}
Widget _recordRow(int index) {
final record = _records[index];
final (bg, fg) = _statusStyle(record.$5);
return InkWell(
onTap: () => setState(() => _detailIndex = index),
borderRadius: BorderRadius.circular(14),
child: WorkCard(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
record.$1,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
color: kWtNavy,
),
),
),
WorkChip(text: record.$2, background: bg, foreground: fg),
],
),
const SizedBox(height: 6),
Text(
record.$3,
style: const TextStyle(fontSize: 11.5, color: kWtGray),
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Text(
record.$4,
style: const TextStyle(fontSize: 11.5, color: kWtGray2),
),
),
const Text(
'查看详情 ›',
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w700,
color: kWtBlue,
),
),
],
),
],
),
),
);
}
(Color, Color) _statusStyle(int kind) {
switch (kind) {
case 0:
return (kWtGreenBg, kWtGreen);
case 1:
return (kWtWarnBg, kWtWarn);
default:
return (const Color(0xFFFDEAEA), kWtDanger);
}
}
// ===== 单次详情(以「B区 · 路线01 / 人工停止」为例,与设计稿一致)=====
Widget _buildDetail(int index) {
final record = _records[index];
final (bg, fg) = _statusStyle(record.$5);
return WorkPanelScaffold(
title: '单次作业记录',
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () => setState(() => _detailIndex = null),
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: kWtSoftBlue,
borderRadius: BorderRadius.circular(10),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.arrow_back_ios_new_rounded,
size: 12,
color: kWtBlue,
),
SizedBox(width: 6),
Text(
'返回记录列表',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: kWtBlue,
),
),
],
),
),
),
const SizedBox(height: 14),
Text(
record.$1,
style: const TextStyle(
fontSize: 15.5,
fontWeight: FontWeight.w800,
color: kWtNavy,
),
),
const SizedBox(height: 8),
Row(
children: [
const WorkChip(
text: 'RW-1019-02',
background: kWtSoftBlue,
foreground: kWtBlue,
),
const SizedBox(width: 8),
WorkChip(text: record.$2, background: bg, foreground: fg),
],
),
const SizedBox(height: 14),
const Row(
children: [
WorkStatCard(value: '1.34亩', label: '实际完成面积'),
SizedBox(width: 10),
WorkStatCard(value: '37分钟', label: '执行时长'),
SizedBox(width: 10),
WorkStatCard(value: '42%', label: '最终进度'),
],
),
const SizedBox(height: 16),
const WorkSectionTitle('任务信息'),
const WorkCard(
padding: EdgeInsets.symmetric(horizontal: 14, vertical: 6),
child: Column(
children: [
WorkLabelRow(label: '路线版本', value: 'v1.3'),
WorkLabelRow(label: '任务时间', value: '10月19日 14:20–14:57'),
WorkLabelRow(label: '结束状态', value: '人工停止'),
WorkLabelRow(label: '结果说明', value: '用户主动停止,已完成区域保留'),
WorkLabelRow(label: '轨迹保存', value: '已保留已完成部分(示例)'),
],
),
),
const SizedBox(height: 16),
const WorkSectionTitle('执行事件'),
const _EventItem(title: '任务开始', subtitle: '定位与安全条件通过', last: false),
const _EventItem(title: '执行路径', subtitle: '割刀随任务启停', last: true),
],
),
footer: Row(
children: [
Expanded(
child: WorkButton(
label: '查看地图轨迹',
onTap: () => CapsuleToast.show('轨迹回放暂未开发', showCheck: false),
),
),
const SizedBox(width: 12),
Expanded(
child: WorkButton(
label: '导出报告',
primary: true,
onTap: () => CapsuleToast.show('报告导出暂未开发', showCheck: false),
),
),
],
),
);
}
}
class _EventItem extends StatelessWidget {
const _EventItem({
required this.title,
required this.subtitle,
required this.last,
});
final String title;
final String subtitle;
final bool last;
@override
Widget build(BuildContext context) {
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
Container(
width: 10,
height: 10,
margin: const EdgeInsets.only(top: 3),
decoration: const BoxDecoration(
color: kWtBlue,
shape: BoxShape.circle,
),
),
if (!last)
Expanded(
child: Container(
width: 2,
margin: const EdgeInsets.symmetric(vertical: 3),
color: kWtBorder,
),
),
],
),
const SizedBox(width: 10),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: last ? 0 : 15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: kWtNavy,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(fontSize: 11.5, color: kWtGray),
),
],
),
),
),
],
),
);
}
}

View File

@@ -12,11 +12,9 @@ import 'work_ui.dart';
/// 用结构体而不是字符串哨兵,是为了后续新增结果类型不破坏调用方。
class WorkRoutePanelResult {
const WorkRoutePanelResult.select(WorkRouteOption this.route)
: editRequested = false;
: editRequested = false;
const WorkRoutePanelResult.edit()
: route = null,
editRequested = true;
const WorkRoutePanelResult.edit() : route = null, editRequested = true;
final WorkRouteOption? route;
final bool editRequested;
@@ -91,9 +89,7 @@ class _RouteLibraryPanelState extends State<_RouteLibraryPanel> {
setState(() {
_loading = false;
_routes = result.data ?? const <WorkRouteOption>[];
_error = _routes.isEmpty
? (result.error ?? '该场站下暂无作业路线')
: null;
_error = _routes.isEmpty ? (result.error ?? '该场站下暂无作业路线') : null;
if (_selectedId != null && !_routes.any((r) => r.id == _selectedId)) {
_selectedId = null;
}
@@ -306,7 +302,14 @@ class _TaskQueuePanelState extends State<_TaskQueuePanel> {
@override
void initState() {
super.initState();
_sub = _gateway.watchTask(deviceId: widget.deviceId).listen(_onSnapshot);
final gateway = _gateway;
_sub =
(gateway is WorkTaskQueueSource
? (gateway as WorkTaskQueueSource).watchTaskChanges(
deviceId: widget.deviceId,
)
: gateway.watchTask(deviceId: widget.deviceId))
.listen(_onSnapshot);
_load();
}
@@ -335,14 +338,11 @@ class _TaskQueuePanelState extends State<_TaskQueuePanel> {
if (!mounted) return;
setState(() {
if (snapshot == null) {
_tasks = _tasks
.where(
(t) =>
t.state != WorkRunState.queued &&
t.state != WorkRunState.running &&
t.state != WorkRunState.paused,
)
.toList();
// 当前任务为空不代表其它排队任务也被取消;列表以 loadTasks 为准。
return;
}
if (snapshot.state == WorkRunState.finished) {
_tasks = _tasks.where((t) => t.taskId != snapshot.taskId).toList();
return;
}
final index = _tasks.indexWhere((t) => t.taskId == snapshot.taskId);

View File

@@ -14,12 +14,22 @@ import '../../../../pv_control/presentation/pages/pv_control_page.dart';
/// 保留可扩展:将来要按账号属性动态决定「默认进哪个模式 / 展示哪几个模式」,
/// 只需在此枚举增删值即可,不影响前置信息获取与原极简页逻辑。
enum _RobotControlMode {
minimal('极简模式', '传统远程遥控界面', Icons.videogame_asset_outlined),
full('全功能模式', '横屏大屏可视化控制', Icons.grid_view_rounded);
minimal(
'remote_control.mode_minimal',
'remote_control.mode_minimal_sub',
Icons.videogame_asset_outlined,
),
full(
'remote_control.mode_full',
'remote_control.mode_full_sub',
Icons.grid_view_rounded,
);
const _RobotControlMode(this.title, this.subtitle, this.icon);
final String title;
final String subtitle;
const _RobotControlMode(this.titleKey, this.subtitleKey, this.icon);
/// 标题 / 副标题的语言包 key(在弹窗 builder 内按当前语言翻译)
final String titleKey;
final String subtitleKey;
final IconData icon;
}
@@ -257,10 +267,11 @@ class RobotControlPanel extends StatelessWidget {
/// 模式选择弹窗:极简 / 全功能两个选项,选中即返回对应模式,
/// 点外部或返回手势取消则返回 null。选项由枚举驱动,仅本文件使用。
Future<_RobotControlMode?> _showControlModePicker(BuildContext context) {
final l10n = AppLocalizations.of(context);
return showDialog<_RobotControlMode>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('选择控制模式'),
title: Text(l10n.translate('remote_control.mode_picker_title')),
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
content: SizedBox(
width: 320,
@@ -271,14 +282,14 @@ class RobotControlPanel extends StatelessWidget {
ListTile(
leading: Icon(m.icon, color: context.appColors.primary),
title: Text(
m.title,
l10n.translate(m.titleKey),
style: TextStyle(
fontWeight: FontWeight.bold,
color: context.appColors.textPrimary,
),
),
subtitle: Text(
m.subtitle,
l10n.translate(m.subtitleKey),
style: TextStyle(color: context.appColors.textTertiary),
),
onTap: () => Navigator.of(dialogContext).pop(m),

View File

@@ -11,6 +11,10 @@ class RobotItemCard extends StatelessWidget {
final String status;
final double battery;
final String task;
/// 型号 · 版本(如 MC700 · PRO 旗舰版),由列表页从 productModel 解析后传入
final String? modelText;
final VoidCallback onTap;
const RobotItemCard({
@@ -22,6 +26,7 @@ class RobotItemCard extends StatelessWidget {
required this.status,
required this.battery,
required this.task,
this.modelText,
required this.onTap,
});
@@ -93,6 +98,19 @@ class RobotItemCard extends StatelessWidget {
maxLines: 2,
softWrap: true,
),
if (modelText != null && modelText!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
modelText!,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: context.appColors.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import '../../data/models/robot_data_model.dart';
@@ -104,7 +105,9 @@ class RobotSummaryCardV2 extends StatelessWidget {
),
const SizedBox(height: 6),
Text(
'${modelOf(robot)} · STD 标准版',
// 型号 · 版本:deviceType 排在 PRO / PLUS / STD 之前,
// 版本由 productModel 解析,不再写死 STD
robot.modelLabelOf(AppLocalizations.of(context)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11.5, color: _gray),

View File

@@ -39,7 +39,10 @@ class RobotTaskInfo extends StatelessWidget {
children: [
Expanded(
child: Text(
robot['task'] ?? '逆变器区巡检',
robot['task'] ??
AppLocalizations.of(
context,
).translate('device_list_v2.task_default'),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import '../../data/models/robot_data_model.dart';
import '../../data/voice_preset_store.dart';
@@ -55,15 +56,21 @@ class _VoiceBroadcastSheetState extends State<_VoiceBroadcastSheet> {
List<VoicePreset> _presets = const [];
bool _loading = true;
bool _loadStarted = false;
@override
void initState() {
super.initState();
_load();
void didChangeDependencies() {
super.didChangeDependencies();
if (_loadStarted) return;
_loadStarted = true;
_load(AppLocalizations.of(context));
}
Future<void> _load() async {
final presets = await VoicePresetStore.loadPresets(widget.robot.name);
Future<void> _load(AppLocalizations l10n) async {
final presets = await VoicePresetStore.loadPresets(
widget.robot.name,
fallback: localizedVoiceDefaults(l10n),
);
if (!mounted) return;
setState(() {
_presets = presets;
@@ -77,7 +84,10 @@ class _VoiceBroadcastSheetState extends State<_VoiceBroadcastSheet> {
widget.onPlay!(preset);
return;
}
CapsuleToast.show('试听需设备端语音合成,暂未接入', showCheck: false);
CapsuleToast.show(
AppLocalizations.of(context).translate('robot_voice_v2.toast_preview'),
showCheck: false,
);
}
Future<void> _manage() async {
@@ -93,11 +103,12 @@ class _VoiceBroadcastSheetState extends State<_VoiceBroadcastSheet> {
),
);
if (!mounted) return;
_load();
_load(AppLocalizations.of(context));
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return SafeArea(
top: false,
child: Container(
@@ -113,10 +124,10 @@ class _VoiceBroadcastSheetState extends State<_VoiceBroadcastSheet> {
children: [
Row(
children: [
const Expanded(
Expanded(
child: Text(
'语音喊话',
style: TextStyle(
l10n.translate('robot_voice_v2.sheet_title'),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: _navy,
@@ -149,14 +160,14 @@ class _VoiceBroadcastSheetState extends State<_VoiceBroadcastSheet> {
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _blue, width: 1.2),
),
child: const Row(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.edit_outlined, size: 17, color: _blue),
SizedBox(width: 7),
const Icon(Icons.edit_outlined, size: 17, color: _blue),
const SizedBox(width: 7),
Text(
'管理/编辑预设语音',
style: TextStyle(
l10n.translate('robot_voice_v2.sheet_manage'),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: _blue,
@@ -177,10 +188,9 @@ class _VoiceBroadcastSheetState extends State<_VoiceBroadcastSheet> {
color: _noteBg,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'后续开放按住麦克风实时喊话;预设语音永久保留。'
'该模式不自动开麦,也不要求进入视频监看。',
style: TextStyle(
child: Text(
l10n.translate('robot_voice_v2.sheet_note'),
style: const TextStyle(
fontSize: 11.5,
height: 1.55,
color: _noteText,
@@ -195,7 +205,11 @@ class _VoiceBroadcastSheetState extends State<_VoiceBroadcastSheet> {
}
Widget _buildTabBar() {
const labels = ['预设语音 · 首期', '实时喊话 · 后续'];
final l10n = AppLocalizations.of(context);
final labels = [
l10n.translate('robot_voice_v2.tab_preset'),
l10n.translate('robot_voice_v2.tab_realtime'),
];
return Container(
height: 46,
padding: const EdgeInsets.all(4),
@@ -210,7 +224,10 @@ class _VoiceBroadcastSheetState extends State<_VoiceBroadcastSheet> {
child: GestureDetector(
onTap: () {
if (index == 1) {
CapsuleToast.show('实时喊话将在后续开放', showCheck: false);
CapsuleToast.show(
l10n.translate('robot_voice_v2.toast_realtime_later'),
showCheck: false,
);
}
setState(() => _tab = index);
},
@@ -358,6 +375,7 @@ class _VoiceBroadcastSheetState extends State<_VoiceBroadcastSheet> {
}
Widget _buildRealtimePanel() {
final l10n = AppLocalizations.of(context);
return Container(
padding: const EdgeInsets.fromLTRB(14, 16, 14, 16),
decoration: BoxDecoration(
@@ -376,19 +394,19 @@ class _VoiceBroadcastSheetState extends State<_VoiceBroadcastSheet> {
child: const Icon(Icons.mic_none_rounded, size: 28, color: _gray),
),
const SizedBox(height: 12),
const Text(
'按住麦克风实时喊话',
style: TextStyle(
Text(
l10n.translate('robot_voice_v2.hold_title'),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: _navy,
),
),
const SizedBox(height: 6),
const Text(
'后续开放:长按说话,松开立即下发设备播放',
Text(
l10n.translate('robot_voice_v2.hold_sub'),
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, height: 1.5, color: _gray),
style: const TextStyle(fontSize: 12, height: 1.5, color: _gray),
),
const SizedBox(height: 12),
Container(
@@ -397,9 +415,9 @@ class _VoiceBroadcastSheetState extends State<_VoiceBroadcastSheet> {
color: const Color(0xFFF4F6F9),
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'待开放',
style: TextStyle(
child: Text(
l10n.translate('robot_voice_v2.badge_todo'),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: _gray,