Files
patbond-flutter/lib/features/pets/vaccination_records_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

595 lines
20 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/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/app_text_field.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_record_analytics.dart';
import 'package:patbond_flutter/features/pets/health_record_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_models.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
import 'package:patbond_flutter/features/pets/vaccination_form_page.dart';
import 'package:patbond_flutter/widgets/common.dart';
enum _ListPhase { loading, ready, error }
/// 疫苗记录列表页(T2-13):契约不分页,服务端按
/// `series_key, dose_no, created_at, id` 排序,客户端按系列直接分组;
/// 含 cancelled 行原样展示(取消后同剂次可重新登记的事实留痕)。
/// 四态齐备;登记经 [VaccinationFormPage]。
///
/// T2-14 收尾(25 号报告 §7 遗留①②):scheduled 行支持「标记完成 /
/// 取消登记」PATCH 流转;完成时可补录厂商/批号(契约可选字段);
/// 挂 `health_record_edit_succeeded/failed`recordType=vaccine)。
///
/// 曝光埋点:每次进入首个成功加载上报一次
/// `health_record_viewed(recordType=vaccine, source=pet_detail)`。
class VaccinationRecordsPage extends StatefulWidget {
const VaccinationRecordsPage({
required this.repository,
required this.petId,
required this.petSpecies,
required this.canWrite,
super.key,
this.analytics,
});
final PetsRepository repository;
final String petId;
/// 疫苗目录按宠物物种过滤(契约:疫苗 species 须与宠物一致)。
final PetSpecies petSpecies;
/// owner/caregiver 可写;viewer 隐藏登记入口。
final bool canWrite;
final HealthRecordAnalytics? analytics;
@override
State<VaccinationRecordsPage> createState() => _VaccinationRecordsPageState();
}
class _VaccinationRecordsPageState extends State<VaccinationRecordsPage> {
_ListPhase _phase = _ListPhase.loading;
List<Vaccination> _records = const [];
ApiException? _error;
bool _viewedFired = false;
bool _mutating = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_phase = _ListPhase.loading;
_error = null;
});
try {
final records = await widget.repository.listVaccinations(widget.petId);
if (!mounted) return;
setState(() {
_records = records;
_phase = _ListPhase.ready;
});
if (!_viewedFired) {
_viewedFired = true;
widget.analytics?.viewed(
recordType: HealthRecordType.vaccine,
source: HealthRecordViewSource.petDetail,
);
}
} on ApiException catch (error) {
if (!mounted) return;
setState(() {
_error = error;
_phase = _ListPhase.error;
});
}
}
Future<void> _openCreate() async {
final created = await Navigator.of(context).push<Vaccination>(
fadePageRoute(
VaccinationFormPage(
repository: widget.repository,
petId: widget.petId,
petSpecies: widget.petSpecies,
analytics: widget.analytics,
),
settings: RouteSettings(name: AnalyticsPageName.recordForm.pageName),
),
);
if (created != null && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已登记疫苗')));
// 排序键在服务端(series_key, dose_no),重新拉取而非本地猜位置。
await _load();
}
}
void _trackEditFailed(HealthRecordFailureReason reason, [int? errorCode]) {
widget.analytics?.editFailed(
recordType: HealthRecordType.vaccine,
reason: reason,
errorCode: errorCode,
);
}
/// 标记完成(25 号报告遗留①②):接种日期必填、下次接种/厂商/批号可选
/// (厂商/批号为契约可选字段的补录入口)。
Future<void> _markCompleted(Vaccination record) async {
final result = await showDialog<_CompleteVaccinationResult>(
context: context,
builder: (context) => _CompleteVaccinationDialog(record: record),
);
if (result == null || !mounted) return;
await _mutate(
record,
UpdateVaccinationRequest(
version: record.version,
status: VaccinationStatus.completed,
administeredOn: result.administeredOn,
nextDueOn: result.nextDueOn,
manufacturer: result.manufacturer,
batchNo: result.batchNo,
),
successText: '已标记完成',
);
}
Future<void> _cancelRegistration(Vaccination record) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('取消这条登记?'),
content: Text(
'「${vaccinationDoseLabel(record)}」将标记为已取消;'
'取消后同系列同剂次可重新登记。',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('返回'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('取消登记'),
),
],
),
);
if (confirmed != true || !mounted) return;
await _mutate(
record,
UpdateVaccinationRequest(
version: record.version,
status: VaccinationStatus.cancelled,
),
successText: '已取消登记',
);
}
Future<void> _mutate(
Vaccination record,
UpdateVaccinationRequest request, {
required String successText,
}) async {
if (_mutating) return;
setState(() => _mutating = true);
final messenger = ScaffoldMessenger.of(context);
try {
await widget.repository.updateVaccination(record.id, request);
widget.analytics?.editSucceeded(
recordType: HealthRecordType.vaccine,
fieldCount: request.toJson().length - 1,
);
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(successText)));
await _load();
} on PetVersionConflictException {
if (!mounted) return;
// 40902:并发修改抢先——刷新取新 version 后由用户重试动作。
messenger.showSnackBar(
const SnackBar(content: Text('记录已在其他设备被修改,已刷新,请重试')),
);
_trackEditFailed(HealthRecordFailureReason.conflict, 40902);
await _load();
} on VaccinationRuleException {
if (!mounted) return;
// 42201:状态机/状态-日期规则兜底(前端已按规则拦截主路径)。
messenger.showSnackBar(
const SnackBar(content: Text('接种状态与日期不符合规则,请核对后重试')),
);
_trackEditFailed(HealthRecordFailureReason.validationError, 42201);
} on PetRecordNotFoundException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('记录不存在或已被删除,已刷新')));
_trackEditFailed(HealthRecordFailureReason.notFound, 40402);
await _load();
} on PetAccessDeniedException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('你没有权限操作该记录')));
_trackEditFailed(HealthRecordFailureReason.permissionDenied, 40300);
} on ApiBusinessException catch (error) {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('操作失败,请稍后重试')));
_trackEditFailed(HealthRecordFailureReason.serverError, error.code);
} on ApiNetworkException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('网络异常,请检查网络后重试')));
_trackEditFailed(HealthRecordFailureReason.networkError);
} on SessionExpiredException {
// 会话失效:认证状态机自动回登录页。
} finally {
if (mounted) setState(() => _mutating = 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,
),
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: _load, child: const Text('重试')),
],
),
),
),
_ListPhase.ready when _records.isEmpty => Center(
child: SingleChildScrollView(
child: EmptyStateIllustration(
icon: Icons.vaccines_outlined,
title: '还没有疫苗记录',
description: '登记接种计划与完成情况,不错过每一针',
ctaLabel: widget.canWrite ? '登记第一针' : null,
onCtaPressed: widget.canWrite ? _openCreate : null,
),
),
),
_ListPhase.ready => _list(),
},
);
}
/// 按系列分组渲染:服务端排序保证同系列相邻,系列变化处插组头
/// (疫苗名 · 系列键)。
Widget _list() {
final children = <Widget>[];
String? currentSeries;
for (final record in _records) {
final seriesId = '${record.vaccineId}/${record.seriesKey}';
if (seriesId != currentSeries) {
currentSeries = seriesId;
children.add(
Padding(
padding: EdgeInsets.only(top: children.isEmpty ? 0 : 14, bottom: 8),
child: Text(
'${record.vaccineName} · ${record.seriesKey}',
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
);
}
children
..add(
_VaccinationTile(
record: record,
// 流转动作仅 scheduled 行可用(终态由服务端状态机守卫)。
onComplete:
widget.canWrite &&
record.status == VaccinationStatus.scheduled &&
!_mutating
? () => _markCompleted(record)
: null,
onCancel:
widget.canWrite &&
record.status == VaccinationStatus.scheduled &&
!_mutating
? () => _cancelRegistration(record)
: null,
),
)
..add(const SizedBox(height: 10));
}
return RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 30),
children: children,
),
);
}
}
/// 疫苗条目:RecordTypeDot(疫苗) + 剂次标题 + 日期副行 + 状态 TagPill
/// (图标+文字双通道,不单靠颜色区分);scheduled 行附
/// 「标记完成 / 取消登记」流转动作。
class _VaccinationTile extends StatelessWidget {
const _VaccinationTile({
required this.record,
this.onComplete,
this.onCancel,
});
final Vaccination record;
final VoidCallback? onComplete;
final VoidCallback? onCancel;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const RecordTypeDot(
type: RecordType.vaccine,
size: RecordTypeDotSize.md,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
vaccinationDoseLabel(record),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
vaccinationDateLine(record),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
),
),
],
),
),
const SizedBox(width: 8),
TagPill(
vaccinationStatusLabel(record.status),
color: vaccinationStatusColor(record.status),
),
],
),
if (onComplete != null || onCancel != null) ...[
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.close, size: 16),
style: TextButton.styleFrom(
foregroundColor: AppColors.inkSoft,
),
label: const Text('取消登记'),
),
const SizedBox(width: 4),
TextButton.icon(
onPressed: onComplete,
icon: const Icon(Icons.check_circle_outline, size: 16),
label: const Text('标记完成'),
),
],
),
],
],
),
),
);
}
}
/// 标记完成对话框返回值。
class _CompleteVaccinationResult {
const _CompleteVaccinationResult({
required this.administeredOn,
this.nextDueOn,
this.manufacturer,
this.batchNo,
});
final DateTime administeredOn;
final DateTime? nextDueOn;
final String? manufacturer;
final String? batchNo;
}
/// 标记完成对话框:接种日期必填(默认今天)、下次接种可选;
/// 厂商/批号补录(契约可选字段,25 号报告遗留②的落地入口)。
/// 日期规则复用 [vaccinationDateRuleError]42201 前置拦截)。
class _CompleteVaccinationDialog extends StatefulWidget {
const _CompleteVaccinationDialog({required this.record});
final Vaccination record;
@override
State<_CompleteVaccinationDialog> createState() =>
_CompleteVaccinationDialogState();
}
class _CompleteVaccinationDialogState
extends State<_CompleteVaccinationDialog> {
final _manufacturerCtrl = TextEditingController();
final _batchNoCtrl = TextEditingController();
DateTime _administeredOn = DateTime.now();
DateTime? _nextDueOn;
String? _dateError;
@override
void initState() {
super.initState();
_manufacturerCtrl.text = widget.record.manufacturer ?? '';
_batchNoCtrl.text = widget.record.batchNo ?? '';
}
@override
void dispose() {
_manufacturerCtrl.dispose();
_batchNoCtrl.dispose();
super.dispose();
}
void _confirm() {
final error = vaccinationDateRuleError(
status: VaccinationStatus.completed,
administeredOn: _administeredOn,
nextDueOn: _nextDueOn,
);
if (error != null) {
setState(() => _dateError = error);
return;
}
final manufacturer = _manufacturerCtrl.text.trim();
final batchNo = _batchNoCtrl.text.trim();
Navigator.of(context).pop(
_CompleteVaccinationResult(
administeredOn: _administeredOn,
nextDueOn: _nextDueOn,
manufacturer: manufacturer.isEmpty ? null : manufacturer,
batchNo: batchNo.isEmpty ? null : batchNo,
),
);
}
Widget _dateTile({
required String label,
required DateTime? value,
required bool allowFuture,
required ValueChanged<DateTime> onPicked,
}) {
return ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
title: Text(label, style: const TextStyle(fontSize: 14)),
subtitle: Text(
value == null ? '未选择' : dateToJson(value),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
onTap: () async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: value ?? now,
firstDate: DateTime(1990),
lastDate: allowFuture ? DateTime(now.year + 5) : now,
);
if (picked != null && mounted) {
setState(() {
onPicked(picked);
_dateError = null;
});
}
},
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('标记完成'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'「${vaccinationDoseLabel(widget.record)}」',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
_dateTile(
label: '接种日期',
value: _administeredOn,
allowFuture: false,
onPicked: (value) => _administeredOn = value,
),
_dateTile(
label: '下次接种日期(可选)',
value: _nextDueOn,
allowFuture: true,
onPicked: (value) => _nextDueOn = value,
),
if (_dateError != null) ...[
const SizedBox(height: 4),
Text(
_dateError!,
style: const TextStyle(color: AppColors.error, fontSize: 12),
),
],
const SizedBox(height: 12),
AppTextField(
label: '厂商(可选)',
controller: _manufacturerCtrl,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
AppTextField(
label: '批号(可选)',
controller: _batchNoCtrl,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _confirm(),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
FilledButton(onPressed: _confirm, child: const Text('确认完成')),
],
);
}
}