import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'dart:async'; import '../core/utils/dialog_util.dart'; import 'package:flutter/material.dart'; import '../core/responsive/responsive.dart'; import 'package:lpinyin/lpinyin.dart'; import '../core/theme/context_tokens.dart'; import 'ds/ds_atoms.dart'; import 'ds/ds_toast.dart'; class OptionItem { final int id; final String name; final String? code; // 拼音索引,构造时预计算 late final String _fullPinyin; late final String _initials; OptionItem({required this.id, required this.name, this.code}) { _fullPinyin = PinyinHelper.getPinyinE(name, separator: '', defPinyin: '') .toLowerCase(); _initials = PinyinHelper.getShortPinyin(name).toLowerCase(); } bool matches(String kw) { final k = kw.toLowerCase(); return name.toLowerCase().contains(k) || (_fullPinyin.isNotEmpty && _fullPinyin.contains(k)) || (_initials.isNotEmpty && _initials.contains(k)) || (code?.toLowerCase().contains(k) ?? false); } } /// 点击后弹出搜索对话框的下拉选择框 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; /// 带框样式(OutlineInputBorder);默认 false 沿用主题下划线。 /// 详细搜索等「表单里与文本框并列」的场景传 true,视觉统一为带框。 final bool bordered; /// 可选:搜索无匹配时「新增到基础数据」。回调收到当前关键字(用户可在确认框里改), /// 创建成功返回新选项 id(自动选中),失败/取消返回 null。为空则不显示新增入口。 final Future Function(String keyword)? onCreate; /// 可选:服务端搜索。提供时搜索框输入会 debounce 调它取结果(替代本地过滤)。 final Future> Function(String keyword)? onSearch; 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, this.bordered = false, this.onCreate, this.onSearch, }); 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, onCreate: onCreate, onSearch: onSearch, ), ); // 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, border: bordered ? const OutlineInputBorder() : null, errorText: state.errorText, suffixIcon: selected ? GestureDetector( onTap: () => onChanged(null), child: Icon(LucideIcons.x, size: 14, color: context.tokens.muted), ) : Icon(LucideIcons.chevronDown, size: 16, color: context.tokens.muted), ), isEmpty: !selected, child: Text( selected ? _displayText : hint, style: TextStyle( fontSize: 12, color: selected ? context.tokens.text : context.tokens.muted, ), overflow: TextOverflow.ellipsis, ), ), ); }, ); } } class _SearchDialog extends StatefulWidget { final String title; final List options; final int? selectedId; final Future Function(String keyword)? onCreate; final Future> Function(String keyword)? onSearch; const _SearchDialog({ required this.title, required this.options, this.selectedId, this.onCreate, this.onSearch, }); @override State<_SearchDialog> createState() => _SearchDialogState(); } class _SearchDialogState extends State<_SearchDialog> { final _ctrl = TextEditingController(); String _keyword = ''; bool _creating = false; List? _serverItems; // 服务端搜索结果(null=尚未搜索,用 widget.options) bool _searching = false; Timer? _debounce; void _onSearchChanged(String v) { setState(() => _keyword = v); if (widget.onSearch == null) return; _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 300), () async { setState(() => _searching = true); try { final res = await widget.onSearch!(v.trim()); if (mounted) setState(() => _serverItems = res); } finally { if (mounted) setState(() => _searching = false); } }); } @override void dispose() { _debounce?.cancel(); _ctrl.dispose(); super.dispose(); } /// 「新增到基础数据」流程:确认(名称可改)→ onCreate → 成功则带新 id 关闭搜索框。 Future _createFlow() async { final kw = _keyword.trim(); if (widget.onCreate == null || kw.isEmpty) return; final nameCtrl = TextEditingController(text: kw); final confirmed = await showAppDialog( context: context, builder: (_) => AlertDialog( title: const Text('新增到基础数据', style: TextStyle(fontSize: 16)), content: SizedBox( width: context.dialogWidth(320), child: TextField( controller: nameCtrl, autofocus: true, decoration: const InputDecoration(hintText: '名称', isDense: true), ), ), actions: [ DsButton('取消', onPressed: () => Navigator.of(context).pop(false)), DsButton('确认新增', variant: DsBtnVariant.primary, onPressed: () => Navigator.of(context).pop(true)), ], ), ); if (confirmed != true) return; final name = nameCtrl.text.trim(); if (name.isEmpty) return; setState(() => _creating = true); try { final newId = await widget.onCreate!(name); if (!mounted) return; if (newId != null) { Navigator.of(context).pop(newId); // 回填并自动选中 } else { setState(() => _creating = false); showDsToast(context, '新增失败,请重试'); } } catch (e) { if (!mounted) return; setState(() => _creating = false); showDsToast(context, '新增失败:$e'); } } @override Widget build(BuildContext context) { final filtered = widget.onSearch != null ? (_serverItems ?? widget.options) : (_keyword.isEmpty ? widget.options : widget.options.where((o) => o.matches(_keyword)).toList()); final kw = _keyword.trim(); final hasExact = filtered.any((o) => o.name.toLowerCase() == kw.toLowerCase()); final canCreate = widget.onCreate != null && kw.isNotEmpty && !hasExact; return AlertDialog( title: Text(widget.title, style: const TextStyle(fontSize: 16)), contentPadding: const EdgeInsets.fromLTRB(16, 12, 16, 0), content: SizedBox( width: context.dialogWidth(300), height: 400, child: Column( children: [ TextField( controller: _ctrl, autofocus: true, decoration: InputDecoration( hintText: '搜索...', prefixIcon: const Icon(LucideIcons.search, size: 18), suffixIcon: _searching ? const Padding( padding: EdgeInsets.all(10), child: SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ), ) : null, isDense: true, ), onChanged: _onSearchChanged, ), const SizedBox(height: 8), Expanded( child: filtered.isEmpty ? Center( child: Text( canCreate ? '无匹配结果,可新增到基础数据' : '无匹配结果', style: TextStyle(color: context.tokens.muted), ), ) : 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: context.tokens.primary.withValues(alpha: 0.08), onTap: () => Navigator.of(context).pop(opt.id), ); }, ), ), if (canCreate) Align( alignment: Alignment.centerLeft, child: TextButton.icon( onPressed: _creating ? null : _createFlow, icon: _creating ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(LucideIcons.plus, size: 16), label: Text('新增「$kw」到基础数据', style: const TextStyle(fontSize: 12)), ), ), ], ), ), actions: [ if (widget.selectedId != null) DsButton('清除选择', onPressed: () => Navigator.of(context).pop(-1)), DsButton('取消', onPressed: () => Navigator.of(context).pop(null)), ], ); } }