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

297 lines
11 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.
import 'package:flutter/material.dart';
/// 语义色 tokenADR-005:以 AI宠物_iOS_UI设计稿.html 珊瑚橙暖色系为品牌正典,
/// 详见 iteration-1-reports/04-ui-login-design-spec.md §1.1)。
abstract final class AppColors {
/// 品牌珊瑚橙:图标、装饰、渐变起点;不用于承载文字的实心填充(对比度不足)。
static const primary = Color(0xFFFF6F4C);
/// 加深珊瑚:实心按钮填充、可点击文字链接(白字约 4.5:1,WCAG AA)。
static const primaryStrong = Color(0xFFD6431A);
/// 浅色底上的强调文字、按钮反白替代。
static const primaryDark = Color(0xFF7A2E12);
/// amber:渐变终点、徽章、会员/促销 CTA。
static const accent = Color(0xFFFFB648);
/// amber 底上的文字。
static const accentDark = Color(0xFF7A4B0A);
/// 页面背景(奶油底)。
static const canvas = Color(0xFFFFF7ED);
/// 卡片、输入框背景。
static const surface = Color(0xFFFFFFFF);
/// peach:图标底、占位块、选中指示。
static const surfaceTint = Color(0xFFFFE8D6);
/// 主文字。
static const ink = Color(0xFF3E2A1F);
/// 承载信息的次级文字(日期、元数据、字段名;正典 feed-caption 色,
/// 白底 6.59:1 达 AA)。muted 仅限占位符、禁用态、纯装饰(05 号规范 DEBT-2)。
static const inkSoft = Color(0xFF6B5A4A);
/// 次级文字、占位符。
static const muted = Color(0xFF9C8977);
/// 描边、分隔线。
static const border = Color(0xFFF0DCC8);
/// sage:成功提示的图标/描边。
static const success = Color(0xFF7FA88A);
/// 成功提示底上的文字。
static const successInk = Color(0xFF3F5744);
/// 成功提示底色。
static const successSurface = Color(0xFFE8F0E8);
static const warning = Color(0xFFF59E0B);
/// 校验错误文字、错误描边、失败提示(白底上约 5.4:1,AA 达标)。
static const error = Color(0xFFD0342C);
/// error 淡底(8% tint)上的文字(error 本身在自家淡底仅 4.44:1 贴线不过;
/// 与 primary→primaryStrong 同构加深,05 号规范 §2/D8)。
static const errorDark = Color(0xFFB02C25);
/// 品牌渐变:hero 区、头像环、装饰性大面积(不承载正文文字)。
static const brandGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [primary, accent],
);
}
/// 圆角 token(设计规范 §1.3)。
abstract final class AppRadius {
/// 小按钮、内嵌 CTA、错误横幅。
static const sm = 12.0;
/// 主按钮、SnackBar、菜单组。
static const md = 16.0;
/// 输入框、feed 卡。
static const lg = 18.0;
/// 大卡片。
static const xl = 24.0;
/// 芯片、徽章。
static const pill = 999.0;
}
ThemeData buildAppTheme() {
final scheme = ColorScheme.fromSeed(
seedColor: AppColors.primary,
brightness: Brightness.light,
surface: AppColors.surface,
).copyWith(error: AppColors.error, onError: Colors.white);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: AppColors.canvas,
fontFamilyFallback: const [
'Noto Sans CJK SC',
'Noto Sans SC',
'PingFang SC',
'Microsoft YaHei',
'sans-serif',
],
textTheme: const TextTheme(
headlineSmall: TextStyle(
color: AppColors.ink,
fontSize: 22,
fontWeight: FontWeight.w800,
),
titleLarge: TextStyle(
color: AppColors.ink,
fontSize: 18,
fontWeight: FontWeight.w800,
),
titleMedium: TextStyle(
color: AppColors.ink,
fontSize: 15,
fontWeight: FontWeight.w700,
),
bodyMedium: TextStyle(color: AppColors.ink, fontSize: 14, height: 1.5),
bodySmall: TextStyle(color: AppColors.muted, fontSize: 12, height: 1.4),
),
cardTheme: const CardThemeData(
color: AppColors.surface,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.xl)),
side: BorderSide(color: AppColors.border),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: AppColors.primaryStrong,
foregroundColor: Colors.white,
disabledBackgroundColor: AppColors.ink.withAlpha(31),
disabledForegroundColor: AppColors.ink.withAlpha(97),
minimumSize: const Size(64, 52),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.md)),
),
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
),
),
inputDecorationTheme: const InputDecorationTheme(
filled: true,
fillColor: AppColors.surface,
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
borderSide: BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
borderSide: BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
borderSide: BorderSide(color: AppColors.primary, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
borderSide: BorderSide(color: AppColors.error, width: 1.5),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.lg)),
borderSide: BorderSide(color: AppColors.error, width: 1.5),
),
errorStyle: TextStyle(color: AppColors.error, fontSize: 12),
helperStyle: TextStyle(color: AppColors.muted, fontSize: 12),
),
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.md)),
),
),
datePickerTheme: _datePickerTheme,
navigationBarTheme: NavigationBarThemeData(
backgroundColor: AppColors.surface,
indicatorColor: AppColors.surfaceTint,
height: 72,
labelTextStyle: WidgetStateProperty.resolveWith((states) {
return TextStyle(
fontSize: 11,
fontWeight: states.contains(WidgetState.selected)
? FontWeight.w700
: FontWeight.w500,
color: states.contains(WidgetState.selected)
? AppColors.primaryStrong
: AppColors.muted,
);
}),
),
);
}
/// 日期选择器主题(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),
),
);