diff --git a/client/lib/screens/products/product_detail_screen.dart b/client/lib/screens/products/product_detail_screen.dart index bcf95e9..4a6d61f 100644 --- a/client/lib/screens/products/product_detail_screen.dart +++ b/client/lib/screens/products/product_detail_screen.dart @@ -512,6 +512,7 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> { late Future _future; Uint8List? _bytes; bool _printing = false; + String _printStatus = ''; @override void initState() { @@ -526,9 +527,10 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> { Future _print() async { if (_bytes == null || _printing) return; - setState(() => _printing = true); + setState(() { _printing = true; _printStatus = '正在准备字体...'; }); try { final fontData = await rootBundle.load('assets/fonts/NotoSansSC-Regular.ttf'); + if (mounted) setState(() => _printStatus = '正在生成 PDF...'); final font = pw.Font.ttf(fontData); final p = widget.product; final doc = pw.Document(); @@ -553,9 +555,16 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> { ), ), )); + if (mounted) setState(() => _printStatus = '正在打开打印...'); await Printing.layoutPdf(onLayout: (_) async => doc.save()); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('打印失败:$e'), backgroundColor: Colors.red), + ); + } } finally { - if (mounted) setState(() => _printing = false); + if (mounted) setState(() { _printing = false; _printStatus = ''; }); } } @@ -591,7 +600,9 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> { width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Icon(Icons.print, size: 16), - label: const Text('打印'), + label: Text(_printing + ? (_printStatus.isNotEmpty ? _printStatus : '准备中...') + : '打印'), ), ], ); diff --git a/client/lib/screens/stock_in/stock_in_form_screen.dart b/client/lib/screens/stock_in/stock_in_form_screen.dart index fe5b7ac..f79fe1b 100644 --- a/client/lib/screens/stock_in/stock_in_form_screen.dart +++ b/client/lib/screens/stock_in/stock_in_form_screen.dart @@ -10,6 +10,7 @@ import '../../providers/product_provider.dart'; import '../../providers/inventory_provider.dart'; import '../../providers/stock_in_provider.dart'; import '../../providers/warehouse_provider.dart'; +import '../../widgets/searchable_option_field.dart'; class StockInFormScreen extends ConsumerStatefulWidget { final int? editOrderId; @@ -515,24 +516,18 @@ class _StockInFormScreenState extends ConsumerState { child: asyncNames.when( loading: () => const LinearProgressIndicator(), error: (_, __) => const Text('加载失败'), - data: (names) => DropdownButtonFormField( - value: item.selectedNameId, - hint: const Text('选择名称', style: TextStyle(fontSize: 12)), - isExpanded: true, - items: names - .map((o) => DropdownMenuItem( - value: o.id, - child: Text(o.name, - style: const TextStyle(fontSize: 12), - overflow: TextOverflow.ellipsis))) + data: (names) => SearchableOptionField( + options: names + .map((o) => OptionItem(id: o.id, name: o.name, code: o.code)) .toList(), + selectedId: item.selectedNameId, + hint: '选择名称', + dialogTitle: '选择商品名称', + isRequired: true, onChanged: (v) => setState(() { item.selectedNameId = v; item.productId = null; }), - validator: (v) => v == null ? '请选择' : null, - decoration: const InputDecoration(isDense: true), - style: const TextStyle(fontSize: 12, color: Colors.black87), ), ), ), @@ -542,23 +537,17 @@ class _StockInFormScreenState extends ConsumerState { child: asyncSeries.when( loading: () => const LinearProgressIndicator(), error: (_, __) => const Text('加载失败'), - data: (series) => DropdownButtonFormField( - value: item.selectedSeriesId, - hint: const Text('选择系列', style: TextStyle(fontSize: 12)), - isExpanded: true, - items: series - .map((o) => DropdownMenuItem( - value: o.id, - child: Text(o.name, - style: const TextStyle(fontSize: 12), - overflow: TextOverflow.ellipsis))) + data: (series) => SearchableOptionField( + options: series + .map((o) => OptionItem(id: o.id, name: o.name, code: o.code)) .toList(), + selectedId: item.selectedSeriesId, + hint: '选择系列', + dialogTitle: '选择系列', onChanged: (v) => setState(() { item.selectedSeriesId = v; item.productId = null; }), - decoration: const InputDecoration(isDense: true), - style: const TextStyle(fontSize: 12, color: Colors.black87), ), ), ), @@ -568,24 +557,18 @@ class _StockInFormScreenState extends ConsumerState { child: asyncSpecs.when( loading: () => const LinearProgressIndicator(), error: (_, __) => const Text('加载失败'), - data: (specs) => DropdownButtonFormField( - value: item.selectedSpecId, - hint: const Text('选择规格', style: TextStyle(fontSize: 12)), - isExpanded: true, - items: specs - .map((o) => DropdownMenuItem( - value: o.id, - child: Text(o.name, - style: const TextStyle(fontSize: 12), - overflow: TextOverflow.ellipsis))) + data: (specs) => SearchableOptionField( + options: specs + .map((o) => OptionItem(id: o.id, name: o.name, code: o.code)) .toList(), + selectedId: item.selectedSpecId, + hint: '选择规格', + dialogTitle: '选择规格', + isRequired: true, onChanged: (v) => setState(() { item.selectedSpecId = v; item.productId = null; }), - validator: (v) => v == null ? '请选择' : null, - decoration: const InputDecoration(isDense: true), - style: const TextStyle(fontSize: 12, color: Colors.black87), ), ), ), diff --git a/client/lib/screens/stock_out/stock_out_form_screen.dart b/client/lib/screens/stock_out/stock_out_form_screen.dart index 2a4a32e..4cc03f4 100644 --- a/client/lib/screens/stock_out/stock_out_form_screen.dart +++ b/client/lib/screens/stock_out/stock_out_form_screen.dart @@ -10,6 +10,7 @@ import '../../providers/product_option_provider.dart'; import '../../providers/product_provider.dart'; import '../../providers/stock_out_provider.dart'; import '../../providers/warehouse_provider.dart'; +import '../../widgets/searchable_option_field.dart'; class StockOutFormScreen extends ConsumerStatefulWidget { final int? editOrderId; @@ -515,24 +516,18 @@ class _StockOutFormScreenState extends ConsumerState { child: asyncNames.when( loading: () => const LinearProgressIndicator(), error: (_, __) => const Text('加载失败'), - data: (names) => DropdownButtonFormField( - value: item.selectedNameId, - hint: const Text('选择名称', style: TextStyle(fontSize: 12)), - isExpanded: true, - items: names - .map((o) => DropdownMenuItem( - value: o.id, - child: Text(o.name, - style: const TextStyle(fontSize: 12), - overflow: TextOverflow.ellipsis))) + data: (names) => SearchableOptionField( + options: names + .map((o) => OptionItem(id: o.id, name: o.name, code: o.code)) .toList(), + selectedId: item.selectedNameId, + hint: '选择名称', + dialogTitle: '选择商品名称', + isRequired: true, onChanged: (v) => setState(() { item.selectedNameId = v; item.productId = null; }), - validator: (v) => v == null ? '请选择' : null, - decoration: const InputDecoration(isDense: true), - style: const TextStyle(fontSize: 12, color: Colors.black87), ), ), ), @@ -542,23 +537,17 @@ class _StockOutFormScreenState extends ConsumerState { child: asyncSeries.when( loading: () => const LinearProgressIndicator(), error: (_, __) => const Text('加载失败'), - data: (series) => DropdownButtonFormField( - value: item.selectedSeriesId, - hint: const Text('选择系列', style: TextStyle(fontSize: 12)), - isExpanded: true, - items: series - .map((o) => DropdownMenuItem( - value: o.id, - child: Text(o.name, - style: const TextStyle(fontSize: 12), - overflow: TextOverflow.ellipsis))) + data: (series) => SearchableOptionField( + options: series + .map((o) => OptionItem(id: o.id, name: o.name, code: o.code)) .toList(), + selectedId: item.selectedSeriesId, + hint: '选择系列', + dialogTitle: '选择系列', onChanged: (v) => setState(() { item.selectedSeriesId = v; item.productId = null; }), - decoration: const InputDecoration(isDense: true), - style: const TextStyle(fontSize: 12, color: Colors.black87), ), ), ), @@ -568,24 +557,18 @@ class _StockOutFormScreenState extends ConsumerState { child: asyncSpecs.when( loading: () => const LinearProgressIndicator(), error: (_, __) => const Text('加载失败'), - data: (specs) => DropdownButtonFormField( - value: item.selectedSpecId, - hint: const Text('选择规格', style: TextStyle(fontSize: 12)), - isExpanded: true, - items: specs - .map((o) => DropdownMenuItem( - value: o.id, - child: Text(o.name, - style: const TextStyle(fontSize: 12), - overflow: TextOverflow.ellipsis))) + data: (specs) => SearchableOptionField( + options: specs + .map((o) => OptionItem(id: o.id, name: o.name, code: o.code)) .toList(), + selectedId: item.selectedSpecId, + hint: '选择规格', + dialogTitle: '选择规格', + isRequired: true, onChanged: (v) => setState(() { item.selectedSpecId = v; item.productId = null; }), - validator: (v) => v == null ? '请选择' : null, - decoration: const InputDecoration(isDense: true), - style: const TextStyle(fontSize: 12, color: Colors.black87), ), ), ), diff --git a/client/lib/widgets/searchable_option_field.dart b/client/lib/widgets/searchable_option_field.dart new file mode 100644 index 0000000..977491e --- /dev/null +++ b/client/lib/widgets/searchable_option_field.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import '../core/theme/app_theme.dart'; + +class OptionItem { + final int id; + final String name; + final String? code; + const OptionItem({required this.id, required this.name, this.code}); +} + +/// 点击后弹出搜索对话框的下拉选择框 +class SearchableOptionField extends StatelessWidget { + final List options; + final int? selectedId; + final String hint; + final String dialogTitle; + final ValueChanged onChanged; + final bool isRequired; + final bool isDense; + + const SearchableOptionField({ + super.key, + required this.options, + required this.selectedId, + required this.hint, + required this.dialogTitle, + required this.onChanged, + this.isRequired = false, + this.isDense = true, + }); + + String get _displayText { + if (selectedId == null) return ''; + return options.where((o) => o.id == selectedId).firstOrNull?.name ?? ''; + } + + Future _openDialog(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (_) => _SearchDialog( + title: dialogTitle, + options: options, + selectedId: selectedId, + ), + ); + // result == -1 means "clear selection" + if (result == -1) { + onChanged(null); + } else if (result != null) { + onChanged(result); + } + } + + @override + Widget build(BuildContext context) { + final selected = selectedId != null; + return FormField( + initialValue: selectedId, + validator: isRequired ? (v) => (selectedId == null ? '请选择' : null) : null, + builder: (state) { + return InkWell( + onTap: () => _openDialog(context), + borderRadius: BorderRadius.circular(4), + child: InputDecorator( + decoration: InputDecoration( + isDense: isDense, + errorText: state.errorText, + suffixIcon: selected + ? GestureDetector( + onTap: () => onChanged(null), + child: const Icon(Icons.close, size: 14, color: AppTheme.textSecondary), + ) + : const Icon(Icons.arrow_drop_down, size: 16, color: AppTheme.textSecondary), + ), + isEmpty: !selected, + child: Text( + selected ? _displayText : hint, + style: TextStyle( + fontSize: 12, + color: selected ? Colors.black87 : AppTheme.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ); + }, + ); + } +} + +class _SearchDialog extends StatefulWidget { + final String title; + final List options; + final int? selectedId; + const _SearchDialog({required this.title, required this.options, this.selectedId}); + + @override + State<_SearchDialog> createState() => _SearchDialogState(); +} + +class _SearchDialogState extends State<_SearchDialog> { + final _ctrl = TextEditingController(); + String _keyword = ''; + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final filtered = _keyword.isEmpty + ? widget.options + : widget.options.where((o) => + o.name.toLowerCase().contains(_keyword.toLowerCase()) || + (o.code?.toLowerCase().contains(_keyword.toLowerCase()) ?? false)).toList(); + + return AlertDialog( + title: Text(widget.title, style: const TextStyle(fontSize: 16)), + contentPadding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + content: SizedBox( + width: 300, + height: 400, + child: Column( + children: [ + TextField( + controller: _ctrl, + autofocus: true, + decoration: const InputDecoration( + hintText: '搜索...', + prefixIcon: Icon(Icons.search, size: 18), + isDense: true, + ), + onChanged: (v) => setState(() => _keyword = v), + ), + const SizedBox(height: 8), + Expanded( + child: filtered.isEmpty + ? const Center( + child: Text('无匹配结果', style: TextStyle(color: AppTheme.textSecondary))) + : ListView.builder( + itemCount: filtered.length, + itemBuilder: (_, i) { + final opt = filtered[i]; + final isSelected = opt.id == widget.selectedId; + return ListTile( + dense: true, + title: Text(opt.name, style: const TextStyle(fontSize: 13)), + subtitle: opt.code != null && opt.code!.isNotEmpty + ? Text(opt.code!, style: const TextStyle(fontSize: 11)) + : null, + selected: isSelected, + selectedTileColor: AppTheme.primary.withValues(alpha: 0.08), + onTap: () => Navigator.of(context).pop(opt.id), + ); + }, + ), + ), + ], + ), + ), + actions: [ + if (widget.selectedId != null) + TextButton( + onPressed: () => Navigator.of(context).pop(-1), + child: const Text('清除选择', style: TextStyle(color: AppTheme.textSecondary)), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(null), + child: const Text('取消'), + ), + ], + ); + } +}