- 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:
@@ -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<MainShellPage> {
|
||||
PetsPage(
|
||||
controller: widget.petsController,
|
||||
analytics: widget.petAnalytics,
|
||||
healthAnalytics: widget.healthRecordAnalytics,
|
||||
),
|
||||
ServicesPage(
|
||||
showPersonal: showPersonalServices,
|
||||
|
||||
@@ -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<PetDetailPage> createState() => _PetDetailPageState();
|
||||
@@ -44,12 +54,16 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
||||
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<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 {
|
||||
final pet = _pet;
|
||||
if (pet == null) return;
|
||||
@@ -118,6 +150,41 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
||||
bool get _canEdit =>
|
||||
_phase == _DetailPhase.ready && _pet?.myRole == PetRole.owner;
|
||||
|
||||
/// 记录写入权限档 WRITE(owner + 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
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -201,6 +268,10 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
||||
],
|
||||
),
|
||||
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<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)。
|
||||
|
||||
@@ -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<PetsPage> createState() => _PetsPageState();
|
||||
}
|
||||
@@ -65,6 +74,7 @@ class _PetsPageState extends State<PetsPage> {
|
||||
controller: widget.controller,
|
||||
petId: pet.id,
|
||||
analytics: widget.analytics,
|
||||
healthAnalytics: widget.healthAnalytics,
|
||||
),
|
||||
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/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<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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user