新增:疫苗登记/记录列表与档案页摘要卡接入(T2-13 疫苗半边)
CI / flutter-gates (push) Successful in 1m49s

- VaccinationFormPage:目录按宠物物种过滤(四态)、seriesKey 目录 code
  预填、scheduled/completed 状态-日期规则结构化 + 纯函数双重前端拦截,
  42201/40904 后端兜底横幅(均有测试)
- VaccinationRecordsPage:按系列分组、三态 TagPill(含 cancelled 留痕)、
  四态齐备;viewer 隐藏登记入口
- 档案页数据卡行接 GET /pets/{id}/summary 实时聚合:最新体重/疫苗进度/
  下一针三卡,null 语义为空态文案而非 0/0;从记录页返回即重拉摘要
- health_record 埋点装配(app→shell→pets→detail→记录页面族)
- 测试 205 → 224(+19)全绿;analyze 0 问题;format 无 diff

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 13:20:04 +08:00
parent 5b34fa336b
commit c91f18a845
9 changed files with 1570 additions and 3 deletions
+4
View File
@@ -13,6 +13,7 @@ import 'package:patbond_flutter/features/auth/login_page.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart'; import 'package:patbond_flutter/features/auth/session_manager.dart';
import 'package:patbond_flutter/features/auth/splash_page.dart'; import 'package:patbond_flutter/features/auth/splash_page.dart';
import 'package:patbond_flutter/features/main/main_shell_page.dart'; import 'package:patbond_flutter/features/main/main_shell_page.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.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/pets_controller.dart'; import 'package:patbond_flutter/features/pets/pets_controller.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart'; import 'package:patbond_flutter/features/pets/pets_repository.dart';
@@ -41,6 +42,7 @@ class _AppState extends State<App> {
late final AuthRepository authRepository; late final AuthRepository authRepository;
late final PetsController petsController; late final PetsController petsController;
late final PetAnalytics petAnalytics; late final PetAnalytics petAnalytics;
late final HealthRecordAnalytics healthRecordAnalytics;
late final SessionTracker _sessionTracker; late final SessionTracker _sessionTracker;
late final AnalyticsService _analytics; late final AnalyticsService _analytics;
late final PageViewTracker _pageViewTracker; late final PageViewTracker _pageViewTracker;
@@ -80,6 +82,7 @@ class _AppState extends State<App> {
repository: widget.petsRepository ?? _buildPetsRepository(), repository: widget.petsRepository ?? _buildPetsRepository(),
); );
petAnalytics = PetAnalytics(_analytics.trackEvent); petAnalytics = PetAnalytics(_analytics.trackEvent);
healthRecordAnalytics = HealthRecordAnalytics(_analytics.trackEvent);
// 认证状态切换补点(根路由 AnimatedSwitcher 无路由事件) // 认证状态切换补点(根路由 AnimatedSwitcher 无路由事件)
sessionManager.addListener(_reportAuthStateChange); sessionManager.addListener(_reportAuthStateChange);
@@ -185,6 +188,7 @@ class _AppState extends State<App> {
appState: appState, appState: appState,
petsController: petsController, petsController: petsController,
petAnalytics: petAnalytics, petAnalytics: petAnalytics,
healthRecordAnalytics: healthRecordAnalytics,
pageViewTracker: _pageViewTracker, pageViewTracker: _pageViewTracker,
onLogout: authRepository.logout, onLogout: authRepository.logout,
); );
+6
View File
@@ -4,6 +4,7 @@ import 'package:patbond_flutter/analytics/page_view_tracker.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/features/create/create_page.dart'; import 'package:patbond_flutter/features/create/create_page.dart';
import 'package:patbond_flutter/features/home/home_page.dart'; import 'package:patbond_flutter/features/home/home_page.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.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/pets_controller.dart'; import 'package:patbond_flutter/features/pets/pets_controller.dart';
import 'package:patbond_flutter/features/pets/pets_page.dart'; import 'package:patbond_flutter/features/pets/pets_page.dart';
@@ -20,6 +21,7 @@ class MainShellPage extends StatefulWidget {
required this.petsController, required this.petsController,
super.key, super.key,
this.petAnalytics, this.petAnalytics,
this.healthRecordAnalytics,
this.pageViewTracker, this.pageViewTracker,
this.onLogout, this.onLogout,
}); });
@@ -32,6 +34,9 @@ class MainShellPage extends StatefulWidget {
/// pet 域埋点强类型封装(建宠漏斗三事件)。 /// pet 域埋点强类型封装(建宠漏斗三事件)。
final PetAnalytics? petAnalytics; final PetAnalytics? petAnalytics;
/// health_record 域埋点强类型封装(T2-13 创建漏斗三事件 + viewed)。
final HealthRecordAnalytics? healthRecordAnalytics;
/// Tab 曝光补点(IndexedStack 切换不产生路由事件,03 号评估 §3.2)。 /// Tab 曝光补点(IndexedStack 切换不产生路由事件,03 号评估 §3.2)。
final PageViewTracker? pageViewTracker; final PageViewTracker? pageViewTracker;
@@ -118,6 +123,7 @@ class _MainShellPageState extends State<MainShellPage> {
PetsPage( PetsPage(
controller: widget.petsController, controller: widget.petsController,
analytics: widget.petAnalytics, analytics: widget.petAnalytics,
healthAnalytics: widget.healthRecordAnalytics,
), ),
ServicesPage( ServicesPage(
showPersonal: showPersonalServices, showPersonal: showPersonalServices,
+205 -2
View File
@@ -5,35 +5,45 @@ import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart'; import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
import 'package:patbond_flutter/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/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/health_record_display.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';
import 'package:patbond_flutter/features/pets/pet_form_page.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/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_controller.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'; import 'package:patbond_flutter/widgets/common.dart';
enum _DetailPhase { loading, ready, error, notFound } enum _DetailPhase { loading, ready, error, notFound }
enum _SummaryPhase { loading, ready, error }
/// 宠物详情页(T2-12 / 05 号规范 §4.2 P2 的档案信息部分)。 /// 宠物详情页(T2-12 / 05 号规范 §4.2 P2 的档案信息部分)。
/// ///
/// 打开即用控制器内存副本首屏渲染,同时经 [PetsController.getPet] /// 打开即用控制器内存副本首屏渲染,同时经 [PetsController.getPet]
/// 拉取最新详情(同步列表副本)。四态:loading / ready / error+重试 / /// 拉取最新详情(同步列表副本)。四态:loading / ready / error+重试 /
/// notFound40401 防枚举三态同响应 → 提示后返回列表并刷新)。 /// notFound40401 防枚举三态同响应 → 提示后返回列表并刷新)。
/// ///
/// 体重、疫苗进度、健康时间线与提醒区块随 T2-13/14 接入摘要与 /// T2-13:数据卡行接 `GET /pets/{id}/summary` 实时聚合(最新体重 /
/// 记录接口后补充,本单不渲染 demo 占位。 /// 疫苗进度 / 下一针,null 语义为空态文案而非 0/0),并作为体重历史、
/// 疫苗记录两页的导航入口;从记录页返回即重拉摘要。
class PetDetailPage extends StatefulWidget { class PetDetailPage extends StatefulWidget {
const PetDetailPage({ const PetDetailPage({
required this.controller, required this.controller,
required this.petId, required this.petId,
super.key, super.key,
this.analytics, this.analytics,
this.healthAnalytics,
}); });
final PetsController controller; final PetsController controller;
final String petId; final String petId;
final PetAnalytics? analytics; final PetAnalytics? analytics;
final HealthRecordAnalytics? healthAnalytics;
@override @override
State<PetDetailPage> createState() => _PetDetailPageState(); State<PetDetailPage> createState() => _PetDetailPageState();
@@ -44,12 +54,16 @@ class _PetDetailPageState extends State<PetDetailPage> {
Pet? _pet; Pet? _pet;
ApiException? _error; ApiException? _error;
_SummaryPhase _summaryPhase = _SummaryPhase.loading;
PetSummary? _summary;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_pet = _fromController(); _pet = _fromController();
if (_pet != null) _phase = _DetailPhase.ready; if (_pet != null) _phase = _DetailPhase.ready;
_load(); _load();
_loadSummary();
} }
Pet? _fromController() { Pet? _fromController() {
@@ -89,6 +103,24 @@ class _PetDetailPageState extends State<PetDetailPage> {
} }
} }
/// 摘要实时聚合(40401 由主链路 notFound 态承载,摘要只降级为 error 态)。
Future<void> _loadSummary() async {
setState(() => _summaryPhase = _SummaryPhase.loading);
try {
final summary = await widget.controller.repository.getPetSummary(
widget.petId,
);
if (!mounted) return;
setState(() {
_summary = summary;
_summaryPhase = _SummaryPhase.ready;
});
} on ApiException {
if (!mounted) return;
setState(() => _summaryPhase = _SummaryPhase.error);
}
}
Future<void> _openEdit() async { Future<void> _openEdit() async {
final pet = _pet; final pet = _pet;
if (pet == null) return; if (pet == null) return;
@@ -118,6 +150,41 @@ class _PetDetailPageState extends State<PetDetailPage> {
bool get _canEdit => bool get _canEdit =>
_phase == _DetailPhase.ready && _pet?.myRole == PetRole.owner; _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();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -201,6 +268,10 @@ class _PetDetailPageState extends State<PetDetailPage> {
], ],
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text('健康数据', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 10),
_summarySection(pet),
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),
SectionCard( SectionCard(
@@ -244,6 +315,138 @@ class _PetDetailPageState extends State<PetDetailPage> {
], ],
); );
} }
/// 数据卡行(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;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: _SummaryCard(
type: RecordType.weight,
value: weight == null
? '暂无记录'
: '${formatWeightKg(weight.weightKg)} kg',
emphasized: weight != null,
label: '最新体重',
onTap: () => _openWeights(pet),
),
),
const SizedBox(width: 10),
Expanded(
child: _SummaryCard(
type: RecordType.vaccine,
// 契约:totalDoses=0 → 整体 null(不是 0/0)→ 空态文案。
value: progress == null
? '未登记'
: '${vaccinationProgressLabel(progress)}',
emphasized: progress != null,
label: '疫苗进度',
onTap: () => _openVaccinations(pet),
),
),
const SizedBox(width: 10),
Expanded(
child: _SummaryCard(
type: RecordType.vaccine,
value: next == null ? '暂无安排' : dateToJson(next.dueOn),
emphasized: next != null,
label: next == null ? '下一针' : '下一针·${next.vaccineName}',
onTap: () => _openVaccinations(pet),
),
),
],
),
);
}
}
}
/// 数据卡(正典 stat-card 形态):类型图标 + 数值 15/w800 + 标签 12 inkSoft
/// 空态数值降级 inkSoft 常规字重(区分「有数据」与「空态」两种视觉)。
class _SummaryCard extends StatelessWidget {
const _SummaryCard({
required this.type,
required this.value,
required this.label,
required this.emphasized,
this.onTap,
});
final RecordType type;
final String value;
final String label;
final bool emphasized;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final style = recordTypeStyles[type]!;
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(style.icon, size: 18, color: style.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)。 /// 键值行(05 §4.3 P3 风格:字段名 12 inkSoft / 值 bodyMedium ink)。
+11 -1
View File
@@ -5,6 +5,7 @@ import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart'; import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
import 'package:patbond_flutter/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/features/pets/health_record_analytics.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_detail_page.dart'; import 'package:patbond_flutter/features/pets/pet_detail_page.dart';
import 'package:patbond_flutter/features/pets/pet_display.dart'; import 'package:patbond_flutter/features/pets/pet_display.dart';
@@ -19,11 +20,19 @@ import 'package:patbond_flutter/widgets/common.dart';
/// empty(空态插画 + 建档 CTA)、error(横幅 + 重试)、ready(列表)。 /// empty(空态插画 + 建档 CTA)、error(横幅 + 重试)、ready(列表)。
/// 页面不直连 ApiClient,不读写 AppState demo 数据。 /// 页面不直连 ApiClient,不读写 AppState demo 数据。
class PetsPage extends StatefulWidget { class PetsPage extends StatefulWidget {
const PetsPage({required this.controller, super.key, this.analytics}); const PetsPage({
required this.controller,
super.key,
this.analytics,
this.healthAnalytics,
});
final PetsController controller; final PetsController controller;
final PetAnalytics? analytics; final PetAnalytics? analytics;
/// health_record 域埋点(T2-13,记录页面族透传)。
final HealthRecordAnalytics? healthAnalytics;
@override @override
State<PetsPage> createState() => _PetsPageState(); State<PetsPage> createState() => _PetsPageState();
} }
@@ -65,6 +74,7 @@ class _PetsPageState extends State<PetsPage> {
controller: widget.controller, controller: widget.controller,
petId: pet.id, petId: pet.id,
analytics: widget.analytics, analytics: widget.analytics,
healthAnalytics: widget.healthAnalytics,
), ),
settings: RouteSettings(name: AnalyticsPageName.petDetail.pageName), settings: RouteSettings(name: AnalyticsPageName.petDetail.pageName),
), ),
@@ -0,0 +1,526 @@
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/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/health_record_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';
/// 疫苗登记表单页(T2-13)。
///
/// - 目录:vaccine-catalog 按宠物物种过滤(契约:疫苗 species 须与宠物
/// 一致),目录网络面自带 loading / 失败重试 / 空目录提示。
/// - 系列/剂次:seriesKey 选中疫苗后以目录 code 自动预填(可改);
/// doseNo 默认 1。
/// - 状态机:创建仅 scheduled / completed;状态-日期规则由
/// [vaccinationDateRuleError] 前端拦截(scheduled 态不渲染接种日期字段,
/// 结构上杜绝「scheduled 带 administeredOn」),后端 42201 兜底提示;
/// 同系列同剂次重复登记 40904 兜底提示。
/// - 埋点:health_record_create_started/succeeded/failedrecordType=vaccine)。
class VaccinationFormPage extends StatefulWidget {
const VaccinationFormPage({
required this.repository,
required this.petId,
required this.petSpecies,
super.key,
this.analytics,
this.entryPoint = HealthRecordEntryPoint.recordList,
});
final PetsRepository repository;
final String petId;
final PetSpecies petSpecies;
final HealthRecordAnalytics? analytics;
final HealthRecordEntryPoint entryPoint;
@override
State<VaccinationFormPage> createState() => _VaccinationFormPageState();
}
class _VaccinationFormPageState extends State<VaccinationFormPage> {
final _seriesCtrl = TextEditingController();
final _doseNoCtrl = TextEditingController(text: '1');
final _doseLabelCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
List<VaccineCatalogItem>? _catalog;
bool _catalogLoading = false;
bool _catalogFailed = false;
String? _vaccineId;
/// seriesKey 是否仍处于「目录 code 自动预填」状态(用户一改即失效)。
bool _seriesAutoFilled = true;
VaccinationStatus _status = VaccinationStatus.scheduled;
DateTime? _plannedOn;
DateTime? _administeredOn;
DateTime? _nextDueOn;
String? _vaccineError;
String? _seriesError;
String? _doseNoError;
String? _dateError;
String? _formError;
bool _submitting = false;
bool _startedFired = false;
int _attemptSeq = 0;
late final DateTime _openedAt;
@override
void initState() {
super.initState();
_openedAt = DateTime.now();
_loadCatalog();
}
@override
void dispose() {
_seriesCtrl.dispose();
_doseNoCtrl.dispose();
_doseLabelCtrl.dispose();
_notesCtrl.dispose();
super.dispose();
}
Future<void> _loadCatalog() async {
setState(() {
_catalogLoading = true;
_catalogFailed = false;
});
try {
final catalog = await widget.repository.listVaccineCatalog(
species: widget.petSpecies,
);
if (!mounted) return;
setState(() {
_catalog = catalog;
_catalogLoading = false;
});
} on ApiException {
if (!mounted) return;
setState(() {
_catalogLoading = false;
_catalogFailed = true;
});
}
}
void _markStarted() {
if (_startedFired) return;
_startedFired = true;
widget.analytics?.createStarted(
recordType: HealthRecordType.vaccine,
entryPoint: widget.entryPoint,
);
}
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
widget.analytics?.createFailed(
recordType: HealthRecordType.vaccine,
reason: reason,
attemptSeq: _attemptSeq,
errorCode: errorCode,
);
}
int? get _doseNo {
final value = int.tryParse(_doseNoCtrl.text.trim());
if (value == null || value < 1 || value > 32767) return null;
return value;
}
void _showFormError(String message) {
setState(() => _formError = message);
SemanticsService.sendAnnouncement(
View.of(context),
message,
TextDirection.ltr,
);
}
Future<void> _submit() async {
if (_submitting) return;
_attemptSeq++;
final vaccineError = _vaccineId == null ? '请选择疫苗' : null;
final seriesError = _seriesCtrl.text.trim().isEmpty ? '请输入系列名称' : null;
final doseNoError = _doseNo == null ? '剂次需为 1~32767 的整数' : null;
final dateError = vaccinationDateRuleError(
status: _status,
plannedOn: _plannedOn,
administeredOn: _administeredOn,
nextDueOn: _nextDueOn,
);
if (vaccineError != null ||
seriesError != null ||
doseNoError != null ||
dateError != null) {
setState(() {
_vaccineError = vaccineError;
_seriesError = seriesError;
_doseNoError = doseNoError;
_dateError = dateError;
});
_trackFailed(HealthRecordFailureReason.validationError);
return;
}
setState(() {
_submitting = true;
_formError = null;
});
try {
final scheduled = _status == VaccinationStatus.scheduled;
final doseLabel = _doseLabelCtrl.text.trim();
final notes = _notesCtrl.text.trim();
final record = await widget.repository.createVaccination(
widget.petId,
CreateVaccinationRequest(
vaccineId: _vaccineId!,
seriesKey: _seriesCtrl.text.trim(),
doseNo: _doseNo!,
status: _status,
// 状态-日期规则结构化落地:scheduled 只发 plannedOn
// completed 只发 administeredOn+可选 nextDueOn)。
plannedOn: scheduled ? _plannedOn : null,
administeredOn: scheduled ? null : _administeredOn,
nextDueOn: scheduled ? null : _nextDueOn,
doseLabel: doseLabel.isEmpty ? null : doseLabel,
notes: notes.isEmpty ? null : notes,
),
);
widget.analytics?.createSucceeded(
recordType: HealthRecordType.vaccine,
durationMs: DateTime.now().difference(_openedAt).inMilliseconds,
);
if (mounted) Navigator.of(context).pop(record);
} on VaccinationDoseExistsException {
if (!mounted) return;
_showFormError('该系列该剂次已有记录(40904);如登记有误,可取消原记录后重新登记');
_trackFailed(HealthRecordFailureReason.validationError, 40904);
} on VaccinationRuleException {
if (!mounted) return;
// 前端已拦截主路径,此处为后端状态-日期规则兜底(42201)。
_showFormError('接种状态与日期不符合规则,请核对后重试');
_trackFailed(HealthRecordFailureReason.validationError, 42201);
} 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: [
..._catalogSection(),
const SizedBox(height: 14),
AppTextField(
label: '系列名称',
controller: _seriesCtrl,
prefixIcon: Icons.tag_outlined,
errorText: _seriesError,
helperText: '区分初免/加强等系列,同系列同剂次唯一',
enabled: !_submitting,
textInputAction: TextInputAction.next,
onChanged: (_) {
_markStarted();
_seriesAutoFilled = false;
if (_seriesError != null || _formError != null) {
setState(() {
_seriesError = null;
_formError = null;
});
}
},
),
const SizedBox(height: 14),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: AppTextField(
label: '剂次',
controller: _doseNoCtrl,
prefixIcon: Icons.numbers_outlined,
errorText: _doseNoError,
enabled: !_submitting,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
onChanged: (_) {
_markStarted();
if (_doseNoError != null || _formError != null) {
setState(() {
_doseNoError = null;
_formError = null;
});
}
},
),
),
const SizedBox(width: 12),
Expanded(
child: AppTextField(
label: '剂次标签(可选)',
controller: _doseLabelCtrl,
enabled: !_submitting,
textInputAction: TextInputAction.next,
onChanged: (_) => _markStarted(),
),
),
],
),
const SizedBox(height: 18),
const Text(
'接种状态',
style: TextStyle(
color: AppColors.inkSoft,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
SegmentedButton<VaccinationStatus>(
segments: const [
ButtonSegment(
value: VaccinationStatus.scheduled,
icon: Icon(Icons.event_outlined),
label: Text('计划接种'),
),
ButtonSegment(
value: VaccinationStatus.completed,
icon: Icon(Icons.check_circle_outline),
label: Text('已完成'),
),
],
selected: {_status},
onSelectionChanged: _submitting
? null
: (value) {
_markStarted();
setState(() {
_status = value.first;
_dateError = null;
_formError = null;
});
},
),
const SizedBox(height: 14),
if (_status == VaccinationStatus.scheduled)
_dateTile(
label: '计划接种日期',
value: _plannedOn,
allowFuture: true,
onPicked: (value) => setState(() {
_plannedOn = value;
_dateError = null;
}),
)
else ...[
_dateTile(
label: '接种日期',
value: _administeredOn,
allowFuture: false,
onPicked: (value) => setState(() {
_administeredOn = value;
_dateError = null;
}),
),
const SizedBox(height: 12),
_dateTile(
label: '下次接种日期(可选)',
value: _nextDueOn,
allowFuture: true,
onPicked: (value) => setState(() {
_nextDueOn = value;
_dateError = null;
}),
),
],
if (_dateError != null) ...[
const SizedBox(height: 6),
Text(
_dateError!,
style: const TextStyle(color: AppColors.error, fontSize: 12),
),
],
const SizedBox(height: 14),
AppTextField(
label: '备注(可选)',
controller: _notesCtrl,
prefixIcon: Icons.sticky_note_2_outlined,
enabled: !_submitting,
textInputAction: TextInputAction.done,
onChanged: (_) => _markStarted(),
),
if (_formError != null) ...[
const SizedBox(height: 16),
InlineErrorBanner(message: _formError!),
],
const SizedBox(height: 24),
PrimaryButton(
label: '保存登记',
isLoading: _submitting,
onPressed: _submit,
),
],
),
),
);
}
List<Widget> _catalogSection() {
if (_catalogLoading) {
return const [
SizedBox(
height: 52,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
];
}
if (_catalogFailed) {
return [
Row(
children: [
const Expanded(
child: Text(
'疫苗目录加载失败,请重试后选择',
style: TextStyle(color: AppColors.error, fontSize: 12),
),
),
TextButton(onPressed: _loadCatalog, child: const Text('重试')),
],
),
];
}
final catalog = _catalog ?? const <VaccineCatalogItem>[];
if (catalog.isEmpty) {
return const [
Text(
'该物种暂无可选疫苗目录',
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
];
}
return [
DropdownButtonFormField<String>(
initialValue: _vaccineId,
decoration: InputDecoration(labelText: '疫苗', errorText: _vaccineError),
items: [
for (final item in catalog)
DropdownMenuItem(value: item.id, child: Text(item.name)),
],
onChanged: _submitting
? null
: (value) {
_markStarted();
setState(() {
_vaccineId = value;
_vaccineError = null;
_formError = null;
// seriesKey 以目录 code 预填(用户未改过才覆盖)。
if (_seriesAutoFilled && value != null) {
final item = catalog.firstWhere((c) => c.id == value);
_seriesCtrl.text = item.code;
}
});
},
),
];
}
Widget _dateTile({
required String label,
required DateTime? value,
required bool allowFuture,
required ValueChanged<DateTime> onPicked,
}) {
return 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: Text(label, style: const TextStyle(fontSize: 14)),
subtitle: Text(
value == null ? '未选择' : dateToJson(value),
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 picked = await showDatePicker(
context: context,
initialDate: value ?? now,
firstDate: DateTime(1990),
lastDate: allowFuture ? DateTime(now.year + 5) : now,
);
if (picked != null && mounted) {
_markStarted();
onPicked(picked);
}
},
);
}
}
@@ -0,0 +1,253 @@
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_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_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]。
///
/// 曝光埋点:每次进入首个成功加载上报一次
/// `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;
@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();
}
}
@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))
..add(const SizedBox(height: 10));
}
return RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 30),
children: children,
),
);
}
}
/// 疫苗条目:RecordTypeDot(疫苗) + 剂次标题 + 日期副行 + 状态 TagPill
/// (图标+文字双通道,不单靠颜色区分)。
class _VaccinationTile extends StatelessWidget {
const _VaccinationTile({required this.record});
final Vaccination record;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: 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),
),
],
),
),
);
}
}
@@ -9,6 +9,8 @@ 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';
import 'package:patbond_flutter/features/pets/pet_models.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/pets_controller.dart';
import 'package:patbond_flutter/features/pets/vaccination_records_page.dart';
import 'package:patbond_flutter/features/pets/weight_records_page.dart';
import '../../helpers/pet_test_helpers.dart'; import '../../helpers/pet_test_helpers.dart';
@@ -22,6 +24,10 @@ void main() {
}); });
Future<void> pumpDetail(WidgetTester tester, {String petId = 'p-1'}) async { Future<void> pumpDetail(WidgetTester tester, {String petId = 'p-1'}) async {
// 详情页自 T2-13 增加健康数据卡行,加高视口保证底部按钮在栏内。
tester.view.physicalSize = const Size(700, 1800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
theme: buildAppTheme(), theme: buildAppTheme(),
@@ -176,4 +182,114 @@ void main() {
expect(find.text('编辑宠物资料'), findsOneWidget); expect(find.text('编辑宠物资料'), findsOneWidget);
expect(find.text('豆豆'), findsOneWidget); expect(find.text('豆豆'), findsOneWidget);
}); });
group('T2-13 · 数据卡行接 summary', () {
testWidgets('三卡取数:最新体重 / 疫苗进度 / 下一针(含疫苗名)', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1');
String? capturedTz;
repository.getPetSummaryHandler = (petId, tz) async {
capturedTz = tz;
return buildSummary();
};
await pumpDetail(tester);
await tester.pumpAndSettle();
expect(find.text('健康数据'), findsOneWidget);
expect(find.text('4.35 kg'), findsOneWidget);
expect(find.text('最新体重'), findsOneWidget);
expect(find.text('2/3 针'), findsOneWidget);
expect(find.text('疫苗进度'), findsOneWidget);
expect(find.text('2026-08-01'), findsOneWidget);
expect(find.text('下一针·狂犬疫苗'), findsOneWidget);
// 本单不消费 monthlyExpenseT2-14),tz 不传走服务端缺省 UTC。
expect(capturedTz, isNull);
});
testWidgets('null 语义:无登记显示空态而非 0/0', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1');
repository.getPetSummaryHandler = (petId, tz) async => buildSummary(
overrides: {
'latestWeight': null,
'vaccinationProgress': null,
'nextVaccination': null,
},
);
await pumpDetail(tester);
await tester.pumpAndSettle();
expect(find.text('暂无记录'), findsOneWidget);
expect(find.text('未登记'), findsOneWidget);
expect(find.text('暂无安排'), findsOneWidget);
expect(find.textContaining('0/0'), findsNothing);
expect(find.text('下一针'), findsOneWidget);
});
testWidgets('摘要加载失败:档案主链路不受阻,行内重试恢复', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1');
var calls = 0;
repository.getPetSummaryHandler = (petId, tz) async {
calls++;
if (calls == 1) throw const ApiNetworkException('断网');
return buildSummary();
};
await pumpDetail(tester);
await tester.pumpAndSettle();
// 基本资料照常渲染,摘要行内错误 + 重试。
expect(find.text('豆豆'), findsOneWidget);
expect(find.text('健康数据加载失败'), findsOneWidget);
await tester.tap(find.text('重试'));
await tester.pumpAndSettle();
expect(find.text('4.35 kg'), findsOneWidget);
expect(find.text('健康数据加载失败'), findsNothing);
});
testWidgets('点体重卡 → 体重历史页;返回后重拉摘要', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1');
var summaryCalls = 0;
repository.getPetSummaryHandler = (petId, tz) async {
summaryCalls++;
return buildSummary();
};
repository.listWeightsHandler = (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(WeightRecordsPage), 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.listVaccinationsHandler = (petId) async => const [];
await pumpDetail(tester);
await tester.pumpAndSettle();
await tester.tap(find.text('疫苗进度'));
await tester.pumpAndSettle();
expect(find.byType(VaccinationRecordsPage), findsOneWidget);
// viewer:空态无 CTA、AppBar 无添加入口。
expect(find.text('登记第一针'), findsNothing);
expect(find.byIcon(Icons.add), findsNothing);
});
});
} }
@@ -0,0 +1,261 @@
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/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 'package:patbond_flutter/features/pets/vaccination_form_page.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, 2000);
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: (_) => VaccinationFormPage(
repository: repository,
petId: 'p-1',
petSpecies: PetSpecies.dog,
analytics: analytics,
),
),
),
);
await tester.pumpAndSettle();
}
Future<void> selectVaccine(WidgetTester tester, String name) async {
await tester.tap(find.byType(DropdownButtonFormField<String>));
await tester.pumpAndSettle();
await tester.tap(find.text(name).last);
await tester.pumpAndSettle();
}
Future<void> pickDate(WidgetTester tester, String tileLabel) async {
await tester.tap(find.text(tileLabel));
await tester.pumpAndSettle();
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
}
testWidgets('目录按宠物物种过滤;加载失败可重试恢复', (tester) async {
var calls = 0;
PetSpecies? capturedSpecies;
repository.listVaccineCatalogHandler = (species) async {
calls++;
capturedSpecies = species;
if (calls == 1) throw const ApiNetworkException('断网');
return [VaccineCatalogItem.fromJson(sampleVaccineCatalogJson())];
};
await pumpForm(tester);
expect(find.text('疫苗目录加载失败,请重试后选择'), findsOneWidget);
await tester.tap(find.text('重试'));
await tester.pumpAndSettle();
expect(find.byType(DropdownButtonFormField<String>), findsOneWidget);
expect(capturedSpecies, PetSpecies.dog);
});
testWidgets('空表单提交:疫苗与计划日期双拦截 + failed(validation_error)', (tester) async {
await pumpForm(tester);
await tester.tap(find.text('保存登记'));
await tester.pumpAndSettle();
expect(find.text('请选择疫苗'), findsOneWidget);
expect(find.text('请选择计划接种日期'), findsOneWidget);
final failed = eventsOf('health_record_create_failed');
expect(failed.single!['recordType'], 'vaccine');
expect(failed.single!['failureReason'], 'validation_error');
});
testWidgets('选疫苗自动预填系列(目录 code),首次输入触发 started 一次', (tester) async {
await pumpForm(tester);
expect(eventsOf('health_record_create_started'), isEmpty);
await selectVaccine(tester, '狂犬疫苗');
expect(
tester
.widget<TextFormField>(find.widgetWithText(TextFormField, '系列名称'))
.controller!
.text,
'rabies',
);
// 再动一个字段:started 不重复。
await tester.enterText(find.widgetWithText(TextFormField, '剂次'), '2');
await tester.pumpAndSettle();
final started = eventsOf('health_record_create_started');
expect(started, hasLength(1));
expect(started.single, {
'recordType': 'vaccine',
'entryPoint': 'record_list',
});
});
testWidgets('状态机拦截 · 未填接种日期就标完成:前端拦截不发请求', (tester) async {
var createCalls = 0;
repository.createVaccinationHandler = (petId, request) async {
createCalls++;
return buildVaccination('vx-x');
};
await pumpForm(tester);
await selectVaccine(tester, '狂犬疫苗');
await tester.tap(find.text('已完成'));
await tester.pumpAndSettle();
// completed 态不渲染计划日期字段(结构上杜绝 scheduled 带 administeredOn 的反向路径)。
expect(find.text('计划接种日期'), findsNothing);
expect(find.text('接种日期'), findsOneWidget);
await tester.tap(find.text('保存登记'));
await tester.pumpAndSettle();
expect(find.text('请选择接种日期'), findsOneWidget);
expect(createCalls, 0);
});
testWidgets('scheduled 登记成功:请求形状精确对齐契约 + succeeded', (tester) async {
CreateVaccinationRequest? captured;
repository.createVaccinationHandler = (petId, request) async {
expect(petId, 'p-1');
captured = request;
return buildVaccination('vx-new');
};
await pumpForm(tester);
await selectVaccine(tester, '狂犬疫苗');
await tester.enterText(
find.widgetWithText(TextFormField, '剂次标签(可选)'),
'第一针',
);
await pickDate(tester, '计划接种日期');
await tester.tap(find.text('保存登记'));
await tester.pumpAndSettle();
final json = captured!.toJson();
expect(json['vaccineId'], 'v-1');
expect(json['seriesKey'], 'rabies');
expect(json['doseNo'], 1);
expect(json['status'], 'scheduled');
expect(json['doseLabel'], '第一针');
expect(json.containsKey('plannedOn'), isTrue);
// scheduled 不得携带 administeredOn(契约 42201 规则,结构化保证)。
expect(json.containsKey('administeredOn'), isFalse);
expect(json.containsKey('nextDueOn'), isFalse);
expect(find.text('列表基底'), findsOneWidget);
final succeeded = eventsOf('health_record_create_succeeded');
expect(succeeded.single!['recordType'], 'vaccine');
expect(succeeded.single!['photoCount'], 0);
});
testWidgets('completed 登记成功:administeredOn 必填已给、无计划日期字段', (tester) async {
CreateVaccinationRequest? captured;
repository.createVaccinationHandler = (petId, request) async {
captured = request;
return buildVaccination('vx-new');
};
await pumpForm(tester);
await selectVaccine(tester, '狂犬疫苗');
await tester.tap(find.text('已完成'));
await tester.pumpAndSettle();
await pickDate(tester, '接种日期');
await tester.tap(find.text('保存登记'));
await tester.pumpAndSettle();
final json = captured!.toJson();
expect(json['status'], 'completed');
expect(json.containsKey('administeredOn'), isTrue);
expect(json.containsKey('plannedOn'), isFalse);
});
testWidgets('40904 同系列同剂次冲突:兜底横幅 + failed 带码', (tester) async {
repository.createVaccinationHandler = (petId, request) async =>
throw const VaccinationDoseExistsException(message: '已存在');
await pumpForm(tester);
await selectVaccine(tester, '狂犬疫苗');
await pickDate(tester, '计划接种日期');
await tester.tap(find.text('保存登记'));
await tester.pumpAndSettle();
expect(find.text('该系列该剂次已有记录(40904);如登记有误,可取消原记录后重新登记'), findsOneWidget);
expect(find.byType(VaccinationFormPage), findsOneWidget);
final failed = eventsOf('health_record_create_failed');
expect(failed.single!['errorCode'], 40904);
expect(failed.single!['httpStatus'], 409);
});
testWidgets('42201 状态-日期规则后端兜底:横幅提示 + failed 带码', (tester) async {
repository.createVaccinationHandler = (petId, request) async =>
throw const VaccinationRuleException(
message: 'scheduled 必须提供 plannedOn',
);
await pumpForm(tester);
await selectVaccine(tester, '狂犬疫苗');
await pickDate(tester, '计划接种日期');
await tester.tap(find.text('保存登记'));
await tester.pumpAndSettle();
expect(find.text('接种状态与日期不符合规则,请核对后重试'), findsOneWidget);
final failed = eventsOf('health_record_create_failed');
expect(failed.single!['errorCode'], 42201);
expect(failed.single!['httpStatus'], 422);
expect(failed.single!['failureReason'], 'validation_error');
});
testWidgets('剂次非法(0)拦截', (tester) async {
await pumpForm(tester);
await selectVaccine(tester, '狂犬疫苗');
await tester.enterText(find.widgetWithText(TextFormField, '剂次'), '0');
await pickDate(tester, '计划接种日期');
await tester.tap(find.text('保存登记'));
await tester.pumpAndSettle();
expect(find.text('剂次需为 1~32767 的整数'), findsOneWidget);
});
}
@@ -0,0 +1,188 @@
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_record_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/vaccination_form_page.dart';
import 'package:patbond_flutter/features/pets/vaccination_records_page.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}) 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: (_) => VaccinationRecordsPage(
repository: repository,
petId: 'p-1',
petSpecies: PetSpecies.dog,
canWrite: canWrite,
analytics: analytics,
),
),
),
);
await tester.pump();
}
testWidgets('四态 · ready:按系列分组,剂次/日期/状态标签齐备;viewed 一次', (tester) async {
repository.listVaccinationsHandler = (petId) async => [
buildVaccination(
'vx-1',
overrides: {
'status': 'completed',
'plannedOn': null,
'administeredOn': '2026-06-12',
'nextDueOn': '2027-06-12',
'doseLabel': '首免第一针',
},
),
buildVaccination('vx-2', overrides: {'doseNo': 2}),
buildVaccination(
'vx-3',
overrides: {
'seriesKey': 'dhppi-initial',
'vaccineName': '四联疫苗',
'vaccineId': 'v-2',
'status': 'cancelled',
'plannedOn': null,
},
),
];
await pumpPage(tester);
await tester.pumpAndSettle();
// 系列组头(疫苗名 · 系列键)。
expect(find.text('狂犬疫苗 · rabies-initial'), findsOneWidget);
expect(find.text('四联疫苗 · dhppi-initial'), findsOneWidget);
// 条目:doseLabel 优先 / 回落第 N 针;状态三色 TagPill。
expect(find.text('首免第一针'), findsOneWidget);
expect(find.text('第 2 针'), findsOneWidget);
expect(find.text('接种 2026-06-12 · 下次 2027-06-12'), findsOneWidget);
expect(find.text('计划 2026-10-01'), findsOneWidget);
expect(find.text('已完成'), findsOneWidget);
expect(find.text('计划中'), findsOneWidget);
expect(find.widgetWithText(TagPill, '已取消'), findsOneWidget);
final viewed = eventsOf('health_record_viewed');
expect(viewed.single, {'recordType': 'vaccine', 'source': 'pet_detail'});
});
testWidgets('四态 · loading / empty:空态插画 + 登记 CTA', (tester) async {
final completer = Completer<List<Vaccination>>();
repository.listVaccinationsHandler = (petId) => completer.future;
await pumpPage(tester);
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
completer.complete(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.listVaccinationsHandler = (petId) async {
calls++;
if (calls == 1) throw const ApiNetworkException('断网');
return [buildVaccination('vx-1')];
};
await pumpPage(tester);
await tester.pumpAndSettle();
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
await tester.tap(find.text('重试'));
await tester.pumpAndSettle();
expect(find.text('第 1 针'), findsOneWidget);
});
testWidgets('登记闭环:CTA → 表单(record_form 路由名)→ 成功后重拉列表', (tester) async {
var listCalls = 0;
repository.listVaccinationsHandler = (petId) async {
listCalls++;
return listCalls == 1 ? const [] : [buildVaccination('vx-new')];
};
repository.createVaccinationHandler = (petId, request) async =>
buildVaccination('vx-new');
await pumpPage(tester);
await tester.pumpAndSettle();
await tester.tap(find.text('登记第一针'));
await tester.pumpAndSettle();
expect(find.byType(VaccinationFormPage), findsOneWidget);
final route = ModalRoute.of(
tester.element(find.byType(VaccinationFormPage)),
)!;
expect(route.settings.name, 'record_form');
// 选疫苗(目录默认桩:狂犬疫苗)→ 选计划日期 → 提交。
await tester.tap(find.byType(DropdownButtonFormField<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('狂犬疫苗').last);
await tester.pumpAndSettle();
await tester.tap(find.text('计划接种日期'));
await tester.pumpAndSettle();
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
await tester.tap(find.text('保存登记'));
await tester.pumpAndSettle();
expect(find.byType(VaccinationFormPage), findsNothing);
expect(find.text('已登记疫苗'), findsOneWidget);
expect(listCalls, 2);
expect(find.text('第 1 针'), findsOneWidget);
});
testWidgets('viewercanWrite=false):无登记入口、空态无 CTA', (tester) async {
repository.listVaccinationsHandler = (petId) async => const [];
await pumpPage(tester, canWrite: false);
await tester.pumpAndSettle();
expect(find.byIcon(Icons.add), findsNothing);
expect(find.text('登记第一针'), findsNothing);
});
}