Files
jiu/client/lib/screens/inventory/inventory_check_screen.dart
T
wangjia dde87a32b6 feat(client): 库存/盘点/财务窄屏对齐移动原型(KPI 网格/卡片流/图标徽章/sheet)
- 库存列表:窄屏隐藏页内大标题头;KPI 2×2 MKpiGrid(缺货卡 warn+点击筛选);
  MSearchRow(详搜钮开筛选 sheet:状态/规格/系列);卡片徽章换图标变体
- 库存盘点:窄屏卡片流(单号/范围·项数/进行中徽章带图标/差异/日期)+
  详情 sheet(drow 键值行)+ 底部操作条;DateTime.now→appNow 可注入时钟
- 财务:窄屏隐藏大标题头;KPI 2×2(收入 up 绿/支出 down 红/应收 warn);
  DsSeg 应收/应付/流水三分段 + 卡片流(类型徽章带图标、金额正负色)
- golden:新增 m_inventory / m_inventory_check / m_finance 三屏×三主题
  (390×844@2x);重生成既有 inventory_list_mobile / finance_mobile 基准
  (桌面 golden 零改动)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 12:18:17 +08:00

734 lines
27 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/utils/clock.dart';
import '../../models/inventory.dart';
import '../../models/warehouse.dart';
import '../../core/config/app_constants.dart';
import '../../providers/inventory_provider.dart';
import '../../providers/warehouse_provider.dart';
import '../../core/theme/app_fonts.dart';
import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_toast.dart';
import '../../widgets/ds/m_sheet.dart';
import '../../widgets/ds/status_icon_map.dart';
import '../../widgets/mobile_list_card.dart';
class InventoryCheckScreen extends ConsumerStatefulWidget {
const InventoryCheckScreen({super.key});
@override
ConsumerState<InventoryCheckScreen> createState() =>
_InventoryCheckScreenState();
}
class _InventoryCheckScreenState extends ConsumerState<InventoryCheckScreen> {
Warehouse? _selectedWarehouse;
String _checkType = '全盘';
bool _submitting = false;
bool _loadingItems = false;
// Editable check items built from real inventory
final List<_CheckItem> _checkItems = [];
String get _checkNo {
final now = appNow();
final date =
'${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
return 'PD$date${_selectedWarehouse?.id.toString().padLeft(3, '0') ?? '001'}';
}
/// 盘点日期(appNow 可注入时钟,golden 冻结确定化)。
String get _dateStr {
final now = appNow();
return '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
}
@override
void dispose() {
for (final item in _checkItems) {
item.dispose();
}
super.dispose();
}
Future<void> _loadInventory(Warehouse wh) async {
setState(() {
_loadingItems = true;
for (final item in _checkItems) {
item.dispose();
}
_checkItems.clear();
});
try {
final result = await ref.read(inventoryRepositoryProvider).listInventory(
warehouseId: wh.id, pageSize: AppConstants.inventoryCheckPageSize);
final items = result.data
.where((inv) => inv.productId != null)
.map((inv) => _CheckItem(inventory: inv))
.toList();
if (mounted) {
setState(() {
_checkItems.addAll(items);
_loadingItems = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _loadingItems = false);
showDsToast(context, '加载库存失败:$e', bg: context.tokens.danger);
}
}
}
Future<void> _submit() async {
if (_selectedWarehouse == null) {
showDsToast(context, '请先选择仓库');
return;
}
setState(() => _submitting = true);
try {
final dateStr = _dateStr;
final items = _checkItems.map((item) {
final actual =
double.tryParse(item.actualQtyCtrl.text) ?? item.inventory.quantity;
return {
'product_id': item.inventory.productId!,
'actual_qty': actual,
'remark': item.remarkCtrl.text.trim(),
};
}).toList();
await ref.read(inventoryRepositoryProvider).createCheck({
'check_no': _checkNo,
'warehouse_id': _selectedWarehouse!.id,
'check_date': dateStr,
'items': items,
});
if (mounted) {
showDsToast(context, '盘点单已提交', bg: context.tokens.success);
context.go('/inventory');
}
} catch (e) {
if (mounted) {
showDsToast(context, '提交失败:$e', bg: context.tokens.danger);
}
} finally {
if (mounted) setState(() => _submitting = false);
}
}
int _getDiff(_CheckItem item) {
final actual =
double.tryParse(item.actualQtyCtrl.text) ?? item.inventory.quantity;
return (actual - item.inventory.quantity).round();
}
@override
Widget build(BuildContext context) {
final asyncWarehouses = ref.watch(warehouseListProvider);
// 窄屏(原型 m-inventory-check):壳顶栏带标题/返回 → 隐藏页内大标题头,
// 盘点单以卡片流呈现(点卡开详情 sheet),底部操作条提交。
if (context.isMobile) return _buildMobile(asyncWarehouses);
return Scaffold(
backgroundColor: context.tokens.bg,
body: Column(
children: [
// Header
Container(
height: 52,
color: context.tokens.surface,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
IconButton(
icon: const Icon(LucideIcons.arrowLeft, size: 20),
onPressed: () => context.go('/inventory'),
),
const SizedBox(width: 8),
const Text('库存盘点',
style:
TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const Spacer(),
OutlinedButton(
onPressed: () => context.go('/inventory'),
child: const Text('取消'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed:
(_submitting || _checkItems.isEmpty) ? null : _submit,
icon: _submitting
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white)) // ds-ignore: 盘点屏待重建批次统一
: const Icon(LucideIcons.circleCheck, size: 16),
label: const Text('提交盘点'),
),
],
),
),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Basic info
_basicInfoCard(asyncWarehouses),
const SizedBox(height: 12),
// Check items table
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('盘点明细',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: context.tokens.primaryDark)),
const SizedBox(width: 12),
if (_checkItems.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color:
context.tokens.accent.withOpacity(0.1),
borderRadius: BorderRadius.circular(3),
),
child: Text(
'差异 ${_checkItems.where((i) => _getDiff(i) != 0).length}',
style: TextStyle(
fontSize: 12,
color: context.tokens.accent),
),
),
],
),
const SizedBox(height: 12),
if (_selectedWarehouse == null)
Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text('请先选择盘点仓库',
style:
TextStyle(color: context.tokens.muted)),
),
)
else if (_loadingItems)
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator(),
),
)
else if (_checkItems.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text('该仓库暂无库存记录',
style:
TextStyle(color: context.tokens.muted)),
),
)
else
Table(
columnWidths: const {
0: FixedColumnWidth(36),
1: FixedColumnWidth(80),
2: FlexColumnWidth(3),
3: FixedColumnWidth(50),
4: FixedColumnWidth(80),
5: FixedColumnWidth(120),
6: FixedColumnWidth(80),
7: FlexColumnWidth(2),
},
children: [
TableRow(
decoration:
BoxDecoration(color: context.tokens.thBg),
children: [
'序号',
'商品编码',
'商品名称',
'单位',
'账面数量',
'实际数量',
'差异',
'备注',
]
.map((h) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 10),
child: Text(h,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: context
.tokens.primaryDark)),
))
.toList(),
),
...List.generate(_checkItems.length,
(i) => _buildCheckRow(i)),
],
),
if (_checkItems.isNotEmpty) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
_SummaryItem(
label: '盘点商品',
value: '${_checkItems.length}',
color: context.tokens.primary,
),
const SizedBox(width: 24),
_SummaryItem(
label: '盘盈',
value:
'${_checkItems.where((i) => _getDiff(i) > 0).length}',
color: context.tokens.success,
),
const SizedBox(width: 24),
_SummaryItem(
label: '盘亏',
value:
'${_checkItems.where((i) => _getDiff(i) < 0).length}',
color: context.tokens.danger,
),
const SizedBox(width: 24),
_SummaryItem(
label: '相符',
value:
'${_checkItems.where((i) => _getDiff(i) == 0).length}',
color: context.tokens.muted,
),
],
),
),
],
],
),
),
),
],
),
),
),
],
),
);
}
/// 盘点基本信息卡(桌面/移动共用):单号 / 仓库 / 类型 / 日期。
Widget _basicInfoCard(AsyncValue<List<Warehouse>> asyncWarehouses) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('盘点基本信息',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: context.tokens.primaryDark)),
const SizedBox(height: 16),
Wrap(
spacing: 16,
runSpacing: 16,
children: [
_InfoField(
label: '盘点单号',
child: InputDecorator(
decoration: const InputDecoration(),
child: Text(
_checkNo,
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontSize: 13),
),
),
),
_InfoField(
label: '盘点仓库',
child: asyncWarehouses.when(
loading: () => const LinearProgressIndicator(),
error: (e, _) => Text('$e',
style: TextStyle(
color: context.tokens.danger, fontSize: 12)),
data: (warehouses) => DsSelect<Warehouse>(
value: _selectedWarehouse,
hint: '请选择仓库',
options: [
for (final w in warehouses) (w, w.name),
],
onChanged: (w) {
setState(() => _selectedWarehouse = w);
_loadInventory(w);
},
),
),
),
_InfoField(
label: '盘点类型',
child: DsSelect<String>(
value: _checkType,
options: const [
('全盘', '全盘'),
('抽盘', '抽盘'),
('循环盘点', '循环盘点'),
],
onChanged: (v) => setState(() => _checkType = v),
),
),
_InfoField(
label: '盘点日期',
child: InputDecorator(
decoration: const InputDecoration(),
child: Text(_dateStr, style: const TextStyle(fontSize: 13)),
),
),
],
),
],
),
),
);
}
// ── 窄屏(原型 m-inventory-check 粒度)──────────────────────────────
int get _totalDiff =>
_checkItems.fold(0, (sum, item) => sum + _getDiff(item));
String get _scopeLabel =>
'${_selectedWarehouse?.name ?? '未选仓库'} · $_checkType';
String _fmtDiff(int diff) => diff > 0 ? '+$diff' : '$diff';
Widget _buildMobile(AsyncValue<List<Warehouse>> asyncWarehouses) {
final t = context.tokens;
return Scaffold(
backgroundColor: t.bg,
body: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppDims.sp3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_basicInfoCard(asyncWarehouses),
// .m-section
Padding(
padding: const EdgeInsets.fromLTRB(2, 16, 2, 8),
child: Text('盘点单',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w700,
letterSpacing: .4,
color: t.muted)),
),
if (_loadingItems)
const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
)
else
_draftCard(t),
],
),
),
),
// 底部操作条(原型 .m-actionbar 形态:横排等分按钮)
Container(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
decoration: BoxDecoration(
color: t.surface,
border: Border(top: BorderSide(color: t.borderSubtle)),
),
child: Row(children: [
Expanded(
child: DsButton('取消',
onPressed: () => context.go('/inventory')),
),
const SizedBox(width: 10),
Expanded(
child: DsButton('提交盘点',
variant: DsBtnVariant.primary,
onPressed: (_submitting || _checkItems.isEmpty)
? null
: _submit),
),
]),
),
],
),
);
}
/// 盘点单卡(原型 .m-card:单号 / 范围·项数 / 状态徽章 + 差异 / 日期脚注)。
Widget _draftCard(dynamic t) {
final diff = _totalDiff;
return MobileListCard(
onTap: _openDraftSheet,
title: Text(_checkNo,
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
subtitle: Text('$_scopeLabel · ${_checkItems.length}'),
trailing: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
DsBadge('进行中',
tone: DsBadgeTone.warn, icon: statusIcon('进行中')),
const SizedBox(height: 6),
// 「差异」为 CJK 走默认字体,数值走 monoJetBrains Mono 无 CJK 字形)
Text.rich(
TextSpan(children: [
TextSpan(
text: '差异 ',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 13,
color: diff == 0 ? t.success : t.danger)),
TextSpan(
text: _fmtDiff(diff),
style: TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontWeight: FontWeight.w700,
fontSize: 13,
color: diff == 0 ? t.success : t.danger)),
]),
),
],
),
fields: [MobileCardField('日期', _dateStr)],
);
}
/// 盘点单详情 sheet(原型 openSheet.drow 键值行 + 状态徽章 + 继续录入)。
void _openDraftSheet() {
final t = context.tokens;
final diff = _totalDiff;
showMSheet<void>(
context,
title: '盘点单详情',
builder: (ctx) => Column(
mainAxisSize: MainAxisSize.min,
children: [
_drow(ctx, '单号', value: _checkNo, mono: true),
_drow(ctx, '范围', value: _scopeLabel),
_drow(ctx, '商品项数', value: '${_checkItems.length}'),
_drow(ctx, '盈亏差异',
value: _fmtDiff(diff),
mono: true,
color: diff == 0 ? t.success : t.danger),
_drow(ctx, '状态',
child: DsBadge('进行中',
tone: DsBadgeTone.warn, icon: statusIcon('进行中')),
last: true),
],
),
actions: [
DsButton('继续录入',
variant: DsBtnVariant.primary,
onPressed: () => Navigator.of(context).pop()),
],
);
}
/// 原型 .drowlabel(muted) + b(text 600)pad 11 0,下边 border-subtle。
Widget _drow(BuildContext ctx, String label,
{String? value,
Widget? child,
bool mono = false,
Color? color,
bool last = false}) {
final t = ctx.tokens;
return Container(
padding: const EdgeInsets.symmetric(vertical: 11),
decoration: BoxDecoration(
border:
last ? null : Border(bottom: BorderSide(color: t.borderSubtle))),
child: Row(children: [
Text(label,
style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
const Spacer(),
child ??
Text(value ?? '',
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600,
fontFamily: mono ? AppFonts.mono : null,
fontFamilyFallback: mono ? AppFonts.monoFallback : null,
color: color ?? t.text)),
]),
);
}
TableRow _buildCheckRow(int index) {
final item = _checkItems[index];
final diff = _getDiff(item);
final inv = item.inventory;
return TableRow(
decoration: BoxDecoration(
color: diff != 0
? (diff > 0
? context.tokens.success.withOpacity(0.04)
: context.tokens.danger.withOpacity(0.04))
: (index.isEven ? context.tokens.surface : context.tokens.bg),
),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text('${index + 1}',
style: TextStyle(fontSize: 13, color: context.tokens.muted)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text(inv.productCode.isEmpty ? '-' : inv.productCode,
style: TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontSize: 12,
color: context.tokens.muted)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text(inv.productName.isEmpty ? '-' : inv.productName,
style: const TextStyle(fontSize: 13),
overflow: TextOverflow.ellipsis),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text(inv.unit.isEmpty ? '-' : inv.unit,
style: const TextStyle(fontSize: 13)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text(inv.quantity.toStringAsFixed(0),
style: const TextStyle(fontSize: 13)),
),
Padding(
padding: const EdgeInsets.all(4),
child: TextFormField(
controller: item.actualQtyCtrl,
decoration: const InputDecoration(),
style: const TextStyle(fontSize: 13),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (_) => setState(() {}),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text(
diff == 0 ? '0' : (diff > 0 ? '+$diff' : '$diff'),
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: diff == 0
? context.tokens.muted
: (diff > 0 ? context.tokens.success : context.tokens.danger),
),
),
),
Padding(
padding: const EdgeInsets.all(4),
child: TextFormField(
controller: item.remarkCtrl,
decoration: const InputDecoration(hintText: '备注'),
style: const TextStyle(fontSize: 13),
),
),
],
);
}
}
class _CheckItem {
final Inventory inventory;
final TextEditingController actualQtyCtrl;
final TextEditingController remarkCtrl;
_CheckItem({required this.inventory})
: actualQtyCtrl =
TextEditingController(text: inventory.quantity.toStringAsFixed(0)),
remarkCtrl = TextEditingController();
void dispose() {
actualQtyCtrl.dispose();
remarkCtrl.dispose();
}
}
class _InfoField extends StatelessWidget {
final String label;
final Widget child;
const _InfoField({required this.label, required this.child});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 220,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: TextStyle(fontSize: 13, color: context.tokens.muted)),
const SizedBox(height: 6),
child,
],
),
);
}
}
class _SummaryItem extends StatelessWidget {
final String label;
final String value;
final Color color;
const _SummaryItem(
{required this.label, required this.value, required this.color});
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(label,
style: TextStyle(fontSize: 13, color: context.tokens.muted)),
const SizedBox(width: 4),
Text(value,
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600, color: color)),
],
);
}
}