Files
CloudHome-Ops/lib/screens/device/device_scan_page.dart

1024 lines
35 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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