修复:日期录入收口共享层——「今天」快捷键 + 品牌配色 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>
This commit is contained in:
2026-09-10 17:59:52 +08:00
parent 6038901099
commit 294fc4a781
12 changed files with 824 additions and 23 deletions
+100
View File
@@ -176,6 +176,7 @@ ThemeData buildAppTheme() {
borderRadius: BorderRadius.all(Radius.circular(AppRadius.md)), borderRadius: BorderRadius.all(Radius.circular(AppRadius.md)),
), ),
), ),
datePickerTheme: _datePickerTheme,
navigationBarTheme: NavigationBarThemeData( navigationBarTheme: NavigationBarThemeData(
backgroundColor: AppColors.surface, backgroundColor: AppColors.surface,
indicatorColor: AppColors.surfaceTint, 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),
),
);
+144
View File
@@ -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<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,
),
],
);
}
}
+13 -4
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart'; import 'package:flutter/semantics.dart';
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.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/app_text_field.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/primary_button.dart'; import 'package:patbond_flutter/core/widgets/primary_button.dart';
@@ -265,14 +266,22 @@ class _CareReminderFormPageState extends State<CareReminderFormPage> {
_dueDate == null ? '未选择' : dateToJson(_dueDate!), _dueDate == null ? '未选择' : dateToJson(_dueDate!),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
), ),
trailing: const Icon( trailing: AppDateFieldTrailing(
Icons.calendar_month_outlined, firstDate: DateTime.now(),
color: AppColors.muted, lastDate: DateTime(DateTime.now().year + 5),
enabled: !_submitting,
onToday: (value) {
_markStarted();
setState(() {
_dueDate = value;
_dueError = null;
});
},
), ),
enabled: !_submitting, enabled: !_submitting,
onTap: () async { onTap: () async {
final now = DateTime.now(); final now = DateTime.now();
final value = await showDatePicker( final value = await pickAppDate(
context: context, context: context,
initialDate: _dueDate ?? now, initialDate: _dueDate ?? now,
firstDate: now, firstDate: now,
+7 -1
View File
@@ -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/navigation/fade_route.dart';
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.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/empty_state_illustration.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart'; import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
@@ -484,7 +485,7 @@ class _CompleteDialogState extends State<_CompleteDialog> {
), ),
onTap: () async { onTap: () async {
final now = DateTime.now(); final now = DateTime.now();
final value = await showDatePicker( final value = await pickAppDate(
context: context, context: context,
initialDate: _date, initialDate: _date,
firstDate: DateTime(1990), firstDate: DateTime(1990),
@@ -494,6 +495,11 @@ class _CompleteDialogState extends State<_CompleteDialog> {
setState(() => _date = value); setState(() => _date = value);
} }
}, },
trailing: AppDateFieldTrailing(
firstDate: DateTime(1990),
lastDate: DateTime.now(),
onToday: (value) => setState(() => _date = value),
),
), ),
], ],
), ),
+10 -4
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart'; import 'package:flutter/semantics.dart';
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.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/app_text_field.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/primary_button.dart'; import 'package:patbond_flutter/core/widgets/primary_button.dart';
@@ -269,14 +270,19 @@ class _HealthEventFormPageState extends State<HealthEventFormPage> {
dateToJson(_occurredDate), dateToJson(_occurredDate),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
), ),
trailing: const Icon( trailing: AppDateFieldTrailing(
Icons.calendar_month_outlined, firstDate: DateTime(1990),
color: AppColors.muted, lastDate: DateTime.now(),
enabled: !_submitting,
onToday: (value) {
_markStarted();
setState(() => _occurredDate = value);
},
), ),
enabled: !_submitting, enabled: !_submitting,
onTap: () async { onTap: () async {
final now = DateTime.now(); final now = DateTime.now();
final value = await showDatePicker( final value = await pickAppDate(
context: context, context: context,
initialDate: _occurredDate, initialDate: _occurredDate,
firstDate: DateTime(1990), firstDate: DateTime(1990),
+10 -4
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart'; import 'package:flutter/semantics.dart';
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.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/app_text_field.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/pet_avatar.dart'; import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
@@ -659,14 +660,19 @@ class _PetFormPageState extends State<PetFormPage> {
_birthDate == null ? '未填写' : dateToJson(_birthDate!), _birthDate == null ? '未填写' : dateToJson(_birthDate!),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
), ),
trailing: const Icon( trailing: AppDateFieldTrailing(
Icons.calendar_month_outlined, firstDate: DateTime(1990),
color: AppColors.muted, lastDate: DateTime.now(),
enabled: !_submitting,
onToday: (value) {
_markStarted();
setState(() => _birthDate = value);
},
), ),
enabled: !_submitting, enabled: !_submitting,
onTap: () async { onTap: () async {
final now = DateTime.now(); final now = DateTime.now();
final value = await showDatePicker( final value = await pickAppDate(
context: context, context: context,
initialDate: _birthDate ?? DateTime(now.year - 1, now.month), initialDate: _birthDate ?? DateTime(now.year - 1, now.month),
firstDate: DateTime(1990), firstDate: DateTime(1990),
+12 -4
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart'; import 'package:flutter/semantics.dart';
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.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/app_text_field.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/primary_button.dart'; import 'package:patbond_flutter/core/widgets/primary_button.dart';
@@ -503,14 +504,21 @@ class _VaccinationFormPageState extends State<VaccinationFormPage> {
value == null ? '未选择' : dateToJson(value), value == null ? '未选择' : dateToJson(value),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
), ),
trailing: const Icon( trailing: AppDateFieldTrailing(
Icons.calendar_month_outlined, firstDate: DateTime(1990),
color: AppColors.muted, lastDate: allowFuture
? DateTime(DateTime.now().year + 5)
: DateTime.now(),
enabled: !_submitting,
onToday: (value) {
_markStarted();
onPicked(value);
},
), ),
enabled: !_submitting, enabled: !_submitting,
onTap: () async { onTap: () async {
final now = DateTime.now(); final now = DateTime.now();
final picked = await showDatePicker( final picked = await pickAppDate(
context: context, context: context,
initialDate: value ?? now, initialDate: value ?? now,
firstDate: DateTime(1990), firstDate: DateTime(1990),
@@ -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/navigation/fade_route.dart';
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.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/app_text_field.dart';
import 'package:patbond_flutter/core/widgets/empty_state_illustration.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/inline_error_banner.dart';
@@ -514,9 +515,19 @@ class _CompleteVaccinationDialogState
value == null ? '未选择' : dateToJson(value), value == null ? '未选择' : dateToJson(value),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), 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 { onTap: () async {
final now = DateTime.now(); final now = DateTime.now();
final picked = await showDatePicker( final picked = await pickAppDate(
context: context, context: context,
initialDate: value ?? now, initialDate: value ?? now,
firstDate: DateTime(1990), firstDate: DateTime(1990),
+10 -4
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart'; import 'package:flutter/semantics.dart';
import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.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/app_text_field.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/primary_button.dart'; import 'package:patbond_flutter/core/widgets/primary_button.dart';
@@ -241,14 +242,19 @@ class _WeightFormPageState extends State<WeightFormPage> {
dateToJson(_measuredDate), dateToJson(_measuredDate),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
), ),
trailing: const Icon( trailing: AppDateFieldTrailing(
Icons.calendar_month_outlined, firstDate: DateTime(1990),
color: AppColors.muted, lastDate: DateTime.now(),
enabled: !_submitting,
onToday: (value) {
_markStarted();
setState(() => _measuredDate = value);
},
), ),
enabled: !_submitting, enabled: !_submitting,
onTap: () async { onTap: () async {
final now = DateTime.now(); final now = DateTime.now();
final value = await showDatePicker( final value = await pickAppDate(
context: context, context: context,
initialDate: _measuredDate, initialDate: _measuredDate,
firstDate: DateTime(1990), firstDate: DateTime(1990),
+385
View File
@@ -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<TextField>(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<TextButton>(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>{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);
// 越界不可选日走禁用态 mutedDEBT-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<Ink>(find.byType(Ink))
.map((ink) => ink.decoration)
.whereType<ShapeDecoration>()
.map((d) => d.color)
.toList();
expect(
inkColors,
contains(AppColors.primaryStrong),
reason: '选中日未使用品牌 primaryStrong 实底(仍是 fromSeed 派生色)',
);
});
});
}
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.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/care_reminder_form_page.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart'; import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
@@ -34,6 +36,10 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
theme: buildAppTheme(), theme: buildAppTheme(),
// M3.5-01:与生产一致挂 zh-CN delegate。
localizationsDelegates: appLocalizationsDelegates,
supportedLocales: appSupportedLocales,
locale: appLocale,
home: Builder( home: Builder(
builder: (context) => Scaffold( builder: (context) => Scaffold(
body: Center( body: Center(
@@ -67,7 +73,7 @@ void main() {
); );
await tester.tap(find.text('到期日期')); await tester.tap(find.text('到期日期'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('OK')); await tester.tap(find.text('确定'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }
@@ -124,4 +130,36 @@ void main() {
expect(failed[1]!['failureReason'], 'network_error'); expect(failed[1]!['failureReason'], 'network_error');
expect(failed[1]!['attemptSeq'], 2); 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<DatePickerDialog>(
find.byType(DatePickerDialog),
);
expect(dialog.firstDate, dateOnly(DateTime.now()));
expect(dialog.lastDate, DateTime(DateTime.now().year + 5));
expect(find.text('选择日期'), findsOneWidget);
});
});
} }
@@ -1,7 +1,11 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.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_event_form_page.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart'; import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
@@ -34,6 +38,11 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
theme: buildAppTheme(), theme: buildAppTheme(),
// M3.5-01:与生产一致挂 zh-CN delegate,否则测里的日期选择器是
// 英文兜底,测不出用户真机看到的东西。
localizationsDelegates: appLocalizationsDelegates,
supportedLocales: appSupportedLocales,
locale: appLocale,
home: Builder( home: Builder(
builder: (context) => Scaffold( builder: (context) => Scaffold(
body: Center( body: Center(
@@ -192,4 +201,77 @@ void main() {
expect(failed[2]!['failureReason'], 'network_error'); expect(failed[2]!['failureReason'], 'network_error');
expect(failed[2]!['attemptSeq'], 3); 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<DatePickerDialog>(
find.byType(DatePickerDialog),
);
expect(dialog.firstDate, DateTime(1990));
expect(dialog.lastDate, dateOnly(DateTime.now()));
});
testWidgets('提交中禁用「今天」快捷键', (tester) async {
final gate = Completer<HealthEvent>();
repository.createHealthEventHandler = (petId, req) => gate.future;
await pumpForm(tester);
await fillValid(tester);
await tester.tap(find.text('保存记录'));
await tester.pump();
expect(
tester
.widget<TextButton>(find.widgetWithText(TextButton, '今天'))
.onPressed,
isNull,
);
gate.complete(buildHealthEvent('he-1'));
await tester.pumpAndSettle();
});
});
} }