Files
lixi 294fc4a781 修复:日期录入收口共享层——「今天」快捷键 + 品牌配色 datePickerTheme(M3.5-02)
用户实测已因日期选择器误录:Flutter 原生日历只给年份网格、月份必须靠 < >
逐月点,从 9 月回 4 月要点 5 次,他把当月(2026-09)的就医记录记成了
2026-04-09,进而误判「本月花费 ¥0」是聚合坏了。

新增 lib/core/widgets/app_date_picker.dart,全仓 7 处裸 showDatePicker 收口
(改造后 lib/ 下 showDatePicker 只出现在该文件内部一次):

- pickAppDate(...):calendar 首屏 + 保留头部铅笔切手输;initialDate 自动夹进
  [firstDate, lastDate] 防原生越界断言(调用方常传「当前值 ?? 今天」,而
  「到期日期」的 firstDate 就是今天,历史值可能已越界);返回值统一抹时分秒;
  中文文案一律交给 zh-CN 本地化,不硬编码,避免两处文案漂移。
- AppDateFieldTrailing(...):7 处日期行统一「今天 + 日历图标」。今天越界自动
  隐藏按钮、提交中禁用、触控 44×44、primaryStrong 白底 4.49:1。

入口模式取舍:不用 calendarOnly——它恰好会砍掉手输按钮,把「录一个已知日期」
这条唯一快路堵死;也不用 input 首屏——「记今天」这类高频场景敲 8 个数字更慢。
两条路都留着最省事。

「今天」为何放表单行而非弹窗内:原生 showDatePicker 无法注入自定义动作
(builder 只能包裹整个 Dialog,拿不到内部选中态;塞进 Column 还会因 Dialog
在无界高度下贪心布局而溢出)。放表单行反而更快——一键落值连弹窗都不用开,
把容易走错的月份导航整段绕开,且一次实现 7 处形态完全一致。

各调用点原有的 firstDate/lastDate 业务约束原样传入、一字未改(健康事件
不许未来、到期日不许补记过去、疫苗 allowFuture 双态、生日不许未来),
并由 widget 测试直接断言 DatePickerDialog.firstDate/lastDate 防后续悄悄放宽。

顺带修配色:此前从未定制 datePickerTheme,选中日直接吃 ColorScheme.fromSeed
由珊瑚橙派生的暗红棕,与品牌脱节。新增 _datePickerTheme 只复用 05 号规范
(iteration-2/05、iteration-3/05)已审计的色对,不新造色值:头部
surfaceTint + primaryDark 7.98:1(选中 chip 同款)、选中日/年 primaryStrong
实底白字 4.49:1、今日 primaryStrong 1.5px 描边、星期表头 inkSoft 6.59:1、
越界日 muted(DEBT-2 允许的禁用态用途)。headerHeadlineStyle 取 22px
(默认 32):中文「9月10日周四」在横屏侧栏头部 26px 起就折行。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 17:59:52 +08:00

145 lines
5.8 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 日期录入共享层(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<DateTime?> 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<DateTime> 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,
),
],
);
}
}