Files
jiu/client/lib/widgets/searchable_option_field.dart
T
2026-06-24 23:12:20 +08:00

330 lines
11 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 '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';
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<OptionItem> options;
final int? selectedId;
final String hint;
final String dialogTitle;
final ValueChanged<int?> onChanged;
final bool isRequired;
final bool isDense;
/// 可选:搜索无匹配时「新增到基础数据」。回调收到当前关键字(用户可在确认框里改),
/// 创建成功返回新选项 id(自动选中),失败/取消返回 null。为空则不显示新增入口。
final Future<int?> Function(String keyword)? onCreate;
/// 可选:服务端搜索。提供时搜索框输入会 debounce 调它取结果(替代本地过滤)。
final Future<List<OptionItem>> 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.onCreate,
this.onSearch,
});
String get _displayText {
if (selectedId == null) return '';
return options.where((o) => o.id == selectedId).firstOrNull?.name ?? '';
}
Future<void> _openDialog(BuildContext context) async {
final result = await showDialog<int?>(
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<int>(
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: Icon(Icons.close,
size: 14, color: context.tokens.muted),
)
: Icon(Icons.arrow_drop_down,
size: 16, color: context.tokens.muted),
),
isEmpty: !selected,
child: Text(
selected ? _displayText : hint,
style: TextStyle(
fontSize: 12,
color: selected ? Colors.black87 : context.tokens.muted,
),
overflow: TextOverflow.ellipsis,
),
),
);
},
);
}
}
class _SearchDialog extends StatefulWidget {
final String title;
final List<OptionItem> options;
final int? selectedId;
final Future<int?> Function(String keyword)? onCreate;
final Future<List<OptionItem>> 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<OptionItem>? _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<void> _createFlow() async {
final kw = _keyword.trim();
if (widget.onCreate == null || kw.isEmpty) return;
final nameCtrl = TextEditingController(text: kw);
final confirmed = await showAppDialog<bool>(
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(labelText: '名称', isDense: true),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('确认新增'),
),
],
),
);
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);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('新增失败,请重试')),
);
}
} catch (e) {
if (!mounted) return;
setState(() => _creating = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('新增失败:$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(Icons.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(Icons.add, size: 16),
label: Text('新增「$kw」到基础数据',
style: const TextStyle(fontSize: 12)),
),
),
],
),
),
actions: [
if (widget.selectedId != null)
TextButton(
onPressed: () => Navigator.of(context).pop(-1),
child: Text('清除选择',
style: TextStyle(color: context.tokens.muted)),
),
TextButton(
onPressed: () => Navigator.of(context).pop(null),
child: const Text('取消'),
),
],
);
}
}