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 createState() => _DeviceScanPageState(); } class _DeviceScanPageState extends ConsumerState { 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 _storeOptions = const []; /// 当前选中的门店(bind 模式为预填门店,激活模式为用户选择)。 StoreInfo? _selectedStore; /// 当前门店下的分组列表。 List _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 _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 _applyZoomToCamera() async { try { if (_controller.value.isRunning) { await _controller.setZoomScale(_currentZoomScale); } } catch (_) { // 缩放失败不阻塞扫码流程。 } } /// 放大一步:增加 [_kZoomStep],不超过 [_kMaxZoomScale]。 Future _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 _zoomOut() async { if (_currentZoomScale <= 0) return; _currentZoomScale = math.max(_currentZoomScale - _kZoomStep, 0.0); _lastZoomAdjustment = DateTime.now(); _largeFrames = 0; if (mounted) setState(() {}); await _applyZoomToCamera(); } /// 恢复默认视图:重置缩放与所有计数器。 Future _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 _handlePayload(String payload) async { if (_handled) return; _handled = true; if (!_isBind) { await _queryDevice(payload); return; } await _enterBindForm(payload); } /// 查询模式:解析二维码(纯 MAC 或 JSON)后在本地列表匹配,命中跳详情; /// 本地未命中时再按 MAC/SN 到后端查询,覆盖「设备已激活但尚未进入本地缓存列表」的场景。 Future _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 _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 _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 _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 _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) { 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? 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( 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( 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( 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), ), ], ), ); } }