import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:pad_scanner/services/app_config_service.dart'; import 'package:pad_scanner/services/scanner_service.dart'; import 'package:pad_scanner/services/code_parser.dart'; import 'package:pad_scanner/services/api_service.dart'; import 'package:pad_scanner/services/feedback_service.dart'; import 'package:pad_scanner/pages/boxing_detail_page.dart'; import 'package:pad_scanner/widgets/status_bar.dart'; // === 装箱模式 === enum BoxingMode { one2one, // 一码一箱 one2many, // 一码多箱 many2one, // 多码一箱 } // === 页面阶段 === enum _Phase { waiting, // 等待扫码 scanned, // 已扫码,显示信息 submitted, // 已提交成功 } // === 主页面 === class BoxingPage extends StatefulWidget { const BoxingPage({super.key}); @override State createState() => _BoxingPageState(); } class _BoxingPageState extends State { final _scannerService = ScannerService(); final _apiService = ApiService(); final _feedbackService = FeedbackService(); // 模式 BoxingMode _mode = BoxingMode.one2one; // 阶段 _Phase _phase = _Phase.waiting; // 当前总排号 String? _zongpaiNo; // 排产号信息(来自后端查询) String? _paichanNo; int? _erpQuantity; List _existingBoxes = []; int _maxBoxNo = 0; // 输入 final _boxNoController = TextEditingController(); final _quantityController = TextEditingController(); final _boxNoFocusNode = FocusNode(); final _quantityFocusNode = FocusNode(); // 多码一箱:已扫描过的总排号列表(用于显示) final _scannedZongpais = []; // 箱号锁定(多码一箱模式) bool _boxNoLocked = false; // 提交中 bool _isSubmitting = false; // 一码多箱:上次提交的值(用于继续添加预填) int? _lastBoxNo; int? _lastQuantity; // 重复箱号 bool _isDuplicateBoxNo = false; // Status bar state StatusDotColor _statusDot = StatusDotColor.blue; String _statusText = '等待扫码'; String? _statusOverrideText; StatusDotColor? _statusOverrideDot; @override void initState() { super.initState(); _scannerService.scanResults.listen(_onScan); } @override void dispose() { _boxNoController.dispose(); _quantityController.dispose(); _boxNoFocusNode.dispose(); _quantityFocusNode.dispose(); _feedbackService.dispose(); super.dispose(); } // === 模式切换 === String get _modeLabel { switch (_mode) { case BoxingMode.one2one: return '一码一箱'; case BoxingMode.one2many: return '一码多箱'; case BoxingMode.many2one: return '多码一箱'; } } void _cycleMode() { _feedbackService.trigger(FeedbackEvent.modeSwitch); setState(() { switch (_mode) { case BoxingMode.one2one: _mode = BoxingMode.one2many; case BoxingMode.one2many: _mode = BoxingMode.many2one; case BoxingMode.many2one: _mode = BoxingMode.one2one; } _resetState(); }); } void _resetState() { _phase = _Phase.waiting; _zongpaiNo = null; _paichanNo = null; _erpQuantity = null; _existingBoxes = []; _maxBoxNo = 0; _boxNoController.clear(); _quantityController.clear(); _scannedZongpais.clear(); _boxNoLocked = false; _isSubmitting = false; _lastBoxNo = null; _lastQuantity = null; _isDuplicateBoxNo = false; _statusOverrideText = null; _statusOverrideDot = null; } // === 扫码处理 === void _onScan(ScanResult result) { final parsed = CodeParser.parse(result.barcode); if (parsed.type != CodeType.zongpaiNo) { _feedbackService.trigger(FeedbackEvent.scanInvalid); _showStatusOverride( '无效码,请重新扫描', StatusDotColor.red, const Duration(seconds: 2), ); return; } final zongpai = parsed.value; // 多码一箱已提交后:等待下一个扫码 if (_mode == BoxingMode.many2one && _phase == _Phase.submitted) { _handleNextScanMany2One(zongpai); return; } // 一码一箱:允许覆盖,直接查询新二维码(不提前清空状态,避免闪烁) if (_mode != BoxingMode.one2one && _phase != _Phase.waiting) { return; } _queryBoxInfo(zongpai); } void _handleNextScanMany2One(String zongpai) { setState(() { _phase = _Phase.waiting; _zongpaiNo = null; _statusOverrideText = null; }); _queryBoxInfo(zongpai); } Future _queryBoxInfo(String zongpai) async { final configService = AppConfigService(); final baseUrl = await configService.getString('api_url') ?? ''; if (baseUrl.isEmpty) { _feedbackService.trigger(FeedbackEvent.submitFailure); _showStatusOverride( '未配置 API 地址,请前往设置', StatusDotColor.red, const Duration(seconds: 2), ); return; } final result = await _apiService.fetchBoxInfo( baseUrl: baseUrl, zongpaiNo: zongpai, ); if (!mounted) return; if (!result.success) { final isNetwork = result.errorMessage == '网络异常,请检查网络连接'; _feedbackService.trigger( isNetwork ? FeedbackEvent.networkError : FeedbackEvent.scanInvalid, ); _showStatusOverride( result.errorMessage ?? '查询失败', isNetwork ? StatusDotColor.yellow : StatusDotColor.red, const Duration(seconds: 2), ); return; } _feedbackService.trigger(FeedbackEvent.scanValid); setState(() { _zongpaiNo = zongpai; _paichanNo = result.paichanNo; _erpQuantity = result.quantity; _existingBoxes = result.existingBoxes; _maxBoxNo = result.maxBoxNo; _phase = _Phase.scanned; _isDuplicateBoxNo = false; _statusOverrideText = null; // 多码一箱:记录已扫描列表 if (_mode == BoxingMode.many2one && !_scannedZongpais.contains(zongpai)) { _scannedZongpais.add(zongpai); } // 根据模式自动填充 _applyAutoFill(); }); } void _applyAutoFill() { switch (_mode) { case BoxingMode.one2one: _boxNoController.text = (_maxBoxNo + 1).toString(); _quantityController.text = (_erpQuantity ?? 0).toString(); case BoxingMode.one2many: if (_lastBoxNo != null) { _boxNoController.text = (_lastBoxNo! + 1).toString(); _quantityController.text = _lastQuantity?.toString() ?? ''; } else { _boxNoController.text = (_maxBoxNo + 1).toString(); _quantityController.clear(); } case BoxingMode.many2one: if (!_boxNoLocked) { _boxNoController.text = (_maxBoxNo + 1).toString(); _boxNoLocked = true; } _quantityController.text = (_erpQuantity ?? 0).toString(); } _checkDuplicateBoxNo(); } // === 重复箱号检测 === void _checkDuplicateBoxNo() { final boxNo = int.tryParse(_boxNoController.text); if (boxNo == null) { setState(() => _isDuplicateBoxNo = false); return; } final exists = _existingBoxes.any((b) => b.boxNo == boxNo); setState(() => _isDuplicateBoxNo = exists); } // === 提交 === bool get _canSubmit { if (_isSubmitting || _phase != _Phase.scanned) return false; if (_zongpaiNo == null) return false; final boxNo = int.tryParse(_boxNoController.text); final qty = int.tryParse(_quantityController.text); if (boxNo == null || qty == null || qty <= 0) return false; if (_isDuplicateBoxNo) return false; return true; } Future _submit() async { if (!_canSubmit) return; final configService = AppConfigService(); final baseUrl = await configService.getString('api_url') ?? ''; if (baseUrl.isEmpty) { _feedbackService.trigger(FeedbackEvent.submitFailure); _showStatusOverride( '未配置 API 地址,请前往设置', StatusDotColor.red, const Duration(seconds: 2), ); return; } final boxNo = int.parse(_boxNoController.text); final quantity = int.parse(_quantityController.text); setState(() => _isSubmitting = true); final modeStr = switch (_mode) { BoxingMode.one2one => 'one-to-one', BoxingMode.one2many => 'one-to-many', BoxingMode.many2one => 'many-to-one', }; final result = await _apiService.saveBoxRecord( baseUrl: baseUrl, zongpaiNo: _zongpaiNo!, boxNo: boxNo, quantity: quantity, boxMode: modeStr, ); if (!mounted) return; setState(() => _isSubmitting = false); if (result.success) { _onSubmitSuccess(boxNo, quantity); } else if (result.isDuplicate) { _feedbackService.trigger(FeedbackEvent.duplicateBoxNo); if (_mode == BoxingMode.one2one) { _showStatusOverride( '该总排号已绑定箱号 ${result.boxNo ?? "?"},请勿重复装箱', StatusDotColor.red, const Duration(seconds: 2), ); } else { _showStatusOverride( '排产号 ${result.paichanNo ?? ""} 下箱号 ${result.boxNo ?? ""} 已存在', StatusDotColor.amber, const Duration(seconds: 2), ); } } else { final isNetwork = result.errorMessage == '网络异常,请检查网络连接'; _feedbackService.trigger( isNetwork ? FeedbackEvent.networkError : FeedbackEvent.submitFailure, ); _showStatusOverride( result.errorMessage ?? '提交失败', isNetwork ? StatusDotColor.yellow : StatusDotColor.red, const Duration(seconds: 2), ); } } void _onSubmitSuccess(int boxNo, int quantity) { _feedbackService.trigger(FeedbackEvent.submitSuccess); setState(() { _lastBoxNo = boxNo; _lastQuantity = quantity; _existingBoxes = List.from(_existingBoxes) ..add( BoxDetailData( boxNo: boxNo, items: [BoxItemData(zongpaiNo: _zongpaiNo!, quantity: quantity)], ), ); _maxBoxNo = _maxBoxNo > boxNo ? _maxBoxNo : boxNo; }); switch (_mode) { case BoxingMode.one2one: _showStatusOverride('装箱成功', StatusDotColor.green, const Duration(milliseconds: 1500)); Future.delayed(const Duration(milliseconds: 1500), () { if (mounted) setState(() => _resetState()); }); case BoxingMode.one2many: setState(() { _phase = _Phase.submitted; _zongpaiNo = null; }); _showStatusOverride('装箱成功,可继续添加或返回', StatusDotColor.green, const Duration(milliseconds: 1500)); case BoxingMode.many2one: setState(() { _phase = _Phase.submitted; _zongpaiNo = null; }); _showStatusOverride('装箱成功,请扫描下一个总排号', StatusDotColor.green, const Duration(milliseconds: 1500)); } } // === 操作按钮 === void _onContinueAdding() { setState(() { _phase = _Phase.waiting; _statusOverrideText = null; }); } void _onGoBack() { setState(() => _resetState()); } // === Status bar management === void _showStatusOverride(String text, StatusDotColor dot, Duration duration) { setState(() { _statusOverrideText = text; _statusOverrideDot = dot; }); Future.delayed(duration, () { if (mounted) { setState(() { _statusOverrideText = null; _statusOverrideDot = null; }); } }); } // === 导航到详情页 === void _openDetail() { Navigator.push( context, MaterialPageRoute( builder: (_) => BoxingDetailPage( paichanNo: _paichanNo ?? '', existingBoxes: _existingBoxes, ), ), ); } // === 状态文字 === void _updateBaseStatus() { if (_isSubmitting) { _statusDot = StatusDotColor.orange; _statusText = '正在提交…'; return; } if (_isDuplicateBoxNo && _phase == _Phase.scanned) { _statusDot = StatusDotColor.amber; _statusText = '箱号 ${_boxNoController.text} 已存在,请重新输入'; return; } switch (_phase) { case _Phase.waiting: _statusDot = StatusDotColor.blue; if (_mode == BoxingMode.many2one && _boxNoLocked) { _statusText = '请扫描下一个总排号'; } else { _statusText = '等待扫码'; } case _Phase.scanned: _statusDot = StatusDotColor.blue; _statusText = '请确认信息并提交'; case _Phase.submitted: _statusDot = StatusDotColor.green; switch (_mode) { case BoxingMode.one2many: _statusText = '装箱成功,可继续添加或返回'; case BoxingMode.many2one: _statusText = '请扫描下一个总排号'; case BoxingMode.one2one: _statusText = ''; } } } // === Build === @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isWaiting = _phase == _Phase.waiting && _zongpaiNo == null; final showActionButtons = _phase == _Phase.submitted && _mode != BoxingMode.one2one; _updateBaseStatus(); final effectiveDot = _statusOverrideDot ?? _statusDot; final effectiveText = _statusOverrideText ?? _statusText; return Scaffold( appBar: AppBar( title: const Text('装箱编号'), actions: [ Padding( padding: const EdgeInsets.only(right: 4), child: TextButton.icon( onPressed: _cycleMode, icon: const Icon(Icons.swap_horiz, size: 18), label: Text(_modeLabel, style: const TextStyle(fontSize: 13)), style: TextButton.styleFrom( foregroundColor: _mode == BoxingMode.many2one ? Colors.orange : colorScheme.primary, ), ), ), ], ), body: Column( children: [ // 重复箱号警告条(保留,因为这是输入区关联的即时反馈) if (_isDuplicateBoxNo && _phase == _Phase.scanned) Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), color: Colors.amber.shade100, child: Text( '箱号 ${_boxNoController.text} 已存在,请重新输入', style: TextStyle(color: Colors.amber.shade900, fontSize: 13), ), ), // 主内容区 Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // === 扫码区 === _buildScanArea(isWaiting), const SizedBox(height: 12), // === 信息区 === _buildInfoArea(isWaiting, colorScheme), const Divider(height: 24), // === 输入区 === _buildInputArea(isWaiting), ], ), ), ), // 操作按钮(一码多箱 / 多码一箱) if (showActionButtons) Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Row( children: [ Expanded( child: OutlinedButton( onPressed: _onGoBack, style: OutlinedButton.styleFrom( minimumSize: const Size.fromHeight(40), ), child: const Text('返回'), ), ), if (_mode == BoxingMode.one2many) ...[ const SizedBox(width: 12), Expanded( child: ElevatedButton( onPressed: _onContinueAdding, style: ElevatedButton.styleFrom( minimumSize: const Size.fromHeight(40), ), child: const Text('继续添加'), ), ), ], ], ), ), // 底部状态栏 StatusBar(dotColor: effectiveDot, text: effectiveText), ], ), ); } // === 扫码区 Widget === Widget _buildScanArea(bool isWaiting) { if (isWaiting) { return Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8), ), child: const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.qr_code_scanner, color: Colors.grey, size: 20), SizedBox(width: 8), Text( '请扫描执行卡二维码', style: TextStyle(color: Colors.grey, fontSize: 14), ), ], ), ); } if (_mode == BoxingMode.many2one && _scannedZongpais.isNotEmpty) { return Wrap( spacing: 6, runSpacing: 4, children: _scannedZongpais.map((zp) { final isCurrent = zp == _zongpaiNo; return Chip( avatar: Icon( Icons.check_circle, size: 16, color: isCurrent ? Colors.green : Colors.grey, ), label: Text(zp, style: const TextStyle(fontSize: 13)), visualDensity: VisualDensity.compact, ); }).toList(), ); } return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), decoration: BoxDecoration( border: Border.all(color: Colors.green, width: 2), borderRadius: BorderRadius.circular(8), ), child: Row( children: [ const Icon(Icons.check_circle, color: Colors.green, size: 18), const SizedBox(width: 8), Text( _zongpaiNo ?? '', style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black87, ), ), const Spacer(), const Text( '已识别', style: TextStyle(fontSize: 12, color: Colors.green), ), ], ), ); } // === 信息区 Widget === Widget _buildInfoArea(bool isWaiting, ColorScheme colorScheme) { final grey = isWaiting; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '排产号:', style: TextStyle( fontSize: 13, color: grey ? Colors.grey : Colors.black54, ), ), const SizedBox(height: 2), Text( _paichanNo ?? '--', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: grey ? Colors.grey : Colors.black87, ), ), const SizedBox(height: 8), Row( children: [ Expanded( child: Row( children: [ Text( '已有箱数:', style: TextStyle( fontSize: 13, color: grey ? Colors.grey : Colors.black54, ), ), Text( grey ? '--' : '${_existingBoxes.length}箱', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: grey ? Colors.grey : Colors.black87, ), ), const SizedBox(width: 16), Text( '最大箱号:', style: TextStyle( fontSize: 13, color: grey ? Colors.grey : Colors.black54, ), ), Text( grey ? '--' : '$_maxBoxNo', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: _maxBoxNo > 0 ? Colors.amber.shade800 : Colors.grey, ), ), ], ), ), TextButton( onPressed: grey || _existingBoxes.isEmpty ? null : _openDetail, style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 8), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), child: Text( '详情 →', style: TextStyle( fontSize: 13, color: grey || _existingBoxes.isEmpty ? Colors.grey.shade400 : colorScheme.primary, ), ), ), ], ), ], ); } // === 输入区 Widget === Widget _buildInputArea(bool isWaiting) { final enabled = !isWaiting && _phase == _Phase.scanned; return Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ // 箱号输入 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '箱号', style: TextStyle( fontSize: 13, color: enabled ? Colors.black54 : Colors.grey, ), ), const SizedBox(height: 4), SizedBox( height: 40, child: TextField( controller: _boxNoController, focusNode: _boxNoFocusNode, enabled: enabled && !_boxNoLocked, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (_) => _checkDuplicateBoxNo(), decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(horizontal: 10), border: const OutlineInputBorder(), enabledBorder: OutlineInputBorder( borderSide: BorderSide( color: _isDuplicateBoxNo ? Colors.amber : Colors.grey.shade400, ), ), suffixIcon: _boxNoLocked ? const Icon(Icons.lock, size: 18, color: Colors.orange) : null, ), style: const TextStyle(fontSize: 16), ), ), ], ), ), const SizedBox(width: 8), // 数量输入 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '数量', style: TextStyle( fontSize: 13, color: enabled ? Colors.black54 : Colors.grey, ), ), const SizedBox(height: 4), SizedBox( height: 40, child: TextField( controller: _quantityController, focusNode: _quantityFocusNode, enabled: enabled, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: const InputDecoration( contentPadding: EdgeInsets.symmetric(horizontal: 10), border: OutlineInputBorder(), ), style: const TextStyle(fontSize: 16), ), ), ], ), ), const SizedBox(width: 8), // 确认按钮 SizedBox( height: 40, child: ElevatedButton( onPressed: _canSubmit ? _submit : null, style: ElevatedButton.styleFrom( backgroundColor: _canSubmit ? Theme.of(context).colorScheme.primary : Colors.grey.shade300, foregroundColor: _canSubmit ? Colors.white : Colors.grey.shade600, ), child: _isSubmitting ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : const Text('确认', style: TextStyle(fontSize: 15)), ), ), ], ); } }