294fc4a781
用户实测已因日期选择器误录: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>
297 lines
11 KiB
Dart
297 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
||
|
||
/// 语义色 token(ADR-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),
|
||
),
|
||
);
|