import 'dart:async'; import 'package:flutter/material.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import 'package:easy_refresh/easy_refresh.dart'; import '../../core/i18n/app_localizations.dart'; import '../../core/theme/app_colors_extension.dart'; import 'list_footer.dart'; /// 分页数据加载函数签名。 typedef PickerLoader = Future> Function(String keyword, int page); /// 单选弹窗,返回选中项或 null。 Future showSearchablePicker( BuildContext context, { required String title, required PickerLoader loader, required String Function(T item) labelBuilder, String Function(T item)? subtitleBuilder, T? initialSelected, String? searchHint, }) { return Navigator.push( context, TDSlidePopupRoute( slideTransitionFrom: SlideTransitionFrom.bottom, isDismissible: true, builder: (_) => _SearchablePickerSheet( title: title, multiSelect: false, loader: loader, labelBuilder: labelBuilder, subtitleBuilder: subtitleBuilder, initialSelected: initialSelected != null ? [initialSelected] : null, searchHint: searchHint, ), ), ); } /// 多选弹窗,返回选中列表。 Future> showSearchableMultiPicker( BuildContext context, { required String title, required PickerLoader loader, required String Function(T item) labelBuilder, String Function(T item)? subtitleBuilder, List? initialSelected, String? searchHint, }) async { final result = await Navigator.push>( context, TDSlidePopupRoute>( slideTransitionFrom: SlideTransitionFrom.bottom, isDismissible: false, builder: (_) => _SearchablePickerSheet( title: title, multiSelect: true, loader: loader, labelBuilder: labelBuilder, subtitleBuilder: subtitleBuilder, initialSelected: initialSelected, searchHint: searchHint, ), ), ); return result ?? []; } // ═══ 内部实现 ═══ class _SearchablePickerSheet extends StatefulWidget { final String title; final bool multiSelect; final PickerLoader loader; final String Function(T item) labelBuilder; final String Function(T item)? subtitleBuilder; final List? initialSelected; final String? searchHint; const _SearchablePickerSheet({ required this.title, required this.multiSelect, required this.loader, required this.labelBuilder, this.subtitleBuilder, this.initialSelected, this.searchHint, }); @override State<_SearchablePickerSheet> createState() => _SearchablePickerSheetState(); } class _SearchablePickerSheetState extends State<_SearchablePickerSheet> { final _scrollCtrl = ScrollController(); List _items = []; int _page = 1; bool _loading = true; bool _loadingMore = false; bool _hasMore = true; String _keyword = ''; final Set _selected = {}; Timer? _debounce; @override void initState() { super.initState(); if (widget.initialSelected != null) { _selected.addAll(widget.initialSelected!); } _scrollCtrl.addListener(_onScroll); _load(); } @override void dispose() { _debounce?.cancel(); _scrollCtrl.dispose(); super.dispose(); } void _onScroll() { if (_scrollCtrl.position.pixels >= _scrollCtrl.position.maxScrollExtent - 100 && !_loadingMore && _hasMore) { _loadMore(); } } Future _load({bool reset = false}) async { if (reset) { _page = 1; _hasMore = true; } if (_page == 1) setState(() => _loading = true); try { final items = await widget.loader(_keyword, _page); if (!mounted) return; setState(() { if (_page == 1) { _items = items; } else { _items.addAll(items); } _hasMore = items.length >= 20; _loading = false; _loadingMore = false; }); } catch (_) { if (!mounted) return; setState(() { _loading = false; _loadingMore = false; }); } } Future _loadMore() async { if (_loadingMore || !_hasMore) return; _page++; setState(() => _loadingMore = true); await _load(); } void _onSearch(String value) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 300), () { _keyword = value; _page = 1; _items = []; _load(reset: true); }); } void _onItemTap(T item) { setState(() { if (widget.multiSelect) { if (_selected.contains(item)) { _selected.remove(item); } else { _selected.add(item); } } else { // 单选:仅记录选中项,点击「确定」才提交 _selected ..clear() ..add(item); } }); } @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; final screenHeight = MediaQuery.of(context).size.height; return SafeArea( child: Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), child: Material( color: colors.bgCard, clipBehavior: Clip.antiAlias, borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), child: ConstrainedBox( constraints: BoxConstraints(maxHeight: screenHeight * 0.8), child: Container( color: colors.bgPage, child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildHeader(colors), _buildSearchBar(colors), Expanded( child: ColoredBox( color: colors.bgCard, child: _buildList(colors), ), ), _buildFooter(colors), ], ), ), ), ), ), ); } Widget _buildHeader(AppColorsExtension colors) { return Container( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), decoration: BoxDecoration(color: colors.bgCard), child: Column( mainAxisSize: MainAxisSize.min, children: [ Center( child: Container( width: 36, height: 4, margin: const EdgeInsets.only(bottom: 10), decoration: BoxDecoration( color: colors.border, borderRadius: BorderRadius.circular(2), ), ), ), Row( children: [ const SizedBox(width: 22), Expanded( child: Text( widget.title, textAlign: TextAlign.center, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: colors.textPrimary, ), ), ), GestureDetector( onTap: () => Navigator.of(context).pop(), child: Icon(Icons.close, size: 22, color: colors.textSecondary), ), ], ), ], ), ); } Widget _buildSearchBar(AppColorsExtension colors) { return Container( padding: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( color: colors.bgCard, border: Border( bottom: BorderSide(color: colors.border, width: 0.5), ), ), child: TDSearchBar( placeHolder: widget.searchHint ?? AppLocalizations.of(context).get('search'), style: TDSearchStyle.round, onTextChanged: _onSearch, ), ); } Widget _buildList(AppColorsExtension colors) { if (_loading && _items.isEmpty) { return const Center( child: TDLoading( size: TDLoadingSize.medium, icon: TDLoadingIcon.activity, ), ); } return EasyRefresh( header: TDRefreshHeader(), onRefresh: () => _load(reset: true), child: _items.isEmpty ? ListView( children: [ const SizedBox(height: 80), Center( child: Text( AppLocalizations.of(context).get('noData'), style: TextStyle(fontSize: 14, color: colors.textSecondary), ), ), ], ) : ListView.separated( controller: _scrollCtrl, padding: EdgeInsets.zero, itemCount: _items.length + 1, separatorBuilder: (_, _) => Divider(height: 1, thickness: 0.5, color: colors.border), itemBuilder: (context, index) { if (index == _items.length) { if (_loadingMore) { return const Padding( padding: EdgeInsets.all(16), child: Center( child: TDLoading( size: TDLoadingSize.small, icon: TDLoadingIcon.activity, ), ), ); } return ListFooter( itemCount: _items.length, hasMore: _hasMore, ); } final item = _items[index]; final label = widget.labelBuilder(item); final isSelected = _selected.contains(item); return _PickerItemTile( label: label, isSelected: isSelected, isMultiSelect: widget.multiSelect, onTap: () => _onItemTap(item), ); }, ), ); } Widget _buildFooter(AppColorsExtension colors) { final l10n = AppLocalizations.of(context); final selectedText = _selected.map(widget.labelBuilder).join(';'); return Container( padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), decoration: BoxDecoration( color: colors.bgCard, border: Border(top: BorderSide(color: colors.border, width: 0.5)), ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // 已选内容预览:单选/多选统一展示,多选以 ; 分隔,最多两行超出省略 if (selectedText.isNotEmpty) ...[ Text.rich( TextSpan( children: [ TextSpan( text: l10n.get('selectedLabel'), style: TextStyle(color: colors.textSecondary), ), TextSpan( text: selectedText, style: TextStyle(color: colors.primary), ), ], ), maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, height: 1.4), ), const SizedBox(height: 10), ], Row( children: [ Expanded( child: GestureDetector( onTap: () => Navigator.of(context).pop(), child: Container( height: 40, decoration: BoxDecoration( color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(20), ), child: Center( child: Text( l10n.get('cancel'), style: TextStyle( fontSize: 14, color: colors.textSecondary, ), ), ), ), ), ), const SizedBox(width: 12), Expanded( child: GestureDetector( onTap: () { if (widget.multiSelect) { Navigator.of(context).pop(_selected.toList()); } else { Navigator.of(context) .pop(_selected.isEmpty ? null : _selected.first); } }, child: Container( height: 40, decoration: BoxDecoration( color: colors.primary, borderRadius: BorderRadius.circular(20), ), child: Center( child: Text( widget.multiSelect ? '${l10n.get('confirm')}${_selected.isNotEmpty ? "(${_selected.length})" : ""}' : l10n.get('confirm'), style: const TextStyle(fontSize: 14, color: Colors.white), ), ), ), ), ), ], ), ], ), ); } } /// 选择器列表项。 class _PickerItemTile extends StatelessWidget { final String label; final bool isSelected; final bool isMultiSelect; final VoidCallback onTap; const _PickerItemTile({ required this.label, required this.isSelected, required this.isMultiSelect, required this.onTap, }); @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; return GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: Row( children: [ Icon( isMultiSelect ? (isSelected ? TDIcons.check_rectangle_filled : TDIcons.rectangle) : (isSelected ? TDIcons.check_circle_filled : TDIcons.circle), size: 22, color: isSelected ? colors.primary : colors.textPlaceholder, ), const SizedBox(width: 12), Expanded( child: Text( label, style: TextStyle( fontSize: 15, color: isSelected ? colors.primary : colors.textPrimary, fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal, ), ), ), ], ), ), ); } }