|
|
@@ -1,39 +1,100 @@
|
|
|
import 'package:flutter/material.dart';
|
|
|
import 'package:tdesign_flutter/tdesign_flutter.dart';
|
|
|
import '../../core/theme/app_colors_extension.dart';
|
|
|
+import '../../core/i18n/app_localizations.dart';
|
|
|
|
|
|
-/// 筛选弹出面板辅助类
|
|
|
+/// 列表页筛选面板,从右侧滑出。
|
|
|
///
|
|
|
-/// 调用 [FilterBar.show] 从右侧弹出筛选面板。
|
|
|
+/// ## 调用方式
|
|
|
///
|
|
|
-/// [FilterGroup] 列表定义各组筛选维度。日期范围组内展示「起始日期」「结束日期」行,
|
|
|
-/// 点击弹出 [TDDatePicker];其余组点击弹出 [TDPicker.showMultiPicker]。
|
|
|
-class FilterBar {
|
|
|
- FilterBar._();
|
|
|
+/// ```dart
|
|
|
+/// // 1. 声明筛选值的 provider
|
|
|
+/// final dateStart = ref.watch(expenseDateStartProvider);
|
|
|
+/// final dateEnd = ref.watch(expenseDateEndProvider);
|
|
|
+///
|
|
|
+/// // 2. 构建 FilterGroup 列表
|
|
|
+/// final filterGroups = [
|
|
|
+/// FilterGroup(
|
|
|
+/// title: '日期范围',
|
|
|
+/// type: FilterGroupType.dateRange,
|
|
|
+/// sections: [
|
|
|
+/// FilterSection(
|
|
|
+/// label: '日期范围',
|
|
|
+/// type: FilterSectionType.dateRange,
|
|
|
+/// startDate: dateStart, endDate: dateEnd,
|
|
|
+/// onStartChanged: (v) => ref.read(startProvider.notifier).state = v,
|
|
|
+/// onEndChanged: (v) => ref.read(endProvider.notifier).state = v,
|
|
|
+/// ),
|
|
|
+/// ],
|
|
|
+/// ),
|
|
|
+/// ];
|
|
|
+///
|
|
|
+/// // 3. 维护辅助变量
|
|
|
+/// final hasFilter = ListFilterPanel.hasActiveFilter(filterGroups);
|
|
|
+/// final filterVersion = Object.hash(dateStart, dateEnd);
|
|
|
+/// void onReset() { /* 清空所有筛选 provider */ }
|
|
|
+///
|
|
|
+/// // 4. 调用 show + 更新 NavBarConfig
|
|
|
+/// ListFilterPanel.show(context,
|
|
|
+/// groups: filterGroups,
|
|
|
+/// onReset: onReset,
|
|
|
+/// onConfirm: () {},
|
|
|
+/// defaultStartDate: DateTime(year, month, 1),
|
|
|
+/// defaultEndDate: DateTime(year, month, day),
|
|
|
+/// );
|
|
|
+/// ```
|
|
|
+///
|
|
|
+/// ## 新增筛选条件
|
|
|
+///
|
|
|
+/// ① 声明 provider → ② 在 `filterGroups` 中加 `FilterSection`
|
|
|
+/// → ③ 更新 `filterVersion = Object.hash(...)` 加入新值
|
|
|
+/// → ④ 更新 `onReset` 清空新 provider
|
|
|
+/// → ⑤ 列表请求逻辑接入新 provider。
|
|
|
+///
|
|
|
+/// `FilterSection.type` 支持 [FilterSectionType.dateRange](日期)、
|
|
|
+/// [FilterSectionType.singleSelect](单选)、[FilterSectionType.multiSelect](多选)。
|
|
|
+class ListFilterPanel {
|
|
|
+ ListFilterPanel._();
|
|
|
+
|
|
|
+ static TDDrawer? _currentDrawer;
|
|
|
|
|
|
/// 从右侧弹出筛选面板
|
|
|
+ ///
|
|
|
+ /// [defaultStartDate]/[defaultEndDate] 可选默认日期范围,
|
|
|
+ /// 当 [FilterSection] 未提供日期值时作为初始值。
|
|
|
static void show(
|
|
|
BuildContext context, {
|
|
|
required List<FilterGroup> groups,
|
|
|
required VoidCallback onReset,
|
|
|
required VoidCallback onConfirm,
|
|
|
+ DateTime? defaultStartDate,
|
|
|
+ DateTime? defaultEndDate,
|
|
|
}) {
|
|
|
- final screenWidth = MediaQuery.of(context).size.width;
|
|
|
- const panelWidth = 300.0;
|
|
|
- Navigator.of(context).push(
|
|
|
- TDSlidePopupRoute(
|
|
|
- slideTransitionFrom: SlideTransitionFrom.right,
|
|
|
- modalWidth: panelWidth,
|
|
|
- modalLeft: screenWidth - panelWidth,
|
|
|
- modalTop: 0,
|
|
|
- modalBarrierFull: true,
|
|
|
- builder: (ctx) => _FilterPopup(
|
|
|
- groups: groups,
|
|
|
- onReset: onReset,
|
|
|
- onConfirm: onConfirm,
|
|
|
- ),
|
|
|
+ _currentDrawer?.close();
|
|
|
+
|
|
|
+ final drawer = TDDrawer(
|
|
|
+ context,
|
|
|
+ width: 300,
|
|
|
+ placement: TDDrawerPlacement.right,
|
|
|
+ closeOnOverlayClick: true,
|
|
|
+ showOverlay: true,
|
|
|
+ onClose: () {
|
|
|
+ _currentDrawer = null;
|
|
|
+ },
|
|
|
+ contentWidget: _FilterPopup(
|
|
|
+ groups: groups,
|
|
|
+ onReset: onReset,
|
|
|
+ onConfirm: onConfirm,
|
|
|
+ defaultStartDate: defaultStartDate,
|
|
|
+ defaultEndDate: defaultEndDate,
|
|
|
+ onClose: () {
|
|
|
+ _currentDrawer?.close();
|
|
|
+ _currentDrawer = null;
|
|
|
+ },
|
|
|
),
|
|
|
);
|
|
|
+ _currentDrawer = drawer;
|
|
|
+ drawer.show();
|
|
|
}
|
|
|
|
|
|
/// 是否有筛选条件激活(给 NavBar 红点使用)
|
|
|
@@ -52,8 +113,8 @@ class _SectionValue {
|
|
|
final List<FilterOption> options;
|
|
|
final ValueChanged<String>? onChanged;
|
|
|
final ValueChanged<List<String>>? onMultiChanged;
|
|
|
- final ValueChanged<DateTime>? onStartChanged;
|
|
|
- final ValueChanged<DateTime>? onEndChanged;
|
|
|
+ final ValueChanged<DateTime?>? onStartChanged;
|
|
|
+ final ValueChanged<DateTime?>? onEndChanged;
|
|
|
|
|
|
DateTime? startDate;
|
|
|
DateTime? endDate;
|
|
|
@@ -74,19 +135,15 @@ class _SectionValue {
|
|
|
this.selectedValues,
|
|
|
});
|
|
|
|
|
|
- /// 将本地值回写到原始回调(仅非空值)
|
|
|
+ /// 将本地值回写到原始回调(包括 null/空值,用于清除筛选)
|
|
|
void apply() {
|
|
|
if (type == FilterSectionType.dateRange) {
|
|
|
- if (startDate != null) onStartChanged?.call(startDate!);
|
|
|
- if (endDate != null) onEndChanged?.call(endDate!);
|
|
|
+ onStartChanged?.call(startDate);
|
|
|
+ onEndChanged?.call(endDate);
|
|
|
} else if (type == FilterSectionType.singleSelect) {
|
|
|
- if (selectedValue != null && selectedValue!.isNotEmpty) {
|
|
|
- onChanged?.call(selectedValue!);
|
|
|
- }
|
|
|
+ onChanged?.call(selectedValue ?? '');
|
|
|
} else if (type == FilterSectionType.multiSelect) {
|
|
|
- if (selectedValues != null && selectedValues!.isNotEmpty) {
|
|
|
- onMultiChanged?.call(selectedValues!);
|
|
|
- }
|
|
|
+ onMultiChanged?.call(selectedValues ?? []);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -107,11 +164,17 @@ class _FilterPopup extends StatefulWidget {
|
|
|
final List<FilterGroup> groups;
|
|
|
final VoidCallback onReset;
|
|
|
final VoidCallback onConfirm;
|
|
|
+ final VoidCallback? onClose;
|
|
|
+ final DateTime? defaultStartDate;
|
|
|
+ final DateTime? defaultEndDate;
|
|
|
|
|
|
const _FilterPopup({
|
|
|
required this.groups,
|
|
|
required this.onReset,
|
|
|
required this.onConfirm,
|
|
|
+ this.onClose,
|
|
|
+ this.defaultStartDate,
|
|
|
+ this.defaultEndDate,
|
|
|
});
|
|
|
|
|
|
@override
|
|
|
@@ -136,8 +199,8 @@ class _FilterPopupState extends State<_FilterPopup> {
|
|
|
onMultiChanged: s.onMultiChanged,
|
|
|
onStartChanged: s.onStartChanged,
|
|
|
onEndChanged: s.onEndChanged,
|
|
|
- startDate: s.startDate,
|
|
|
- endDate: s.endDate,
|
|
|
+ startDate: s.startDate ?? widget.defaultStartDate,
|
|
|
+ endDate: s.endDate ?? widget.defaultEndDate,
|
|
|
selectedValue: s.selectedValue,
|
|
|
selectedValues: s.selectedValues,
|
|
|
),
|
|
|
@@ -147,121 +210,132 @@ class _FilterPopupState extends State<_FilterPopup> {
|
|
|
}
|
|
|
|
|
|
void _applyAll() {
|
|
|
- final pending = List<_SectionValue>.from(_values);
|
|
|
- final onConfirm = widget.onConfirm;
|
|
|
- Navigator.of(context).pop();
|
|
|
- Future.delayed(const Duration(milliseconds: 300), () {
|
|
|
- for (final v in pending) {
|
|
|
- v.apply();
|
|
|
- }
|
|
|
- onConfirm();
|
|
|
- });
|
|
|
+ for (final v in _values) {
|
|
|
+ v.apply();
|
|
|
+ }
|
|
|
+ final onClose = widget.onClose;
|
|
|
+ if (onClose != null) {
|
|
|
+ onClose();
|
|
|
+ } else {
|
|
|
+ Navigator.of(context).pop();
|
|
|
+ }
|
|
|
+ widget.onConfirm();
|
|
|
}
|
|
|
|
|
|
void _resetAll() {
|
|
|
for (final v in _values) {
|
|
|
v.reset();
|
|
|
}
|
|
|
- widget.onReset();
|
|
|
setState(() {});
|
|
|
}
|
|
|
|
|
|
@override
|
|
|
Widget build(BuildContext context) {
|
|
|
final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
- return Container(
|
|
|
- color: TDTheme.of(context).bgColorContainer,
|
|
|
- child: SafeArea(
|
|
|
- child: Column(
|
|
|
- crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
- children: [
|
|
|
- // 标题栏
|
|
|
- Padding(
|
|
|
- padding: const EdgeInsets.fromLTRB(16, 28, 16, 14),
|
|
|
- child: Row(
|
|
|
- children: [
|
|
|
- Icon(
|
|
|
- TDIcons.filter_filled,
|
|
|
- size: 20,
|
|
|
- color: colors.primary,
|
|
|
- ),
|
|
|
- const SizedBox(width: 8),
|
|
|
- Expanded(
|
|
|
- child: Text(
|
|
|
- '过滤条件',
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 18,
|
|
|
- fontWeight: FontWeight.w600,
|
|
|
- color: colors.textPrimary,
|
|
|
+ final l10n = AppLocalizations.of(context);
|
|
|
+ return LayoutBuilder(
|
|
|
+ builder: (context, constraints) => Container(
|
|
|
+ color: TDTheme.of(context).bgColorContainer,
|
|
|
+ child: SafeArea(
|
|
|
+ child: SizedBox(
|
|
|
+ height: constraints.maxHeight,
|
|
|
+ child: Column(
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
+ children: [
|
|
|
+ // 标题栏
|
|
|
+ Padding(
|
|
|
+ padding: const EdgeInsets.fromLTRB(16, 28, 16, 14),
|
|
|
+ child: Row(
|
|
|
+ children: [
|
|
|
+ Icon(TDIcons.filter, size: 20, color: colors.textPrimary),
|
|
|
+ const SizedBox(width: 8),
|
|
|
+ Expanded(
|
|
|
+ child: Text(
|
|
|
+ l10n.get('filterTitle'),
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: 18,
|
|
|
+ fontWeight: FontWeight.w600,
|
|
|
+ color: colors.textPrimary,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
),
|
|
|
- ),
|
|
|
+ GestureDetector(
|
|
|
+ onTap: () {
|
|
|
+ final onClose = widget.onClose;
|
|
|
+ if (onClose != null) {
|
|
|
+ onClose();
|
|
|
+ } else {
|
|
|
+ Navigator.pop(context);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ child: Icon(
|
|
|
+ TDIcons.close,
|
|
|
+ size: 22,
|
|
|
+ color: colors.textSecondary,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
),
|
|
|
- GestureDetector(
|
|
|
- onTap: () => Navigator.pop(context),
|
|
|
- child: Icon(
|
|
|
- TDIcons.close,
|
|
|
- size: 22,
|
|
|
- color: colors.textSecondary,
|
|
|
+ ),
|
|
|
+
|
|
|
+ // 筛选项区域
|
|
|
+ Expanded(
|
|
|
+ child: SingleChildScrollView(
|
|
|
+ child: Column(
|
|
|
+ children: [
|
|
|
+ for (final g in widget.groups)
|
|
|
+ _GroupSection(
|
|
|
+ group: g,
|
|
|
+ values: _valuesForGroup(g),
|
|
|
+ onValuesChanged: () => setState(() {}),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
),
|
|
|
),
|
|
|
- ],
|
|
|
- ),
|
|
|
- ),
|
|
|
-
|
|
|
- // 筛选项区域
|
|
|
- Expanded(
|
|
|
- child: SingleChildScrollView(
|
|
|
- child: Column(
|
|
|
- children: [
|
|
|
- for (final g in widget.groups)
|
|
|
- _GroupSection(
|
|
|
- group: g,
|
|
|
- values: _valuesForGroup(g),
|
|
|
- onValuesChanged: () => setState(() {}),
|
|
|
- ),
|
|
|
- ],
|
|
|
),
|
|
|
- ),
|
|
|
- ),
|
|
|
|
|
|
- // 底部按钮
|
|
|
- Padding(
|
|
|
- padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
|
|
- child: Row(
|
|
|
- children: [
|
|
|
- Expanded(
|
|
|
- child: TDButton(
|
|
|
- text: '重置',
|
|
|
- size: TDButtonSize.medium,
|
|
|
- type: TDButtonType.outline,
|
|
|
- shape: TDButtonShape.rectangle,
|
|
|
- theme: TDButtonTheme.defaultTheme,
|
|
|
- style: TDButtonStyle(
|
|
|
- frameColor: colors.textPlaceholder,
|
|
|
- textColor: colors.textPlaceholder,
|
|
|
+ // 底部按钮
|
|
|
+ Padding(
|
|
|
+ padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
|
|
+ child: Row(
|
|
|
+ children: [
|
|
|
+ Expanded(
|
|
|
+ child: TDButton(
|
|
|
+ text: l10n.get('reset'),
|
|
|
+ size: TDButtonSize.medium,
|
|
|
+ type: TDButtonType.outline,
|
|
|
+ shape: TDButtonShape.rectangle,
|
|
|
+ theme: TDButtonTheme.defaultTheme,
|
|
|
+ style: TDButtonStyle(
|
|
|
+ frameColor: colors.textSecondary,
|
|
|
+ frameWidth: 1,
|
|
|
+ textColor: colors.textSecondary,
|
|
|
+ backgroundColor: Colors.transparent,
|
|
|
+ ),
|
|
|
+ onTap: _resetAll,
|
|
|
+ ),
|
|
|
),
|
|
|
- onTap: _resetAll,
|
|
|
- ),
|
|
|
- ),
|
|
|
- const SizedBox(width: 12),
|
|
|
- Expanded(
|
|
|
- child: TDButton(
|
|
|
- text: '确定',
|
|
|
- size: TDButtonSize.medium,
|
|
|
- type: TDButtonType.fill,
|
|
|
- shape: TDButtonShape.rectangle,
|
|
|
- theme: TDButtonTheme.primary,
|
|
|
- style: TDButtonStyle(
|
|
|
- backgroundColor: colors.primary,
|
|
|
- textColor: colors.bgCard,
|
|
|
+ const SizedBox(width: 12),
|
|
|
+ Expanded(
|
|
|
+ child: TDButton(
|
|
|
+ text: l10n.get('confirm'),
|
|
|
+ size: TDButtonSize.medium,
|
|
|
+ type: TDButtonType.fill,
|
|
|
+ shape: TDButtonShape.rectangle,
|
|
|
+ theme: TDButtonTheme.primary,
|
|
|
+ style: TDButtonStyle(
|
|
|
+ backgroundColor: colors.primary,
|
|
|
+ textColor: colors.bgCard,
|
|
|
+ ),
|
|
|
+ onTap: _applyAll,
|
|
|
+ ),
|
|
|
),
|
|
|
- onTap: _applyAll,
|
|
|
- ),
|
|
|
+ ],
|
|
|
),
|
|
|
- ],
|
|
|
- ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
),
|
|
|
- ],
|
|
|
+ ),
|
|
|
),
|
|
|
),
|
|
|
);
|
|
|
@@ -292,11 +366,8 @@ class _GroupSection extends StatelessWidget {
|
|
|
void _applyQuickDate(DateTime start, DateTime end) {
|
|
|
for (final v in values) {
|
|
|
if (v.type == FilterSectionType.dateRange) {
|
|
|
- if (v.label == '起始日期') {
|
|
|
- v.startDate = start;
|
|
|
- } else if (v.label == '结束日期') {
|
|
|
- v.endDate = end;
|
|
|
- }
|
|
|
+ v.startDate = start;
|
|
|
+ v.endDate = end;
|
|
|
}
|
|
|
}
|
|
|
onValuesChanged();
|
|
|
@@ -305,9 +376,21 @@ class _GroupSection extends StatelessWidget {
|
|
|
@override
|
|
|
Widget build(BuildContext context) {
|
|
|
final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
+ final l10n = AppLocalizations.of(context);
|
|
|
final isDateGroup = group.type == FilterGroupType.dateRange;
|
|
|
final now = DateTime.now();
|
|
|
final today = DateTime(now.year, now.month, now.day);
|
|
|
+ final monthEnd = DateTime(today.year, today.month + 1, 0);
|
|
|
+ final quarterEndMonth = ((today.month - 1) ~/ 3 + 1) * 3;
|
|
|
+ final quarterEnd = DateTime(today.year, quarterEndMonth + 1, 0);
|
|
|
+ final yearEnd = DateTime(today.year, 12, 31);
|
|
|
+ final weekStart = today.subtract(Duration(days: today.weekday - 1));
|
|
|
+ final weekEnd = today.add(
|
|
|
+ Duration(days: DateTime.daysPerWeek - today.weekday),
|
|
|
+ );
|
|
|
+ final lastMonthStart = DateTime(today.year, today.month - 1, 1);
|
|
|
+ final lastMonthEnd = DateTime(today.year, today.month, 0);
|
|
|
+ final last3MonthsStart = DateTime(today.year, today.month - 3, today.day);
|
|
|
|
|
|
return Column(
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
@@ -317,7 +400,7 @@ class _GroupSection extends StatelessWidget {
|
|
|
child: Text(
|
|
|
group.title,
|
|
|
style: TextStyle(
|
|
|
- fontSize: 12,
|
|
|
+ fontSize: 13,
|
|
|
fontWeight: FontWeight.w500,
|
|
|
color: colors.textPlaceholder,
|
|
|
),
|
|
|
@@ -327,39 +410,57 @@ class _GroupSection extends StatelessWidget {
|
|
|
Padding(
|
|
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
|
|
child: Wrap(
|
|
|
- spacing: 8,
|
|
|
+ spacing: 6,
|
|
|
runSpacing: 6,
|
|
|
children: [
|
|
|
_QuickDateChip(
|
|
|
- label: '本月',
|
|
|
+ label: l10n.get('filterThisWeek'),
|
|
|
+ start: weekStart,
|
|
|
+ end: weekEnd,
|
|
|
+ onTap: (s, e) => _applyQuickDate(s, e),
|
|
|
+ ),
|
|
|
+ _QuickDateChip(
|
|
|
+ label: l10n.get('filterThisMonth'),
|
|
|
start: DateTime(today.year, today.month, 1),
|
|
|
- end: today,
|
|
|
+ end: monthEnd,
|
|
|
onTap: (s, e) => _applyQuickDate(s, e),
|
|
|
),
|
|
|
_QuickDateChip(
|
|
|
- label: '本季',
|
|
|
+ label: l10n.get('filterThisQuarter'),
|
|
|
start: DateTime(
|
|
|
today.year,
|
|
|
((today.month - 1) ~/ 3) * 3 + 1,
|
|
|
1,
|
|
|
),
|
|
|
- end: today,
|
|
|
+ end: quarterEnd,
|
|
|
onTap: (s, e) => _applyQuickDate(s, e),
|
|
|
),
|
|
|
_QuickDateChip(
|
|
|
- label: '本年',
|
|
|
+ label: l10n.get('filterThisYear'),
|
|
|
start: DateTime(today.year, 1, 1),
|
|
|
+ end: yearEnd,
|
|
|
+ onTap: (s, e) => _applyQuickDate(s, e),
|
|
|
+ ),
|
|
|
+ _QuickDateChip(
|
|
|
+ label: l10n.get('filterLastMonth'),
|
|
|
+ start: lastMonthStart,
|
|
|
+ end: lastMonthEnd,
|
|
|
+ onTap: (s, e) => _applyQuickDate(s, e),
|
|
|
+ ),
|
|
|
+ _QuickDateChip(
|
|
|
+ label: l10n.get('filterLast3Months'),
|
|
|
+ start: last3MonthsStart,
|
|
|
end: today,
|
|
|
onTap: (s, e) => _applyQuickDate(s, e),
|
|
|
),
|
|
|
_QuickDateChip(
|
|
|
- label: '7天内',
|
|
|
+ label: l10n.get('filter7Days'),
|
|
|
start: today.subtract(const Duration(days: 6)),
|
|
|
end: today,
|
|
|
onTap: (s, e) => _applyQuickDate(s, e),
|
|
|
),
|
|
|
_QuickDateChip(
|
|
|
- label: '30天内',
|
|
|
+ label: l10n.get('filter30Days'),
|
|
|
start: today.subtract(const Duration(days: 29)),
|
|
|
end: today,
|
|
|
onTap: (s, e) => _applyQuickDate(s, e),
|
|
|
@@ -369,7 +470,16 @@ class _GroupSection extends StatelessWidget {
|
|
|
),
|
|
|
...values
|
|
|
.where((v) => v.type == FilterSectionType.dateRange)
|
|
|
- .map((v) => _DateRow(value: v, onChanged: onValuesChanged)),
|
|
|
+ .expand(
|
|
|
+ (v) => [
|
|
|
+ _DateRow(value: v, isStart: true, onChanged: onValuesChanged),
|
|
|
+ _DateRow(
|
|
|
+ value: v,
|
|
|
+ isStart: false,
|
|
|
+ onChanged: onValuesChanged,
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
] else
|
|
|
...values.map(
|
|
|
(v) => _FilterRow(value: v, onChanged: onValuesChanged),
|
|
|
@@ -396,14 +506,20 @@ class _QuickDateChip extends StatelessWidget {
|
|
|
@override
|
|
|
Widget build(BuildContext context) {
|
|
|
final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
- return TDButton(
|
|
|
- text: label,
|
|
|
- size: TDButtonSize.small,
|
|
|
- type: TDButtonType.outline,
|
|
|
- shape: TDButtonShape.round,
|
|
|
- theme: TDButtonTheme.primary,
|
|
|
- style: TDButtonStyle(textColor: colors.primary),
|
|
|
+ return GestureDetector(
|
|
|
onTap: () => onTap(start, end),
|
|
|
+ child: Container(
|
|
|
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ border: Border.all(color: colors.border),
|
|
|
+ borderRadius: BorderRadius.circular(14),
|
|
|
+ color: Colors.transparent,
|
|
|
+ ),
|
|
|
+ child: Text(
|
|
|
+ label,
|
|
|
+ style: TextStyle(fontSize: 13, color: colors.textSecondary),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
@@ -414,16 +530,17 @@ class _QuickDateChip extends StatelessWidget {
|
|
|
|
|
|
class _DateRow extends StatelessWidget {
|
|
|
final _SectionValue value;
|
|
|
+ final bool isStart;
|
|
|
final VoidCallback onChanged;
|
|
|
|
|
|
- const _DateRow({required this.value, required this.onChanged});
|
|
|
+ const _DateRow({
|
|
|
+ required this.value,
|
|
|
+ required this.isStart,
|
|
|
+ required this.onChanged,
|
|
|
+ });
|
|
|
|
|
|
- String _fmt(DateTime? dt, bool isStart) {
|
|
|
- if (dt == null) {
|
|
|
- final now = DateTime.now();
|
|
|
- final d = isStart ? DateTime(now.year, now.month, 1) : now;
|
|
|
- return '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
|
|
- }
|
|
|
+ String _fmt(DateTime? dt) {
|
|
|
+ if (dt == null) return '';
|
|
|
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
|
|
|
}
|
|
|
|
|
|
@@ -437,42 +554,79 @@ class _DateRow extends StatelessWidget {
|
|
|
|
|
|
if (!context.mounted) return;
|
|
|
final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
+ final theme = Theme.of(context);
|
|
|
+ final selectHighlight = Container(
|
|
|
+ height: 40,
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: colors.primary.withValues(alpha: 0.08),
|
|
|
+ borderRadius: BorderRadius.all(Radius.circular(8)),
|
|
|
+ ),
|
|
|
+ );
|
|
|
await showModalBottomSheet<void>(
|
|
|
context: context,
|
|
|
- backgroundColor: colors.bgCard,
|
|
|
+ backgroundColor: Colors.transparent,
|
|
|
shape: RoundedRectangleBorder(
|
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
|
|
),
|
|
|
builder: (ctx) {
|
|
|
- return TDDatePicker(
|
|
|
- title: isStart ? '选择起始日期' : '选择结束日期',
|
|
|
- backgroundColor: colors.bgCard,
|
|
|
- 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],
|
|
|
+ return Theme(
|
|
|
+ data: theme,
|
|
|
+ child: TDDatePicker(
|
|
|
+ title: isStart
|
|
|
+ ? AppLocalizations.of(context).get('filterSelectStartDate')
|
|
|
+ : AppLocalizations.of(context).get('filterSelectEndDate'),
|
|
|
+ backgroundColor: colors.bgCard,
|
|
|
+ customSelectWidget: selectHighlight,
|
|
|
+ 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(),
|
|
|
),
|
|
|
- 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) {
|
|
|
+ final l10n = AppLocalizations.of(context);
|
|
|
+ if (isStart && value.endDate != null && picked!.isAfter(value.endDate!)) {
|
|
|
+ TDMessage.showMessage(
|
|
|
+ context: context,
|
|
|
+ content: l10n.get('filterDateStartAfterEnd'),
|
|
|
+ icon: TDIcons.error_circle_filled,
|
|
|
+ theme: MessageTheme.warning,
|
|
|
+ duration: 3000,
|
|
|
+ );
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!isStart &&
|
|
|
+ value.startDate != null &&
|
|
|
+ picked!.isBefore(value.startDate!)) {
|
|
|
+ TDMessage.showMessage(
|
|
|
+ context: context,
|
|
|
+ content: l10n.get('filterDateEndBeforeStart'),
|
|
|
+ icon: TDIcons.error_circle_filled,
|
|
|
+ theme: MessageTheme.warning,
|
|
|
+ duration: 3000,
|
|
|
+ );
|
|
|
+ return;
|
|
|
+ }
|
|
|
if (isStart) {
|
|
|
value.startDate = picked;
|
|
|
} else {
|
|
|
@@ -485,7 +639,6 @@ class _DateRow extends StatelessWidget {
|
|
|
@override
|
|
|
Widget build(BuildContext context) {
|
|
|
final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
- final isStart = value.label == '起始日期';
|
|
|
final date = isStart ? value.startDate : value.endDate;
|
|
|
final icon = isStart ? TDIcons.calendar_1 : TDIcons.calendar_2;
|
|
|
|
|
|
@@ -499,11 +652,10 @@ class _DateRow extends StatelessWidget {
|
|
|
Icon(icon, size: 16, color: colors.textSecondary),
|
|
|
const SizedBox(width: 8),
|
|
|
Text(
|
|
|
- value.label,
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 14,
|
|
|
- color: colors.textSecondary,
|
|
|
- ),
|
|
|
+ isStart
|
|
|
+ ? AppLocalizations.of(context).get('filterStartDate')
|
|
|
+ : AppLocalizations.of(context).get('filterEndDate'),
|
|
|
+ style: TextStyle(fontSize: 14, color: colors.textSecondary),
|
|
|
),
|
|
|
const Spacer(),
|
|
|
SizedBox(
|
|
|
@@ -511,13 +663,8 @@ class _DateRow extends StatelessWidget {
|
|
|
child: Align(
|
|
|
alignment: Alignment.centerRight,
|
|
|
child: Text(
|
|
|
- _fmt(date, isStart),
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 14,
|
|
|
- color: date != null
|
|
|
- ? colors.primary
|
|
|
- : colors.textPlaceholder,
|
|
|
- ),
|
|
|
+ _fmt(date),
|
|
|
+ style: TextStyle(fontSize: 14, color: colors.primary),
|
|
|
maxLines: 1,
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
),
|
|
|
@@ -567,30 +714,48 @@ class _FilterRow extends StatelessWidget {
|
|
|
}
|
|
|
|
|
|
if (!context.mounted) return;
|
|
|
- TDPicker.showMultiPicker(
|
|
|
- context,
|
|
|
- title: value.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);
|
|
|
- }
|
|
|
- }
|
|
|
- value.selectedValues = values;
|
|
|
- } else {
|
|
|
- if (selected.isNotEmpty && selected[0] is int) {
|
|
|
- final idx = selected[0] as int;
|
|
|
- if (idx >= 0 && idx < options.length) {
|
|
|
- value.selectedValue = options[idx].value;
|
|
|
+ final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
+ final theme = Theme.of(context);
|
|
|
+ final selectHighlight = Container(
|
|
|
+ height: 40,
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: colors.primary.withValues(alpha: 0.08),
|
|
|
+ borderRadius: BorderRadius.all(Radius.circular(8)),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ await showModalBottomSheet<void>(
|
|
|
+ context: context,
|
|
|
+ backgroundColor: Colors.transparent,
|
|
|
+ builder: (ctx) => Theme(
|
|
|
+ data: theme,
|
|
|
+ child: TDMultiPicker(
|
|
|
+ title: value.label,
|
|
|
+ backgroundColor: colors.bgCard,
|
|
|
+ customSelectWidget: selectHighlight,
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ value.selectedValues = values;
|
|
|
+ } else {
|
|
|
+ if (selected.isNotEmpty && selected[0] is int) {
|
|
|
+ final idx = selected[0] as int;
|
|
|
+ if (idx >= 0 && idx < options.length) {
|
|
|
+ value.selectedValue = options[idx].value;
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
- }
|
|
|
- }
|
|
|
- WidgetsBinding.instance.addPostFrameCallback((_) => onChanged());
|
|
|
- },
|
|
|
+ Navigator.of(ctx).pop();
|
|
|
+ onChanged();
|
|
|
+ },
|
|
|
+ ),
|
|
|
+ ),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
@@ -626,18 +791,11 @@ class _FilterRow extends StatelessWidget {
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
|
child: Row(
|
|
|
children: [
|
|
|
- Icon(
|
|
|
- TDIcons.filter_1,
|
|
|
- size: 16,
|
|
|
- color: colors.textSecondary,
|
|
|
- ),
|
|
|
+ Icon(TDIcons.filter_1, size: 16, color: colors.textSecondary),
|
|
|
const SizedBox(width: 8),
|
|
|
Text(
|
|
|
value.label,
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 14,
|
|
|
- color: colors.textSecondary,
|
|
|
- ),
|
|
|
+ style: TextStyle(fontSize: 14, color: colors.textSecondary),
|
|
|
),
|
|
|
const Spacer(),
|
|
|
SizedBox(
|
|
|
@@ -645,13 +803,8 @@ class _FilterRow extends StatelessWidget {
|
|
|
child: Align(
|
|
|
alignment: Alignment.centerRight,
|
|
|
child: Text(
|
|
|
- summary.isNotEmpty ? summary : '全部',
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 14,
|
|
|
- color: summary.isNotEmpty
|
|
|
- ? colors.primary
|
|
|
- : colors.textPlaceholder,
|
|
|
- ),
|
|
|
+ summary,
|
|
|
+ style: TextStyle(fontSize: 14, color: colors.primary),
|
|
|
maxLines: 1,
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
),
|
|
|
@@ -688,8 +841,8 @@ class FilterSection {
|
|
|
final ValueChanged<List<String>>? onMultiChanged;
|
|
|
final DateTime? startDate;
|
|
|
final DateTime? endDate;
|
|
|
- final ValueChanged<DateTime>? onStartChanged;
|
|
|
- final ValueChanged<DateTime>? onEndChanged;
|
|
|
+ final ValueChanged<DateTime?>? onStartChanged;
|
|
|
+ final ValueChanged<DateTime?>? onEndChanged;
|
|
|
|
|
|
const FilterSection({
|
|
|
required this.label,
|