diff --git a/lib/app/app.dart b/lib/app/app.dart index 90a34fd..e822e9d 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -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/splash_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/pets_controller.dart'; import 'package:patbond_flutter/features/pets/pets_repository.dart'; @@ -41,6 +42,7 @@ class _AppState extends State { late final AuthRepository authRepository; late final PetsController petsController; late final PetAnalytics petAnalytics; + late final HealthRecordAnalytics healthRecordAnalytics; late final SessionTracker _sessionTracker; late final AnalyticsService _analytics; late final PageViewTracker _pageViewTracker; @@ -80,6 +82,7 @@ class _AppState extends State { repository: widget.petsRepository ?? _buildPetsRepository(), ); petAnalytics = PetAnalytics(_analytics.trackEvent); + healthRecordAnalytics = HealthRecordAnalytics(_analytics.trackEvent); // 认证状态切换补点(根路由 AnimatedSwitcher 无路由事件) sessionManager.addListener(_reportAuthStateChange); @@ -185,6 +188,7 @@ class _AppState extends State { appState: appState, petsController: petsController, petAnalytics: petAnalytics, + healthRecordAnalytics: healthRecordAnalytics, pageViewTracker: _pageViewTracker, onLogout: authRepository.logout, ); diff --git a/lib/features/main/main_shell_page.dart b/lib/features/main/main_shell_page.dart index 93349a4..0485b08 100644 --- a/lib/features/main/main_shell_page.dart +++ b/lib/features/main/main_shell_page.dart @@ -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/features/create/create_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/pets_controller.dart'; import 'package:patbond_flutter/features/pets/pets_page.dart'; @@ -20,6 +21,7 @@ class MainShellPage extends StatefulWidget { required this.petsController, super.key, this.petAnalytics, + this.healthRecordAnalytics, this.pageViewTracker, this.onLogout, }); @@ -32,6 +34,9 @@ class MainShellPage extends StatefulWidget { /// pet 域埋点强类型封装(建宠漏斗三事件)。 final PetAnalytics? petAnalytics; + /// health_record 域埋点强类型封装(T2-13 创建漏斗三事件 + viewed)。 + final HealthRecordAnalytics? healthRecordAnalytics; + /// Tab 曝光补点(IndexedStack 切换不产生路由事件,03 号评估 §3.2)。 final PageViewTracker? pageViewTracker; @@ -118,6 +123,7 @@ class _MainShellPageState extends State { PetsPage( controller: widget.petsController, analytics: widget.petAnalytics, + healthAnalytics: widget.healthRecordAnalytics, ), ServicesPage( showPersonal: showPersonalServices, diff --git a/lib/features/pets/pet_detail_page.dart b/lib/features/pets/pet_detail_page.dart index 0420a4e..9285421 100644 --- a/lib/features/pets/pet_detail_page.dart +++ b/lib/features/pets/pet_detail_page.dart @@ -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/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_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_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 } + /// 宠物详情页(T2-12 / 05 号规范 §4.2 P2 的档案信息部分)。 /// /// 打开即用控制器内存副本首屏渲染,同时经 [PetsController.getPet] /// 拉取最新详情(同步列表副本)。四态:loading / ready / error+重试 / /// notFound(40401 防枚举三态同响应 → 提示后返回列表并刷新)。 /// -/// 体重、疫苗进度、健康时间线与提醒区块随 T2-13/14 接入摘要与 -/// 记录接口后补充,本单不渲染 demo 占位。 +/// 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 createState() => _PetDetailPageState(); @@ -44,12 +54,16 @@ class _PetDetailPageState extends State { Pet? _pet; ApiException? _error; + _SummaryPhase _summaryPhase = _SummaryPhase.loading; + PetSummary? _summary; + @override void initState() { super.initState(); _pet = _fromController(); if (_pet != null) _phase = _DetailPhase.ready; _load(); + _loadSummary(); } Pet? _fromController() { @@ -89,6 +103,24 @@ class _PetDetailPageState extends State { } } + /// 摘要实时聚合(40401 由主链路 notFound 态承载,摘要只降级为 error 态)。 + Future _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 _openEdit() async { final pet = _pet; if (pet == null) return; @@ -118,6 +150,41 @@ class _PetDetailPageState extends State { bool get _canEdit => _phase == _DetailPhase.ready && _pet?.myRole == PetRole.owner; + /// 记录写入权限档 WRITE(owner + caregiver);viewer 隐藏录入入口。 + bool get _canWriteRecords => _pet?.myRole != PetRole.viewer; + + Future _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 _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 Widget build(BuildContext context) { return Scaffold( @@ -201,6 +268,10 @@ class _PetDetailPageState extends State { ], ), 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), const SizedBox(height: 10), SectionCard( @@ -244,6 +315,138 @@ class _PetDetailPageState extends State { ], ); } + + /// 数据卡行(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)。 diff --git a/lib/features/pets/pets_page.dart b/lib/features/pets/pets_page.dart index 9d0921f..0cd3d49 100644 --- a/lib/features/pets/pets_page.dart +++ b/lib/features/pets/pets_page.dart @@ -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/inline_error_banner.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_detail_page.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(列表)。 /// 页面不直连 ApiClient,不读写 AppState demo 数据。 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 PetAnalytics? analytics; + /// health_record 域埋点(T2-13,记录页面族透传)。 + final HealthRecordAnalytics? healthAnalytics; + @override State createState() => _PetsPageState(); } @@ -65,6 +74,7 @@ class _PetsPageState extends State { controller: widget.controller, petId: pet.id, analytics: widget.analytics, + healthAnalytics: widget.healthAnalytics, ), settings: RouteSettings(name: AnalyticsPageName.petDetail.pageName), ), diff --git a/lib/features/pets/vaccination_form_page.dart b/lib/features/pets/vaccination_form_page.dart new file mode 100644 index 0000000..4e3ae07 --- /dev/null +++ b/lib/features/pets/vaccination_form_page.dart @@ -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/failed(recordType=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 createState() => _VaccinationFormPageState(); +} + +class _VaccinationFormPageState extends State { + final _seriesCtrl = TextEditingController(); + final _doseNoCtrl = TextEditingController(text: '1'); + final _doseLabelCtrl = TextEditingController(); + final _notesCtrl = TextEditingController(); + + List? _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 _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 _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( + 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 _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 []; + if (catalog.isEmpty) { + return const [ + Text( + '该物种暂无可选疫苗目录', + style: TextStyle(color: AppColors.inkSoft, fontSize: 12), + ), + ]; + } + return [ + DropdownButtonFormField( + 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 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); + } + }, + ); + } +} diff --git a/lib/features/pets/vaccination_records_page.dart b/lib/features/pets/vaccination_records_page.dart new file mode 100644 index 0000000..5408ba1 --- /dev/null +++ b/lib/features/pets/vaccination_records_page.dart @@ -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 createState() => _VaccinationRecordsPageState(); +} + +class _VaccinationRecordsPageState extends State { + _ListPhase _phase = _ListPhase.loading; + List _records = const []; + ApiException? _error; + bool _viewedFired = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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 _openCreate() async { + final created = await Navigator.of(context).push( + 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 = []; + 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), + ), + ], + ), + ), + ); + } +} diff --git a/test/features/pets/pet_detail_page_test.dart b/test/features/pets/pet_detail_page_test.dart index 95318dc..5365fe1 100644 --- a/test/features/pets/pet_detail_page_test.dart +++ b/test/features/pets/pet_detail_page_test.dart @@ -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_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 '../../helpers/pet_test_helpers.dart'; @@ -22,6 +24,10 @@ void main() { }); Future 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( MaterialApp( theme: buildAppTheme(), @@ -176,4 +182,114 @@ void main() { 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); + // 本单不消费 monthlyExpense(T2-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); + }); + }); } diff --git a/test/features/pets/vaccination_form_page_test.dart b/test/features/pets/vaccination_form_page_test.dart new file mode 100644 index 0000000..c6cec22 --- /dev/null +++ b/test/features/pets/vaccination_form_page_test.dart @@ -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?)> events; + late HealthRecordAnalytics analytics; + + setUp(() { + repository = FakePetsRepository(); + events = []; + analytics = HealthRecordAnalytics( + (name, [props]) async => events.add((name, props)), + ); + }); + + List?> eventsOf(String name) => [ + for (final e in events) + if (e.$1 == name) e.$2, + ]; + + Future 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(find.byType(Navigator)); + unawaited( + navigator.push( + MaterialPageRoute( + builder: (_) => VaccinationFormPage( + repository: repository, + petId: 'p-1', + petSpecies: PetSpecies.dog, + analytics: analytics, + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Future selectVaccine(WidgetTester tester, String name) async { + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text(name).last); + await tester.pumpAndSettle(); + } + + Future 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), 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(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); + }); +} diff --git a/test/features/pets/vaccination_records_page_test.dart b/test/features/pets/vaccination_records_page_test.dart new file mode 100644 index 0000000..a489045 --- /dev/null +++ b/test/features/pets/vaccination_records_page_test.dart @@ -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?)> events; + late HealthRecordAnalytics analytics; + + setUp(() { + repository = FakePetsRepository(); + events = []; + analytics = HealthRecordAnalytics( + (name, [props]) async => events.add((name, props)), + ); + }); + + List?> eventsOf(String name) => [ + for (final e in events) + if (e.$1 == name) e.$2, + ]; + + Future 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(find.byType(Navigator)); + unawaited( + navigator.push( + MaterialPageRoute( + 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>(); + 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)); + 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('viewer(canWrite=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); + }); +}