| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516 |
- 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<T> = Future<List<T>> Function(String keyword, int page);
- /// 单选弹窗,返回选中项或 null。
- Future<T?> showSearchablePicker<T>(
- BuildContext context, {
- required String title,
- required PickerLoader<T> loader,
- required String Function(T item) labelBuilder,
- String Function(T item)? subtitleBuilder,
- T? initialSelected,
- String? searchHint,
- VoidCallback? onRefresh,
- }) {
- return Navigator.push<T>(
- context,
- TDSlidePopupRoute<T>(
- slideTransitionFrom: SlideTransitionFrom.bottom,
- isDismissible: true,
- builder: (_) => _SearchablePickerSheet<T>(
- title: title,
- multiSelect: false,
- loader: loader,
- labelBuilder: labelBuilder,
- subtitleBuilder: subtitleBuilder,
- initialSelected: initialSelected != null ? [initialSelected] : null,
- searchHint: searchHint,
- onRefresh: onRefresh,
- ),
- ),
- );
- }
- /// 多选弹窗,返回选中列表。
- Future<List<T>> showSearchableMultiPicker<T>(
- BuildContext context, {
- required String title,
- required PickerLoader<T> loader,
- required String Function(T item) labelBuilder,
- String Function(T item)? subtitleBuilder,
- List<T>? initialSelected,
- String? searchHint,
- VoidCallback? onRefresh,
- }) async {
- final result = await Navigator.push<List<T>>(
- context,
- TDSlidePopupRoute<List<T>>(
- slideTransitionFrom: SlideTransitionFrom.bottom,
- isDismissible: false,
- builder: (_) => _SearchablePickerSheet<T>(
- title: title,
- multiSelect: true,
- loader: loader,
- labelBuilder: labelBuilder,
- subtitleBuilder: subtitleBuilder,
- initialSelected: initialSelected,
- searchHint: searchHint,
- onRefresh: onRefresh,
- ),
- ),
- );
- return result ?? [];
- }
- // ═══ 内部实现 ═══
- class _SearchablePickerSheet<T> extends StatefulWidget {
- final String title;
- final bool multiSelect;
- final PickerLoader<T> loader;
- final String Function(T item) labelBuilder;
- final String Function(T item)? subtitleBuilder;
- final List<T>? initialSelected;
- final String? searchHint;
- final VoidCallback? onRefresh;
- const _SearchablePickerSheet({
- required this.title,
- required this.multiSelect,
- required this.loader,
- required this.labelBuilder,
- this.subtitleBuilder,
- this.initialSelected,
- this.searchHint,
- this.onRefresh,
- });
- @override
- State<_SearchablePickerSheet<T>> createState() =>
- _SearchablePickerSheetState<T>();
- }
- class _SearchablePickerSheetState<T> extends State<_SearchablePickerSheet<T>> {
- final _scrollCtrl = ScrollController();
- List<T> _items = [];
- int _page = 1;
- bool _loading = true;
- bool _loadingMore = false;
- bool _hasMore = true;
- String _keyword = '';
- final Set<T> _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<void> _load({bool reset = false}) async {
- if (reset) {
- _page = 1;
- _hasMore = true;
- setState(() => _items = []);
- }
- 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<void> _loadMore() async {
- if (_loadingMore || _loading || !_hasMore) return;
- _page++;
- setState(() => _loadingMore = true);
- await _load();
- }
- void _onSearch(String value) {
- _debounce?.cancel();
- _debounce = Timer(const Duration(milliseconds: 300), () {
- _keyword = value;
- _page = 1;
- setState(() => _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<AppColorsExtension>()!;
- 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: () async {
- widget.onRefresh?.call();
- await _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<AppColorsExtension>()!;
- 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,
- ),
- ),
- ),
- ],
- ),
- ),
- );
- }
- }
|