修复:日期录入收口共享层——「今天」快捷键 + 品牌配色 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:
@@ -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);
|
||||
// 越界不可选日走禁用态 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<Ink>(find.byType(Ink))
|
||||
.map((ink) => ink.decoration)
|
||||
.whereType<ShapeDecoration>()
|
||||
.map((d) => d.color)
|
||||
.toList();
|
||||
expect(
|
||||
inkColors,
|
||||
contains(AppColors.primaryStrong),
|
||||
reason: '选中日未使用品牌 primaryStrong 实底(仍是 fromSeed 派生色)',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user