searchable_picker_sheet.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. import 'dart:async';
  2. import 'package:flutter/material.dart';
  3. import 'package:tdesign_flutter/tdesign_flutter.dart';
  4. import 'package:easy_refresh/easy_refresh.dart';
  5. import '../../core/i18n/app_localizations.dart';
  6. import '../../core/theme/app_colors_extension.dart';
  7. import 'list_footer.dart';
  8. /// 分页数据加载函数签名。
  9. typedef PickerLoader<T> = Future<List<T>> Function(String keyword, int page);
  10. /// 单选弹窗,返回选中项或 null。
  11. Future<T?> showSearchablePicker<T>(
  12. BuildContext context, {
  13. required String title,
  14. required PickerLoader<T> loader,
  15. required String Function(T item) labelBuilder,
  16. String Function(T item)? subtitleBuilder,
  17. T? initialSelected,
  18. String? searchHint,
  19. }) {
  20. return Navigator.push<T>(
  21. context,
  22. TDSlidePopupRoute<T>(
  23. slideTransitionFrom: SlideTransitionFrom.bottom,
  24. isDismissible: true,
  25. builder: (_) => _SearchablePickerSheet<T>(
  26. title: title,
  27. multiSelect: false,
  28. loader: loader,
  29. labelBuilder: labelBuilder,
  30. subtitleBuilder: subtitleBuilder,
  31. initialSelected: initialSelected != null ? [initialSelected] : null,
  32. searchHint: searchHint,
  33. ),
  34. ),
  35. );
  36. }
  37. /// 多选弹窗,返回选中列表。
  38. Future<List<T>> showSearchableMultiPicker<T>(
  39. BuildContext context, {
  40. required String title,
  41. required PickerLoader<T> loader,
  42. required String Function(T item) labelBuilder,
  43. String Function(T item)? subtitleBuilder,
  44. List<T>? initialSelected,
  45. String? searchHint,
  46. }) async {
  47. final result = await Navigator.push<List<T>>(
  48. context,
  49. TDSlidePopupRoute<List<T>>(
  50. slideTransitionFrom: SlideTransitionFrom.bottom,
  51. isDismissible: false,
  52. builder: (_) => _SearchablePickerSheet<T>(
  53. title: title,
  54. multiSelect: true,
  55. loader: loader,
  56. labelBuilder: labelBuilder,
  57. subtitleBuilder: subtitleBuilder,
  58. initialSelected: initialSelected,
  59. searchHint: searchHint,
  60. ),
  61. ),
  62. );
  63. return result ?? [];
  64. }
  65. // ═══ 内部实现 ═══
  66. class _SearchablePickerSheet<T> extends StatefulWidget {
  67. final String title;
  68. final bool multiSelect;
  69. final PickerLoader<T> loader;
  70. final String Function(T item) labelBuilder;
  71. final String Function(T item)? subtitleBuilder;
  72. final List<T>? initialSelected;
  73. final String? searchHint;
  74. const _SearchablePickerSheet({
  75. required this.title,
  76. required this.multiSelect,
  77. required this.loader,
  78. required this.labelBuilder,
  79. this.subtitleBuilder,
  80. this.initialSelected,
  81. this.searchHint,
  82. });
  83. @override
  84. State<_SearchablePickerSheet<T>> createState() =>
  85. _SearchablePickerSheetState<T>();
  86. }
  87. class _SearchablePickerSheetState<T> extends State<_SearchablePickerSheet<T>> {
  88. final _scrollCtrl = ScrollController();
  89. List<T> _items = [];
  90. int _page = 1;
  91. bool _loading = true;
  92. bool _loadingMore = false;
  93. bool _hasMore = true;
  94. String _keyword = '';
  95. final Set<T> _selected = {};
  96. Timer? _debounce;
  97. @override
  98. void initState() {
  99. super.initState();
  100. if (widget.initialSelected != null) {
  101. _selected.addAll(widget.initialSelected!);
  102. }
  103. _scrollCtrl.addListener(_onScroll);
  104. _load();
  105. }
  106. @override
  107. void dispose() {
  108. _debounce?.cancel();
  109. _scrollCtrl.dispose();
  110. super.dispose();
  111. }
  112. void _onScroll() {
  113. if (_scrollCtrl.position.pixels >=
  114. _scrollCtrl.position.maxScrollExtent - 100 &&
  115. !_loadingMore &&
  116. _hasMore) {
  117. _loadMore();
  118. }
  119. }
  120. Future<void> _load({bool reset = false}) async {
  121. if (reset) {
  122. _page = 1;
  123. _hasMore = true;
  124. }
  125. if (_page == 1) setState(() => _loading = true);
  126. try {
  127. final items = await widget.loader(_keyword, _page);
  128. if (!mounted) return;
  129. setState(() {
  130. if (_page == 1) {
  131. _items = items;
  132. } else {
  133. _items.addAll(items);
  134. }
  135. _hasMore = items.length >= 20;
  136. _loading = false;
  137. _loadingMore = false;
  138. });
  139. } catch (_) {
  140. if (!mounted) return;
  141. setState(() {
  142. _loading = false;
  143. _loadingMore = false;
  144. });
  145. }
  146. }
  147. Future<void> _loadMore() async {
  148. if (_loadingMore || !_hasMore) return;
  149. _page++;
  150. setState(() => _loadingMore = true);
  151. await _load();
  152. }
  153. void _onSearch(String value) {
  154. _debounce?.cancel();
  155. _debounce = Timer(const Duration(milliseconds: 300), () {
  156. _keyword = value;
  157. _page = 1;
  158. _items = [];
  159. _load(reset: true);
  160. });
  161. }
  162. void _onItemTap(T item) {
  163. setState(() {
  164. if (widget.multiSelect) {
  165. if (_selected.contains(item)) {
  166. _selected.remove(item);
  167. } else {
  168. _selected.add(item);
  169. }
  170. } else {
  171. // 单选:仅记录选中项,点击「确定」才提交
  172. _selected
  173. ..clear()
  174. ..add(item);
  175. }
  176. });
  177. }
  178. @override
  179. Widget build(BuildContext context) {
  180. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  181. final screenHeight = MediaQuery.of(context).size.height;
  182. return SafeArea(
  183. child: Padding(
  184. padding: EdgeInsets.only(
  185. bottom: MediaQuery.of(context).viewInsets.bottom,
  186. ),
  187. child: Material(
  188. color: colors.bgCard,
  189. clipBehavior: Clip.antiAlias,
  190. borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
  191. child: ConstrainedBox(
  192. constraints: BoxConstraints(maxHeight: screenHeight * 0.8),
  193. child: Container(
  194. color: colors.bgPage,
  195. child: Column(
  196. mainAxisSize: MainAxisSize.min,
  197. children: [
  198. _buildHeader(colors),
  199. _buildSearchBar(colors),
  200. Expanded(
  201. child: ColoredBox(
  202. color: colors.bgCard,
  203. child: _buildList(colors),
  204. ),
  205. ),
  206. _buildFooter(colors),
  207. ],
  208. ),
  209. ),
  210. ),
  211. ),
  212. ),
  213. );
  214. }
  215. Widget _buildHeader(AppColorsExtension colors) {
  216. return Container(
  217. padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
  218. decoration: BoxDecoration(color: colors.bgCard),
  219. child: Column(
  220. mainAxisSize: MainAxisSize.min,
  221. children: [
  222. Center(
  223. child: Container(
  224. width: 36,
  225. height: 4,
  226. margin: const EdgeInsets.only(bottom: 10),
  227. decoration: BoxDecoration(
  228. color: colors.border,
  229. borderRadius: BorderRadius.circular(2),
  230. ),
  231. ),
  232. ),
  233. Row(
  234. children: [
  235. const SizedBox(width: 22),
  236. Expanded(
  237. child: Text(
  238. widget.title,
  239. textAlign: TextAlign.center,
  240. style: TextStyle(
  241. fontSize: 16,
  242. fontWeight: FontWeight.w600,
  243. color: colors.textPrimary,
  244. ),
  245. ),
  246. ),
  247. GestureDetector(
  248. onTap: () => Navigator.of(context).pop(),
  249. child: Icon(Icons.close, size: 22, color: colors.textSecondary),
  250. ),
  251. ],
  252. ),
  253. ],
  254. ),
  255. );
  256. }
  257. Widget _buildSearchBar(AppColorsExtension colors) {
  258. return Container(
  259. padding: const EdgeInsets.only(bottom: 8),
  260. decoration: BoxDecoration(
  261. color: colors.bgCard,
  262. border: Border(
  263. bottom: BorderSide(color: colors.border, width: 0.5),
  264. ),
  265. ),
  266. child: TDSearchBar(
  267. placeHolder:
  268. widget.searchHint ?? AppLocalizations.of(context).get('search'),
  269. style: TDSearchStyle.round,
  270. onTextChanged: _onSearch,
  271. ),
  272. );
  273. }
  274. Widget _buildList(AppColorsExtension colors) {
  275. if (_loading && _items.isEmpty) {
  276. return const Center(
  277. child: TDLoading(
  278. size: TDLoadingSize.medium,
  279. icon: TDLoadingIcon.activity,
  280. ),
  281. );
  282. }
  283. return EasyRefresh(
  284. header: TDRefreshHeader(),
  285. onRefresh: () => _load(reset: true),
  286. child: _items.isEmpty
  287. ? ListView(
  288. children: [
  289. const SizedBox(height: 80),
  290. Center(
  291. child: Text(
  292. AppLocalizations.of(context).get('noData'),
  293. style:
  294. TextStyle(fontSize: 14, color: colors.textSecondary),
  295. ),
  296. ),
  297. ],
  298. )
  299. : ListView.separated(
  300. controller: _scrollCtrl,
  301. padding: EdgeInsets.zero,
  302. itemCount: _items.length + 1,
  303. separatorBuilder: (_, _) =>
  304. Divider(height: 1, thickness: 0.5, color: colors.border),
  305. itemBuilder: (context, index) {
  306. if (index == _items.length) {
  307. if (_loadingMore) {
  308. return const Padding(
  309. padding: EdgeInsets.all(16),
  310. child: Center(
  311. child: TDLoading(
  312. size: TDLoadingSize.small,
  313. icon: TDLoadingIcon.activity,
  314. ),
  315. ),
  316. );
  317. }
  318. return ListFooter(
  319. itemCount: _items.length,
  320. hasMore: _hasMore,
  321. );
  322. }
  323. final item = _items[index];
  324. final label = widget.labelBuilder(item);
  325. final isSelected = _selected.contains(item);
  326. return _PickerItemTile(
  327. label: label,
  328. isSelected: isSelected,
  329. isMultiSelect: widget.multiSelect,
  330. onTap: () => _onItemTap(item),
  331. );
  332. },
  333. ),
  334. );
  335. }
  336. Widget _buildFooter(AppColorsExtension colors) {
  337. final l10n = AppLocalizations.of(context);
  338. final selectedText = _selected.map(widget.labelBuilder).join(';');
  339. return Container(
  340. padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
  341. decoration: BoxDecoration(
  342. color: colors.bgCard,
  343. border: Border(top: BorderSide(color: colors.border, width: 0.5)),
  344. ),
  345. child: Column(
  346. mainAxisSize: MainAxisSize.min,
  347. crossAxisAlignment: CrossAxisAlignment.start,
  348. children: [
  349. // 已选内容预览:单选/多选统一展示,多选以 ; 分隔,最多两行超出省略
  350. if (selectedText.isNotEmpty) ...[
  351. Text.rich(
  352. TextSpan(
  353. children: [
  354. TextSpan(
  355. text: l10n.get('selectedLabel'),
  356. style: TextStyle(color: colors.textSecondary),
  357. ),
  358. TextSpan(
  359. text: selectedText,
  360. style: TextStyle(color: colors.primary),
  361. ),
  362. ],
  363. ),
  364. maxLines: 2,
  365. overflow: TextOverflow.ellipsis,
  366. style: const TextStyle(fontSize: 13, height: 1.4),
  367. ),
  368. const SizedBox(height: 10),
  369. ],
  370. Row(
  371. children: [
  372. Expanded(
  373. child: GestureDetector(
  374. onTap: () => Navigator.of(context).pop(),
  375. child: Container(
  376. height: 40,
  377. decoration: BoxDecoration(
  378. color: colors.bgSecondaryContainer,
  379. borderRadius: BorderRadius.circular(20),
  380. ),
  381. child: Center(
  382. child: Text(
  383. l10n.get('cancel'),
  384. style: TextStyle(
  385. fontSize: 14,
  386. color: colors.textSecondary,
  387. ),
  388. ),
  389. ),
  390. ),
  391. ),
  392. ),
  393. const SizedBox(width: 12),
  394. Expanded(
  395. child: GestureDetector(
  396. onTap: () {
  397. if (widget.multiSelect) {
  398. Navigator.of(context).pop(_selected.toList());
  399. } else {
  400. Navigator.of(context)
  401. .pop(_selected.isEmpty ? null : _selected.first);
  402. }
  403. },
  404. child: Container(
  405. height: 40,
  406. decoration: BoxDecoration(
  407. color: colors.primary,
  408. borderRadius: BorderRadius.circular(20),
  409. ),
  410. child: Center(
  411. child: Text(
  412. widget.multiSelect
  413. ? '${l10n.get('confirm')}${_selected.isNotEmpty ? "(${_selected.length})" : ""}'
  414. : l10n.get('confirm'),
  415. style:
  416. const TextStyle(fontSize: 14, color: Colors.white),
  417. ),
  418. ),
  419. ),
  420. ),
  421. ),
  422. ],
  423. ),
  424. ],
  425. ),
  426. );
  427. }
  428. }
  429. /// 选择器列表项。
  430. class _PickerItemTile extends StatelessWidget {
  431. final String label;
  432. final bool isSelected;
  433. final bool isMultiSelect;
  434. final VoidCallback onTap;
  435. const _PickerItemTile({
  436. required this.label,
  437. required this.isSelected,
  438. required this.isMultiSelect,
  439. required this.onTap,
  440. });
  441. @override
  442. Widget build(BuildContext context) {
  443. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  444. return GestureDetector(
  445. onTap: onTap,
  446. behavior: HitTestBehavior.opaque,
  447. child: Padding(
  448. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
  449. child: Row(
  450. children: [
  451. Icon(
  452. isMultiSelect
  453. ? (isSelected
  454. ? TDIcons.check_rectangle_filled
  455. : TDIcons.rectangle)
  456. : (isSelected ? TDIcons.check_circle_filled : TDIcons.circle),
  457. size: 22,
  458. color: isSelected ? colors.primary : colors.textPlaceholder,
  459. ),
  460. const SizedBox(width: 12),
  461. Expanded(
  462. child: Text(
  463. label,
  464. style: TextStyle(
  465. fontSize: 15,
  466. color: isSelected ? colors.primary : colors.textPrimary,
  467. fontWeight:
  468. isSelected ? FontWeight.w500 : FontWeight.normal,
  469. ),
  470. ),
  471. ),
  472. ],
  473. ),
  474. ),
  475. );
  476. }
  477. }