新增:健康事件时间线接入真实数据(T2-14 时间线半边)

- 时间线页:occurred_at DESC cursor 分页 + 月分组组头,四态齐备;
  六类事件经 RecordTypeDot 映射(增补喂养/洗护/测量三型,色族复用
  已审计色对),类型 TagPill 双通道
- 事件录入表单:六类类型选择器、金额以元录入/整数分传输(money.dart
  换算)、UTC 时间戳约定与体重表单一致
- 事件编辑页:顶层 PATCH 差量提交(title/notes/amountCents + version),
  40902 照 T2-12 模式自动经时间线检索取新 version 重提(保留输入)
- 档案页:月度花费卡接 summary.monthlyExpense(分→元展示)、tz 透传
  设备时区固定偏移(T2-13 遗留③)、健康时间线导航入口
- 埋点:health_record 域 create 三事件 + viewed(recordType=health_event)
  挂通;新增 edit_succeeded/failed 封装(failureReason 含 conflict)
- 测试 224 → 250 全绿(+26);analyze 0 问题;format 无 diff

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 13:49:48 +08:00
parent c91f18a845
commit e186ba3da9
15 changed files with 2274 additions and 23 deletions
+35 -2
View File
@@ -1,8 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart';
/// M2 记录类型(05 号规范 §2 五种,「其他」为扩展兜底)。 /// M2 记录类型(05 号规范 §2 五种,「其他」为扩展兜底
enum RecordType { weight, vaccine, deworming, medical, other } /// T2-14 为六类健康事件增补 feeding / grooming / measurement 三型,
/// 色族复用 05 §2 已审计的四个色对,仅图标与文案区分——对比度结论不变)。
enum RecordType {
weight,
vaccine,
deworming,
medical,
other,
feeding,
grooming,
measurement,
}
/// 类型 → 图标 + 三色(dot 底 8% 淡染基色 / 图标色 / 文字标签色)+ 文案的 /// 类型 → 图标 + 三色(dot 底 8% 淡染基色 / 图标色 / 文字标签色)+ 文案的
/// 唯一映射出口(05 §3.2:映射只存在于本文件一处,杜绝散落硬编码)。 /// 唯一映射出口(05 §3.2:映射只存在于本文件一处,杜绝散落硬编码)。
@@ -69,6 +80,28 @@ const Map<RecordType, RecordTypeStyle> recordTypeStyles = {
inkColor: AppColors.inkSoft, inkColor: AppColors.inkSoft,
label: '其他', label: '其他',
), ),
// —— T2-14 健康事件增补(色族复用已审计色对)——
RecordType.feeding: RecordTypeStyle(
icon: Icons.restaurant_outlined,
baseColor: AppColors.success,
iconColor: AppColors.successInk,
inkColor: AppColors.successInk,
label: '喂养',
),
RecordType.grooming: RecordTypeStyle(
icon: Icons.content_cut,
baseColor: AppColors.accent,
iconColor: AppColors.accentDark,
inkColor: AppColors.accentDark,
label: '洗护',
),
RecordType.measurement: RecordTypeStyle(
icon: Icons.straighten_outlined,
baseColor: AppColors.primary,
iconColor: AppColors.primaryStrong,
inkColor: AppColors.primaryDark,
label: '测量',
),
}; };
/// 圆标尺寸档(05 §3.2)。图标尺寸 = dot 的 50%。 /// 圆标尺寸档(05 §3.2)。图标尺寸 = dot 的 50%。
@@ -0,0 +1,351 @@
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.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/primary_button.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/health_record_display.dart';
import 'package:patbond_flutter/features/pets/money.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
/// 健康事件编辑页(T2-14):顶层短路径 `PATCH /health-events/{id}`。
///
/// - 契约:仅 title / notes / amountCents 可编辑;eventType / occurredAt
/// 为条目身份,静态展示不可改;不支持清空回 null(清空输入视为不变更)。
/// - 差量提交:只发送改动字段 + version;无变更不发 PATCH 直接返回。
/// - 40902 版本冲突照 T2-12 模式:明确提示 + 自动取最新版本更新乐观锁
/// 基线(保留用户输入),用户核对后重新保存。契约无按 id 读取端点,
/// 最新版本经时间线分页检索取回。
/// - 埋点:health_record_edit_succeeded(fieldCount) / edit_failed
/// failureReason 含 conflictrecordType=health_event)。
class HealthEventEditPage extends StatefulWidget {
const HealthEventEditPage({
required this.repository,
required this.event,
super.key,
this.analytics,
});
final PetsRepository repository;
/// 编辑基线(含 version 乐观锁与差量比较基准)。
final HealthEvent event;
final HealthRecordAnalytics? analytics;
@override
State<HealthEventEditPage> createState() => _HealthEventEditPageState();
}
class _HealthEventEditPageState extends State<HealthEventEditPage> {
late final TextEditingController _titleCtrl;
late final TextEditingController _amountCtrl;
late final TextEditingController _notesCtrl;
/// 编辑基线:40902 冲突刷新后更新(version 与差量计算的比较基准)。
late HealthEvent _base;
String? _titleError;
String? _amountError;
String? _formError;
bool _submitting = false;
@override
void initState() {
super.initState();
_base = widget.event;
_titleCtrl = TextEditingController(text: _base.title);
_amountCtrl = TextEditingController(
text: _base.amountCents == null
? ''
: formatCentsAsYuan(_base.amountCents!),
);
_notesCtrl = TextEditingController(text: _base.notes ?? '');
}
@override
void dispose() {
_titleCtrl.dispose();
_amountCtrl.dispose();
_notesCtrl.dispose();
super.dispose();
}
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
widget.analytics?.editFailed(
recordType: HealthRecordType.healthEvent,
reason: reason,
errorCode: errorCode,
);
}
void _showFormError(String message) {
setState(() => _formError = message);
SemanticsService.sendAnnouncement(
View.of(context),
message,
TextDirection.ltr,
);
}
/// 差量请求:只含改动字段(契约不支持清空回 null——清空输入视为不变更)。
/// 无实际变更返回 null。
UpdateHealthEventRequest? _buildDiff() {
final title = _titleCtrl.text.trim();
final notes = _notesCtrl.text.trim();
final amountText = _amountCtrl.text.trim();
final amountCents = amountText.isEmpty
? null
: parseYuanToCents(amountText);
final request = UpdateHealthEventRequest(
version: _base.version,
title: title != _base.title ? title : null,
notes: notes.isNotEmpty && notes != (_base.notes ?? '') ? notes : null,
amountCents: amountCents != null && amountCents != _base.amountCents
? amountCents
: null,
);
// 只剩 version 一个键 → 无实际变更。
return request.toJson().length == 1 ? null : request;
}
Future<void> _submit() async {
if (_submitting) return;
final titleError = _titleCtrl.text.trim().isEmpty ? '标题不能为空' : null;
final amountText = _amountCtrl.text.trim();
final amountError =
amountText.isNotEmpty && parseYuanToCents(amountText) == null
? '金额格式不正确,最多两位小数'
: null;
if (titleError != null || amountError != null) {
setState(() {
_titleError = titleError;
_amountError = amountError;
});
_trackFailed(HealthRecordFailureReason.validationError);
return;
}
final request = _buildDiff();
if (request == null) {
Navigator.of(context).pop();
return;
}
setState(() {
_submitting = true;
_formError = null;
});
try {
final updated = await widget.repository.updateHealthEvent(
_base.id,
request,
);
widget.analytics?.editSucceeded(
recordType: HealthRecordType.healthEvent,
fieldCount: request.toJson().length - 1,
);
if (mounted) Navigator.of(context).pop(updated);
} on PetVersionConflictException {
_trackFailed(HealthRecordFailureReason.conflict, 40902);
await _handleVersionConflict();
} on PetRecordNotFoundException {
if (!mounted) return;
final navigator = Navigator.of(context);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('记录不存在或已被删除')));
_trackFailed(HealthRecordFailureReason.notFound, 40402);
navigator.pop();
} on PetAccessDeniedException {
if (!mounted) return;
_showFormError('你没有权限修改该记录');
_trackFailed(HealthRecordFailureReason.permissionDenied, 40300);
} on ApiRateLimitException {
if (!mounted) return;
_showFormError('操作过于频繁,请稍后再试');
_trackFailed(HealthRecordFailureReason.rateLimited);
} on ApiBusinessException catch (error) {
if (!mounted) return;
final isParam = error.code == ApiCodes.paramError;
_showFormError(isParam ? '请检查填写内容后重试' : '保存失败,请稍后重试');
_trackFailed(
isParam
? HealthRecordFailureReason.validationError
: HealthRecordFailureReason.serverError,
error.code,
);
} on ApiNetworkException {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('网络异常,请检查网络后重试'),
action: SnackBarAction(label: '重试', onPressed: _submit),
),
);
_trackFailed(HealthRecordFailureReason.networkError);
} on SessionExpiredException {
// 会话失效:认证状态机自动回登录页。
} finally {
if (mounted) setState(() => _submitting = false);
}
}
/// 40902:提示 + 刷新路径——契约无按 id 读取端点,经时间线分页检索
/// 取回最新版本更新乐观锁基线(保留用户输入),用户核对后重新保存。
Future<void> _handleVersionConflict() async {
try {
final fresh = await _fetchLatest();
if (!mounted) return;
if (fresh == null) {
final navigator = Navigator.of(context);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('记录不存在或已被删除')));
navigator.pop();
return;
}
setState(() => _base = fresh);
_showFormError('记录已在其他设备被修改,已获取最新版本,请核对后重新保存');
} on ApiException {
if (!mounted) return;
_showFormError('记录已在其他设备被修改,请返回后刷新重试');
}
}
/// 按 occurred_at DESC 分页检索本记录(上限 10 页防御性截断)。
Future<HealthEvent?> _fetchLatest() async {
String? cursor;
for (var page = 0; page < 10; page++) {
final result = await widget.repository.listHealthEvents(
_base.petId,
limit: 50,
cursor: cursor,
);
for (final item in result.items) {
if (item.id == _base.id) return item;
}
if (!result.hasMore) return null;
cursor = result.nextCursor;
}
return null;
}
@override
Widget build(BuildContext context) {
final style = recordTypeStyles[recordTypeForHealthEvent(_base.eventType)]!;
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: AppColors.ink,
title: const Text('编辑健康事件'),
centerTitle: true,
titleTextStyle: const TextStyle(
color: AppColors.ink,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 30),
children: [
// 事件身份静态区:类型与发生时间不可编辑(契约不在请求体)。
Row(
children: [
RecordTypeDot(
type: recordTypeForHealthEvent(_base.eventType),
size: RecordTypeDotSize.md,
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
healthEventTypeLabel(_base.eventType),
style: TextStyle(
color: style.inkColor,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
formatOccurredAt(_base.occurredAt),
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
),
),
],
),
],
),
const SizedBox(height: 18),
AppTextField(
label: '标题',
controller: _titleCtrl,
prefixIcon: Icons.title_outlined,
errorText: _titleError,
enabled: !_submitting,
textInputAction: TextInputAction.next,
onChanged: (_) {
if (_titleError != null || _formError != null) {
setState(() {
_titleError = null;
_formError = null;
});
}
},
),
const SizedBox(height: 14),
AppTextField(
label: '金额(元)',
controller: _amountCtrl,
prefixIcon: Icons.payments_outlined,
errorText: _amountError,
helperText: '以元填写,最多两位小数;清空视为不变更',
enabled: !_submitting,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
textInputAction: TextInputAction.next,
onChanged: (_) {
if (_amountError != null || _formError != null) {
setState(() {
_amountError = null;
_formError = null;
});
}
},
),
const SizedBox(height: 14),
AppTextField(
label: '备注',
controller: _notesCtrl,
prefixIcon: Icons.sticky_note_2_outlined,
enabled: !_submitting,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
),
if (_formError != null) ...[
const SizedBox(height: 16),
InlineErrorBanner(message: _formError!),
],
const SizedBox(height: 24),
PrimaryButton(
label: '保存修改',
isLoading: _submitting,
onPressed: _submit,
),
],
),
),
);
}
}
@@ -0,0 +1,416 @@
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.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/primary_button.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/health_record_display.dart';
import 'package:patbond_flutter/features/pets/money.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
/// 健康事件录入表单页(T2-14)。
///
/// - 六类事件类型选择(05 §4.4 类型选择器形态:RecordTypeDot sm + 标签,
/// 图标 + 文字双通道);类型必选。
/// - 发生时间默认「现在」,可回选日期(非今日取当日 12:00,与体重表单
/// 同一约定);提交前转 UTC,ISO 8601 带 Z 上送。
/// - 金额可选:**UI 以元录入/展示,传输为整数分**(money.dart 换算,
/// 最多两位小数,非法拦截);后端提交小数 400/40000 兜底。
/// - 埋点:health_record_create_started/succeeded/failed
/// recordType=health_event)。
class HealthEventFormPage extends StatefulWidget {
const HealthEventFormPage({
required this.repository,
required this.petId,
super.key,
this.analytics,
this.entryPoint = HealthRecordEntryPoint.recordList,
});
final PetsRepository repository;
final String petId;
final HealthRecordAnalytics? analytics;
final HealthRecordEntryPoint entryPoint;
@override
State<HealthEventFormPage> createState() => _HealthEventFormPageState();
}
class _HealthEventFormPageState extends State<HealthEventFormPage> {
final _titleCtrl = TextEditingController();
final _amountCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
HealthEventType? _type;
DateTime _occurredDate = DateTime.now();
String? _typeError;
String? _titleError;
String? _amountError;
String? _formError;
bool _submitting = false;
bool _startedFired = false;
int _attemptSeq = 0;
late final DateTime _openedAt;
@override
void initState() {
super.initState();
_openedAt = DateTime.now();
}
@override
void dispose() {
_titleCtrl.dispose();
_amountCtrl.dispose();
_notesCtrl.dispose();
super.dispose();
}
void _markStarted() {
if (_startedFired) return;
_startedFired = true;
widget.analytics?.createStarted(
recordType: HealthRecordType.healthEvent,
entryPoint: widget.entryPoint,
);
}
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
widget.analytics?.createFailed(
recordType: HealthRecordType.healthEvent,
reason: reason,
attemptSeq: _attemptSeq,
errorCode: errorCode,
);
}
void _showFormError(String message) {
setState(() => _formError = message);
SemanticsService.sendAnnouncement(
View.of(context),
message,
TextDirection.ltr,
);
}
/// 发生时刻:今日取此刻,历史日期取当日 12:00(本地),提交前转 UTC
/// (与体重表单同一约定,规避无时区后缀的解析歧义)。
DateTime _occurredAt() {
final now = DateTime.now();
final sameDay =
_occurredDate.year == now.year &&
_occurredDate.month == now.month &&
_occurredDate.day == now.day;
final local = sameDay
? now
: DateTime(
_occurredDate.year,
_occurredDate.month,
_occurredDate.day,
12,
);
return local.toUtc();
}
Future<void> _submit() async {
if (_submitting) return;
_attemptSeq++;
final typeError = _type == null ? '请选择事件类型' : null;
final titleError = _titleCtrl.text.trim().isEmpty ? '请输入标题' : null;
final amountText = _amountCtrl.text.trim();
final amountError =
amountText.isNotEmpty && parseYuanToCents(amountText) == null
? '金额格式不正确,最多两位小数'
: null;
if (typeError != null || titleError != null || amountError != null) {
setState(() {
_typeError = typeError;
_titleError = titleError;
_amountError = amountError;
});
_trackFailed(HealthRecordFailureReason.validationError);
return;
}
setState(() {
_submitting = true;
_formError = null;
});
try {
final notes = _notesCtrl.text.trim();
final record = await widget.repository.createHealthEvent(
widget.petId,
CreateHealthEventRequest(
eventType: _type!,
occurredAt: _occurredAt(),
title: _titleCtrl.text.trim(),
notes: notes.isEmpty ? null : notes,
// 元 → 整数分换算,DTO 层保持整数分(开发计划 §4.3)。
amountCents: amountText.isEmpty ? null : parseYuanToCents(amountText),
),
);
widget.analytics?.createSucceeded(
recordType: HealthRecordType.healthEvent,
durationMs: DateTime.now().difference(_openedAt).inMilliseconds,
);
if (mounted) Navigator.of(context).pop(record);
} on PetAccessDeniedException {
if (!mounted) return;
_showFormError('你没有权限为该宠物添加记录');
_trackFailed(HealthRecordFailureReason.permissionDenied, 40300);
} on PetNotFoundException {
if (!mounted) return;
final navigator = Navigator.of(context);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('宠物不存在或已被删除')));
_trackFailed(HealthRecordFailureReason.notFound, 40401);
navigator.pop();
} on ApiRateLimitException {
if (!mounted) return;
_showFormError('操作过于频繁,请稍后再试');
_trackFailed(HealthRecordFailureReason.rateLimited);
} on ApiBusinessException catch (error) {
if (!mounted) return;
final isParam = error.code == ApiCodes.paramError;
_showFormError(isParam ? '请检查填写内容后重试' : '保存失败,请稍后重试');
_trackFailed(
isParam
? HealthRecordFailureReason.validationError
: HealthRecordFailureReason.serverError,
error.code,
);
} on ApiNetworkException {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('网络异常,请检查网络后重试'),
action: SnackBarAction(label: '重试', onPressed: _submit),
),
);
_trackFailed(HealthRecordFailureReason.networkError);
} on SessionExpiredException {
// 会话失效:认证状态机自动回登录页。
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: AppColors.ink,
title: const Text('记录健康事件'),
centerTitle: true,
titleTextStyle: const TextStyle(
color: AppColors.ink,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 30),
children: [
const Text(
'事件类型',
style: TextStyle(
color: AppColors.inkSoft,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
_typeSelector(),
if (_typeError != null) ...[
const SizedBox(height: 6),
Text(
_typeError!,
style: const TextStyle(color: AppColors.error, fontSize: 12),
),
],
const SizedBox(height: 18),
AppTextField(
label: '标题(如:皮肤检查)',
controller: _titleCtrl,
prefixIcon: Icons.title_outlined,
errorText: _titleError,
enabled: !_submitting,
textInputAction: TextInputAction.next,
onChanged: (_) {
_markStarted();
if (_titleError != null || _formError != null) {
setState(() {
_titleError = null;
_formError = null;
});
}
},
),
const SizedBox(height: 14),
ListTile(
shape: RoundedRectangleBorder(
side: const BorderSide(color: AppColors.border),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
tileColor: AppColors.surface,
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
title: const Text('发生日期', style: TextStyle(fontSize: 14)),
subtitle: Text(
dateToJson(_occurredDate),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
trailing: const Icon(
Icons.calendar_month_outlined,
color: AppColors.muted,
),
enabled: !_submitting,
onTap: () async {
final now = DateTime.now();
final value = await showDatePicker(
context: context,
initialDate: _occurredDate,
firstDate: DateTime(1990),
lastDate: now,
);
if (value != null && mounted) {
_markStarted();
setState(() => _occurredDate = value);
}
},
),
const SizedBox(height: 14),
AppTextField(
label: '金额(元,可选)',
controller: _amountCtrl,
prefixIcon: Icons.payments_outlined,
errorText: _amountError,
helperText: '以元填写,最多两位小数,如 128.50',
enabled: !_submitting,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
textInputAction: TextInputAction.next,
onChanged: (_) {
_markStarted();
if (_amountError != null || _formError != null) {
setState(() {
_amountError = null;
_formError = null;
});
}
},
),
const SizedBox(height: 14),
AppTextField(
label: '备注(可选)',
controller: _notesCtrl,
prefixIcon: Icons.sticky_note_2_outlined,
enabled: !_submitting,
textInputAction: TextInputAction.done,
onChanged: (_) => _markStarted(),
onSubmitted: (_) => _submit(),
),
if (_formError != null) ...[
const SizedBox(height: 16),
InlineErrorBanner(message: _formError!),
],
const SizedBox(height: 24),
PrimaryButton(
label: '保存记录',
isLoading: _submitting,
onPressed: _submit,
),
],
),
),
);
}
/// 六类类型选择器(05 §4.4RecordTypeDot sm 上、标签下;
/// 选中 surfaceTint 底 + primaryDark 标签,未选中 inkSoft)。
Widget _typeSelector() {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final type in HealthEventType.values)
_TypeUnit(
type: type,
selected: _type == type,
enabled: !_submitting,
onTap: () {
_markStarted();
setState(() {
_type = type;
_typeError = null;
_formError = null;
});
},
),
],
);
}
}
class _TypeUnit extends StatelessWidget {
const _TypeUnit({
required this.type,
required this.selected,
required this.enabled,
required this.onTap,
});
final HealthEventType type;
final bool selected;
final bool enabled;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: selected ? AppColors.surfaceTint : AppColors.surface,
shape: RoundedRectangleBorder(
side: BorderSide(
color: selected ? AppColors.primaryStrong : AppColors.border,
),
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: InkWell(
onTap: enabled ? onTap : null,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Container(
constraints: const BoxConstraints(minWidth: 64, minHeight: 52),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
RecordTypeDot(
type: recordTypeForHealthEvent(type),
size: RecordTypeDotSize.sm,
),
const SizedBox(height: 4),
Text(
healthEventTypeLabel(type),
style: TextStyle(
fontSize: 11,
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
color: selected ? AppColors.primaryDark : AppColors.inkSoft,
),
),
],
),
),
),
);
}
}
+368
View File
@@ -0,0 +1,368 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
import 'package:patbond_flutter/core/navigation/fade_route.dart';
import 'package:patbond_flutter/core/network/api_exception.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/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
import 'package:patbond_flutter/features/pets/health_event_edit_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_display.dart';
import 'package:patbond_flutter/features/pets/money.dart';
import 'package:patbond_flutter/features/pets/pet_display.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
import 'package:patbond_flutter/widgets/common.dart';
enum _ListPhase { loading, ready, error }
/// 健康事件时间线页(T2-14):cursor 分页(occurred_at DESC
/// 「加载更多」追加,末页收起),按月分组(05 §4.2 组头),四态齐备。
///
/// - 六类事件经 [recordTypeForHealthEvent] 映射 RecordTypeDot
/// 类型标签 TagPill 双通道呈现;金额以元展示(传输为整数分)。
/// - 录入经 [HealthEventFormPage];成功后重拉首页(排序与月分组以
/// 服务端为准,不本地猜位置)。
/// - 编辑经 [HealthEventEditPage]canWrite 点条目进入);成功就地替换。
/// - 曝光埋点:每次进入首个成功加载上报一次
/// `health_record_viewed(recordType=health_event, source=pet_detail)`。
class HealthEventsPage extends StatefulWidget {
const HealthEventsPage({
required this.repository,
required this.petId,
required this.canWrite,
super.key,
this.analytics,
this.pageSize,
});
final PetsRepository repository;
final String petId;
/// owner/caregiver 可写;viewer 隐藏录入/编辑入口(40300 语义前置)。
final bool canWrite;
final HealthRecordAnalytics? analytics;
/// 每页条数(测试注入小页验证分页;缺省走服务端默认 20)。
final int? pageSize;
@override
State<HealthEventsPage> createState() => _HealthEventsPageState();
}
class _HealthEventsPageState extends State<HealthEventsPage> {
_ListPhase _phase = _ListPhase.loading;
List<HealthEvent> _events = const [];
String? _nextCursor;
bool _hasMore = false;
bool _loadingMore = false;
ApiException? _error;
bool _viewedFired = false;
@override
void initState() {
super.initState();
_loadFirstPage();
}
Future<void> _loadFirstPage() async {
setState(() {
_phase = _ListPhase.loading;
_error = null;
});
try {
final page = await widget.repository.listHealthEvents(
widget.petId,
limit: widget.pageSize,
);
if (!mounted) return;
setState(() {
_events = page.items;
_nextCursor = page.nextCursor;
_hasMore = page.hasMore;
_phase = _ListPhase.ready;
});
if (!_viewedFired) {
_viewedFired = true;
widget.analytics?.viewed(
recordType: HealthRecordType.healthEvent,
source: HealthRecordViewSource.petDetail,
);
}
} on ApiException catch (error) {
if (!mounted) return;
setState(() {
_error = error;
_phase = _ListPhase.error;
});
}
}
Future<void> _loadMore() async {
if (_loadingMore || !_hasMore) return;
setState(() => _loadingMore = true);
try {
final page = await widget.repository.listHealthEvents(
widget.petId,
limit: widget.pageSize,
cursor: _nextCursor,
);
if (!mounted) return;
setState(() {
_events = [..._events, ...page.items];
_nextCursor = page.nextCursor;
_hasMore = page.hasMore;
});
} on ApiException catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(petLoadErrorMessage(error))));
} finally {
if (mounted) setState(() => _loadingMore = false);
}
}
Future<void> _openCreate() async {
final created = await Navigator.of(context).push<HealthEvent>(
fadePageRoute(
HealthEventFormPage(
repository: widget.repository,
petId: widget.petId,
analytics: widget.analytics,
),
// record_form:健康记录漏斗到达段页名(06 §1.6 / §2.2)。
settings: RouteSettings(name: AnalyticsPageName.recordForm.pageName),
),
);
if (created != null && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已记录健康事件')));
// occurred_at DESC + 月分组:补录历史日期的位置由服务端定,
// 重拉首页而非本地猜位置。
await _loadFirstPage();
}
}
Future<void> _openEdit(HealthEvent event) async {
final updated = await Navigator.of(context).push<HealthEvent>(
// 编辑页不带路由名:record_form 专属创建漏斗到达段(T2-12 先例)。
fadePageRoute(
HealthEventEditPage(
repository: widget.repository,
event: event,
analytics: widget.analytics,
),
),
);
if (updated != null && mounted) {
setState(() {
// occurredAt 不可编辑 → 排序/分组位置不变,就地替换安全。
_events = [
for (final item in _events)
if (item.id == updated.id) updated else item,
];
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已保存修改')));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: AppColors.ink,
title: const Text('健康时间线'),
centerTitle: true,
titleTextStyle: const TextStyle(
color: AppColors.ink,
fontSize: 16,
fontWeight: FontWeight.w800,
),
actions: [
if (widget.canWrite && _phase == _ListPhase.ready)
IconButton(
tooltip: '记录健康事件',
onPressed: _openCreate,
icon: const Icon(Icons.add),
),
],
),
body: switch (_phase) {
_ListPhase.loading => const Center(child: CircularProgressIndicator()),
_ListPhase.error => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
InlineErrorBanner(message: petLoadErrorMessage(_error)),
const SizedBox(height: 16),
FilledButton(
onPressed: _loadFirstPage,
child: const Text('重试'),
),
],
),
),
),
_ListPhase.ready when _events.isEmpty => Center(
child: SingleChildScrollView(
child: EmptyStateIllustration(
icon: Icons.event_note_outlined,
title: '还没有健康记录',
description: '就医、驱虫、洗护……随手记下毛孩子的健康点滴',
ctaLabel: widget.canWrite ? '记录第一条' : null,
onCtaPressed: widget.canWrite ? _openCreate : null,
),
),
),
_ListPhase.ready => _list(),
},
);
}
/// 按月分组渲染:服务端 occurred_at DESC 保证同月相邻,
/// 月份变化处插组头(05 §4.2「2026 年 9 月」)。
Widget _list() {
final children = <Widget>[];
String? currentMonth;
for (final event in _events) {
final month = healthEventMonthHeader(event.occurredAt);
if (month != currentMonth) {
currentMonth = month;
children.add(
Padding(
padding: EdgeInsets.only(top: children.isEmpty ? 0 : 14, bottom: 8),
child: Text(
month,
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
);
}
children
..add(
_HealthEventTile(
event: event,
onTap: widget.canWrite ? () => _openEdit(event) : null,
),
)
..add(const SizedBox(height: 10));
}
if (_hasMore) {
children.add(
Center(
child: _loadingMore
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: TextButton(onPressed: _loadMore, child: const Text('加载更多')),
),
);
}
return RefreshIndicator(
onRefresh: _loadFirstPage,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 30),
children: children,
),
);
}
}
/// 时间线条目(05 §3.3 形态):RecordTypeDot(六类映射) + 标题 +
/// 日期/备注副行;尾部类型 TagPill(双通道)与金额(元展示,
/// 15/w800 类型文字色)。
class _HealthEventTile extends StatelessWidget {
const _HealthEventTile({required this.event, this.onTap});
final HealthEvent event;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final recordType = recordTypeForHealthEvent(event.eventType);
final style = recordTypeStyles[recordType]!;
final meta = StringBuffer(formatOccurredAt(event.occurredAt));
if (event.notes != null && event.notes!.isNotEmpty) {
meta.write(' · ${event.notes}');
}
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.xl),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
RecordTypeDot(type: recordType, size: RecordTypeDotSize.md),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
meta.toString(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
),
),
],
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
TagPill(
healthEventTypeLabel(event.eventType),
color: style.baseColor,
),
if (event.amountCents != null) ...[
const SizedBox(height: 4),
Text(
'¥${formatCentsAsYuan(event.amountCents!)}',
style: TextStyle(
color: style.inkColor,
fontSize: 15,
fontWeight: FontWeight.w800,
),
),
],
],
),
],
),
),
),
);
}
}
+37 -4
View File
@@ -4,8 +4,9 @@ import 'package:patbond_flutter/analytics/page_view_tracker.dart';
/// 已扩充就绪,24 号报告 §2.2)。沿用 13 号规范 §3.1 惯例:枚举编译期 /// 已扩充就绪,24 号报告 §2.2)。沿用 13 号规范 §3.1 惯例:枚举编译期
/// 锁死,业务代码禁止手拼事件名与属性。 /// 锁死,业务代码禁止手拼事件名与属性。
/// ///
/// T2-13 挂接创建漏斗三事件 + viewed;edit/deleted 事件的挂接随 /// T2-13 挂接创建漏斗三事件 + viewed;T2-14 补挂 edit_succeeded/failed
/// 编辑/删除交互落地(「标记完成」等)另行接线,见 25 号报告遗留。 /// (健康事件编辑、疫苗标记完成/取消)。`health_record_deleted` 因 M2
/// 契约无删除端点暂无挂接点,留待删除交互落地。
/// 记录类型(06 §1.4 recordType 枚举,四类记录接口对应)。 /// 记录类型(06 §1.4 recordType 枚举,四类记录接口对应)。
enum HealthRecordType { enum HealthRecordType {
@@ -30,7 +31,8 @@ enum HealthRecordEntryPoint {
final String value; final String value;
} }
/// 创建失败原因(06 §1.4 基底 + M2 验收新增三值)。与 pet 域同款 /// 创建/编辑失败原因(06 §1.4 基底 + M2 验收新增三值`conflict`
/// 仅编辑链路会出现——40902 乐观锁/条件更新守卫落空)。与 pet 域同款
/// 网络归并口径:断网/超时/5xx 均并入 network_errorserver_error /// 网络归并口径:断网/超时/5xx 均并入 network_errorserver_error
/// 保留给无法归类的兜底。 /// 保留给无法归类的兜底。
enum HealthRecordFailureReason { enum HealthRecordFailureReason {
@@ -39,7 +41,8 @@ enum HealthRecordFailureReason {
notFound('not_found'), notFound('not_found'),
rateLimited('rate_limited'), rateLimited('rate_limited'),
networkError('network_error'), networkError('network_error'),
serverError('server_error'); serverError('server_error'),
conflict('conflict');
const HealthRecordFailureReason(this.value); const HealthRecordFailureReason(this.value);
@@ -122,4 +125,34 @@ class HealthRecordAnalytics {
'source': source.value, 'source': source.value,
}); });
} }
/// 编辑保存成功响应(06 §1.4:编辑不设 started)。
///
/// [fieldCount] 为本次变更字段数(差量 PATCH 的键数,不含 version)。
void editSucceeded({
required HealthRecordType recordType,
required int fieldCount,
}) {
_track('health_record_edit_succeeded', {
'recordType': recordType.value,
'fieldCount': fieldCount,
});
}
/// 编辑保存失败(失败原因含 `conflict`——40902 版本/状态守卫冲突,
/// M2 验收「并发冲突明确」场景的数据面)。属性集无 attemptSeq
/// (白名单对齐 06 §1.5)。
void editFailed({
required HealthRecordType recordType,
required HealthRecordFailureReason reason,
int? errorCode,
}) {
_track('health_record_edit_failed', {
'recordType': recordType.value,
'failureReason': reason.value,
'errorCode': ?errorCode,
if (errorCode != null && errorCode >= 10000)
'httpStatus': errorCode ~/ 100,
});
}
} }
+51 -1
View File
@@ -1,8 +1,10 @@
/// 体重 / 疫苗展示与输入解析的纯函数集合(列表 / 表单共用,可单测)。 /// 体重 / 疫苗 / 健康事件 / 提醒展示与输入解析的纯函数集合
/// (列表 / 表单共用,可单测)。
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.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/record_type_dot.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart'; import 'package:patbond_flutter/features/pets/pet_models.dart';
/// 体重输入解析:契约区间 (0, 500]、最多两位小数(numeric(6,2))。 /// 体重输入解析:契约区间 (0, 500]、最多两位小数(numeric(6,2))。
@@ -84,3 +86,51 @@ String? vaccinationDateRuleError({
} }
return null; return null;
} }
// ---- 健康事件(T2-14----
/// 契约六类健康事件 → 视觉记录类型(RecordTypeDot 映射唯一出口)。
/// note(随手记)归入「其他」视觉族;其余五类各有专属图标。
RecordType recordTypeForHealthEvent(HealthEventType type) => switch (type) {
HealthEventType.medical => RecordType.medical,
HealthEventType.feeding => RecordType.feeding,
HealthEventType.deworming => RecordType.deworming,
HealthEventType.grooming => RecordType.grooming,
HealthEventType.measurement => RecordType.measurement,
HealthEventType.note => RecordType.other,
};
/// 六类事件中文文案(类型选择器 / 条目标签共用;
/// 图标 + 文字双通道,不单靠颜色区分)。
String healthEventTypeLabel(HealthEventType type) => switch (type) {
HealthEventType.medical => '就医',
HealthEventType.feeding => '喂养',
HealthEventType.deworming => '驱虫',
HealthEventType.grooming => '洗护',
HealthEventType.measurement => '测量',
HealthEventType.note => '随手记',
};
/// 事件发生时刻展示:本地时区 `YYYY-MM-DD HH:mm`。
String formatOccurredAt(DateTime occurredAt) {
final local = occurredAt.toLocal();
final h = local.hour.toString().padLeft(2, '0');
final min = local.minute.toString().padLeft(2, '0');
return '${dateToJson(local)} $h:$min';
}
/// 时间线月分组组头(05 §4.2:「2026 年 9 月」),按本地时区归月。
String healthEventMonthHeader(DateTime occurredAt) {
final local = occurredAt.toLocal();
return '${local.year}${local.month}';
}
/// 设备时区 → summary `tz` 参数(契约接受固定偏移形如 `+08:00`;
/// Flutter 无 IANA 名可取,固定偏移语义等价——只作用于月度窗口)。
String tzOffsetQueryValue(Duration offset) {
final sign = offset.isNegative ? '-' : '+';
final abs = offset.abs();
final h = abs.inHours.toString().padLeft(2, '0');
final m = (abs.inMinutes % 60).toString().padLeft(2, '0');
return '$sign$h:$m';
}
+72 -9
View File
@@ -6,8 +6,10 @@ 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/pet_avatar.dart'; import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart'; import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
import 'package:patbond_flutter/features/pets/health_events_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/health_record_display.dart'; import 'package:patbond_flutter/features/pets/health_record_display.dart';
import 'package:patbond_flutter/features/pets/money.dart';
import 'package:patbond_flutter/features/pets/pet_analytics.dart'; import 'package:patbond_flutter/features/pets/pet_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_display.dart'; import 'package:patbond_flutter/features/pets/pet_display.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart'; import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
@@ -104,11 +106,14 @@ class _PetDetailPageState extends State<PetDetailPage> {
} }
/// 摘要实时聚合(40401 由主链路 notFound 态承载,摘要只降级为 error 态)。 /// 摘要实时聚合(40401 由主链路 notFound 态承载,摘要只降级为 error 态)。
/// `tz` 透传设备时区固定偏移(T2-13 遗留③):monthlyExpense 的月度
/// 窗口随设备时区取边界,与用户直觉一致。
Future<void> _loadSummary() async { Future<void> _loadSummary() async {
setState(() => _summaryPhase = _SummaryPhase.loading); setState(() => _summaryPhase = _SummaryPhase.loading);
try { try {
final summary = await widget.controller.repository.getPetSummary( final summary = await widget.controller.repository.getPetSummary(
widget.petId, widget.petId,
tz: tzOffsetQueryValue(DateTime.now().timeZoneOffset),
); );
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -185,6 +190,21 @@ class _PetDetailPageState extends State<PetDetailPage> {
if (mounted) await _loadSummary(); if (mounted) await _loadSummary();
} }
Future<void> _openTimeline(Pet pet) async {
await Navigator.of(context).push(
fadePageRoute(
HealthEventsPage(
repository: widget.controller.repository,
petId: pet.id,
canWrite: _canWriteRecords,
analytics: widget.healthAnalytics,
),
),
);
// 事件可能已变化:返回即重拉摘要(月度花费实时聚合)。
if (mounted) await _loadSummary();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -271,6 +291,8 @@ class _PetDetailPageState extends State<PetDetailPage> {
Text('健康数据', style: Theme.of(context).textTheme.titleLarge), Text('健康数据', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 10), const SizedBox(height: 10),
_summarySection(pet), _summarySection(pet),
const SizedBox(height: 16),
_recordsSection(pet),
const SizedBox(height: 24), const SizedBox(height: 24),
Text('基本资料', style: Theme.of(context).textTheme.titleLarge), Text('基本资料', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 10), const SizedBox(height: 10),
@@ -316,7 +338,31 @@ class _PetDetailPageState extends State<PetDetailPage> {
); );
} }
/// 数据卡行(05 §4.2 stat-row):三卡取数全部来自 summary 实时聚合, /// 记录导航区:健康时间线入口(T2-14;照护提醒入口随提醒半边落位)。
Widget _recordsSection(Pet pet) {
return SectionCard(
padding: EdgeInsets.zero,
child: Column(
children: [
ListTile(
leading: const Icon(
Icons.event_note_outlined,
color: AppColors.inkSoft,
),
title: const Text('健康时间线', style: TextStyle(fontSize: 14)),
subtitle: const Text(
'就医 · 喂养 · 驱虫 · 洗护 · 测量 · 随手记',
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
trailing: const Icon(Icons.chevron_right, color: AppColors.muted),
onTap: () => _openTimeline(pet),
),
],
),
);
}
/// 数据卡行(05 §4.2 stat-row):四卡取数全部来自 summary 实时聚合,
/// 不落任何本地展示字符串(第 4.3 节红线)。null 语义 → 空态文案。 /// 不落任何本地展示字符串(第 4.3 节红线)。null 语义 → 空态文案。
Widget _summarySection(Pet pet) { Widget _summarySection(Pet pet) {
switch (_summaryPhase) { switch (_summaryPhase) {
@@ -348,13 +394,15 @@ class _PetDetailPageState extends State<PetDetailPage> {
final weight = summary.latestWeight; final weight = summary.latestWeight;
final progress = summary.vaccinationProgress; final progress = summary.vaccinationProgress;
final next = summary.nextVaccination; final next = summary.nextVaccination;
final expense = summary.monthlyExpense;
return IntrinsicHeight( return IntrinsicHeight(
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Expanded( Expanded(
child: _SummaryCard( child: _SummaryCard(
type: RecordType.weight, icon: recordTypeStyles[RecordType.weight]!.icon,
iconColor: recordTypeStyles[RecordType.weight]!.iconColor,
value: weight == null value: weight == null
? '暂无记录' ? '暂无记录'
: '${formatWeightKg(weight.weightKg)} kg', : '${formatWeightKg(weight.weightKg)} kg',
@@ -366,7 +414,8 @@ class _PetDetailPageState extends State<PetDetailPage> {
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: _SummaryCard( child: _SummaryCard(
type: RecordType.vaccine, icon: recordTypeStyles[RecordType.vaccine]!.icon,
iconColor: recordTypeStyles[RecordType.vaccine]!.iconColor,
// 契约:totalDoses=0 → 整体 null(不是 0/0)→ 空态文案。 // 契约:totalDoses=0 → 整体 null(不是 0/0)→ 空态文案。
value: progress == null value: progress == null
? '未登记' ? '未登记'
@@ -379,13 +428,26 @@ class _PetDetailPageState extends State<PetDetailPage> {
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: _SummaryCard( child: _SummaryCard(
type: RecordType.vaccine, icon: recordTypeStyles[RecordType.vaccine]!.icon,
iconColor: recordTypeStyles[RecordType.vaccine]!.iconColor,
value: next == null ? '暂无安排' : dateToJson(next.dueOn), value: next == null ? '暂无安排' : dateToJson(next.dueOn),
emphasized: next != null, emphasized: next != null,
label: next == null ? '下一针' : '下一针·${next.vaccineName}', label: next == null ? '下一针' : '下一针·${next.vaccineName}',
onTap: () => _openVaccinations(pet), onTap: () => _openVaccinations(pet),
), ),
), ),
const SizedBox(width: 10),
Expanded(
child: _SummaryCard(
icon: Icons.payments_outlined,
iconColor: AppColors.primaryStrong,
// monthlyExpense 恒非 null(契约);金额整数分 → 元展示。
value: '¥${formatCentsAsYuan(expense.amountCents)}',
emphasized: expense.amountCents > 0,
label: '本月花费',
onTap: () => _openTimeline(pet),
),
),
], ],
), ),
); );
@@ -393,18 +455,20 @@ class _PetDetailPageState extends State<PetDetailPage> {
} }
} }
/// 数据卡(正典 stat-card 形态):类型图标 + 数值 15/w800 + 标签 12 inkSoft /// 数据卡(正典 stat-card 形态):图标 + 数值 15/w800 + 标签 12 inkSoft
/// 空态数值降级 inkSoft 常规字重(区分「有数据」与「空态」两种视觉)。 /// 空态数值降级 inkSoft 常规字重(区分「有数据」与「空态」两种视觉)。
class _SummaryCard extends StatelessWidget { class _SummaryCard extends StatelessWidget {
const _SummaryCard({ const _SummaryCard({
required this.type, required this.icon,
required this.iconColor,
required this.value, required this.value,
required this.label, required this.label,
required this.emphasized, required this.emphasized,
this.onTap, this.onTap,
}); });
final RecordType type; final IconData icon;
final Color iconColor;
final String value; final String value;
final String label; final String label;
final bool emphasized; final bool emphasized;
@@ -412,7 +476,6 @@ class _SummaryCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final style = recordTypeStyles[type]!;
return Card( return Card(
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
@@ -422,7 +485,7 @@ class _SummaryCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon(style.icon, size: 18, color: style.iconColor), Icon(icon, size: 18, color: iconColor),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
value, value,
+21 -2
View File
@@ -4,8 +4,8 @@ import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart'; import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
void main() { void main() {
testWidgets('五类映射齐备:图标 + 双色(05 §2 表)', (tester) async { testWidgets('全类型映射齐备:图标 + 双色(05 §2 表 + T2-14 增补三型', (tester) async {
// 映射表唯一出口:五类各有图标、底色、图标色、文字色、文案。 // 映射表唯一出口:各类型均有图标、底色、图标色、文字色、文案。
expect(recordTypeStyles.length, RecordType.values.length); expect(recordTypeStyles.length, RecordType.values.length);
expect( expect(
recordTypeStyles[RecordType.weight]!.iconColor, recordTypeStyles[RecordType.weight]!.iconColor,
@@ -26,6 +26,25 @@ void main() {
expect(recordTypeStyles[RecordType.medical]!.inkColor, AppColors.errorDark); expect(recordTypeStyles[RecordType.medical]!.inkColor, AppColors.errorDark);
expect(recordTypeStyles[RecordType.other]!.inkColor, AppColors.inkSoft); expect(recordTypeStyles[RecordType.other]!.inkColor, AppColors.inkSoft);
expect(recordTypeStyles[RecordType.medical]!.label, '就医'); expect(recordTypeStyles[RecordType.medical]!.label, '就医');
// T2-14 增补三型:色族复用已审计色对,仅图标/文案区分。
expect(
recordTypeStyles[RecordType.feeding]!.inkColor,
AppColors.successInk,
);
expect(recordTypeStyles[RecordType.feeding]!.label, '喂养');
expect(
recordTypeStyles[RecordType.grooming]!.inkColor,
AppColors.accentDark,
);
expect(recordTypeStyles[RecordType.grooming]!.label, '洗护');
expect(
recordTypeStyles[RecordType.measurement]!.inkColor,
AppColors.primaryDark,
);
expect(recordTypeStyles[RecordType.measurement]!.label, '测量');
// 三型图标彼此不同(与既有五型也不重复),保证图标通道可辨。
final icons = {for (final style in recordTypeStyles.values) style.icon};
expect(icons.length, RecordType.values.length);
}); });
testWidgets('圆标渲染:尺寸档正确、图标为 dot 的 50%、底为基色 8% 淡染', (tester) async { testWidgets('圆标渲染:尺寸档正确、图标为 dot 的 50%、底为基色 8% 淡染', (tester) async {
@@ -0,0 +1,207 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/features/pets/health_event_edit_page.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_models.dart';
import '../../helpers/pet_test_helpers.dart';
void main() {
late FakePetsRepository repository;
late List<(String, Map<String, dynamic>?)> events;
late HealthRecordAnalytics analytics;
setUp(() {
repository = FakePetsRepository();
events = [];
analytics = HealthRecordAnalytics(
(name, [props]) async => events.add((name, props)),
);
});
List<Map<String, dynamic>?> eventsOf(String name) => [
for (final e in events)
if (e.$1 == name) e.$2,
];
HealthEvent baseEvent({int version = 3}) =>
buildHealthEvent('e-1', overrides: {'notes': '医院复查', 'version': version});
Future<void> pumpEdit(WidgetTester tester, {HealthEvent? event}) async {
tester.view.physicalSize = const Size(700, 1600);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: TextButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<HealthEvent>(
builder: (_) => HealthEventEditPage(
repository: repository,
event: event ?? baseEvent(),
analytics: analytics,
),
),
),
child: const Text('打开编辑'),
),
),
),
),
),
);
await tester.tap(find.text('打开编辑'));
await tester.pumpAndSettle();
}
testWidgets('预填与身份静态区:类型/发生时间不可编辑,金额以元回显', (tester) async {
await pumpEdit(tester);
// 身份区:类型文案 + 发生时间(无输入控件)。
expect(find.text('就医'), findsOneWidget);
// 预填:标题 / 金额(12850 分 → 128.50 元)/ 备注。
expect(find.text('皮肤检查'), findsOneWidget);
expect(find.text('128.50'), findsOneWidget);
expect(find.text('医院复查'), findsOneWidget);
});
testWidgets('差量提交:只发送改动字段 + version;成功回传并报 edit_succeeded(fieldCount)', (
tester,
) async {
UpdateHealthEventRequest? captured;
repository.updateHealthEventHandler = (eventId, request) async {
captured = request;
return buildHealthEvent('e-1', overrides: {'version': 4});
};
await pumpEdit(tester);
await tester.enterText(find.widgetWithText(TextFormField, '标题'), '皮肤复查');
await tester.enterText(find.widgetWithText(TextFormField, '金额(元)'), '99');
await tester.tap(find.text('保存修改'));
await tester.pumpAndSettle();
// 备注未改不出现;金额 99 元 → 9900 分。
expect(captured!.toJson(), {
'version': 3,
'title': '皮肤复查',
'amountCents': 9900,
});
final succeeded = eventsOf('health_record_edit_succeeded');
expect(succeeded.single, {'recordType': 'health_event', 'fieldCount': 2});
expect(find.byType(HealthEventEditPage), findsNothing);
});
testWidgets('无变更不发 PATCH 直接返回;清空可选字段视为不变更(契约不支持清空回 null)', (tester) async {
var called = false;
repository.updateHealthEventHandler = (eventId, request) async {
called = true;
return baseEvent();
};
await pumpEdit(tester);
// 清空金额与备注:契约不支持清空回 null → 视为不变更。
await tester.enterText(find.widgetWithText(TextFormField, '金额(元)'), '');
await tester.enterText(find.widgetWithText(TextFormField, '备注'), '');
await tester.tap(find.text('保存修改'));
await tester.pumpAndSettle();
expect(called, isFalse);
expect(find.byType(HealthEventEditPage), findsNothing);
expect(eventsOf('health_record_edit_succeeded'), isEmpty);
});
testWidgets('40902 冲突:明确提示 + 经时间线检索自动取新 version(保留输入),重提成功', (tester) async {
final submittedVersions = <int>[];
var conflictOnce = true;
repository.updateHealthEventHandler = (eventId, request) async {
submittedVersions.add(request.version);
if (conflictOnce) {
conflictOnce = false;
throw const PetVersionConflictException(message: '数据已被修改');
}
return buildHealthEvent('e-1', overrides: {'version': 8});
};
// 契约无按 id 读取端点:40902 后经时间线分页检索取回最新版本(version 7)。
final listCaptured = <String?>[];
repository.listHealthEventsHandler = (petId, limit, cursor) async {
listCaptured.add(cursor);
return CursorPage(
items: [buildHealthEvent('e-other'), baseEvent(version: 7)],
nextCursor: null,
hasMore: false,
);
};
await pumpEdit(tester);
await tester.enterText(find.widgetWithText(TextFormField, '标题'), '皮肤复查');
await tester.tap(find.text('保存修改'));
await tester.pumpAndSettle();
// 明确提示 + 用户输入保留。
expect(find.text('记录已在其他设备被修改,已获取最新版本,请核对后重新保存'), findsOneWidget);
expect(find.text('皮肤复查'), findsOneWidget);
expect(listCaptured, [null]);
// conflict 失败事件(M2 验收「并发冲突明确」数据面)。
final failed = eventsOf('health_record_edit_failed');
expect(failed.single, {
'recordType': 'health_event',
'failureReason': 'conflict',
'errorCode': 40902,
'httpStatus': 409,
});
// 重提:自动用检索回的新 version。
await tester.tap(find.text('保存修改'));
await tester.pumpAndSettle();
expect(submittedVersions, [3, 7]);
expect(find.byType(HealthEventEditPage), findsNothing);
});
testWidgets('40402 记录不存在:SnackBar + 返回;40300 横幅;网络 SnackBar', (tester) async {
var attempt = 0;
repository.updateHealthEventHandler = (eventId, request) async {
attempt++;
switch (attempt) {
case 1:
throw const PetAccessDeniedException(message: '无权限');
case 2:
throw const ApiNetworkException('断网');
default:
throw const PetRecordNotFoundException(message: '不存在');
}
};
await pumpEdit(tester);
await tester.enterText(find.widgetWithText(TextFormField, '标题'), '皮肤复查');
await tester.tap(find.text('保存修改'));
await tester.pumpAndSettle();
expect(find.text('你没有权限修改该记录'), findsOneWidget);
await tester.tap(find.text('保存修改'));
await tester.pumpAndSettle();
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
// 经 SnackBar「重试」重提(顺带锁定重试动作接线)→ 40402。
await tester.tap(find.text('重试'));
await tester.pumpAndSettle();
expect(find.text('记录不存在或已被删除'), findsOneWidget);
expect(find.byType(HealthEventEditPage), findsNothing);
final failed = eventsOf('health_record_edit_failed');
expect(failed.length, 3);
expect(failed[0]!['failureReason'], 'permission_denied');
expect(failed[1]!['failureReason'], 'network_error');
expect(failed[2]!['failureReason'], 'not_found');
expect(failed[2]!['errorCode'], 40402);
});
}
@@ -0,0 +1,195 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.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/pet_exceptions.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import '../../helpers/pet_test_helpers.dart';
void main() {
late FakePetsRepository repository;
late List<(String, Map<String, dynamic>?)> events;
late HealthRecordAnalytics analytics;
setUp(() {
repository = FakePetsRepository();
events = [];
analytics = HealthRecordAnalytics(
(name, [props]) async => events.add((name, props)),
);
});
List<Map<String, dynamic>?> eventsOf(String name) => [
for (final e in events)
if (e.$1 == name) e.$2,
];
Future<void> pumpForm(WidgetTester tester) async {
tester.view.physicalSize = const Size(700, 1700);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: TextButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<HealthEvent>(
builder: (_) => HealthEventFormPage(
repository: repository,
petId: 'p-1',
analytics: analytics,
),
),
),
child: const Text('打开表单'),
),
),
),
),
),
);
await tester.tap(find.text('打开表单'));
await tester.pumpAndSettle();
}
Future<void> fillValid(WidgetTester tester, {String? amount}) async {
await tester.tap(find.text('就医'));
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, '标题(如:皮肤检查)'),
'皮肤检查',
);
if (amount != null) {
await tester.enterText(
find.widgetWithText(TextFormField, '金额(元,可选)'),
amount,
);
}
await tester.pump();
}
testWidgets('本地校验:类型/标题缺失与金额非法拦截,不发请求且报 validation_error', (tester) async {
var called = false;
repository.createHealthEventHandler = (petId, request) async {
called = true;
return buildHealthEvent('e-1');
};
await pumpForm(tester);
await tester.enterText(
find.widgetWithText(TextFormField, '金额(元,可选)'),
'12.345',
);
await tester.tap(find.text('保存记录'));
await tester.pump();
expect(find.text('请选择事件类型'), findsOneWidget);
expect(find.text('请输入标题'), findsOneWidget);
expect(find.text('金额格式不正确,最多两位小数'), findsOneWidget);
expect(called, isFalse);
final failed = eventsOf('health_record_create_failed');
expect(failed.single!['recordType'], 'health_event');
expect(failed.single!['failureReason'], 'validation_error');
});
testWidgets('成功请求形状:六类类型、UTC 时间戳、金额元→整数分、无金额时键缺席', (tester) async {
CreateHealthEventRequest? captured;
repository.createHealthEventHandler = (petId, request) async {
captured = request;
return buildHealthEvent('e-1');
};
await pumpForm(tester);
await fillValid(tester, amount: '128.50');
await tester.tap(find.text('保存记录'));
await tester.pumpAndSettle();
final json = captured!.toJson();
expect(json['eventType'], 'medical');
expect(json['title'], '皮肤检查');
// 金额以元录入 → 整数分传输(工单硬项)。
expect(json['amountCents'], 12850);
// 今日默认此刻,转 UTC 带 Z 上送。
expect(json['occurredAt'], endsWith('Z'));
expect(json.containsKey('notes'), isFalse);
final succeeded = eventsOf('health_record_create_succeeded');
expect(succeeded.single!['recordType'], 'health_event');
expect(succeeded.single!['durationMs'], isA<int>());
});
testWidgets('无金额提交:amountCents 键整体缺席(非 0、非 null)', (tester) async {
CreateHealthEventRequest? captured;
repository.createHealthEventHandler = (petId, request) async {
captured = request;
return buildHealthEvent('e-1');
};
await pumpForm(tester);
await fillValid(tester);
await tester.tap(find.text('保存记录'));
await tester.pumpAndSettle();
expect(captured!.toJson().containsKey('amountCents'), isFalse);
});
testWidgets('started 去重:首次输入一次,后续输入不再上报', (tester) async {
await pumpForm(tester);
await fillValid(tester);
await tester.enterText(
find.widgetWithText(TextFormField, '备注(可选)'),
'换季护理',
);
await tester.pump();
final started = eventsOf('health_record_create_started');
expect(started.single, {
'recordType': 'health_event',
'entryPoint': 'record_list',
});
});
testWidgets('40300 横幅 / 40000 横幅 / 网络 SnackBar 三兜底 + 失败事件', (tester) async {
var attempt = 0;
repository.createHealthEventHandler = (petId, request) async {
attempt++;
switch (attempt) {
case 1:
throw const PetAccessDeniedException(message: '无权限');
case 2:
throw const ApiBusinessException(code: 40000, message: '参数错误');
default:
throw const ApiNetworkException('断网');
}
};
await pumpForm(tester);
await fillValid(tester);
await tester.tap(find.text('保存记录'));
await tester.pumpAndSettle();
expect(find.text('你没有权限为该宠物添加记录'), findsOneWidget);
await tester.tap(find.text('保存记录'));
await tester.pumpAndSettle();
expect(find.text('请检查填写内容后重试'), findsOneWidget);
await tester.tap(find.text('保存记录'));
await tester.pumpAndSettle();
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
final failed = eventsOf('health_record_create_failed');
expect(failed.length, 3);
expect(failed[0]!['failureReason'], 'permission_denied');
expect(failed[0]!['errorCode'], 40300);
expect(failed[1]!['failureReason'], 'validation_error');
expect(failed[1]!['errorCode'], 40000);
expect(failed[2]!['failureReason'], 'network_error');
expect(failed[2]!['attemptSeq'], 3);
});
}
@@ -0,0 +1,288 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
import 'package:patbond_flutter/features/pets/health_event_edit_page.dart';
import 'package:patbond_flutter/features/pets/health_event_form_page.dart';
import 'package:patbond_flutter/features/pets/health_events_page.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/widgets/common.dart';
import '../../helpers/pet_test_helpers.dart';
void main() {
late FakePetsRepository repository;
late List<(String, Map<String, dynamic>?)> events;
late HealthRecordAnalytics analytics;
setUp(() {
repository = FakePetsRepository();
events = [];
analytics = HealthRecordAnalytics(
(name, [props]) async => events.add((name, props)),
);
});
List<Map<String, dynamic>?> eventsOf(String name) => [
for (final e in events)
if (e.$1 == name) e.$2,
];
Future<void> pumpPage(
WidgetTester tester, {
bool canWrite = true,
int? pageSize,
}) async {
tester.view.physicalSize = const Size(700, 1600);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: const Scaffold(body: Text('详情基底')),
),
);
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
unawaited(
navigator.push(
MaterialPageRoute<void>(
builder: (_) => HealthEventsPage(
repository: repository,
petId: 'p-1',
canWrite: canWrite,
analytics: analytics,
pageSize: pageSize,
),
),
),
);
await tester.pump();
}
CursorPage<HealthEvent> page(
List<HealthEvent> items, {
String? next,
bool hasMore = false,
}) => CursorPage(items: items, nextCursor: next, hasMore: hasMore);
testWidgets('四态 · ready:六类条目、月分组组头、金额元展示、类型标签;viewed 一次', (tester) async {
repository.listHealthEventsHandler = (petId, limit, cursor) async => page([
buildHealthEvent(
'e-1',
overrides: {'occurredAt': '2026-09-05T14:00:00+08:00'},
),
buildHealthEvent(
'e-2',
overrides: {
'eventType': 'deworming',
'title': '体内驱虫',
'occurredAt': '2026-09-01T10:00:00+08:00',
'amountCents': null,
'notes': '博来恩',
},
),
buildHealthEvent(
'e-3',
overrides: {
'eventType': 'note',
'title': '换粮观察',
'occurredAt': '2026-08-20T10:00:00+08:00',
'amountCents': null,
},
),
]);
await pumpPage(tester);
await tester.pumpAndSettle();
// 月分组:9 月两条 + 8 月一条各一枚组头。
expect(find.text('2026 年 9 月'), findsOneWidget);
expect(find.text('2026 年 8 月'), findsOneWidget);
// 条目标题与副行(日期 · 备注)。
expect(find.text('皮肤检查'), findsOneWidget);
expect(find.textContaining('· 博来恩'), findsOneWidget);
// 类型 TagPill(图标 + 文字双通道)。
expect(find.widgetWithText(TagPill, '就医'), findsOneWidget);
expect(find.widgetWithText(TagPill, '驱虫'), findsOneWidget);
expect(find.widgetWithText(TagPill, '随手记'), findsOneWidget);
// 金额:12850 分 → 元展示;无金额条目不渲染金额。
expect(find.text('¥128.50'), findsOneWidget);
final viewed = eventsOf('health_record_viewed');
expect(viewed.single, {
'recordType': 'health_event',
'source': 'pet_detail',
});
});
testWidgets('四态 · loading / empty:空态插画 + 录入 CTA', (tester) async {
final completer = Completer<CursorPage<HealthEvent>>();
repository.listHealthEventsHandler = (petId, limit, cursor) =>
completer.future;
await pumpPage(tester);
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
completer.complete(page(const []));
await tester.pumpAndSettle();
expect(find.byType(EmptyStateIllustration), findsOneWidget);
expect(find.text('还没有健康记录'), findsOneWidget);
expect(find.text('记录第一条'), findsOneWidget);
});
testWidgets('四态 · error/retry:横幅 + 重试恢复', (tester) async {
var calls = 0;
repository.listHealthEventsHandler = (petId, limit, cursor) async {
calls++;
if (calls == 1) throw const ApiNetworkException('断网');
return page([buildHealthEvent('e-1')]);
};
await pumpPage(tester);
await tester.pumpAndSettle();
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
await tester.tap(find.text('重试'));
await tester.pumpAndSettle();
expect(find.text('皮肤检查'), findsOneWidget);
});
testWidgets('cursor 分页:透传游标追加不重不漏,末页收起按钮;翻页失败保留重试', (tester) async {
final captured = <(int?, String?)>[];
var moreFails = true;
repository.listHealthEventsHandler = (petId, limit, cursor) async {
captured.add((limit, cursor));
if (cursor == null) {
return page([buildHealthEvent('e-1')], next: 'CUR-1', hasMore: true);
}
if (moreFails) {
moreFails = false;
throw const ApiNetworkException('断网');
}
return page([
buildHealthEvent(
'e-2',
overrides: {
'title': '洗澡美容',
'eventType': 'grooming',
'occurredAt': '2026-09-01T10:00:00+08:00',
'amountCents': null,
},
),
]);
};
await pumpPage(tester, pageSize: 1);
await tester.pumpAndSettle();
expect(find.text('皮肤检查'), findsOneWidget);
expect(find.text('加载更多'), findsOneWidget);
// 第一次翻页失败:SnackBar + 按钮保留。
await tester.tap(find.text('加载更多'));
await tester.pumpAndSettle();
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
expect(find.text('加载更多'), findsOneWidget);
// 重试成功:追加且不重复,末页收起按钮。
await tester.tap(find.text('加载更多'));
await tester.pumpAndSettle();
expect(find.text('皮肤检查'), findsOneWidget);
expect(find.text('洗澡美容'), findsOneWidget);
expect(find.text('加载更多'), findsNothing);
expect(captured, [(1, null), (1, 'CUR-1'), (1, 'CUR-1')]);
});
testWidgets('录入闭环:CTA → 表单(record_form 路由名)→ 成功后重拉首页', (tester) async {
var listCalls = 0;
repository.listHealthEventsHandler = (petId, limit, cursor) async {
listCalls++;
return listCalls == 1
? page(const [])
: page([
buildHealthEvent('e-new', overrides: {'title': '首次体检'}),
]);
};
repository.createHealthEventHandler = (petId, request) async =>
buildHealthEvent('e-new', overrides: {'title': '首次体检'});
await pumpPage(tester);
await tester.pumpAndSettle();
await tester.tap(find.text('记录第一条'));
await tester.pumpAndSettle();
expect(find.byType(HealthEventFormPage), findsOneWidget);
final route = ModalRoute.of(
tester.element(find.byType(HealthEventFormPage)),
)!;
expect(route.settings.name, 'record_form');
await tester.tap(find.text('就医'));
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, '标题(如:皮肤检查)'),
'首次体检',
);
await tester.tap(find.text('保存记录'));
await tester.pumpAndSettle();
expect(find.byType(HealthEventFormPage), findsNothing);
expect(find.text('已记录健康事件'), findsOneWidget);
// 排序/月分组以服务端为准:成功后重拉首页。
expect(listCalls, 2);
expect(find.text('首次体检'), findsOneWidget);
});
testWidgets('编辑闭环:点条目 → 编辑页(无路由名)→ 成功后就地替换', (tester) async {
repository.listHealthEventsHandler = (petId, limit, cursor) async =>
page([buildHealthEvent('e-1')]);
repository.updateHealthEventHandler = (eventId, request) async =>
buildHealthEvent('e-1', overrides: {'title': '皮肤复查', 'version': 2});
await pumpPage(tester);
await tester.pumpAndSettle();
await tester.tap(find.text('皮肤检查'));
await tester.pumpAndSettle();
expect(find.byType(HealthEventEditPage), findsOneWidget);
final route = ModalRoute.of(
tester.element(find.byType(HealthEventEditPage)),
)!;
// 编辑页不带路由名:record_form 专属创建漏斗到达段。
expect(route.settings.name, isNull);
await tester.enterText(find.widgetWithText(TextFormField, '标题'), '皮肤复查');
await tester.tap(find.text('保存修改'));
await tester.pumpAndSettle();
expect(find.byType(HealthEventEditPage), findsNothing);
expect(find.text('已保存修改'), findsOneWidget);
expect(find.text('皮肤复查'), findsOneWidget);
expect(find.text('皮肤检查'), findsNothing);
});
testWidgets('viewercanWrite=false):无录入入口、空态无 CTA、点条目不进编辑', (tester) async {
repository.listHealthEventsHandler = (petId, limit, cursor) async =>
page([buildHealthEvent('e-1')]);
await pumpPage(tester, canWrite: false);
await tester.pumpAndSettle();
expect(find.byIcon(Icons.add), findsNothing);
await tester.tap(find.text('皮肤检查'));
await tester.pumpAndSettle();
expect(find.byType(HealthEventEditPage), findsNothing);
});
}
@@ -82,4 +82,45 @@ void main() {
'source': 'pet_detail', 'source': 'pet_detail',
}); });
}); });
test('editSucceededrecordType + fieldCount(差量键数,不含 version', () {
analytics.editSucceeded(
recordType: HealthRecordType.healthEvent,
fieldCount: 2,
);
expect(events.single.$1, 'health_record_edit_succeeded');
expect(events.single.$2, {'recordType': 'health_event', 'fieldCount': 2});
});
test(
'editFailedconflict40902)带 errorCode 与 httpStatus 推导,无 attemptSeq',
() {
analytics.editFailed(
recordType: HealthRecordType.vaccine,
reason: HealthRecordFailureReason.conflict,
errorCode: 40902,
);
expect(events.single.$1, 'health_record_edit_failed');
expect(events.single.$2, {
'recordType': 'vaccine',
'failureReason': 'conflict',
'errorCode': 40902,
'httpStatus': 409,
});
},
);
test('editFailed:网络失败可空属性整体缺席', () {
analytics.editFailed(
recordType: HealthRecordType.healthEvent,
reason: HealthRecordFailureReason.networkError,
);
expect(events.single.$2, {
'recordType': 'health_event',
'failureReason': 'network_error',
});
});
} }
@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.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/record_type_dot.dart';
import 'package:patbond_flutter/features/pets/health_record_display.dart'; import 'package:patbond_flutter/features/pets/health_record_display.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart'; import 'package:patbond_flutter/features/pets/pet_models.dart';
@@ -147,4 +148,57 @@ void main() {
); );
}); });
}); });
group('T2-14 · 健康事件展示纯函数', () {
test('六类事件 → RecordType 映射齐备(note 归「其他」,其余五类专属)', () {
expect(
recordTypeForHealthEvent(HealthEventType.medical),
RecordType.medical,
);
expect(
recordTypeForHealthEvent(HealthEventType.feeding),
RecordType.feeding,
);
expect(
recordTypeForHealthEvent(HealthEventType.deworming),
RecordType.deworming,
);
expect(
recordTypeForHealthEvent(HealthEventType.grooming),
RecordType.grooming,
);
expect(
recordTypeForHealthEvent(HealthEventType.measurement),
RecordType.measurement,
);
expect(recordTypeForHealthEvent(HealthEventType.note), RecordType.other);
});
test('六类事件中文文案', () {
expect(
[for (final t in HealthEventType.values) healthEventTypeLabel(t)],
['就医', '喂养', '驱虫', '洗护', '测量', '随手记'],
);
});
test('月分组组头:按本地时区归月', () {
expect(healthEventMonthHeader(DateTime(2026, 9, 5, 14)), '2026 年 9 月');
expect(healthEventMonthHeader(DateTime(2025, 12, 31)), '2025 年 12 月');
});
});
group('T2-14 · tz 固定偏移(summary tz 参数)', () {
test('正/负/零/半小时偏移格式', () {
expect(tzOffsetQueryValue(const Duration(hours: 8)), '+08:00');
expect(
tzOffsetQueryValue(const Duration(hours: -5, minutes: -30)),
'-05:30',
);
expect(tzOffsetQueryValue(Duration.zero), '+00:00');
expect(
tzOffsetQueryValue(const Duration(hours: 5, minutes: 45)),
'+05:45',
);
});
});
} }
+61 -5
View File
@@ -4,6 +4,8 @@ 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/core/theme/app_theme.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/features/pets/health_events_page.dart';
import 'package:patbond_flutter/features/pets/health_record_display.dart';
import 'package:patbond_flutter/features/pets/pet_detail_page.dart'; import 'package:patbond_flutter/features/pets/pet_detail_page.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart'; import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
import 'package:patbond_flutter/features/pets/pet_form_page.dart'; import 'package:patbond_flutter/features/pets/pet_form_page.dart';
@@ -24,8 +26,9 @@ void main() {
}); });
Future<void> pumpDetail(WidgetTester tester, {String petId = 'p-1'}) async { Future<void> pumpDetail(WidgetTester tester, {String petId = 'p-1'}) async {
// 详情页自 T2-13 增加健康数据卡行,加高视口保证底部按钮在栏内。 // 详情页自 T2-13 增加健康数据卡行、T2-14 增加花费卡与记录导航区,
tester.view.physicalSize = const Size(700, 1800); // 加高视口保证底部按钮在栏内。
tester.view.physicalSize = const Size(700, 2200);
tester.view.devicePixelRatio = 1.0; tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
await tester.pumpWidget( await tester.pumpWidget(
@@ -184,7 +187,7 @@ void main() {
}); });
group('T2-13 · 数据卡行接 summary', () { group('T2-13 · 数据卡行接 summary', () {
testWidgets('卡取数:最新体重 / 疫苗进度 / 下一针(含疫苗名', (tester) async { testWidgets('卡取数:最新体重 / 疫苗进度 / 下一针 / 本月花费(tz 透传', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1'); repository.getPetHandler = (petId) async => buildPet('p-1');
String? capturedTz; String? capturedTz;
repository.getPetSummaryHandler = (petId, tz) async { repository.getPetSummaryHandler = (petId, tz) async {
@@ -202,8 +205,12 @@ void main() {
expect(find.text('疫苗进度'), findsOneWidget); expect(find.text('疫苗进度'), findsOneWidget);
expect(find.text('2026-08-01'), findsOneWidget); expect(find.text('2026-08-01'), findsOneWidget);
expect(find.text('下一针·狂犬疫苗'), findsOneWidget); expect(find.text('下一针·狂犬疫苗'), findsOneWidget);
// 本单不消费 monthlyExpenseT2-14),tz 不传走服务端缺省 UTC // T2-14:月度花费卡接 monthlyExpense12850 分 → 元展示)
expect(capturedTz, isNull); expect(find.text('¥128.50'), findsOneWidget);
expect(find.text('本月花费'), findsOneWidget);
// T2-13 遗留③:tz 透传设备时区固定偏移(月度窗口随设备时区)。
expect(capturedTz, tzOffsetQueryValue(DateTime.now().timeZoneOffset));
expect(capturedTz, matches(RegExp(r'^[+-]\d{2}:\d{2}$')));
}); });
testWidgets('null 语义:无登记显示空态而非 0/0', (tester) async { testWidgets('null 语义:无登记显示空态而非 0/0', (tester) async {
@@ -292,4 +299,53 @@ void main() {
expect(find.byIcon(Icons.add), findsNothing); expect(find.byIcon(Icons.add), findsNothing);
}); });
}); });
group('T2-14 · 时间线入口', () {
testWidgets('点本月花费卡 → 健康时间线页;返回后重拉摘要', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1');
var summaryCalls = 0;
repository.getPetSummaryHandler = (petId, tz) async {
summaryCalls++;
return buildSummary();
};
repository.listHealthEventsHandler = (petId, limit, cursor) async =>
const CursorPage(items: [], nextCursor: null, hasMore: false);
await pumpDetail(tester);
await tester.pumpAndSettle();
await tester.tap(find.text('本月花费'));
await tester.pumpAndSettle();
expect(find.byType(HealthEventsPage), findsOneWidget);
expect(find.text('健康时间线'), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
expect(summaryCalls, 2);
});
testWidgets('记录导航区「健康时间线」入口可达(viewer 透传隐藏录入)', (tester) async {
repository.getPetHandler = (petId) async =>
buildPet('p-1', overrides: {'myRole': 'viewer'});
repository.getPetSummaryHandler = (petId, tz) async => buildSummary();
repository.listHealthEventsHandler = (petId, limit, cursor) async =>
const CursorPage(items: [], nextCursor: null, hasMore: false);
await pumpDetail(tester);
await tester.pumpAndSettle();
final tile = find.widgetWithText(ListTile, '健康时间线');
expect(tile, findsOneWidget);
await tester.ensureVisible(tile);
await tester.tap(tile);
await tester.pumpAndSettle();
expect(find.byType(HealthEventsPage), findsOneWidget);
// viewer:空态无 CTA、AppBar 无添加入口。
expect(find.text('记录第一条'), findsNothing);
expect(find.byIcon(Icons.add), findsNothing);
});
});
} }
+77
View File
@@ -163,6 +163,23 @@ Vaccination buildVaccination(
}) => }) =>
Vaccination.fromJson({...sampleVaccinationJson(), 'id': id, ...overrides}); Vaccination.fromJson({...sampleVaccinationJson(), 'id': id, ...overrides});
/// 快速构造健康事件。
HealthEvent buildHealthEvent(
String id, {
Map<String, Object?> overrides = const {},
}) =>
HealthEvent.fromJson({...sampleHealthEventJson(), 'id': id, ...overrides});
/// 快速构造照护提醒。
CareReminder buildReminder(
String id, {
Map<String, Object?> overrides = const {},
}) => CareReminder.fromJson({
...sampleCareReminderJson(),
'id': id,
...overrides,
});
/// 快速构造摘要(缺省为「三聚合齐备」样本;overrides 可置 null 验证空态)。 /// 快速构造摘要(缺省为「三聚合齐备」样本;overrides 可置 null 验证空态)。
PetSummary buildSummary({Map<String, Object?> overrides = const {}}) => PetSummary buildSummary({Map<String, Object?> overrides = const {}}) =>
PetSummary.fromJson({...samplePetSummaryJson(), ...overrides}); PetSummary.fromJson({...samplePetSummaryJson(), ...overrides});
@@ -184,6 +201,20 @@ class FakePetsRepository implements PetsRepository {
Future<List<Vaccination>> Function(String)? listVaccinationsHandler; Future<List<Vaccination>> Function(String)? listVaccinationsHandler;
Future<Vaccination> Function(String, CreateVaccinationRequest)? Future<Vaccination> Function(String, CreateVaccinationRequest)?
createVaccinationHandler; createVaccinationHandler;
Future<Vaccination> Function(String, UpdateVaccinationRequest)?
updateVaccinationHandler;
Future<CursorPage<HealthEvent>> Function(String, int?, String?)?
listHealthEventsHandler;
Future<HealthEvent> Function(String, CreateHealthEventRequest)?
createHealthEventHandler;
Future<HealthEvent> Function(String, UpdateHealthEventRequest)?
updateHealthEventHandler;
Future<List<CareReminder>> Function(String, CareReminderStatus?)?
listCareRemindersHandler;
Future<CareReminder> Function(String, CreateCareReminderRequest)?
createCareReminderHandler;
Future<CareReminder> Function(String, UpdateCareReminderRequest)?
updateCareReminderHandler;
Future<PetSummary> Function(String, String?)? getPetSummaryHandler; Future<PetSummary> Function(String, String?)? getPetSummaryHandler;
@override @override
@@ -233,6 +264,52 @@ class FakePetsRepository implements PetsRepository {
CreateVaccinationRequest request, CreateVaccinationRequest request,
) => createVaccinationHandler!(petId, request); ) => createVaccinationHandler!(petId, request);
@override
Future<Vaccination> updateVaccination(
String vaccinationId,
UpdateVaccinationRequest request,
) => updateVaccinationHandler!(vaccinationId, request);
@override
Future<CursorPage<HealthEvent>> listHealthEvents(
String petId, {
int? limit,
String? cursor,
}) => listHealthEventsHandler!(petId, limit, cursor);
@override
Future<HealthEvent> createHealthEvent(
String petId,
CreateHealthEventRequest request,
) => createHealthEventHandler!(petId, request);
@override
Future<HealthEvent> updateHealthEvent(
String eventId,
UpdateHealthEventRequest request,
) => updateHealthEventHandler!(eventId, request);
@override
Future<List<CareReminder>> listCareReminders(
String petId, {
CareReminderStatus? status,
}) =>
listCareRemindersHandler?.call(petId, status) ??
// 缺省空列表:既有详情页测试不必逐个注入。
Future.value(const []);
@override
Future<CareReminder> createCareReminder(
String petId,
CreateCareReminderRequest request,
) => createCareReminderHandler!(petId, request);
@override
Future<CareReminder> updateCareReminder(
String reminderId,
UpdateCareReminderRequest request,
) => updateCareReminderHandler!(reminderId, request);
@override @override
Future<PetSummary> getPetSummary(String petId, {String? tz}) => Future<PetSummary> getPetSummary(String petId, {String? tz}) =>
getPetSummaryHandler?.call(petId, tz) ?? getPetSummaryHandler?.call(petId, tz) ??