Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d5c84d06d | |||
| 294fc4a781 | |||
| 6038901099 |
@@ -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);
|
||||||
|
}
|
||||||
@@ -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-01:Material 内置组件中文化(此前未配 delegate,日期选择器等
|
||||||
|
// 全部回退英文兜底)。单语言 zh-CN,不随设备语言回退英文。
|
||||||
|
localizationsDelegates: appLocalizationsDelegates,
|
||||||
|
supportedLocales: appSupportedLocales,
|
||||||
|
locale: appLocale,
|
||||||
navigatorObservers: [_routeObserver],
|
navigatorObservers: [_routeObserver],
|
||||||
home: ListenableBuilder(
|
home: ListenableBuilder(
|
||||||
listenable: sessionManager,
|
listenable: sessionManager,
|
||||||
|
|||||||
@@ -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];
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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月');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 派生色)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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), '本月花费');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:月度花费卡接 monthlyExpense(12850 分 → 元展示)。
|
// T2-14:月度花费卡接 monthlyExpense(12850 分 → 元展示)。
|
||||||
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();
|
||||||
|
|||||||
Reference in New Issue
Block a user