From 294fc4a781677c61a9dba706057baa7630686607 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Thu, 10 Sep 2026 17:59:52 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E5=BD=95=E5=85=A5=E6=94=B6=E5=8F=A3=E5=85=B1=E4=BA=AB=E5=B1=82?= =?UTF-8?q?=E2=80=94=E2=80=94=E3=80=8C=E4=BB=8A=E5=A4=A9=E3=80=8D=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E9=94=AE=20+=20=E5=93=81=E7=89=8C=E9=85=8D=E8=89=B2?= =?UTF-8?q?=20datePickerTheme=EF=BC=88M3.5-02=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户实测已因日期选择器误录: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 --- lib/core/theme/app_theme.dart | 100 +++++ lib/core/widgets/app_date_picker.dart | 144 +++++++ .../pets/care_reminder_form_page.dart | 17 +- lib/features/pets/care_reminders_page.dart | 8 +- lib/features/pets/health_event_form_page.dart | 14 +- lib/features/pets/pet_form_page.dart | 14 +- lib/features/pets/vaccination_form_page.dart | 16 +- .../pets/vaccination_records_page.dart | 13 +- lib/features/pets/weight_form_page.dart | 14 +- test/core/widgets/app_date_picker_test.dart | 385 ++++++++++++++++++ .../pets/care_reminder_form_page_test.dart | 40 +- .../pets/health_event_form_page_test.dart | 82 ++++ 12 files changed, 824 insertions(+), 23 deletions(-) create mode 100644 lib/core/widgets/app_date_picker.dart create mode 100644 test/core/widgets/app_date_picker_test.dart diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 7311a09..f87332a 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -176,6 +176,7 @@ ThemeData buildAppTheme() { borderRadius: BorderRadius.all(Radius.circular(AppRadius.md)), ), ), + datePickerTheme: _datePickerTheme, navigationBarTheme: NavigationBarThemeData( backgroundColor: AppColors.surface, indicatorColor: AppColors.surfaceTint, @@ -194,3 +195,102 @@ ThemeData buildAppTheme() { ), ); } + +/// 日期选择器主题(M3.5-01)。 +/// +/// 根因备忘:此前未定制,`showDatePicker` 完全走 `ColorScheme.fromSeed` +/// 由珊瑚橙 `#FF6F4C` 派生出的 M3 调和色(选中日为暗红棕实底), +/// 与全 app 品牌色脱节。这里只复用 05 号规范(iteration-2/05、iteration-3/05) +/// 已审计过的色对,不新造色值: +/// +/// | 位置 | 色对 | 对比度 | 来源 | +/// | --- | --- | --- | --- | +/// | 头部(帮助文字 + 标题 + 模式切换图标) | `surfaceTint` 底 + `primaryDark` | 7.98:1 | 选中 chip 同款(iteration-2/05 §3 D7) | +/// | 选中日 / 选中年 | `primaryStrong` 底 + 白字 | 4.49:1 | FAB / 头像徽标同款(同 §2) | +/// | 今日(未选中)描边与文字 | 白底 + `primaryStrong` | 4.49:1 | 同上 | +/// | 未选中日 / 年 | 白底 + `ink` | ≥12:1 | 正文主色 | +/// | 星期表头 | 白底 + `inkSoft` | 6.59:1 | 承载信息的次级文字(§3 DEBT-2) | +/// | 越界不可选日 | 白底 + `muted` | 3.36:1 | 禁用态,DEBT-2 允许的 `muted` 用途 | +/// | 确定 / 取消按钮 | 白底 + `primaryStrong` 文字 | 4.49:1 | 可点击文字链接(§1.1 token 注释) | +final DatePickerThemeData _datePickerTheme = DatePickerThemeData( + backgroundColor: AppColors.surface, + elevation: 0, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(AppRadius.xl)), + side: BorderSide(color: AppColors.border), + ), + dividerColor: AppColors.border, + headerBackgroundColor: AppColors.surfaceTint, + headerForegroundColor: AppColors.primaryDark, + headerHelpStyle: const TextStyle( + color: AppColors.primaryDark, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + headerHeadlineStyle: const TextStyle( + color: AppColors.primaryDark, + // 22(默认 32 偏大):中文 `formatMediumDate` 是「9月10日周四」5~6 字, + // 横屏侧栏头部宽度下 26 起就会折行,22 一行放得下。 + fontSize: 22, + fontWeight: FontWeight.w800, + ), + // 年份下拉 / 上下月箭头一行:与头部同族深色,白底 8.74:1。 + subHeaderForegroundColor: AppColors.primaryDark, + weekdayStyle: const TextStyle( + color: AppColors.inkSoft, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + dayStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + dayForegroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return AppColors.muted; + if (states.contains(WidgetState.selected)) return Colors.white; + return AppColors.ink; + }), + dayBackgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return AppColors.primaryStrong; + return null; + }), + dayOverlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return Colors.white24; + return AppColors.primary.withAlpha(31); + }), + todayForegroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return AppColors.muted; + if (states.contains(WidgetState.selected)) return Colors.white; + return AppColors.primaryStrong; + }), + todayBackgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return AppColors.primaryStrong; + return null; + }), + // 今日始终带描边(选中态由实底承载,未选中态靠 1.5px 描边定位): + // `primaryStrong` 白底 4.49:1,超过非文字元素 3:1 门槛。 + todayBorder: const BorderSide(color: AppColors.primaryStrong, width: 1.5), + yearStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + yearForegroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return AppColors.muted; + if (states.contains(WidgetState.selected)) return Colors.white; + return AppColors.ink; + }), + yearBackgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return AppColors.primaryStrong; + return null; + }), + yearOverlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return Colors.white24; + return AppColors.primary.withAlpha(31); + }), + cancelButtonStyle: TextButton.styleFrom( + foregroundColor: AppColors.inkSoft, + minimumSize: const Size(64, 44), + textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + confirmButtonStyle: TextButton.styleFrom( + foregroundColor: AppColors.primaryStrong, + minimumSize: const Size(64, 44), + textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700), + ), +); diff --git a/lib/core/widgets/app_date_picker.dart b/lib/core/widgets/app_date_picker.dart new file mode 100644 index 0000000..dae5ddb --- /dev/null +++ b/lib/core/widgets/app_date_picker.dart @@ -0,0 +1,144 @@ +/// 日期录入共享层(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, + ), + ], + ); + } +} diff --git a/lib/features/pets/care_reminder_form_page.dart b/lib/features/pets/care_reminder_form_page.dart index ead3a1b..8fd3dbe 100644 --- a/lib/features/pets/care_reminder_form_page.dart +++ b/lib/features/pets/care_reminder_form_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_date_picker.dart'; import 'package:patbond_flutter/core/widgets/app_text_field.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/primary_button.dart'; @@ -265,14 +266,22 @@ class _CareReminderFormPageState extends State { _dueDate == null ? '未选择' : dateToJson(_dueDate!), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), ), - trailing: const Icon( - Icons.calendar_month_outlined, - color: AppColors.muted, + trailing: AppDateFieldTrailing( + firstDate: DateTime.now(), + lastDate: DateTime(DateTime.now().year + 5), + enabled: !_submitting, + onToday: (value) { + _markStarted(); + setState(() { + _dueDate = value; + _dueError = null; + }); + }, ), enabled: !_submitting, onTap: () async { final now = DateTime.now(); - final value = await showDatePicker( + final value = await pickAppDate( context: context, initialDate: _dueDate ?? now, firstDate: now, diff --git a/lib/features/pets/care_reminders_page.dart b/lib/features/pets/care_reminders_page.dart index 1de52f5..81f6961 100644 --- a/lib/features/pets/care_reminders_page.dart +++ b/lib/features/pets/care_reminders_page.dart @@ -3,6 +3,7 @@ import 'package:patbond_flutter/analytics/analytics_page_name.dart'; import 'package:patbond_flutter/core/navigation/fade_route.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_date_picker.dart'; import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/record_type_dot.dart'; @@ -484,7 +485,7 @@ class _CompleteDialogState extends State<_CompleteDialog> { ), onTap: () async { final now = DateTime.now(); - final value = await showDatePicker( + final value = await pickAppDate( context: context, initialDate: _date, firstDate: DateTime(1990), @@ -494,6 +495,11 @@ class _CompleteDialogState extends State<_CompleteDialog> { setState(() => _date = value); } }, + trailing: AppDateFieldTrailing( + firstDate: DateTime(1990), + lastDate: DateTime.now(), + onToday: (value) => setState(() => _date = value), + ), ), ], ), diff --git a/lib/features/pets/health_event_form_page.dart b/lib/features/pets/health_event_form_page.dart index baec899..446303d 100644 --- a/lib/features/pets/health_event_form_page.dart +++ b/lib/features/pets/health_event_form_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_date_picker.dart'; import 'package:patbond_flutter/core/widgets/app_text_field.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/primary_button.dart'; @@ -269,14 +270,19 @@ class _HealthEventFormPageState extends State { dateToJson(_occurredDate), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), ), - trailing: const Icon( - Icons.calendar_month_outlined, - color: AppColors.muted, + trailing: AppDateFieldTrailing( + firstDate: DateTime(1990), + lastDate: DateTime.now(), + enabled: !_submitting, + onToday: (value) { + _markStarted(); + setState(() => _occurredDate = value); + }, ), enabled: !_submitting, onTap: () async { final now = DateTime.now(); - final value = await showDatePicker( + final value = await pickAppDate( context: context, initialDate: _occurredDate, firstDate: DateTime(1990), diff --git a/lib/features/pets/pet_form_page.dart b/lib/features/pets/pet_form_page.dart index 8c9632f..febab81 100644 --- a/lib/features/pets/pet_form_page.dart +++ b/lib/features/pets/pet_form_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_date_picker.dart'; import 'package:patbond_flutter/core/widgets/app_text_field.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/pet_avatar.dart'; @@ -659,14 +660,19 @@ class _PetFormPageState extends State { _birthDate == null ? '未填写' : dateToJson(_birthDate!), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), ), - trailing: const Icon( - Icons.calendar_month_outlined, - color: AppColors.muted, + trailing: AppDateFieldTrailing( + firstDate: DateTime(1990), + lastDate: DateTime.now(), + enabled: !_submitting, + onToday: (value) { + _markStarted(); + setState(() => _birthDate = value); + }, ), enabled: !_submitting, onTap: () async { final now = DateTime.now(); - final value = await showDatePicker( + final value = await pickAppDate( context: context, initialDate: _birthDate ?? DateTime(now.year - 1, now.month), firstDate: DateTime(1990), diff --git a/lib/features/pets/vaccination_form_page.dart b/lib/features/pets/vaccination_form_page.dart index 4e3ae07..78f914c 100644 --- a/lib/features/pets/vaccination_form_page.dart +++ b/lib/features/pets/vaccination_form_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_date_picker.dart'; import 'package:patbond_flutter/core/widgets/app_text_field.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/primary_button.dart'; @@ -503,14 +504,21 @@ class _VaccinationFormPageState extends State { value == null ? '未选择' : dateToJson(value), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), ), - trailing: const Icon( - Icons.calendar_month_outlined, - color: AppColors.muted, + trailing: AppDateFieldTrailing( + firstDate: DateTime(1990), + lastDate: allowFuture + ? DateTime(DateTime.now().year + 5) + : DateTime.now(), + enabled: !_submitting, + onToday: (value) { + _markStarted(); + onPicked(value); + }, ), enabled: !_submitting, onTap: () async { final now = DateTime.now(); - final picked = await showDatePicker( + final picked = await pickAppDate( context: context, initialDate: value ?? now, firstDate: DateTime(1990), diff --git a/lib/features/pets/vaccination_records_page.dart b/lib/features/pets/vaccination_records_page.dart index b9c3521..c1219f1 100644 --- a/lib/features/pets/vaccination_records_page.dart +++ b/lib/features/pets/vaccination_records_page.dart @@ -3,6 +3,7 @@ import 'package:patbond_flutter/analytics/analytics_page_name.dart'; import 'package:patbond_flutter/core/navigation/fade_route.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_date_picker.dart'; import 'package:patbond_flutter/core/widgets/app_text_field.dart'; import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; @@ -514,9 +515,19 @@ class _CompleteVaccinationDialogState value == null ? '未选择' : dateToJson(value), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), ), + trailing: AppDateFieldTrailing( + firstDate: DateTime(1990), + lastDate: allowFuture + ? DateTime(DateTime.now().year + 5) + : DateTime.now(), + onToday: (picked) => setState(() { + onPicked(picked); + _dateError = null; + }), + ), onTap: () async { final now = DateTime.now(); - final picked = await showDatePicker( + final picked = await pickAppDate( context: context, initialDate: value ?? now, firstDate: DateTime(1990), diff --git a/lib/features/pets/weight_form_page.dart b/lib/features/pets/weight_form_page.dart index ee6af84..8e8cd94 100644 --- a/lib/features/pets/weight_form_page.dart +++ b/lib/features/pets/weight_form_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_date_picker.dart'; import 'package:patbond_flutter/core/widgets/app_text_field.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/primary_button.dart'; @@ -241,14 +242,19 @@ class _WeightFormPageState extends State { dateToJson(_measuredDate), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), ), - trailing: const Icon( - Icons.calendar_month_outlined, - color: AppColors.muted, + trailing: AppDateFieldTrailing( + firstDate: DateTime(1990), + lastDate: DateTime.now(), + enabled: !_submitting, + onToday: (value) { + _markStarted(); + setState(() => _measuredDate = value); + }, ), enabled: !_submitting, onTap: () async { final now = DateTime.now(); - final value = await showDatePicker( + final value = await pickAppDate( context: context, initialDate: _measuredDate, firstDate: DateTime(1990), diff --git a/test/core/widgets/app_date_picker_test.dart b/test/core/widgets/app_date_picker_test.dart new file mode 100644 index 0000000..04f81d8 --- /dev/null +++ b/test/core/widgets/app_date_picker_test.dart @@ -0,0 +1,385 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/app/app_localization.dart'; +import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_date_picker.dart'; + +/// M3.5-01/02:日期录入共享层。 +/// +/// 覆盖三条用户实测反馈:中文文案实际渲染、键盘输入模式可用、「今天」快捷键。 +void main() { + /// 挂真实 app 配置(主题 + zh-CN delegate)的宿主:不挂 delegate 的话 + /// Material 会回退英文兜底,测出来的「中文」是假的。 + Widget host(Widget child) => MaterialApp( + theme: buildAppTheme(), + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + locale: appLocale, + home: Scaffold(body: child), + ); + + group('dateOnly / today / isDateSelectable', () { + test('dateOnly 抹掉时分秒', () { + expect( + dateOnly(DateTime(2026, 4, 9, 23, 59, 58, 777)), + DateTime(2026, 4, 9), + ); + }); + + test('today 为本地当天零点', () { + final now = DateTime.now(); + expect(today(), DateTime(now.year, now.month, now.day)); + }); + + test('isDateSelectable 闭区间按日粒度判定', () { + final first = DateTime(2026, 9, 1); + final last = DateTime(2026, 9, 30, 8, 30); + expect( + isDateSelectable( + date: DateTime(2026, 9, 1), + firstDate: first, + lastDate: last, + ), + isTrue, + ); + // 边界当天含时分也算落在区间内(lastDate 的时分不该把当天挤出去)。 + expect( + isDateSelectable( + date: DateTime(2026, 9, 30, 23, 0), + firstDate: first, + lastDate: last, + ), + isTrue, + ); + expect( + isDateSelectable( + date: DateTime(2026, 8, 31), + firstDate: first, + lastDate: last, + ), + isFalse, + ); + expect( + isDateSelectable( + date: DateTime(2026, 10, 1), + firstDate: first, + lastDate: last, + ), + isFalse, + ); + }); + }); + + group('pickAppDate · 中文本地化实际渲染', () { + testWidgets('日历模式:标题/确定/取消全中文(此前为 Select date / OK / Cancel)', ( + tester, + ) async { + await tester.pumpWidget( + host( + Builder( + builder: (context) => TextButton( + onPressed: () => pickAppDate( + context: context, + initialDate: DateTime(2026, 9, 10), + firstDate: DateTime(1990), + lastDate: DateTime(2026, 9, 30), + ), + child: const Text('打开'), + ), + ), + ), + ); + + await tester.tap(find.text('打开')); + await tester.pumpAndSettle(); + + expect(find.text('选择日期'), findsOneWidget); + expect(find.text('确定'), findsOneWidget); + expect(find.text('取消'), findsOneWidget); + expect(find.text('Select date'), findsNothing); + expect(find.text('OK'), findsNothing); + expect(find.text('Cancel'), findsNothing); + }); + + testWidgets('保留手输切换按钮(不用 calendarOnly),切换后提示与标签为中文', (tester) async { + await tester.pumpWidget( + host( + Builder( + builder: (context) => TextButton( + onPressed: () => pickAppDate( + context: context, + initialDate: DateTime(2026, 9, 10), + firstDate: DateTime(1990), + lastDate: DateTime(2026, 9, 30), + ), + child: const Text('打开'), + ), + ), + ), + ); + + await tester.tap(find.text('打开')); + await tester.pumpAndSettle(); + + // calendarOnly 会砍掉这枚铅笔按钮——手输是「录一个已知日期」的快路, + // 必须留着。 + final toggle = find.byIcon(Icons.edit_outlined); + expect(toggle, findsOneWidget); + + await tester.tap(toggle); + await tester.pumpAndSettle(); + + expect(find.text('输入日期'), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); + final field = tester.widget(find.byType(TextField)); + // 手输提示/标签由 zh-CN 本地化提供(不硬编码),不再是 mm/dd/yyyy。 + expect(field.decoration!.labelText, isNot(contains('Date'))); + expect(field.decoration!.hintText, isNot('mm/dd/yyyy')); + }); + + testWidgets('手输模式敲入已知日期即可返回(无需逐月点箭头)', (tester) async { + DateTime? picked; + await tester.pumpWidget( + host( + Builder( + builder: (context) => TextButton( + onPressed: () async { + picked = await pickAppDate( + context: context, + initialDate: DateTime(2026, 9, 10), + firstDate: DateTime(1990), + lastDate: DateTime(2026, 9, 30), + ); + }, + child: const Text('打开'), + ), + ), + ), + ); + + await tester.tap(find.text('打开')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.edit_outlined)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '2026/04/09'); + await tester.pumpAndSettle(); + await tester.tap(find.text('确定')); + await tester.pumpAndSettle(); + + expect(picked, DateTime(2026, 4, 9)); + }); + + testWidgets('取消返回 null;确认返回值已抹掉时分秒', (tester) async { + DateTime? picked; + var calls = 0; + await tester.pumpWidget( + host( + Builder( + builder: (context) => TextButton( + onPressed: () async { + calls++; + picked = await pickAppDate( + context: context, + initialDate: DateTime(2026, 9, 10, 15, 30), + firstDate: DateTime(1990), + lastDate: DateTime(2026, 9, 30), + ); + }, + child: const Text('打开'), + ), + ), + ), + ); + + await tester.tap(find.text('打开')); + await tester.pumpAndSettle(); + await tester.tap(find.text('取消')); + await tester.pumpAndSettle(); + expect(calls, 1); + expect(picked, isNull); + + await tester.tap(find.text('打开')); + await tester.pumpAndSettle(); + await tester.tap(find.text('确定')); + await tester.pumpAndSettle(); + expect(picked, DateTime(2026, 9, 10)); + }); + + testWidgets('initialDate 越界自动夹进 [firstDate, lastDate](不触发原生断言)', ( + tester, + ) async { + DateTime? picked; + await tester.pumpWidget( + host( + Builder( + builder: (context) => TextButton( + onPressed: () async { + // 历史值早于 firstDate:如「提醒到期日」firstDate 是今天, + // 而表单预填的是一个已过期的旧值。 + picked = await pickAppDate( + context: context, + initialDate: DateTime(2020, 1, 1), + firstDate: DateTime(2026, 9, 1), + lastDate: DateTime(2026, 9, 30), + ); + }, + child: const Text('打开'), + ), + ), + ), + ); + + await tester.tap(find.text('打开')); + await tester.pumpAndSettle(); + await tester.tap(find.text('确定')); + await tester.pumpAndSettle(); + + expect(picked, DateTime(2026, 9, 1)); + }); + }); + + group('AppDateFieldTrailing · 「今天」快捷键', () { + testWidgets('点「今天」直接回调今天,不开弹窗', (tester) async { + DateTime? got; + await tester.pumpWidget( + host( + AppDateFieldTrailing( + firstDate: DateTime(1990), + lastDate: DateTime(2100), + onToday: (value) => got = value, + ), + ), + ); + + expect(find.text('今天'), findsOneWidget); + expect(find.byIcon(Icons.calendar_month_outlined), findsOneWidget); + + await tester.tap(find.text('今天')); + await tester.pumpAndSettle(); + + expect(got, today()); + // 快捷键不经日期选择器(少两次点击、绕开月份导航)。 + expect(find.text('选择日期'), findsNothing); + }); + + testWidgets('今天越界时隐藏按钮,只留日历图标', (tester) async { + await tester.pumpWidget( + host( + AppDateFieldTrailing( + firstDate: DateTime(1990), + lastDate: DateTime(1999, 12, 31), + onToday: (_) {}, + ), + ), + ); + + expect(find.text('今天'), findsNothing); + expect(find.byIcon(Icons.calendar_month_outlined), findsOneWidget); + }); + + testWidgets('enabled=false(提交中)按钮禁用', (tester) async { + var calls = 0; + await tester.pumpWidget( + host( + AppDateFieldTrailing( + firstDate: DateTime(1990), + lastDate: DateTime(2100), + enabled: false, + onToday: (_) => calls++, + ), + ), + ); + + expect( + tester.widget(find.byType(TextButton)).onPressed, + isNull, + ); + await tester.tap(find.text('今天'), warnIfMissed: false); + await tester.pumpAndSettle(); + expect(calls, 0); + }); + + testWidgets('触控目标不小于 44×44', (tester) async { + await tester.pumpWidget( + host( + AppDateFieldTrailing( + firstDate: DateTime(1990), + lastDate: DateTime(2100), + onToday: (_) {}, + ), + ), + ); + + final size = tester.getSize(find.byType(TextButton)); + expect(size.width, greaterThanOrEqualTo(44)); + expect(size.height, greaterThanOrEqualTo(44)); + }); + }); + + group('datePickerTheme · 品牌配色(不再是 fromSeed 的暗红棕)', () { + test('选中日/选中年为 primaryStrong 实底白字,今日 primaryStrong 描边', () { + final theme = buildAppTheme().datePickerTheme; + const selected = {WidgetState.selected}; + + expect( + theme.dayBackgroundColor!.resolve(selected), + AppColors.primaryStrong, + ); + expect(theme.dayForegroundColor!.resolve(selected), Colors.white); + expect( + theme.yearBackgroundColor!.resolve(selected), + AppColors.primaryStrong, + ); + expect(theme.yearForegroundColor!.resolve(selected), Colors.white); + expect(theme.todayBorder!.color, AppColors.primaryStrong); + expect( + theme.todayForegroundColor!.resolve(const {}), + AppColors.primaryStrong, + ); + // 头部沿用「选中 chip」既有色对:surfaceTint 底 + primaryDark 字 7.98:1。 + expect(theme.headerBackgroundColor, AppColors.surfaceTint); + expect(theme.headerForegroundColor, AppColors.primaryDark); + expect(theme.backgroundColor, AppColors.surface); + // 越界不可选日走禁用态 muted(DEBT-2 允许的 muted 用途)。 + expect( + theme.dayForegroundColor!.resolve(const {WidgetState.disabled}), + AppColors.muted, + ); + }); + + testWidgets('实际渲染:选中日圆底取 primaryStrong', (tester) async { + await tester.pumpWidget( + host( + Builder( + builder: (context) => TextButton( + onPressed: () => pickAppDate( + context: context, + initialDate: DateTime(2026, 9, 10), + firstDate: DateTime(1990), + lastDate: DateTime(2026, 9, 30), + ), + child: const Text('打开'), + ), + ), + ), + ); + await tester.tap(find.text('打开')); + await tester.pumpAndSettle(); + + // 选中日的圆底由 Ink(decoration: ShapeDecoration) 承载,色值取自 + // datePickerTheme.dayBackgroundColor / todayBackgroundColor。 + final inkColors = tester + .widgetList(find.byType(Ink)) + .map((ink) => ink.decoration) + .whereType() + .map((d) => d.color) + .toList(); + expect( + inkColors, + contains(AppColors.primaryStrong), + reason: '选中日未使用品牌 primaryStrong 实底(仍是 fromSeed 派生色)', + ); + }); + }); +} diff --git a/test/features/pets/care_reminder_form_page_test.dart b/test/features/pets/care_reminder_form_page_test.dart index 4d479da..8709533 100644 --- a/test/features/pets/care_reminder_form_page_test.dart +++ b/test/features/pets/care_reminder_form_page_test.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/app/app_localization.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_date_picker.dart'; import 'package:patbond_flutter/features/pets/care_reminder_form_page.dart'; import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; import 'package:patbond_flutter/features/pets/pet_exceptions.dart'; @@ -34,6 +36,10 @@ void main() { await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), + // M3.5-01:与生产一致挂 zh-CN delegate。 + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + locale: appLocale, home: Builder( builder: (context) => Scaffold( body: Center( @@ -67,7 +73,7 @@ void main() { ); await tester.tap(find.text('到期日期')); await tester.pumpAndSettle(); - await tester.tap(find.text('OK')); + await tester.tap(find.text('确定')); await tester.pumpAndSettle(); } @@ -124,4 +130,36 @@ void main() { expect(failed[1]!['failureReason'], 'network_error'); expect(failed[1]!['attemptSeq'], 2); }); + + group('M3.5-02 · 到期日期录入(firstDate 就是今天的那一格)', () { + testWidgets('未选日期时也有「今天」快捷键;一键落今天并清掉必填校验错', (tester) async { + await pumpForm(tester); + + // 先触发必填校验错。 + await tester.tap(find.text('保存提醒')); + await tester.pumpAndSettle(); + expect(find.text('未选择'), findsOneWidget); + + await tester.tap(find.text('今天')); + await tester.pumpAndSettle(); + + expect(find.text(dateToJson(DateTime.now())), findsOneWidget); + expect(find.text('未选择'), findsNothing); + expect(find.text('选择日期'), findsNothing); + }); + + testWidgets('业务约束不变:firstDate 今天(不许补记过去)、lastDate 五年后', (tester) async { + await pumpForm(tester); + + await tester.tap(find.widgetWithText(ListTile, '到期日期')); + await tester.pumpAndSettle(); + + final dialog = tester.widget( + find.byType(DatePickerDialog), + ); + expect(dialog.firstDate, dateOnly(DateTime.now())); + expect(dialog.lastDate, DateTime(DateTime.now().year + 5)); + expect(find.text('选择日期'), findsOneWidget); + }); + }); } diff --git a/test/features/pets/health_event_form_page_test.dart b/test/features/pets/health_event_form_page_test.dart index 9f0d920..e48cd91 100644 --- a/test/features/pets/health_event_form_page_test.dart +++ b/test/features/pets/health_event_form_page_test.dart @@ -1,7 +1,11 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/app/app_localization.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; +import 'package:patbond_flutter/core/widgets/app_date_picker.dart'; import 'package:patbond_flutter/features/pets/health_event_form_page.dart'; import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; import 'package:patbond_flutter/features/pets/pet_exceptions.dart'; @@ -34,6 +38,11 @@ void main() { await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), + // M3.5-01:与生产一致挂 zh-CN delegate,否则测里的日期选择器是 + // 英文兜底,测不出用户真机看到的东西。 + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + locale: appLocale, home: Builder( builder: (context) => Scaffold( body: Center( @@ -192,4 +201,77 @@ void main() { expect(failed[2]!['failureReason'], 'network_error'); expect(failed[2]!['attemptSeq'], 3); }); + + group('M3.5-02 · 日期录入(用户误录 2026-04 的那一格)', () { + testWidgets('点「今天」直接落今天,不开弹窗;同时算首次输入触发 started', (tester) async { + await pumpForm(tester); + + final expected = dateToJson(DateTime.now()); + // 先把日期改成远月,模拟用户翻错月份后的状态。 + await tester.tap(find.widgetWithText(ListTile, '发生日期')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.edit_outlined)); + await tester.pumpAndSettle(); + await tester.enterText( + find.descendant( + of: find.byType(DatePickerDialog), + matching: find.byType(TextField), + ), + '2026/04/09', + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('确定')); + await tester.pumpAndSettle(); + expect(find.text('2026-04-09'), findsOneWidget); + + // 一键回今天:不经日期选择器。 + await tester.tap(find.text('今天')); + await tester.pumpAndSettle(); + + expect(find.text(expected), findsOneWidget); + expect(find.text('选择日期'), findsNothing); + expect(eventsOf('health_record_create_started'), hasLength(1)); + }); + + testWidgets('选择器为中文,且业务约束不变(firstDate 1990 / lastDate 今天,不许未来)', ( + tester, + ) async { + await pumpForm(tester); + + await tester.tap(find.widgetWithText(ListTile, '发生日期')); + await tester.pumpAndSettle(); + + expect(find.text('选择日期'), findsOneWidget); + expect(find.text('确定'), findsOneWidget); + expect(find.text('取消'), findsOneWidget); + // 手输快路可达(原生 calendarOnly 会砍掉它)。 + expect(find.byIcon(Icons.edit_outlined), findsOneWidget); + + final dialog = tester.widget( + find.byType(DatePickerDialog), + ); + expect(dialog.firstDate, DateTime(1990)); + expect(dialog.lastDate, dateOnly(DateTime.now())); + }); + + testWidgets('提交中禁用「今天」快捷键', (tester) async { + final gate = Completer(); + repository.createHealthEventHandler = (petId, req) => gate.future; + await pumpForm(tester); + await fillValid(tester); + + await tester.tap(find.text('保存记录')); + await tester.pump(); + + expect( + tester + .widget(find.widgetWithText(TextButton, '今天')) + .onPressed, + isNull, + ); + + gate.complete(buildHealthEvent('he-1')); + await tester.pumpAndSettle(); + }); + }); }