/// 日期录入共享层(M3.5-02)。 /// /// 根因备忘:全仓 7 处 `showDatePicker` 各写一遍裸调用,用户实测暴露两个问题: /// /// 1. **月份只能靠 `< >` 逐月切**。Flutter 原生日历有年份网格、没有月份网格, /// 从 9 月回到 4 月要点 5 次箭头。已实际导致误录——用户把当月(2026-09) /// 的就医记录记成了 2026-04-09,进而误判「本月花费 ¥0」是统计坏了。 /// 2. **键盘输入模式在英文兜底下不可用**。原生 calendar 模式本就带一枚 /// 切换到手输的铅笔按钮,但未配本地化时按钮 tooltip 是 /// `Switch to input`、报错是 `Invalid format.`,中文用户看不懂也不敢用。 /// M3.5-01 挂上 zh-CN delegate 后变成「切换到输入模式」/「格式无效。」/ /// 「超出范围。」,输入框提示也从 `mm/dd/yyyy` 变成中文习惯的 /// `yyyy/mm/dd`,这条路才真正走通。 /// /// 本文件的处置: /// /// - [pickAppDate] 收口 7 处调用:统一 calendar 首屏 + **保留**手输切换 /// (明确不用 `calendarOnly`——那恰好会砍掉手输这条快路),并把 /// `initialDate` 夹进 `[firstDate, lastDate]` 防越界断言。各调用点原有的 /// 业务约束(如健康事件 `lastDate: now` 不许未来)由调用方原样传入,不改。 /// - [AppDateFieldTrailing] 给 7 处日期行统一挂「今天」快捷键:绝大多数录入 /// 就是「记今天的事」,一键落值比开弹窗再找今天更快,也把「月份导航」 /// 这条容易走错的路整段绕开。原生 `showDatePicker` 无法注入自定义动作 /// (`builder` 只能包裹整个 Dialog,拿不到它的内部选中态),所以快捷键 /// 放在调用方表单行而非弹窗内。 library; import 'package:flutter/material.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; /// 抹掉时分秒,只留年月日(日期选择器返回值与业务日期字段的统一口径)。 DateTime dateOnly(DateTime value) => DateTime(value.year, value.month, value.day); /// 今天(本地时区,仅日期)。 DateTime today() => dateOnly(DateTime.now()); /// 判断 [date] 是否落在 `[firstDate, lastDate]` 闭区间内(按日粒度)。 bool isDateSelectable({ required DateTime date, required DateTime firstDate, required DateTime lastDate, }) { final day = dateOnly(date); return !day.isBefore(dateOnly(firstDate)) && !day.isAfter(dateOnly(lastDate)); } /// 全仓统一的日期选择入口。 /// /// [initialDate] 会被夹进 `[firstDate, lastDate]`——调用方常传「当前值 ?? 今天」, /// 而部分表单的 `firstDate` 就是今天(如提醒到期日),历史值可能已越界。 /// /// 返回 `null` 表示用户取消;返回值已抹掉时分秒。 Future pickAppDate({ required BuildContext context, required DateTime initialDate, required DateTime firstDate, required DateTime lastDate, }) async { final first = dateOnly(firstDate); final last = dateOnly(lastDate); var initial = dateOnly(initialDate); if (initial.isBefore(first)) initial = first; if (initial.isAfter(last)) initial = last; final picked = await showDatePicker( context: context, initialDate: initial, firstDate: first, lastDate: last, // calendar 首屏 + 头部铅笔按钮切手输:日期不确定时翻日历,日期已知时 // 一行敲完。不用 calendarOnly(砍掉手输)、不用 input 首屏(多数录入 // 是「今天/最近几天」,日历一眼可点,手输反而更慢)。 initialEntryMode: DatePickerEntryMode.calendar, // 帮助文字/确定/取消/手输提示与格式报错全部交给 zh-CN 本地化 // (appLocalizationsDelegates),不在此硬编码中文,避免两处文案漂移。 ); return picked == null ? null : dateOnly(picked); } /// 日期行尾部(7 处日期 [ListTile] 统一形态):「今天」快捷键 + 日历图标。 /// /// - 「今天」直接把字段落到今天,不开弹窗;今天越界(业务不允许)时按钮 /// 自动隐藏,只留日历图标。 /// - 触控目标 44×44(项目最小触控口径),文字 `primaryStrong` 白底 4.49:1。 class AppDateFieldTrailing extends StatelessWidget { const AppDateFieldTrailing({ required this.firstDate, required this.lastDate, required this.onToday, super.key, this.enabled = true, }); /// 业务允许的最早日期(与 [pickAppDate] 同一约束)。 final DateTime firstDate; /// 业务允许的最晚日期。 final DateTime lastDate; /// 点「今天」的回调,入参为今天(仅日期)。 final ValueChanged onToday; /// 表单提交中等禁用态。 final bool enabled; @override Widget build(BuildContext context) { final now = today(); final showToday = isDateSelectable( date: now, firstDate: firstDate, lastDate: lastDate, ); return Row( mainAxisSize: MainAxisSize.min, children: [ if (showToday) Tooltip( message: '设为今天', child: TextButton( onPressed: enabled ? () => onToday(now) : null, style: TextButton.styleFrom( foregroundColor: AppColors.primaryStrong, disabledForegroundColor: AppColors.muted, minimumSize: const Size(44, 44), padding: const EdgeInsets.symmetric(horizontal: 8), textStyle: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, ), ), child: const Text('今天'), ), ), const Icon( Icons.calendar_month_outlined, size: 20, color: AppColors.muted, ), ], ); } }