远程遥控相关
This commit is contained in:
@@ -7,6 +7,11 @@ class CapsuleToast {
|
||||
static OverlayEntry? _entry;
|
||||
static bool _showing = false;
|
||||
|
||||
/// 弹出代数:每次真正插入新胶囊时递增。
|
||||
/// 自动消失的定时器只允许关闭「自己这一代」的胶囊,
|
||||
/// 否则旧提示的定时器会把后弹的新提示提前移除(连点时新 toast 一闪即没)。
|
||||
static int _generation = 0;
|
||||
|
||||
/// 显示胶囊提示
|
||||
/// [message] 提示文案
|
||||
/// [duration] 显示时长,默认 2 秒
|
||||
@@ -49,6 +54,7 @@ class CapsuleToast {
|
||||
_entry?.remove();
|
||||
_showing = false;
|
||||
}
|
||||
final int gen = ++_generation;
|
||||
|
||||
_entry = OverlayEntry(
|
||||
builder: (context) => IgnorePointer(
|
||||
@@ -56,7 +62,8 @@ class CapsuleToast {
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 64),
|
||||
// 左右留白:英文文案明显长于中文,限制胶囊最大宽度避免贴边溢出
|
||||
margin: const EdgeInsets.only(top: 64, left: 24, right: 24),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xE62C2C2C),
|
||||
@@ -80,12 +87,18 @@ class CapsuleToast {
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
// 长文案(尤其英文)需要可收缩并换行,否则 Row 会横向溢出
|
||||
Flexible(
|
||||
child: Text(
|
||||
message,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -100,6 +113,8 @@ class CapsuleToast {
|
||||
_showing = true;
|
||||
|
||||
Future.delayed(duration, () {
|
||||
// 期间已弹出过更新的提示 → 本定时器属于旧一代,不能误杀新胶囊
|
||||
if (gen != _generation) return;
|
||||
if (_showing) {
|
||||
_entry?.remove();
|
||||
_showing = false;
|
||||
|
||||
56
lib/components/control_mode_text.dart
Normal file
56
lib/components/control_mode_text.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
|
||||
/// 控制模式(`RunningStatusEntity.controlMode`)→ 界面文案的唯一映射。
|
||||
///
|
||||
/// 为什么要单独一个文件:这个值以前在 7 个页面各写一遍「== 3 ? 远程 : 本地」,
|
||||
/// 而数据层又把「后端没给」的默认值写成 `'0'`,两个错误叠在一起,结果是
|
||||
/// 设备从来没上报过控制模式时,界面一本正经地显示「本地控制」——假信号。
|
||||
///
|
||||
/// 协议定义(2026-11 后端重新确认,覆盖早先「0=无控制 / 2=蓝牙 / 4=其他接口」那版):
|
||||
/// 空串 / null → 「未知」:后端根本没给这个字段
|
||||
/// 0 → 未知
|
||||
/// 1 → 本地控制
|
||||
/// 2 → 自主任务
|
||||
/// 3 → 远程控制(远程遥控)
|
||||
/// 其它数字 → 「未知(6)」:协议外的值,不猜它是什么意思
|
||||
/// 非数字字符串 → 原样显示:后端给什么展示什么
|
||||
///
|
||||
/// [controlModeKey] 只返回已定义的 0~4,其余为 null,供各页面沿用本页的
|
||||
/// 翻译器与自定义文案接;需要一步拿到最终文本用 [controlModeDisplayText]。
|
||||
|
||||
/// 已定义的协议值 → 多语言 key;空值、非数字、协议外数字返回 null。
|
||||
String? controlModeKey(String? raw) {
|
||||
final int? n = controlModeValue(raw);
|
||||
if (n == null) return null;
|
||||
return switch (n) {
|
||||
0 => 'common.unknown',
|
||||
1 => 'running_status.local_control',
|
||||
2 => 'running_status.autonomous_task',
|
||||
3 => 'running_status.remote_control',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// 原始值 → 协议数值。空串/非数字返回 null,表示「没有可信的控制模式数据」。
|
||||
int? controlModeValue(String? raw) {
|
||||
final String v = (raw ?? '').trim();
|
||||
if (v.isEmpty) return null;
|
||||
return int.tryParse(v);
|
||||
}
|
||||
|
||||
/// 直接得到可显示文案(含未知/协议外/非数字的兜底)。
|
||||
String controlModeDisplayText(AppLocalizations l10n, String? raw) {
|
||||
final String? key = controlModeKey(raw);
|
||||
if (key != null) return l10n.translate(key);
|
||||
|
||||
final String v = (raw ?? '').trim();
|
||||
if (v.isEmpty) return l10n.translate('common.unknown');
|
||||
|
||||
final int? n = int.tryParse(v);
|
||||
// 协议外的数字:带上原值,现场排查时能看出后端到底发了什么
|
||||
if (n != null) {
|
||||
return '${l10n.translate('common.unknown')}($n)';
|
||||
}
|
||||
// 后端给的不是数字:按它给的显示,不做任何猜测
|
||||
return v;
|
||||
}
|
||||
@@ -152,18 +152,19 @@ class BleProtocolDecoder {
|
||||
parts.add('');
|
||||
}
|
||||
|
||||
// 协议定义 控制模式: 0无控制 1本地遥控 2蓝牙 3TCP 4其他接口;
|
||||
// 协议定义 控制模式: 0未知 1本地 2自主任务 3远程遥控(2026-11 后端重新确认,
|
||||
// 覆盖早先 0无控制/1本地遥控/2蓝牙/3TCP/4其他接口 那版;
|
||||
// 与 lib/components/control_mode_text.dart 保持同一口径);
|
||||
// 先按整数解析(容忍前后空格),有映射的一律显示对应文字;
|
||||
// 超出协议范围的码(如固件扩展值)显示为 未知(n),避免被误当成有效模式
|
||||
String controlModeText(String v) {
|
||||
final n = int.tryParse(v.trim());
|
||||
if (n == null) return v.isEmpty ? '--' : v;
|
||||
return switch (n) {
|
||||
0 => '无控制',
|
||||
1 => '本地遥控',
|
||||
2 => '蓝牙',
|
||||
3 => 'TCP',
|
||||
4 => '其他接口',
|
||||
0 => '未知',
|
||||
1 => '本地',
|
||||
2 => '自主任务',
|
||||
3 => '远程遥控',
|
||||
_ => '未知($n)',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import '../../../features/devices/presentation/bloc/device_status_bloc.dart';
|
||||
import '../../../features/devices/presentation/bloc/device_status_state.dart';
|
||||
import '../../../features/device_control_mode/domain/entities/device_control_mode.dart';
|
||||
|
||||
/// 适配设备详情「运行概况」的同一个实时源;不另建 MQTT 连接或猜测模式。
|
||||
class DeviceControlModeRealtimeAdapter {
|
||||
DeviceControlModeRealtimeAdapter(this.statusBloc);
|
||||
final DeviceStatusBloc statusBloc;
|
||||
|
||||
Stream<DeviceControlModeReading> watch(String deviceId) {
|
||||
return Stream<DeviceControlModeReading>.multi((controller) {
|
||||
void forward(DeviceStatusState state) {
|
||||
final belongsToDevice = statusBloc.listeningDeviceSn == deviceId;
|
||||
controller.add(
|
||||
DeviceControlModeReading(
|
||||
deviceId: deviceId,
|
||||
rawMode: belongsToDevice && state is DeviceStatusUpdated
|
||||
? state.status.controlMode
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final subscription = statusBloc.stream.listen(
|
||||
forward,
|
||||
onError: controller.addError,
|
||||
);
|
||||
controller.onCancel = subscription.cancel;
|
||||
// 换设备时 Bloc 同步清空旧缓存;同设备保留最新快照,与设备详情一致。
|
||||
statusBloc.startMqttRealtimeListening(deviceId);
|
||||
forward(statusBloc.state);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,18 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../entities/work_session_models.dart';
|
||||
import '../entities/return_plan_models.dart';
|
||||
|
||||
/// 可查询真实任务记录的新版实现;未实现时页面明确显示不可用。
|
||||
abstract interface class WorkTaskHistorySource {
|
||||
Future<WorkResult<List<WorkTaskSnapshot>>> loadTaskHistory({
|
||||
required String deviceId,
|
||||
});
|
||||
}
|
||||
|
||||
abstract interface class WorkTaskQueueSource {
|
||||
Stream<WorkTaskSnapshot?> watchTaskChanges({required String deviceId});
|
||||
}
|
||||
|
||||
/// 自主作业的业务门面。
|
||||
///
|
||||
@@ -46,6 +58,9 @@ abstract class AutonomousWorkGateway {
|
||||
///
|
||||
/// - [path]:作业几何;[outer]:作业区边界(自定义模式传空)
|
||||
/// - [planModel]:0 = 覆盖模式、2 = 自定义模式(与 mode 字段同源)
|
||||
/// - [routeType]:后端 workRecord.routeType,取 RouteType 枚举的 code
|
||||
/// (如 'MOWING_TASK')。后端必填、与 mode 平级,缺失会报「未选择规划类型」;
|
||||
/// 这里用 String 而不是枚举,避免 domain 层反向依赖 presentation。
|
||||
/// - [imageBase64]:地图截图(jpg,不带 data url 前缀),截图失败传 null
|
||||
///
|
||||
/// 环境差异(接口地址/鉴权/截图是否必需)全部封在实现里,页面只负责取几何。
|
||||
@@ -55,9 +70,29 @@ abstract class AutonomousWorkGateway {
|
||||
required List<LatLng> path,
|
||||
required List<LatLng> outer,
|
||||
required int planModel,
|
||||
required String routeType,
|
||||
String? imageBase64,
|
||||
});
|
||||
|
||||
/// 保存一键返航方案(返航点 + 线上禁行区)→ /system/siteMapInfo/save。
|
||||
///
|
||||
/// 接口地址、后端字段名(size_x / tempObstaclePoints / source 等)、
|
||||
/// 鉴权与 id 语义(有 id=更新、无 id=新建)全部封在实现里;
|
||||
/// 页面只交出业务数据 [ReturnPlanDraft]。
|
||||
/// 返回 data = 后端回写的 siteMapInfo 主键(拿不到则为 null,仅表示无法回填更新)。
|
||||
Future<WorkResult<int?>> saveReturnPlan(ReturnPlanDraft draft);
|
||||
|
||||
/// 查询某场站已有的一键返航方案 → /system/siteMapInfo/selectBySiteId。
|
||||
///
|
||||
/// 一个场站业务上只允许一条方案,接口直接返回带完整几何的单体:
|
||||
/// - 有 → data 为回填好的 [ReturnPlanDraft](id 非空 = 修改态)
|
||||
/// - 无 → data 为 null(进入空白新建态)
|
||||
/// 接口地址 / 后端字段名(size_x / tempObstaclePoints 等)/ 鉴权全部封在实现里,
|
||||
/// 页面只按 data 是否为 null 决定「修改」还是「新建」。
|
||||
Future<WorkResult<ReturnPlanDraft?>> fetchReturnPlanBySiteId({
|
||||
required int siteId,
|
||||
});
|
||||
|
||||
/// 当前任务实时状态流(null 表示设备上没有活跃任务)
|
||||
Stream<WorkTaskSnapshot?> watchTask({required String deviceId});
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../../../core/consts/http_api_consts.dart';
|
||||
import '../../../../core/logging/i_logger_service.dart';
|
||||
import '../../domain/entities/device_control_mode.dart';
|
||||
|
||||
class ControlModeRemoteDatasource {
|
||||
ControlModeRemoteDatasource(this.dio, this.logger);
|
||||
final Dio dio;
|
||||
final ILoggerService logger;
|
||||
|
||||
Future<void> change(String deviceId, ControlModeTarget target) async {
|
||||
final parameters = {'deviceId': deviceId, 'controlMode': target.value};
|
||||
final requestId = DateTime.now().microsecondsSinceEpoch.toString();
|
||||
final clock = Stopwatch()..start();
|
||||
final uri = Uri.parse(HttpApiConsts.hostModeChange).replace(
|
||||
queryParameters: parameters.map((key, value) => MapEntry(key, '$value')),
|
||||
);
|
||||
void log(String stage, Map<String, Object?> details) {
|
||||
try {
|
||||
logger.logWithLevel(
|
||||
'[控制模式切换][$stage] ${jsonEncode({'requestId': requestId, 'deviceId': deviceId, 'targetMode': target.value, 'elapsedMs': clock.elapsedMilliseconds, ...details})}',
|
||||
shouldLog: true,
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint('[控制模式切换][日志输出异常] requestId=$requestId, error=$error');
|
||||
}
|
||||
}
|
||||
|
||||
log('请求', {
|
||||
'method': 'GET',
|
||||
'url': uri.toString(),
|
||||
'parameters': parameters,
|
||||
'deviceIdType': deviceId.runtimeType.toString(),
|
||||
'controlModeType': target.value.runtimeType.toString(),
|
||||
});
|
||||
try {
|
||||
final response = await dio.get(
|
||||
HttpApiConsts.hostModeChange,
|
||||
queryParameters: parameters,
|
||||
options: Options(receiveTimeout: const Duration(seconds: 10)),
|
||||
);
|
||||
final body = response.data;
|
||||
log('响应', {
|
||||
'httpStatus': response.statusCode,
|
||||
'body': body,
|
||||
'bodyType': body.runtimeType.toString(),
|
||||
if (body is Map) 'businessCode': body['code'],
|
||||
if (body is Map) 'message': body['msg'],
|
||||
});
|
||||
if (response.statusCode != 200 ||
|
||||
body is! Map ||
|
||||
'${body['code']}' != '200' ||
|
||||
body['data'] == false) {
|
||||
throw Exception(body is Map ? body['msg'] ?? '模式切换失败' : '模式切换响应格式错误');
|
||||
}
|
||||
log('结果', {'success': true, 'note': '接口成功,允许进入页面;实时模式仍以设备上报为准,不等待回推'});
|
||||
} catch (error, stack) {
|
||||
log('异常', {
|
||||
'errorType': error.runtimeType.toString(),
|
||||
'error': '$error',
|
||||
if (error is DioException) 'dioType': error.type.name,
|
||||
if (error is DioException) 'httpStatus': error.response?.statusCode,
|
||||
if (error is DioException) 'body': error.response?.data,
|
||||
'stack': '$stack',
|
||||
});
|
||||
rethrow;
|
||||
} finally {
|
||||
clock.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import '../../../../core/error/failure.dart';
|
||||
import '../../domain/entities/device_control_mode.dart';
|
||||
import '../../domain/repositories/device_control_mode_repository.dart';
|
||||
import '../datasources/control_mode_remote_datasource.dart';
|
||||
|
||||
class DeviceControlModeRepositoryImpl implements DeviceControlModeRepository {
|
||||
DeviceControlModeRepositoryImpl({
|
||||
required this.realtime,
|
||||
required this.remote,
|
||||
});
|
||||
final Stream<DeviceControlModeReading> Function(String deviceId) realtime;
|
||||
final ControlModeRemoteDatasource remote;
|
||||
|
||||
@override
|
||||
Stream<DeviceControlModeReading> watch(String deviceId) => realtime(deviceId);
|
||||
|
||||
@override
|
||||
Future<Either<Failure, Unit>> change(
|
||||
String deviceId,
|
||||
ControlModeTarget target,
|
||||
) async {
|
||||
try {
|
||||
await remote.change(deviceId, target);
|
||||
return const Right(unit);
|
||||
} catch (error) {
|
||||
return Left(Failure(error.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
enum ControlModeTarget {
|
||||
autonomous(2),
|
||||
remote(3);
|
||||
|
||||
const ControlModeTarget(this.value);
|
||||
final int value;
|
||||
}
|
||||
|
||||
/// 仅来自当前设备实时状态,不使用设备列表的静态字段。
|
||||
class DeviceControlModeReading {
|
||||
const DeviceControlModeReading({required this.deviceId, this.rawMode});
|
||||
final String deviceId;
|
||||
final String? rawMode;
|
||||
|
||||
int? get value => int.tryParse(rawMode?.trim() ?? '');
|
||||
bool get hasReportedMode => rawMode?.trim().isNotEmpty == true;
|
||||
bool matches(ControlModeTarget target) => value == target.value;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import '../../../../core/error/failure.dart';
|
||||
import '../entities/device_control_mode.dart';
|
||||
|
||||
abstract interface class DeviceControlModeRepository {
|
||||
Stream<DeviceControlModeReading> watch(String deviceId);
|
||||
Future<Either<Failure, Unit>> change(
|
||||
String deviceId,
|
||||
ControlModeTarget target,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import '../../../../core/error/failure.dart';
|
||||
import '../entities/device_control_mode.dart';
|
||||
import '../repositories/device_control_mode_repository.dart';
|
||||
|
||||
class WatchDeviceControlModeUseCase {
|
||||
const WatchDeviceControlModeUseCase(this.repository);
|
||||
final DeviceControlModeRepository repository;
|
||||
Stream<DeviceControlModeReading> call(String deviceId) =>
|
||||
repository.watch(deviceId.trim());
|
||||
}
|
||||
|
||||
class ChangeDeviceControlModeUseCase {
|
||||
const ChangeDeviceControlModeUseCase(this.repository);
|
||||
final DeviceControlModeRepository repository;
|
||||
|
||||
Future<Either<Failure, Unit>> call(
|
||||
String deviceId,
|
||||
ControlModeTarget target,
|
||||
) {
|
||||
final id = deviceId.trim();
|
||||
if (id.isEmpty) return Future.value(Left(Failure('设备编码不能为空')));
|
||||
return repository.change(id, target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../domain/entities/device_control_mode.dart';
|
||||
import '../../domain/usecases/device_control_mode_usecases.dart';
|
||||
|
||||
enum ControlModeEntryPhase { ready, submitting, failed, confirmed }
|
||||
|
||||
class ControlModeEntryState {
|
||||
const ControlModeEntryState({
|
||||
this.phase = ControlModeEntryPhase.ready,
|
||||
this.reading,
|
||||
this.error,
|
||||
this.errorKey,
|
||||
});
|
||||
final ControlModeEntryPhase phase;
|
||||
final DeviceControlModeReading? reading;
|
||||
final String? error;
|
||||
final String? errorKey;
|
||||
bool get busy => phase == ControlModeEntryPhase.submitting;
|
||||
bool get canConfirm =>
|
||||
!busy &&
|
||||
phase != ControlModeEntryPhase.confirmed &&
|
||||
errorKey != 'invalid_device';
|
||||
}
|
||||
|
||||
/// 用户确定后下发模式;接口成功即允许进入,不等待设备模式回推。
|
||||
/// 实时模式只展示设备上报值,不用请求目标覆盖它。
|
||||
class ControlModeEntryCubit extends Cubit<ControlModeEntryState> {
|
||||
ControlModeEntryCubit({
|
||||
required this.deviceId,
|
||||
required this.target,
|
||||
required this.watchMode,
|
||||
required this.changeMode,
|
||||
this.timeout = const Duration(seconds: 12),
|
||||
}) : super(const ControlModeEntryState());
|
||||
final String deviceId;
|
||||
final ControlModeTarget target;
|
||||
final WatchDeviceControlModeUseCase watchMode;
|
||||
final ChangeDeviceControlModeUseCase changeMode;
|
||||
final Duration timeout;
|
||||
StreamSubscription<DeviceControlModeReading>? _subscription;
|
||||
int _generation = 0;
|
||||
|
||||
void _trace(String stage, [Map<String, Object?> details = const {}]) {
|
||||
try {
|
||||
debugPrint(
|
||||
'[控制模式切换][$stage] ${jsonEncode({'time': DateTime.now().toIso8601String(), 'deviceId': deviceId, 'currentRawMode': state.reading?.rawMode, 'parsedMode': state.reading?.value, 'targetMode': target.value, 'phase': state.phase.name, 'confirmEnabled': state.canConfirm, ...details})}',
|
||||
);
|
||||
} catch (_) {
|
||||
// 诊断输出不能影响模式下发。
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> start() async {
|
||||
++_generation;
|
||||
final previous = _subscription;
|
||||
if (previous != null) unawaited(previous.cancel());
|
||||
if (isClosed) return;
|
||||
emit(const ControlModeEntryState());
|
||||
_trace('开始读取实时模式');
|
||||
if (deviceId.trim().isEmpty) {
|
||||
_trace('设备编码为空,禁止下发');
|
||||
emit(
|
||||
const ControlModeEntryState(
|
||||
phase: ControlModeEntryPhase.failed,
|
||||
errorKey: 'invalid_device',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
_subscription = watchMode(deviceId).listen(
|
||||
_onReading,
|
||||
onError: (Object error) {
|
||||
if (!isClosed &&
|
||||
!state.busy &&
|
||||
state.phase != ControlModeEntryPhase.confirmed) {
|
||||
emit(
|
||||
ControlModeEntryState(
|
||||
phase: ControlModeEntryPhase.failed,
|
||||
reading: state.reading,
|
||||
error: '$error',
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onReading(DeviceControlModeReading reading) {
|
||||
if (state.reading == null || state.reading?.rawMode != reading.rawMode) {
|
||||
_trace('实时模式收到', {
|
||||
'receivedDeviceId': reading.deviceId,
|
||||
'receivedRawMode': reading.rawMode,
|
||||
});
|
||||
}
|
||||
if (isClosed ||
|
||||
reading.deviceId != deviceId ||
|
||||
state.phase == ControlModeEntryPhase.confirmed)
|
||||
return;
|
||||
emit(
|
||||
ControlModeEntryState(
|
||||
phase: state.phase,
|
||||
reading: reading,
|
||||
error: state.error,
|
||||
errorKey: state.errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> confirm() async {
|
||||
_trace('点击确定');
|
||||
if (isClosed || !state.canConfirm) {
|
||||
_trace('点击被忽略', {
|
||||
'closed': isClosed,
|
||||
'reason': state.busy ? '请求发送中,防止重复下发' : '设备无效或页面已关闭',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (state.reading?.matches(target) == true) {
|
||||
_trace('模式已符合,跳过接口');
|
||||
_allowEntry('reported_match');
|
||||
return;
|
||||
}
|
||||
final generation = ++_generation;
|
||||
emit(
|
||||
ControlModeEntryState(
|
||||
phase: ControlModeEntryPhase.submitting,
|
||||
reading: state.reading,
|
||||
),
|
||||
);
|
||||
_trace('开始下发', {'deviceIdType': deviceId.runtimeType.toString()});
|
||||
try {
|
||||
final result = await changeMode(deviceId, target).timeout(timeout);
|
||||
if (isClosed || generation != _generation) {
|
||||
_trace('忽略迟到的接口结果', {'closed': isClosed});
|
||||
return;
|
||||
}
|
||||
result.fold((failure) {
|
||||
_trace('接口失败', {'message': failure.message});
|
||||
emit(
|
||||
ControlModeEntryState(
|
||||
phase: ControlModeEntryPhase.failed,
|
||||
reading: state.reading,
|
||||
error: failure.message,
|
||||
),
|
||||
);
|
||||
}, (_) => _allowEntry('http_success'));
|
||||
} on TimeoutException {
|
||||
_trace('请求超时', {'timeoutMs': timeout.inMilliseconds});
|
||||
if (!isClosed && generation == _generation) {
|
||||
emit(
|
||||
ControlModeEntryState(
|
||||
phase: ControlModeEntryPhase.failed,
|
||||
reading: state.reading,
|
||||
errorKey: 'request_timeout',
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
_trace('提交异常', {'error': '$error'});
|
||||
if (!isClosed && generation == _generation) {
|
||||
emit(
|
||||
ControlModeEntryState(
|
||||
phase: ControlModeEntryPhase.failed,
|
||||
reading: state.reading,
|
||||
error: '$error',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _allowEntry(String source) {
|
||||
_trace('允许进入', {
|
||||
'source': source,
|
||||
'note': source == 'http_success' ? '接口成功,不等待模式回推' : '实时模式已符合',
|
||||
});
|
||||
debugPrint(
|
||||
'[控制模式] 允许进入 deviceId=$deviceId, target=${target.value}, source=$source, reportedMode=${state.reading?.rawMode}',
|
||||
);
|
||||
emit(
|
||||
ControlModeEntryState(
|
||||
phase: ControlModeEntryPhase.confirmed,
|
||||
reading: state.reading,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_generation++;
|
||||
final cancelled = _subscription?.cancel();
|
||||
final closed = super.close();
|
||||
if (cancelled == null) return closed;
|
||||
return Future.wait<void>([cancelled, closed]).then((_) {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../components/control_mode_text.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/localization/app_localizations.dart';
|
||||
import '../../domain/entities/device_control_mode.dart';
|
||||
import '../../domain/usecases/device_control_mode_usecases.dart';
|
||||
import '../cubit/control_mode_entry_cubit.dart';
|
||||
|
||||
Future<bool> showControlModeEntryDialog(
|
||||
BuildContext context, {
|
||||
required String deviceId,
|
||||
required ControlModeTarget target,
|
||||
String? deviceName,
|
||||
ControlModeEntryCubit? cubit,
|
||||
}) async {
|
||||
final entry =
|
||||
cubit ??
|
||||
ControlModeEntryCubit(
|
||||
deviceId: deviceId,
|
||||
target: target,
|
||||
watchMode: sl<WatchDeviceControlModeUseCase>(),
|
||||
changeMode: sl<ChangeDeviceControlModeUseCase>(),
|
||||
);
|
||||
try {
|
||||
entry.start();
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => BlocProvider.value(
|
||||
value: entry,
|
||||
child: _ControlModeEntryDialog(deviceName: deviceName),
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
} finally {
|
||||
unawaited(entry.close());
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前模式已符合或切换接口成功后进入;不改动摇杆归零与停止协议。
|
||||
class ControlModeEntryGate extends StatefulWidget {
|
||||
const ControlModeEntryGate({
|
||||
super.key,
|
||||
required this.deviceId,
|
||||
required this.target,
|
||||
required this.child,
|
||||
this.deviceName,
|
||||
this.onReady,
|
||||
});
|
||||
final String deviceId;
|
||||
final String? deviceName;
|
||||
final ControlModeTarget target;
|
||||
final Widget child;
|
||||
final VoidCallback? onReady;
|
||||
@override
|
||||
State<ControlModeEntryGate> createState() => _ControlModeEntryGateState();
|
||||
}
|
||||
|
||||
class _ControlModeEntryGateState extends State<ControlModeEntryGate> {
|
||||
bool _ready = false;
|
||||
bool _cancelled = false;
|
||||
bool _entering = false;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _enter());
|
||||
}
|
||||
|
||||
Future<void> _enter() async {
|
||||
if (!mounted || _entering || _ready) return;
|
||||
_entering = true;
|
||||
setState(() => _cancelled = false);
|
||||
try {
|
||||
final accepted = await showControlModeEntryDialog(
|
||||
context,
|
||||
deviceId: widget.deviceId,
|
||||
deviceName: widget.deviceName,
|
||||
target: widget.target,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (accepted) {
|
||||
setState(() => _ready = true);
|
||||
widget.onReady?.call();
|
||||
} else {
|
||||
final popped = await Navigator.of(context).maybePop();
|
||||
if (!popped && mounted) setState(() => _cancelled = true);
|
||||
}
|
||||
} finally {
|
||||
_entering = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
IgnorePointer(ignoring: !_ready, child: widget.child),
|
||||
if (_cancelled)
|
||||
ColoredBox(
|
||||
color: Colors.black54,
|
||||
child: Center(
|
||||
child: FilledButton(
|
||||
onPressed: _enter,
|
||||
child: Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).translate('device_control_mode.retry'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _ControlModeEntryDialog extends StatelessWidget {
|
||||
const _ControlModeEntryDialog({this.deviceName});
|
||||
final String? deviceName;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
String tr(String key) => l10n.translate('device_control_mode.$key');
|
||||
final cubit = context.read<ControlModeEntryCubit>();
|
||||
return BlocConsumer<ControlModeEntryCubit, ControlModeEntryState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.phase != current.phase ||
|
||||
previous.reading?.rawMode != current.reading?.rawMode ||
|
||||
previous.canConfirm != current.canConfirm,
|
||||
listener: (context, state) {
|
||||
debugPrint(
|
||||
'[控制模式切换][弹窗状态] deviceId=${cubit.deviceId}, currentRawMode=${state.reading?.rawMode}, targetMode=${cubit.target.value}, phase=${state.phase.name}, confirmEnabled=${state.canConfirm}',
|
||||
);
|
||||
if (state.phase == ControlModeEntryPhase.confirmed) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final matches = state.reading?.matches(cubit.target) == true;
|
||||
final targetText = controlModeDisplayText(
|
||||
l10n,
|
||||
'${cubit.target.value}',
|
||||
);
|
||||
return PopScope(
|
||||
canPop: !state.busy,
|
||||
child: AlertDialog(
|
||||
title: Text(tr('title')),
|
||||
content: SizedBox(
|
||||
width: 380,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
deviceName?.trim().isNotEmpty == true
|
||||
? '$deviceName · ${cubit.deviceId}'
|
||||
: cubit.deviceId,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'${tr('current')}:${controlModeDisplayText(l10n, state.reading?.rawMode)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('${tr('target')}:$targetText'),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
state.busy
|
||||
? tr('submitting')
|
||||
: matches
|
||||
? tr('matched')
|
||||
: state.reading?.hasReportedMode == true
|
||||
? tr('mismatch').replaceAll('%s', targetText)
|
||||
: tr('unknown_mode').replaceAll('%s', targetText),
|
||||
),
|
||||
if (state.error != null || state.errorKey != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
state.errorKey != null
|
||||
? tr(state.errorKey!)
|
||||
: state.error!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (state.busy) ...[
|
||||
const SizedBox(height: 12),
|
||||
const LinearProgressIndicator(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: state.busy
|
||||
? null
|
||||
: () => Navigator.of(context).pop(false),
|
||||
child: Text(l10n.translate('common.back')),
|
||||
),
|
||||
if (state.phase == ControlModeEntryPhase.failed &&
|
||||
state.errorKey != 'invalid_device')
|
||||
TextButton(onPressed: cubit.start, child: Text(tr('retry'))),
|
||||
FilledButton(
|
||||
onPressed: state.canConfirm ? cubit.confirm : null,
|
||||
child: Text(l10n.translate('common.ok')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import 'base_map_config.dart';
|
||||
import 'resilient_tile_provider.dart';
|
||||
|
||||
const String _kUserAgent = 'com.maibu.maibu_satabot_v2';
|
||||
|
||||
/// App 统一底图:数据源跟随 App 语言
|
||||
/// - 中文 → ESRI 卫星影像(境内稳定,WGS84 直用)
|
||||
/// - 其他语言 → Google 混合图(卫星 + 英文地名)
|
||||
/// - Google 不可达(境内网络)→ 自动回退 ESRI,不出现空白地图
|
||||
class AppBaseMapLayer extends StatelessWidget {
|
||||
const AppBaseMapLayer({super.key, this.maxZoom = 21, this.forceConfig});
|
||||
|
||||
final double maxZoom;
|
||||
|
||||
/// 强制指定源(离线包/调试用),传了就忽略语言
|
||||
final BaseMapConfig? forceConfig;
|
||||
|
||||
/// 已打印过的「语言 + 实际底图源」组合,避免每帧刷屏
|
||||
static String? _loggedSignature;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final locale = Localizations.localeOf(context);
|
||||
final cfg = forceConfig ?? BaseMapConfig.of(locale);
|
||||
if (cfg.provider == BaseMapProvider.esri) {
|
||||
_logSelection(locale, cfg, fromFallback: false);
|
||||
return _build(cfg);
|
||||
}
|
||||
|
||||
return FutureBuilder<bool>(
|
||||
future: GoogleTileAvailability.reachable(),
|
||||
builder: (context, snapshot) {
|
||||
final googleOk = snapshot.data == true;
|
||||
_logSelection(
|
||||
locale,
|
||||
googleOk ? cfg : BaseMapConfig.esri,
|
||||
fromFallback: !googleOk,
|
||||
);
|
||||
// 探测中 / 不可达 → 先用 ESRI 顶住
|
||||
if (!googleOk) return _build(BaseMapConfig.esri);
|
||||
return _build(cfg);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _logSelection(
|
||||
Locale locale,
|
||||
BaseMapConfig cfg, {
|
||||
required bool fromFallback,
|
||||
}) {
|
||||
final sig = '${locale.languageCode}/${cfg.provider.name}/$fromFallback';
|
||||
if (_loggedSignature == sig) return;
|
||||
_loggedSignature = sig;
|
||||
debugPrint(
|
||||
'🗺️ [BaseMap] 语言=${locale.languageCode} → 底图=${cfg.provider.name}'
|
||||
'${fromFallback ? '(Google 不可达,已回退)' : ''}',
|
||||
);
|
||||
}
|
||||
|
||||
Widget _build(BaseMapConfig cfg) {
|
||||
// key 随源变化:语言切换时强制重建瓦片层,避免复用旧源瓦片
|
||||
final key = ValueKey('base_map_${cfg.provider.name}');
|
||||
if (cfg.provider == BaseMapProvider.google) {
|
||||
return TileLayer(
|
||||
key: key,
|
||||
tileProvider: GoogleTileProvider(lyr: cfg.googleLyr!),
|
||||
userAgentPackageName: _kUserAgent,
|
||||
minZoom: 0,
|
||||
maxNativeZoom: cfg.maxNativeZoom,
|
||||
maxZoom: maxZoom,
|
||||
);
|
||||
}
|
||||
return TileLayer(
|
||||
key: key,
|
||||
urlTemplate: cfg.urlTemplate,
|
||||
userAgentPackageName: _kUserAgent,
|
||||
minZoom: 0,
|
||||
maxNativeZoom: cfg.maxNativeZoom,
|
||||
maxZoom: maxZoom,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Google 栅格瓦片:与 flutter_map 的 XYZ 编号一致,无需坐标转换
|
||||
class GoogleTileProvider extends TileProvider {
|
||||
GoogleTileProvider({required this.lyr, this.hl = 'en'});
|
||||
|
||||
final String lyr;
|
||||
final String hl;
|
||||
|
||||
@override
|
||||
ImageProvider<Object> getImage(
|
||||
TileCoordinates coordinates,
|
||||
TileLayer options,
|
||||
) {
|
||||
final sub = coordinates.x % 4;
|
||||
final url = 'https://mt$sub.google.com/vt/lyrs=$lyr&hl=$hl'
|
||||
'&x=${coordinates.x}&y=${coordinates.y}&z=${coordinates.z}';
|
||||
return ResilientNetworkImage(url);
|
||||
}
|
||||
}
|
||||
102
lib/features/home/presentation/widgets/map/base_map_config.dart
Normal file
102
lib/features/home/presentation/widgets/map/base_map_config.dart
Normal file
@@ -0,0 +1,102 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// 底图来源:中文用户用 ESRI,非中文用户用 Google
|
||||
enum BaseMapProvider { esri, google }
|
||||
|
||||
class BaseMapConfig {
|
||||
const BaseMapConfig({
|
||||
required this.provider,
|
||||
required this.maxNativeZoom,
|
||||
this.urlTemplate,
|
||||
this.googleLyr,
|
||||
});
|
||||
|
||||
final BaseMapProvider provider;
|
||||
|
||||
/// 原生最大层级:超出后由 flutter_map 用低级别瓦片拉伸,避免整片空白
|
||||
final int maxNativeZoom;
|
||||
|
||||
/// ESRI XYZ 模板(WGS84,与设备 GPS 同一坐标系)
|
||||
final String? urlTemplate;
|
||||
|
||||
/// Google 瓦片图层:s=纯卫星 / h=地名 / y=卫星+地名
|
||||
final String? googleLyr;
|
||||
|
||||
static const BaseMapConfig esri = BaseMapConfig(
|
||||
provider: BaseMapProvider.esri,
|
||||
maxNativeZoom: 19,
|
||||
urlTemplate:
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
);
|
||||
|
||||
/// lyrs=y 让境外用户一张图同时拿到卫星影像 + 英文地名(hl=en)
|
||||
static const BaseMapConfig google = BaseMapConfig(
|
||||
provider: BaseMapProvider.google,
|
||||
maxNativeZoom: 20,
|
||||
googleLyr: 'y',
|
||||
);
|
||||
|
||||
/// 规则:中文 → ESRI;其余语言 → Google
|
||||
static BaseMapConfig of(Locale locale) =>
|
||||
locale.languageCode == 'zh' ? esri : google;
|
||||
}
|
||||
|
||||
/// Google 瓦片可达性探测:失败后允许定期重试,避免一次探测失败就永久降级
|
||||
class GoogleTileAvailability {
|
||||
/// 失败后的重试冷却时长:开/关 VPN、切换网络后无需重启 App
|
||||
static const Duration retryAfter = Duration(seconds: 30);
|
||||
|
||||
static Future<bool>? _cached;
|
||||
static bool? _lastResult;
|
||||
static DateTime? _lastFailAt;
|
||||
|
||||
static Future<bool> reachable() {
|
||||
final cached = _cached;
|
||||
if (cached != null) {
|
||||
final failedAt = _lastFailAt;
|
||||
final shouldRetry = _lastResult == false &&
|
||||
failedAt != null &&
|
||||
DateTime.now().difference(failedAt) >= retryAfter;
|
||||
// 继续复用同一个 Future 对象,避免 FutureBuilder 因 future 变化而反复重建
|
||||
if (!shouldRetry) return cached;
|
||||
}
|
||||
final future = _probe();
|
||||
_cached = future;
|
||||
return future;
|
||||
}
|
||||
|
||||
/// 手动重试(例如用户点「刷新地图」、App 从后台恢复)
|
||||
static void reset() {
|
||||
_cached = null;
|
||||
_lastResult = null;
|
||||
_lastFailAt = null;
|
||||
}
|
||||
|
||||
static Future<bool> _probe() async {
|
||||
final sw = Stopwatch()..start();
|
||||
bool ok;
|
||||
try {
|
||||
final res = await http
|
||||
.get(Uri.parse('https://mt0.google.com/vt/lyrs=s&hl=en&x=1&y=0&z=1'))
|
||||
.timeout(const Duration(seconds: 3));
|
||||
ok = res.statusCode == 200 && res.bodyBytes.isNotEmpty;
|
||||
debugPrint(
|
||||
'🗺️ [BaseMap] Google 探测: http=${res.statusCode} '
|
||||
'bytes=${res.bodyBytes.length} ${sw.elapsedMilliseconds}ms '
|
||||
'→ ${ok ? '可用,英文底图用 Google' : '不可用,回退 ESRI'}',
|
||||
);
|
||||
} catch (e) {
|
||||
ok = false;
|
||||
debugPrint(
|
||||
'🗺️ [BaseMap] Google 探测异常: ${e.runtimeType} '
|
||||
'${sw.elapsedMilliseconds}ms → 回退 ESRI',
|
||||
);
|
||||
}
|
||||
_lastResult = ok;
|
||||
_lastFailAt = ok ? null : DateTime.now();
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,13 @@ abstract class BindDeviceDatasource {
|
||||
int? siteId,
|
||||
int? userId,
|
||||
});
|
||||
|
||||
/// 批量解绑设备(后端 /iot/device/unbind)。
|
||||
///
|
||||
/// [type] 见 UnbindScope:1 清除负责人 / 2 清除场站 / 3 清除组织。
|
||||
/// 后端为批量接口,一次可解绑多台设备且必须指定清除层级。
|
||||
Future<void> unbindDevice({
|
||||
required List<String> deviceIds,
|
||||
required int type,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
|
||||
import 'package:maibu_satabot_v2/core/di/injection.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
|
||||
@@ -135,24 +138,151 @@ class BindDeviceDatasourceImpl implements BindDeviceDatasource {
|
||||
if (siteId != null) body['siteId'] = siteId;
|
||||
if (userId != null) body['userId'] = userId;
|
||||
|
||||
final response = await _dio.post(
|
||||
HttpApiConsts.bindDevice,
|
||||
data: body,
|
||||
options: Options(
|
||||
headers: {
|
||||
'Authorization': token != null ? 'Bearer $token' : '',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
),
|
||||
// 绑定接口完整请求日志:body 用分段打印,避免 debugPrint 单条过长被截断
|
||||
debugPrint('>>> [BindDevice] ===== 开始绑定 =====');
|
||||
debugPrint('>>> [BindDevice] url=${HttpApiConsts.bindDevice}');
|
||||
debugPrint(
|
||||
'>>> [BindDevice] 入参 deviceIds=$deviceIds orgId=$orgId '
|
||||
'siteId=$siteId userId=$userId',
|
||||
);
|
||||
debugPrint(
|
||||
'>>> [BindDevice] token=${token == null || token.isEmpty ? '空' : '已携带'}',
|
||||
);
|
||||
_printChunked('>>> [BindDevice] requestBody: ', _safeEncode(body));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
HttpApiConsts.bindDevice,
|
||||
data: body,
|
||||
options: Options(
|
||||
headers: {
|
||||
'Authorization': token != null ? 'Bearer $token' : '',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
debugPrint('>>> [BindDevice] response statusCode=${response.statusCode}');
|
||||
_printChunked(
|
||||
'>>> [BindDevice] responseBody: ',
|
||||
_safeEncode(response.data),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final data = response.data;
|
||||
if (data['code'] != 200) {
|
||||
debugPrint(
|
||||
'>>> [BindDevice] ❌ 业务失败 code=${data['code']} msg=${data['msg']}',
|
||||
);
|
||||
throw Exception(data['msg'] ?? '绑定设备失败');
|
||||
}
|
||||
debugPrint('>>> [BindDevice] ✅ 绑定成功');
|
||||
} on DioException catch (e) {
|
||||
debugPrint(
|
||||
'>>> [BindDevice] ❌ DioException type=${e.type} '
|
||||
'status=${e.response?.statusCode} message=${e.message}',
|
||||
);
|
||||
if (e.response?.data != null) {
|
||||
_printChunked(
|
||||
'>>> [BindDevice] errorResponseBody: ',
|
||||
_safeEncode(e.response?.data),
|
||||
);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
final data = response.data;
|
||||
if (data['code'] != 200) {
|
||||
throw Exception(data['msg'] ?? '绑定设备失败');
|
||||
@override
|
||||
Future<void> unbindDevice({
|
||||
required List<String> deviceIds,
|
||||
required int type,
|
||||
}) async {
|
||||
final token = await _getToken();
|
||||
final Map<String, dynamic> body = {'deviceIds': deviceIds, 'type': type};
|
||||
|
||||
// 解绑为不可逆数据操作,请求前后均打印完整日志便于核对 deviceIds 与 type
|
||||
debugPrint('>>> [UnbindDevice] ===== 开始解绑 =====');
|
||||
debugPrint('>>> [UnbindDevice] url=${HttpApiConsts.unbindDeviceV2}');
|
||||
debugPrint(
|
||||
'>>> [UnbindDevice] 入参 deviceIds=$deviceIds type=$type '
|
||||
'(1清除负责人 2清除场站 3清除组织)',
|
||||
);
|
||||
debugPrint(
|
||||
'>>> [UnbindDevice] token=${token == null || token.isEmpty ? '空' : '已携带'}',
|
||||
);
|
||||
_printChunked('>>> [UnbindDevice] requestBody: ', _safeEncode(body));
|
||||
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
HttpApiConsts.unbindDeviceV2,
|
||||
data: body,
|
||||
options: Options(
|
||||
headers: {
|
||||
'Authorization': token != null ? 'Bearer $token' : '',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
'>>> [UnbindDevice] response statusCode=${response.statusCode}',
|
||||
);
|
||||
_printChunked(
|
||||
'>>> [UnbindDevice] responseBody: ',
|
||||
_safeEncode(response.data),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
// 后端返回格式未固定,只在明确给出业务码时才按失败处理:
|
||||
// {code:200, data:true} 与 {code:200, msg:"..."} 两种都会当作成功
|
||||
final data = response.data;
|
||||
if (data is Map && data['code'] != null && data['code'] != 200) {
|
||||
debugPrint(
|
||||
'>>> [UnbindDevice] ❌ 业务失败 code=${data['code']} msg=${data['msg']}',
|
||||
);
|
||||
throw Exception(data['msg'] ?? '解绑设备失败');
|
||||
}
|
||||
debugPrint('>>> [UnbindDevice] ✅ 解绑成功');
|
||||
} on DioException catch (e) {
|
||||
debugPrint(
|
||||
'>>> [UnbindDevice] ❌ DioException type=${e.type} '
|
||||
'status=${e.response?.statusCode} message=${e.message}',
|
||||
);
|
||||
if (e.response?.data != null) {
|
||||
_printChunked(
|
||||
'>>> [UnbindDevice] errorResponseBody: ',
|
||||
_safeEncode(e.response?.data),
|
||||
);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 长文本分段打印,避免 debugPrint 单条过长被截断
|
||||
void _printChunked(String prefix, String content) {
|
||||
const chunk = 700;
|
||||
var head = prefix;
|
||||
for (int start = 0; start < content.length; start += chunk) {
|
||||
final end =
|
||||
start + chunk < content.length ? start + chunk : content.length;
|
||||
debugPrint('$head${content.substring(start, end)}');
|
||||
head = ' ↳ ';
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON 编码失败时退回 toString,保证日志本身不抛异常
|
||||
String _safeEncode(dynamic data) {
|
||||
if (data == null) return 'null';
|
||||
try {
|
||||
return jsonEncode(data);
|
||||
} catch (_) {
|
||||
return data.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ class DeviceDataModel {
|
||||
final double? radiation;
|
||||
final bool isRobot;
|
||||
|
||||
/// 产品型号(如「割草机产品MC700」)
|
||||
final String productName;
|
||||
|
||||
/// 机型编码(如 MC700PRO)
|
||||
final String? productModel;
|
||||
|
||||
const DeviceDataModel({
|
||||
required this.deviceId,
|
||||
required this.name,
|
||||
@@ -26,6 +32,8 @@ class DeviceDataModel {
|
||||
this.voltage,
|
||||
this.radiation,
|
||||
this.isRobot = false,
|
||||
this.productName = '',
|
||||
this.productModel,
|
||||
});
|
||||
|
||||
/// 从 JSON 创建数据模型
|
||||
@@ -41,6 +49,8 @@ class DeviceDataModel {
|
||||
current: json['current']?.toDouble(),
|
||||
voltage: json['voltage']?.toDouble(),
|
||||
radiation: json['radiation']?.toDouble(),
|
||||
productName: (json['productName'] as String?) ?? '',
|
||||
productModel: json['productModel'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,6 +67,8 @@ class DeviceDataModel {
|
||||
'current': current,
|
||||
'voltage': voltage,
|
||||
'radiation': radiation,
|
||||
'productName': productName,
|
||||
'productModel': productModel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,6 +86,8 @@ class DeviceDataModel {
|
||||
voltage: voltage,
|
||||
radiation: radiation,
|
||||
isRobot: isRobot,
|
||||
productName: productName,
|
||||
productModel: productModel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,4 +77,20 @@ class BindDeviceRepositoryImpl implements BindDeviceRepository {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> unbindDevice({
|
||||
required List<String> deviceIds,
|
||||
required UnbindScope scope,
|
||||
}) async {
|
||||
try {
|
||||
await _datasource.unbindDevice(
|
||||
deviceIds: deviceIds,
|
||||
type: scope.value,
|
||||
);
|
||||
return const Right(null);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,39 @@
|
||||
/// 设备归属操作类型(设备归属与安全页的两个高风险动作)。
|
||||
///
|
||||
/// 两者共用同一套「扫码核验 + 组织/场站/负责人三级选择」流程,
|
||||
/// 差别只在提交:
|
||||
/// - [transfer] 转交设备 = 将设备挂到选定层级(POST /iot/device/bind)
|
||||
/// - [unbind] 解绑设备 = 将设备从选定层级摘除(POST /iot/device/unbind)
|
||||
enum OwnershipAction {
|
||||
transfer,
|
||||
unbind;
|
||||
}
|
||||
|
||||
/// 解绑清除范围(后端解绑接口的 type 字段)
|
||||
///
|
||||
/// 归属层级为“组织 → 场站 → 负责人”,解绑需要指明从哪一级摘除:
|
||||
/// 1 = 清除负责人,2 = 清除场站,3 = 清除组织。
|
||||
/// 值越大解绑得越彻底,不可越级(例如设备只挂在场站下时不要传 org)。
|
||||
enum UnbindScope {
|
||||
user(1, '清除负责人'),
|
||||
site(2, '清除场站'),
|
||||
org(3, '清除组织');
|
||||
|
||||
final int value;
|
||||
final String label;
|
||||
|
||||
const UnbindScope(this.value, this.label);
|
||||
|
||||
/// 后端回传的 type 反查枚举;未知值返回 null
|
||||
static UnbindScope? fromValue(int? value) {
|
||||
if (value == null) return null;
|
||||
for (final scope in values) {
|
||||
if (scope.value == value) return scope;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class OrgEntity {
|
||||
final int id;
|
||||
final String name;
|
||||
|
||||
@@ -12,6 +12,12 @@ class DeviceEntity {
|
||||
final double? radiation;
|
||||
final bool isRobot;
|
||||
|
||||
/// 产品型号(如「割草机产品MC700」)
|
||||
final String productName;
|
||||
|
||||
/// 机型编码(如 MC700PRO),列表型号展示以此为准
|
||||
final String? productModel;
|
||||
|
||||
const DeviceEntity({
|
||||
required this.deviceId,
|
||||
required this.name,
|
||||
@@ -24,5 +30,7 @@ class DeviceEntity {
|
||||
this.voltage,
|
||||
this.radiation,
|
||||
this.isRobot = false,
|
||||
this.productName = '',
|
||||
this.productModel,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
|
||||
/// 设备机型:把后端 productModel 解析成「系列 + 版本」。
|
||||
///
|
||||
/// 字段口径:getSiteList 返回的 productModel 形如
|
||||
/// `MC700` / `MC700STD` / `MC700PLUS` / `MC700PRO`,
|
||||
/// 也可能带长后缀:`MC700PRO-CN-JS-1751525588120-0000000A-9527`。
|
||||
///
|
||||
/// 该解析结果同时供列表卡展示与详情页功能开放使用,禁止再用伪随机/写死兜底。
|
||||
/// `label` 仅供内部解析/搜索匹配,展示请用 `labelOf` 取当前语言文案。
|
||||
enum DeviceEdition {
|
||||
std('标准版', 'std'),
|
||||
plus('智能版', 'plus'),
|
||||
pro('旗舰版', 'pro'),
|
||||
air('轻享版', 'air'),
|
||||
unknown('未知版本', 'unknown');
|
||||
|
||||
const DeviceEdition(this.label, this.code);
|
||||
|
||||
/// 中文短名(仅解析/搜索匹配用,不再直接展示)
|
||||
final String label;
|
||||
|
||||
/// 语言包代码:device_list_v2.edition_<code>
|
||||
final String code;
|
||||
}
|
||||
|
||||
class DeviceModelInfo {
|
||||
/// 后端原始值(如 MC700PRO)
|
||||
final String raw;
|
||||
|
||||
/// 型号系列(MC700 / MCAIR / RCHWD …)
|
||||
final String series;
|
||||
|
||||
/// 设备版本(std / plus / pro / air)
|
||||
final DeviceEdition edition;
|
||||
|
||||
const DeviceModelInfo({
|
||||
required this.raw,
|
||||
required this.series,
|
||||
required this.edition,
|
||||
});
|
||||
|
||||
static const empty = DeviceModelInfo(
|
||||
raw: '',
|
||||
series: '',
|
||||
edition: DeviceEdition.unknown,
|
||||
);
|
||||
|
||||
/// 是否解析出版本
|
||||
bool get resolved => edition != DeviceEdition.unknown;
|
||||
|
||||
/// 展示文案:`MC700 · PRO 旗舰版`;只有系列时 `MC700`;
|
||||
/// productModel 为 null / 空串时统一显示未知。
|
||||
///
|
||||
/// [deviceType] 是后端另一个字段(如 MC700 / 割草机器人),要排在
|
||||
/// 版本号(PRO / PLUS / STD)之前,并且优先于从 productModel 里解析出的系列:
|
||||
/// 后端明确给了机型就用它,没给才回退到 productModel 的解析结果。
|
||||
String labelOf(AppLocalizations l10n, {String? deviceType}) {
|
||||
final typeText = (deviceType ?? '').trim();
|
||||
// deviceType 若是纯数字编码(1/2/3)拿来展示没意义,回退到 productModel 解析结果
|
||||
final usableType = RegExp(r'^\d+$').hasMatch(typeText) ? '' : typeText;
|
||||
final head = usableType.isNotEmpty ? usableType : series;
|
||||
|
||||
if (raw.isEmpty && head.isEmpty) {
|
||||
return l10n.translate('device_list_v2.unknown');
|
||||
}
|
||||
if (head.isEmpty && edition == DeviceEdition.unknown) return raw;
|
||||
if (edition == DeviceEdition.unknown) return head;
|
||||
return '$head · ${editionLabelOf(l10n)}';
|
||||
}
|
||||
|
||||
/// 版本短文案:`PRO 旗舰版`;未知时为空
|
||||
String editionLabelOf(AppLocalizations l10n) => edition == DeviceEdition.unknown
|
||||
? ''
|
||||
: '${edition.name.toUpperCase()} ${l10n.translate('device_list_v2.edition_${edition.code}')}';
|
||||
|
||||
/// 搜索用关键字:原始值 + 系列 + 版本中英文
|
||||
String get searchText =>
|
||||
'$raw $series ${edition.name.toUpperCase()} ${edition.label} ${edition.name}'
|
||||
.toLowerCase();
|
||||
|
||||
/// 解析:兼容 MC700PRO / MC700-PRO / MC700 PRO / 长后缀 / 中文「旗舰版」
|
||||
static DeviceModelInfo parse(String? value) {
|
||||
final raw = (value ?? '').trim();
|
||||
if (raw.isEmpty) return empty;
|
||||
|
||||
final compact = raw.toUpperCase().replaceAll(RegExp(r'[\s\-_·]+'), '');
|
||||
final seriesMatch = RegExp(
|
||||
r'(MCAIR|RCH[A-Z]*|RCE[A-Z]*|MC\d{3})',
|
||||
).firstMatch(compact);
|
||||
final series = seriesMatch?.group(1) ?? '';
|
||||
|
||||
final DeviceEdition edition;
|
||||
if (compact.contains('PRO') || raw.contains('旗舰')) {
|
||||
edition = DeviceEdition.pro;
|
||||
} else if (compact.contains('PLUS') || raw.contains('智能')) {
|
||||
edition = DeviceEdition.plus;
|
||||
} else if (compact.contains('AIR') || raw.contains('轻享')) {
|
||||
edition = DeviceEdition.air;
|
||||
} else if (compact.contains('STD') || raw.contains('标准')) {
|
||||
edition = DeviceEdition.std;
|
||||
} else {
|
||||
edition = DeviceEdition.unknown;
|
||||
}
|
||||
|
||||
return DeviceModelInfo(raw: raw, series: series, edition: edition);
|
||||
}
|
||||
}
|
||||
@@ -16,4 +16,13 @@ abstract class BindDeviceRepository {
|
||||
int? siteId,
|
||||
int? userId,
|
||||
});
|
||||
|
||||
/// 批量解绑设备。
|
||||
///
|
||||
/// [scope] 指定从归属链的哪一级摘除(负责人/场站/组织),
|
||||
/// 由 UseCase 保证设备当前挂载层级与 scope 匹配后再下发。
|
||||
Future<Either<Failure, void>> unbindDevice({
|
||||
required List<String> deviceIds,
|
||||
required UnbindScope scope,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,3 +48,29 @@ class IsDeviceAtSiteUseCase {
|
||||
required int siteId,
|
||||
}) => _repository.isDeviceAtSite(deviceId: deviceId, siteId: siteId);
|
||||
}
|
||||
|
||||
/// 批量解绑设备用例(后端 /iot/device/unbind)
|
||||
///
|
||||
/// 归属层级:组织 → 场站 → 负责人,[scope] 需与设备当前挂载的层级一致,
|
||||
/// 传错会把设备从错误的层级摘除且不可逆,所以这里只做入参清理,不猜测 scope。
|
||||
class UnbindDeviceV2UseCase {
|
||||
final BindDeviceRepository _repository;
|
||||
UnbindDeviceV2UseCase(this._repository);
|
||||
|
||||
Future<Either<Failure, void>> call({
|
||||
required List<String> deviceIds,
|
||||
required UnbindScope scope,
|
||||
}) {
|
||||
// 去空串、去重(保留调用方顺序):空列表发给后端可能被当成“整个层级清空”
|
||||
final ids = <String>[];
|
||||
for (final raw in deviceIds) {
|
||||
final id = raw.trim();
|
||||
if (id.isEmpty || ids.contains(id)) continue;
|
||||
ids.add(id);
|
||||
}
|
||||
if (ids.isEmpty) {
|
||||
return Future.value(Left(Failure('请选择需要解绑的设备')));
|
||||
}
|
||||
return _repository.unbindDevice(deviceIds: ids, scope: scope);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,12 +304,20 @@ class BindDeviceCubit extends Cubit<BindDeviceState> {
|
||||
}
|
||||
|
||||
Future<bool> submitBind(String deviceId) async {
|
||||
debugPrint(
|
||||
'>>> [BindDevice] submitBind 入参 deviceId=$deviceId roleKey=$roleKey '
|
||||
'orgId=${state.selectedOrgId} siteId=${state.selectedSiteId} '
|
||||
'userId=${state.selectedUserId}',
|
||||
);
|
||||
|
||||
if (state.selectedOrgId == null) {
|
||||
debugPrint('>>> [BindDevice] 前置拦截:selectedOrgId 为空,未发请求');
|
||||
emit(state.copyWith(errorMessage: '请选择所属组织'));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (roleKey == 'user' && state.selectedUserId == null) {
|
||||
debugPrint('>>> [BindDevice] 前置拦截:user 角色未选负责人,未发请求');
|
||||
emit(state.copyWith(errorMessage: '请选择负责人'));
|
||||
return false;
|
||||
}
|
||||
@@ -323,6 +331,10 @@ class BindDeviceCubit extends Cubit<BindDeviceState> {
|
||||
);
|
||||
|
||||
final isAtSite = existResult.fold((failure) => false, (exists) => exists);
|
||||
debugPrint(
|
||||
'>>> [BindDevice] 场站查重 deviceId=$deviceId siteId=${state.selectedSiteId} '
|
||||
'isAtSite=$isAtSite',
|
||||
);
|
||||
|
||||
if (isAtSite) {
|
||||
emit(
|
||||
@@ -332,6 +344,7 @@ class BindDeviceCubit extends Cubit<BindDeviceState> {
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('>>> [BindDevice] 前置校验通过,开始调用绑定用例');
|
||||
final result = await _bindDeviceUseCase(
|
||||
deviceIds: [deviceId],
|
||||
orgId: state.selectedOrgId!,
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
|
||||
|
||||
import '../../domain/entities/bind_device_entities.dart';
|
||||
import '../../domain/usecases/bind_device_usecases.dart';
|
||||
import 'device_ownership_action_state.dart';
|
||||
|
||||
/// 设备归属操作:转交(= 绑定接口)与解绑(= 解绑接口)共用一套流程。
|
||||
///
|
||||
/// 两条链路都是「扫码核验 → 按角色选择组织/场站/负责人 → 下发」,
|
||||
/// 三级选择与角色规则逐行对齐绑定流程([BindDeviceCubit]),
|
||||
/// 差别只在提交:转交走 [BindDeviceV2UseCase],解绑走 [UnbindDeviceV2UseCase]。
|
||||
///
|
||||
/// 之所以不直接复用 [BindDeviceCubit],是因为绑定页还带着"扫码即绑定"的
|
||||
/// 独立页面流程,这里只借用它的层级加载与角色口径,不改动它本身。
|
||||
class DeviceOwnershipActionCubit extends Cubit<DeviceOwnershipActionState> {
|
||||
final GetOrgListUseCase _getOrgListUseCase;
|
||||
final GetSitesByOrgUseCase _getSitesByOrgUseCase;
|
||||
final GetUsersBySiteUseCase _getUsersBySiteUseCase;
|
||||
final BindDeviceV2UseCase _bindDeviceUseCase;
|
||||
final UnbindDeviceV2UseCase _unbindDeviceUseCase;
|
||||
final AppUserCubit _appUserCubit;
|
||||
|
||||
DeviceOwnershipActionCubit(
|
||||
this._getOrgListUseCase,
|
||||
this._getSitesByOrgUseCase,
|
||||
this._getUsersBySiteUseCase,
|
||||
this._bindDeviceUseCase,
|
||||
this._unbindDeviceUseCase,
|
||||
this._appUserCubit,
|
||||
) : super(const DeviceOwnershipActionState());
|
||||
|
||||
UserEntity? get _currentUser => _appUserCubit.state.user;
|
||||
String get roleKey => state.roleKey;
|
||||
|
||||
/// 角色可编辑范围(与绑定页保持一致)
|
||||
bool get isOrgEnabled => roleKey == 'admin';
|
||||
bool get isSiteEnabled => roleKey == 'admin' || roleKey == 'manager';
|
||||
|
||||
/// 负责人:
|
||||
/// - 普通用户 / 个人角色只能选自己,所以 UI 锁定(选中值默认就是自己);
|
||||
/// - 场站负责人及以上可以选场站内的人。
|
||||
bool get isUserEnabled =>
|
||||
roleKey == 'admin' || roleKey == 'manager' || roleKey == 'siteManager';
|
||||
|
||||
/// 当前角色能触达的最深一级,口径与可编辑范围一一对应:
|
||||
/// admin 可动到组织(含场站/负责人),manager 只到场站,
|
||||
/// siteManager 与普通用户只到负责人(他们能动的只有人这一层)。
|
||||
UnbindScope get roleMaxScope {
|
||||
if (isOrgEnabled) return UnbindScope.org;
|
||||
if (isSiteEnabled) return UnbindScope.site;
|
||||
return UnbindScope.user;
|
||||
}
|
||||
|
||||
/// 解绑实际下发的清除范围:所选层级不得越过角色上限。
|
||||
/// UnbindScope 的值越大解绑得越彻底(1 人 < 2 站 < 3 组织),所以取较小者。
|
||||
UnbindScope get effectiveScope {
|
||||
final picked = state.scopePreview;
|
||||
final max = roleMaxScope;
|
||||
return picked.value <= max.value ? picked : max;
|
||||
}
|
||||
|
||||
/// 能否用「不指定」把负责人清空(清空后由场站决定层级)
|
||||
bool get canClearUser => roleMaxScope.value >= UnbindScope.site.value;
|
||||
|
||||
/// 能否用「不指定」把场站清空(清空后由组织决定层级)
|
||||
bool get canClearSite => roleMaxScope.value >= UnbindScope.org.value;
|
||||
|
||||
// ── 去重 / 兜底辅助(与绑定页同口径)──
|
||||
List<OrgEntity> _uniqueOrgs(List<OrgEntity> list) {
|
||||
final seen = <int>{};
|
||||
return list.where((e) => seen.add(e.id)).toList();
|
||||
}
|
||||
|
||||
List<SiteEntity> _uniqueSites(List<SiteEntity> list) {
|
||||
final seen = <int>{};
|
||||
return list.where((e) => seen.add(e.id)).toList();
|
||||
}
|
||||
|
||||
List<UserSimpleEntity> _uniqueUsers(List<UserSimpleEntity> list) {
|
||||
final seen = <int>{};
|
||||
return list.where((e) => seen.add(e.id)).toList();
|
||||
}
|
||||
|
||||
int? _validSelected(int? id, List<dynamic> list) {
|
||||
if (id == null) return null;
|
||||
final ids = list.map((e) => e.id as int).toSet();
|
||||
return ids.contains(id) ? id : null;
|
||||
}
|
||||
|
||||
List<OrgEntity> _ensureOrgInList(List<OrgEntity> orgs, int orgId) {
|
||||
if (orgs.any((e) => e.id == orgId)) return orgs;
|
||||
return [...orgs, OrgEntity(id: orgId, name: '组织 #$orgId')];
|
||||
}
|
||||
|
||||
List<SiteEntity> _ensureSiteInList(List<SiteEntity> sites, int? siteId) {
|
||||
if (siteId == null) return sites;
|
||||
if (sites.any((e) => e.id == siteId)) return sites;
|
||||
return [...sites, SiteEntity(id: siteId, name: '场站 #$siteId')];
|
||||
}
|
||||
|
||||
List<UserSimpleEntity> _ensureUserInList(
|
||||
List<UserSimpleEntity> users,
|
||||
UserEntity user,
|
||||
) {
|
||||
final uid = int.tryParse(user.userId) ?? 0;
|
||||
if (users.any((e) => e.id == uid)) return users;
|
||||
return [...users, UserSimpleEntity(id: uid, name: user.nickname)];
|
||||
}
|
||||
|
||||
/// 按角色初始化。
|
||||
///
|
||||
/// - 转交(绑定):需要加载组织/场站/负责人三级列表;
|
||||
/// - 解绑:不需要任何列表 —— 只选「个人 / 场站 / 组织」一级,
|
||||
/// 而这一级由角色决定上限,所以直接定好默认值就能用。
|
||||
Future<void> init({required OwnershipAction action}) async {
|
||||
final user = _currentUser;
|
||||
if (user == null) {
|
||||
debugPrint(
|
||||
'>>> [OwnershipAction] ERROR: _currentUser is null,无法加载归属层级',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'>>> [OwnershipAction] action=$action roleKey=${user.roleKey}, '
|
||||
'orgId=${user.orgId}, siteId=${user.siteId}, userId=${user.userId}',
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
action: action,
|
||||
isLoading: true,
|
||||
roleKey: user.roleKey ?? 'user',
|
||||
// 解绑默认选最轻的一级(个人),不预设更彻底的清除
|
||||
selectedScope: UnbindScope.user,
|
||||
),
|
||||
);
|
||||
|
||||
// 解绑不依赖三级列表,直接结束 loading
|
||||
if (action == OwnershipAction.unbind) {
|
||||
debugPrint(
|
||||
'>>> [OwnershipAction] 解绑模式:roleMax=${roleMaxScope.value}'
|
||||
'(${roleMaxScope.label}),跳过组织/场站/人员加载',
|
||||
);
|
||||
emit(state.copyWith(isLoading: false));
|
||||
return;
|
||||
}
|
||||
|
||||
switch (roleKey) {
|
||||
case 'admin':
|
||||
await _loadAllOrgs();
|
||||
break;
|
||||
case 'manager':
|
||||
await _loadForManager(user);
|
||||
break;
|
||||
case 'siteManager':
|
||||
await _loadForSiteManager(user);
|
||||
break;
|
||||
default:
|
||||
await _loadForUser(user);
|
||||
}
|
||||
|
||||
emit(state.copyWith(isLoading: false));
|
||||
}
|
||||
|
||||
/// 解绑:直接选清除层级(个人 / 场站 / 组织)。
|
||||
/// 超出当前角色上限的层级不响应,UI 也会置灰。
|
||||
void onScopeSelected(UnbindScope scope) {
|
||||
if (scope.value > roleMaxScope.value) {
|
||||
debugPrint(
|
||||
'>>> [OwnershipAction] 拦截越权清除:picked=${scope.value} '
|
||||
'roleMax=${roleMaxScope.value}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(selectedScope: scope, clearError: true));
|
||||
debugPrint(
|
||||
'>>> [OwnershipAction] 清除层级选中 scope=${scope.value}(${scope.label})',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadAllOrgs() async {
|
||||
final result = await _getOrgListUseCase();
|
||||
result.fold(
|
||||
(failure) => emit(state.copyWith(errorMessage: failure.message)),
|
||||
(orgs) {
|
||||
final unique = _uniqueOrgs(orgs);
|
||||
emit(
|
||||
state.copyWith(
|
||||
orgList: unique,
|
||||
selectedOrgId: _validSelected(state.selectedOrgId, unique),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadForManager(UserEntity user) async {
|
||||
final result = await _getOrgListUseCase();
|
||||
result.fold(
|
||||
(failure) => emit(state.copyWith(errorMessage: failure.message)),
|
||||
(orgs) async {
|
||||
final unique = _ensureOrgInList(_uniqueOrgs(orgs), user.orgId);
|
||||
emit(state.copyWith(orgList: unique, selectedOrgId: user.orgId));
|
||||
await _loadSitesByOrgForManager(user.orgId, user.siteId);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadForSiteManager(UserEntity user) async {
|
||||
final result = await _getOrgListUseCase();
|
||||
result.fold(
|
||||
(failure) => emit(state.copyWith(errorMessage: failure.message)),
|
||||
(orgs) async {
|
||||
final unique = _ensureOrgInList(_uniqueOrgs(orgs), user.orgId);
|
||||
emit(state.copyWith(orgList: unique, selectedOrgId: user.orgId));
|
||||
await _loadSitesAndUsers(user);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadForUser(UserEntity user) async {
|
||||
final result = await _getOrgListUseCase();
|
||||
result.fold(
|
||||
(failure) => emit(state.copyWith(errorMessage: failure.message)),
|
||||
(orgs) async {
|
||||
final unique = _ensureOrgInList(_uniqueOrgs(orgs), user.orgId);
|
||||
emit(state.copyWith(orgList: unique, selectedOrgId: user.orgId));
|
||||
await _loadSitesAndUsers(user);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadSitesByOrgForManager(int orgId, int? siteId) async {
|
||||
final result = await _getSitesByOrgUseCase(orgId);
|
||||
result.fold(
|
||||
(failure) => emit(state.copyWith(errorMessage: failure.message)),
|
||||
(sites) {
|
||||
final unique = _ensureSiteInList(_uniqueSites(sites), siteId);
|
||||
emit(
|
||||
state.copyWith(
|
||||
siteList: unique,
|
||||
selectedSiteId: siteId,
|
||||
clearSite: siteId == null,
|
||||
clearUser: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadSitesAndUsers(UserEntity user) async {
|
||||
final sitesResult = await _getSitesByOrgUseCase(user.orgId);
|
||||
sitesResult.fold(
|
||||
(failure) => emit(state.copyWith(errorMessage: failure.message)),
|
||||
(sites) async {
|
||||
final unique = _ensureSiteInList(_uniqueSites(sites), user.siteId);
|
||||
emit(state.copyWith(siteList: unique, selectedSiteId: user.siteId));
|
||||
if (user.siteId != null) {
|
||||
await _loadUsers(user);
|
||||
} else {
|
||||
final currentUser = UserSimpleEntity(
|
||||
id: int.tryParse(user.userId) ?? 0,
|
||||
name: user.nickname,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
userList: [currentUser],
|
||||
selectedUserId: currentUser.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadUsers(UserEntity user) async {
|
||||
final result = await _getUsersBySiteUseCase(user.siteId!);
|
||||
final currentUser = UserSimpleEntity(
|
||||
id: int.tryParse(user.userId) ?? 0,
|
||||
name: user.nickname,
|
||||
);
|
||||
result.fold(
|
||||
(failure) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
userList: _ensureUserInList([], user),
|
||||
selectedUserId: currentUser.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
(users) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
userList: _ensureUserInList(_uniqueUsers(users), user),
|
||||
selectedUserId: currentUser.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 换组织:清空场站与负责人,重新拉场站
|
||||
Future<void> onOrgChanged(int orgId) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectedOrgId: orgId,
|
||||
siteList: const [],
|
||||
userList: const [],
|
||||
clearSite: true,
|
||||
clearUser: true,
|
||||
clearError: true,
|
||||
),
|
||||
);
|
||||
|
||||
final result = await _getSitesByOrgUseCase(orgId);
|
||||
result.fold(
|
||||
(failure) => emit(state.copyWith(errorMessage: failure.message)),
|
||||
(sites) => emit(state.copyWith(siteList: _uniqueSites(sites))),
|
||||
);
|
||||
}
|
||||
|
||||
/// 换场站:清空负责人,重新拉人员;[siteId] 传 null 表示不限定场站
|
||||
Future<void> onSiteChanged(int? siteId) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectedSiteId: siteId,
|
||||
clearSite: siteId == null,
|
||||
userList: const [],
|
||||
clearUser: true,
|
||||
clearError: true,
|
||||
),
|
||||
);
|
||||
|
||||
if (siteId == null) return;
|
||||
|
||||
final result = await _getUsersBySiteUseCase(siteId);
|
||||
result.fold(
|
||||
(failure) => emit(state.copyWith(errorMessage: failure.message)),
|
||||
(users) => emit(state.copyWith(userList: _uniqueUsers(users))),
|
||||
);
|
||||
}
|
||||
|
||||
void onUserChanged(int? userId) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectedUserId: userId,
|
||||
clearUser: userId == null,
|
||||
clearError: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 清空已消费的错误提示,避免 BlocListener 重复弹出
|
||||
void clearError() {
|
||||
if (state.errorMessage == null) return;
|
||||
emit(state.copyWith(clearError: true));
|
||||
}
|
||||
|
||||
/// 记录扫码值,并返回它与当前设备是否一致。
|
||||
///
|
||||
/// 二维码里可能是纯编码、带前缀的 URL 或一串文本,所以做宽松匹配:
|
||||
/// 归一化(去空白 + 大写)后只要有一方包含另一方就算同一台设备。
|
||||
bool recordScan({
|
||||
required String scanned,
|
||||
required String deviceCode,
|
||||
required String deviceId,
|
||||
}) {
|
||||
final code = scanned.trim();
|
||||
emit(state.copyWith(scannedValue: code));
|
||||
|
||||
final normalized = code.replaceAll(RegExp(r'\s'), '').toUpperCase();
|
||||
if (normalized.isEmpty) return false;
|
||||
|
||||
final candidates = [deviceCode, deviceId]
|
||||
.map((e) => e.replaceAll(RegExp(r'\s'), '').toUpperCase())
|
||||
.where((e) => e.isNotEmpty);
|
||||
|
||||
final matched = candidates.any(
|
||||
(e) => normalized == e || normalized.contains(e) || e.contains(normalized),
|
||||
);
|
||||
debugPrint(
|
||||
'>>> [OwnershipAction] 扫码核验 scanned=$code deviceCode=$deviceCode '
|
||||
'deviceId=$deviceId matched=$matched',
|
||||
);
|
||||
return matched;
|
||||
}
|
||||
|
||||
/// 提交归属操作,按 [DeviceOwnershipActionState.isTransfer] 分流。
|
||||
///
|
||||
/// [deviceIds] 由页面决定(当前设备编码,MC700- 开头)。
|
||||
Future<bool> submit({required List<String> deviceIds}) {
|
||||
return state.isTransfer
|
||||
? _submitTransfer(deviceIds)
|
||||
: _submitUnbind(deviceIds);
|
||||
}
|
||||
|
||||
/// 转交设备 = 绑定接口:把设备挂到选定的组织/场站/负责人下。
|
||||
/// 校验口径与绑定页保持一致,不在这里额外加规则。
|
||||
Future<bool> _submitTransfer(List<String> deviceIds) async {
|
||||
final orgId = state.selectedOrgId;
|
||||
debugPrint(
|
||||
'>>> [OwnershipAction] transfer 入参 deviceIds=$deviceIds orgId=$orgId '
|
||||
'siteId=${state.selectedSiteId} userId=${state.selectedUserId} '
|
||||
'roleKey=$roleKey',
|
||||
);
|
||||
|
||||
if (orgId == null || orgId <= 0) {
|
||||
debugPrint('>>> [OwnershipAction] 前置拦截:组织为空,未发请求');
|
||||
emit(state.copyWith(errorMessage: '请选择所属组织'));
|
||||
return false;
|
||||
}
|
||||
// 转交只开放给「普通用户以上」的角色;普通用户 / 个人角色只能选自己,
|
||||
// 入口已在归属页隐藏,这里再做一道兜底。
|
||||
// 负责人不强制:只挂到组织或场站也是合法挂靠(与绑定页口径一致)。
|
||||
if (!isUserEnabled) {
|
||||
debugPrint('>>> [OwnershipAction] 前置拦截:$roleKey 无转交权限');
|
||||
emit(state.copyWith(errorMessage: '当前角色无转交设备的权限'));
|
||||
return false;
|
||||
}
|
||||
|
||||
emit(state.copyWith(isSubmitting: true, clearError: true));
|
||||
|
||||
final result = await _bindDeviceUseCase(
|
||||
deviceIds: deviceIds,
|
||||
orgId: orgId,
|
||||
siteId: state.selectedSiteId,
|
||||
userId: state.selectedUserId,
|
||||
);
|
||||
|
||||
return result.fold(
|
||||
(failure) {
|
||||
debugPrint('>>> [OwnershipAction] ❌ 转交失败 ${failure.message}');
|
||||
emit(
|
||||
state.copyWith(isSubmitting: false, errorMessage: failure.message),
|
||||
);
|
||||
return false;
|
||||
},
|
||||
(_) {
|
||||
debugPrint('>>> [OwnershipAction] ✅ 转交成功');
|
||||
emit(
|
||||
state.copyWith(
|
||||
isSubmitting: false,
|
||||
isSuccess: true,
|
||||
clearError: true,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 解绑设备 = 解绑接口。
|
||||
///
|
||||
/// 交互与绑定无关:用户只选「个人 / 场站 / 组织」一级,
|
||||
/// 下发只有 deviceIds + type(见 [UnbindScope]),不需要组织/场站/负责人 ID。
|
||||
Future<bool> _submitUnbind(List<String> deviceIds) async {
|
||||
// 下发时一律用角色钳制后的范围,避免前端被改出越权 type
|
||||
final scope = effectiveScope;
|
||||
debugPrint(
|
||||
'>>> [OwnershipAction] unbind 入参 deviceIds=$deviceIds '
|
||||
'scope=${scope.value}(${scope.label}) roleKey=$roleKey '
|
||||
'roleMax=${roleMaxScope.value}',
|
||||
);
|
||||
|
||||
emit(state.copyWith(isSubmitting: true, clearError: true));
|
||||
|
||||
final result = await _unbindDeviceUseCase(
|
||||
deviceIds: deviceIds,
|
||||
scope: scope,
|
||||
);
|
||||
|
||||
return result.fold(
|
||||
(failure) {
|
||||
debugPrint('>>> [OwnershipAction] ❌ 解绑失败 ${failure.message}');
|
||||
emit(
|
||||
state.copyWith(isSubmitting: false, errorMessage: failure.message),
|
||||
);
|
||||
return false;
|
||||
},
|
||||
(_) {
|
||||
debugPrint('>>> [OwnershipAction] ✅ 解绑成功');
|
||||
emit(
|
||||
state.copyWith(
|
||||
isSubmitting: false,
|
||||
isSuccess: true,
|
||||
clearError: true,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../domain/entities/bind_device_entities.dart';
|
||||
|
||||
/// 设备归属操作页状态。
|
||||
///
|
||||
/// 转交与解绑共用一份状态,但交互完全不同:
|
||||
/// - 转交(绑定):逐级选组织 → 场站 → 负责人([orgList] / [siteList] / [userList]);
|
||||
/// - 解绑:不选具体对象,只选清除到哪一级([selectedScope],对应后端 type 1/2/3)。
|
||||
class DeviceOwnershipActionState extends Equatable {
|
||||
final OwnershipAction action;
|
||||
|
||||
/// 转交用:三级选择列表
|
||||
final List<OrgEntity> orgList;
|
||||
final List<SiteEntity> siteList;
|
||||
final List<UserSimpleEntity> userList;
|
||||
final int? selectedOrgId;
|
||||
final int? selectedSiteId;
|
||||
final int? selectedUserId;
|
||||
|
||||
/// 解绑用:用户选中的清除层级(个人 / 场站 / 组织)
|
||||
final UnbindScope selectedScope;
|
||||
|
||||
final String roleKey;
|
||||
final bool isLoading;
|
||||
final bool isSubmitting;
|
||||
final String? errorMessage;
|
||||
final bool isSuccess;
|
||||
|
||||
/// 扫码得到的原始值;null 表示还没扫码(或扫码页被跳过)
|
||||
final String? scannedValue;
|
||||
|
||||
const DeviceOwnershipActionState({
|
||||
this.action = OwnershipAction.unbind,
|
||||
this.orgList = const [],
|
||||
this.siteList = const [],
|
||||
this.userList = const [],
|
||||
this.selectedOrgId,
|
||||
this.selectedSiteId,
|
||||
this.selectedUserId,
|
||||
this.selectedScope = UnbindScope.user,
|
||||
this.roleKey = 'user',
|
||||
this.isLoading = false,
|
||||
this.isSubmitting = false,
|
||||
this.errorMessage,
|
||||
this.isSuccess = false,
|
||||
this.scannedValue,
|
||||
});
|
||||
|
||||
bool get isTransfer => action == OwnershipAction.transfer;
|
||||
|
||||
/// 解绑直接返回用户选中的层级;转交按「选到哪一级」由下级往上推导,
|
||||
/// 仅供结果提示条展示。
|
||||
UnbindScope get scopePreview {
|
||||
if (!isTransfer) return selectedScope;
|
||||
final uid = selectedUserId;
|
||||
if (uid != null && uid > 0) return UnbindScope.user;
|
||||
final sid = selectedSiteId;
|
||||
if (sid != null && sid > 0) return UnbindScope.site;
|
||||
return UnbindScope.org;
|
||||
}
|
||||
|
||||
/// 截止到 [scope] 的归属链路,用于提示条:
|
||||
/// 解绑时只展示会被清除的层级,转交时展示要挂靠的完整链路。
|
||||
String pathTextUpTo(UnbindScope scope) {
|
||||
final orgName = orgList
|
||||
.where((e) => e.id == selectedOrgId)
|
||||
.fold<String?>(null, (_, e) => e.name);
|
||||
final siteName = scope.value >= UnbindScope.site.value
|
||||
? siteList.where((e) => e.id == selectedSiteId).fold<String?>(null, (
|
||||
_,
|
||||
e,
|
||||
) => e.name)
|
||||
: null;
|
||||
final userName = scope.value >= UnbindScope.user.value
|
||||
? userList.where((e) => e.id == selectedUserId).fold<String?>(null, (
|
||||
_,
|
||||
e,
|
||||
) => e.name)
|
||||
: null;
|
||||
return [?orgName, ?siteName, ?userName].join(' / ');
|
||||
}
|
||||
|
||||
DeviceOwnershipActionState copyWith({
|
||||
OwnershipAction? action,
|
||||
List<OrgEntity>? orgList,
|
||||
List<SiteEntity>? siteList,
|
||||
List<UserSimpleEntity>? userList,
|
||||
int? selectedOrgId,
|
||||
int? selectedSiteId,
|
||||
int? selectedUserId,
|
||||
UnbindScope? selectedScope,
|
||||
String? roleKey,
|
||||
bool? isLoading,
|
||||
bool? isSubmitting,
|
||||
String? errorMessage,
|
||||
bool? isSuccess,
|
||||
String? scannedValue,
|
||||
bool clearSite = false,
|
||||
bool clearUser = false,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return DeviceOwnershipActionState(
|
||||
action: action ?? this.action,
|
||||
orgList: orgList ?? this.orgList,
|
||||
siteList: siteList ?? this.siteList,
|
||||
userList: userList ?? this.userList,
|
||||
selectedOrgId: selectedOrgId ?? this.selectedOrgId,
|
||||
// 换组织/换场站时必须能真正清空下级,不能用 ?? 保留旧值
|
||||
selectedSiteId: clearSite ? null : (selectedSiteId ?? this.selectedSiteId),
|
||||
selectedUserId: clearUser ? null : (selectedUserId ?? this.selectedUserId),
|
||||
selectedScope: selectedScope ?? this.selectedScope,
|
||||
roleKey: roleKey ?? this.roleKey,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isSubmitting: isSubmitting ?? this.isSubmitting,
|
||||
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
|
||||
isSuccess: isSuccess ?? this.isSuccess,
|
||||
scannedValue: scannedValue ?? this.scannedValue,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
action,
|
||||
orgList,
|
||||
siteList,
|
||||
userList,
|
||||
selectedOrgId,
|
||||
selectedSiteId,
|
||||
selectedUserId,
|
||||
selectedScope,
|
||||
roleKey,
|
||||
isLoading,
|
||||
isSubmitting,
|
||||
errorMessage,
|
||||
isSuccess,
|
||||
scannedValue,
|
||||
];
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:maibu_satabot_v2/core/di/injection.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
|
||||
import '../cubit/bind_device_cubit.dart';
|
||||
import '../cubit/bind_device_state.dart';
|
||||
@@ -52,9 +53,15 @@ class _BindActivatePageV2State extends State<BindActivatePageV2> {
|
||||
).showSnackBar(SnackBar(content: Text(state.errorMessage!)));
|
||||
}
|
||||
if (state.isSuccess) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('绑定成功')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).translate('robot_bind_v2.bind_success'),
|
||||
),
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
@@ -119,9 +126,9 @@ class _BindActivatePageV2State extends State<BindActivatePageV2> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'绑定激活',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
AppLocalizations.of(context).translate('robot_bind_v2.title'),
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _navy,
|
||||
@@ -134,16 +141,29 @@ class _BindActivatePageV2State extends State<BindActivatePageV2> {
|
||||
|
||||
// ===== 步骤条:1/2 已完成 / 3 当前 =====
|
||||
Widget _buildSteps() {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_stepNode(index: 1, label: '识别设备', mode: 'done'),
|
||||
_stepNode(
|
||||
index: 1,
|
||||
label: l10n.translate('robot_bind_v2.step_identify'),
|
||||
mode: 'done',
|
||||
),
|
||||
_stepLine(),
|
||||
_stepNode(index: 2, label: '确认信息', mode: 'done'),
|
||||
_stepNode(
|
||||
index: 2,
|
||||
label: l10n.translate('robot_bind_v2.step_confirm'),
|
||||
mode: 'done',
|
||||
),
|
||||
_stepLine(),
|
||||
_stepNode(index: 3, label: '绑定激活', mode: 'active'),
|
||||
_stepNode(
|
||||
index: 3,
|
||||
label: l10n.translate('robot_bind_v2.step_bind'),
|
||||
mode: 'active',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -224,6 +244,7 @@ class _BindActivatePageV2State extends State<BindActivatePageV2> {
|
||||
|
||||
// ===== 表单卡:逻辑与旧页一致(级联下拉 + 角色必填) =====
|
||||
Widget _buildFormCard(BindDeviceState state) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
@@ -242,19 +263,25 @@ class _BindActivatePageV2State extends State<BindActivatePageV2> {
|
||||
children: [
|
||||
_buildRoleTag(state.roleKey),
|
||||
const SizedBox(height: 16),
|
||||
_buildLabel('已选装备 ID', required: false),
|
||||
_buildLabel(
|
||||
l10n.translate('robot_bind_v2.equip_id'),
|
||||
required: false,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildReadonlyField(widget.scanResult),
|
||||
const SizedBox(height: 16),
|
||||
_buildLabel('所属组织', required: true),
|
||||
_buildLabel(l10n.translate('robot_bind_v2.org'), required: true),
|
||||
const SizedBox(height: 8),
|
||||
_buildOrgDropdown(state),
|
||||
const SizedBox(height: 16),
|
||||
_buildLabel('所属场站', required: false),
|
||||
_buildLabel(l10n.translate('robot_bind_v2.site'), required: false),
|
||||
const SizedBox(height: 8),
|
||||
_buildSiteDropdown(state),
|
||||
const SizedBox(height: 16),
|
||||
_buildLabel('负责人', required: _cubit.roleKey == 'user'),
|
||||
_buildLabel(
|
||||
l10n.translate('robot_bind_v2.owner'),
|
||||
required: _cubit.roleKey == 'user',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildUserDropdown(state),
|
||||
],
|
||||
@@ -263,14 +290,16 @@ class _BindActivatePageV2State extends State<BindActivatePageV2> {
|
||||
}
|
||||
|
||||
Widget _buildRoleTag(String roleKey) {
|
||||
final roleLabel =
|
||||
const {
|
||||
'admin': '管理员',
|
||||
'manager': '组织管理员',
|
||||
'siteManager': '场站管理员',
|
||||
'user': '普通用户',
|
||||
}[roleKey] ??
|
||||
'普通用户';
|
||||
final l10n = AppLocalizations.of(context);
|
||||
const roleKeyMap = {
|
||||
'admin': 'robot_bind_v2.role_admin',
|
||||
'manager': 'robot_bind_v2.role_manager',
|
||||
'siteManager': 'robot_bind_v2.role_site_manager',
|
||||
'user': 'robot_bind_v2.role_user',
|
||||
};
|
||||
final roleLabel = l10n.translate(
|
||||
roleKeyMap[roleKey] ?? 'robot_bind_v2.role_user',
|
||||
);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
@@ -278,7 +307,10 @@ class _BindActivatePageV2State extends State<BindActivatePageV2> {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'当前角色:$roleLabel($roleKey)',
|
||||
l10n
|
||||
.translate('robot_bind_v2.role_current')
|
||||
.replaceFirst('%s', roleLabel)
|
||||
.replaceFirst('%s', roleKey),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -351,14 +383,21 @@ class _BindActivatePageV2State extends State<BindActivatePageV2> {
|
||||
: null;
|
||||
|
||||
if (!enabled) {
|
||||
return _buildReadonlyField(selectedName ?? '未分配组织');
|
||||
return _buildReadonlyField(
|
||||
selectedName ??
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).translate('robot_bind_v2.unassigned_org'),
|
||||
);
|
||||
}
|
||||
|
||||
final validValue = _validValue(state.orgList, state.selectedOrgId);
|
||||
|
||||
return DropdownButtonFormField<int>(
|
||||
value: validValue,
|
||||
decoration: _dropdownDecoration('请选择'),
|
||||
decoration: _dropdownDecoration(
|
||||
AppLocalizations.of(context).translate('robot_bind_v2.please_select'),
|
||||
),
|
||||
icon: const Icon(Icons.expand_more, color: _gray),
|
||||
items: state.orgList.map((org) {
|
||||
return DropdownMenuItem<int>(
|
||||
@@ -385,10 +424,18 @@ class _BindActivatePageV2State extends State<BindActivatePageV2> {
|
||||
: null;
|
||||
|
||||
if (!enabled) {
|
||||
return _buildReadonlyField(selectedName ?? '未分配场站');
|
||||
return _buildReadonlyField(
|
||||
selectedName ??
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).translate('robot_bind_v2.unassigned_site'),
|
||||
);
|
||||
}
|
||||
|
||||
final hintText = state.selectedOrgId == null ? '请先选择组织' : '请选择';
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final hintText = state.selectedOrgId == null
|
||||
? l10n.translate('robot_bind_v2.select_org_first')
|
||||
: l10n.translate('robot_bind_v2.please_select');
|
||||
final validValue = _validValue(state.siteList, state.selectedSiteId);
|
||||
|
||||
return DropdownButtonFormField<int>(
|
||||
@@ -420,10 +467,18 @@ class _BindActivatePageV2State extends State<BindActivatePageV2> {
|
||||
: null;
|
||||
|
||||
if (!enabled) {
|
||||
return _buildReadonlyField(selectedName ?? '未分配负责人');
|
||||
return _buildReadonlyField(
|
||||
selectedName ??
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).translate('robot_bind_v2.unassigned_owner'),
|
||||
);
|
||||
}
|
||||
|
||||
final hintText = state.selectedSiteId == null ? '请先选择场站' : '请选择';
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final hintText = state.selectedSiteId == null
|
||||
? l10n.translate('robot_bind_v2.select_site_first')
|
||||
: l10n.translate('robot_bind_v2.please_select');
|
||||
final validValue = _validValue(state.userList, state.selectedUserId);
|
||||
|
||||
return DropdownButtonFormField<int>(
|
||||
@@ -500,12 +555,14 @@ class _BindActivatePageV2State extends State<BindActivatePageV2> {
|
||||
),
|
||||
),
|
||||
]
|
||||
: const [
|
||||
Icon(Icons.check, color: Colors.white, size: 20),
|
||||
SizedBox(width: 10),
|
||||
: [
|
||||
const Icon(Icons.check, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'确认绑定',
|
||||
style: TextStyle(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).translate('robot_bind_v2.submit'),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
|
||||
import 'bind_activate_page_v2.dart';
|
||||
|
||||
@@ -27,7 +28,6 @@ class _BindDevicePageV2State extends State<BindDevicePageV2> {
|
||||
static const _greenDark = Color(0xFF1D9E57);
|
||||
static const _greenBg = Color(0xFFE5F8EC);
|
||||
static const _divider = Color(0xFFF0F4FA);
|
||||
static const _unknown = '未知';
|
||||
static const _btnGradient = LinearGradient(
|
||||
colors: [Color(0xFF2BAFFE), Color(0xFF1C96F6), Color(0xFF0B7DEE)],
|
||||
);
|
||||
@@ -93,9 +93,11 @@ class _BindDevicePageV2State extends State<BindDevicePageV2> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'确认设备信息',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).translate('device_pages_v2.bind_title'),
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _navy,
|
||||
@@ -108,16 +110,29 @@ class _BindDevicePageV2State extends State<BindDevicePageV2> {
|
||||
|
||||
// ===== 步骤条:1 已完成 / 2 当前 / 3 待进行 =====
|
||||
Widget _buildSteps() {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_stepNode(index: 1, label: '识别设备', mode: 'done'),
|
||||
_stepNode(
|
||||
index: 1,
|
||||
label: l10n.translate('device_pages_v2.step_scan'),
|
||||
mode: 'done',
|
||||
),
|
||||
_stepLine(),
|
||||
_stepNode(index: 2, label: '确认信息', mode: 'active'),
|
||||
_stepNode(
|
||||
index: 2,
|
||||
label: l10n.translate('device_pages_v2.step_confirm'),
|
||||
mode: 'active',
|
||||
),
|
||||
_stepLine(),
|
||||
_stepNode(index: 3, label: '绑定激活', mode: 'wait'),
|
||||
_stepNode(
|
||||
index: 3,
|
||||
label: l10n.translate('device_pages_v2.step_bind'),
|
||||
mode: 'wait',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -210,7 +225,9 @@ class _BindDevicePageV2State extends State<BindDevicePageV2> {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'已识别设备,请确认信息与归属',
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).translate('device_pages_v2.bind_banner'),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -225,6 +242,8 @@ class _BindDevicePageV2State extends State<BindDevicePageV2> {
|
||||
|
||||
// ===== 设备信息卡:扫出来的名字填名称/序列号,其余未知 =====
|
||||
Widget _buildDeviceCard() {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final unknown = l10n.translate('device_detail_v2.unknown_version');
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
@@ -274,22 +293,34 @@ class _BindDevicePageV2State extends State<BindDevicePageV2> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_buildBadge(_unknown),
|
||||
_buildBadge(unknown),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
_unknown,
|
||||
unknown,
|
||||
style: const TextStyle(fontSize: 13, color: _gray),
|
||||
),
|
||||
),
|
||||
const Divider(height: 28, thickness: 1, color: _divider),
|
||||
_infoRow('设备序列号', widget.scanResult),
|
||||
_infoRow('设备绑定状态', _unknown),
|
||||
_infoRow('设备连接', _unknown),
|
||||
_infoRow('服务区域', _unknown),
|
||||
_infoRow(l10n.translate('device_pages_v2.bind_sn'), widget.scanResult),
|
||||
_infoRow(
|
||||
l10n.translate('device_pages_v2.bind_status'),
|
||||
unknown,
|
||||
muted: true,
|
||||
),
|
||||
_infoRow(
|
||||
l10n.translate('device_pages_v2.bind_conn'),
|
||||
unknown,
|
||||
muted: true,
|
||||
),
|
||||
_infoRow(
|
||||
l10n.translate('device_pages_v2.bind_area'),
|
||||
unknown,
|
||||
muted: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -313,8 +344,7 @@ class _BindDevicePageV2State extends State<BindDevicePageV2> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoRow(String label, String value) {
|
||||
final isUnknown = value == _unknown;
|
||||
Widget _infoRow(String label, String value, {bool muted = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 9),
|
||||
child: Row(
|
||||
@@ -328,7 +358,7 @@ class _BindDevicePageV2State extends State<BindDevicePageV2> {
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isUnknown ? _gray : _navy,
|
||||
color: muted ? _gray : _navy,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -357,7 +387,9 @@ class _BindDevicePageV2State extends State<BindDevicePageV2> {
|
||||
const Icon(Icons.verified_user_outlined, color: _blue, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'设备归属验证',
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).translate('device_pages_v2.bind_verify_title'),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
@@ -370,8 +402,10 @@ class _BindDevicePageV2State extends State<BindDevicePageV2> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 28),
|
||||
child: Text(
|
||||
'仅扫描 SN 不代表已获得控制权。绑定前需要激活凭证或设备本地确认。',
|
||||
style: TextStyle(fontSize: 13, color: _gray, height: 1.6),
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).translate('device_pages_v2.bind_verify_desc'),
|
||||
style: const TextStyle(fontSize: 13, color: _gray, height: 1.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -403,14 +437,16 @@ class _BindDevicePageV2State extends State<BindDevicePageV2> {
|
||||
),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: const Row(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.arrow_forward, color: Colors.white, size: 20),
|
||||
SizedBox(width: 10),
|
||||
const Icon(Icons.arrow_forward, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'验证归属并继续',
|
||||
style: TextStyle(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).translate('device_pages_v2.bind_next'),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../../../../core/bluetooth/mc700_device_config.dart';
|
||||
import '../../../../../core/bluetooth/protocol_parser.dart';
|
||||
import '../../../../../core/protocol/machine_protocol_constants.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
import '../../../../../core/services/device_permission_service.dart';
|
||||
import '../../domain/entities/device_entity.dart';
|
||||
import '../../domain/usecases/get_device_by_spilt_code_usecase.dart';
|
||||
@@ -36,6 +37,8 @@ class BleDeviceDetailPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
AppLocalizations get _l10n => AppLocalizations.of(context);
|
||||
|
||||
bool _isConnecting = false;
|
||||
bool _isConnected = false;
|
||||
List<BluetoothService> _services = [];
|
||||
@@ -215,12 +218,16 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('已有设备连接'),
|
||||
content: Text('当前已连接设备:$connName\n\n请先断开当前设备后再连接新设备。'),
|
||||
title: Text(_l10n.translate('ble_detail_v2.dlg_occupied_title')),
|
||||
content: Text(
|
||||
_l10n
|
||||
.translate('ble_detail_v2.dlg_occupied_msg')
|
||||
.replaceFirst('%s', connName),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('知道了'),
|
||||
child: Text(_l10n.translate('ble_detail_v2.dlg_ok')),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -289,9 +296,9 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
Future<void> _bindDevice() async {
|
||||
final bleName = _bleDeviceName;
|
||||
if (bleName.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('无法获取蓝牙设备名称')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(_l10n.translate('ble_detail_v2.err_no_name'))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -324,18 +331,28 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
// 接口未查到(返回无 data)→ 与之前一致弹窗提示
|
||||
if (matchedDevice == null || matchedDevice.name.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('未找到名称包含「$bleName」的设备')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_l10n
|
||||
.translate('ble_detail_v2.err_no_match')
|
||||
.replaceFirst('%s', bleName),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchedDevice.deviceId.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('匹配到的设备缺少设备ID')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_l10n.translate('ble_detail_v2.err_no_device_id'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -354,9 +371,15 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
}
|
||||
debugPrint('>>> [BindDevice] error: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('绑定设备失败: $e')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_l10n
|
||||
.translate('ble_detail_v2.err_bind_failed')
|
||||
.replaceFirst('%s', '$e'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -493,31 +516,44 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
String _commandName(int command) {
|
||||
String _commandKey(int command) {
|
||||
switch (command) {
|
||||
case MachineProtocolConstants.cmdRemoteControl:
|
||||
return '远程遥控';
|
||||
return 'ble_detail_v2.cmd_remote';
|
||||
case MachineProtocolConstants.cmdPathPlanning:
|
||||
return '路径规划';
|
||||
return 'ble_detail_v2.cmd_path';
|
||||
case MachineProtocolConstants.cmdStatusInfo:
|
||||
return '状态信息';
|
||||
return 'ble_detail_v2.cmd_status';
|
||||
case MachineProtocolConstants.cmdGetId:
|
||||
return '查询ID';
|
||||
return 'ble_detail_v2.cmd_get_id';
|
||||
case MachineProtocolConstants.cmdGetAuth:
|
||||
return '获取授权';
|
||||
return 'ble_detail_v2.cmd_get_auth';
|
||||
case MachineProtocolConstants.cmdReadConfig:
|
||||
return '读配置';
|
||||
return 'ble_detail_v2.cmd_read_cfg';
|
||||
case MachineProtocolConstants.cmdWriteConfig:
|
||||
return '写配置';
|
||||
return 'ble_detail_v2.cmd_write_cfg';
|
||||
case MachineProtocolConstants.cmdObstacleAvoid:
|
||||
return '避障';
|
||||
return 'ble_detail_v2.cmd_obstacle';
|
||||
case MachineProtocolConstants.cmdHeartbeat:
|
||||
return '心跳包';
|
||||
return 'ble_detail_v2.cmd_heartbeat';
|
||||
default:
|
||||
return '未知(0x${command.toRadixString(16).toUpperCase().padLeft(2, '0')})';
|
||||
return 'ble_detail_v2.cmd_unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/// 命令名称:未知命令需要带上十六进制命令码
|
||||
String _commandName(int command) {
|
||||
final key = _commandKey(command);
|
||||
final label = _l10n.translate(key);
|
||||
if (key == 'ble_detail_v2.cmd_unknown') {
|
||||
return label.replaceFirst(
|
||||
'%s',
|
||||
command.toRadixString(16).toUpperCase().padLeft(2, '0'),
|
||||
);
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final device = widget.device;
|
||||
@@ -535,7 +571,9 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
? advData!.localName!
|
||||
: (device.advName.isNotEmpty
|
||||
? device.advName
|
||||
: '未知设备'))));
|
||||
: _l10n.translate(
|
||||
'ble_detail_v2.unknown_device',
|
||||
)))));
|
||||
final scanResult = widget.scanResult;
|
||||
|
||||
return Scaffold(
|
||||
@@ -547,7 +585,10 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text('设备详情', style: TextStyle(color: Colors.white)),
|
||||
title: Text(
|
||||
_l10n.translate('ble_detail_v2.title'),
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
@@ -623,23 +664,31 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
const SizedBox(height: 16),
|
||||
Divider(height: 1, color: context.appColors.divider),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('设备名称', name),
|
||||
_buildInfoRow(
|
||||
_l10n.translate('ble_detail_v2.row_name'),
|
||||
name,
|
||||
),
|
||||
if (scanResult != null) ...[
|
||||
_buildInfoRow('信号强度', '${scanResult.rssi} dBm'),
|
||||
_buildInfoRow(
|
||||
'广播名称',
|
||||
_l10n.translate('ble_detail_v2.row_rssi'),
|
||||
'${scanResult.rssi} dBm',
|
||||
),
|
||||
_buildInfoRow(
|
||||
_l10n.translate('ble_detail_v2.row_adv_name'),
|
||||
scanResult.device.advName.isEmpty
|
||||
? '-'
|
||||
: scanResult.device.advName,
|
||||
),
|
||||
],
|
||||
_buildInfoRow(
|
||||
'连接状态',
|
||||
_l10n.translate('ble_detail_v2.row_conn'),
|
||||
_isConnected
|
||||
? '已连接'
|
||||
? _l10n.translate('ble_detail_v2.state_connected')
|
||||
: _isConnecting
|
||||
? '连接中 ${_connectingCountdown}s...'
|
||||
: '未连接',
|
||||
? _l10n
|
||||
.translate('ble_detail_v2.state_connecting')
|
||||
.replaceFirst('%s', '$_connectingCountdown')
|
||||
: _l10n.translate('ble_detail_v2.state_disconnected'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -655,7 +704,9 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'连接中 ${_connectingCountdown}s',
|
||||
_l10n
|
||||
.translate('ble_detail_v2.state_connecting_short')
|
||||
.replaceFirst('%s', '${_connectingCountdown}s'),
|
||||
style: TextStyle(fontSize: 12, color: context.appColors.warning),
|
||||
),
|
||||
);
|
||||
@@ -668,7 +719,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'已连接',
|
||||
_l10n.translate('ble_detail_v2.state_connected'),
|
||||
style: TextStyle(fontSize: 12, color: context.appColors.success),
|
||||
),
|
||||
);
|
||||
@@ -680,7 +731,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'未连接',
|
||||
_l10n.translate('ble_detail_v2.state_disconnected'),
|
||||
style: TextStyle(fontSize: 12, color: context.appColors.textTertiary),
|
||||
),
|
||||
);
|
||||
@@ -734,7 +785,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'操作',
|
||||
_l10n.translate('ble_detail_v2.sec_actions'),
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -762,14 +813,22 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
),
|
||||
child: _isConnecting
|
||||
? Text(
|
||||
'连接中 ${_connectingCountdown}s',
|
||||
_l10n
|
||||
.translate('ble_detail_v2.state_connecting_short')
|
||||
.replaceFirst('%s', '${_connectingCountdown}s'),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
)
|
||||
: Text(_isConnected ? '断开连接' : '连接设备'),
|
||||
: Text(
|
||||
_l10n.translate(
|
||||
_isConnected
|
||||
? 'ble_detail_v2.btn_disconnect'
|
||||
: 'ble_detail_v2.btn_connect',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -790,7 +849,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'绑定设备',
|
||||
_l10n.translate('ble_detail_v2.btn_bind'),
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -807,7 +866,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isConnected ? _sendReadConfig : null,
|
||||
icon: const Icon(Icons.settings, size: 18),
|
||||
label: const Text('读配置'),
|
||||
label: Text(_l10n.translate('ble_detail_v2.btn_read_cfg')),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
backgroundColor: _isConnected
|
||||
@@ -847,7 +906,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'服务列表',
|
||||
_l10n.translate('ble_detail_v2.sec_services'),
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -984,7 +1043,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'发送指令',
|
||||
_l10n.translate('ble_detail_v2.sec_commands'),
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -997,7 +1056,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _sendReadConfig,
|
||||
icon: const Icon(Icons.settings, size: 18),
|
||||
label: const Text('读配置'),
|
||||
label: Text(_l10n.translate('ble_detail_v2.btn_read_cfg')),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: context.appColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
@@ -1038,7 +1097,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'数据推送',
|
||||
_l10n.translate('ble_detail_v2.sec_push'),
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -1077,7 +1136,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
BleManager.instance.clearReceivedPackets();
|
||||
},
|
||||
child: Text(
|
||||
'清空',
|
||||
_l10n.translate('ble_detail_v2.clear'),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: context.appColors.primary,
|
||||
@@ -1092,7 +1151,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'等待接收数据...',
|
||||
_l10n.translate('ble_detail_v2.waiting'),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: context.appColors.textTertiary,
|
||||
@@ -1239,7 +1298,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'设备配置',
|
||||
_l10n.translate('ble_detail_v2.sec_config'),
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -1264,7 +1323,11 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
)
|
||||
: const Icon(Icons.cloud_upload, size: 14),
|
||||
label: Text(
|
||||
_writeConfigLoading ? '写入中...' : '写配置',
|
||||
_l10n.translate(
|
||||
_writeConfigLoading
|
||||
? 'ble_detail_v2.writing'
|
||||
: 'ble_detail_v2.write_cfg',
|
||||
),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
@@ -1296,9 +1359,11 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
_showEditDialog(label, currentValue);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('暂不支持更改'),
|
||||
duration: Duration(seconds: 1),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_l10n.translate('ble_detail_v2.toast_not_editable'),
|
||||
),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1393,7 +1458,9 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
title: Text('修改 $label'),
|
||||
title: Text(
|
||||
_l10n.translate('ble_detail_v2.edit_title').replaceFirst('%s', label),
|
||||
),
|
||||
content: Form(
|
||||
key: formKey,
|
||||
child: TextField(
|
||||
@@ -1401,9 +1468,11 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.text,
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入新值',
|
||||
hintText: _l10n.translate('ble_detail_v2.edit_hint'),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixText: '当前: $currentValue',
|
||||
suffixText: _l10n
|
||||
.translate('ble_detail_v2.edit_current')
|
||||
.replaceFirst('%s', currentValue),
|
||||
suffixStyle: TextStyle(
|
||||
fontSize: 11,
|
||||
color: context.appColors.textTertiary,
|
||||
@@ -1415,7 +1484,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('取消'),
|
||||
child: Text(_l10n.translate('ble_detail_v2.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
@@ -1445,7 +1514,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
});
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('确定'),
|
||||
child: Text(_l10n.translate('ble_detail_v2.confirm')),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1465,7 +1534,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
if (chipUid.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('未获取到设备序列号,请先读配置'),
|
||||
content: Text(_l10n.translate('ble_detail_v2.err_no_chip')),
|
||||
backgroundColor: context.appColors.warning,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
@@ -1480,7 +1549,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
if (!hasPermission) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('权限校验未通过,无法写入配置'),
|
||||
content: Text(_l10n.translate('ble_detail_v2.err_no_permission')),
|
||||
backgroundColor: context.appColors.danger,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
@@ -1556,7 +1625,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
if (readbackPayload == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('回读超时,请手动读配置确认'),
|
||||
content: Text(_l10n.translate('ble_detail_v2.err_readback_timeout')),
|
||||
backgroundColor: context.appColors.warning,
|
||||
),
|
||||
);
|
||||
@@ -1609,7 +1678,12 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('校验不一致: ${mismatched.join('、')},已恢复为设备实际值'),
|
||||
content: Text(
|
||||
_l10n.translate('ble_detail_v2.err_mismatch').replaceFirst(
|
||||
'%s',
|
||||
mismatched.join(_l10n.translate('ble_detail_v2.list_sep')),
|
||||
),
|
||||
),
|
||||
backgroundColor: context.appColors.warning,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
@@ -1640,7 +1714,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('写配置成功,已回读确认'),
|
||||
content: Text(_l10n.translate('ble_detail_v2.write_ok')),
|
||||
backgroundColor: context.appColors.success,
|
||||
),
|
||||
);
|
||||
@@ -1649,7 +1723,11 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('写配置异常: $e'),
|
||||
content: Text(
|
||||
_l10n
|
||||
.translate('ble_detail_v2.write_error')
|
||||
.replaceFirst('%s', '$e'),
|
||||
),
|
||||
backgroundColor: context.appColors.danger,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
|
||||
import '../../../../devices/domain/entities/device_entity.dart';
|
||||
import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart';
|
||||
@@ -39,10 +40,12 @@ class DeviceAllFunctionsPageV2 extends StatefulWidget {
|
||||
|
||||
class _FuncItem {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
/// 语言包 key(device_pages_v2.xxx),渲染与搜索时再取词
|
||||
final String labelKey;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _FuncItem(this.icon, this.label, [this.onTap]);
|
||||
const _FuncItem(this.icon, this.labelKey, [this.onTap]);
|
||||
}
|
||||
|
||||
class _DeviceAllFunctionsPageV2State extends State<DeviceAllFunctionsPageV2> {
|
||||
@@ -73,7 +76,10 @@ class _DeviceAllFunctionsPageV2State extends State<DeviceAllFunctionsPageV2> {
|
||||
return (alias != null && alias.isNotEmpty) ? alias : widget.robot.name;
|
||||
}
|
||||
|
||||
void _todo() => CapsuleToast.show('功能暂未开发', showCheck: false);
|
||||
void _todo() => CapsuleToast.show(
|
||||
AppLocalizations.of(context).translate('device_list_v2.feature_todo'),
|
||||
showCheck: false,
|
||||
);
|
||||
|
||||
/// 机器人信息:进「机器人完整信息」页(W02 设计稿)
|
||||
void _openRobotInfo() {
|
||||
@@ -196,32 +202,76 @@ class _DeviceAllFunctionsPageV2State extends State<DeviceAllFunctionsPageV2> {
|
||||
List<(String, List<_FuncItem>)> _sections() {
|
||||
return [
|
||||
(
|
||||
'视频与操作',
|
||||
'device_pages_v2.group_video',
|
||||
[
|
||||
_FuncItem(Icons.videocam_outlined, '实时图传', _openLiveVideo),
|
||||
_FuncItem(Icons.sports_esports_outlined, '远程遥控', _openLegacyPage),
|
||||
_FuncItem(Icons.location_on_outlined, '一键寻车', _openFindRobot),
|
||||
_FuncItem(Icons.mic_outlined, '语音喊话', _openVoiceBroadcast),
|
||||
_FuncItem(Icons.power_settings_new, 'APP开关机', _openAppPower),
|
||||
_FuncItem(Icons.person_outline, '人员跟随'),
|
||||
_FuncItem(Icons.home_outlined, '一键返航'),
|
||||
_FuncItem(
|
||||
Icons.videocam_outlined,
|
||||
'device_pages_v2.live_video',
|
||||
_openLiveVideo,
|
||||
),
|
||||
_FuncItem(
|
||||
Icons.sports_esports_outlined,
|
||||
'device_pages_v2.remote',
|
||||
_openLegacyPage,
|
||||
),
|
||||
_FuncItem(
|
||||
Icons.location_on_outlined,
|
||||
'device_pages_v2.find',
|
||||
_openFindRobot,
|
||||
),
|
||||
_FuncItem(
|
||||
Icons.mic_outlined,
|
||||
'device_pages_v2.voice',
|
||||
_openVoiceBroadcast,
|
||||
),
|
||||
_FuncItem(
|
||||
Icons.power_settings_new,
|
||||
'device_pages_v2.app_power',
|
||||
_openAppPower,
|
||||
),
|
||||
_FuncItem(Icons.person_outline, 'device_pages_v2.follow'),
|
||||
_FuncItem(Icons.home_outlined, 'device_pages_v2.return_home'),
|
||||
],
|
||||
),
|
||||
(
|
||||
'设备信息',
|
||||
'device_pages_v2.group_info',
|
||||
[
|
||||
_FuncItem(Icons.smart_toy_outlined, '机器人信息', _openRobotInfo),
|
||||
_FuncItem(Icons.location_on_outlined, '位置与地图', _openLocationMap),
|
||||
_FuncItem(
|
||||
Icons.smart_toy_outlined,
|
||||
'device_pages_v2.robot_info',
|
||||
_openRobotInfo,
|
||||
),
|
||||
_FuncItem(
|
||||
Icons.location_on_outlined,
|
||||
'device_pages_v2.location_map',
|
||||
_openLocationMap,
|
||||
),
|
||||
// 预设语音管理也放在设备信息里(与「语音喊话」磁贴同一页)
|
||||
_FuncItem(Icons.record_voice_over_outlined, '预设语音', _openVoicePreset),
|
||||
_FuncItem(Icons.bar_chart, '运行记录', _openRunRecord),
|
||||
_FuncItem(
|
||||
Icons.record_voice_over_outlined,
|
||||
'device_pages_v2.voice_preset',
|
||||
_openVoicePreset,
|
||||
),
|
||||
_FuncItem(
|
||||
Icons.bar_chart,
|
||||
'device_pages_v2.run_record',
|
||||
_openRunRecord,
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
'设备监测',
|
||||
'device_pages_v2.group_monitor',
|
||||
[
|
||||
_FuncItem(Icons.sensors_outlined, '传感器监测', _openSensor),
|
||||
_FuncItem(Icons.warning_amber_outlined, '故障诊断', _openFaultCenter),
|
||||
_FuncItem(
|
||||
Icons.sensors_outlined,
|
||||
'device_pages_v2.sensor',
|
||||
_openSensor,
|
||||
),
|
||||
_FuncItem(
|
||||
Icons.warning_amber_outlined,
|
||||
'device_pages_v2.fault',
|
||||
_openFaultCenter,
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
@@ -229,12 +279,20 @@ class _DeviceAllFunctionsPageV2State extends State<DeviceAllFunctionsPageV2> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final sections = _sections()
|
||||
.map(
|
||||
(s) => (
|
||||
s.$1,
|
||||
l10n.translate(s.$1),
|
||||
s.$2
|
||||
.where((e) => _query.isEmpty || e.label.contains(_query))
|
||||
.where(
|
||||
(e) =>
|
||||
_query.isEmpty ||
|
||||
l10n
|
||||
.translate(e.labelKey)
|
||||
.toLowerCase()
|
||||
.contains(_query.toLowerCase()),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
)
|
||||
@@ -269,12 +327,12 @@ class _DeviceAllFunctionsPageV2State extends State<DeviceAllFunctionsPageV2> {
|
||||
_buildSearch(),
|
||||
const SizedBox(height: 16),
|
||||
if (sections.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 40),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
child: Text(
|
||||
'未找到匹配功能',
|
||||
l10n.translate('device_pages_v2.empty'),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: _gray),
|
||||
style: const TextStyle(fontSize: 13, color: _gray),
|
||||
),
|
||||
)
|
||||
else
|
||||
@@ -329,9 +387,11 @@ class _DeviceAllFunctionsPageV2State extends State<DeviceAllFunctionsPageV2> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'全部功能中心',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).translate('device_pages_v2.title'),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _navy,
|
||||
@@ -418,7 +478,13 @@ class _DeviceAllFunctionsPageV2State extends State<DeviceAllFunctionsPageV2> {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
_online ? '在线' : '离线',
|
||||
_online
|
||||
? AppLocalizations.of(
|
||||
context,
|
||||
).translate('device_list_v2.online')
|
||||
: AppLocalizations.of(
|
||||
context,
|
||||
).translate('device_list_v2.offline'),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -430,7 +496,7 @@ class _DeviceAllFunctionsPageV2State extends State<DeviceAllFunctionsPageV2> {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${widget.robot.type} · ${DeviceV2Display.editionOf(widget.robot.name)}',
|
||||
'${widget.robot.type} · ${DeviceV2Display.editionOf(widget.robot.name, AppLocalizations.of(context))}',
|
||||
style: const TextStyle(fontSize: 12, color: _gray),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@@ -448,8 +514,9 @@ class _DeviceAllFunctionsPageV2State extends State<DeviceAllFunctionsPageV2> {
|
||||
|
||||
// ===== 说明卡:当前设备 + 能力边界 =====
|
||||
Widget _buildNotice() {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final device =
|
||||
'${widget.robot.type} · ${DeviceV2Display.editionOf(widget.robot.name)}';
|
||||
'${widget.robot.type} · ${DeviceV2Display.editionOf(widget.robot.name, l10n)}';
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
@@ -465,12 +532,12 @@ class _DeviceAllFunctionsPageV2State extends State<DeviceAllFunctionsPageV2> {
|
||||
height: 1.5,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: '当前设备:'),
|
||||
TextSpan(text: l10n.translate('device_pages_v2.notice_prefix')),
|
||||
TextSpan(
|
||||
text: device,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, color: _navy),
|
||||
),
|
||||
const TextSpan(text: '。仅展示设备实际支持的功能;本版不提供自主规划、任务下发及一键返航。'),
|
||||
TextSpan(text: l10n.translate('device_pages_v2.notice_suffix')),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -497,15 +564,20 @@ class _DeviceAllFunctionsPageV2State extends State<DeviceAllFunctionsPageV2> {
|
||||
onChanged: (v) => setState(() => _query = v.trim()),
|
||||
style: const TextStyle(fontSize: 14, color: _navy),
|
||||
cursorColor: _blue,
|
||||
decoration: const InputDecoration(
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
hintText: '搜索功能名称',
|
||||
hintStyle: TextStyle(fontSize: 14, color: Color(0xFFB9C2CF)),
|
||||
hintText: AppLocalizations.of(
|
||||
context,
|
||||
).translate('device_pages_v2.search_hint'),
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFFB9C2CF),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -557,7 +629,7 @@ class _DeviceAllFunctionsPageV2State extends State<DeviceAllFunctionsPageV2> {
|
||||
Icon(item.icon, size: 26, color: _blue),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item.label,
|
||||
AppLocalizations.of(context).translate(item.labelKey),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,959 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
|
||||
import 'package:maibu_satabot_v2/core/di/injection.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
|
||||
import '../../data/models/robot_data_model.dart';
|
||||
import '../../domain/entities/bind_device_entities.dart';
|
||||
import '../cubit/device_ownership_action_cubit.dart';
|
||||
import '../cubit/device_ownership_action_state.dart';
|
||||
import '../widgets/robot_summary_card_v2.dart';
|
||||
|
||||
/// 设备归属操作页 V2(入口:设备归属与安全页上的两个操作)。
|
||||
///
|
||||
/// 一个页面承载两个动作,因为它们的交互完全一致,差别只在提交:
|
||||
/// - [OwnershipAction.transfer] 转交设备 = 调绑定接口,把设备挂到选定层级;
|
||||
/// - [OwnershipAction.unbind] 解绑设备 = 调解绑接口,把设备从选定层级摘除。
|
||||
///
|
||||
/// 视觉沿用 [RobotOwnershipPageV2]:浅蓝渐变底 + 白卡 + KV 行 + 页脚提示;
|
||||
/// 业务沿用绑定流程的三级级联:组织 → 场站 → 负责人逐级加载不可越级,
|
||||
/// 角色决定哪一级可编辑(admin 全可选、manager 锁组织、siteManager 锁组织+场站、
|
||||
/// user / personal 三级全锁、只能是自己)。
|
||||
class DeviceOwnershipActionPageV2 extends StatefulWidget {
|
||||
final RobotDataModel robot;
|
||||
final OwnershipAction action;
|
||||
|
||||
const DeviceOwnershipActionPageV2({
|
||||
super.key,
|
||||
required this.robot,
|
||||
required this.action,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DeviceOwnershipActionPageV2> createState() =>
|
||||
_DeviceOwnershipActionPageV2State();
|
||||
}
|
||||
|
||||
class _DeviceOwnershipActionPageV2State
|
||||
extends State<DeviceOwnershipActionPageV2> {
|
||||
static const Color _navy = Color(0xFF1A2B4A);
|
||||
static const Color _gray = Color(0xFF86909C);
|
||||
static const Color _hint = Color(0xFFA8B3C5);
|
||||
static const Color _blue = Color(0xFF1C96F6);
|
||||
static const Color _danger = Color(0xFFF0483E);
|
||||
static const Color _cardBorder = Color(0xFFE5EDF7);
|
||||
static const Color _softBlue = Color(0xFFEAF3FE);
|
||||
static const Color _softRed = Color(0xFFFDEDEC);
|
||||
static const Color _softGray = Color(0xFFF2F4F8);
|
||||
static const Color _divider = Color(0xFFF0F4FA);
|
||||
|
||||
static const LinearGradient _dangerGradient = LinearGradient(
|
||||
colors: [Color(0xFFFF7A6B), Color(0xFFF0483E), Color(0xFFD93A30)],
|
||||
);
|
||||
|
||||
static const LinearGradient _primaryGradient = LinearGradient(
|
||||
colors: [Color(0xFF4FAEFB), Color(0xFF1C96F6), Color(0xFF0B7BDC)],
|
||||
);
|
||||
|
||||
late final DeviceOwnershipActionCubit _cubit;
|
||||
|
||||
bool get _isTransfer => widget.action == OwnershipAction.transfer;
|
||||
|
||||
/// i18n 命名空间:两个动作共用同一段 key,靠后缀区分文案
|
||||
static const String _ns = 'device_ownership_action_v2.';
|
||||
|
||||
/// 拼接本页的 i18n key
|
||||
String _k(String suffix) => '$_ns$suffix';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cubit = sl<DeviceOwnershipActionCubit>();
|
||||
_cubit.init(action: widget.action);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 下发的设备标识:设备编码(deviceName,MC700- 开头),不是数字主键 id。
|
||||
List<String> _targetDeviceIds() {
|
||||
final code = widget.robot.name.trim();
|
||||
return [code.isNotEmpty ? code : widget.robot.id];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return BlocProvider.value(
|
||||
value: _cubit,
|
||||
child: BlocListener<DeviceOwnershipActionCubit, DeviceOwnershipActionState>(
|
||||
listener: (context, state) {
|
||||
final message = state.errorMessage;
|
||||
if (message != null && message.isNotEmpty) {
|
||||
CapsuleToast.show(message, showCheck: false);
|
||||
_cubit.clearError();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFFEDF6FD),
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFD9EBFA), Color(0xFFEDF6FD)],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child:
|
||||
BlocBuilder<DeviceOwnershipActionCubit, DeviceOwnershipActionState>(
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildTopBar(),
|
||||
Expanded(
|
||||
child: state.isLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: _blue,
|
||||
),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
16,
|
||||
4,
|
||||
16,
|
||||
16,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
RobotSummaryCardV2(robot: widget.robot),
|
||||
const SizedBox(height: 14),
|
||||
// 转交:三级级联(绑定逻辑);
|
||||
// 解绑:只选清除到哪一级,不是绑定表单
|
||||
if (_isTransfer)
|
||||
_buildScopeCard(state)
|
||||
else
|
||||
_buildScopeSelectCard(state),
|
||||
const SizedBox(height: 12),
|
||||
_buildPreviewBar(state),
|
||||
const SizedBox(height: 14),
|
||||
_buildFooterNote(l10n),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
|
||||
child: _buildSubmitButton(state),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 顶栏:返回 + 居中标题(与归属页一致)=====
|
||||
Widget _buildTopBar() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
color: _navy,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppLocalizations.of(context).translate(
|
||||
_k(_isTransfer ? 'title_transfer' : 'title_unbind'),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: _navy,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 36),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCardTitle(String title, {Color color = _navy}) {
|
||||
return Text(
|
||||
title,
|
||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w800, color: color),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCard({required List<Widget> children}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 16, 14, 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: _cardBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 归属层级卡:三级级联,角色决定可编辑范围 =====
|
||||
Widget _buildScopeCard(DeviceOwnershipActionState state) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return _buildCard(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildCardTitle(
|
||||
l10n.translate(
|
||||
_k(_isTransfer ? 'card_scope_transfer' : 'card_scope_unbind'),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: _softBlue,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
l10n
|
||||
.translate(_k('role_current'))
|
||||
.replaceFirst('%s', l10n.translate(_roleLabelKey(state.roleKey))),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _blue,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// 把“这个角色能触达哪一级”直接写在卡头,不让用户去猜锁图标
|
||||
Text(
|
||||
l10n
|
||||
.translate(
|
||||
_k(_isTransfer ? 'scope_max_transfer' : 'scope_max_unbind'),
|
||||
)
|
||||
.replaceFirst(
|
||||
'%s',
|
||||
l10n.translate(_scopeLabelKey(_cubit.roleMaxScope)),
|
||||
),
|
||||
style: const TextStyle(fontSize: 11.5, color: _hint),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
_buildPickerRow(
|
||||
label: l10n.translate(_k('scope_org')),
|
||||
value: _nameOf(state.orgList, state.selectedOrgId),
|
||||
placeholder: l10n.translate(_k('unassigned_org')),
|
||||
enabled: _cubit.isOrgEnabled,
|
||||
onTap: () => _pickOrg(state),
|
||||
),
|
||||
const Divider(height: 1, thickness: 0.6, color: _divider),
|
||||
_buildPickerRow(
|
||||
label: l10n.translate(_k('scope_site')),
|
||||
value: _nameOf(state.siteList, state.selectedSiteId),
|
||||
placeholder: l10n.translate(_k('unassigned_site')),
|
||||
// 必须先有组织才能选场站(不可越级)
|
||||
enabled: _cubit.isSiteEnabled && state.selectedOrgId != null,
|
||||
onTap: () => _pickSite(state),
|
||||
),
|
||||
const Divider(height: 1, thickness: 0.6, color: _divider),
|
||||
_buildPickerRow(
|
||||
label: l10n.translate(_k('scope_user')),
|
||||
value: _nameOf(state.userList, state.selectedUserId),
|
||||
placeholder: l10n.translate(_k('unassigned_user')),
|
||||
// 必须先有场站才能选负责人(不可越级)
|
||||
enabled: _cubit.isUserEnabled && state.selectedSiteId != null,
|
||||
onTap: () => _pickUser(state),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: _softBlue,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.info_outline_rounded, size: 16, color: _blue),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.translate(
|
||||
_k(_isTransfer ? 'scope_tip_transfer' : 'scope_tip_unbind'),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
height: 1.5,
|
||||
color: Color(0xFF5C6B84),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 解绑:三个选项(个人 / 场站 / 组织),不做三级级联 =====
|
||||
Widget _buildScopeSelectCard(DeviceOwnershipActionState state) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return _buildCard(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildCardTitle(l10n.translate(_k('card_scope_unbind'))),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: _softBlue,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
l10n
|
||||
.translate(_k('role_current'))
|
||||
.replaceFirst(
|
||||
'%s',
|
||||
l10n.translate(_roleLabelKey(state.roleKey)),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _blue,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
l10n
|
||||
.translate(_k('scope_max_unbind'))
|
||||
.replaceFirst(
|
||||
'%s',
|
||||
l10n.translate(_scopeOptionKey(_cubit.roleMaxScope)),
|
||||
),
|
||||
style: const TextStyle(fontSize: 11.5, color: _hint),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildScopeOption(UnbindScope.user, state),
|
||||
const SizedBox(height: 8),
|
||||
_buildScopeOption(UnbindScope.site, state),
|
||||
const SizedBox(height: 8),
|
||||
_buildScopeOption(UnbindScope.org, state),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 12, bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: _softBlue,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.info_outline_rounded, size: 16, color: _blue),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.translate(_k('scope_select_tip')),
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
height: 1.5,
|
||||
color: Color(0xFF5C6B84),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 单个清除层级选项:选中蓝框(组织为红框),越权置灰并锁住
|
||||
Widget _buildScopeOption(UnbindScope scope, DeviceOwnershipActionState state) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final enabled = scope.value <= _cubit.roleMaxScope.value;
|
||||
final selected = state.selectedScope == scope;
|
||||
final isDeepest = scope == UnbindScope.org;
|
||||
final accent = isDeepest ? _danger : _blue;
|
||||
final titleColor = !enabled
|
||||
? _hint
|
||||
: selected
|
||||
? accent
|
||||
: _navy;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: enabled ? () => _cubit.onScopeSelected(scope) : _notifyLockedScope,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? (isDeepest ? _softRed : _softBlue)
|
||||
: (enabled ? Colors.white : _softGray),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: selected ? accent : _cardBorder,
|
||||
width: selected ? 1.4 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildRadio(selected, enabled, accent),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.translate(_scopeOptionKey(scope)),
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
// _scopeOptionKey 已含命名空间前缀,直接拼 _sub,不能再走 _k()
|
||||
l10n.translate('${_scopeOptionKey(scope)}_sub'),
|
||||
style: const TextStyle(fontSize: 11.5, color: _gray),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!enabled)
|
||||
_buildStatusPill(
|
||||
l10n.translate(_k('tag_locked')),
|
||||
_hint,
|
||||
soft: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRadio(bool selected, bool enabled, Color accent) {
|
||||
return Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: selected ? accent : Colors.transparent,
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? accent
|
||||
: (enabled ? const Color(0xFFC9CDD4) : _hint),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: selected
|
||||
? const Icon(Icons.check_rounded, size: 12, color: Colors.white)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 小胶囊标签(与归属页同款)
|
||||
Widget _buildStatusPill(String text, Color color, {bool soft = false}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: soft ? 0.1 : 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _notifyLockedScope() => CapsuleToast.show(
|
||||
AppLocalizations.of(context).translate(_k('scope_locked_hint')),
|
||||
showCheck: false,
|
||||
);
|
||||
|
||||
String _roleLabelKey(String roleKey) {
|
||||
switch (roleKey) {
|
||||
case 'admin':
|
||||
return _k('role_admin');
|
||||
case 'manager':
|
||||
return _k('role_manager');
|
||||
case 'siteManager':
|
||||
return _k('role_site_manager');
|
||||
case 'personal':
|
||||
return _k('role_personal');
|
||||
default:
|
||||
// user 在归属操作里与 personal 同口径:只能是自己
|
||||
return _k('role_user');
|
||||
}
|
||||
}
|
||||
|
||||
String _scopeLabelKey(UnbindScope scope) {
|
||||
switch (scope) {
|
||||
case UnbindScope.user:
|
||||
return _k('scope_user');
|
||||
case UnbindScope.site:
|
||||
return _k('scope_site');
|
||||
case UnbindScope.org:
|
||||
return _k('scope_org');
|
||||
}
|
||||
}
|
||||
|
||||
/// 解绑三个选项的文案 key(个人 / 场站 / 组织,比「负责人」更口语)
|
||||
String _scopeOptionKey(UnbindScope scope) {
|
||||
switch (scope) {
|
||||
case UnbindScope.user:
|
||||
return _k('scope_opt_user');
|
||||
case UnbindScope.site:
|
||||
return _k('scope_opt_site');
|
||||
case UnbindScope.org:
|
||||
return _k('scope_opt_org');
|
||||
}
|
||||
}
|
||||
|
||||
String? _nameOf(List<dynamic> list, int? id) {
|
||||
if (id == null) return null;
|
||||
for (final item in list) {
|
||||
if (item.id == id) return item.name as String;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ===== 可点选 KV 行:可编辑时蓝字 + 箭头,锁定时灰字 + 锁图标 =====
|
||||
Widget _buildPickerRow({
|
||||
required String label,
|
||||
required String? value,
|
||||
required String placeholder,
|
||||
required bool enabled,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: enabled ? onTap : null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 14, bottom: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 13.5, color: _hint)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value ?? placeholder,
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: value == null
|
||||
? _hint
|
||||
: enabled
|
||||
? _blue
|
||||
: _navy,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
enabled
|
||||
? Icons.chevron_right_rounded
|
||||
: Icons.lock_outline_rounded,
|
||||
size: enabled ? 20 : 15,
|
||||
color: enabled ? const Color(0xFFC9CDD4) : _hint,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 结果提示条:把内部层级翻译成用户能看懂的一句话 =====
|
||||
Widget _buildPreviewBar(DeviceOwnershipActionState state) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
// 解绑用角色钳制后的范围,与真正下发的 type 保持一致;
|
||||
// 转交没有 type 概念,直接按已选到的最深一级展示挂靠点。
|
||||
final scope = _isTransfer ? state.scopePreview : _cubit.effectiveScope;
|
||||
final path = state.pathTextUpTo(scope);
|
||||
// 转交用层级名(组织/场站/负责人),解绑用选项名(个人/场站/组织)
|
||||
final scopeText = l10n.translate(
|
||||
_isTransfer ? _scopeLabelKey(scope) : _scopeOptionKey(scope),
|
||||
);
|
||||
|
||||
final prefix = l10n.translate(
|
||||
_k(_isTransfer ? 'preview_transfer' : 'preview_unbind'),
|
||||
);
|
||||
final text = path.isEmpty ? '$prefix $scopeText' : '$prefix $scopeText · $path';
|
||||
|
||||
final accent = _isTransfer ? _blue : _danger;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _cardBorder),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_isTransfer ? Icons.link_rounded : Icons.link_off_rounded,
|
||||
size: 16,
|
||||
color: accent,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _navy,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooterNote(AppLocalizations l10n) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: _softBlue,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.verified_user_rounded, size: 16, color: _blue),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.translate(
|
||||
_k(_isTransfer ? 'footer_transfer' : 'footer_unbind'),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
height: 1.55,
|
||||
color: Color(0xFF5C6B84),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 底部主按钮:转交蓝色、解绑红色 =====
|
||||
Widget _buildSubmitButton(DeviceOwnershipActionState state) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final orgOk = (state.selectedOrgId ?? 0) > 0;
|
||||
|
||||
final bool enabled;
|
||||
if (_isTransfer) {
|
||||
// 转交:组织必填(负责人可不选,表示只挂到组织或场站级)
|
||||
enabled = orgOk && !state.isSubmitting;
|
||||
} else {
|
||||
// 解绑:默认已选最轻的「个人」,只要不在提交中就能点
|
||||
enabled = !state.isSubmitting;
|
||||
}
|
||||
|
||||
final gradient = _isTransfer ? _primaryGradient : _dangerGradient;
|
||||
final accent = _isTransfer ? _blue : _danger;
|
||||
|
||||
return Opacity(
|
||||
opacity: enabled ? 1 : 0.55,
|
||||
child: Container(
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
gradient: gradient,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: enabled
|
||||
? [
|
||||
BoxShadow(
|
||||
color: accent.withValues(alpha: 0.28),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: enabled ? () => _handleSubmit(state) : null,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: state.isSubmitting
|
||||
? const [
|
||||
SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
]
|
||||
: [
|
||||
Icon(
|
||||
_isTransfer
|
||||
? Icons.link_rounded
|
||||
: Icons.link_off_rounded,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
l10n.translate(
|
||||
_k(_isTransfer ? 'submit_transfer' : 'submit_unbind'),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleSubmit(DeviceOwnershipActionState state) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
final success = await _cubit.submit(deviceIds: _targetDeviceIds());
|
||||
if (!success || !mounted) return;
|
||||
|
||||
CapsuleToast.show(
|
||||
l10n.translate(_k(_isTransfer ? 'toast_transfer' : 'toast_unbind')),
|
||||
);
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
// ===== 层级选择器:白卡列表 + 勾选,视觉对齐归属页 =====
|
||||
Future<void> _pickOrg(DeviceOwnershipActionState state) async {
|
||||
final picked = await _showPicker(
|
||||
title: AppLocalizations.of(context).translate(_k('scope_org')),
|
||||
items: [for (final e in state.orgList) (id: e.id, name: e.name)],
|
||||
selectedId: state.selectedOrgId,
|
||||
allowClear: false,
|
||||
);
|
||||
if (picked != null) await _cubit.onOrgChanged(picked);
|
||||
}
|
||||
|
||||
Future<void> _pickSite(DeviceOwnershipActionState state) async {
|
||||
final picked = await _showPicker(
|
||||
title: AppLocalizations.of(context).translate(_k('scope_site')),
|
||||
items: [for (final e in state.siteList) (id: e.id, name: e.name)],
|
||||
selectedId: state.selectedSiteId,
|
||||
allowClear: _cubit.canClearSite,
|
||||
);
|
||||
if (picked == null) return;
|
||||
if (picked == _clearValue) {
|
||||
await _cubit.onSiteChanged(null);
|
||||
return;
|
||||
}
|
||||
await _cubit.onSiteChanged(picked);
|
||||
}
|
||||
|
||||
Future<void> _pickUser(DeviceOwnershipActionState state) async {
|
||||
final picked = await _showPicker(
|
||||
title: AppLocalizations.of(context).translate(_k('scope_user')),
|
||||
items: [for (final e in state.userList) (id: e.id, name: e.name)],
|
||||
selectedId: state.selectedUserId,
|
||||
allowClear: _cubit.canClearUser,
|
||||
);
|
||||
if (picked == null) return;
|
||||
if (picked == _clearValue) {
|
||||
_cubit.onUserChanged(null);
|
||||
return;
|
||||
}
|
||||
_cubit.onUserChanged(picked);
|
||||
}
|
||||
|
||||
/// 选择器里「不指定」的哨兵值:把该级清空,让归属层级上移到上一级
|
||||
static const int _clearValue = -1;
|
||||
|
||||
Future<int?> _showPicker({
|
||||
required String title,
|
||||
required List<({int id, String name})> items,
|
||||
required int? selectedId,
|
||||
required bool allowClear,
|
||||
}) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final rows = <({int id, String name})>[
|
||||
if (allowClear)
|
||||
(
|
||||
id: _clearValue,
|
||||
name: l10n.translate(
|
||||
_k(_isTransfer ? 'picker_clear_transfer' : 'picker_clear_unbind'),
|
||||
),
|
||||
),
|
||||
...items,
|
||||
];
|
||||
|
||||
return showModalBottomSheet<int>(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (sheetCtx) => Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(sheetCtx).size.height * 0.6,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: _navy,
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(sheetCtx),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(4),
|
||||
child: Icon(Icons.close_rounded, size: 20, color: _gray),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, thickness: 0.6, color: _divider),
|
||||
if (rows.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Text(
|
||||
l10n.translate(_k('picker_empty')),
|
||||
style: const TextStyle(fontSize: 13.5, color: _hint),
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: rows.length,
|
||||
separatorBuilder: (_, _) => const Divider(
|
||||
height: 1,
|
||||
thickness: 0.6,
|
||||
indent: 20,
|
||||
endIndent: 20,
|
||||
color: _divider,
|
||||
),
|
||||
itemBuilder: (_, index) {
|
||||
final item = rows[index];
|
||||
final isClear = item.id == _clearValue;
|
||||
final selected = item.id == selectedId;
|
||||
return InkWell(
|
||||
onTap: () => Navigator.pop(sheetCtx, item.id),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 15,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: selected
|
||||
? FontWeight.w700
|
||||
: FontWeight.w500,
|
||||
color: isClear
|
||||
? _gray
|
||||
: selected
|
||||
? _blue
|
||||
: _navy,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (selected)
|
||||
const Icon(
|
||||
Icons.check_rounded,
|
||||
size: 20,
|
||||
color: _blue,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import '../../../../../core/bluetooth/ble_manager.dart';
|
||||
import '../../../../../core/localization/app_localizations.dart';
|
||||
import '../pages/ble_device_detail_page.dart';
|
||||
|
||||
class BluetoothScanModal extends StatefulWidget {
|
||||
@@ -203,15 +204,21 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
? currentConnected.platformName
|
||||
: '${currentConnected.remoteId}';
|
||||
if (!mounted) return;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('已有设备连接'),
|
||||
content: Text('当前已连接设备:$connName\n\n请先断开当前设备后再连接新设备。'),
|
||||
title: Text(l10n.translate('ble_v2.already_connected_title')),
|
||||
content: Text(
|
||||
l10n.translate('ble_v2.already_connected_msg').replaceAll(
|
||||
'%s',
|
||||
connName,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('知道了'),
|
||||
child: Text(l10n.translate('ble_v2.got_it')),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -258,7 +265,14 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'已连接 ${result.device.platformName.isEmpty ? result.device.remoteId : result.device.platformName}',
|
||||
AppLocalizations.of(context)
|
||||
.translate('ble_v2.connected_toast')
|
||||
.replaceAll(
|
||||
'%s',
|
||||
result.device.platformName.isEmpty
|
||||
? result.device.remoteId.toString()
|
||||
: result.device.platformName,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -339,6 +353,7 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: const BoxDecoration(
|
||||
@@ -355,9 +370,9 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'蓝牙设备',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
l10n.translate('ble_v2.title'),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -366,10 +381,15 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_connectedDevice != null
|
||||
? '当前已连接: ${_connectedDevice!.platformName.isEmpty ? _connectedDevice!.remoteId : _connectedDevice!.platformName}'
|
||||
? l10n.translate('ble_v2.connected_current').replaceAll(
|
||||
'%s',
|
||||
_connectedDevice!.platformName.isEmpty
|
||||
? _connectedDevice!.remoteId.toString()
|
||||
: _connectedDevice!.platformName,
|
||||
)
|
||||
: _isBluetoothOn
|
||||
? '正在扫描周边设备...'
|
||||
: '请先打开蓝牙',
|
||||
? l10n.translate('ble_v2.scanning_nearby')
|
||||
: l10n.translate('ble_v2.turn_on_bt_first'),
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
],
|
||||
@@ -392,6 +412,7 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
}
|
||||
|
||||
Widget _buildStatusCard() {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final isOn = _isBluetoothOn;
|
||||
final hasConnected = _connectedDevice != null;
|
||||
return Container(
|
||||
@@ -416,7 +437,9 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isOn ? '蓝牙已开启' : '蓝牙未打开',
|
||||
isOn
|
||||
? l10n.translate('ble_v2.bt_on')
|
||||
: l10n.translate('ble_v2.bt_off'),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -426,10 +449,10 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
hasConnected
|
||||
? '已连接设备'
|
||||
? l10n.translate('ble_v2.connected_device')
|
||||
: isOn
|
||||
? '正在扫描周边设备'
|
||||
: '请打开蓝牙以扫描附近设备',
|
||||
? l10n.translate('ble_v2.scanning_nearby_short')
|
||||
: l10n.translate('ble_v2.turn_on_bt_to_scan'),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF86909C),
|
||||
@@ -458,9 +481,9 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
color: const Color(0xFF165DFF),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text(
|
||||
'去打开',
|
||||
style: TextStyle(
|
||||
child: Text(
|
||||
l10n.translate('ble_v2.go_turn_on'),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -474,6 +497,7 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
}
|
||||
|
||||
Widget _buildDeviceList() {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
// 过滤掉已连接设备(它已在状态卡片下方单独显示)
|
||||
var otherResults = _connectedDevice != null
|
||||
? _scanResults
|
||||
@@ -494,9 +518,9 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'附近设备',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
l10n.translate('ble_v2.nearby_devices'),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
@@ -520,15 +544,15 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
onChanged: (value) {
|
||||
setState(() => _searchQuery = value);
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
hintText: '搜索设备名称或MAC',
|
||||
hintStyle: TextStyle(
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.translate('ble_v2.search_hint'),
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 10),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 10),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
@@ -551,20 +575,20 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
),
|
||||
),
|
||||
if (!_isBluetoothOn)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.bluetooth_disabled,
|
||||
size: 48,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'蓝牙未开启,无法扫描设备',
|
||||
style: TextStyle(color: Color(0xFF86909C)),
|
||||
l10n.translate('ble_v2.bt_off_cannot_scan'),
|
||||
style: const TextStyle(color: Color(0xFF86909C)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -583,7 +607,9 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_searchQuery.isNotEmpty ? '未找到匹配的设备' : '正在扫描附近设备...',
|
||||
_searchQuery.isNotEmpty
|
||||
? l10n.translate('ble_v2.no_match')
|
||||
: l10n.translate('ble_v2.scanning_dots'),
|
||||
style: const TextStyle(color: Color(0xFF86909C)),
|
||||
),
|
||||
],
|
||||
@@ -601,6 +627,7 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
|
||||
/// 已连接设备卡片(始终显示在状态卡片下方,不依赖扫描结果)
|
||||
Widget _buildConnectedDeviceCard() {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final device = _connectedDevice!;
|
||||
final name = device.platformName.isNotEmpty
|
||||
? device.platformName
|
||||
@@ -663,9 +690,12 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
color: const Color(0xFF00B42A),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'已连接',
|
||||
style: TextStyle(fontSize: 10, color: Colors.white),
|
||||
child: Text(
|
||||
l10n.translate('ble_v2.connected'),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -692,9 +722,9 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
color: const Color(0xFF00B42A),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text(
|
||||
'断开',
|
||||
style: TextStyle(
|
||||
child: Text(
|
||||
l10n.translate('ble_v2.disconnect'),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -709,6 +739,7 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
}
|
||||
|
||||
Widget _buildDeviceItem(ScanResult result) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final device = result.device;
|
||||
final advData = result.advertisementData;
|
||||
// 优先使用广告数据中的名称(含扫描响应),手机蓝牙列表也是这样显示的
|
||||
@@ -808,7 +839,9 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
isConnected ? '已连接' : '连接',
|
||||
isConnected
|
||||
? l10n.translate('ble_v2.connected')
|
||||
: l10n.translate('ble_v2.connect'),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
@@ -830,14 +863,17 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
color: const Color(0xFFFFF7E6),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Color(0xFFFF7D00), size: 16),
|
||||
SizedBox(width: 8),
|
||||
const Icon(Icons.info_outline, color: Color(0xFFFF7D00), size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'请选择现场本机设备,避免连接无关设备',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFFFF7D00)),
|
||||
AppLocalizations.of(context).translate('ble_v2.tip'),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFFFF7D00),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
|
||||
/// 设备状态 V2 —— 卡片视图设计稿组件。
|
||||
///
|
||||
@@ -16,23 +17,36 @@ enum DeviceV2Status { working, online, offline }
|
||||
class DeviceV2Display {
|
||||
DeviceV2Display._();
|
||||
|
||||
static const _editions = ['PLUS 智能版', 'STD 标准版', 'PRO 旗舰版'];
|
||||
/// 版本代码:plus / std / pro,文案由调用方取 device_list_v2.edition_<code>
|
||||
static const _editionCodes = ['plus', 'std', 'pro'];
|
||||
|
||||
static int _seed(String key) => key.hashCode & 0x7FFFFFFF;
|
||||
|
||||
/// 版本后缀:PLUS 智能版 / STD 标准版 / PRO 旗舰版
|
||||
static String editionOf(String key) =>
|
||||
_editions[_seed(key) % _editions.length];
|
||||
///
|
||||
/// 列表卡已改用后端 productModel 真值(RobotDataModel.modelLabel),
|
||||
/// 此处仅详情页/全功能页临时兜底,待这两处也接入机型字段后删除。
|
||||
static String editionOf(String key, AppLocalizations l10n) {
|
||||
final code = _editionCodes[_seed(key) % _editionCodes.length];
|
||||
return l10n.translate('device_list_v2.edition_$code');
|
||||
}
|
||||
|
||||
/// 版本占位:v2.x.y(后端暂无版本字段,集中概览表格用)
|
||||
static String versionOf(String key) =>
|
||||
'v2.${_seed(key) % 4}.${_seed(key) ~/ 4 % 10}';
|
||||
// Deleted:static String versionOf(String key) =>
|
||||
// Deleted: 'v2.${_seed(key) % 4}.${_seed(key) ~/ 4 % 10}';
|
||||
|
||||
static const _locations = ['1号厂房', '2号厂房', '原料区', '综合楼'];
|
||||
/// 位置代码:plant_1 / plant_2 / raw_material / office_building
|
||||
static const _locationCodes = [
|
||||
'plant_1',
|
||||
'plant_2',
|
||||
'raw_material',
|
||||
'office_building',
|
||||
];
|
||||
|
||||
/// 位置占位:后端暂无位置字段,集中概览表格用
|
||||
static String locationOf(String key) =>
|
||||
_locations[_seed(key) % _locations.length];
|
||||
static String locationOf(String key, AppLocalizations l10n) {
|
||||
final code = _locationCodes[_seed(key) % _locationCodes.length];
|
||||
return l10n.translate('device_list_v2.location_$code');
|
||||
}
|
||||
|
||||
/// SN 掩码展示:前4 · · · 后2
|
||||
static String maskedSn(String key) {
|
||||
@@ -42,11 +56,15 @@ class DeviceV2Display {
|
||||
}
|
||||
|
||||
/// 底部作业态文案
|
||||
static String workTextOf(DeviceV2Status status) {
|
||||
static String workTextOf(AppLocalizations l10n, DeviceV2Status status) {
|
||||
return switch (status) {
|
||||
DeviceV2Status.working => '自主作业中',
|
||||
DeviceV2Status.online => '待机',
|
||||
DeviceV2Status.offline => '暂时无法连接',
|
||||
DeviceV2Status.working => l10n.translate(
|
||||
'device_list_v2.work_text_working',
|
||||
),
|
||||
DeviceV2Status.online => l10n.translate('device_list_v2.work_text_online'),
|
||||
DeviceV2Status.offline => l10n.translate(
|
||||
'device_list_v2.work_text_offline',
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -102,6 +120,7 @@ class DeviceCardV2 extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final offline = status == DeviceV2Status.offline;
|
||||
final battery = offline ? null : batteryLevel;
|
||||
|
||||
@@ -147,15 +166,19 @@ class DeviceCardV2 extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
// 编辑(改名):默认未开发,由外部注入(如修改设备名称弹窗)
|
||||
// 热区撑到 31x31:原来只包 15px 图标,手指经常点不中
|
||||
GestureDetector(
|
||||
onTap:
|
||||
onRename ??
|
||||
() => CapsuleToast.show(
|
||||
'功能暂未开发',
|
||||
l10n.translate(
|
||||
'device_list_v2.feature_todo',
|
||||
),
|
||||
showCheck: false,
|
||||
),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
padding: EdgeInsets.fromLTRB(9, 8, 9, 8),
|
||||
child: Icon(
|
||||
Icons.edit_outlined,
|
||||
size: 15,
|
||||
@@ -163,7 +186,7 @@ class DeviceCardV2 extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildBadge(),
|
||||
_buildBadge(l10n),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
@@ -191,7 +214,9 @@ class DeviceCardV2 extends StatelessWidget {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: snKey),
|
||||
);
|
||||
CapsuleToast.show('SN 已复制到剪贴板');
|
||||
CapsuleToast.show(
|
||||
l10n.translate('device_list_v2.sn_copied'),
|
||||
);
|
||||
},
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
@@ -213,7 +238,10 @@ class DeviceCardV2 extends StatelessWidget {
|
||||
GestureDetector(
|
||||
onTap:
|
||||
onMore ??
|
||||
() => CapsuleToast.show('功能暂未开发', showCheck: false),
|
||||
() => CapsuleToast.show(
|
||||
l10n.translate('device_list_v2.feature_todo'),
|
||||
showCheck: false,
|
||||
),
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
@@ -251,7 +279,7 @@ class DeviceCardV2 extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
DeviceV2Display.workTextOf(status),
|
||||
DeviceV2Display.workTextOf(l10n, status),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -308,11 +336,23 @@ class DeviceCardV2 extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 状态徽章:作业中(橙) / 在线(绿) / 离线(灰)
|
||||
Widget _buildBadge() {
|
||||
Widget _buildBadge(AppLocalizations l10n) {
|
||||
final (color, bg, label) = switch (status) {
|
||||
DeviceV2Status.working => (_workingOrange, _workingBg, '作业中'),
|
||||
DeviceV2Status.online => (_onlineGreen, _onlineBg, '在线'),
|
||||
DeviceV2Status.offline => (_offlineGray, _offlineBg, '离线'),
|
||||
DeviceV2Status.working => (
|
||||
_workingOrange,
|
||||
_workingBg,
|
||||
l10n.translate('device_list_v2.stat_working'),
|
||||
),
|
||||
DeviceV2Status.online => (
|
||||
_onlineGreen,
|
||||
_onlineBg,
|
||||
l10n.translate('device_list_v2.online'),
|
||||
),
|
||||
DeviceV2Status.offline => (
|
||||
_offlineGray,
|
||||
_offlineBg,
|
||||
l10n.translate('device_list_v2.offline'),
|
||||
),
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
|
||||
/// 通用设备操作确认弹窗(设计稿:远程开机确认 / 关机确认 / 急停确认等)
|
||||
///
|
||||
@@ -21,10 +22,11 @@ Future<bool> showDeviceConfirmDialog(
|
||||
required String title,
|
||||
required String message,
|
||||
String? note,
|
||||
String confirmText = '确认',
|
||||
String cancelText = '取消',
|
||||
String? confirmText,
|
||||
String? cancelText,
|
||||
bool danger = false,
|
||||
}) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierColor: const Color(0x8C0B1B2B),
|
||||
@@ -34,8 +36,9 @@ Future<bool> showDeviceConfirmDialog(
|
||||
title: title,
|
||||
message: message,
|
||||
note: note,
|
||||
confirmText: confirmText,
|
||||
cancelText: cancelText,
|
||||
confirmText:
|
||||
confirmText ?? l10n.translate('device_list_v2.action_confirm'),
|
||||
cancelText: cancelText ?? l10n.translate('device_list_v2.action_cancel'),
|
||||
danger: danger,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../domain/entities/device_entity.dart';
|
||||
import '../../domain/entities/device_model_info.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
import 'package:maibu_satabot_v2/core/theme/AppTheme.dart';
|
||||
|
||||
@@ -14,6 +15,13 @@ class DeviceItemWidget extends StatelessWidget {
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
/// 型号文案:productModel 解析优先(MC700 · PRO 旗舰版),
|
||||
/// productModel 为 null / 空串时统一显示「未知」
|
||||
String _modelTextOf(BuildContext context) =>
|
||||
DeviceModelInfo.parse(device.productModel).labelOf(
|
||||
AppLocalizations.of(context),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
@@ -64,6 +72,16 @@ class DeviceItemWidget extends StatelessWidget {
|
||||
color: context.appColors.textTertiary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_modelTextOf(context),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: context.appColors.textSecondary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -126,13 +126,16 @@ class _CustomJoystickState extends State<CCJoystick>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
// 🔥 兜底:监听全局指针抬起事件,防止 onPanEnd 丢失
|
||||
// 原始触点结束时立即停车;多指情况下 Pan 手势可能仍会继续回调。
|
||||
onPointerUp: (_) async {
|
||||
if (_isPanning) {
|
||||
debugPrint('🛡️ [兜底] 全局指针抬起,强制归零');
|
||||
await _handlePanEnd();
|
||||
}
|
||||
},
|
||||
onPointerCancel: (_) async {
|
||||
await _handlePanEnd();
|
||||
},
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanStart: (_) {
|
||||
@@ -140,6 +143,10 @@ class _CustomJoystickState extends State<CCJoystick>
|
||||
widget.onPress?.call();
|
||||
},
|
||||
onPanUpdate: (details) {
|
||||
// 另一根手指抬起已触发停车时,识别器可能继续派发剩余手指的移动。
|
||||
// 必须等下一次 onPanStart 才接受输入,否则非零值会覆盖停车结果,
|
||||
// 而最终 onPanEnd 因 _isPanning=false 跳过停车,控制循环就会一直发旧值。
|
||||
if (!_isPanning) return;
|
||||
setState(() {
|
||||
Offset newOffset = _offset + details.delta;
|
||||
double distance = newOffset.distance;
|
||||
|
||||
36
test/capsule_toast_test.dart
Normal file
36
test/capsule_toast_test.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'旧提示的自动消失定时器不能误杀后弹出的新提示',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: Scaffold(body: Text('page'))),
|
||||
);
|
||||
|
||||
// 第一条:显示 2 秒
|
||||
CapsuleToast.show('A-第一条', duration: const Duration(seconds: 2));
|
||||
await tester.pumpAndSettle(); // postFrame 插入 + Overlay 重建
|
||||
expect(find.text('A-第一条'), findsOne);
|
||||
|
||||
// 1 秒后第二条插入(模拟用户连点触发的新 toast)
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
CapsuleToast.show('B-第二条', duration: const Duration(seconds: 2));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('B-第二条'), findsOne);
|
||||
expect(find.text('A-第一条'), findsNothing);
|
||||
|
||||
// 到点:第一条的 2 秒定时器到期,第二条只应已显示 1 秒,不能被移除
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('B-第二条'), findsOne);
|
||||
|
||||
// 第二条自己的 2 秒到点后才消失
|
||||
await tester.pump(const Duration(milliseconds: 1100));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('B-第二条'), findsNothing);
|
||||
},
|
||||
);
|
||||
}
|
||||
79
test/cc_joystick_safety_test.dart
Normal file
79
test/cc_joystick_safety_test.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
import 'package:cc_ui_kit/cc_ui_kit.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
for (final axis in AxisHint.values) {
|
||||
for (final cancel in [false, true]) {
|
||||
testWidgets(
|
||||
'$axis extra finger ${cancel ? 'cancel' : 'release'} stays stopped',
|
||||
(tester) async {
|
||||
var drive = 0;
|
||||
var stops = 0;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Center(
|
||||
child: CCJoystick(
|
||||
axisHint: axis,
|
||||
onValueChanged: (value) => drive =
|
||||
axis == AxisHint.forwardBackward ? value.y : value.x,
|
||||
onPanEnd: () async {
|
||||
drive = 0;
|
||||
stops++;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final center = tester.getCenter(find.byType(CCJoystick));
|
||||
final direction = axis == AxisHint.forwardBackward
|
||||
? const Offset(0, -1)
|
||||
: const Offset(1, 0);
|
||||
final first = await tester.startGesture(center, pointer: 1);
|
||||
await first.moveBy(direction * 30);
|
||||
await first.moveBy(direction * 20);
|
||||
await tester.pump();
|
||||
expect(drive, greaterThan(0));
|
||||
|
||||
final second = await tester.startGesture(center, pointer: 2);
|
||||
if (cancel) {
|
||||
await second.cancel();
|
||||
} else {
|
||||
await second.up();
|
||||
}
|
||||
await tester.pump();
|
||||
expect(drive, 0);
|
||||
await first.moveBy(direction * 20);
|
||||
await tester.pump();
|
||||
expect(
|
||||
drive,
|
||||
0,
|
||||
reason: 'ended interaction must ignore remaining moves',
|
||||
);
|
||||
await first.up();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(stops, greaterThan(0));
|
||||
expect(
|
||||
drive,
|
||||
0,
|
||||
reason: 'released joystick must leave zero for the timer',
|
||||
);
|
||||
expect(stops, 1, reason: 'pointer and pan end must not stop twice');
|
||||
|
||||
// A new gesture must still work after the interrupted interaction.
|
||||
final next = await tester.startGesture(center, pointer: 3);
|
||||
await next.moveBy(direction * 30);
|
||||
await next.moveBy(direction * 20);
|
||||
await tester.pump();
|
||||
expect(drive, greaterThan(0));
|
||||
await next.up();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(drive, 0);
|
||||
expect(stops, 2);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
254
test/control_mode_entry_widget_test.dart
Normal file
254
test/control_mode_entry_widget_test.dart
Normal file
@@ -0,0 +1,254 @@
|
||||
import 'dart:async';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:maibu_satabot_v2/core/di/injection.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
import 'package:maibu_satabot_v2/features/device_control_mode/domain/entities/device_control_mode.dart';
|
||||
import 'package:maibu_satabot_v2/features/device_control_mode/domain/usecases/device_control_mode_usecases.dart';
|
||||
import 'package:maibu_satabot_v2/features/device_control_mode/presentation/cubit/control_mode_entry_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/features/device_control_mode/presentation/widgets/control_mode_entry_gate.dart';
|
||||
import 'support/control_mode_fakes.dart';
|
||||
|
||||
final _translations = <String, AppLocalizations>{};
|
||||
|
||||
class _LoadedTranslations extends LocalizationsDelegate<AppLocalizations> {
|
||||
const _LoadedTranslations();
|
||||
@override
|
||||
bool isSupported(Locale locale) =>
|
||||
_translations.containsKey(locale.toString());
|
||||
@override
|
||||
Future<AppLocalizations> load(Locale locale) =>
|
||||
SynchronousFuture(_translations[locale.toString()]!);
|
||||
@override
|
||||
bool shouldReload(_LoadedTranslations old) => false;
|
||||
}
|
||||
|
||||
Widget app(Widget home, {Locale locale = const Locale('zh', 'CN')}) =>
|
||||
MaterialApp(
|
||||
locale: locale,
|
||||
supportedLocales: const [Locale('zh', 'CN'), Locale('en', 'US')],
|
||||
localizationsDelegates: const [
|
||||
_LoadedTranslations(),
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
home: home,
|
||||
);
|
||||
|
||||
Future<void> openDialog(
|
||||
WidgetTester tester,
|
||||
ControlModeEntryCubit cubit,
|
||||
ValueChanged<bool> result, {
|
||||
Locale locale = const Locale('zh', 'CN'),
|
||||
Size size = const Size(800, 360),
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
app(
|
||||
Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: TextButton(
|
||||
onPressed: () async => result(
|
||||
await showControlModeEntryDialog(
|
||||
context,
|
||||
deviceId: cubit.deviceId,
|
||||
target: cubit.target,
|
||||
deviceName: '测试车',
|
||||
cubit: cubit,
|
||||
),
|
||||
),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
locale: locale,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUpAll(() async {
|
||||
// 真实资产I/O在测试假时钟外完成,弹窗测试使用同一套真实中英文文案。
|
||||
for (final locale in [const Locale('zh', 'CN'), const Locale('en', 'US')]) {
|
||||
final translations = AppLocalizations(locale);
|
||||
await translations.load();
|
||||
_translations[locale.toString()] = translations;
|
||||
await GlobalMaterialLocalizations.delegate.load(locale);
|
||||
await GlobalCupertinoLocalizations.delegate.load(locale);
|
||||
}
|
||||
});
|
||||
late FakeModes repo;
|
||||
late ControlModeEntryCubit cubit;
|
||||
setUp(() {
|
||||
repo = FakeModes();
|
||||
cubit = modeCubit(repo);
|
||||
});
|
||||
tearDown(() async {
|
||||
if (!cubit.isClosed) await cubit.close();
|
||||
await repo.reports.close();
|
||||
await sl.reset();
|
||||
});
|
||||
|
||||
testWidgets('进入弹窗显示实时模式,已匹配时确定仅进入、不调用切换', (tester) async {
|
||||
repo.initial = '2';
|
||||
bool? result;
|
||||
await openDialog(tester, cubit, (value) => result = value);
|
||||
expect(find.text('控制模式确认'), findsOneWidget);
|
||||
expect(find.textContaining('当前实时模式:自主任务'), findsOneWidget);
|
||||
expect(repo.requests, isEmpty);
|
||||
expect(result, isNull);
|
||||
await tester.tap(find.text('确定'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(result, isTrue);
|
||||
expect(repo.requests, isEmpty);
|
||||
expect(cubit.isClosed, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('确定才下发,接口成功立即关闭,不等模式回推', (tester) async {
|
||||
repo.initial = '3';
|
||||
repo.pending = Completer();
|
||||
bool? result;
|
||||
await openDialog(tester, cubit, (value) => result = value);
|
||||
expect(repo.requests, isEmpty);
|
||||
await tester.tap(find.text('确定'));
|
||||
await tester.pump();
|
||||
expect(repo.requests.single, ('SN002', ControlModeTarget.autonomous));
|
||||
expect(result, isNull);
|
||||
repo.pending!.complete(const Right(unit));
|
||||
await tester.pumpAndSettle();
|
||||
expect(result, isTrue);
|
||||
expect(cubit.state.reading!.value, 3);
|
||||
});
|
||||
|
||||
testWidgets('返回不会下发切换,关闭后后续实时数据不会触发进入', (tester) async {
|
||||
repo.initial = '1';
|
||||
bool? result;
|
||||
await openDialog(tester, cubit, (value) => result = value);
|
||||
await tester.tap(find.text('返回'));
|
||||
await tester.pumpAndSettle();
|
||||
repo.report('2');
|
||||
await tester.pump();
|
||||
expect(result, isFalse);
|
||||
expect(repo.requests, isEmpty);
|
||||
expect(cubit.isClosed, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('无模式数据不转圈且确定可点击,成功后直接进入', (tester) async {
|
||||
bool? result;
|
||||
await openDialog(tester, cubit, (value) => result = value);
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
expect(find.textContaining('暂未收到当前模式'), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<FilledButton>(find.byType(FilledButton)).onPressed,
|
||||
isNotNull,
|
||||
);
|
||||
await tester.tap(find.text('确定'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(result, isTrue);
|
||||
expect(repo.requests.single, ('SN002', ControlModeTarget.autonomous));
|
||||
});
|
||||
|
||||
testWidgets('英文小横屏可以滚动查看内容且无布局溢出', (tester) async {
|
||||
repo.initial = '1';
|
||||
await openDialog(
|
||||
tester,
|
||||
cubit,
|
||||
(_) {},
|
||||
locale: const Locale('en', 'US'),
|
||||
size: const Size(560, 280),
|
||||
);
|
||||
expect(find.text('Confirm control mode'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tap(find.text('Back'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('遥控入口只在模式确认后激活一次,确认前页面动作被阻止', (tester) async {
|
||||
repo.initial = '2';
|
||||
repo.pending = Completer();
|
||||
sl.registerSingleton(WatchDeviceControlModeUseCase(repo));
|
||||
sl.registerSingleton(ChangeDeviceControlModeUseCase(repo));
|
||||
var activations = 0;
|
||||
var drives = 0;
|
||||
await tester.pumpWidget(
|
||||
app(
|
||||
ControlModeEntryGate(
|
||||
deviceId: 'SN002',
|
||||
target: ControlModeTarget.remote,
|
||||
onReady: () => activations++,
|
||||
child: Scaffold(
|
||||
body: TextButton(
|
||||
key: const Key('drive'),
|
||||
onPressed: () => drives++,
|
||||
child: const Text('drive'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(activations, 0);
|
||||
await tester.tap(find.byKey(const Key('drive')), warnIfMissed: false);
|
||||
expect(drives, 0);
|
||||
await tester.tap(find.text('确定'));
|
||||
await tester.pump();
|
||||
expect(repo.requests.single, ('SN002', ControlModeTarget.remote));
|
||||
expect(activations, 0);
|
||||
repo.pending!.complete(const Right(unit));
|
||||
await tester.pumpAndSettle();
|
||||
expect(activations, 1);
|
||||
expect(drives, 0);
|
||||
await tester.tap(find.byKey(const Key('drive')));
|
||||
expect(drives, 1);
|
||||
});
|
||||
|
||||
testWidgets('取消遥控模式确认返回来源页,不激活控制或下发切换', (tester) async {
|
||||
repo.initial = '2';
|
||||
sl.registerSingleton(WatchDeviceControlModeUseCase(repo));
|
||||
sl.registerSingleton(ChangeDeviceControlModeUseCase(repo));
|
||||
var activations = 0;
|
||||
await tester.pumpWidget(
|
||||
app(
|
||||
Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: TextButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ControlModeEntryGate(
|
||||
deviceId: 'SN002',
|
||||
target: ControlModeTarget.remote,
|
||||
onReady: () => activations++,
|
||||
child: const Scaffold(body: Text('remote')),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('open remote'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('open remote'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('返回'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('open remote'), findsOneWidget);
|
||||
expect(find.text('remote'), findsNothing);
|
||||
expect(activations, 0);
|
||||
expect(repo.requests, isEmpty);
|
||||
});
|
||||
}
|
||||
251
test/device_control_mode_test.dart
Normal file
251
test/device_control_mode_test.dart
Normal file
@@ -0,0 +1,251 @@
|
||||
import 'dart:async';
|
||||
import 'support/control_mode_fakes.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/di/adapters/device_control_mode_realtime_adapter.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
import 'package:maibu_satabot_v2/core/logging/i_logger_service.dart';
|
||||
import 'package:maibu_satabot_v2/features/device_control_mode/data/datasources/control_mode_remote_datasource.dart';
|
||||
import 'package:maibu_satabot_v2/features/device_control_mode/data/repositories/device_control_mode_repository_impl.dart';
|
||||
import 'package:maibu_satabot_v2/features/device_control_mode/domain/entities/device_control_mode.dart';
|
||||
import 'package:maibu_satabot_v2/features/device_control_mode/domain/usecases/device_control_mode_usecases.dart';
|
||||
import 'package:maibu_satabot_v2/features/device_control_mode/presentation/cubit/control_mode_entry_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/features/devices/domain/entities/gps_entity.dart';
|
||||
import 'package:maibu_satabot_v2/features/devices/domain/entities/running_status_entity.dart';
|
||||
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_bloc.dart';
|
||||
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_state.dart';
|
||||
|
||||
class _Logger implements ILoggerService {
|
||||
final lines = <String>[];
|
||||
@override
|
||||
void logWithLevel(
|
||||
String message, {
|
||||
String level = 'INFO',
|
||||
Map<String, dynamic>? data,
|
||||
bool shouldLog = false,
|
||||
}) {
|
||||
if (shouldLog) lines.add(message);
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _StatusBloc extends Cubit<DeviceStatusState> implements DeviceStatusBloc {
|
||||
_StatusBloc(String sn, String raw)
|
||||
: listeningSn = sn,
|
||||
super(
|
||||
DeviceStatusUpdated(
|
||||
RunningStatusEntity(controlMode: raw),
|
||||
GPSEntity(0, 0),
|
||||
),
|
||||
);
|
||||
String listeningSn;
|
||||
final starts = <String>[];
|
||||
@override
|
||||
String? get listeningDeviceSn => listeningSn;
|
||||
@override
|
||||
void startMqttRealtimeListening(String sn) {
|
||||
starts.add(sn);
|
||||
if (listeningSn != sn) {
|
||||
listeningSn = sn;
|
||||
emit(DeviceStatusInitial());
|
||||
}
|
||||
}
|
||||
|
||||
void report(String raw) => emit(
|
||||
DeviceStatusUpdated(RunningStatusEntity(controlMode: raw), GPSEntity(0, 0)),
|
||||
);
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('接口按文档GET下发编码和整数模式,数字data=1不做bool强转', () async {
|
||||
final dio = Dio();
|
||||
final requests = <RequestOptions>[];
|
||||
final logger = _Logger();
|
||||
Map<String, dynamic> response = {'code': 200, 'msg': '操作成功', 'data': 1};
|
||||
dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
requests.add(options);
|
||||
handler.resolve(
|
||||
Response(requestOptions: options, statusCode: 200, data: response),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
final repo = DeviceControlModeRepositoryImpl(
|
||||
realtime: (_) => const Stream.empty(),
|
||||
remote: ControlModeRemoteDatasource(dio, logger),
|
||||
);
|
||||
final change = ChangeDeviceControlModeUseCase(repo);
|
||||
for (final target in ControlModeTarget.values) {
|
||||
expect((await change(' SN002 ', target)).isRight(), isTrue);
|
||||
expect(requests.last.method, 'GET');
|
||||
expect(requests.last.uri.path, '/iot/host/modeChage');
|
||||
expect(requests.last.queryParameters, {
|
||||
'deviceId': 'SN002',
|
||||
'controlMode': target.value,
|
||||
});
|
||||
expect(requests.last.data, isNull);
|
||||
}
|
||||
response = {'code': 500, 'msg': '设备不在线'};
|
||||
final failed = await change('SN002', ControlModeTarget.remote);
|
||||
expect(
|
||||
failed.fold((failure) => failure.message, (_) => ''),
|
||||
contains('设备不在线'),
|
||||
);
|
||||
expect((await change(' ', ControlModeTarget.remote)).isLeft(), isTrue);
|
||||
expect(requests, hasLength(3));
|
||||
expect(logger.lines.any((s) => s.contains('"controlMode":3')), isTrue);
|
||||
expect(logger.lines.any((s) => s.contains('HTTP 200')), isTrue);
|
||||
dio.close();
|
||||
});
|
||||
|
||||
test('实时适配器与设备详情共用Bloc,同设备复用快照,切换设备不串模式', () async {
|
||||
final bloc = _StatusBloc('old', '3');
|
||||
final readings = <DeviceControlModeReading>[];
|
||||
final source = DeviceControlModeRealtimeAdapter(bloc);
|
||||
var sub = source.watch('new').listen(readings.add);
|
||||
await flushModes();
|
||||
expect(bloc.starts, ['new']);
|
||||
expect(readings.every((reading) => reading.rawMode == null), isTrue);
|
||||
bloc.report('2');
|
||||
await flushModes();
|
||||
expect(readings.last.deviceId, 'new');
|
||||
expect(readings.last.value, 2);
|
||||
await sub.cancel();
|
||||
readings.clear();
|
||||
sub = source.watch('new').listen(readings.add);
|
||||
await flushModes();
|
||||
expect(readings.last.value, 2);
|
||||
bloc.startMqttRealtimeListening('other');
|
||||
bloc.report('3');
|
||||
await flushModes();
|
||||
expect(readings.last.rawMode, isNull);
|
||||
await sub.cancel();
|
||||
await bloc.close();
|
||||
});
|
||||
|
||||
group('入口模式确认', () {
|
||||
late FakeModes repo;
|
||||
late ControlModeEntryCubit cubit;
|
||||
setUp(() {
|
||||
repo = FakeModes();
|
||||
cubit = modeCubit(repo);
|
||||
});
|
||||
tearDown(() async {
|
||||
if (!cubit.isClosed) await cubit.close();
|
||||
await repo.reports.close();
|
||||
});
|
||||
Future<void> start(String? mode) async {
|
||||
repo.initial = mode;
|
||||
await cubit.start();
|
||||
await flushModes();
|
||||
}
|
||||
|
||||
test('无模式上报也允许用户确定下发,不等待topic', () async {
|
||||
await start(null);
|
||||
expect(cubit.state.canConfirm, isTrue);
|
||||
expect(repo.requests, isEmpty);
|
||||
await cubit.confirm();
|
||||
expect(repo.requests.single, ('SN002', ControlModeTarget.autonomous));
|
||||
expect(cubit.state.phase, ControlModeEntryPhase.confirmed);
|
||||
expect(cubit.state.reading?.rawMode, isNull);
|
||||
});
|
||||
test('已匹配时确定直接进入,不重复切换', () async {
|
||||
await start('2');
|
||||
await cubit.confirm();
|
||||
expect(cubit.state.phase, ControlModeEntryPhase.confirmed);
|
||||
expect(repo.requests, isEmpty);
|
||||
});
|
||||
test('文本模式可展示也可确定,不因非整数被禁用', () async {
|
||||
await start('本地控制');
|
||||
expect(cubit.state.reading!.hasReportedMode, isTrue);
|
||||
expect(cubit.state.canConfirm, isTrue);
|
||||
await cubit.confirm();
|
||||
expect(cubit.state.phase, ControlModeEntryPhase.confirmed);
|
||||
expect(cubit.state.reading!.rawMode, '本地控制');
|
||||
});
|
||||
test('接口成功立即进入,保持真实上报值而不伪造目标模式', () async {
|
||||
await start('1');
|
||||
await cubit.confirm();
|
||||
expect(cubit.state.phase, ControlModeEntryPhase.confirmed);
|
||||
expect(cubit.state.reading!.value, 1);
|
||||
});
|
||||
test('发送过程中重复点击只请求一次,成功后无需到达目标模式上报', () async {
|
||||
await start('1');
|
||||
repo.pending = Completer();
|
||||
final first = cubit.confirm();
|
||||
await cubit.confirm();
|
||||
expect(cubit.state.busy, isTrue);
|
||||
expect(repo.requests, hasLength(1));
|
||||
repo.pending!.complete(const Right(unit));
|
||||
await first;
|
||||
expect(cubit.state.phase, ControlModeEntryPhase.confirmed);
|
||||
});
|
||||
test('失败后允许重试,同模式实时帧不抹掉失败信息', () async {
|
||||
await start('1');
|
||||
repo.failure = Failure('设备拒绝切换');
|
||||
await cubit.confirm();
|
||||
repo.report('1');
|
||||
await flushModes();
|
||||
expect(cubit.state.error, '设备拒绝切换');
|
||||
expect(cubit.state.canConfirm, isTrue);
|
||||
repo.failure = null;
|
||||
await cubit.confirm();
|
||||
expect(repo.requests, hasLength(2));
|
||||
expect(cubit.state.phase, ControlModeEntryPhase.confirmed);
|
||||
});
|
||||
test('关闭后迟到HTTP响应不触发进入', () async {
|
||||
await start('1');
|
||||
repo.pending = Completer();
|
||||
final request = cubit.confirm();
|
||||
await cubit.close();
|
||||
repo.pending!.complete(const Right(unit));
|
||||
await request;
|
||||
expect(cubit.state.phase, ControlModeEntryPhase.submitting);
|
||||
});
|
||||
test('远程入口下发3,接口成功后直接进入', () async {
|
||||
await cubit.close();
|
||||
cubit = modeCubit(repo, target: ControlModeTarget.remote);
|
||||
await start('2');
|
||||
await cubit.confirm();
|
||||
expect(repo.requests.single, ('SN002', ControlModeTarget.remote));
|
||||
expect(cubit.state.phase, ControlModeEntryPhase.confirmed);
|
||||
expect(cubit.state.reading!.value, 2);
|
||||
});
|
||||
test('未知或协议外模式仍可手动确定下发', () async {
|
||||
await start('0');
|
||||
repo.report('6');
|
||||
await flushModes();
|
||||
expect(cubit.state.canConfirm, isTrue);
|
||||
await cubit.confirm();
|
||||
expect(repo.requests.single, ('SN002', ControlModeTarget.autonomous));
|
||||
});
|
||||
test('其他设备上报不改变本设备模式', () async {
|
||||
await start('1');
|
||||
repo.report('2', deviceId: 'other');
|
||||
await flushModes();
|
||||
expect(cubit.state.reading!.value, 1);
|
||||
await cubit.confirm();
|
||||
expect(repo.requests, hasLength(1));
|
||||
});
|
||||
test('请求超时恢复确定按钮,不把迟到响应当成成功', () async {
|
||||
await cubit.close();
|
||||
cubit = modeCubit(repo, timeout: const Duration(milliseconds: 40));
|
||||
await start('1');
|
||||
repo.pending = Completer();
|
||||
await cubit.confirm();
|
||||
expect(cubit.state.errorKey, 'request_timeout');
|
||||
expect(cubit.state.canConfirm, isTrue);
|
||||
repo.pending!.complete(const Right(unit));
|
||||
await flushModes();
|
||||
expect(cubit.state.phase, ControlModeEntryPhase.failed);
|
||||
});
|
||||
});
|
||||
}
|
||||
60
test/support/control_mode_fakes.dart
Normal file
60
test/support/control_mode_fakes.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'dart:async';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
import 'package:maibu_satabot_v2/features/device_control_mode/domain/entities/device_control_mode.dart';
|
||||
import 'package:maibu_satabot_v2/features/device_control_mode/domain/repositories/device_control_mode_repository.dart';
|
||||
import 'package:maibu_satabot_v2/features/device_control_mode/domain/usecases/device_control_mode_usecases.dart';
|
||||
import 'package:maibu_satabot_v2/features/device_control_mode/presentation/cubit/control_mode_entry_cubit.dart';
|
||||
|
||||
class FakeModes implements DeviceControlModeRepository {
|
||||
final reports = StreamController<DeviceControlModeReading>.broadcast(
|
||||
sync: true,
|
||||
);
|
||||
final requests = <(String, ControlModeTarget)>[];
|
||||
String? initial;
|
||||
Completer<Either<Failure, Unit>>? pending;
|
||||
Failure? failure;
|
||||
@override
|
||||
Stream<DeviceControlModeReading> watch(String deviceId) {
|
||||
final cached = initial;
|
||||
if (cached != null) {
|
||||
scheduleMicrotask(() {
|
||||
if (reports.isClosed) return;
|
||||
reports.add(
|
||||
DeviceControlModeReading(deviceId: deviceId, rawMode: cached),
|
||||
);
|
||||
});
|
||||
}
|
||||
return reports.stream;
|
||||
}
|
||||
|
||||
void report(String? mode, {String deviceId = 'SN002'}) =>
|
||||
reports.add(DeviceControlModeReading(deviceId: deviceId, rawMode: mode));
|
||||
@override
|
||||
Future<Either<Failure, Unit>> change(
|
||||
String deviceId,
|
||||
ControlModeTarget target,
|
||||
) async {
|
||||
requests.add((deviceId, target));
|
||||
return pending?.future ??
|
||||
(failure == null ? const Right(unit) : Left(failure!));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> flushModes() async {
|
||||
for (var i = 0; i < 4; i++) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
}
|
||||
|
||||
ControlModeEntryCubit modeCubit(
|
||||
FakeModes repo, {
|
||||
ControlModeTarget target = ControlModeTarget.autonomous,
|
||||
Duration timeout = const Duration(seconds: 12),
|
||||
}) => ControlModeEntryCubit(
|
||||
deviceId: 'SN002',
|
||||
target: target,
|
||||
watchMode: WatchDeviceControlModeUseCase(repo),
|
||||
changeMode: ChangeDeviceControlModeUseCase(repo),
|
||||
timeout: timeout,
|
||||
);
|
||||
Reference in New Issue
Block a user