searchable_picker_sheet.dart 15 KB

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