feat: link transit registration to boxing

This commit is contained in:
Misaka_Company
2026-05-15 12:40:10 +08:00
parent b2f2c536cb
commit deb038404a
5 changed files with 445 additions and 29 deletions

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:pad_scanner/services/app_config_service.dart'; import 'package:pad_scanner/services/app_config_service.dart';
@@ -16,6 +18,16 @@ enum BoxingMode {
multiCode, // 多码凑箱 multiCode, // 多码凑箱
} }
class BoxingPageArguments {
final BoxingMode initialMode;
final List<String> autoScanCodes;
const BoxingPageArguments({
required this.initialMode,
required this.autoScanCodes,
});
}
// === 页面阶段 === // === 页面阶段 ===
enum _Phase { enum _Phase {
@@ -63,7 +75,9 @@ class _ManyToOnePackedItem {
// === 主页面 === // === 主页面 ===
class BoxingPage extends StatefulWidget { class BoxingPage extends StatefulWidget {
const BoxingPage({super.key}); final BoxingPageArguments? arguments;
const BoxingPage({super.key, this.arguments});
@override @override
State<BoxingPage> createState() => _BoxingPageState(); State<BoxingPage> createState() => _BoxingPageState();
@@ -73,6 +87,7 @@ class _BoxingPageState extends State<BoxingPage> {
final _scannerService = ScannerService(); final _scannerService = ScannerService();
final _apiService = ApiService(); final _apiService = ApiService();
final _feedbackService = FeedbackService(); final _feedbackService = FeedbackService();
StreamSubscription<ScanResult>? _scanSubscription;
// 模式 // 模式
BoxingMode _mode = BoxingMode.singleCode; BoxingMode _mode = BoxingMode.singleCode;
@@ -123,17 +138,29 @@ class _BoxingPageState extends State<BoxingPage> {
String _statusText = '等待扫码'; String _statusText = '等待扫码';
String? _statusOverrideText; String? _statusOverrideText;
StatusDotColor? _statusOverrideDot; StatusDotColor? _statusOverrideDot;
bool _isAutoProcessing = false;
final _autoWarnings = <String>[];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final arguments = widget.arguments;
if (arguments != null) {
_mode = arguments.initialMode;
}
_boxNoController.addListener(_onBoxNoTextChanged); _boxNoController.addListener(_onBoxNoTextChanged);
_scannerService.scanResults.listen(_onScan); _scanSubscription = _scannerService.scanResults.listen(_onScan);
if (arguments != null && arguments.autoScanCodes.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_processAutoScanCodes(arguments.autoScanCodes);
});
}
} }
@override @override
void dispose() { void dispose() {
_boxNoController.removeListener(_onBoxNoTextChanged); _boxNoController.removeListener(_onBoxNoTextChanged);
_scanSubscription?.cancel();
_boxNoController.dispose(); _boxNoController.dispose();
_quantityController.dispose(); _quantityController.dispose();
_boxNoFocusNode.dispose(); _boxNoFocusNode.dispose();
@@ -206,6 +233,10 @@ class _BoxingPageState extends State<BoxingPage> {
// === 扫码处理 === // === 扫码处理 ===
void _onScan(ScanResult result) { void _onScan(ScanResult result) {
if (_isAutoProcessing) {
return;
}
final parsed = CodeParser.parse(result.barcode); final parsed = CodeParser.parse(result.barcode);
if (parsed.type != CodeType.zongpaiNo) { if (parsed.type != CodeType.zongpaiNo) {
@@ -224,7 +255,7 @@ class _BoxingPageState extends State<BoxingPage> {
_queryBoxInfo(zongpai); _queryBoxInfo(zongpai);
} }
Future<void> _queryBoxInfo(String zongpai) async { Future<bool> _queryBoxInfo(String zongpai) async {
final configService = AppConfigService(); final configService = AppConfigService();
final baseUrl = await configService.getString('api_url') ?? ''; final baseUrl = await configService.getString('api_url') ?? '';
if (baseUrl.isEmpty) { if (baseUrl.isEmpty) {
@@ -234,7 +265,7 @@ class _BoxingPageState extends State<BoxingPage> {
StatusDotColor.red, StatusDotColor.red,
const Duration(seconds: 2), const Duration(seconds: 2),
); );
return; return false;
} }
final result = await _apiService.fetchBoxInfo( final result = await _apiService.fetchBoxInfo(
@@ -242,7 +273,7 @@ class _BoxingPageState extends State<BoxingPage> {
zongpaiNo: zongpai, zongpaiNo: zongpai,
); );
if (!mounted) return; if (!mounted) return false;
if (!result.success) { if (!result.success) {
final isNetwork = result.errorMessage == '网络异常,请检查网络连接'; final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
@@ -254,7 +285,7 @@ class _BoxingPageState extends State<BoxingPage> {
isNetwork ? StatusDotColor.yellow : StatusDotColor.red, isNetwork ? StatusDotColor.yellow : StatusDotColor.red,
const Duration(seconds: 2), const Duration(seconds: 2),
); );
return; return false;
} }
_feedbackService.trigger(FeedbackEvent.scanValid); _feedbackService.trigger(FeedbackEvent.scanValid);
@@ -281,6 +312,54 @@ class _BoxingPageState extends State<BoxingPage> {
// 根据模式自动填充 // 根据模式自动填充
_applyAutoFill(); _applyAutoFill();
}); });
return true;
}
Future<void> _processAutoScanCodes(List<String> codes) async {
if (!mounted) return;
setState(() {
_isAutoProcessing = true;
_autoWarnings.clear();
_statusOverrideText = '正在同步转运数据...';
_statusOverrideDot = StatusDotColor.orange;
});
if (_mode == BoxingMode.singleCode) {
final code = codes.first;
final ok = await _queryBoxInfo(code);
if (!ok && mounted) {
setState(() => _autoWarnings.add('总排号 $code 未找到排产号信息,已忽略'));
}
} else {
for (final code in codes) {
if (!mounted) return;
final queried = await _queryBoxInfo(code);
if (!queried) {
if (mounted) {
setState(() => _autoWarnings.add('总排号 $code 未找到排产号信息,已忽略'));
}
continue;
}
if (!_canSubmit) {
if (mounted) {
setState(() => _autoWarnings.add('总排号 $code 暂不能装箱,已忽略'));
}
continue;
}
final saved = await _submit();
if (!saved && mounted) {
setState(() => _autoWarnings.add('总排号 $code 自动装箱失败,请手动处理'));
}
}
}
if (!mounted) return;
setState(() {
_isAutoProcessing = false;
_statusOverrideText = null;
_statusOverrideDot = null;
});
_focusQuantityInput();
} }
void _applyAutoFill() { void _applyAutoFill() {
@@ -452,8 +531,8 @@ class _BoxingPageState extends State<BoxingPage> {
return byItemId.values.toList(growable: false); return byItemId.values.toList(growable: false);
} }
Future<void> _submit() async { Future<bool> _submit() async {
if (!_canSubmit) return; if (!_canSubmit) return false;
final configService = AppConfigService(); final configService = AppConfigService();
final baseUrl = await configService.getString('api_url') ?? ''; final baseUrl = await configService.getString('api_url') ?? '';
@@ -464,7 +543,7 @@ class _BoxingPageState extends State<BoxingPage> {
StatusDotColor.red, StatusDotColor.red,
const Duration(seconds: 2), const Duration(seconds: 2),
); );
return; return false;
} }
final boxNo = int.parse(_boxNoController.text); final boxNo = int.parse(_boxNoController.text);
@@ -487,7 +566,7 @@ class _BoxingPageState extends State<BoxingPage> {
quantity: quantity, quantity: quantity,
); );
if (!mounted) return; if (!mounted) return false;
setState(() => _isSubmitting = false); setState(() => _isSubmitting = false);
@@ -497,6 +576,7 @@ class _BoxingPageState extends State<BoxingPage> {
} else { } else {
_onUpdateSuccess(editing, boxNo, quantity); _onUpdateSuccess(editing, boxNo, quantity);
} }
return true;
} else if (result.isDuplicate) { } else if (result.isDuplicate) {
_feedbackService.trigger(FeedbackEvent.duplicateBoxNo); _feedbackService.trigger(FeedbackEvent.duplicateBoxNo);
_showStatusOverride( _showStatusOverride(
@@ -504,6 +584,7 @@ class _BoxingPageState extends State<BoxingPage> {
StatusDotColor.amber, StatusDotColor.amber,
const Duration(seconds: 2), const Duration(seconds: 2),
); );
return false;
} else { } else {
final isNetwork = result.errorMessage == '网络异常,请检查网络连接'; final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
_feedbackService.trigger( _feedbackService.trigger(
@@ -514,6 +595,7 @@ class _BoxingPageState extends State<BoxingPage> {
isNetwork ? StatusDotColor.yellow : StatusDotColor.red, isNetwork ? StatusDotColor.yellow : StatusDotColor.red,
const Duration(seconds: 2), const Duration(seconds: 2),
); );
return false;
} }
} }
@@ -1103,6 +1185,11 @@ class _BoxingPageState extends State<BoxingPage> {
// === 状态文字 === // === 状态文字 ===
void _updateBaseStatus() { void _updateBaseStatus() {
if (_isAutoProcessing) {
_statusDot = StatusDotColor.orange;
_statusText = '正在同步转运数据...';
return;
}
if (_isSubmitting) { if (_isSubmitting) {
_statusDot = StatusDotColor.orange; _statusDot = StatusDotColor.orange;
_statusText = '正在提交…'; _statusText = '正在提交…';
@@ -1173,7 +1260,7 @@ class _BoxingPageState extends State<BoxingPage> {
Padding( Padding(
padding: const EdgeInsets.only(right: 4), padding: const EdgeInsets.only(right: 4),
child: TextButton.icon( child: TextButton.icon(
onPressed: _cycleMode, onPressed: _isAutoProcessing ? null : _cycleMode,
icon: const Icon(Icons.swap_horiz, size: 18), icon: const Icon(Icons.swap_horiz, size: 18),
label: Text(_modeLabel, style: const TextStyle(fontSize: 13)), label: Text(_modeLabel, style: const TextStyle(fontSize: 13)),
style: TextButton.styleFrom( style: TextButton.styleFrom(
@@ -1187,6 +1274,42 @@ class _BoxingPageState extends State<BoxingPage> {
), ),
body: Column( body: Column(
children: [ children: [
if (_isAutoProcessing)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: Colors.orange.shade50,
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.orange.shade700,
),
),
const SizedBox(width: 8),
Text(
'正在同步转运数据...',
style: TextStyle(
color: Colors.orange.shade900,
fontSize: 13,
),
),
],
),
),
if (_autoWarnings.isNotEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: Colors.amber.shade100,
child: Text(
_autoWarnings.join(''),
style: TextStyle(color: Colors.amber.shade900, fontSize: 13),
),
),
// 重复箱号警告条(保留,因为这是输入区关联的即时反馈) // 重复箱号警告条(保留,因为这是输入区关联的即时反馈)
if (_paichanSwitchNotice != null) if (_paichanSwitchNotice != null)
Container( Container(

View File

@@ -7,6 +7,8 @@ import 'package:pad_scanner/services/scanner_service.dart';
import 'package:pad_scanner/services/code_parser.dart'; import 'package:pad_scanner/services/code_parser.dart';
import 'package:pad_scanner/services/api_service.dart'; import 'package:pad_scanner/services/api_service.dart';
import 'package:pad_scanner/services/feedback_service.dart'; import 'package:pad_scanner/services/feedback_service.dart';
import 'package:pad_scanner/services/registration_linking.dart';
import 'package:pad_scanner/pages/boxing_page.dart';
import 'package:pad_scanner/widgets/status_bar.dart'; import 'package:pad_scanner/widgets/status_bar.dart';
class RegistrationPage extends StatefulWidget { class RegistrationPage extends StatefulWidget {
@@ -44,7 +46,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scanSubscription = _scannerService.scanResults.listen(_onScan); _startScanListening();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus(); _focusNode.requestFocus();
}); });
@@ -58,6 +60,27 @@ class _RegistrationPageState extends State<RegistrationPage> {
super.dispose(); super.dispose();
} }
void _startScanListening() {
_scanSubscription ??= _scannerService.scanResults.listen(_onScan);
}
Future<void> _stopScanListening() async {
final subscription = _scanSubscription;
_scanSubscription = null;
await subscription?.cancel();
}
void _restartScanListening() {
_scanSubscription?.cancel();
_scanSubscription = null;
_startScanListening();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_focusNode.requestFocus();
}
});
}
void _onKeyEvent(KeyEvent event) { void _onKeyEvent(KeyEvent event) {
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.enter) { if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.enter) {
_submit(); _submit();
@@ -78,7 +101,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
_locationType = parsed.type; _locationType = parsed.type;
_clearStatusOverride(); _clearStatusOverride();
}); });
} } else {}
case CodeType.invalid: case CodeType.invalid:
_feedbackService.trigger(FeedbackEvent.scanInvalid); _feedbackService.trigger(FeedbackEvent.scanInvalid);
_showStatusOverride( _showStatusOverride(
@@ -163,6 +186,8 @@ class _RegistrationPageState extends State<RegistrationPage> {
bool get _canSubmit => bool get _canSubmit =>
_zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting; _zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting;
bool get _isTransitTarget => isTransitTarget(_locationType, _locationCode);
Future<String?> _baseUrl() async { Future<String?> _baseUrl() async {
final configService = AppConfigService(); final configService = AppConfigService();
final baseUrl = await configService.getString('api_url') ?? ''; final baseUrl = await configService.getString('api_url') ?? '';
@@ -239,12 +264,49 @@ class _RegistrationPageState extends State<RegistrationPage> {
setState(() => _isSubmitting = true); setState(() => _isSubmitting = true);
if (_isLocked && _zongpaiNos.length > 1) { if (_isLocked && _zongpaiNos.length > 1) {
if (_isTransitTarget && !await _ensureBatchSamePaicha(baseUrl)) {
if (!mounted) return;
setState(() => _isSubmitting = false);
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
batchPaichaMismatchMessage,
StatusDotColor.red,
const Duration(seconds: 2),
);
return;
}
await _submitBatch(baseUrl); await _submitBatch(baseUrl);
} else { } else {
await _submitOne(baseUrl, _zongpaiNos.first); await _submitOne(baseUrl, _zongpaiNos.first);
} }
} }
Future<bool> _ensureBatchSamePaicha(String baseUrl) async {
final paichaByCode = <String, String>{};
final cachedOverview = _overview;
if (cachedOverview?.success == true && cachedOverview!.paichaNo != null) {
for (final item in cachedOverview.items) {
paichaByCode[item.zongpaiNo] = cachedOverview.paichaNo!;
}
}
for (final zongpaiNo in _zongpaiNos) {
if (paichaByCode.containsKey(zongpaiNo)) continue;
final result = await _apiService.fetchPaichaOverview(
baseUrl: baseUrl,
zongpaiNo: zongpaiNo,
);
if (!result.success || result.paichaNo == null) {
return false;
}
for (final item in result.items) {
paichaByCode[item.zongpaiNo] = result.paichaNo!;
}
}
return allSamePaicha(_zongpaiNos.map((code) => paichaByCode[code]));
}
Future<void> _submitOne(String baseUrl, String zongpaiNo) async { Future<void> _submitOne(String baseUrl, String zongpaiNo) async {
final result = await _apiService.registerLocation( final result = await _apiService.registerLocation(
baseUrl: baseUrl, baseUrl: baseUrl,
@@ -258,6 +320,16 @@ class _RegistrationPageState extends State<RegistrationPage> {
if (result.success) { if (result.success) {
_feedbackService.trigger(FeedbackEvent.submitSuccess); _feedbackService.trigger(FeedbackEvent.submitSuccess);
if (_isTransitTarget) {
setState(() => _isSubmitting = false);
await _navigateToBoxing(
mode: BoxingMode.singleCode,
codes: [zongpaiNo],
codesToClear: [zongpaiNo],
clearLocation: !_isLocked,
);
return;
}
setState(() { setState(() {
_zongpaiNos.remove(zongpaiNo); _zongpaiNos.remove(zongpaiNo);
if (!_isLocked) { if (!_isLocked) {
@@ -302,6 +374,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
int offShelfCount = 0; int offShelfCount = 0;
final failed = <String>[]; final failed = <String>[];
final toRemove = <String>[]; final toRemove = <String>[];
final transitCodes = <String>[];
for (final zongpaiNo in List.of(_zongpaiNos)) { for (final zongpaiNo in List.of(_zongpaiNos)) {
final result = await _apiService.registerLocation( final result = await _apiService.registerLocation(
@@ -315,9 +388,26 @@ class _RegistrationPageState extends State<RegistrationPage> {
offShelfCount++; offShelfCount++;
} }
toRemove.add(zongpaiNo); toRemove.add(zongpaiNo);
if (_isTransitTarget) {
transitCodes.add(zongpaiNo);
}
} else { } else {
failed.add(zongpaiNo); failed.add(zongpaiNo);
if (result.isDuplicate) { if (result.isDuplicate) {
if (_isTransitTarget && transitCodes.isNotEmpty) {
setState(() {
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
_isSubmitting = false;
});
await _navigateToBoxing(
mode: BoxingMode.multiCode,
codes: transitCodes,
codesToClear: toRemove,
clearLocation: false,
returnMessage: '成功 $successCount 条,失败 ${failed.length}',
);
return;
}
setState(() { setState(() {
_zongpaiNos.removeWhere((item) => toRemove.contains(item)); _zongpaiNos.removeWhere((item) => toRemove.contains(item));
_isSubmitting = false; _isSubmitting = false;
@@ -327,6 +417,20 @@ class _RegistrationPageState extends State<RegistrationPage> {
return; return;
} }
if (result.isAlreadyOffShelf) { if (result.isAlreadyOffShelf) {
if (_isTransitTarget && transitCodes.isNotEmpty) {
setState(() {
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
_isSubmitting = false;
});
await _navigateToBoxing(
mode: BoxingMode.multiCode,
codes: transitCodes,
codesToClear: toRemove,
clearLocation: false,
returnMessage: '成功 $successCount 条,失败 ${failed.length}',
);
return;
}
setState(() { setState(() {
_zongpaiNos.removeWhere((item) => toRemove.contains(item)); _zongpaiNos.removeWhere((item) => toRemove.contains(item));
_isSubmitting = false; _isSubmitting = false;
@@ -349,6 +453,19 @@ class _RegistrationPageState extends State<RegistrationPage> {
_isSubmitting = false; _isSubmitting = false;
}); });
if (_isTransitTarget && transitCodes.isNotEmpty) {
await _navigateToBoxing(
mode: BoxingMode.multiCode,
codes: transitCodes,
codesToClear: toRemove,
clearLocation: failed.isEmpty,
returnMessage: failed.isEmpty
? null
: '成功 $successCount 条,失败 ${failed.length}',
);
return;
}
if (failed.isEmpty) { if (failed.isEmpty) {
_feedbackService.trigger(FeedbackEvent.submitSuccess); _feedbackService.trigger(FeedbackEvent.submitSuccess);
final msg = offShelfCount == successCount final msg = offShelfCount == successCount
@@ -370,6 +487,49 @@ class _RegistrationPageState extends State<RegistrationPage> {
} }
} }
Future<void> _navigateToBoxing({
required BoxingMode mode,
required List<String> codes,
required List<String> codesToClear,
required bool clearLocation,
String? returnMessage,
}) async {
await _refreshCurrentOverview();
if (!mounted) return;
final navigator = Navigator.of(context);
await _stopScanListening();
await navigator.push(
MaterialPageRoute(
builder: (_) => BoxingPage(
arguments: BoxingPageArguments(
initialMode: mode,
autoScanCodes: codes,
),
),
),
);
if (!mounted) return;
await Future<void>.delayed(const Duration(milliseconds: 120));
if (!mounted) return;
_restartScanListening();
setState(() {
_zongpaiNos.removeWhere((item) => codesToClear.contains(item));
if (clearLocation) {
_locationCode = null;
_locationType = null;
}
});
await _refreshCurrentOverview();
if (!mounted) return;
if (returnMessage != null) {
_showStatusOverride(
returnMessage,
StatusDotColor.red,
const Duration(seconds: 2),
);
}
}
void _showDuplicateDialog(String zongpaiNo, Map<String, dynamic>? info) { void _showDuplicateDialog(String zongpaiNo, Map<String, dynamic>? info) {
showDialog( showDialog(
context: context, context: context,
@@ -498,9 +658,11 @@ class _RegistrationPageState extends State<RegistrationPage> {
child: ElevatedButton( child: ElevatedButton(
onPressed: _canSubmit ? _submit : null, onPressed: _canSubmit ? _submit : null,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: _canSubmit backgroundColor: registrationSubmitColor(
? colorScheme.primary canSubmit: _canSubmit,
: Colors.grey.shade300, isTransitTarget: _isTransitTarget,
primaryColor: colorScheme.primary,
),
foregroundColor: _canSubmit foregroundColor: _canSubmit
? Colors.white ? Colors.white
: Colors.grey.shade600, : Colors.grey.shade600,
@@ -518,9 +680,11 @@ class _RegistrationPageState extends State<RegistrationPage> {
), ),
) )
: Text( : Text(
_isLocked && _zongpaiNos.length > 1 registrationSubmitLabel(
? '批量上架(${_zongpaiNos.length} 条)' isTransitTarget: _isTransitTarget,
: '确 认 上 架', isLocked: _isLocked,
zongpaiCount: _zongpaiNos.length,
),
style: const TextStyle(fontSize: 17), style: const TextStyle(fontSize: 17),
), ),
), ),

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:pad_scanner/services/code_parser.dart';
const batchPaichaMismatchMessage = '批量凑箱仅支持同排产号产品,请移除不属于同一批次的总排号';
bool isTransitTarget(CodeType? locationType, String? locationCode) {
return locationType == CodeType.locationTransit ||
(locationCode?.startsWith('TRANS-') ?? false);
}
String registrationSubmitLabel({
required bool isTransitTarget,
required bool isLocked,
required int zongpaiCount,
}) {
if (!isTransitTarget) {
return isLocked && zongpaiCount > 1 ? '批量上架($zongpaiCount 条)' : '确 认 上 架';
}
return isLocked && zongpaiCount > 1 ? '批量转运并凑箱($zongpaiCount 条)' : '转运并装箱';
}
Color registrationSubmitColor({
required bool canSubmit,
required bool isTransitTarget,
required Color primaryColor,
}) {
if (!canSubmit) return Colors.grey.shade300;
return isTransitTarget ? Colors.orange : primaryColor;
}
bool allSamePaicha(Iterable<String?> paichaNos) {
String? first;
for (final paichaNo in paichaNos) {
if (paichaNo == null || paichaNo.isEmpty) return false;
first ??= paichaNo;
if (paichaNo != first) return false;
}
return first != null;
}

View File

@@ -9,19 +9,24 @@ class ScanResult {
} }
class ScannerService { class ScannerService {
static const _eventChannel = static const _eventChannel = EventChannel('com.example.pad_scanner/scan');
EventChannel('com.example.pad_scanner/scan'); static Stream<ScanResult>? _sharedScanStream;
Stream<ScanResult>? _scanStream;
Stream<ScanResult> get scanResults { Stream<ScanResult> get scanResults {
_scanStream ??= _eventChannel _sharedScanStream ??= _createSharedScanStream();
return _sharedScanStream!;
}
static Stream<ScanResult> _createSharedScanStream() {
return _eventChannel
.receiveBroadcastStream() .receiveBroadcastStream()
.map((event) => event as Map) .map((event) => event as Map)
.map((event) => ScanResult( .map(
(event) => ScanResult(
barcode: event['barcode'] as String? ?? '', barcode: event['barcode'] as String? ?? '',
codeType: event['codeType'] as String? ?? 'UNKNOWN', codeType: event['codeType'] as String? ?? 'UNKNOWN',
)); ),
return _scanStream!; )
.asBroadcastStream();
} }
} }

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pad_scanner/services/code_parser.dart';
import 'package:pad_scanner/services/registration_linking.dart';
void main() {
group('registration linking helpers', () {
test('detects transit target by type or code prefix', () {
expect(isTransitTarget(CodeType.locationTransit, 'TRANS-01'), isTrue);
expect(isTransitTarget(CodeType.locationNormal, 'TRANS-02'), isTrue);
expect(isTransitTarget(CodeType.locationNormal, 'A01-02-03'), isFalse);
expect(isTransitTarget(null, null), isFalse);
});
test('builds submit labels for normal and transit modes', () {
expect(
registrationSubmitLabel(
isTransitTarget: false,
isLocked: false,
zongpaiCount: 1,
),
'确 认 上 架',
);
expect(
registrationSubmitLabel(
isTransitTarget: false,
isLocked: true,
zongpaiCount: 3,
),
'批量上架3 条)',
);
expect(
registrationSubmitLabel(
isTransitTarget: true,
isLocked: false,
zongpaiCount: 1,
),
'转运并装箱',
);
expect(
registrationSubmitLabel(
isTransitTarget: true,
isLocked: true,
zongpaiCount: 3,
),
'批量转运并凑箱3 条)',
);
});
test('uses orange submit color only for enabled transit target', () {
const primary = Colors.blue;
expect(
registrationSubmitColor(
canSubmit: true,
isTransitTarget: true,
primaryColor: primary,
),
Colors.orange,
);
expect(
registrationSubmitColor(
canSubmit: true,
isTransitTarget: false,
primaryColor: primary,
),
primary,
);
expect(
registrationSubmitColor(
canSubmit: false,
isTransitTarget: true,
primaryColor: primary,
),
Colors.grey.shade300,
);
});
test('validates all codes belong to one paicha', () {
expect(allSamePaicha(['R00001', 'R00001']), isTrue);
expect(allSamePaicha(['R00001', 'R00002']), isFalse);
expect(allSamePaicha(['R00001', null]), isFalse);
expect(allSamePaicha(<String>[]), isFalse);
});
});
}