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>
This commit is contained in:
Misaka_Company
2026-05-18 11:26:02 +08:00
parent 2b5fdf41a9
commit 073162dc65
4 changed files with 950 additions and 308 deletions

View File

@@ -84,9 +84,12 @@ class BoxingPage extends StatefulWidget {
}
class _BoxingPageState extends State<BoxingPage> {
static const _androidKeyCodeButtonR1 = 103;
final _scannerService = ScannerService();
final _apiService = ApiService();
final _feedbackService = FeedbackService();
final _focusNode = FocusNode();
StreamSubscription<ScanResult>? _scanSubscription;
// 模式
@@ -155,12 +158,14 @@ class _BoxingPageState extends State<BoxingPage> {
_processAutoScanCodes(arguments.autoScanCodes);
});
}
WidgetsBinding.instance.addPostFrameCallback((_) => _requestFocus());
}
@override
void dispose() {
_boxNoController.removeListener(_onBoxNoTextChanged);
_scanSubscription?.cancel();
_focusNode.dispose();
_boxNoController.dispose();
_quantityController.dispose();
_boxNoFocusNode.dispose();
@@ -169,6 +174,35 @@ class _BoxingPageState extends State<BoxingPage> {
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() {
if (!mounted || _mode != BoxingMode.multiCode) return;
setState(() {
@@ -360,6 +394,7 @@ class _BoxingPageState extends State<BoxingPage> {
_statusOverrideDot = null;
});
_focusQuantityInput();
_requestFocus();
}
void _applyAutoFill() {
@@ -1253,163 +1288,229 @@ class _BoxingPageState extends State<BoxingPage> {
final effectiveDot = _statusOverrideDot ?? _statusDot;
final effectiveText = _statusOverrideText ?? _statusText;
return Scaffold(
appBar: AppBar(
title: const Text('装箱编号'),
actions: [
Padding(
padding: const EdgeInsets.only(right: 4),
child: TextButton.icon(
onPressed: _isAutoProcessing ? null : _cycleMode,
icon: const Icon(Icons.swap_horiz, size: 18),
label: Text(_modeLabel, style: const TextStyle(fontSize: 13)),
style: TextButton.styleFrom(
foregroundColor: _mode == BoxingMode.multiCode
? Colors.orange
: colorScheme.primary,
),
),
return KeyboardListener(
focusNode: _focusNode,
onKeyEvent: _onKeyEvent,
child: Scaffold(
appBar: AppBar(
title: Row(
children: [
const Text('装箱编号'),
const SizedBox(width: 12),
Expanded(child: _buildModeHeader()),
],
),
],
),
body: Column(
children: [
if (_isAutoProcessing)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: Colors.orange.shade50,
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.orange.shade700,
),
),
const SizedBox(width: 8),
Text(
'正在同步转运数据...',
style: TextStyle(
color: Colors.orange.shade900,
fontSize: 13,
),
),
],
),
),
if (_autoWarnings.isNotEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: Colors.amber.shade100,
child: Text(
_autoWarnings.join(''),
style: TextStyle(color: Colors.amber.shade900, fontSize: 13),
),
),
// 重复箱号警告条(保留,因为这是输入区关联的即时反馈)
if (_paichanSwitchNotice != null)
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(
child: OutlinedButton(
onPressed: _onGoBack,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(40),
titleSpacing: 12,
),
body: Column(
children: [
if (_isAutoProcessing)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
color: Colors.orange.shade50,
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.orange.shade700,
),
),
const SizedBox(width: 8),
Text(
'正在同步转运数据...',
style: TextStyle(
color: Colors.orange.shade900,
fontSize: 13,
),
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(
child: ElevatedButton(
onPressed: _cancelEditAssigned,
style: ElevatedButton.styleFrom(
child: OutlinedButton(
onPressed: _onGoBack,
style: OutlinedButton.styleFrom(
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('完成本箱'),
),
),
// 底部状态栏
StatusBar(dotColor: effectiveDot, text: effectiveText),
],
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('完成本箱'),
),
),
// 底部状态栏
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,
),
),
],
),
),
),
);
}