Files
patbond-flutter/lib/features/pets/pet_detail_page.dart
T
lixi ba503327f5
CI / flutter-gates (push) Successful in 2m12s
新增:照护提醒接入与疫苗流转收尾(T2-14 提醒半边 + T2-13 遗留)
- 照护提醒页:status 过滤(服务端白名单视图)、due_at ASC、逾期红标
  双通道标识,四态齐备;创建表单(四类)+ 完成(completedAt 必带、
  支持补记)/ 忽略(禁带 completedAt)流转,42202/40902 兜底重拉
- 档案页:「健康提醒」卡改真实待办数据驱动(取代 demo 硬编码文案,
  最近到期一条 + 逾期警示形态),照护提醒导航入口带待办数副行
- 疫苗列表(25 号报告遗留①②):scheduled 行「标记完成/取消登记」
  PATCH 流转;完成对话框补录厂商/批号(契约可选字段);
  42201/40902/40402 兜底
- 埋点:create 三事件 + viewed(recordType=reminder)挂通;
  edit_succeeded/failed 挂 vaccine 流转(failureReason 含 conflict);
  提醒完成/忽略按 06 §7 缺口 3 既定取舍不埋
- 测试 250 → 272 全绿(+22,较基线 +48);analyze 0 问题;format 无 diff

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 14:01:17 +08:00

700 lines
24 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.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/pet_avatar.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
import 'package:patbond_flutter/features/pets/care_reminders_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/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';
import 'package:patbond_flutter/features/pets/pet_form_page.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_controller.dart';
import 'package:patbond_flutter/features/pets/vaccination_records_page.dart';
import 'package:patbond_flutter/features/pets/weight_records_page.dart';
import 'package:patbond_flutter/widgets/common.dart';
enum _DetailPhase { loading, ready, error, notFound }
enum _SummaryPhase { loading, ready, error }
enum _RemindersPhase { loading, ready, error }
/// 宠物详情页(T2-12 / 05 号规范 §4.2 P2 的档案信息部分)。
///
/// 打开即用控制器内存副本首屏渲染,同时经 [PetsController.getPet]
/// 拉取最新详情(同步列表副本)。四态:loading / ready / error+重试 /
/// notFound40401 防枚举三态同响应 → 提示后返回列表并刷新)。
///
/// T2-13:数据卡行接 `GET /pets/{id}/summary` 实时聚合(最新体重 /
/// 疫苗进度 / 下一针,null 语义为空态文案而非 0/0),并作为体重历史、
/// 疫苗记录两页的导航入口;从记录页返回即重拉摘要。
class PetDetailPage extends StatefulWidget {
const PetDetailPage({
required this.controller,
required this.petId,
super.key,
this.analytics,
this.healthAnalytics,
});
final PetsController controller;
final String petId;
final PetAnalytics? analytics;
final HealthRecordAnalytics? healthAnalytics;
@override
State<PetDetailPage> createState() => _PetDetailPageState();
}
class _PetDetailPageState extends State<PetDetailPage> {
_DetailPhase _phase = _DetailPhase.loading;
Pet? _pet;
ApiException? _error;
_SummaryPhase _summaryPhase = _SummaryPhase.loading;
PetSummary? _summary;
_RemindersPhase _remindersPhase = _RemindersPhase.loading;
List<CareReminder> _pendingReminders = const [];
@override
void initState() {
super.initState();
_pet = _fromController();
if (_pet != null) _phase = _DetailPhase.ready;
_load();
_loadSummary();
_loadPendingReminders();
}
Pet? _fromController() {
for (final pet in widget.controller.pets) {
if (pet.id == widget.petId) return pet;
}
return null;
}
Future<void> _load() async {
if (_pet == null) {
setState(() => _phase = _DetailPhase.loading);
}
try {
final pet = await widget.controller.getPet(widget.petId);
if (!mounted) return;
setState(() {
_pet = pet;
_phase = _DetailPhase.ready;
});
} on PetNotFoundException {
if (!mounted) return;
setState(() => _phase = _DetailPhase.notFound);
} on ApiException catch (error) {
if (!mounted) return;
if (_pet == null) {
setState(() {
_error = error;
_phase = _DetailPhase.error;
});
} else {
// 已有内存副本:刷新失败降级为瞬态提示,不打断阅读。
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(petLoadErrorMessage(error))));
}
}
}
/// 摘要实时聚合(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(() {
_summary = summary;
_summaryPhase = _SummaryPhase.ready;
});
} on ApiException {
if (!mounted) return;
setState(() => _summaryPhase = _SummaryPhase.error);
}
}
/// 待办提醒(?status=pendingdue_at ASC):驱动「健康提醒」卡与
/// 提醒入口副行——demo 时代的硬编码提醒文案自此为真实数据取代。
/// 失败只降级为入口副行提示,不阻塞档案主链路。
Future<void> _loadPendingReminders() async {
setState(() => _remindersPhase = _RemindersPhase.loading);
try {
final reminders = await widget.controller.repository.listCareReminders(
widget.petId,
status: CareReminderStatus.pending,
);
if (!mounted) return;
setState(() {
_pendingReminders = reminders;
_remindersPhase = _RemindersPhase.ready;
});
} on ApiException {
if (!mounted) return;
setState(() => _remindersPhase = _RemindersPhase.error);
}
}
Future<void> _openEdit() async {
final pet = _pet;
if (pet == null) return;
final updated = await Navigator.of(context).push<Pet>(
// 编辑态不带路由名:pet_form 专属建宠漏斗到达段(06 §1.6),
// 编辑曝光不计入,避免「到达→动笔」分母虚高。
fadePageRoute(
PetFormPage.edit(
controller: widget.controller,
pet: pet,
analytics: widget.analytics,
),
),
);
if (updated != null && mounted) {
setState(() {
_pet = updated;
_phase = _DetailPhase.ready;
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已保存修改')));
}
}
/// 仅 owner 可改档案(40300caregiver/viewer 改档案被拒 → 隐藏写入口)。
bool get _canEdit =>
_phase == _DetailPhase.ready && _pet?.myRole == PetRole.owner;
/// 记录写入权限档 WRITEowner + caregiver);viewer 隐藏录入入口。
bool get _canWriteRecords => _pet?.myRole != PetRole.viewer;
Future<void> _openWeights(Pet pet) async {
await Navigator.of(context).push(
// 列表页页名不在字典 v2 枚举内(06 §5.2 验收 4:字典外不上报),
// 曝光由 health_record_viewed 承载。
fadePageRoute(
WeightRecordsPage(
repository: widget.controller.repository,
petId: pet.id,
canWrite: _canWriteRecords,
analytics: widget.healthAnalytics,
),
),
);
// 记录可能已变化:返回即重拉摘要(服务端实时聚合是唯一事实来源)。
if (mounted) await _loadSummary();
}
Future<void> _openVaccinations(Pet pet) async {
await Navigator.of(context).push(
fadePageRoute(
VaccinationRecordsPage(
repository: widget.controller.repository,
petId: pet.id,
petSpecies: pet.species,
canWrite: _canWriteRecords,
analytics: widget.healthAnalytics,
),
),
);
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();
}
Future<void> _openReminders(Pet pet) async {
await Navigator.of(context).push(
fadePageRoute(
CareRemindersPage(
repository: widget.controller.repository,
petId: pet.id,
canWrite: _canWriteRecords,
analytics: widget.healthAnalytics,
),
),
);
// 待办可能已变化(完成/忽略/新建):返回即重拉待办。
if (mounted) await _loadPendingReminders();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: AppColors.ink,
actions: [
if (_canEdit)
IconButton(
tooltip: '编辑资料',
onPressed: _openEdit,
icon: const Icon(Icons.edit_outlined),
),
],
),
body: switch (_phase) {
_DetailPhase.loading => const Center(
child: CircularProgressIndicator(),
),
_DetailPhase.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: _load, child: const Text('重试')),
],
),
),
),
_DetailPhase.notFound => Center(
child: SingleChildScrollView(
child: EmptyStateIllustration(
icon: Icons.search_off_rounded,
title: '宠物不存在或已被删除',
description: '档案可能已被移除,返回列表查看最新档案',
ctaLabel: '返回列表',
onCtaPressed: () {
widget.controller.refresh();
Navigator.of(context).pop();
},
),
),
),
_DetailPhase.ready => _content(_pet!),
},
);
}
Widget _content(Pet pet) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 30),
children: [
Column(
children: [
PetAvatar(
size: PetAvatarSize.xl,
showEditBadge: _canEdit,
onTap: _canEdit ? _openEdit : null,
semanticLabel: _canEdit ? '编辑宠物资料' : null,
),
const SizedBox(height: 12),
Text(pet.name, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 4),
Text(
petMetaLine(pet),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
if (pet.status != PetStatus.active) ...[
const SizedBox(height: 8),
TagPill(
petStatusLabel(pet.status),
color: pet.status == PetStatus.lost
? AppColors.error
: AppColors.muted,
),
],
],
),
const SizedBox(height: 24),
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),
SectionCard(
child: Column(
children: [
_InfoRow(label: '物种', value: petSpeciesLabel(pet.species)),
const _RowDivider(),
_InfoRow(label: '品种', value: petBreedLabel(pet)),
const _RowDivider(),
_InfoRow(label: '性别', value: petSexLabel(pet.sex)),
const _RowDivider(),
_InfoRow(
label: '生日',
value: pet.birthDate == null
? '未填写'
: dateToJson(pet.birthDate!) +
(pet.birthDateEstimated ? '(估算)' : ''),
),
const _RowDivider(),
_InfoRow(label: '芯片号', value: pet.microchipNo ?? '未填写'),
const _RowDivider(),
_InfoRow(label: '性格', value: pet.personality ?? '未填写'),
const _RowDivider(),
_InfoRow(
label: '绝育日期',
value: pet.sterilizedOn == null
? '未填写'
: dateToJson(pet.sterilizedOn!),
),
],
),
),
if (_canEdit) ...[
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: _openEdit,
icon: const Icon(Icons.edit_outlined, size: 17),
label: const Text('编辑资料'),
),
],
],
);
}
/// 记录导航区:健康时间线与照护提醒入口(T2-14)。待办提醒非空时
/// 上方渲染真实数据驱动的「健康提醒」卡(正典 alert-card 形态,
/// 取代 demo 硬编码文案),点卡与点入口同去提醒页。
Widget _recordsSection(Pet pet) {
final nearest = _pendingReminders.isEmpty ? null : _pendingReminders.first;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (nearest != null) ...[
_ReminderAlertCard(
reminder: nearest,
overdue: isReminderOverdue(nearest, DateTime.now()),
onTap: () => _openReminders(pet),
),
const SizedBox(height: 10),
],
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),
),
const Divider(height: 1, thickness: 1, color: AppColors.border),
ListTile(
leading: const Icon(
Icons.notifications_outlined,
color: AppColors.inkSoft,
),
title: const Text('照护提醒', style: TextStyle(fontSize: 14)),
subtitle: Text(
switch (_remindersPhase) {
_RemindersPhase.loading => '加载中…',
_RemindersPhase.error => '提醒加载失败,点击查看',
_RemindersPhase.ready when _pendingReminders.isEmpty =>
'暂无待办提醒',
_RemindersPhase.ready => '${_pendingReminders.length} 条待办',
},
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
),
),
trailing: const Icon(
Icons.chevron_right,
color: AppColors.muted,
),
onTap: () => _openReminders(pet),
),
],
),
),
],
);
}
/// 数据卡行(05 §4.2 stat-row):四卡取数全部来自 summary 实时聚合,
/// 不落任何本地展示字符串(第 4.3 节红线)。null 语义 → 空态文案。
Widget _summarySection(Pet pet) {
switch (_summaryPhase) {
case _SummaryPhase.loading:
return const SizedBox(
height: 86,
child: Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
case _SummaryPhase.error:
return Row(
children: [
const Expanded(
child: Text(
'健康数据加载失败',
style: TextStyle(color: AppColors.error, fontSize: 12),
),
),
TextButton(onPressed: _loadSummary, child: const Text('重试')),
],
);
case _SummaryPhase.ready:
final summary = _summary!;
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(
icon: recordTypeStyles[RecordType.weight]!.icon,
iconColor: recordTypeStyles[RecordType.weight]!.iconColor,
value: weight == null
? '暂无记录'
: '${formatWeightKg(weight.weightKg)} kg',
emphasized: weight != null,
label: '最新体重',
onTap: () => _openWeights(pet),
),
),
const SizedBox(width: 10),
Expanded(
child: _SummaryCard(
icon: recordTypeStyles[RecordType.vaccine]!.icon,
iconColor: recordTypeStyles[RecordType.vaccine]!.iconColor,
// 契约:totalDoses=0 → 整体 null(不是 0/0)→ 空态文案。
value: progress == null
? '未登记'
: '${vaccinationProgressLabel(progress)} 针',
emphasized: progress != null,
label: '疫苗进度',
onTap: () => _openVaccinations(pet),
),
),
const SizedBox(width: 10),
Expanded(
child: _SummaryCard(
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),
),
),
],
),
);
}
}
}
/// 数据卡(正典 stat-card 形态):图标 + 数值 15/w800 + 标签 12 inkSoft
/// 空态数值降级 inkSoft 常规字重(区分「有数据」与「空态」两种视觉)。
class _SummaryCard extends StatelessWidget {
const _SummaryCard({
required this.icon,
required this.iconColor,
required this.value,
required this.label,
required this.emphasized,
this.onTap,
});
final IconData icon;
final Color iconColor;
final String value;
final String label;
final bool emphasized;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.xl),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: iconColor),
const SizedBox(height: 8),
Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: emphasized ? AppColors.ink : AppColors.inkSoft,
fontSize: emphasized ? 15 : 13,
fontWeight: emphasized ? FontWeight.w800 : FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppColors.inkSoft, fontSize: 11),
),
],
),
),
),
);
}
}
/// 键值行(05 §4.3 P3 风格:字段名 12 inkSoft / 值 bodyMedium ink)。
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 72,
child: Text(
label,
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
),
Expanded(
child: Text(value, style: Theme.of(context).textTheme.bodyMedium),
),
],
),
);
}
}
class _RowDivider extends StatelessWidget {
const _RowDivider();
@override
Widget build(BuildContext context) {
return const Divider(height: 1, thickness: 1, color: AppColors.border);
}
}
/// 「健康提醒」卡(正典 alert-cardsuccessSurface 底 + dot + 文字):
/// 数据源为最近到期的待办提醒(真实数据驱动,取代 demo 硬编码文案);
/// 逾期时文案切警示深色(双通道:前缀文字 + 颜色)。
class _ReminderAlertCard extends StatelessWidget {
const _ReminderAlertCard({
required this.reminder,
required this.overdue,
this.onTap,
});
final CareReminder reminder;
final bool overdue;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final textColor = overdue ? AppColors.errorDark : AppColors.successInk;
return Material(
color: AppColors.successSurface,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: textColor,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'健康提醒:${reminder.title}'
'${overdue ? '已逾期' : '${dateToJson(reminder.dueAt.toLocal())} 到期'}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: textColor,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
Icon(Icons.chevron_right, size: 18, color: textColor),
],
),
),
),
);
}
}