远程遥控相关

This commit is contained in:
2026-11-06 08:34:19 +08:00
parent eb8dec2868
commit d882db7995
42 changed files with 5023 additions and 631 deletions

View File

@@ -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;

View 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;
}

View File

@@ -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)',
};
}

View File

@@ -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);
});
}
}

View File

@@ -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});

View File

@@ -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();
}
}
}

View File

@@ -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()));
}
}
}

View File

@@ -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;
}

View File

@@ -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,
);
}

View File

@@ -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);
}
}

View File

@@ -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((_) {});
}
}

View File

@@ -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')),
),
],
),
);
},
);
}
}

View File

@@ -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);
}
}

View 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;
}
}

View File

@@ -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,
});
}

View File

@@ -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();
}
}
}

View File

@@ -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,
);
}
}

View File

@@ -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()));
}
}
}

View File

@@ -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;

View File

@@ -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,
});
}

View File

@@ -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);
}
}

View File

@@ -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,
});
}

View File

@@ -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);
}
}

View File

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

View File

@@ -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;
},
);
}
}

View File

@@ -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,
];
}

View File

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

View File

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

View File

@@ -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,
),
);

View File

@@ -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(

View File

@@ -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),
],
),
),
);
}
}

View File

@@ -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),
),
),
),
],

View File

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

View File

@@ -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,
);
},

View File

@@ -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,
),
],
),
),

View File

@@ -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;

View 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);
},
);
}

View 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());
},
);
}
}
}

View 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);
});
}

View 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);
});
});
}

View 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,
);