新增:健康事件时间线接入真实数据(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
+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/pet_avatar.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_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_display.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
@@ -104,11 +106,14 @@ class _PetDetailPageState extends State<PetDetailPage> {
}
/// 摘要实时聚合(40401 由主链路 notFound 态承载,摘要只降级为 error 态)。
/// `tz` 透传设备时区固定偏移(T2-13 遗留③):monthlyExpense 的月度
/// 窗口随设备时区取边界,与用户直觉一致。
Future<void> _loadSummary() async {
setState(() => _summaryPhase = _SummaryPhase.loading);
try {
final summary = await widget.controller.repository.getPetSummary(
widget.petId,
tz: tzOffsetQueryValue(DateTime.now().timeZoneOffset),
);
if (!mounted) return;
setState(() {
@@ -185,6 +190,21 @@ class _PetDetailPageState extends State<PetDetailPage> {
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
Widget build(BuildContext context) {
return Scaffold(
@@ -271,6 +291,8 @@ class _PetDetailPageState extends State<PetDetailPage> {
Text('健康数据', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 10),
_summarySection(pet),
const SizedBox(height: 16),
_recordsSection(pet),
const SizedBox(height: 24),
Text('基本资料', style: Theme.of(context).textTheme.titleLarge),
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 语义 → 空态文案。
Widget _summarySection(Pet pet) {
switch (_summaryPhase) {
@@ -348,13 +394,15 @@ class _PetDetailPageState extends State<PetDetailPage> {
final weight = summary.latestWeight;
final progress = summary.vaccinationProgress;
final next = summary.nextVaccination;
final expense = summary.monthlyExpense;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: _SummaryCard(
type: RecordType.weight,
icon: recordTypeStyles[RecordType.weight]!.icon,
iconColor: recordTypeStyles[RecordType.weight]!.iconColor,
value: weight == null
? '暂无记录'
: '${formatWeightKg(weight.weightKg)} kg',
@@ -366,7 +414,8 @@ class _PetDetailPageState extends State<PetDetailPage> {
const SizedBox(width: 10),
Expanded(
child: _SummaryCard(
type: RecordType.vaccine,
icon: recordTypeStyles[RecordType.vaccine]!.icon,
iconColor: recordTypeStyles[RecordType.vaccine]!.iconColor,
// 契约:totalDoses=0 → 整体 null(不是 0/0)→ 空态文案。
value: progress == null
? '未登记'
@@ -379,13 +428,26 @@ class _PetDetailPageState extends State<PetDetailPage> {
const SizedBox(width: 10),
Expanded(
child: _SummaryCard(
type: RecordType.vaccine,
icon: recordTypeStyles[RecordType.vaccine]!.icon,
iconColor: recordTypeStyles[RecordType.vaccine]!.iconColor,
value: next == null ? '暂无安排' : dateToJson(next.dueOn),
emphasized: next != null,
label: next == null ? '下一针' : '下一针·${next.vaccineName}',
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 常规字重(区分「有数据」与「空态」两种视觉)。
class _SummaryCard extends StatelessWidget {
const _SummaryCard({
required this.type,
required this.icon,
required this.iconColor,
required this.value,
required this.label,
required this.emphasized,
this.onTap,
});
final RecordType type;
final IconData icon;
final Color iconColor;
final String value;
final String label;
final bool emphasized;
@@ -412,7 +476,6 @@ class _SummaryCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final style = recordTypeStyles[type]!;
return Card(
child: InkWell(
onTap: onTap,
@@ -422,7 +485,7 @@ class _SummaryCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(style.icon, size: 18, color: style.iconColor),
Icon(icon, size: 18, color: iconColor),
const SizedBox(height: 8),
Text(
value,