date_range_picker.dart 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  1. import 'package:flutter/material.dart';
  2. import 'package:tdesign_flutter/tdesign_flutter.dart';
  3. import '../../core/i18n/app_localizations.dart';
  4. import '../../core/theme/app_colors_extension.dart';
  5. /// 通用日期区间选择器。
  6. ///
  7. /// 关闭态为单个胶囊 chip(日历图标 + 日期区间文本),
  8. /// 点击弹出 TDSlidePopupRoute,内含快捷日期 chip、
  9. /// 起始/截止 TDDatePicker、已选区间展示、取消/确定按钮。
  10. class DateRangePicker extends StatefulWidget {
  11. final TextEditingController startCtrl;
  12. final TextEditingController endCtrl;
  13. final VoidCallback? onChanged;
  14. const DateRangePicker({
  15. super.key,
  16. required this.startCtrl,
  17. required this.endCtrl,
  18. this.onChanged,
  19. });
  20. @override
  21. State<DateRangePicker> createState() => _DateRangePickerState();
  22. }
  23. class _DateRangePickerState extends State<DateRangePicker> {
  24. late DateTime _tempStart;
  25. late DateTime _tempEnd;
  26. @override
  27. void initState() {
  28. super.initState();
  29. _initTemp();
  30. }
  31. void _initTemp() {
  32. final now = DateTime.now();
  33. _tempStart =
  34. DateTime.tryParse(widget.startCtrl.text) ??
  35. DateTime(now.year, now.month, 1);
  36. _tempEnd =
  37. DateTime.tryParse(widget.endCtrl.text) ??
  38. DateTime(now.year, now.month + 1, 0);
  39. }
  40. String get _displayText =>
  41. '${widget.startCtrl.text} ~ ${widget.endCtrl.text}';
  42. DateTime _today() => DateTime.now();
  43. DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day);
  44. // ---- 弹窗 ----
  45. void _openPicker() {
  46. _initTemp();
  47. final l10n = AppLocalizations.of(context);
  48. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  49. final now = DateTime.now();
  50. Navigator.of(context).push(
  51. TDSlidePopupRoute(
  52. builder: (ctx) => _PickerContent(
  53. l10n: l10n,
  54. colors: colors,
  55. now: now,
  56. tempStart: _tempStart,
  57. tempEnd: _tempEnd,
  58. onStartChanged: (d) => _tempStart = d,
  59. onEndChanged: (d) => _tempEnd = d,
  60. onQuick: (key) {
  61. switch (key) {
  62. case 'today':
  63. final t = _dateOnly(_today());
  64. return (start: t, end: t);
  65. case 'thisWeek':
  66. final t = _dateOnly(_today());
  67. final wd = t.weekday;
  68. final s = t.subtract(Duration(days: wd - 1));
  69. return (start: s, end: s.add(const Duration(days: 6)));
  70. case 'last7Days':
  71. final t = _dateOnly(_today());
  72. return (start: t.subtract(const Duration(days: 6)), end: t);
  73. case 'thisMonth':
  74. final t = _dateOnly(_today());
  75. return (
  76. start: DateTime(t.year, t.month, 1),
  77. end: DateTime(t.year, t.month + 1, 0),
  78. );
  79. case 'lastMonth':
  80. final t = _dateOnly(_today());
  81. return (
  82. start: DateTime(t.year, t.month - 1, 1),
  83. end: DateTime(t.year, t.month, 0),
  84. );
  85. case 'last30Days':
  86. final t = _dateOnly(_today());
  87. return (start: t.subtract(const Duration(days: 29)), end: t);
  88. case 'thisQuarter':
  89. final t = _dateOnly(_today());
  90. final q = (t.month - 1) ~/ 3;
  91. return (
  92. start: DateTime(t.year, q * 3 + 1, 1),
  93. end: DateTime(t.year, q * 3 + 4, 0),
  94. );
  95. case 'thisYear':
  96. final t = _dateOnly(_today());
  97. return (
  98. start: DateTime(t.year, 1, 1),
  99. end: DateTime(t.year, 12, 31),
  100. );
  101. default:
  102. return (start: _tempStart, end: _tempEnd);
  103. }
  104. },
  105. onConfirm: () {
  106. widget.startCtrl.text = _fmt(_tempStart);
  107. widget.endCtrl.text = _fmt(_tempEnd);
  108. widget.onChanged?.call();
  109. Navigator.of(ctx).pop();
  110. },
  111. onCancel: () => Navigator.of(ctx).pop(),
  112. ),
  113. ),
  114. );
  115. }
  116. String _fmt(DateTime d) =>
  117. '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
  118. // ---- 关闭态 chip ----
  119. @override
  120. Widget build(BuildContext context) {
  121. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  122. final tdTheme = TDTheme.of(context);
  123. final text = _displayText;
  124. return GestureDetector(
  125. onTap: _openPicker,
  126. child: Container(
  127. height: 40,
  128. padding: const EdgeInsets.symmetric(horizontal: 12),
  129. decoration: BoxDecoration(
  130. color: colors.bgSecondaryContainer,
  131. borderRadius: BorderRadius.circular(20),
  132. border: Border.all(color: tdTheme.componentStrokeColor),
  133. ),
  134. child: Row(
  135. children: [
  136. Icon(Icons.calendar_today, size: 16, color: colors.textSecondary),
  137. const SizedBox(width: 12),
  138. Expanded(
  139. child: Text(
  140. text,
  141. textAlign: TextAlign.center,
  142. maxLines: 1,
  143. overflow: TextOverflow.ellipsis,
  144. style: TextStyle(fontSize: 14, color: colors.textPrimary),
  145. ),
  146. ),
  147. const SizedBox(width: 4),
  148. Icon(Icons.chevron_right, size: 16, color: colors.textSecondary),
  149. ],
  150. ),
  151. ),
  152. );
  153. }
  154. }
  155. /// 弹窗内容(StatefulWidget,自行管理临时状态)
  156. class _PickerContent extends StatefulWidget {
  157. final AppLocalizations l10n;
  158. final AppColorsExtension colors;
  159. final DateTime now;
  160. final DateTime tempStart;
  161. final DateTime tempEnd;
  162. final void Function(DateTime d) onStartChanged;
  163. final void Function(DateTime d) onEndChanged;
  164. final ({DateTime start, DateTime end}) Function(String key) onQuick;
  165. final VoidCallback onConfirm;
  166. final VoidCallback onCancel;
  167. const _PickerContent({
  168. required this.l10n,
  169. required this.colors,
  170. required this.now,
  171. required this.tempStart,
  172. required this.tempEnd,
  173. required this.onStartChanged,
  174. required this.onEndChanged,
  175. required this.onQuick,
  176. required this.onConfirm,
  177. required this.onCancel,
  178. });
  179. @override
  180. State<_PickerContent> createState() => _PickerContentState();
  181. }
  182. class _PickerContentState extends State<_PickerContent> {
  183. late DateTime _start;
  184. late DateTime _end;
  185. @override
  186. void initState() {
  187. super.initState();
  188. _start = widget.tempStart;
  189. _end = widget.tempEnd;
  190. }
  191. String _fmt(DateTime d) =>
  192. '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
  193. List<String> get _quickKeys => [
  194. 'today',
  195. 'thisWeek',
  196. 'last7Days',
  197. 'thisMonth',
  198. 'lastMonth',
  199. 'last30Days',
  200. 'thisQuarter',
  201. 'thisYear',
  202. ];
  203. String _quickLabel(String key) {
  204. switch (key) {
  205. case 'today':
  206. return widget.l10n.get('today');
  207. case 'thisWeek':
  208. return widget.l10n.get('thisWeek');
  209. case 'last7Days':
  210. return widget.l10n.get('last7Days');
  211. case 'thisMonth':
  212. return widget.l10n.get('thisMonth');
  213. case 'lastMonth':
  214. return widget.l10n.get('lastMonth');
  215. case 'last30Days':
  216. return widget.l10n.get('last30Days');
  217. case 'thisQuarter':
  218. return widget.l10n.get('thisQuarter');
  219. case 'thisYear':
  220. return widget.l10n.get('thisYear');
  221. default:
  222. return key;
  223. }
  224. }
  225. void _onQuickTap(String key) {
  226. final r = widget.onQuick(key);
  227. setState(() {
  228. _start = r.start;
  229. _end = r.end;
  230. });
  231. }
  232. List<int> _toList(DateTime d) => [d.year, d.month, d.day];
  233. /// 兼容 TDDatePicker.onChange 的 List / Map 两种回调格式
  234. List<int> _parseSelected(dynamic selected) {
  235. if (selected is List) {
  236. return [
  237. int.parse(selected[0].toString()),
  238. int.parse(selected[1].toString()),
  239. int.parse(selected[2].toString()),
  240. ];
  241. }
  242. return [
  243. int.parse(selected['year'].toString()),
  244. int.parse(selected['month'].toString()),
  245. int.parse(selected['day'].toString()),
  246. ];
  247. }
  248. String? get _errorMessage {
  249. if (_start.isAfter(_end)) {
  250. return widget.l10n.get('filterDateStartAfterEnd');
  251. }
  252. if (_end.difference(_start).inDays > 365) {
  253. return widget.l10n.get('dateRangeLimit');
  254. }
  255. return null;
  256. }
  257. @override
  258. Widget build(BuildContext context) {
  259. final l10n = widget.l10n;
  260. final colors = widget.colors;
  261. final now = widget.now;
  262. return ConstrainedBox(
  263. constraints: BoxConstraints(
  264. maxHeight: MediaQuery.of(context).size.height * 0.9,
  265. ),
  266. child: Container(
  267. decoration: BoxDecoration(
  268. color: colors.bgPage,
  269. borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
  270. ),
  271. child: Column(
  272. mainAxisSize: MainAxisSize.min,
  273. crossAxisAlignment: CrossAxisAlignment.stretch,
  274. children: [
  275. // 标题栏(拖拽手柄 + 标题 + 关闭按钮)
  276. _buildHeader(colors, l10n),
  277. // 内容区域(可滚动,适应不同屏幕高度)
  278. Flexible(
  279. child: SingleChildScrollView(
  280. physics: const ClampingScrollPhysics(),
  281. padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
  282. child: Column(
  283. crossAxisAlignment: CrossAxisAlignment.start,
  284. children: [
  285. // 快捷日期
  286. Wrap(
  287. spacing: 8,
  288. runSpacing: 8,
  289. children: _quickKeys.map((key) {
  290. return GestureDetector(
  291. onTap: () => _onQuickTap(key),
  292. child: Container(
  293. padding: const EdgeInsets.symmetric(
  294. horizontal: 10,
  295. vertical: 4,
  296. ),
  297. decoration: BoxDecoration(
  298. color: colors.primary.withValues(alpha: 0.1),
  299. borderRadius: BorderRadius.circular(6),
  300. border: Border.all(
  301. color: colors.primary.withValues(alpha: 0.3),
  302. width: 0.5,
  303. ),
  304. ),
  305. child: Text(
  306. _quickLabel(key),
  307. style: TextStyle(
  308. fontSize: 13,
  309. color: colors.primary,
  310. ),
  311. ),
  312. ),
  313. );
  314. }).toList(),
  315. ),
  316. const SizedBox(height: 24),
  317. // 起始日期
  318. Text(
  319. l10n.get('startDateLabel'),
  320. style: TextStyle(
  321. fontSize: 14,
  322. fontWeight: FontWeight.w500,
  323. color: colors.textPrimary,
  324. ),
  325. ),
  326. const SizedBox(height: 8),
  327. ClipRRect(
  328. borderRadius: BorderRadius.circular(12),
  329. child: Container(
  330. decoration: BoxDecoration(color: colors.bgCard),
  331. child: TDDatePicker(
  332. key: ValueKey('start_${_fmt(_start)}'),
  333. header: false,
  334. model: DatePickerModel(
  335. useYear: true,
  336. useMonth: true,
  337. useDay: true,
  338. useHour: false,
  339. useMinute: false,
  340. useSecond: false,
  341. useWeekDay: false,
  342. dateStart: [2000, 1, 1],
  343. dateEnd: [now.year + 1, 12, 31],
  344. dateInitial: _toList(_start),
  345. ),
  346. onChange: (selected) {
  347. final vals = _parseSelected(selected);
  348. setState(() {
  349. _start = DateTime(vals[0], vals[1], vals[2]);
  350. });
  351. },
  352. ),
  353. ),
  354. ),
  355. const SizedBox(height: 24),
  356. // 截止日期
  357. Text(
  358. l10n.get('endDateLabel'),
  359. style: TextStyle(
  360. fontSize: 14,
  361. fontWeight: FontWeight.w500,
  362. color: colors.textPrimary,
  363. ),
  364. ),
  365. const SizedBox(height: 8),
  366. ClipRRect(
  367. borderRadius: BorderRadius.circular(12),
  368. child: Container(
  369. decoration: BoxDecoration(color: colors.bgCard),
  370. child: TDDatePicker(
  371. key: ValueKey('end_${_fmt(_end)}'),
  372. header: false,
  373. model: DatePickerModel(
  374. useYear: true,
  375. useMonth: true,
  376. useDay: true,
  377. useHour: false,
  378. useMinute: false,
  379. useSecond: false,
  380. useWeekDay: false,
  381. dateStart: [2000, 1, 1],
  382. dateEnd: [now.year + 1, 12, 31],
  383. dateInitial: _toList(_end),
  384. ),
  385. onChange: (selected) {
  386. final vals = _parseSelected(selected);
  387. setState(() {
  388. _end = DateTime(vals[0], vals[1], vals[2]);
  389. });
  390. },
  391. ),
  392. ),
  393. ),
  394. ],
  395. ),
  396. ),
  397. ),
  398. // 校验提示 + 底部按钮
  399. Container(
  400. decoration: BoxDecoration(
  401. color: colors.bgCard,
  402. border: Border(
  403. top: BorderSide(color: colors.border, width: 0.5),
  404. ),
  405. ),
  406. child: Column(
  407. mainAxisSize: MainAxisSize.min,
  408. children: [
  409. AnimatedSize(
  410. duration: const Duration(milliseconds: 200),
  411. curve: Curves.easeInOut,
  412. child: _errorMessage != null
  413. ? Padding(
  414. padding: const EdgeInsets.fromLTRB(16, 10, 16, 0),
  415. child: Row(
  416. children: [
  417. Icon(
  418. Icons.error_outline,
  419. size: 14,
  420. color: colors.warning,
  421. ),
  422. const SizedBox(width: 6),
  423. Expanded(
  424. child: Text(
  425. _errorMessage!,
  426. style: TextStyle(
  427. fontSize: 12,
  428. color: colors.warning,
  429. ),
  430. ),
  431. ),
  432. ],
  433. ),
  434. )
  435. : const SizedBox.shrink(),
  436. ),
  437. Padding(
  438. padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
  439. child: Row(
  440. children: [
  441. Expanded(
  442. child: TDButton(
  443. text: l10n.get('cancel'),
  444. size: TDButtonSize.large,
  445. type: TDButtonType.outline,
  446. shape: TDButtonShape.rectangle,
  447. theme: TDButtonTheme.defaultTheme,
  448. onTap: widget.onCancel,
  449. ),
  450. ),
  451. const SizedBox(width: 12),
  452. Expanded(
  453. child: TDButton(
  454. text: l10n.get('confirm'),
  455. size: TDButtonSize.large,
  456. type: TDButtonType.fill,
  457. shape: TDButtonShape.rectangle,
  458. theme: _errorMessage != null
  459. ? TDButtonTheme.defaultTheme
  460. : TDButtonTheme.primary,
  461. onTap: _errorMessage != null
  462. ? null
  463. : () {
  464. widget.onStartChanged(_start);
  465. widget.onEndChanged(_end);
  466. widget.onConfirm();
  467. },
  468. ),
  469. ),
  470. ],
  471. ),
  472. ),
  473. ],
  474. ),
  475. ),
  476. ],
  477. ),
  478. ),
  479. );
  480. }
  481. Widget _buildHeader(AppColorsExtension colors, AppLocalizations l10n) {
  482. return Column(
  483. mainAxisSize: MainAxisSize.min,
  484. children: [
  485. Center(
  486. child: Container(
  487. margin: const EdgeInsets.only(top: 8, bottom: 4),
  488. width: 36,
  489. height: 4,
  490. decoration: BoxDecoration(
  491. color: colors.border,
  492. borderRadius: BorderRadius.circular(2),
  493. ),
  494. ),
  495. ),
  496. Padding(
  497. padding: const EdgeInsets.fromLTRB(20, 8, 12, 16),
  498. child: Row(
  499. children: [
  500. const SizedBox(width: 24),
  501. Expanded(
  502. child: Center(
  503. child: Text(
  504. l10n.get('datePickerTitle'),
  505. style: TextStyle(
  506. fontSize: 18,
  507. fontWeight: FontWeight.w600,
  508. color: colors.textPrimary,
  509. ),
  510. ),
  511. ),
  512. ),
  513. GestureDetector(
  514. onTap: widget.onCancel,
  515. child: Padding(
  516. padding: const EdgeInsets.all(4),
  517. child: Icon(
  518. Icons.close,
  519. size: 20,
  520. color: colors.textSecondary,
  521. ),
  522. ),
  523. ),
  524. ],
  525. ),
  526. ),
  527. ],
  528. );
  529. }
  530. }