3 Commits

Author SHA1 Message Date
lixi 7d5c84d06d 修复:花费卡展示实际月份 + 四张数据卡补可点提示(M3.5-03)
CI / flutter-gates (push) Successful in 3m24s
卡片标签硬编码「本月花费」,而服务端 summary.monthlyExpense 本就返回 month
(ISO year-month,按 tz 归月)。用户看不到实际月份,所以无法自证「我这条记录
到底落在哪个月」,把正确的 ¥0 当成统计故障。

已核实不改后端聚合:用户记录落在 2026-04-09、当天是 2026-09-10,
「本月花费 ¥0」是正确行为。本单只让客户端把口径亮出来。

- health_record_display 新增纯函数 monthlyExpenseCardLabel(month, {now}):
  同年「9 月花费」(窄卡一行放得下,故不取「2026-09 花费」);跨年(服务端
  归月年份 ≠ 设备当前年份)「2026/12 花费」补年份消歧;串非法退回
  「本月花费」不崩不显示脏值。
- 可点提示:_SummaryCard 在 onTap 非空时右上角补 chevron_right。四张卡本都
  可点进明细页却无任何视觉提示(用户反馈不知道能点),提示形态沿用项目既有
  可点行/卡(宠物列表卡、健康提醒卡、资料页设置行)的 chevron_right,不自创。
- 整卡包 MergeSemantics:读屏一次读全「¥128.50,9 月花费,按钮」而非两段
  孤立文字。没有用 excludeSemantics——那会连带丢掉 InkWell 的可激活性。

既有测试口径调整:pet_detail_page_test 两处断言改为实际月份,且样本
monthlyExpense.month 改用当月串使断言不随年份漂移(跨年格式由纯函数单测覆盖)。

新增 integration_test/client_ux_live_test.dart(环境变量门控,默认 skip,
沿用 M3 既有 live 测试形态):compose 六容器 + 真实 App 走完三项修复——
日期选择器全中文/品牌配色/手输录入/一键今天,以及花费卡实际月份 + chevron。
本机是 Wayland 会话、X11 import -window root 取不到根窗口,改为把整棵 App 包
一层 RepaintBoundary 后 toImage() 直出真实渲染像素落 build/ux-live/。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 18:00:21 +08:00
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
lixi 6038901099 修复:Material 内置组件中文化——挂 zh-CN 三件套 delegate(M3.5-01)
用户 Linux 桌面实测反馈「日期选择器全英文」。根因是 MaterialApp 从一迭代
建起就没配 localizationsDelegates/supportedLocales/locale,pubspec 也没有
flutter_localizations;Flutter 在缺 delegate 时静默回退内置的
DefaultMaterialLocalizations(只有英文),于是业务自绘文案全中文、Material
内置组件全英文,同一弹窗里中英混排:

  Select date → 选择日期
  OK / Cancel → 确定 / 取消
  Enter Date → 输入日期
  Switch to input → 切换到输入模式
  Invalid format. / Out of range. → 格式无效。/ 超出范围。
  mm/dd/yyyy → yyyy/mm/dd(zh 顺序年在前)
  September 2026 → 2026年9月

处置:

- pubspec 加 flutter_localizations(sdk)与 intl(日期符号底座,显式直接
  依赖以锁定与 SDK 一致的版本)。
- 新增 lib/app/app_localization.dart 把 Global{Material,Cupertino,Widgets}
  三件套 delegate、supportedLocales、locale 收在一处常量:widget 测试若只
  pumpWidget(MaterialApp(home: ...)) 而不挂 delegate,测到的「中文」是假的,
  测试直接引用同一份常量,生产与测试不会各配一套而漂移。
- 单语言 zh-CN,不列 en:避免设备语言为英文时回退英文,再造混排。
- pets 域三份 widget 测试的 MaterialApp 同步挂上 delegate,
  tap(find.text('OK')) 相应改 '确定'——让测试与生产一致,而不是在英文兜底下测。

新增 test/app/app_localization_test.dart 守住这一行不被回删:断言根
MaterialApp 实际挂上三件套 + locale zh-CN,并从运行期 MaterialLocalizations
取回中文文案。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 17:59:21 +08:00
25 changed files with 1356 additions and 61 deletions
+243
View File
@@ -0,0 +1,243 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui' show ImageByteFormat;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:patbond_flutter/app/app.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
import 'package:patbond_flutter/features/pets/pet_detail_page.dart';
import 'package:patbond_flutter/features/pets/pet_form_page.dart';
/// 内存 token 存储(桌面实测环境无 keyring;不落任何持久化)。
class _InMemoryTokenStore implements TokenStore {
final Map<String, String> _values = {};
@override
Future<String?> read(String key) async => _values[key];
@override
Future<void> write(String key, String value) async => _values[key] = value;
@override
Future<void> delete(String key) async => _values.remove(key);
}
/// M3.5-01/02/03 compose 真链路桌面实测(默认跳过,不计入常规测试套件):
///
/// ```bash
/// # 先起后端六容器(patbond-api 仓库根 docker compose up -d --build),再:
/// PATBOND_UX_LIVE=1 flutter test integration_test/client_ux_live_test.dart -d linux
/// ```
///
/// 驱动**真实 App**Linux 桌面 GTK 渲染管线 + 真实 HTTP)走用户实测那条路:
/// 注册 → UI 登录 → 档案页建档 → 打开生日选择器(校验全中文 + 品牌配色 +
/// 手输可用 + 一键今天)→ 保存进详情页(校验花费卡展示实际月份 + chevron)。
/// 逐步截图落到 `build/ux-live/`:本机是 Wayland 会话,X11 的
/// `import -window root` 取不到根窗口,改为把整棵 App 包一层
/// [RepaintBoundary] 后 `toImage()` 直出真实渲染像素(含 Overlay 里的弹窗)。
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
final enabled = Platform.environment['PATBOND_UX_LIVE'] == '1';
const authBase = 'http://127.0.0.1:8081';
const petBase = 'http://127.0.0.1:8083';
final shotDir = Directory('build/ux-live');
const rootKey = ValueKey<String>('ux-live-root');
Future<void> shot(WidgetTester tester, String name) async {
final boundary = tester.renderObject<RenderRepaintBoundary>(
find.byKey(rootKey),
);
await tester.runAsync(() async {
if (!shotDir.existsSync()) shotDir.createSync(recursive: true);
final image = await boundary.toImage(pixelRatio: 1.5);
final bytes = await image.toByteData(format: ImageByteFormat.png);
final path = '${shotDir.path}/$name.png';
File(path).writeAsBytesSync(bytes!.buffer.asUint8List());
debugPrint('[shot] $name$path (${bytes.lengthInBytes} bytes)');
});
}
Future<void> pumpUntil(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 25),
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 250));
if (finder.evaluate().isNotEmpty) return;
}
fail('等待超时:$finder');
}
Future<void> pumpUntilGone(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 25),
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 250));
if (finder.evaluate().isEmpty) return;
}
fail('等待消失超时:$finder');
}
/// 直连服务端下单个 JSON 请求(种子数据走真实 HTTP,不绕 DB)。
Future<Map<String, dynamic>> postJson(
String url,
Map<String, Object?> body, {
String? bearer,
}) async {
final client = HttpClient();
final request = await client.postUrl(Uri.parse(url));
request.headers.contentType = ContentType.json;
if (bearer != null) {
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $bearer');
}
request.add(utf8.encode(jsonEncode(body)));
final response = await request.close();
final text = await response.transform(utf8.decoder).join();
client.close();
expect(response.statusCode, anyOf(200, 201), reason: 'POST $url 失败:$text');
final envelope = jsonDecode(text) as Map<String, dynamic>;
expect(envelope['code'], 0, reason: 'POST $url 业务码非 0$text');
return envelope['data'] as Map<String, dynamic>;
}
testWidgets('桌面真链路:日期选择器中文/品牌色/手输/今天 + 花费卡实际月份', (tester) async {
// ---- 注册一次性账号 + 种子数据(宠物 + 当月一笔就医支出)----
final seed = DateTime.now().millisecondsSinceEpoch;
final username = 'uxlive$seed';
final password = 'Live1234!$seed';
await postJson('$authBase/api/v1/auth/register', {
'username': username,
'phone': '+86137${(seed % 100000000).toString().padLeft(8, '0')}',
'password': password,
});
final tokens = await postJson('$authBase/api/v1/auth/login', {
'username': username,
'password': password,
});
final accessToken = tokens['accessToken'] as String;
final pet = await postJson('$petBase/api/v1/pets', {
'name': '实测豆豆',
'species': 'dog',
'sex': 'male',
'customBreedName': '中华田园犬',
}, bearer: accessToken);
final petId = pet['id'] as String;
// 当月一笔 128.50 元就医支出:花费卡应显示「N 月花费 ¥128.50」——
// 用户那次误录(记到 4 月)看到 ¥0 正是因为记录不在当月窗口内。
await postJson('$petBase/api/v1/pets/$petId/health-events', {
'eventType': 'medical',
'occurredAt': DateTime.now().toUtc().toIso8601String(),
'title': '皮肤检查',
'amountCents': 12850,
}, bearer: accessToken);
// ---- 启动真实 App ----
await tester.pumpWidget(
RepaintBoundary(
key: rootKey,
child: App(
sessionManager: SessionManager(store: _InMemoryTokenStore()),
),
),
);
await pumpUntil(tester, find.text('登录'));
await tester.enterText(find.byType(TextField).at(0), username);
await tester.enterText(find.byType(TextField).at(1), password);
await tester.tap(find.text('登录'));
// ---- 档案 Tab → 建档表单 ----
await pumpUntil(tester, find.text('档案'));
await tester.tap(find.text('档案'));
// 列表已有种子宠物 → 走虚线「+ 添加宠物」卡(空态 CTA 文案不同)。
await pumpUntil(tester, find.textContaining('添加宠物'));
await tester.tap(find.textContaining('添加宠物').first);
await pumpUntil(tester, find.byType(PetFormPage));
await shot(tester, '01-pet-form');
// ---- 生日日期选择器:全中文 + 品牌配色 ----
await tester.tap(find.widgetWithText(ListTile, '生日(可选)'));
await pumpUntil(tester, find.byType(DatePickerDialog));
await tester.pump(const Duration(milliseconds: 600));
await shot(tester, '02-date-picker-zh');
expect(find.text('选择日期'), findsOneWidget, reason: '标题非中文');
expect(find.text('确定'), findsOneWidget);
expect(find.text('取消'), findsOneWidget);
expect(find.text('Select date'), findsNothing);
expect(find.text('OK'), findsNothing);
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: '选中日仍是 fromSeed 派生的暗红棕,未取品牌 primaryStrong',
);
// ---- 手输模式:中文标签 + 敲入已知日期 ----
await tester.tap(find.byIcon(Icons.edit_outlined));
await pumpUntil(tester, find.text('输入日期'));
await shot(tester, '03-date-input-zh');
final field = find.descendant(
of: find.byType(DatePickerDialog),
matching: find.byType(TextField),
);
await tester.enterText(field, '2024/03/15');
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('确定'));
await pumpUntil(tester, find.text('2024-03-15'));
await shot(tester, '04-date-typed');
// ---- 「今天」快捷键:一键落今天,不开弹窗 ----
final todayText =
'${DateTime.now().year}-'
'${DateTime.now().month.toString().padLeft(2, '0')}-'
'${DateTime.now().day.toString().padLeft(2, '0')}';
await tester.tap(find.widgetWithText(TextButton, '今天'));
await pumpUntil(tester, find.text(todayText));
expect(find.byType(DatePickerDialog), findsNothing, reason: '「今天」不该开弹窗');
await shot(tester, '05-today-shortcut');
// ---- 退出建档表单(本单不验建档链路,宠物与支出已由 API 种下)----
await tester.tap(find.byIcon(Icons.arrow_back));
await pumpUntilGone(tester, find.byType(PetFormPage));
// ---- 详情页:花费卡展示实际月份 + chevron 可点提示 ----
await pumpUntil(tester, find.text('实测豆豆'));
await shot(tester, '06-pets-list');
await tester.tap(find.text('实测豆豆'));
await pumpUntil(tester, find.byType(PetDetailPage));
final monthLabel = '${DateTime.now().month} 月花费';
await pumpUntil(tester, find.text(monthLabel));
await tester.pump(const Duration(milliseconds: 600));
await shot(tester, '07-pet-detail-expense-card');
expect(find.text('本月花费'), findsNothing, reason: '仍是硬编码「本月花费」');
// 当月真有支出 → 卡片给出金额(月份口径自证:记录落在当月才计入)。
expect(find.text('¥128.50'), findsOneWidget);
expect(
find.ancestor(
of: find.text(monthLabel),
matching: find.widgetWithIcon(Card, Icons.chevron_right),
),
findsOneWidget,
reason: '花费卡缺 chevron 可点提示',
);
}, skip: !enabled);
}
+6
View File
@@ -5,6 +5,7 @@ import 'package:patbond_flutter/analytics/analytics_route_observer.dart';
import 'package:patbond_flutter/analytics/analytics_service.dart'; import 'package:patbond_flutter/analytics/analytics_service.dart';
import 'package:patbond_flutter/analytics/page_view_tracker.dart'; import 'package:patbond_flutter/analytics/page_view_tracker.dart';
import 'package:patbond_flutter/analytics/session_tracker.dart'; import 'package:patbond_flutter/analytics/session_tracker.dart';
import 'package:patbond_flutter/app/app_localization.dart';
import 'package:patbond_flutter/core/network/api_client.dart'; import 'package:patbond_flutter/core/network/api_client.dart';
import 'package:patbond_flutter/core/network/token_refresher.dart'; import 'package:patbond_flutter/core/network/token_refresher.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart';
@@ -268,6 +269,11 @@ class _AppState extends State<App> {
title: 'Patbond', title: 'Patbond',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: buildAppTheme(), theme: buildAppTheme(),
// M3.5-01Material 内置组件中文化(此前未配 delegate,日期选择器等
// 全部回退英文兜底)。单语言 zh-CN,不随设备语言回退英文。
localizationsDelegates: appLocalizationsDelegates,
supportedLocales: appSupportedLocales,
locale: appLocale,
navigatorObservers: [_routeObserver], navigatorObservers: [_routeObserver],
home: ListenableBuilder( home: ListenableBuilder(
listenable: sessionManager, listenable: sessionManager,
+31
View File
@@ -0,0 +1,31 @@
/// 应用本地化配置(M3.5-01)。
///
/// 根因备忘:M3 之前 [MaterialApp] 未配任何 `localizationsDelegates`
/// Flutter 回退到内置的 `DefaultMaterialLocalizations`(仅英文),导致
/// 一切 Material 内置组件(日期选择器标题「Select date」、确定/取消
/// 「OK」/「Cancel」、模式切换 tooltip「Switch to input」、格式报错
/// 「Invalid format.」)全英文,而业务自绘文案全中文——同一弹窗内中英混排。
/// 挂上三件套后分别为「选择日期 / 确定 / 取消 / 切换到输入模式 / 格式无效。」。
///
/// 这里把三件套 delegate 与 locale 收在一处,供 `App` 与 widget 测试共用:
/// 测试若只 `pumpWidget(MaterialApp(home: ...))` 而不带 delegate,看到的仍是
/// 英文兜底,与真机不一致;需要断言中文渲染的测试请一并挂上本文件的常量。
library;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
/// Material / Cupertino / Widgets 三件套本地化 delegate。
const List<LocalizationsDelegate<Object>> appLocalizationsDelegates =
<LocalizationsDelegate<Object>>[
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// 简体中文(中国大陆)。M3.5 仅单语言,暂不做语言切换。
const Locale appLocale = Locale('zh', 'CN');
/// 支持语言列表。仅 zh-CN——避免设备语言为英文时回退到英文,
/// 造成同一界面内业务中文 + 组件英文的混排。
const List<Locale> appSupportedLocales = <Locale>[appLocale];
+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),
@@ -125,6 +125,27 @@ String healthEventMonthHeader(DateTime occurredAt) {
return '${local.year}${local.month}'; return '${local.year}${local.month}';
} }
/// 月度花费卡标签(M3.5-03)。
///
/// 根因备忘:卡片标签此前硬编码「本月花费」,用户无法自证「本月」到底是
/// 哪个月——他把当月记录误录到 2026-04 后看到「本月花费 ¥0」,以为聚合坏了。
/// 服务端 `summary.monthlyExpense.month` 本就返回 ISO year-month(如 `2026-09`
/// 按 `tz` 归月),直接展示即可自查,无需改后端。
///
/// [month] 形如 `2026-09`。窄卡(一行四卡)只放得下 4~5 个字,故取「9 月花费」
/// 而非「2026-09 花费」;跨年(服务端归月的年份与设备当前年份不一致,如设备
/// 已跨到 1 月而窗口仍是去年 12 月)时补年份消歧。解析失败退回「本月花费」。
String monthlyExpenseCardLabel(String month, {DateTime? now}) {
final match = RegExp(r'^(\d{4})-(\d{2})$').firstMatch(month);
if (match == null) return '本月花费';
final year = int.parse(match.group(1)!);
final monthNo = int.parse(match.group(2)!);
if (monthNo < 1 || monthNo > 12) return '本月花费';
final currentYear = (now ?? DateTime.now()).year;
if (year != currentYear) return '$year/$monthNo 花费';
return '$monthNo 月花费';
}
/// 设备时区 → summary `tz` 参数(契约接受固定偏移形如 `+08:00`; /// 设备时区 → summary `tz` 参数(契约接受固定偏移形如 `+08:00`;
/// Flutter 无 IANA 名可取,固定偏移语义等价——只作用于月度窗口)。 /// Flutter 无 IANA 名可取,固定偏移语义等价——只作用于月度窗口)。
String tzOffsetQueryValue(Duration offset) { String tzOffsetQueryValue(Duration offset) {
+28 -3
View File
@@ -532,7 +532,9 @@ class _PetDetailPageState extends State<PetDetailPage> {
// monthlyExpense 恒非 null(契约);金额整数分 → 元展示。 // monthlyExpense 恒非 null(契约);金额整数分 → 元展示。
value: '¥${formatCentsAsYuan(expense.amountCents)}', value: '¥${formatCentsAsYuan(expense.amountCents)}',
emphasized: expense.amountCents > 0, emphasized: expense.amountCents > 0,
label: '本月花费', // M3.5-03:标签展示服务端归月的实际月份(此前硬编码
// 「本月花费」,用户无法自证记录落在哪个月)。
label: monthlyExpenseCardLabel(expense.month),
onTap: () => _openTimeline(pet), onTap: () => _openTimeline(pet),
), ),
), ),
@@ -545,6 +547,13 @@ class _PetDetailPageState extends State<PetDetailPage> {
/// 数据卡(正典 stat-card 形态):图标 + 数值 15/w800 + 标签 12 inkSoft /// 数据卡(正典 stat-card 形态):图标 + 数值 15/w800 + 标签 12 inkSoft
/// 空态数值降级 inkSoft 常规字重(区分「有数据」与「空态」两种视觉)。 /// 空态数值降级 inkSoft 常规字重(区分「有数据」与「空态」两种视觉)。
///
/// M3.5-03:可点卡([onTap] 非空)右上角补 `chevron_right`——四张卡本都可点进
/// 明细页,但此前无任何视觉提示,用户实测反馈不知道能点。提示形态沿用项目
/// 既有可点行/卡(宠物列表卡、健康提醒卡、资料页设置行)的 `chevron_right`
/// 不自创。同时 [MergeSemantics] 把「数值 + 标签」并进 InkWell 的 button 节点,
/// 读屏一次读全「¥0,9 月花费,按钮」,而不是两段孤立文字(tap 动作仍在
/// InkWell 上,不用 `excludeSemantics` 以免连带丢掉可激活性)。
class _SummaryCard extends StatelessWidget { class _SummaryCard extends StatelessWidget {
const _SummaryCard({ const _SummaryCard({
required this.icon, required this.icon,
@@ -564,7 +573,8 @@ class _SummaryCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( return MergeSemantics(
child: Card(
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.xl), borderRadius: BorderRadius.circular(AppRadius.xl),
@@ -572,8 +582,19 @@ class _SummaryCard extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [ children: [
Icon(icon, size: 18, color: iconColor), Icon(icon, size: 18, color: iconColor),
const Spacer(),
if (onTap != null)
const Icon(
Icons.chevron_right,
size: 16,
color: AppColors.muted,
),
],
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
value, value,
@@ -590,12 +611,16 @@ class _SummaryCard extends StatelessWidget {
label, label,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppColors.inkSoft, fontSize: 11), style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 11,
),
), ),
], ],
), ),
), ),
), ),
),
); );
} }
} }
+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),
+13
View File
@@ -235,6 +235,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "6.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@@ -399,6 +404,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
intl:
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
jni: jni:
dependency: transitive dependency: transitive
description: description:
+7
View File
@@ -43,6 +43,13 @@ dependencies:
image_picker: ^1.2.0 image_picker: ^1.2.0
# T3-13 媒体上传:原生编解码压缩(长边重采样 + JPEG 质量 + EXIF 方向矫正)。 # T3-13 媒体上传:原生编解码压缩(长边重采样 + JPEG 质量 + EXIF 方向矫正)。
flutter_image_compress: ^2.4.0 flutter_image_compress: ^2.4.0
# M3.5-01 中文本地化:Material/Cupertino/Widgets 内置组件文案(日期选择器
# 标题、确定/取消、输入模式提示与格式错误)由 Global*Localizations 提供。
flutter_localizations:
sdk: flutter
# flutter_localizations 的日期符号/数字格式底座;显式直接依赖以锁定与 SDK
# 一致的版本(避免间接依赖升级悄悄改变格式化行为)。
intl: ^0.20.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+49
View File
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/app/app.dart';
import 'package:patbond_flutter/app/app_localization.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/auth_test_helpers.dart';
import '../helpers/community_test_helpers.dart';
import '../helpers/pet_test_helpers.dart';
/// M3.5-01 [MaterialApp] zh-CN
///
/// delegate
/// Material 退
void main() {
testWidgets('MaterialApp 挂三件套 delegate + 单语言 zh-CN', (tester) async {
SharedPreferences.setMockInitialValues({});
final session = SessionManager(store: InMemoryTokenStore())
..markAuthenticated();
await tester.pumpWidget(
App(
sessionManager: session,
authRepository: FakeAuthRepository(),
petsRepository: FakePetsRepository(),
communityRepository: FakeCommunityRepository()
..onFeed = (_, _) async => feedPage(const []),
),
);
await tester.pumpAndSettle();
final app = tester.widget<MaterialApp>(find.byType(MaterialApp));
expect(app.localizationsDelegates, appLocalizationsDelegates);
expect(app.supportedLocales, appSupportedLocales);
expect(app.locale, const Locale('zh', 'CN'));
// Material Default*
final context = tester.element(find.byType(NavigationBar));
final l10n = MaterialLocalizations.of(context);
expect(l10n.okButtonLabel, '确定');
expect(l10n.cancelButtonLabel, '取消');
expect(l10n.datePickerHelpText, '选择日期');
expect(l10n.dateInputLabel, '输入日期');
expect(l10n.inputDateModeButtonLabel, '切换到输入模式');
expect(l10n.invalidDateFormatLabel, '格式无效。');
expect(l10n.formatMonthYear(DateTime(2026, 9, 10)), '2026年9月');
});
}
+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 {
// firstDatefirstDate
//
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,
);
// 沿 chipsurfaceTint + 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);
});
});
} }
@@ -3,6 +3,7 @@ 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/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/app/app_localization.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/empty_state_illustration.dart'; import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
import 'package:patbond_flutter/features/pets/care_reminder_form_page.dart'; import 'package:patbond_flutter/features/pets/care_reminder_form_page.dart';
@@ -39,6 +40,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: const Scaffold(body: Text('详情基底')), home: const Scaffold(body: Text('详情基底')),
), ),
); );
@@ -206,7 +212,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();
await tester.tap(find.text('保存提醒')); await tester.tap(find.text('保存提醒'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -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();
});
});
} }
@@ -277,4 +277,30 @@ void main() {
expect(reminderStatusColor(dismissed, now), AppColors.muted); expect(reminderStatusColor(dismissed, now), AppColors.muted);
}); });
}); });
group('M3.5-03 · monthlyExpenseCardLabel(花费卡展示实际月份)', () {
test('同年只给月份,卡片一行放得下(不再硬编码「本月花费」)', () {
final now = DateTime(2026, 9, 10);
expect(monthlyExpenseCardLabel('2026-09', now: now), '9 月花费');
// 4 4
expect(monthlyExpenseCardLabel('2026-04', now: now), '4 月花费');
expect(monthlyExpenseCardLabel('2026-12', now: now), '12 月花费');
expect(monthlyExpenseCardLabel('2026-01', now: now), '1 月花费');
});
test('跨年(服务端归月年份 ≠ 设备当前年份)补年份消歧', () {
// 2027-01 tz 2026-12
final now = DateTime(2027, 1, 1);
expect(monthlyExpenseCardLabel('2026-12', now: now), '2026/12 花费');
});
test('月份串非法退回「本月花费」(不崩、不显示脏值)', () {
final now = DateTime(2026, 9, 10);
expect(monthlyExpenseCardLabel('', now: now), '本月花费');
expect(monthlyExpenseCardLabel('2026-9', now: now), '本月花费');
expect(monthlyExpenseCardLabel('2026-13', now: now), '本月花费');
expect(monthlyExpenseCardLabel('2026-00', now: now), '本月花费');
expect(monthlyExpenseCardLabel('2026-09-01', now: now), '本月花费');
});
});
} }
+61 -6
View File
@@ -188,12 +188,24 @@ void main() {
}); });
group('T2-13 · 数据卡行接 summary', () { group('T2-13 · 数据卡行接 summary', () {
testWidgets('四卡取数:最新体重 / 疫苗进度 / 下一针 / 月花费(tz 透传)', (tester) async { testWidgets('四卡取数:最新体重 / 疫苗进度 / 下一针 / 月花费(tz 透传)', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1'); repository.getPetHandler = (petId) async => buildPet('p-1');
String? capturedTz; String? capturedTz;
// M3.5-03
// monthlyExpenseCardLabel
final now = DateTime.now();
final thisMonth = '${now.year}-${now.month.toString().padLeft(2, '0')}';
repository.getPetSummaryHandler = (petId, tz) async { repository.getPetSummaryHandler = (petId, tz) async {
capturedTz = tz; capturedTz = tz;
return buildSummary(); return buildSummary(
overrides: {
'monthlyExpense': {
'month': thisMonth,
'timezone': 'Asia/Shanghai',
'amountCents': 12850,
},
},
);
}; };
await pumpDetail(tester); await pumpDetail(tester);
@@ -208,12 +220,45 @@ void main() {
expect(find.text('下一针·狂犬疫苗'), findsOneWidget); expect(find.text('下一针·狂犬疫苗'), findsOneWidget);
// T2-14 monthlyExpense12850 // T2-14 monthlyExpense12850
expect(find.text('¥128.50'), findsOneWidget); expect(find.text('¥128.50'), findsOneWidget);
expect(find.text('本月花费'), findsOneWidget); // M3.5-03便
expect(find.text('${now.month} 月花费'), findsOneWidget);
expect(find.text('本月花费'), findsNothing);
// T2-13 tz // T2-13 tz
expect(capturedTz, tzOffsetQueryValue(DateTime.now().timeZoneOffset)); expect(capturedTz, tzOffsetQueryValue(DateTime.now().timeZoneOffset));
expect(capturedTz, matches(RegExp(r'^[+-]\d{2}:\d{2}$'))); expect(capturedTz, matches(RegExp(r'^[+-]\d{2}:\d{2}$')));
}); });
testWidgets('M3.5-03 · 四张数据卡均有 chevron 可点提示', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1');
final now = DateTime.now();
final thisMonth = '${now.year}-${now.month.toString().padLeft(2, '0')}';
repository.getPetSummaryHandler = (petId, tz) async => buildSummary(
overrides: {
'monthlyExpense': {
'month': thisMonth,
'timezone': 'Asia/Shanghai',
'amountCents': 0,
},
},
);
await pumpDetail(tester);
await tester.pumpAndSettle();
// stat onTap
// chevron_right /
for (final label in ['最新体重', '疫苗进度', '下一针·狂犬疫苗', '${now.month} 月花费']) {
expect(
find.ancestor(
of: find.text(label),
matching: find.widgetWithIcon(Card, Icons.chevron_right),
),
findsOneWidget,
reason: '$label」卡缺可点提示',
);
}
});
testWidgets('null 语义:无登记显示空态而非 0/0', (tester) async { testWidgets('null 语义:无登记显示空态而非 0/0', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1'); repository.getPetHandler = (petId) async => buildPet('p-1');
repository.getPetSummaryHandler = (petId, tz) async => buildSummary( repository.getPetSummaryHandler = (petId, tz) async => buildSummary(
@@ -302,12 +347,22 @@ void main() {
}); });
group('T2-14 · 时间线入口', () { group('T2-14 · 时间线入口', () {
testWidgets('月花费卡 → 健康时间线页;返回后重拉摘要', (tester) async { testWidgets('月花费卡 → 健康时间线页;返回后重拉摘要', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1'); repository.getPetHandler = (petId) async => buildPet('p-1');
var summaryCalls = 0; var summaryCalls = 0;
final now = DateTime.now();
final thisMonth = '${now.year}-${now.month.toString().padLeft(2, '0')}';
repository.getPetSummaryHandler = (petId, tz) async { repository.getPetSummaryHandler = (petId, tz) async {
summaryCalls++; summaryCalls++;
return buildSummary(); return buildSummary(
overrides: {
'monthlyExpense': {
'month': thisMonth,
'timezone': 'Asia/Shanghai',
'amountCents': 12850,
},
},
);
}; };
repository.listHealthEventsHandler = (petId, limit, cursor) async => repository.listHealthEventsHandler = (petId, limit, cursor) async =>
const CursorPage(items: [], nextCursor: null, hasMore: false); const CursorPage(items: [], nextCursor: null, hasMore: false);
@@ -315,7 +370,7 @@ void main() {
await pumpDetail(tester); await pumpDetail(tester);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('月花费')); await tester.tap(find.text('${now.month} 月花费'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(HealthEventsPage), findsOneWidget); expect(find.byType(HealthEventsPage), findsOneWidget);
@@ -3,6 +3,7 @@ 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/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/app/app_localization.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart'; import 'package:patbond_flutter/core/theme/app_theme.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';
@@ -36,6 +37,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: const Scaffold(body: Text('列表基底')), home: const Scaffold(body: Text('列表基底')),
), ),
); );
@@ -65,7 +71,7 @@ void main() {
Future<void> pickDate(WidgetTester tester, String tileLabel) async { Future<void> pickDate(WidgetTester tester, String tileLabel) async {
await tester.tap(find.text(tileLabel)); await tester.tap(find.text(tileLabel));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('OK')); await tester.tap(find.text('确定'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }
@@ -3,6 +3,7 @@ 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/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/app/app_localization.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/empty_state_illustration.dart'; import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
@@ -39,6 +40,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: const Scaffold(body: Text('详情基底')), home: const Scaffold(body: Text('详情基底')),
), ),
); );
@@ -166,7 +172,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
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();
await tester.tap(find.text('保存登记')); await tester.tap(find.text('保存登记'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();