| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562 |
- import 'package:flutter/material.dart';
- import 'package:tdesign_flutter/tdesign_flutter.dart';
- import '../../core/i18n/app_localizations.dart';
- import '../../core/theme/app_colors_extension.dart';
- /// 通用日期区间选择器。
- ///
- /// 关闭态为单个胶囊 chip(日历图标 + 日期区间文本),
- /// 点击弹出 TDSlidePopupRoute,内含快捷日期 chip、
- /// 起始/截止 TDDatePicker、已选区间展示、取消/确定按钮。
- class DateRangePicker extends StatefulWidget {
- final TextEditingController startCtrl;
- final TextEditingController endCtrl;
- final VoidCallback? onChanged;
- const DateRangePicker({
- super.key,
- required this.startCtrl,
- required this.endCtrl,
- this.onChanged,
- });
- @override
- State<DateRangePicker> createState() => _DateRangePickerState();
- }
- class _DateRangePickerState extends State<DateRangePicker> {
- late DateTime _tempStart;
- late DateTime _tempEnd;
- @override
- void initState() {
- super.initState();
- _initTemp();
- }
- void _initTemp() {
- final now = DateTime.now();
- _tempStart =
- DateTime.tryParse(widget.startCtrl.text) ??
- DateTime(now.year, now.month, 1);
- _tempEnd =
- DateTime.tryParse(widget.endCtrl.text) ??
- DateTime(now.year, now.month + 1, 0);
- }
- String get _displayText =>
- '${widget.startCtrl.text} ~ ${widget.endCtrl.text}';
- DateTime _today() => DateTime.now();
- DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day);
- // ---- 弹窗 ----
- void _openPicker() {
- _initTemp();
- final l10n = AppLocalizations.of(context);
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- final now = DateTime.now();
- Navigator.of(context).push(
- TDSlidePopupRoute(
- builder: (ctx) => _PickerContent(
- l10n: l10n,
- colors: colors,
- now: now,
- tempStart: _tempStart,
- tempEnd: _tempEnd,
- onStartChanged: (d) => _tempStart = d,
- onEndChanged: (d) => _tempEnd = d,
- onQuick: (key) {
- switch (key) {
- case 'today':
- final t = _dateOnly(_today());
- return (start: t, end: t);
- case 'thisWeek':
- final t = _dateOnly(_today());
- final wd = t.weekday;
- final s = t.subtract(Duration(days: wd - 1));
- return (start: s, end: s.add(const Duration(days: 6)));
- case 'last7Days':
- final t = _dateOnly(_today());
- return (start: t.subtract(const Duration(days: 6)), end: t);
- case 'thisMonth':
- final t = _dateOnly(_today());
- return (
- start: DateTime(t.year, t.month, 1),
- end: DateTime(t.year, t.month + 1, 0),
- );
- case 'lastMonth':
- final t = _dateOnly(_today());
- return (
- start: DateTime(t.year, t.month - 1, 1),
- end: DateTime(t.year, t.month, 0),
- );
- case 'last30Days':
- final t = _dateOnly(_today());
- return (start: t.subtract(const Duration(days: 29)), end: t);
- case 'thisQuarter':
- final t = _dateOnly(_today());
- final q = (t.month - 1) ~/ 3;
- return (
- start: DateTime(t.year, q * 3 + 1, 1),
- end: DateTime(t.year, q * 3 + 4, 0),
- );
- case 'thisYear':
- final t = _dateOnly(_today());
- return (
- start: DateTime(t.year, 1, 1),
- end: DateTime(t.year, 12, 31),
- );
- default:
- return (start: _tempStart, end: _tempEnd);
- }
- },
- onConfirm: () {
- widget.startCtrl.text = _fmt(_tempStart);
- widget.endCtrl.text = _fmt(_tempEnd);
- widget.onChanged?.call();
- Navigator.of(ctx).pop();
- },
- onCancel: () => Navigator.of(ctx).pop(),
- ),
- ),
- );
- }
- String _fmt(DateTime d) =>
- '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
- // ---- 关闭态 chip ----
- @override
- Widget build(BuildContext context) {
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- final tdTheme = TDTheme.of(context);
- final text = _displayText;
- return GestureDetector(
- onTap: _openPicker,
- child: Container(
- height: 40,
- padding: const EdgeInsets.symmetric(horizontal: 12),
- decoration: BoxDecoration(
- color: colors.bgSecondaryContainer,
- borderRadius: BorderRadius.circular(20),
- border: Border.all(color: tdTheme.componentStrokeColor),
- ),
- child: Row(
- children: [
- Icon(Icons.calendar_today, size: 16, color: colors.textSecondary),
- const SizedBox(width: 12),
- Expanded(
- child: Text(
- text,
- textAlign: TextAlign.center,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(fontSize: 14, color: colors.textPrimary),
- ),
- ),
- const SizedBox(width: 4),
- Icon(Icons.chevron_right, size: 16, color: colors.textSecondary),
- ],
- ),
- ),
- );
- }
- }
- /// 弹窗内容(StatefulWidget,自行管理临时状态)
- class _PickerContent extends StatefulWidget {
- final AppLocalizations l10n;
- final AppColorsExtension colors;
- final DateTime now;
- final DateTime tempStart;
- final DateTime tempEnd;
- final void Function(DateTime d) onStartChanged;
- final void Function(DateTime d) onEndChanged;
- final ({DateTime start, DateTime end}) Function(String key) onQuick;
- final VoidCallback onConfirm;
- final VoidCallback onCancel;
- const _PickerContent({
- required this.l10n,
- required this.colors,
- required this.now,
- required this.tempStart,
- required this.tempEnd,
- required this.onStartChanged,
- required this.onEndChanged,
- required this.onQuick,
- required this.onConfirm,
- required this.onCancel,
- });
- @override
- State<_PickerContent> createState() => _PickerContentState();
- }
- class _PickerContentState extends State<_PickerContent> {
- late DateTime _start;
- late DateTime _end;
- @override
- void initState() {
- super.initState();
- _start = widget.tempStart;
- _end = widget.tempEnd;
- }
- String _fmt(DateTime d) =>
- '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
- List<String> get _quickKeys => [
- 'today',
- 'thisWeek',
- 'last7Days',
- 'thisMonth',
- 'lastMonth',
- 'last30Days',
- 'thisQuarter',
- 'thisYear',
- ];
- String _quickLabel(String key) {
- switch (key) {
- case 'today':
- return widget.l10n.get('today');
- case 'thisWeek':
- return widget.l10n.get('thisWeek');
- case 'last7Days':
- return widget.l10n.get('last7Days');
- case 'thisMonth':
- return widget.l10n.get('thisMonth');
- case 'lastMonth':
- return widget.l10n.get('lastMonth');
- case 'last30Days':
- return widget.l10n.get('last30Days');
- case 'thisQuarter':
- return widget.l10n.get('thisQuarter');
- case 'thisYear':
- return widget.l10n.get('thisYear');
- default:
- return key;
- }
- }
- void _onQuickTap(String key) {
- final r = widget.onQuick(key);
- setState(() {
- _start = r.start;
- _end = r.end;
- });
- }
- List<int> _toList(DateTime d) => [d.year, d.month, d.day];
- /// 兼容 TDDatePicker.onChange 的 List / Map 两种回调格式
- List<int> _parseSelected(dynamic selected) {
- if (selected is List) {
- return [
- int.parse(selected[0].toString()),
- int.parse(selected[1].toString()),
- int.parse(selected[2].toString()),
- ];
- }
- return [
- int.parse(selected['year'].toString()),
- int.parse(selected['month'].toString()),
- int.parse(selected['day'].toString()),
- ];
- }
- String? get _errorMessage {
- if (_start.isAfter(_end)) {
- return widget.l10n.get('filterDateStartAfterEnd');
- }
- if (_end.difference(_start).inDays > 365) {
- return widget.l10n.get('dateRangeLimit');
- }
- return null;
- }
- @override
- Widget build(BuildContext context) {
- final l10n = widget.l10n;
- final colors = widget.colors;
- final now = widget.now;
- return ConstrainedBox(
- constraints: BoxConstraints(
- maxHeight: MediaQuery.of(context).size.height * 0.9,
- ),
- child: Container(
- decoration: BoxDecoration(
- color: colors.bgPage,
- borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
- ),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- // 标题栏(拖拽手柄 + 标题 + 关闭按钮)
- _buildHeader(colors, l10n),
- // 内容区域(可滚动,适应不同屏幕高度)
- Flexible(
- child: SingleChildScrollView(
- physics: const ClampingScrollPhysics(),
- padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // 快捷日期
- Wrap(
- spacing: 8,
- runSpacing: 8,
- children: _quickKeys.map((key) {
- return GestureDetector(
- onTap: () => _onQuickTap(key),
- child: Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 10,
- vertical: 4,
- ),
- decoration: BoxDecoration(
- color: colors.primary.withValues(alpha: 0.1),
- borderRadius: BorderRadius.circular(6),
- border: Border.all(
- color: colors.primary.withValues(alpha: 0.3),
- width: 0.5,
- ),
- ),
- child: Text(
- _quickLabel(key),
- style: TextStyle(
- fontSize: 13,
- color: colors.primary,
- ),
- ),
- ),
- );
- }).toList(),
- ),
- const SizedBox(height: 24),
- // 起始日期
- Text(
- l10n.get('startDateLabel'),
- style: TextStyle(
- fontSize: 14,
- fontWeight: FontWeight.w500,
- color: colors.textPrimary,
- ),
- ),
- const SizedBox(height: 8),
- ClipRRect(
- borderRadius: BorderRadius.circular(12),
- child: Container(
- decoration: BoxDecoration(color: colors.bgCard),
- child: TDDatePicker(
- key: ValueKey('start_${_fmt(_start)}'),
- header: false,
- model: DatePickerModel(
- useYear: true,
- useMonth: true,
- useDay: true,
- useHour: false,
- useMinute: false,
- useSecond: false,
- useWeekDay: false,
- dateStart: [2000, 1, 1],
- dateEnd: [now.year + 1, 12, 31],
- dateInitial: _toList(_start),
- ),
- onChange: (selected) {
- final vals = _parseSelected(selected);
- setState(() {
- _start = DateTime(vals[0], vals[1], vals[2]);
- });
- },
- ),
- ),
- ),
- const SizedBox(height: 24),
- // 截止日期
- Text(
- l10n.get('endDateLabel'),
- style: TextStyle(
- fontSize: 14,
- fontWeight: FontWeight.w500,
- color: colors.textPrimary,
- ),
- ),
- const SizedBox(height: 8),
- ClipRRect(
- borderRadius: BorderRadius.circular(12),
- child: Container(
- decoration: BoxDecoration(color: colors.bgCard),
- child: TDDatePicker(
- key: ValueKey('end_${_fmt(_end)}'),
- header: false,
- model: DatePickerModel(
- useYear: true,
- useMonth: true,
- useDay: true,
- useHour: false,
- useMinute: false,
- useSecond: false,
- useWeekDay: false,
- dateStart: [2000, 1, 1],
- dateEnd: [now.year + 1, 12, 31],
- dateInitial: _toList(_end),
- ),
- onChange: (selected) {
- final vals = _parseSelected(selected);
- setState(() {
- _end = DateTime(vals[0], vals[1], vals[2]);
- });
- },
- ),
- ),
- ),
- ],
- ),
- ),
- ),
- // 校验提示 + 底部按钮
- Container(
- decoration: BoxDecoration(
- color: colors.bgCard,
- border: Border(
- top: BorderSide(color: colors.border, width: 0.5),
- ),
- ),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- AnimatedSize(
- duration: const Duration(milliseconds: 200),
- curve: Curves.easeInOut,
- child: _errorMessage != null
- ? Padding(
- padding: const EdgeInsets.fromLTRB(16, 10, 16, 0),
- child: Row(
- children: [
- Icon(
- Icons.error_outline,
- size: 14,
- color: colors.warning,
- ),
- const SizedBox(width: 6),
- Expanded(
- child: Text(
- _errorMessage!,
- style: TextStyle(
- fontSize: 12,
- color: colors.warning,
- ),
- ),
- ),
- ],
- ),
- )
- : const SizedBox.shrink(),
- ),
- Padding(
- padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
- child: Row(
- children: [
- Expanded(
- child: TDButton(
- text: l10n.get('cancel'),
- size: TDButtonSize.large,
- type: TDButtonType.outline,
- shape: TDButtonShape.rectangle,
- theme: TDButtonTheme.defaultTheme,
- onTap: widget.onCancel,
- ),
- ),
- const SizedBox(width: 12),
- Expanded(
- child: TDButton(
- text: l10n.get('confirm'),
- size: TDButtonSize.large,
- type: TDButtonType.fill,
- shape: TDButtonShape.rectangle,
- theme: _errorMessage != null
- ? TDButtonTheme.defaultTheme
- : TDButtonTheme.primary,
- onTap: _errorMessage != null
- ? null
- : () {
- widget.onStartChanged(_start);
- widget.onEndChanged(_end);
- widget.onConfirm();
- },
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- );
- }
- Widget _buildHeader(AppColorsExtension colors, AppLocalizations l10n) {
- return Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Center(
- child: Container(
- margin: const EdgeInsets.only(top: 8, bottom: 4),
- width: 36,
- height: 4,
- decoration: BoxDecoration(
- color: colors.border,
- borderRadius: BorderRadius.circular(2),
- ),
- ),
- ),
- Padding(
- padding: const EdgeInsets.fromLTRB(20, 8, 12, 16),
- child: Row(
- children: [
- const SizedBox(width: 24),
- Expanded(
- child: Center(
- child: Text(
- l10n.get('datePickerTitle'),
- style: TextStyle(
- fontSize: 18,
- fontWeight: FontWeight.w600,
- color: colors.textPrimary,
- ),
- ),
- ),
- ),
- GestureDetector(
- onTap: widget.onCancel,
- child: Padding(
- padding: const EdgeInsets.all(4),
- child: Icon(
- Icons.close,
- size: 20,
- color: colors.textSecondary,
- ),
- ),
- ),
- ],
- ),
- ),
- ],
- );
- }
- }
|