Compare commits

...

7 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
Misaka_Company
b2f2c536cb feat(registration): merge shelf overview into registration 2026-05-15 08:45:22 +08:00
Misaka_Company
ce80e1a1f6 feat(boxing): support editing assigned box records 2026-05-15 08:19:34 +08:00
Misaka_Company
a165bfeabe feat(boxing): handle multi-code paichan switches 2026-05-14 14:04:25 +08:00
Misaka_Company
28871eb21f feat(boxing): support v2.5 dual modes 2026-05-14 12:29:33 +08:00
27 changed files with 2700 additions and 750 deletions

View File

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

@@ -61,14 +61,37 @@ class BoxingDetailPage extends StatelessWidget {
}
Widget _buildBoxGroup(BuildContext context, BoxDetailData box) {
final boxTotalQuantity = box.items.fold<int>(
0,
(sum, item) => sum + item.quantity,
);
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'箱号 ${box.boxNo}',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
Row(
children: [
Expanded(
child: Text(
'箱号 ${box.boxNo}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
),
Text(
'$boxTotalQuantity',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
const SizedBox(height: 4),
Container(

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pad_scanner/services/app_config_service.dart';
import 'package:pad_scanner/pages/settings_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
class FeatureCard {
final ModuleType type;
final String shortcut;
final IconData icon;
final String title;
final String description;
final ModuleStatus status;
final String? version;
final String route;
const FeatureCard({
required this.type,
required this.shortcut,
required this.icon,
required this.title,
required this.description,
required this.status,
this.version,
required this.route,
});
}
@@ -40,7 +39,12 @@ class HomePage extends StatefulWidget {
}
class _HomePageState extends State<HomePage> {
static const _androidKeyCode1 = 8;
static const _androidKeyCode2 = 9;
static const _androidKeyCode3 = 10;
final _apiService = ApiService();
final _focusNode = FocusNode();
bool _isCheckingConnection = false;
bool _isConnected = false;
String _apiAddress = '';
@@ -49,29 +53,26 @@ class _HomePageState extends State<HomePage> {
static const _modules = [
FeatureCard(
type: ModuleType.registration,
shortcut: '1',
icon: Icons.local_shipping_outlined,
title: '上架登记',
description: '扫码绑定总排号与货位',
status: ModuleStatus.online,
version: 'v1.0',
route: '/registration',
),
FeatureCard(
type: ModuleType.boxing,
shortcut: '2',
icon: Icons.view_list_outlined,
title: '装箱编号',
description: '查询箱号、录入装箱明细',
status: ModuleStatus.online,
version: 'v1.0',
route: '/boxing',
),
FeatureCard(
type: ModuleType.shelfQuery,
shortcut: '3',
icon: Icons.search_outlined,
title: '货架查询',
description: '按合同查询货架库存',
status: ModuleStatus.planning,
version: null,
route: '',
),
];
@@ -80,6 +81,77 @@ class _HomePageState extends State<HomePage> {
void initState() {
super.initState();
_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 {
@@ -133,17 +205,22 @@ class _HomePageState extends State<HomePage> {
case ModuleType.shelfQuery:
return; // 规划中,不导航
}
Navigator.push(
context,
MaterialPageRoute(builder: (_) => targetPage),
).then((_) => _checkConnection());
Navigator.push(context, MaterialPageRoute(builder: (_) => targetPage)).then(
(_) {
_checkConnection();
_requestFocus();
},
);
}
void _navigateToSettings() {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsPage()),
).then((_) => _checkConnection());
).then((_) {
_checkConnection();
_requestFocus();
});
}
Color _getConnectionStatusColor() {
@@ -161,87 +238,70 @@ class _HomePageState extends State<HomePage> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('CargoTrace'),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
icon: const Icon(Icons.settings),
onPressed: _navigateToSettings,
return KeyboardListener(
focusNode: _focusNode,
onKeyEvent: _onKeyEvent,
child: Scaffold(
appBar: AppBar(
title: const SizedBox.shrink(),
toolbarHeight: 44,
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
icon: const Icon(Icons.settings),
onPressed: _navigateToSettings,
),
),
),
],
),
body: Column(
children: [
// Function cards area
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
itemCount: _modules.length,
itemBuilder: (context, index) {
final card = _modules[index];
return _buildFeatureCard(context, card);
},
],
),
body: Column(
children: [
// Function cards area
Expanded(
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
itemCount: _modules.length,
itemBuilder: (context, index) {
final card = _modules[index];
return _buildFeatureCard(context, card);
},
),
),
),
// Bottom connection status bar
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
color: colorScheme.surfaceContainerHighest,
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _getConnectionStatusColor(),
shape: BoxShape.circle,
// Bottom connection status bar
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
color: colorScheme.surfaceContainerHighest,
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _getConnectionStatusColor(),
shape: BoxShape.circle,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_getConnectionStatusText(),
style: const TextStyle(fontSize: 13),
overflow: TextOverflow.ellipsis,
const SizedBox(width: 8),
Expanded(
child: Text(
_getConnectionStatusText(),
style: const TextStyle(fontSize: 13),
overflow: TextOverflow.ellipsis,
),
),
),
],
],
),
),
),
],
],
),
),
);
}
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;
return Padding(
@@ -250,7 +310,7 @@ class _HomePageState extends State<HomePage> {
onTap: () => _onCardTap(card),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: canNavigate ? Colors.white : Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
@@ -262,80 +322,49 @@ class _HomePageState extends State<HomePage> {
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: Row(
children: [
// First row: icon + module name
Row(
children: [
Icon(
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,
),
),
],
Icon(
card.icon,
size: 22,
color: canNavigate ? Colors.blue : Colors.grey,
),
const SizedBox(height: 6),
// Second row: description
const SizedBox(width: 8),
Text(
card.description,
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
const SizedBox(height: 10),
// Third row: status badge (right aligned)
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,
),
),
],
],
),
card.title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: canNavigate ? Colors.black87 : Colors.grey,
),
),
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,
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -46,6 +46,76 @@ class RegistrationResult {
RegistrationResult(success: false, errorMessage: message);
}
class PaichaOverviewItem {
final String zongpaiNo;
final String? workOrderNo;
final int quantity;
final String? locationCode;
final String status;
PaichaOverviewItem({
required this.zongpaiNo,
this.workOrderNo,
required this.quantity,
this.locationCode,
required this.status,
});
factory PaichaOverviewItem.fromJson(Map<String, dynamic> json) {
return PaichaOverviewItem(
zongpaiNo: json['zongpai_no'] as String,
workOrderNo: json['work_order_no']?.toString(),
quantity: json['quantity'] as int? ?? 0,
locationCode: json['location_code']?.toString(),
status: json['status']?.toString() ?? 'not_shelved',
);
}
}
class PaichaOverviewResult {
final bool success;
final bool notFound;
final String? errorMessage;
final String? paichaNo;
final int totalCount;
final List<PaichaOverviewItem> items;
PaichaOverviewResult({
required this.success,
this.notFound = false,
this.errorMessage,
this.paichaNo,
this.totalCount = 0,
this.items = const [],
});
factory PaichaOverviewResult.ok(Map<String, dynamic> json) {
final items =
(json['items'] as List<dynamic>?)
?.map(
(item) =>
PaichaOverviewItem.fromJson(item as Map<String, dynamic>),
)
.toList() ??
[];
return PaichaOverviewResult(
success: true,
paichaNo: json['paicha_no'] as String?,
totalCount: json['total_count'] as int? ?? items.length,
items: items,
);
}
factory PaichaOverviewResult.notFoundResult() => PaichaOverviewResult(
success: false,
notFound: true,
errorMessage: '暂无排产信息',
);
factory PaichaOverviewResult.error(String message) =>
PaichaOverviewResult(success: false, errorMessage: message);
}
// === 装箱模块数据类 ===
/// 箱号内单个总排号明细
@@ -290,6 +360,34 @@ class ApiService {
}
}
Future<PaichaOverviewResult> fetchPaichaOverview({
required String baseUrl,
required String zongpaiNo,
}) async {
final uri = Uri.parse(
'$baseUrl/CargoTrace/location/paicha-overview',
).replace(queryParameters: {'zongpai_no': zongpaiNo});
try {
final response = await _client
.get(uri, headers: {'Content-Type': 'application/json'})
.timeout(timeout);
switch (response.statusCode) {
case 200:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return PaichaOverviewResult.ok(body);
case 400:
return PaichaOverviewResult.error('无效的总排号格式');
case 404:
return PaichaOverviewResult.notFoundResult();
default:
return PaichaOverviewResult.error('加载失败,点击重试');
}
} catch (e) {
return PaichaOverviewResult.error('加载失败,点击重试');
}
}
/// 查询装箱信息 — GET /CargoTrace/box/info
Future<BoxInfoResult> fetchBoxInfo({
required String baseUrl,
@@ -325,7 +423,6 @@ class ApiService {
required String zongpaiNo,
required int boxNo,
required int quantity,
required String boxMode,
}) async {
final uri = Uri.parse('$baseUrl/CargoTrace/box');
try {
@@ -337,7 +434,6 @@ class ApiService {
'zongpai_no': zongpaiNo,
'box_no': boxNo,
'quantity': quantity,
'box_mode': boxMode,
}),
)
.timeout(timeout);
@@ -369,7 +465,6 @@ class ApiService {
required int boxItemId,
required int boxNo,
required int quantity,
String boxMode = 'one-to-many',
}) async {
final uri = Uri.parse('$baseUrl/CargoTrace/box/$boxItemId');
try {
@@ -377,11 +472,7 @@ class ApiService {
.patch(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'box_no': boxNo,
'quantity': quantity,
'box_mode': boxMode,
}),
body: jsonEncode({'box_no': boxNo, 'quantity': quantity}),
)
.timeout(timeout);

View File

@@ -0,0 +1,38 @@
enum PaichanContextChange { firstScan, samePaichan, switchedPaichan }
class PaichanContextDecision {
final PaichanContextChange change;
final int? boxNoToApply;
const PaichanContextDecision({required this.change, this.boxNoToApply});
bool get isSwitched => change == PaichanContextChange.switchedPaichan;
}
PaichanContextDecision decideMultiCodePaichanContext({
required String? lastPaichanNo,
required String? currentPaichanNo,
required String currentBoxNoText,
required int maxBoxNo,
}) {
final recommendedBoxNo = maxBoxNo + 1;
if (currentPaichanNo == null || currentPaichanNo.isEmpty) {
return const PaichanContextDecision(change: PaichanContextChange.firstScan);
}
if (lastPaichanNo == null || lastPaichanNo.isEmpty) {
return PaichanContextDecision(
change: PaichanContextChange.firstScan,
boxNoToApply: currentBoxNoText.trim().isEmpty ? recommendedBoxNo : null,
);
}
if (lastPaichanNo == currentPaichanNo) {
return PaichanContextDecision(
change: PaichanContextChange.samePaichan,
boxNoToApply: currentBoxNoText.trim().isEmpty ? recommendedBoxNo : null,
);
}
return PaichanContextDecision(
change: PaichanContextChange.switchedPaichan,
boxNoToApply: recommendedBoxNo,
);
}

View File

@@ -11,6 +11,7 @@ enum FeedbackEvent {
networkError, // Network error → failure sound + short vibrate
duplicateBoxNo, // Duplicate box no → failure sound + short vibrate
modeSwitch, // Mode toggle → beep only
paichanSwitch, // Paichan changed → beep + short vibrate
}
/// Centralized feedback dispatcher.
@@ -56,6 +57,10 @@ class FeedbackService {
case FeedbackEvent.modeSwitch:
_soundService.playBeep();
case FeedbackEvent.paichanSwitch:
_soundService.playBeep();
_vibrateShort();
}
}

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 {
static const _eventChannel =
EventChannel('com.example.pad_scanner/scan');
Stream<ScanResult>? _scanStream;
static const _eventChannel = EventChannel('com.example.pad_scanner/scan');
static Stream<ScanResult>? _sharedScanStream;
Stream<ScanResult> get scanResults {
_scanStream ??= _eventChannel
_sharedScanStream ??= _createSharedScanStream();
return _sharedScanStream!;
}
static Stream<ScanResult> _createSharedScanStream() {
return _eventChannel
.receiveBroadcastStream()
.map((event) => event as Map)
.map((event) => ScanResult(
barcode: event['barcode'] as String? ?? '',
codeType: event['codeType'] as String? ?? 'UNKNOWN',
));
return _scanStream!;
.map(
(event) => ScanResult(
barcode: event['barcode'] as String? ?? '',
codeType: event['codeType'] as String? ?? 'UNKNOWN',
),
)
.asBroadcastStream();
}
}

View File

@@ -144,6 +144,86 @@ void main() {
});
group('ApiService boxing APIs', () {
test('fetchPaichaOverview parses overview items', () async {
final mockClient = _MockClient((request) async {
return http.Response(
jsonEncode({
'paicha_no': 'R00001',
'total_count': 2,
'items': [
{
'zongpai_no': '26B1',
'work_order_no': '6-1(7)',
'quantity': 80,
'location_code': 'A01-02-03',
'status': 'on_shelf',
},
{
'zongpai_no': '26B2',
'work_order_no': '6-2(3)',
'quantity': 45,
'location_code': null,
'status': 'not_shelved',
},
],
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
});
final svc = ApiService(client: mockClient);
final result = await svc.fetchPaichaOverview(
baseUrl: 'http://localhost',
zongpaiNo: '26B1',
);
expect(result.success, isTrue);
expect(result.paichaNo, 'R00001');
expect(result.totalCount, 2);
expect(result.items.first.status, 'on_shelf');
expect(result.items.last.locationCode, isNull);
});
test('fetchPaichaOverview maps 404 to not found result', () async {
final mockClient = _MockClient((request) async {
return http.Response(
jsonEncode({'error_code': 'PAICHA_NOT_FOUND', 'message': '暂无排产信息'}),
404,
headers: {'content-type': 'application/json; charset=utf-8'},
);
});
final svc = ApiService(client: mockClient);
final result = await svc.fetchPaichaOverview(
baseUrl: 'http://localhost',
zongpaiNo: '26B404',
);
expect(result.success, isFalse);
expect(result.notFound, isTrue);
expect(result.errorMessage, '暂无排产信息');
});
test(
'fetchPaichaOverview maps network failure to retryable error',
() async {
final mockClient = _MockClient((request) async {
throw Exception('Connection refused');
});
final svc = ApiService(client: mockClient);
final result = await svc.fetchPaichaOverview(
baseUrl: 'http://localhost',
zongpaiNo: '26B1',
);
expect(result.success, isFalse);
expect(result.notFound, isFalse);
expect(result.errorMessage, '加载失败,点击重试');
},
);
test('fetchBoxInfo parses current boxes and box item ids', () async {
final mockClient = _MockClient((request) async {
return http.Response(
@@ -190,8 +270,11 @@ void main() {
expect(result.suggestedBoxNo, 4);
});
test('saveBoxRecord parses zongpai number from success response', () async {
test('saveBoxRecord sends simplified request body', () async {
late Map<String, dynamic> body;
final mockClient = _MockClient((request) async {
final req = request as http.Request;
body = jsonDecode(req.body) as Map<String, dynamic>;
return http.Response(
jsonEncode({
'box_item_id': 102,
@@ -212,15 +295,16 @@ void main() {
zongpaiNo: '26BW0012',
boxNo: 4,
quantity: 34,
boxMode: 'many-to-one',
);
expect(result.success, isTrue);
expect(result.boxItemId, 102);
expect(result.zongpaiNo, '26BW0012');
expect(body, {'zongpai_no': '26BW0012', 'box_no': 4, 'quantity': 34});
expect(body.containsKey('box_mode'), isFalse);
});
test('updateBoxRecord sends many-to-one box mode', () async {
test('updateBoxRecord sends simplified request body', () async {
late Map<String, dynamic> body;
final mockClient = _MockClient((request) async {
final req = request as http.Request;
@@ -245,11 +329,11 @@ void main() {
boxItemId: 102,
boxNo: 4,
quantity: 20,
boxMode: 'many-to-one',
);
expect(result.success, isTrue);
expect(body['box_mode'], 'many-to-one');
expect(body, {'box_no': 4, 'quantity': 20});
expect(body.containsKey('box_mode'), isFalse);
});
test('deleteBoxRecord handles success and not found', () async {

View File

@@ -0,0 +1,60 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pad_scanner/services/boxing_context.dart';
void main() {
group('decideMultiCodePaichanContext', () {
test('first scan records context and fills recommended box if empty', () {
final decision = decideMultiCodePaichanContext(
lastPaichanNo: null,
currentPaichanNo: 'W00009',
currentBoxNoText: '',
maxBoxNo: 3,
);
expect(decision.change, PaichanContextChange.firstScan);
expect(decision.boxNoToApply, 4);
expect(decision.isSwitched, isFalse);
});
test('same paichan keeps current box number', () {
final decision = decideMultiCodePaichanContext(
lastPaichanNo: 'W00009',
currentPaichanNo: 'W00009',
currentBoxNoText: '8',
maxBoxNo: 3,
);
expect(decision.change, PaichanContextChange.samePaichan);
expect(decision.boxNoToApply, isNull);
expect(decision.isSwitched, isFalse);
});
test('switched paichan resets to new paichan max box plus one', () {
final decision = decideMultiCodePaichanContext(
lastPaichanNo: 'W00009',
currentPaichanNo: 'W00010',
currentBoxNoText: '8',
maxBoxNo: 1,
);
expect(decision.change, PaichanContextChange.switchedPaichan);
expect(decision.boxNoToApply, 2);
expect(decision.isSwitched, isTrue);
});
test(
'same paichan defensively fills recommended box when box is empty',
() {
final decision = decideMultiCodePaichanContext(
lastPaichanNo: 'W00009',
currentPaichanNo: 'W00009',
currentBoxNoText: '',
maxBoxNo: 4,
);
expect(decision.change, PaichanContextChange.samePaichan);
expect(decision.boxNoToApply, 5);
},
);
});
}

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