|
|
@@ -0,0 +1,447 @@
|
|
|
+import 'dart:async';
|
|
|
+import 'package:flutter/material.dart';
|
|
|
+import 'package:tdesign_flutter/tdesign_flutter.dart';
|
|
|
+import '../../core/theme/app_colors.dart';
|
|
|
+
|
|
|
+/// 筛选栏 — 单一下拉菜单,内部分组
|
|
|
+///
|
|
|
+/// [FilterGroup] 列表定义各组筛选维度。日期范围组内自动展示「起始日期」「结束日期」两行,
|
|
|
+/// 其余组每行对应一个 [FilterSection],点击弹出 [TDPicker.showMultiPicker] 选择。
|
|
|
+class FilterBar extends StatelessWidget {
|
|
|
+ final List<FilterGroup> groups;
|
|
|
+ final VoidCallback onReset;
|
|
|
+ final VoidCallback onConfirm;
|
|
|
+
|
|
|
+ const FilterBar({
|
|
|
+ super.key,
|
|
|
+ required this.groups,
|
|
|
+ required this.onReset,
|
|
|
+ required this.onConfirm,
|
|
|
+ });
|
|
|
+
|
|
|
+ String get _label {
|
|
|
+ final parts = <String>[];
|
|
|
+ for (final g in groups) {
|
|
|
+ final summary = g.summary;
|
|
|
+ if (summary.isNotEmpty) parts.add(summary);
|
|
|
+ }
|
|
|
+ return parts.isEmpty ? '过滤条件' : parts.join(' · ');
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ Widget build(BuildContext context) {
|
|
|
+ final hasFilter = groups.any((g) => g.summary.isNotEmpty);
|
|
|
+ return Container(
|
|
|
+ padding: const EdgeInsets.only(left: 16),
|
|
|
+ decoration: const BoxDecoration(
|
|
|
+ color: AppColors.bgCard,
|
|
|
+ border: Border(
|
|
|
+ bottom: BorderSide(color: AppColors.border, width: 0.5),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ child: TDDropdownMenu(
|
|
|
+ closeOnClickOverlay: true,
|
|
|
+ isScrollable: true,
|
|
|
+ arrowIcon: TDIcons.caret_down_small,
|
|
|
+ decoration: const BoxDecoration(color: Colors.transparent),
|
|
|
+ labelBuilder: (context, label, isOpened, index) {
|
|
|
+ return TDText(
|
|
|
+ label,
|
|
|
+ font: TDTheme.of(context).fontBodyMedium,
|
|
|
+ textColor: hasFilter
|
|
|
+ ? AppColors.primary
|
|
|
+ : TDTheme.of(context).textColorPrimary,
|
|
|
+ maxLines: 1,
|
|
|
+ overflow: TextOverflow.ellipsis,
|
|
|
+ );
|
|
|
+ },
|
|
|
+ builder: (ctx) {
|
|
|
+ return [
|
|
|
+ TDDropdownItem(
|
|
|
+ label: _label,
|
|
|
+ multiple: true,
|
|
|
+ builder: (itemCtx, itemState, popupState) =>
|
|
|
+ _FilterPanel(
|
|
|
+ groups: groups,
|
|
|
+ onReset: () {
|
|
|
+ onReset();
|
|
|
+ itemState.reset();
|
|
|
+ Navigator.maybePop(itemCtx);
|
|
|
+ },
|
|
|
+ onConfirm: () {
|
|
|
+ onConfirm();
|
|
|
+ Navigator.maybePop(itemCtx);
|
|
|
+ },
|
|
|
+ ),
|
|
|
+ onReset: onReset,
|
|
|
+ ),
|
|
|
+ ];
|
|
|
+ },
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/// 筛选面板
|
|
|
+class _FilterPanel extends StatelessWidget {
|
|
|
+ final List<FilterGroup> groups;
|
|
|
+ final VoidCallback onReset;
|
|
|
+ final VoidCallback onConfirm;
|
|
|
+
|
|
|
+ const _FilterPanel({
|
|
|
+ required this.groups,
|
|
|
+ required this.onReset,
|
|
|
+ required this.onConfirm,
|
|
|
+ });
|
|
|
+
|
|
|
+ @override
|
|
|
+ Widget build(BuildContext context) {
|
|
|
+ return Container(
|
|
|
+ color: Colors.white,
|
|
|
+ child: Column(
|
|
|
+ mainAxisSize: MainAxisSize.min,
|
|
|
+ children: [
|
|
|
+ Flexible(
|
|
|
+ child: SingleChildScrollView(
|
|
|
+ child: Column(
|
|
|
+ mainAxisSize: MainAxisSize.min,
|
|
|
+ children: groups.map((g) => _GroupSection(group: g)).toList(),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ const Divider(height: 1),
|
|
|
+ Padding(
|
|
|
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
|
+ child: Row(
|
|
|
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
+ children: [
|
|
|
+ TDButton(
|
|
|
+ text: '重置',
|
|
|
+ size: TDButtonSize.medium,
|
|
|
+ type: TDButtonType.text,
|
|
|
+ theme: TDButtonTheme.defaultTheme,
|
|
|
+ onTap: onReset,
|
|
|
+ ),
|
|
|
+ TDButton(
|
|
|
+ text: '确定',
|
|
|
+ size: TDButtonSize.medium,
|
|
|
+ type: TDButtonType.text,
|
|
|
+ theme: TDButtonTheme.primary,
|
|
|
+ onTap: onConfirm,
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/// 分组
|
|
|
+class _GroupSection extends StatelessWidget {
|
|
|
+ final FilterGroup group;
|
|
|
+
|
|
|
+ const _GroupSection({required this.group});
|
|
|
+
|
|
|
+ @override
|
|
|
+ Widget build(BuildContext context) {
|
|
|
+ final isDateGroup = group.type == FilterGroupType.dateRange;
|
|
|
+ return Column(
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
+ children: [
|
|
|
+ Padding(
|
|
|
+ padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
|
+ child: Text(
|
|
|
+ group.title,
|
|
|
+ style: const TextStyle(fontSize: 13, color: AppColors.textPlaceholder),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ if (isDateGroup)
|
|
|
+ ...group.sections.map((s) => _DateSectionRow(section: s))
|
|
|
+ else
|
|
|
+ ...group.sections.map((s) => _SectionRow(section: s)),
|
|
|
+ const Divider(height: 1),
|
|
|
+ ],
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/// 日期范围行(起始日期 / 结束日期)
|
|
|
+class _DateSectionRow extends StatelessWidget {
|
|
|
+ final FilterSection section;
|
|
|
+
|
|
|
+ const _DateSectionRow({required this.section});
|
|
|
+
|
|
|
+ Future<void> _pick(BuildContext context, bool isStart) async {
|
|
|
+ // 先关闭下拉面板
|
|
|
+ Navigator.maybePop(context);
|
|
|
+ await Future.delayed(const Duration(milliseconds: 150));
|
|
|
+
|
|
|
+ final initial = isStart
|
|
|
+ ? (section.startDate ?? DateTime.now().subtract(const Duration(days: 30)))
|
|
|
+ : (section.endDate ?? DateTime.now());
|
|
|
+ final now = DateTime.now();
|
|
|
+ DateTime? picked;
|
|
|
+
|
|
|
+ if (!context.mounted) return;
|
|
|
+ await showModalBottomSheet<void>(
|
|
|
+ context: context,
|
|
|
+ backgroundColor: Colors.white,
|
|
|
+ shape: const RoundedRectangleBorder(
|
|
|
+ borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
|
|
+ ),
|
|
|
+ builder: (ctx) {
|
|
|
+ return TDDatePicker(
|
|
|
+ title: isStart ? '选择起始日期' : '选择结束日期',
|
|
|
+ backgroundColor: Colors.white,
|
|
|
+ model: DatePickerModel(
|
|
|
+ useYear: true, useMonth: true, useDay: true,
|
|
|
+ useHour: false, useMinute: false, useSecond: false, useWeekDay: false,
|
|
|
+ dateStart: [2020, 1, 1],
|
|
|
+ dateEnd: [now.year + 1, 12, 31],
|
|
|
+ dateInitial: [initial.year, initial.month, initial.day],
|
|
|
+ ),
|
|
|
+ onConfirm: (selected) {
|
|
|
+ picked = DateTime(
|
|
|
+ selected['year'] ?? initial.year,
|
|
|
+ selected['month'] ?? initial.month,
|
|
|
+ selected['day'] ?? initial.day,
|
|
|
+ );
|
|
|
+ Navigator.of(ctx).pop();
|
|
|
+ },
|
|
|
+ onCancel: (_) => Navigator.of(ctx).pop(),
|
|
|
+ );
|
|
|
+ },
|
|
|
+ );
|
|
|
+
|
|
|
+ if (picked != null && context.mounted) {
|
|
|
+ if (isStart) {
|
|
|
+ section.onStartChanged?.call(picked!);
|
|
|
+ } else {
|
|
|
+ section.onEndChanged?.call(picked!);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ String _fmt(DateTime? dt) {
|
|
|
+ if (dt == null) return '全部';
|
|
|
+ return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ Widget build(BuildContext context) {
|
|
|
+ final label = section.label; // "起始日期" / "结束日期"
|
|
|
+ final date = label == '起始日期' ? section.startDate : section.endDate;
|
|
|
+ return GestureDetector(
|
|
|
+ onTap: () => _pick(context, label == '起始日期'),
|
|
|
+ behavior: HitTestBehavior.opaque,
|
|
|
+ child: Padding(
|
|
|
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
|
+ child: Row(
|
|
|
+ children: [
|
|
|
+ SizedBox(
|
|
|
+ width: 72,
|
|
|
+ child: Text(label,
|
|
|
+ style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
|
|
+ ),
|
|
|
+ const SizedBox(width: 8),
|
|
|
+ Expanded(
|
|
|
+ child: Text(
|
|
|
+ _fmt(date),
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: 14,
|
|
|
+ color: date != null ? AppColors.primary : AppColors.textPlaceholder,
|
|
|
+ ),
|
|
|
+ maxLines: 1,
|
|
|
+ overflow: TextOverflow.ellipsis,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ const Icon(Icons.chevron_right, size: 16, color: AppColors.textPlaceholder),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/// 非日期行(单选/多选),点击弹出 [TDPicker.showMultiPicker]
|
|
|
+class _SectionRow extends StatelessWidget {
|
|
|
+ final FilterSection section;
|
|
|
+
|
|
|
+ const _SectionRow({required this.section});
|
|
|
+
|
|
|
+ Future<void> _handleTap(BuildContext context) async {
|
|
|
+ // 先关闭下拉面板
|
|
|
+ Navigator.maybePop(context);
|
|
|
+ await Future.delayed(const Duration(milliseconds: 150));
|
|
|
+ if (!context.mounted) return;
|
|
|
+
|
|
|
+ final options = section.options;
|
|
|
+ if (options.isEmpty) return;
|
|
|
+
|
|
|
+ final labels = options.map((o) => o.label).toList();
|
|
|
+ final isMulti = section.type == FilterSectionType.multiSelect;
|
|
|
+
|
|
|
+ List<int> initialIndexes = [];
|
|
|
+ if (isMulti) {
|
|
|
+ final selectedSet = Set<String>.from(section.selectedValues ?? []);
|
|
|
+ for (int i = 0; i < options.length; i++) {
|
|
|
+ if (selectedSet.contains(options[i].value)) initialIndexes.add(i);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ final idx = section.selectedValue != null
|
|
|
+ ? options.indexWhere((o) => o.value == section.selectedValue)
|
|
|
+ : -1;
|
|
|
+ if (idx >= 0) initialIndexes = [idx];
|
|
|
+ }
|
|
|
+
|
|
|
+ TDPicker.showMultiPicker(
|
|
|
+ context,
|
|
|
+ title: section.label,
|
|
|
+ data: [labels],
|
|
|
+ initialIndexes: initialIndexes.isNotEmpty ? initialIndexes : null,
|
|
|
+ onConfirm: (selected) {
|
|
|
+ if (isMulti) {
|
|
|
+ final values = <String>[];
|
|
|
+ for (final s in selected) {
|
|
|
+ if (s is int && s >= 0 && s < options.length) {
|
|
|
+ values.add(options[s].value);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ section.onMultiChanged?.call(values);
|
|
|
+ } else {
|
|
|
+ if (selected.isNotEmpty && selected[0] is int) {
|
|
|
+ final idx = selected[0] as int;
|
|
|
+ if (idx >= 0 && idx < options.length) {
|
|
|
+ section.onChanged?.call(options[idx].value);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ Widget build(BuildContext context) {
|
|
|
+ final summary = section.summary;
|
|
|
+ return GestureDetector(
|
|
|
+ onTap: () => _handleTap(context),
|
|
|
+ behavior: HitTestBehavior.opaque,
|
|
|
+ child: Padding(
|
|
|
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
|
+ child: Row(
|
|
|
+ children: [
|
|
|
+ SizedBox(
|
|
|
+ width: 72,
|
|
|
+ child: Text(section.label,
|
|
|
+ style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
|
|
+ ),
|
|
|
+ const SizedBox(width: 8),
|
|
|
+ Expanded(
|
|
|
+ child: Text(
|
|
|
+ summary.isNotEmpty ? summary : '全部',
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: 14,
|
|
|
+ color: summary.isNotEmpty ? AppColors.primary : AppColors.textPlaceholder,
|
|
|
+ ),
|
|
|
+ maxLines: 1,
|
|
|
+ overflow: TextOverflow.ellipsis,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ const Icon(Icons.chevron_right, size: 16, color: AppColors.textPlaceholder),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ============================================
|
|
|
+// 数据模型
|
|
|
+// ============================================
|
|
|
+
|
|
|
+enum FilterSectionType { dateRange, singleSelect, multiSelect }
|
|
|
+enum FilterGroupType { dateRange, other }
|
|
|
+
|
|
|
+class FilterSection {
|
|
|
+ final String label;
|
|
|
+ final FilterSectionType type;
|
|
|
+ final List<FilterOption> options;
|
|
|
+ final String? selectedValue;
|
|
|
+ final List<String>? selectedValues;
|
|
|
+ final ValueChanged<String>? onChanged;
|
|
|
+ final ValueChanged<List<String>>? onMultiChanged;
|
|
|
+ final DateTime? startDate;
|
|
|
+ final DateTime? endDate;
|
|
|
+ final ValueChanged<DateTime>? onStartChanged;
|
|
|
+ final ValueChanged<DateTime>? onEndChanged;
|
|
|
+
|
|
|
+ const FilterSection({
|
|
|
+ required this.label,
|
|
|
+ required this.type,
|
|
|
+ this.options = const [],
|
|
|
+ this.selectedValue,
|
|
|
+ this.selectedValues,
|
|
|
+ this.onChanged,
|
|
|
+ this.onMultiChanged,
|
|
|
+ this.startDate,
|
|
|
+ this.endDate,
|
|
|
+ this.onStartChanged,
|
|
|
+ this.onEndChanged,
|
|
|
+ });
|
|
|
+
|
|
|
+ String get summary {
|
|
|
+ switch (type) {
|
|
|
+ case FilterSectionType.dateRange:
|
|
|
+ // dateRange sections are rendered as two separate rows, each has its own summary
|
|
|
+ if (startDate != null && endDate != null) return '';
|
|
|
+ return '';
|
|
|
+ case FilterSectionType.singleSelect:
|
|
|
+ if (selectedValue == null) return '';
|
|
|
+ final opt = options.where((o) => o.value == selectedValue);
|
|
|
+ return opt.isNotEmpty ? opt.first.label : '';
|
|
|
+ case FilterSectionType.multiSelect:
|
|
|
+ if (selectedValues == null || selectedValues!.isEmpty) return '';
|
|
|
+ final labels = options
|
|
|
+ .where((o) => selectedValues!.contains(o.value))
|
|
|
+ .map((o) => o.label);
|
|
|
+ return labels.join('、');
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+class FilterOption {
|
|
|
+ final String value;
|
|
|
+ final String label;
|
|
|
+
|
|
|
+ const FilterOption({required this.value, required this.label});
|
|
|
+}
|
|
|
+
|
|
|
+class FilterGroup {
|
|
|
+ final String title;
|
|
|
+ final FilterGroupType type;
|
|
|
+ final List<FilterSection> sections;
|
|
|
+
|
|
|
+ const FilterGroup({
|
|
|
+ required this.title,
|
|
|
+ required this.type,
|
|
|
+ required this.sections,
|
|
|
+ });
|
|
|
+
|
|
|
+ String get summary {
|
|
|
+ final parts = <String>[];
|
|
|
+ for (final s in sections) {
|
|
|
+ if (s.type == FilterSectionType.dateRange) {
|
|
|
+ if (s.startDate != null) {
|
|
|
+ parts.add(
|
|
|
+ '${s.startDate!.month}/${s.startDate!.day} ~ ${s.endDate != null ? "${s.endDate!.month}/${s.endDate!.day}" : "..."}');
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ final sSum = s.summary;
|
|
|
+ if (sSum.isNotEmpty) parts.add(sSum);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return parts.join(' ');
|
|
|
+ }
|
|
|
+}
|