Compare commits

...

3 Commits

Author SHA1 Message Date
Misaka_Company
073162dc65 feat: add hardware key shortcuts and unify lock toggle design
- Home: add keyboard number shortcuts (1/2/3) for module navigation
- Boxing: add P2 key mode switching with pill-style AppBar header
- Registration: replace Switch with pill-style lock header matching boxing design
- Add Android keyCode fallback for scanner device compatibility
- Simplify home page cards to show only module name + shortcut hint
- Add lock redesign HTML preview document

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 11:26:02 +08:00
Misaka_Company
2b5fdf41a9 chore: update Android app branding 2026-05-15 15:46:06 +08:00
Misaka_Company
deb038404a feat: link transit registration to boxing 2026-05-15 12:40:10 +08:00
21 changed files with 1372 additions and 301 deletions

View File

@@ -1,7 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<application <application
android:label="pad_scanner" android:label="@string/app_name"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<uses-library android:name="android.scanner.library"/> <uses-library android:name="android.scanner.library"/>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#00286F</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">库房追踪</string>
</resources>

View File

@@ -0,0 +1,443 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>锁定货位 - 布局重设计预览</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, 'Segoe UI', 'Noto Sans SC', sans-serif; background: #f0f2f5; padding: 20px; }
h1 { text-align: center; margin-bottom: 24px; color: #333; font-size: 22px; }
h2 { color: #555; margin-bottom: 12px; font-size: 16px; }
.wrapper {
max-width: 1200px; margin: 0 auto;
display: grid; grid-template-columns: 1fr 1fr; gap: 32px;
}
.column { display: flex; flex-direction: column; gap: 24px; }
.phone {
width: 360px; margin: 0 auto;
border: 2px solid #333; border-radius: 24px; overflow: hidden;
background: #fff; box-shadow: 0 4px 20px rgba(0,0,0,.15);
}
/* ── AppBar ── */
.appbar {
height: 56px; background: #1565C0; color: #fff;
display: flex; align-items: center; padding: 0 12px;
font-size: 18px; font-weight: 600;
}
.appbar .back { font-size: 22px; margin-right: 12px; cursor: pointer; }
/* ── OLD: lock switch in actions ── */
.appbar-old .title { flex: 0 0 auto; }
.appbar-old .spacer { flex: 1; }
.appbar-old .lock-area {
display: flex; align-items: center; gap: 6px; font-size: 13px;
}
.p2-hint {
padding: 2px 6px; border-radius: 4px;
font-size: 11px; font-weight: 700;
}
.p2-hint.off { background: #e0e0e0; color: #757575; border: 1px solid #bdbdbd; }
.p2-hint.on { background: #fff3e0; color: #e65100; border: 1px solid #ffb74d; }
.switch {
width: 36px; height: 20px; border-radius: 10px;
position: relative; transition: .2s;
}
.switch.off { background: #bdbdbd; }
.switch.on { background: #ff9800; }
.switch::after {
content: ''; position: absolute; top: 2px;
width: 16px; height: 16px; border-radius: 50%;
background: #fff; transition: .2s;
}
.switch.off::after { left: 2px; }
.switch.on::after { left: 18px; }
/* ── NEW: mode header pill (same as boxing page) ── */
.appbar-new .title-text { flex: 0 0 auto; }
.appbar-new .gap { width: 12px; }
.mode-pill {
flex: 1; min-width: 0;
height: 36px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
gap: 6px; cursor: pointer; transition: background .2s;
user-select: none;
}
.mode-pill.blue { background: #1565C0; } /* same blue as boxing single-code */
.mode-pill.orange { background: #E65100; } /* same orange as boxing multi-code */
.mode-pill svg { width: 18px; height: 18px; fill: #fff; flex-shrink: 0; }
.mode-pill .label {
color: #fff; font-size: 18px; font-weight: 800;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.mode-pill .shortcut {
color: rgba(255,255,255,.85); font-size: 12px; font-weight: 700;
flex-shrink: 0;
}
/* ── Body ── */
.body { padding: 10px 12px 8px; }
.section-label { font-size: 14px; font-weight: 700; margin-bottom: 6px; color: #333; }
/* ── Input panels ── */
.panel {
border: 1px solid #bdbdbd; border-radius: 8px; padding: 10px;
min-height: 58px; transition: .2s;
}
.panel.active { border-color: #4caf50; border-width: 2px; }
.panel.locked {
border-color: #fb8c00; border-width: 2px;
background: linear-gradient(135deg, #fff8e1 0%, #fff 40%);
}
.panel-row { display: flex; align-items: center; }
.panel-row .loc { font-size: 18px; font-weight: 700; color: #424242; }
.panel-row .sep { color: #bdbdbd; font-size: 20px; padding: 0 8px; }
.panel-row .zp { flex: 3; text-align: right; font-size: 18px; font-weight: 700; color: #424242; }
.loc-chip {
display: inline-block; padding: 2px 6px; border-radius: 4px;
font-size: 11px; font-weight: 700; margin-left: 6px;
}
.loc-chip.blue { background: #e3f2fd; color: #1565C0; }
.lock-row { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
.lock-row .loc { flex: 1; font-size: 18px; font-weight: 700; color: #424242; }
.lock-icon { color: #fb8c00; font-size: 18px; }
.chip-wrap { display: flex; flex-wrap: wrap; gap: 6px; }
.zchip {
display: inline-flex; align-items: center; gap: 4px;
background: #f5f5f5; border: 1px solid #e0e0e0; border-radius: 16px;
padding: 2px 6px 2px 10px; font-size: 13px; font-weight: 600;
}
.zchip .x { color: #9e9e9e; cursor: pointer; font-size: 16px; line-height: 1; }
.submit-btn {
width: 100%; height: 46px; border: none; border-radius: 8px;
font-size: 17px; font-weight: 600; color: #fff; margin-top: 10px;
cursor: pointer;
}
.submit-btn.off { background: #e0e0e0; color: #757575; }
.submit-btn.on { background: #1565C0; }
.divider { height: 1px; background: #e0e0e0; margin: 0; }
.overview { padding: 8px 12px; }
.overview-label { font-size: 14px; font-weight: 700; margin-bottom: 6px; }
.overview-placeholder {
display: flex; align-items: center; justify-content: center;
color: #9e9e9e; font-size: 13px; min-height: 200px;
}
.status-bar {
padding: 10px 16px; font-size: 13px; background: #f5f5f5;
display: flex; align-items: center; gap: 8px;
}
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #2196F3; }
/* ── Labels & captions ── */
.col-label {
text-align: center; padding: 8px 16px; border-radius: 8px;
font-weight: 700; font-size: 15px; margin-bottom: 4px;
}
.col-label.old { background: #ffcdd2; color: #c62828; }
.col-label.new { background: #c8e6c9; color: #2e7d32; }
.caption {
text-align: center; color: #888; font-size: 12px;
margin-top: 8px; line-height: 1.6;
}
/* ── Reference boxing pill ── */
.ref-section {
max-width: 760px; margin: 40px auto 0;
padding: 20px; background: #fff; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,.08);
}
.ref-section h2 { font-size: 18px; color: #333; margin-bottom: 16px; }
.ref-row {
display: flex; align-items: center; gap: 16px; margin-bottom: 12px;
}
.ref-label { font-size: 13px; color: #888; width: 80px; flex-shrink: 0; text-align: right; }
.ref-pill {
height: 36px; border-radius: 8px;
display: inline-flex; align-items: center; justify-content: center;
gap: 6px; padding: 0 12px;
}
.ref-pill svg { width: 18px; height: 18px; fill: #fff; }
.ref-pill .lbl { color: #fff; font-size: 18px; font-weight: 800; }
.ref-pill .p2 { color: rgba(255,255,255,.85); font-size: 12px; font-weight: 700; }
.compare-table {
width: 100%; border-collapse: collapse; font-size: 14px;
margin-top: 16px;
}
.compare-table th {
text-align: left; padding: 8px 12px; background: #f5f5f5;
border-bottom: 2px solid #ddd;
}
.compare-table td {
padding: 8px 12px; border-bottom: 1px solid #eee;
}
</style>
</head>
<body>
<h1>锁定货位 — 布局重设计预览</h1>
<div class="wrapper">
<!-- ═══════════ LEFT: OLD ═══════════ -->
<div class="column">
<div class="col-label old">现有设计</div>
<div>
<h2 style="text-align:center">未锁定</h2>
<div class="phone">
<div class="appbar appbar-old">
<span class="back">&larr;</span>
<span class="title">上架登记</span>
<span class="spacer"></span>
<div class="lock-area">
<span>锁定货位</span>
<span class="p2-hint off">P2</span>
<div class="switch off"></div>
</div>
</div>
<div class="body">
<div class="section-label">登记操作区</div>
<div class="panel active">
<div class="panel-row">
<span class="loc">A-01-02</span>
<span class="loc-chip blue">普通货架</span>
<span class="sep">|</span>
<span class="zp">ZP2024001</span>
</div>
</div>
<button class="submit-btn on">提 交</button>
</div>
<div class="divider"></div>
<div class="overview">
<div class="overview-label">排产号货架总览</div>
<div class="overview-placeholder">扫描总排号后自动显示排产号上架情况</div>
</div>
<div class="status-bar"><div class="status-dot"></div>请确认信息并提交</div>
</div>
<p class="caption">锁定开关挤在 AppBar 右侧,字小、触控区小</p>
</div>
<div>
<h2 style="text-align:center">已锁定</h2>
<div class="phone">
<div class="appbar appbar-old">
<span class="back">&larr;</span>
<span class="title">上架登记</span>
<span class="spacer"></span>
<div class="lock-area">
<span>锁定货位</span>
<span class="p2-hint on">P2</span>
<div class="switch on"></div>
</div>
</div>
<div class="body">
<div class="section-label">登记操作区</div>
<div class="panel locked">
<div class="lock-row">
<span class="loc">A-01-02</span>
<span class="lock-icon">&#128274;</span>
<span class="loc-chip blue">普通货架</span>
</div>
<div class="chip-wrap">
<span class="zchip">ZP2024001 <span class="x">&times;</span></span>
<span class="zchip">ZP2024002 <span class="x">&times;</span></span>
<span class="zchip">ZP2024003 <span class="x">&times;</span></span>
</div>
</div>
<button class="submit-btn on">批量提交3 条)</button>
</div>
<div class="divider"></div>
<div class="overview">
<div class="overview-label">排产号货架总览</div>
<div class="overview-placeholder">扫描总排号后自动显示排产号上架情况</div>
</div>
<div class="status-bar"><div class="status-dot"></div>货位已锁定,请扫描下一张执行卡</div>
</div>
<p class="caption">锁定状态仅靠边框颜色区分,不够醒目</p>
</div>
</div>
<!-- ═══════════ RIGHT: NEW ═══════════ -->
<div class="column">
<div class="col-label new">新设计(与装箱编号统一设计语言)</div>
<!-- NEW — unlocked -->
<div>
<h2 style="text-align:center">未锁定</h2>
<div class="phone">
<div class="appbar appbar-new">
<span class="back">&larr;</span>
<span class="title-text">上架登记</span>
<span class="gap"></span>
<!-- same pill as boxing page -->
<div class="mode-pill blue">
<svg viewBox="0 0 24 24"><path d="M6.99 11 3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></svg>
<span class="label">未锁定</span>
<span class="shortcut">P2</span>
</div>
</div>
<div class="body">
<div class="section-label">登记操作区</div>
<div class="panel active">
<div class="panel-row">
<span class="loc">A-01-02</span>
<span class="loc-chip blue">普通货架</span>
<span class="sep">|</span>
<span class="zp">ZP2024001</span>
</div>
</div>
<button class="submit-btn on">提 交</button>
</div>
<div class="divider"></div>
<div class="overview">
<div class="overview-label">排产号货架总览</div>
<div class="overview-placeholder">扫描总排号后自动显示排产号上架情况</div>
</div>
<div class="status-bar"><div class="status-dot"></div>请确认信息并提交</div>
</div>
<p class="caption">
蓝色药丸 = 未锁定(与装箱页"单码装箱"同色)<br>
点击或按 P2 切换为锁定
</p>
</div>
<!-- NEW — locked -->
<div>
<h2 style="text-align:center">已锁定</h2>
<div class="phone">
<div class="appbar appbar-new">
<span class="back">&larr;</span>
<span class="title-text">上架登记</span>
<span class="gap"></span>
<!-- same pill as boxing page — orange when locked -->
<div class="mode-pill orange">
<svg viewBox="0 0 24 24"><path d="M6.99 11 3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></svg>
<span class="label">货位锁定</span>
<span class="shortcut">P2</span>
</div>
</div>
<div class="body">
<div class="section-label">登记操作区</div>
<div class="panel locked">
<div class="lock-row">
<span class="loc">A-01-02</span>
<span class="lock-icon">&#128274;</span>
<span class="loc-chip blue">普通货架</span>
</div>
<div class="chip-wrap">
<span class="zchip">ZP2024001 <span class="x">&times;</span></span>
<span class="zchip">ZP2024002 <span class="x">&times;</span></span>
<span class="zchip">ZP2024003 <span class="x">&times;</span></span>
</div>
</div>
<button class="submit-btn on">批量提交3 条)</button>
</div>
<div class="divider"></div>
<div class="overview">
<div class="overview-label">排产号货架总览</div>
<div class="overview-placeholder">扫描总排号后自动显示排产号上架情况</div>
</div>
<div class="status-bar"><div class="status-dot"></div>货位已锁定,请扫描下一张执行卡</div>
</div>
<p class="caption">
橙色药丸 = 已锁定(与装箱页"多码凑箱"同色)<br>
输入面板下方内容不变,仅切换控件统一风格
</p>
</div>
</div>
</div>
<!-- ═══════════ Reference comparison ═══════════ -->
<div class="ref-section">
<h2>设计语言对照</h2>
<div class="ref-row">
<span class="ref-label">装箱编号</span>
<div class="ref-pill" style="background:#1565C0;">
<svg viewBox="0 0 24 24"><path d="M6.99 11 3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></svg>
<span class="lbl">单码装箱</span>
<span class="p2">P2</span>
</div>
<div class="ref-pill" style="background:#E65100;">
<svg viewBox="0 0 24 24"><path d="M6.99 11 3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></svg>
<span class="lbl">多码凑箱</span>
<span class="p2">P2</span>
</div>
</div>
<div class="ref-row">
<span class="ref-label">上架登记</span>
<div class="ref-pill" style="background:#1565C0;">
<svg viewBox="0 0 24 24"><path d="M6.99 11 3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></svg>
<span class="lbl">未锁定</span>
<span class="p2">P2</span>
</div>
<div class="ref-pill" style="background:#E65100;">
<svg viewBox="0 0 24 24"><path d="M6.99 11 3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></svg>
<span class="lbl">货位锁定</span>
<span class="p2">P2</span>
</div>
</div>
<table class="compare-table">
<thead>
<tr>
<th>属性</th>
<th>装箱编号</th>
<th>上架登记(新)</th>
</tr>
</thead>
<tbody>
<tr>
<td style="font-weight:600;">组件位置</td>
<td>AppBar 标题行内(标题 + 药丸)</td>
<td>AppBar 标题行内(标题 + 药丸)</td>
</tr>
<tr>
<td style="font-weight:600;">交互方式</td>
<td>点击药丸 / 按 P2 切换</td>
<td>点击药丸 / 按 P2 切换</td>
</tr>
<tr>
<td style="font-weight:600;">默认态(蓝色)</td>
<td>单码装箱</td>
<td>未锁定</td>
</tr>
<tr>
<td style="font-weight:600;">切换态(橙色)</td>
<td>多码凑箱</td>
<td>货位锁定</td>
</tr>
<tr>
<td style="font-weight:600;">药丸结构</td>
<td>swap 图标 + 模式名 + P2</td>
<td>swap 图标 + 状态名 + P2</td>
</tr>
<tr>
<td style="font-weight:600;">圆角 / 字重</td>
<td>8px / w800</td>
<td>8px / w800</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

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,16 +75,22 @@ 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();
} }
class _BoxingPageState extends State<BoxingPage> { class _BoxingPageState extends State<BoxingPage> {
static const _androidKeyCodeButtonR1 = 103;
final _scannerService = ScannerService(); final _scannerService = ScannerService();
final _apiService = ApiService(); final _apiService = ApiService();
final _feedbackService = FeedbackService(); final _feedbackService = FeedbackService();
final _focusNode = FocusNode();
StreamSubscription<ScanResult>? _scanSubscription;
// 模式 // 模式
BoxingMode _mode = BoxingMode.singleCode; BoxingMode _mode = BoxingMode.singleCode;
@@ -123,17 +141,31 @@ 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);
});
}
WidgetsBinding.instance.addPostFrameCallback((_) => _requestFocus());
} }
@override @override
void dispose() { void dispose() {
_boxNoController.removeListener(_onBoxNoTextChanged); _boxNoController.removeListener(_onBoxNoTextChanged);
_scanSubscription?.cancel();
_focusNode.dispose();
_boxNoController.dispose(); _boxNoController.dispose();
_quantityController.dispose(); _quantityController.dispose();
_boxNoFocusNode.dispose(); _boxNoFocusNode.dispose();
@@ -142,6 +174,35 @@ class _BoxingPageState extends State<BoxingPage> {
super.dispose(); super.dispose();
} }
void _requestFocus() {
if (mounted) {
_focusNode.requestFocus();
}
}
void _onKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent || _isAutoProcessing) return;
if (_isModeSwitchKey(event)) {
_cycleMode();
}
}
bool _isModeSwitchKey(KeyEvent event) {
final key = event.logicalKey;
return key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.gameButtonRight1 ||
_androidKeyCodeFromLogicalKey(key) == _androidKeyCodeButtonR1;
}
int? _androidKeyCodeFromLogicalKey(LogicalKeyboardKey key) {
final keyId = key.keyId;
const androidPlane = LogicalKeyboardKey.androidPlane;
if (keyId >= androidPlane && keyId < androidPlane + 0x100000000) {
return keyId - androidPlane;
}
return null;
}
void _onBoxNoTextChanged() { void _onBoxNoTextChanged() {
if (!mounted || _mode != BoxingMode.multiCode) return; if (!mounted || _mode != BoxingMode.multiCode) return;
setState(() { setState(() {
@@ -206,6 +267,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 +289,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 +299,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 +307,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 +319,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 +346,55 @@ 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();
_requestFocus();
} }
void _applyAutoFill() { void _applyAutoFill() {
@@ -452,8 +566,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 +578,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 +601,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 +611,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 +619,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 +630,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 +1220,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 = '正在提交…';
@@ -1166,127 +1288,229 @@ class _BoxingPageState extends State<BoxingPage> {
final effectiveDot = _statusOverrideDot ?? _statusDot; final effectiveDot = _statusOverrideDot ?? _statusDot;
final effectiveText = _statusOverrideText ?? _statusText; final effectiveText = _statusOverrideText ?? _statusText;
return Scaffold( return KeyboardListener(
appBar: AppBar( focusNode: _focusNode,
title: const Text('装箱编号'), onKeyEvent: _onKeyEvent,
actions: [ child: Scaffold(
Padding( appBar: AppBar(
padding: const EdgeInsets.only(right: 4), title: Row(
child: TextButton.icon( children: [
onPressed: _cycleMode, const Text('装箱编号'),
icon: const Icon(Icons.swap_horiz, size: 18), const SizedBox(width: 12),
label: Text(_modeLabel, style: const TextStyle(fontSize: 13)), Expanded(child: _buildModeHeader()),
style: TextButton.styleFrom( ],
foregroundColor: _mode == BoxingMode.multiCode
? Colors.orange
: colorScheme.primary,
),
),
), ),
], titleSpacing: 12,
), ),
body: Column( body: Column(
children: [ children: [
// 重复箱号警告条(保留,因为这是输入区关联的即时反馈) if (_isAutoProcessing)
if (_paichanSwitchNotice != null) Container(
Container( width: double.infinity,
width: double.infinity, padding: const EdgeInsets.symmetric(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), vertical: 8,
color: Colors.blue.shade50, horizontal: 16,
child: Text( ),
_paichanSwitchNotice!, color: Colors.orange.shade50,
style: TextStyle(color: Colors.blue.shade900, fontSize: 13), child: Row(
), children: [
), SizedBox(
if (_isDuplicateBoxNo && _phase == _Phase.scanned) width: 16,
Container( height: 16,
width: double.infinity, child: CircularProgressIndicator(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), strokeWidth: 2,
color: Colors.amber.shade100, color: Colors.orange.shade700,
child: Text( ),
'箱号 ${_boxNoController.text} 已存在,请重新输入', ),
style: TextStyle(color: Colors.amber.shade900, fontSize: 13), const SizedBox(width: 8),
), Text(
), '正在同步转运数据...',
style: TextStyle(
Expanded( color: Colors.orange.shade900,
child: _mode == BoxingMode.multiCode fontSize: 13,
? _buildManyToOneBody(colorScheme)
: 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),
if (_mode == BoxingMode.singleCode &&
!isWaiting &&
_currentZongpaiBoxes.isNotEmpty) ...[
_buildAssignedBoxes(),
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.singleCode &&
_editingBox == null) ...[
const SizedBox(width: 12),
const Expanded(child: SizedBox.shrink()),
], ],
if (_mode == BoxingMode.singleCode && ),
_editingBox != null) ...[ ),
const SizedBox(width: 12), 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)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
color: Colors.blue.shade50,
child: Text(
_paichanSwitchNotice!,
style: TextStyle(color: Colors.blue.shade900, fontSize: 13),
),
),
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: _mode == BoxingMode.multiCode
? _buildManyToOneBody(colorScheme)
: 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),
if (_mode == BoxingMode.singleCode &&
!isWaiting &&
_currentZongpaiBoxes.isNotEmpty) ...[
_buildAssignedBoxes(),
const Divider(height: 24),
],
_buildInputArea(isWaiting),
],
),
),
),
// 操作按钮(单码装箱 / 多码凑箱)
if (showActionButtons)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
child: Row(
children: [
Expanded( Expanded(
child: ElevatedButton( child: OutlinedButton(
onPressed: _cancelEditAssigned, onPressed: _onGoBack,
style: ElevatedButton.styleFrom( style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(40), minimumSize: const Size.fromHeight(40),
), ),
child: const Text('取消'), child: const Text('返回'),
), ),
), ),
if (_mode == BoxingMode.singleCode &&
_editingBox == null) ...[
const SizedBox(width: 12),
const Expanded(child: SizedBox.shrink()),
],
if (_mode == BoxingMode.singleCode &&
_editingBox != null) ...[
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: _cancelEditAssigned,
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(40),
),
child: const Text('取消'),
),
),
],
], ],
],
),
),
if (_mode == BoxingMode.multiCode)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: OutlinedButton(
onPressed: _visibleManyToOnePackedItems.isEmpty
? null
: _finishManyToOneBox,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(40),
), ),
child: const Text('完成本箱'),
), ),
),
// 底部状态栏 if (_mode == BoxingMode.multiCode)
StatusBar(dotColor: effectiveDot, text: effectiveText), Padding(
], padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
child: OutlinedButton(
onPressed: _visibleManyToOnePackedItems.isEmpty
? null
: _finishManyToOneBox,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(40),
),
child: const Text('完成本箱'),
),
),
// 底部状态栏
StatusBar(dotColor: effectiveDot, text: effectiveText),
],
),
),
);
}
Widget _buildModeHeader() {
final isSingle = _mode == BoxingMode.singleCode;
final backgroundColor = isSingle
? Colors.blue.shade700
: Colors.orange.shade700;
final foregroundColor = Colors.white;
return Material(
borderRadius: BorderRadius.circular(8),
color: backgroundColor,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: _isAutoProcessing ? null : _cycleMode,
child: Container(
width: double.infinity,
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.swap_horiz, color: foregroundColor, size: 18),
const SizedBox(width: 6),
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
_modeLabel,
maxLines: 1,
style: TextStyle(
color: foregroundColor,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
),
),
const SizedBox(width: 8),
Text(
'P2',
style: TextStyle(
color: foregroundColor.withValues(alpha: 0.88),
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
],
),
),
), ),
); );
} }

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pad_scanner/services/app_config_service.dart'; import 'package:pad_scanner/services/app_config_service.dart';
import 'package:pad_scanner/pages/settings_page.dart'; import 'package:pad_scanner/pages/settings_page.dart';
import 'package:pad_scanner/pages/registration_page.dart'; import 'package:pad_scanner/pages/registration_page.dart';
@@ -14,20 +15,18 @@ enum ModuleStatus { online, developing, planning }
/// Function card data model per PRD §3.3 /// Function card data model per PRD §3.3
class FeatureCard { class FeatureCard {
final ModuleType type; final ModuleType type;
final String shortcut;
final IconData icon; final IconData icon;
final String title; final String title;
final String description;
final ModuleStatus status; final ModuleStatus status;
final String? version;
final String route; final String route;
const FeatureCard({ const FeatureCard({
required this.type, required this.type,
required this.shortcut,
required this.icon, required this.icon,
required this.title, required this.title,
required this.description,
required this.status, required this.status,
this.version,
required this.route, required this.route,
}); });
} }
@@ -40,7 +39,12 @@ class HomePage extends StatefulWidget {
} }
class _HomePageState extends State<HomePage> { class _HomePageState extends State<HomePage> {
static const _androidKeyCode1 = 8;
static const _androidKeyCode2 = 9;
static const _androidKeyCode3 = 10;
final _apiService = ApiService(); final _apiService = ApiService();
final _focusNode = FocusNode();
bool _isCheckingConnection = false; bool _isCheckingConnection = false;
bool _isConnected = false; bool _isConnected = false;
String _apiAddress = ''; String _apiAddress = '';
@@ -49,29 +53,26 @@ class _HomePageState extends State<HomePage> {
static const _modules = [ static const _modules = [
FeatureCard( FeatureCard(
type: ModuleType.registration, type: ModuleType.registration,
shortcut: '1',
icon: Icons.local_shipping_outlined, icon: Icons.local_shipping_outlined,
title: '上架登记', title: '上架登记',
description: '扫码绑定总排号与货位',
status: ModuleStatus.online, status: ModuleStatus.online,
version: 'v1.0',
route: '/registration', route: '/registration',
), ),
FeatureCard( FeatureCard(
type: ModuleType.boxing, type: ModuleType.boxing,
shortcut: '2',
icon: Icons.view_list_outlined, icon: Icons.view_list_outlined,
title: '装箱编号', title: '装箱编号',
description: '查询箱号、录入装箱明细',
status: ModuleStatus.online, status: ModuleStatus.online,
version: 'v1.0',
route: '/boxing', route: '/boxing',
), ),
FeatureCard( FeatureCard(
type: ModuleType.shelfQuery, type: ModuleType.shelfQuery,
shortcut: '3',
icon: Icons.search_outlined, icon: Icons.search_outlined,
title: '货架查询', title: '货架查询',
description: '按合同查询货架库存',
status: ModuleStatus.planning, status: ModuleStatus.planning,
version: null,
route: '', route: '',
), ),
]; ];
@@ -80,6 +81,77 @@ class _HomePageState extends State<HomePage> {
void initState() { void initState() {
super.initState(); super.initState();
_checkConnection(); _checkConnection();
WidgetsBinding.instance.addPostFrameCallback((_) => _requestFocus());
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
void _requestFocus() {
if (mounted) {
_focusNode.requestFocus();
}
}
void _onKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent) return;
final module = _moduleFromShortcut(event);
if (module == null) return;
_onCardTap(module);
}
FeatureCard? _moduleFromShortcut(KeyEvent event) {
final shortcut = _shortcutNumberFromKeyEvent(event);
if (shortcut == 1) {
return _modules[0];
}
if (shortcut == 2) {
return _modules[1];
}
if (shortcut == 3) {
return _modules[2];
}
return null;
}
int? _shortcutNumberFromKeyEvent(KeyEvent event) {
final key = event.logicalKey;
if (key == LogicalKeyboardKey.digit1 ||
key == LogicalKeyboardKey.numpad1 ||
event.character == '1') {
return 1;
}
if (key == LogicalKeyboardKey.digit2 ||
key == LogicalKeyboardKey.numpad2 ||
event.character == '2') {
return 2;
}
if (key == LogicalKeyboardKey.digit3 ||
key == LogicalKeyboardKey.numpad3 ||
event.character == '3') {
return 3;
}
// Android keyCode fallback for scanner devices: 1/2/3 => 8/9/10.
final androidKeyCode = _androidKeyCodeFromLogicalKey(key);
return switch (androidKeyCode) {
_androidKeyCode1 => 1,
_androidKeyCode2 => 2,
_androidKeyCode3 => 3,
_ => null,
};
}
int? _androidKeyCodeFromLogicalKey(LogicalKeyboardKey key) {
final keyId = key.keyId;
const androidPlane = LogicalKeyboardKey.androidPlane;
if (keyId >= androidPlane && keyId < androidPlane + 0x100000000) {
return keyId - androidPlane;
}
return null;
} }
Future<void> _checkConnection() async { Future<void> _checkConnection() async {
@@ -133,17 +205,22 @@ class _HomePageState extends State<HomePage> {
case ModuleType.shelfQuery: case ModuleType.shelfQuery:
return; // 规划中,不导航 return; // 规划中,不导航
} }
Navigator.push( Navigator.push(context, MaterialPageRoute(builder: (_) => targetPage)).then(
context, (_) {
MaterialPageRoute(builder: (_) => targetPage), _checkConnection();
).then((_) => _checkConnection()); _requestFocus();
},
);
} }
void _navigateToSettings() { void _navigateToSettings() {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute(builder: (_) => const SettingsPage()), MaterialPageRoute(builder: (_) => const SettingsPage()),
).then((_) => _checkConnection()); ).then((_) {
_checkConnection();
_requestFocus();
});
} }
Color _getConnectionStatusColor() { Color _getConnectionStatusColor() {
@@ -161,87 +238,70 @@ class _HomePageState extends State<HomePage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Scaffold( return KeyboardListener(
appBar: AppBar( focusNode: _focusNode,
title: const Text('CargoTrace'), onKeyEvent: _onKeyEvent,
actions: [ child: Scaffold(
Padding( appBar: AppBar(
padding: const EdgeInsets.only(right: 8), title: const SizedBox.shrink(),
child: IconButton( toolbarHeight: 44,
icon: const Icon(Icons.settings), actions: [
onPressed: _navigateToSettings, Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
icon: const Icon(Icons.settings),
onPressed: _navigateToSettings,
),
), ),
), ],
], ),
), body: Column(
body: Column( children: [
children: [ // Function cards area
// Function cards area Expanded(
Expanded( child: ListView.builder(
child: ListView.builder( padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), itemCount: _modules.length,
itemCount: _modules.length, itemBuilder: (context, index) {
itemBuilder: (context, index) { final card = _modules[index];
final card = _modules[index]; return _buildFeatureCard(context, card);
return _buildFeatureCard(context, card); },
}, ),
), ),
),
// Bottom connection status bar // Bottom connection status bar
Container( Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Row( child: Row(
children: [ children: [
Container( Container(
width: 8, width: 8,
height: 8, height: 8,
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getConnectionStatusColor(), color: _getConnectionStatusColor(),
shape: BoxShape.circle, shape: BoxShape.circle,
),
), ),
), const SizedBox(width: 8),
const SizedBox(width: 8), Expanded(
Expanded( child: Text(
child: Text( _getConnectionStatusText(),
_getConnectionStatusText(), style: const TextStyle(fontSize: 13),
style: const TextStyle(fontSize: 13), overflow: TextOverflow.ellipsis,
overflow: TextOverflow.ellipsis, ),
), ),
), ],
], ),
), ),
), ],
], ),
), ),
); );
} }
Widget _buildFeatureCard(BuildContext context, FeatureCard card) { Widget _buildFeatureCard(BuildContext context, FeatureCard card) {
final Color statusBgColor;
final TextStyle statusTextStyle;
final String statusText;
switch (card.status) {
case ModuleStatus.online:
statusBgColor = Colors.green.shade700;
statusTextStyle = const TextStyle(color: Colors.white, fontSize: 12);
statusText = card.version ?? '已上线';
break;
case ModuleStatus.developing:
statusBgColor = Colors.blue.shade700;
statusTextStyle = const TextStyle(color: Colors.white, fontSize: 12);
statusText = '开发中';
break;
case ModuleStatus.planning:
statusBgColor = Colors.grey.shade500;
statusTextStyle = const TextStyle(color: Colors.white, fontSize: 12);
statusText = '规划中';
break;
}
final canNavigate = card.status == ModuleStatus.online; final canNavigate = card.status == ModuleStatus.online;
return Padding( return Padding(
@@ -250,7 +310,7 @@ class _HomePageState extends State<HomePage> {
onTap: () => _onCardTap(card), onTap: () => _onCardTap(card),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Container( child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: canNavigate ? Colors.white : Colors.grey.shade200, color: canNavigate ? Colors.white : Colors.grey.shade200,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -262,80 +322,49 @@ class _HomePageState extends State<HomePage> {
), ),
], ],
), ),
child: Column( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// First row: icon + module name Icon(
Row( card.icon,
children: [ size: 22,
Icon( color: canNavigate ? Colors.blue : Colors.grey,
card.icon,
size: 22,
color: canNavigate ? Colors.blue : Colors.grey,
),
const SizedBox(width: 8),
Text(
card.title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: canNavigate ? Colors.black87 : Colors.grey,
),
),
],
), ),
const SizedBox(height: 6), const SizedBox(width: 8),
// Second row: description
Text( Text(
card.description, card.title,
style: TextStyle(fontSize: 13, color: Colors.grey.shade600), style: TextStyle(
), fontSize: 18,
const SizedBox(height: 10), fontWeight: FontWeight.bold,
// Third row: status badge (right aligned) color: canNavigate ? Colors.black87 : Colors.grey,
Align(
alignment: Alignment.centerRight,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: statusBgColor,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(statusText, style: statusTextStyle),
if (card.status == ModuleStatus.online) ...[
const SizedBox(width: 6),
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
] else ...[
const SizedBox(width: 6),
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
),
],
],
),
), ),
), ),
const Spacer(),
_buildShortcutHint(card, canNavigate),
], ],
), ),
), ),
), ),
); );
} }
Widget _buildShortcutHint(FeatureCard card, bool canNavigate) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: canNavigate ? Colors.blue.shade50 : Colors.grey.shade300,
border: Border.all(
color: canNavigate ? Colors.blue.shade200 : Colors.grey.shade400,
),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${card.shortcut} 进入',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: canNavigate ? Colors.blue.shade800 : Colors.grey.shade600,
),
),
);
}
} }

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 {
@@ -17,6 +19,8 @@ class RegistrationPage extends StatefulWidget {
} }
class _RegistrationPageState extends State<RegistrationPage> { class _RegistrationPageState extends State<RegistrationPage> {
static const _androidKeyCodeButtonR1 = 103;
final _scannerService = ScannerService(); final _scannerService = ScannerService();
final _apiService = ApiService(); final _apiService = ApiService();
final _feedbackService = FeedbackService(); final _feedbackService = FeedbackService();
@@ -44,7 +48,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,12 +62,54 @@ 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) return;
if (_isLockToggleKey(event)) {
_toggleLock(!_isLocked);
return;
}
if (event.logicalKey == LogicalKeyboardKey.enter) {
_submit(); _submit();
} }
} }
bool _isLockToggleKey(KeyEvent event) {
final key = event.logicalKey;
return key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.gameButtonRight1 ||
_androidKeyCodeFromLogicalKey(key) == _androidKeyCodeButtonR1;
}
int? _androidKeyCodeFromLogicalKey(LogicalKeyboardKey key) {
final keyId = key.keyId;
const androidPlane = LogicalKeyboardKey.androidPlane;
if (keyId >= androidPlane && keyId < androidPlane + 0x100000000) {
return keyId - androidPlane;
}
return null;
}
void _onScan(ScanResult result) { void _onScan(ScanResult result) {
final parsed = CodeParser.parse(result.barcode); final parsed = CodeParser.parse(result.barcode);
switch (parsed.type) { switch (parsed.type) {
@@ -78,7 +124,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 +209,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 +287,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 +343,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 +397,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 +411,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 +440,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 +476,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 +510,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,
@@ -463,18 +646,14 @@ class _RegistrationPageState extends State<RegistrationPage> {
onKeyEvent: _onKeyEvent, onKeyEvent: _onKeyEvent,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('上架登记'), title: Row(
actions: [ children: [
Padding( const Text('上架登记'),
padding: const EdgeInsets.only(right: 4), const SizedBox(width: 12),
child: Row( Expanded(child: _buildLockHeader()),
children: [ ],
const Text('锁定货位', style: TextStyle(fontSize: 13)), ),
Switch(value: _isLocked, onChanged: _toggleLock), titleSpacing: 12,
],
),
),
],
), ),
body: Column( body: Column(
children: [ children: [
@@ -498,9 +677,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 +699,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),
), ),
), ),
@@ -537,6 +720,56 @@ class _RegistrationPageState extends State<RegistrationPage> {
); );
} }
Widget _buildLockHeader() {
final backgroundColor =
_isLocked ? Colors.orange.shade700 : Colors.blue.shade700;
final label = _isLocked ? '货位锁定' : '未锁定';
return Material(
borderRadius: BorderRadius.circular(8),
color: backgroundColor,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => _toggleLock(!_isLocked),
child: Container(
width: double.infinity,
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.swap_horiz, color: Colors.white, size: 18),
const SizedBox(width: 6),
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
label,
maxLines: 1,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
),
),
const SizedBox(width: 8),
Text(
'P2',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.88),
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
);
}
Widget _buildSingleInputPanel() { Widget _buildSingleInputPanel() {
final hasLocation = _locationCode != null; final hasLocation = _locationCode != null;
final hasZongpai = _zongpaiNos.isNotEmpty; final hasZongpai = _zongpaiNos.isNotEmpty;

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(
barcode: event['barcode'] as String? ?? '', (event) => ScanResult(
codeType: event['codeType'] as String? ?? 'UNKNOWN', barcode: event['barcode'] as String? ?? '',
)); 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);
});
});
}