1024 lines
35 KiB
Dart
1024 lines
35 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:math' as math;
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||
import 'package:permission_handler/permission_handler.dart';
|
||
|
||
import '../../models/device.dart';
|
||
import '../../models/org_node.dart';
|
||
import '../../providers/devices_provider.dart';
|
||
import '../../services/api_client.dart';
|
||
import '../../services/device_service.dart';
|
||
import '../../services/org_service.dart';
|
||
import '../../utils/i10n.dart';
|
||
import '../../utils/store_local_cache.dart';
|
||
import '../../models/store_info.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
/// 扫码页(mobile_scanner 封装,含相册识别)。
|
||
///
|
||
/// 路由参数:
|
||
/// - `mode`:`query`(查询设备跳详情)| `bind`(扫码绑定设备到门店/分组);
|
||
/// - `deviceId`:bind 模式下已选中设备(本设计从绑定弹窗中取门店);
|
||
/// - `storeId` / `storeName` / `groupId`:bind 模式预填目标归属(可选)。
|
||
class DeviceScanPage extends ConsumerStatefulWidget {
|
||
const DeviceScanPage({
|
||
super.key,
|
||
required this.mode,
|
||
this.deviceId,
|
||
this.storeId,
|
||
this.storeName,
|
||
this.groupId,
|
||
});
|
||
|
||
/// 模式:query / bind。
|
||
final String mode;
|
||
|
||
/// 设备 ID(bind 模式透传)。
|
||
final String? deviceId;
|
||
|
||
/// 预填门店 ID(bind 模式)。
|
||
final String? storeId;
|
||
|
||
/// 预填门店名称(bind 模式)。
|
||
final String? storeName;
|
||
|
||
/// 预填分组 ID(bind 模式)。
|
||
final String? groupId;
|
||
|
||
@override
|
||
ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState();
|
||
}
|
||
|
||
class _DeviceScanPageState extends ConsumerState<DeviceScanPage> {
|
||
final MobileScannerController _controller = MobileScannerController(
|
||
detectionSpeed: DetectionSpeed.normal,
|
||
);
|
||
bool _handled = false;
|
||
|
||
// ========== 自适应放大参数 ==========
|
||
/// 条码宽度小于画面宽度的该比例时,视为「过小」并触发放大。
|
||
static const double _kSmallBarcodeThreshold = 0.12;
|
||
|
||
/// 条码宽度大于画面宽度的该比例时,视为「足够大」,可逐步缩小。
|
||
static const double _kLargeBarcodeThreshold = 0.30;
|
||
|
||
/// 每次放大/缩小的步进(0.0~1.0)。
|
||
static const double _kZoomStep = 0.18;
|
||
|
||
/// 最大缩放上限,避免过度放大导致画面抖动或失焦。
|
||
static const double _kMaxZoomScale = 0.90;
|
||
|
||
/// 连续检测到「小条码」的帧数阈值。
|
||
static const int _kSmallFramesThreshold = 3;
|
||
|
||
/// 连续未检测到条码的帧数阈值。
|
||
static const int _kNoBarcodeFramesThreshold = 6;
|
||
|
||
/// 连续检测到「大条码」的帧数阈值。
|
||
static const int _kLargeFramesThreshold = 3;
|
||
|
||
/// 缩放调整防抖间隔(毫秒)。
|
||
static const int _kZoomDebounceMs = 700;
|
||
|
||
/// 当前目标缩放比例(0.0 = 最小缩放,1.0 = 最大缩放)。
|
||
double _currentZoomScale = 0.0;
|
||
|
||
/// 上一次缩放调整时间,用于防抖。
|
||
DateTime? _lastZoomAdjustment;
|
||
|
||
/// 用户手动控制缩放后,自适应缩放暂停到此时间点,避免自动逻辑与人手操作打架。
|
||
DateTime? _manualZoomUntil;
|
||
|
||
/// 连续检测到小条码/无条码/大条码的帧数。
|
||
int _smallFrames = 0;
|
||
int _noBarcodeFrames = 0;
|
||
int _largeFrames = 0;
|
||
|
||
/// 相机权限/初始化状态:null=检查中, true=就绪, false=无权限或失败。
|
||
bool? _cameraReady;
|
||
String? _cameraError;
|
||
|
||
/// 扫码进入绑定表单后识别到的 MAC(非空表示当前处于表单态而非相机扫码态)。
|
||
String? _scannedMac;
|
||
|
||
/// 激活模式下可选门店列表(bind 模式预填门店,不展示该下拉)。
|
||
List<StoreInfo> _storeOptions = const [];
|
||
|
||
/// 当前选中的门店(bind 模式为预填门店,激活模式为用户选择)。
|
||
StoreInfo? _selectedStore;
|
||
|
||
/// 当前门店下的分组列表。
|
||
List<OrgNode> _groupOptions = const [];
|
||
|
||
/// 当前选中的分组。
|
||
OrgNode? _selectedGroup;
|
||
|
||
/// 房号输入。
|
||
final TextEditingController _roomNoController = TextEditingController();
|
||
|
||
/// 表单校验错误文案(空表示无错误)。
|
||
String? _storeError;
|
||
String? _groupError;
|
||
String? _roomError;
|
||
|
||
/// 分组列表加载中标记。
|
||
bool _loadingGroups = false;
|
||
|
||
/// 提交中标记(避免重复提交)。
|
||
bool _submitting = false;
|
||
|
||
/// 激活模式:没有预填门店时,需在扫码后选择目标门店。
|
||
bool get _isBind => widget.mode == 'bind';
|
||
bool get _isActivate => widget.mode == 'bind' &&
|
||
(widget.storeId == null || widget.storeId!.isEmpty);
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_initCamera();
|
||
}
|
||
|
||
/// 申请相机权限并启动扫描;失败时给出明确提示而非崩溃。
|
||
Future<void> _initCamera() async {
|
||
final t = AppLocalizations.of(context);
|
||
try {
|
||
final status = await Permission.camera.request();
|
||
if (!mounted) return;
|
||
if (!status.isGranted) {
|
||
setState(() {
|
||
_cameraReady = false;
|
||
_cameraError = '${t.cameraPermissionDenied}\n${t.cameraPermissionDeniedHint}';
|
||
});
|
||
return;
|
||
}
|
||
setState(() => _cameraReady = true);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_cameraReady = false;
|
||
_cameraError = '${t.cameraInitializing}: $e';
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_roomNoController.dispose();
|
||
_controller.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _onDetect(BarcodeCapture capture) {
|
||
if (_handled) return;
|
||
final barcode = capture.barcodes.firstOrNull;
|
||
_applyAdaptiveZoom(barcode, capture);
|
||
final code = barcode?.rawValue;
|
||
if (code == null || code.isEmpty) return;
|
||
_handlePayload(code);
|
||
}
|
||
|
||
/// 根据条码在画面中的尺寸自适应调整摄像头缩放。
|
||
///
|
||
/// 规则:
|
||
/// - 条码过小(< [_kSmallBarcodeThreshold])或长时间未识别 → 逐步放大;
|
||
/// - 条码已足够大(> [_kLargeBarcodeThreshold]) → 逐步恢复默认视图;
|
||
/// - 扫描成功后由调用方主动 [_resetAdaptiveZoom] 恢复默认视图。
|
||
void _applyAdaptiveZoom(Barcode? barcode, BarcodeCapture capture) {
|
||
if (_scannedMac != null) return;
|
||
if (!_controller.value.isRunning) return;
|
||
|
||
final now = DateTime.now();
|
||
if (_lastZoomAdjustment != null &&
|
||
now.difference(_lastZoomAdjustment!).inMilliseconds < _kZoomDebounceMs) {
|
||
return;
|
||
}
|
||
// 用户正在手动控制缩放时暂停自适应,避免自动逻辑与人手操作打架。
|
||
if (_manualZoomUntil != null && now.isBefore(_manualZoomUntil!)) {
|
||
return;
|
||
}
|
||
|
||
double? barcodeWidthRatio;
|
||
if (barcode != null && barcode.size.width > 0 && barcode.size.height > 0) {
|
||
final maxBarcodeDim = math.max(barcode.size.width, barcode.size.height);
|
||
if (maxBarcodeDim <= 1.0) {
|
||
// iOS 等平台返回的是归一化坐标(0~1),直接当作画面比例使用。
|
||
barcodeWidthRatio = maxBarcodeDim;
|
||
} else {
|
||
// Android 等平台返回的是像素坐标,需按预览尺寸归一化。
|
||
final previewSize = capture.size.width > 0 && capture.size.height > 0
|
||
? capture.size
|
||
: _controller.value.size;
|
||
if (previewSize.width > 0 && previewSize.height > 0) {
|
||
final maxPreviewDim = math.max(previewSize.width, previewSize.height);
|
||
barcodeWidthRatio = maxBarcodeDim / maxPreviewDim;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (barcodeWidthRatio != null) {
|
||
if (barcodeWidthRatio < _kSmallBarcodeThreshold) {
|
||
_smallFrames++;
|
||
_largeFrames = 0;
|
||
_noBarcodeFrames = 0;
|
||
} else if (barcodeWidthRatio > _kLargeBarcodeThreshold) {
|
||
_largeFrames++;
|
||
_smallFrames = 0;
|
||
_noBarcodeFrames = 0;
|
||
} else {
|
||
// 尺寸适中:只重置极端计数,保持当前缩放,避免抖动。
|
||
_smallFrames = 0;
|
||
_largeFrames = 0;
|
||
}
|
||
} else {
|
||
_noBarcodeFrames++;
|
||
_smallFrames = 0;
|
||
_largeFrames = 0;
|
||
}
|
||
|
||
if (_smallFrames >= _kSmallFramesThreshold ||
|
||
_noBarcodeFrames >= _kNoBarcodeFramesThreshold) {
|
||
unawaited(_zoomIn());
|
||
} else if (_largeFrames >= _kLargeFramesThreshold && _currentZoomScale > 0) {
|
||
unawaited(_zoomOut());
|
||
}
|
||
}
|
||
|
||
/// 用户手动设置缩放比例(Slider / +/- 按钮)。
|
||
///
|
||
/// 手动操作后自适应缩放会暂停数秒,避免立刻被自动逻辑覆盖。
|
||
void _setManualZoom(double value, {bool apply = true}) {
|
||
final clamped = value.clamp(0.0, _kMaxZoomScale);
|
||
_currentZoomScale = clamped;
|
||
_manualZoomUntil = DateTime.now().add(const Duration(seconds: 3));
|
||
_lastZoomAdjustment = DateTime.now();
|
||
if (mounted) setState(() {});
|
||
if (apply) {
|
||
unawaited(_applyZoomToCamera());
|
||
}
|
||
}
|
||
|
||
/// 将 [_currentZoomScale] 同步到相机(带运行态检查与失败静默)。
|
||
Future<void> _applyZoomToCamera() async {
|
||
try {
|
||
if (_controller.value.isRunning) {
|
||
await _controller.setZoomScale(_currentZoomScale);
|
||
}
|
||
} catch (_) {
|
||
// 缩放失败不阻塞扫码流程。
|
||
}
|
||
}
|
||
|
||
/// 放大一步:增加 [_kZoomStep],不超过 [_kMaxZoomScale]。
|
||
Future<void> _zoomIn() async {
|
||
if (_currentZoomScale >= _kMaxZoomScale) return;
|
||
_currentZoomScale = math.min(_currentZoomScale + _kZoomStep, _kMaxZoomScale);
|
||
_lastZoomAdjustment = DateTime.now();
|
||
_smallFrames = 0;
|
||
_noBarcodeFrames = 0;
|
||
if (mounted) setState(() {});
|
||
await _applyZoomToCamera();
|
||
}
|
||
|
||
/// 缩小一步:减少 [_kZoomStep],不低于 0.0。
|
||
Future<void> _zoomOut() async {
|
||
if (_currentZoomScale <= 0) return;
|
||
_currentZoomScale = math.max(_currentZoomScale - _kZoomStep, 0.0);
|
||
_lastZoomAdjustment = DateTime.now();
|
||
_largeFrames = 0;
|
||
if (mounted) setState(() {});
|
||
await _applyZoomToCamera();
|
||
}
|
||
|
||
/// 恢复默认视图:重置缩放与所有计数器。
|
||
Future<void> _resetAdaptiveZoom() async {
|
||
_currentZoomScale = 0.0;
|
||
_smallFrames = 0;
|
||
_noBarcodeFrames = 0;
|
||
_largeFrames = 0;
|
||
_lastZoomAdjustment = null;
|
||
_manualZoomUntil = null;
|
||
if (mounted) setState(() {});
|
||
try {
|
||
await _controller.resetZoomScale();
|
||
} catch (_) {
|
||
// 相机未运行时 resetZoomScale 会静默失败,无需处理。
|
||
}
|
||
}
|
||
|
||
/// 解析扫码 payload:query 模式本地查找设备跳详情;
|
||
/// bind 模式弹窗确认绑定门店/分组。
|
||
Future<void> _handlePayload(String payload) async {
|
||
if (_handled) return;
|
||
_handled = true;
|
||
|
||
if (!_isBind) {
|
||
await _queryDevice(payload);
|
||
return;
|
||
}
|
||
await _enterBindForm(payload);
|
||
}
|
||
|
||
/// 查询模式:解析二维码(纯 MAC 或 JSON)后在本地列表匹配,命中跳详情;
|
||
/// 本地未命中时再按 MAC/SN 到后端查询,覆盖「设备已激活但尚未进入本地缓存列表」的场景。
|
||
Future<void> _queryDevice(String payload) async {
|
||
final t = AppLocalizations.of(context);
|
||
final parsed = _parseScanPayload(payload);
|
||
// 二维码内容无法识别为任何设备标识(既非 MAC 也非含 mac/id/sn 的 JSON)。
|
||
if (parsed.mac == null && parsed.id == null && parsed.sn == null) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(t.scanPayloadUnrecognized)),
|
||
);
|
||
_handled = false;
|
||
return;
|
||
}
|
||
try {
|
||
final devices = await ref.read(devicesProvider.future);
|
||
Device? match;
|
||
for (final device in devices) {
|
||
if (parsed.mac != null &&
|
||
_normalizeMac(device.mac) == _normalizeMac(parsed.mac!)) {
|
||
match = device;
|
||
break;
|
||
}
|
||
if (parsed.id != null &&
|
||
device.id.toLowerCase() == parsed.id!.toLowerCase()) {
|
||
match = device;
|
||
break;
|
||
}
|
||
if (parsed.sn != null &&
|
||
device.deviceSn.toLowerCase() == parsed.sn!.toLowerCase()) {
|
||
match = device;
|
||
break;
|
||
}
|
||
}
|
||
if (!mounted) return;
|
||
if (match != null) {
|
||
await _resetAdaptiveZoom();
|
||
if (!mounted) return;
|
||
context.push('/device/${match.id}');
|
||
return;
|
||
}
|
||
// 本地列表未命中:尝试后端按 MAC/SN 查询(设备可能已激活但本地缓存尚未刷新)。
|
||
final String identifier = parsed.mac ?? parsed.id ?? parsed.sn!;
|
||
Device? fresh;
|
||
try {
|
||
if (parsed.mac != null) {
|
||
final list =
|
||
await DeviceService().fetchDevices(mac: parsed.mac, limit: 1);
|
||
if (list.isNotEmpty) fresh = list.first;
|
||
} else if (parsed.sn != null) {
|
||
final list = await DeviceService()
|
||
.fetchDevices(deviceSn: parsed.sn, limit: 1);
|
||
if (list.isNotEmpty) fresh = list.first;
|
||
}
|
||
// id 仅作本地匹配;后端按 id 查询走 fetchDeviceById 会吞掉登录失效,
|
||
// 故此处不再对 id-only 场景做后端兜底。
|
||
} on Object catch (err) {
|
||
// 登录态失效需上抛,由外层统一跳转登录页;其余查询异常忽略,走友好提示。
|
||
if (isUnauthorizedError(err)) rethrow;
|
||
}
|
||
if (!mounted) return;
|
||
if (fresh != null) {
|
||
// 刷新门店筛选Provider族,确保返回设备列表页能看到该设备。
|
||
ref.invalidate(devicesByStoreProvider);
|
||
await _resetAdaptiveZoom();
|
||
if (!mounted) return;
|
||
context.push('/device/${fresh.id}');
|
||
return;
|
||
}
|
||
// 二维码有效但设备暂未就绪(未联网 / 未同步 / 未归属当前账号等)。
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(t.scanDeviceNotReady(identifier))),
|
||
);
|
||
_handled = false;
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
// 登录态失效:拦截器已清空令牌并触发路由重定向到登录页,
|
||
// 此处不再展示底层错误,直接兜底跳转登录页。
|
||
if (isUnauthorizedError(e)) {
|
||
context.go('/login');
|
||
return;
|
||
}
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(t.scanQueryFailed(formatApiError(e)))),
|
||
);
|
||
_handled = false;
|
||
}
|
||
}
|
||
|
||
// ================ 扫码绑定表单(页面内表单,替代原分步弹窗) ================
|
||
|
||
/// 绑定/激活模式:扫码成功后进入页面内表单。
|
||
///
|
||
/// 表单含「门店分组」下拉(从分组列表选取)与「房号」输入框,
|
||
/// 由用户在页面上确认门店分组与房号后,再真正写入后端。
|
||
/// - 激活模式(无预填门店):先加载可选门店下拉,选择门店后级联加载其分组;
|
||
/// - 绑定模式(路由已预填门店):门店固定展示,直接进入分组选择。
|
||
Future<void> _enterBindForm(String payload) async {
|
||
final t = AppLocalizations.of(context);
|
||
final mac = _extractMacFromPayload(payload);
|
||
if (mac == null || mac.isEmpty) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(t.scanPayloadNoMac)),
|
||
);
|
||
_handled = false;
|
||
return;
|
||
}
|
||
if (!mounted) return;
|
||
setState(() => _scannedMac = mac);
|
||
// 扫码成功:先恢复默认视图,再停止相机。
|
||
await _resetAdaptiveZoom();
|
||
// 表单态停止相机预览,避免持续占用摄像头。
|
||
await _controller.stop();
|
||
if (!mounted) return;
|
||
|
||
if (_isActivate) {
|
||
try {
|
||
_storeOptions = await OrgService().getMyStores();
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
if (isUnauthorizedError(e)) {
|
||
context.go('/login');
|
||
return;
|
||
}
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(t.scanLoadStoresFailed(formatApiError(e)))),
|
||
);
|
||
}
|
||
} else {
|
||
_selectedStore = StoreInfo(
|
||
orgId: widget.storeId ?? '',
|
||
name: widget.storeName ?? '',
|
||
address: '',
|
||
contactPhone: '',
|
||
activationCode: '',
|
||
status: 0,
|
||
expireTime: null,
|
||
createTime: null,
|
||
);
|
||
await _loadGroups(_selectedStore!.orgId, _selectedStore!.name);
|
||
}
|
||
if (!mounted) return;
|
||
setState(() {});
|
||
}
|
||
|
||
/// 加载指定门店下的分组列表,供「门店分组」下拉选取。
|
||
Future<void> _loadGroups(String storeId, String storeName) async {
|
||
final t = AppLocalizations.of(context);
|
||
if (storeId.isEmpty) {
|
||
setState(() {
|
||
_groupOptions = const [];
|
||
_selectedGroup = null;
|
||
_groupError = null;
|
||
});
|
||
return;
|
||
}
|
||
setState(() => _loadingGroups = true);
|
||
try {
|
||
final groups = await DeviceService().getDeviceGroupList(storeId: storeId);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_groupOptions = groups;
|
||
_selectedGroup = null;
|
||
_groupError = groups.isEmpty ? t.scanNoGroups(storeName) : null;
|
||
});
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
if (isUnauthorizedError(e)) {
|
||
context.go('/login');
|
||
return;
|
||
}
|
||
setState(() {
|
||
_groupOptions = const [];
|
||
_selectedGroup = null;
|
||
_groupError = t.scanLoadGroupsFailed(formatApiError(e));
|
||
});
|
||
} finally {
|
||
if (mounted) setState(() => _loadingGroups = false);
|
||
}
|
||
}
|
||
|
||
/// 激活模式:用户切换门店后级联重新加载分组。
|
||
Future<void> _onStoreChanged(StoreInfo? store) async {
|
||
if (store == null) return;
|
||
setState(() {
|
||
_selectedStore = store;
|
||
_storeError = null;
|
||
});
|
||
await _loadGroups(store.orgId, store.name);
|
||
}
|
||
|
||
/// 校验表单:门店(仅激活模式必填)、门店分组。房号为选填项。
|
||
///
|
||
/// 返回 true 表示校验通过。校验失败时将对应错误文案写入各 *_error 字段,
|
||
/// 由输入框下方 errorText 实时展示。房号允许为空,仅当用户填写了内容时才
|
||
/// 校验其格式(中英文/数字/连字符/斜杠,长度 1-20)。
|
||
bool _validateForm() {
|
||
final t = AppLocalizations.of(context);
|
||
final storeError = (_isActivate && _selectedStore == null) ? t.scanStoreRequired : null;
|
||
final groupError = _selectedGroup == null ? t.scanGroupRequired : null;
|
||
final trimmedRoom = _roomNoController.text.trim();
|
||
// 房号选填:为空直接通过;仅非空时校验格式。
|
||
final roomError = trimmedRoom.isEmpty
|
||
? null
|
||
: (_isRoomNoValid(trimmedRoom) ? null : t.scanRoomInvalid);
|
||
setState(() {
|
||
_storeError = storeError;
|
||
_groupError = groupError;
|
||
_roomError = roomError;
|
||
});
|
||
return storeError == null && groupError == null && roomError == null;
|
||
}
|
||
|
||
/// 房号格式:非空、长度 1~20、仅含中英文/数字/连字符/斜杠,不含空白。
|
||
static bool _isRoomNoValid(String room) {
|
||
if (room.length > 20) return false;
|
||
return RegExp(r'^[一-龥A-Za-z0-9\-\/]+$').hasMatch(room);
|
||
}
|
||
|
||
/// 提交绑定/激活:校验通过后写入后端并刷新设备列表。
|
||
Future<void> _submitBind() async {
|
||
final t = AppLocalizations.of(context);
|
||
if (_submitting) return;
|
||
if (!_validateForm()) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(t.scanFormInvalid)),
|
||
);
|
||
return;
|
||
}
|
||
final mac = _scannedMac!;
|
||
final store = _selectedStore!;
|
||
final group = _selectedGroup!;
|
||
final room = _roomNoController.text.trim();
|
||
setState(() => _submitting = true);
|
||
try {
|
||
await DeviceService().activateDeviceByScan(
|
||
mac: mac,
|
||
storeId: store.orgId,
|
||
groupId: group.id,
|
||
roomNo: room,
|
||
);
|
||
await StoreLocalCache.instance.saveDeviceBinding(
|
||
mac,
|
||
store.orgId,
|
||
store.name,
|
||
group.id,
|
||
);
|
||
// 刷新门店筛选Provider族,确保返回设备列表页同步新绑定的设备。
|
||
ref.invalidate(devicesByStoreProvider);
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(_isActivate ? t.scanActivateSuccess : t.scanBindSuccess)),
|
||
);
|
||
context.pop();
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
if (isUnauthorizedError(e)) {
|
||
context.go('/login');
|
||
return;
|
||
}
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(t.scanBindFailed(formatApiError(e)))),
|
||
);
|
||
} finally {
|
||
if (mounted) setState(() => _submitting = false);
|
||
}
|
||
}
|
||
|
||
/// 重置回扫码态:清空表单并重启相机。
|
||
void _resetScan() {
|
||
_roomNoController.clear();
|
||
setState(() {
|
||
_scannedMac = null;
|
||
_storeOptions = const [];
|
||
_selectedStore = null;
|
||
_groupOptions = const [];
|
||
_selectedGroup = null;
|
||
_storeError = null;
|
||
_groupError = null;
|
||
_roomError = null;
|
||
_loadingGroups = false;
|
||
_submitting = false;
|
||
});
|
||
_handled = false;
|
||
unawaited(_controller.start().then((_) {
|
||
if (mounted) _resetAdaptiveZoom();
|
||
}));
|
||
}
|
||
|
||
/// 从扫码 payload 中提取 MAC 地址。
|
||
///
|
||
/// 兼容格式:
|
||
/// - 纯 MAC 字符串,如 `18:EB:D4:A7:D3:A3` 或 `18-EB-D4-A7-D3-A3`。
|
||
/// - JSON,如 `{"mac":"18:EB:D4:A7:D3:A3"}`。
|
||
/// 提取失败返回 null。
|
||
String? _extractMacFromPayload(String payload) {
|
||
final trimmed = payload.trim();
|
||
// 1. 直接是 MAC。
|
||
if (_isMacAddress(trimmed)) return trimmed.toUpperCase();
|
||
// 2. 尝试 JSON。
|
||
try {
|
||
final decoded = jsonDecode(trimmed);
|
||
if (decoded is Map<String, dynamic>) {
|
||
for (final key in const ['mac', 'macAddress', 'mac_address', 'deviceMac']) {
|
||
final value = decoded[key];
|
||
if (value is String && _isMacAddress(value)) {
|
||
return value.toUpperCase();
|
||
}
|
||
}
|
||
}
|
||
} catch (_) {
|
||
// 忽略解析异常。
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// 判断 [value] 是否为 MAC 地址格式。
|
||
static bool _isMacAddress(String value) {
|
||
return RegExp(r'^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$')
|
||
.hasMatch(value.trim());
|
||
}
|
||
|
||
/// 归一化 MAC:去分隔符并转大写,便于忽略 `:` / `-` 与大小写差异的比较。
|
||
static String _normalizeMac(String mac) =>
|
||
mac.toUpperCase().replaceAll(RegExp(r'[^0-9A-F]'), '');
|
||
|
||
/// 解析扫码 payload,提取可用于匹配设备的标识(mac / id / sn)。
|
||
///
|
||
/// 兼容:纯 MAC(含 `:` / `-` 分隔或无分隔)、
|
||
/// JSON({"mac":...}/{"id":...}/{"sn":...}/{"deviceUid":...} 等)。
|
||
({String? mac, String? id, String? sn}) _parseScanPayload(String payload) {
|
||
final trimmed = payload.trim();
|
||
if (trimmed.isEmpty) return (mac: null, id: null, sn: null);
|
||
// 1. 纯 MAC。
|
||
if (_isMacAddress(trimmed)) {
|
||
return (mac: trimmed.toUpperCase(), id: null, sn: null);
|
||
}
|
||
// 2. 尝试 JSON。
|
||
try {
|
||
final decoded = jsonDecode(trimmed);
|
||
if (decoded is Map<String, dynamic>) {
|
||
String? mac;
|
||
for (final key in const [
|
||
'mac',
|
||
'macAddress',
|
||
'mac_address',
|
||
'deviceMac',
|
||
]) {
|
||
final value = decoded[key];
|
||
if (value is String && _isMacAddress(value)) {
|
||
mac = value.toUpperCase();
|
||
break;
|
||
}
|
||
}
|
||
final idVal = decoded['id'] ?? decoded['deviceId'] ?? decoded['deviceUid'];
|
||
final snVal = decoded['sn'] ?? decoded['deviceSn'] ?? decoded['deviceSN'];
|
||
return (
|
||
mac: mac,
|
||
id: idVal is String && idVal.isNotEmpty ? idVal : null,
|
||
sn: snVal is String && snVal.isNotEmpty ? snVal : null,
|
||
);
|
||
}
|
||
} catch (_) {
|
||
// 非 JSON。
|
||
}
|
||
return (mac: null, id: null, sn: null);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = AppLocalizations.of(context);
|
||
final title = _isActivate
|
||
? t.scanTitleActivate
|
||
: (_isBind ? t.scanTitleBind : t.scanTitleQuery);
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
automaticallyImplyLeading: false,
|
||
title: Text(title),
|
||
// 表单态提供关闭按钮,等价于「重新扫码」。
|
||
actions: _scannedMac != null
|
||
? [
|
||
IconButton(
|
||
icon: const Icon(Icons.close),
|
||
tooltip: t.cancel,
|
||
onPressed: _resetScan,
|
||
),
|
||
]
|
||
: null,
|
||
),
|
||
body: _cameraReady != true
|
||
? _buildCameraStatus(t)
|
||
: (_scannedMac != null ? _buildBindForm(t) : _buildScanner(t)),
|
||
);
|
||
}
|
||
|
||
/// 相机未就绪(权限/初始化中/失败)时的占位界面。
|
||
Widget _buildCameraStatus(AppLocalizations t) {
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
_cameraReady == false
|
||
? Icons.camera_alt_outlined
|
||
: Icons.hourglass_empty,
|
||
size: 56,
|
||
color: Theme.of(context).colorScheme.outline,
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
_cameraReady == false
|
||
? (_cameraError ?? t.cameraUnavailable)
|
||
: t.cameraInitializing,
|
||
textAlign: TextAlign.center,
|
||
style: Theme.of(context).textTheme.bodyMedium,
|
||
),
|
||
if (_cameraReady == false) ...[
|
||
const SizedBox(height: 16),
|
||
FilledButton.tonal(
|
||
onPressed: () {
|
||
setState(() => _cameraReady = null);
|
||
_initCamera();
|
||
},
|
||
child: Text(t.retry),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 扫码相机界面(含取景框与底部提示)。
|
||
Widget _buildScanner(AppLocalizations t) {
|
||
final hint = _isActivate
|
||
? t.scanHintActivate
|
||
: (_isBind ? t.scanHintBind : t.scanHintQuery);
|
||
return Stack(
|
||
children: [
|
||
MobileScanner(
|
||
controller: _controller,
|
||
onDetect: _onDetect,
|
||
errorBuilder: (context, error, child) {
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(Icons.error_outline, size: 56, color: Colors.red),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
t.cameraOpenFailed(error.errorCode.name),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
const SizedBox(height: 16),
|
||
FilledButton.tonal(
|
||
onPressed: () => _controller.start(),
|
||
child: Text(t.retry),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
// 扫码框提示。
|
||
Center(
|
||
child: Container(
|
||
width: 240,
|
||
height: 240,
|
||
decoration: BoxDecoration(
|
||
border: Border.all(color: Colors.white, width: 2),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
),
|
||
),
|
||
// 手动缩放控制条(Slider + +/- 按钮),相机就绪且运行时才显示。
|
||
Positioned(
|
||
bottom: 90,
|
||
left: 16,
|
||
right: 16,
|
||
child: ValueListenableBuilder<MobileScannerState>(
|
||
valueListenable: _controller,
|
||
builder: (context, state, child) {
|
||
if (!state.isInitialized || !state.isRunning) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black54,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
IconButton(
|
||
icon: const Icon(Icons.remove, color: Colors.white),
|
||
tooltip: '缩小',
|
||
onPressed: () => _setManualZoom(
|
||
_currentZoomScale - _kZoomStep,
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Slider(
|
||
value: _currentZoomScale,
|
||
min: 0.0,
|
||
max: _kMaxZoomScale,
|
||
divisions: (_kMaxZoomScale / _kZoomStep).round(),
|
||
activeColor: Colors.white,
|
||
inactiveColor: Colors.white38,
|
||
onChanged: _setManualZoom,
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.add, color: Colors.white),
|
||
tooltip: '放大',
|
||
onPressed: () => _setManualZoom(
|
||
_currentZoomScale + _kZoomStep,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
Text(
|
||
'缩放 ${(_currentZoomScale * 100).toStringAsFixed(0)}%',
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 12,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
Positioned(
|
||
bottom: 32,
|
||
left: 0,
|
||
right: 0,
|
||
child: Center(
|
||
child: Text(
|
||
hint,
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
backgroundColor: Colors.black54,
|
||
fontSize: 14,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 扫码成功后的绑定/激活表单:门店分组下拉 + 房号输入 + 校验与提交。
|
||
Widget _buildBindForm(AppLocalizations t) {
|
||
final isActivate = _isActivate;
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
// 已识别设备(MAC)。
|
||
Card(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(t.scanFormDeviceTitle,
|
||
style: Theme.of(context).textTheme.titleMedium),
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
children: [
|
||
Icon(Icons.qr_code_2,
|
||
size: 20, color: Theme.of(context).colorScheme.primary),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text('${t.macAddress}:$_scannedMac',
|
||
style: Theme.of(context).textTheme.bodyLarge),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
// 门店(激活模式可下拉选择;绑定模式固定展示)。
|
||
if (isActivate)
|
||
DropdownButtonFormField<StoreInfo>(
|
||
value: _selectedStore,
|
||
items: _storeOptions
|
||
.map((s) => DropdownMenuItem(
|
||
value: s,
|
||
child: Text(s.name.isNotEmpty ? s.name : s.orgId),
|
||
))
|
||
.toList(),
|
||
onChanged: _submitting ? null : (v) => _onStoreChanged(v),
|
||
decoration: InputDecoration(
|
||
labelText: t.selectStore,
|
||
border: const OutlineInputBorder(),
|
||
errorText: _storeError,
|
||
),
|
||
hint: Text(t.pleaseSelect),
|
||
)
|
||
else
|
||
InputDecorator(
|
||
decoration: InputDecoration(
|
||
labelText: t.belongStore,
|
||
border: const OutlineInputBorder(),
|
||
),
|
||
child: Text(_selectedStore?.name.isNotEmpty == true
|
||
? _selectedStore!.name
|
||
: (_selectedStore?.orgId ?? '')),
|
||
),
|
||
const SizedBox(height: 16),
|
||
// 门店分组。
|
||
_loadingGroups
|
||
? Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||
child: Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 18,
|
||
height: 18,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
),
|
||
SizedBox(width: 12),
|
||
Text(t.groupListLoading),
|
||
],
|
||
),
|
||
)
|
||
: DropdownButtonFormField<OrgNode>(
|
||
value: _selectedGroup,
|
||
items: _groupOptions
|
||
.map((g) => DropdownMenuItem(
|
||
value: g,
|
||
child: Text(g.name.isNotEmpty ? g.name : g.id),
|
||
))
|
||
.toList(),
|
||
onChanged: _submitting
|
||
? null
|
||
: (v) => setState(() => _selectedGroup = v),
|
||
decoration: InputDecoration(
|
||
labelText: t.scanGroupLabel,
|
||
border: const OutlineInputBorder(),
|
||
errorText: _groupError,
|
||
hintText: t.pleaseSelect,
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
// 房号(选填)。
|
||
TextFormField(
|
||
controller: _roomNoController,
|
||
enabled: !_submitting,
|
||
textInputAction: TextInputAction.done,
|
||
decoration: InputDecoration(
|
||
labelText: t.roomNoOptional,
|
||
border: const OutlineInputBorder(),
|
||
errorText: _roomError,
|
||
hintText: t.scanRoomHint,
|
||
),
|
||
onChanged: (_) {
|
||
if (_roomError != null) setState(() => _roomError = null);
|
||
},
|
||
),
|
||
const SizedBox(height: 24),
|
||
FilledButton(
|
||
onPressed: _submitting ? null : _submitBind,
|
||
child: _submitting
|
||
? const SizedBox(
|
||
width: 18,
|
||
height: 18,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: Colors.white,
|
||
),
|
||
)
|
||
: Text(_isActivate ? t.scanActivate : t.scanBind),
|
||
),
|
||
const SizedBox(height: 12),
|
||
OutlinedButton(
|
||
onPressed: _submitting ? null : _resetScan,
|
||
child: Text(t.scanRescan),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|