Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c91f18a845 | |||
| 5b34fa336b |
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||||
|
|
||||||
|
/// health_record 域埋点强类型封装(06 号规划 §1.4 字典 v2;后端白名单
|
||||||
|
/// 已扩充就绪,24 号报告 §2.2)。沿用 13 号规范 §3.1 惯例:枚举编译期
|
||||||
|
/// 锁死,业务代码禁止手拼事件名与属性。
|
||||||
|
///
|
||||||
|
/// T2-13 挂接创建漏斗三事件 + viewed;edit/deleted 事件的挂接随
|
||||||
|
/// 编辑/删除交互落地(「标记完成」等)另行接线,见 25 号报告遗留。
|
||||||
|
|
||||||
|
/// 记录类型(06 §1.4 recordType 枚举,四类记录接口对应)。
|
||||||
|
enum HealthRecordType {
|
||||||
|
weight('weight'),
|
||||||
|
vaccine('vaccine'),
|
||||||
|
healthEvent('health_event'),
|
||||||
|
reminder('reminder');
|
||||||
|
|
||||||
|
const HealthRecordType(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录创建入口(06 §1.4 entryPoint 枚举)。
|
||||||
|
enum HealthRecordEntryPoint {
|
||||||
|
petDetail('pet_detail'),
|
||||||
|
recordList('record_list'),
|
||||||
|
reminder('reminder');
|
||||||
|
|
||||||
|
const HealthRecordEntryPoint(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建失败原因(06 §1.4 基底 + M2 验收新增三值)。与 pet 域同款
|
||||||
|
/// 网络归并口径:断网/超时/5xx 均并入 network_error,server_error
|
||||||
|
/// 保留给无法归类的兜底。
|
||||||
|
enum HealthRecordFailureReason {
|
||||||
|
validationError('validation_error'),
|
||||||
|
permissionDenied('permission_denied'),
|
||||||
|
notFound('not_found'),
|
||||||
|
rateLimited('rate_limited'),
|
||||||
|
networkError('network_error'),
|
||||||
|
serverError('server_error');
|
||||||
|
|
||||||
|
const HealthRecordFailureReason(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// viewed 的来源(06 §1.4 source 枚举)。
|
||||||
|
enum HealthRecordViewSource {
|
||||||
|
recordList('record_list'),
|
||||||
|
petDetail('pet_detail'),
|
||||||
|
reminder('reminder');
|
||||||
|
|
||||||
|
const HealthRecordViewSource(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
|
class HealthRecordAnalytics {
|
||||||
|
HealthRecordAnalytics(this._track);
|
||||||
|
|
||||||
|
/// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。
|
||||||
|
final TrackEventFn _track;
|
||||||
|
|
||||||
|
/// 进入某类记录的创建表单并产生首次输入(每次进入记一次,表单层去重)。
|
||||||
|
void createStarted({
|
||||||
|
required HealthRecordType recordType,
|
||||||
|
required HealthRecordEntryPoint entryPoint,
|
||||||
|
}) {
|
||||||
|
_track('health_record_create_started', {
|
||||||
|
'recordType': recordType.value,
|
||||||
|
'entryPoint': entryPoint.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建接口成功响应(漏斗事件,北极星与 H1/H3/H4 的核心数据源)。
|
||||||
|
///
|
||||||
|
/// [photoCount] 无照片为 0(M2 不做媒体上传,恒 0,字段随字典保留)。
|
||||||
|
void createSucceeded({
|
||||||
|
required HealthRecordType recordType,
|
||||||
|
required int durationMs,
|
||||||
|
int photoCount = 0,
|
||||||
|
}) {
|
||||||
|
_track('health_record_create_succeeded', {
|
||||||
|
'recordType': recordType.value,
|
||||||
|
'durationMs': durationMs,
|
||||||
|
'photoCount': photoCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建失败:失败响应 / 超时 / 本地校验拦截。
|
||||||
|
///
|
||||||
|
/// [errorCode] 为业务错误码(本地校验/网络错误时缺席);
|
||||||
|
/// [httpStatus] 由五位业务码推导(`code ~/ 100`);
|
||||||
|
/// [attemptSeq] 为本次表单会话内第几次提交尝试(从 1 起)。
|
||||||
|
void createFailed({
|
||||||
|
required HealthRecordType recordType,
|
||||||
|
required HealthRecordFailureReason reason,
|
||||||
|
required int attemptSeq,
|
||||||
|
int? errorCode,
|
||||||
|
}) {
|
||||||
|
_track('health_record_create_failed', {
|
||||||
|
'recordType': recordType.value,
|
||||||
|
'failureReason': reason.value,
|
||||||
|
'attemptSeq': attemptSeq,
|
||||||
|
'errorCode': ?errorCode,
|
||||||
|
if (errorCode != null && errorCode >= 10000)
|
||||||
|
'httpStatus': errorCode ~/ 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录曝光。06 §1.4 定义在详情页可见;M2 体重/疫苗无独立详情页,
|
||||||
|
/// 以「列表页每次进入首个成功加载」为曝光时点(每次进入一次、
|
||||||
|
/// 不随滚动逐条上报,防事件洪水的意图不变)。
|
||||||
|
void viewed({
|
||||||
|
required HealthRecordType recordType,
|
||||||
|
required HealthRecordViewSource source,
|
||||||
|
}) {
|
||||||
|
_track('health_record_viewed', {
|
||||||
|
'recordType': recordType.value,
|
||||||
|
'source': source.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/// 体重 / 疫苗展示与输入解析的纯函数集合(列表 / 表单共用,可单测)。
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
|
||||||
|
/// 体重输入解析:契约区间 (0, 500]、最多两位小数(numeric(6,2))。
|
||||||
|
/// 非法(非数字、越界、三位小数)返回 null,由表单层给 errorText。
|
||||||
|
double? parseWeightKgInput(String raw) {
|
||||||
|
final text = raw.trim();
|
||||||
|
if (!RegExp(r'^\d{1,3}(\.\d{1,2})?$').hasMatch(text)) return null;
|
||||||
|
final value = double.parse(text);
|
||||||
|
if (value <= 0 || value > 500) return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 体重展示:去掉无意义尾零(4.35 → 4.35、5.00 → 5、4.50 → 4.5)。
|
||||||
|
String formatWeightKg(double weightKg) {
|
||||||
|
var text = weightKg.toStringAsFixed(2);
|
||||||
|
if (text.contains('.')) {
|
||||||
|
text = text.replaceFirst(RegExp(r'0+$'), '');
|
||||||
|
text = text.replaceFirst(RegExp(r'\.$'), '');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
String vaccinationStatusLabel(VaccinationStatus status) => switch (status) {
|
||||||
|
VaccinationStatus.scheduled => '计划中',
|
||||||
|
VaccinationStatus.completed => '已完成',
|
||||||
|
VaccinationStatus.cancelled => '已取消',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 状态标签基色(TagPill 淡染底;文字深变体由 TagPill 内置映射)。
|
||||||
|
Color vaccinationStatusColor(VaccinationStatus status) => switch (status) {
|
||||||
|
VaccinationStatus.scheduled => AppColors.accent,
|
||||||
|
VaccinationStatus.completed => AppColors.success,
|
||||||
|
VaccinationStatus.cancelled => AppColors.muted,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 剂次展示:doseLabel 优先,缺席回落「第 N 针」。
|
||||||
|
String vaccinationDoseLabel(Vaccination vaccination) =>
|
||||||
|
vaccination.doseLabel ?? '第 ${vaccination.doseNo} 针';
|
||||||
|
|
||||||
|
/// 疫苗条目副行:按状态给日期语义(计划 / 接种 + 下次到期 / 取消)。
|
||||||
|
String vaccinationDateLine(
|
||||||
|
Vaccination vaccination,
|
||||||
|
) => switch (vaccination.status) {
|
||||||
|
VaccinationStatus.scheduled =>
|
||||||
|
'计划 ${vaccination.plannedOn == null ? '未定' : dateToJson(vaccination.plannedOn!)}',
|
||||||
|
VaccinationStatus.completed =>
|
||||||
|
'接种 ${vaccination.administeredOn == null ? '—' : dateToJson(vaccination.administeredOn!)}'
|
||||||
|
'${vaccination.nextDueOn == null ? '' : ' · 下次 ${dateToJson(vaccination.nextDueOn!)}'}',
|
||||||
|
VaccinationStatus.cancelled => '已取消',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 摘要疫苗进度展示:契约 null 语义——无登记为 null(不是 0/0),
|
||||||
|
/// 调用方对 null 自行给空态文案。
|
||||||
|
String vaccinationProgressLabel(SummaryVaccinationProgress progress) =>
|
||||||
|
'${progress.completedDoses}/${progress.totalDoses}';
|
||||||
|
|
||||||
|
/// 疫苗状态-日期规则前端校验(契约 42201 规则的前置拦截;纯函数可单测):
|
||||||
|
/// scheduled 必有 plannedOn;completed 必有 administeredOn;
|
||||||
|
/// nextDueOn 与 administeredOn 同时存在时须 nextDueOn ≥ administeredOn。
|
||||||
|
/// 通过返回 null,违反返回给用户的拦截文案。
|
||||||
|
String? vaccinationDateRuleError({
|
||||||
|
required VaccinationStatus status,
|
||||||
|
DateTime? plannedOn,
|
||||||
|
DateTime? administeredOn,
|
||||||
|
DateTime? nextDueOn,
|
||||||
|
}) {
|
||||||
|
if (status == VaccinationStatus.scheduled && plannedOn == null) {
|
||||||
|
return '请选择计划接种日期';
|
||||||
|
}
|
||||||
|
if (status == VaccinationStatus.completed && administeredOn == null) {
|
||||||
|
return '请选择接种日期';
|
||||||
|
}
|
||||||
|
if (administeredOn != null &&
|
||||||
|
nextDueOn != null &&
|
||||||
|
nextDueOn.isBefore(
|
||||||
|
DateTime(administeredOn.year, administeredOn.month, administeredOn.day),
|
||||||
|
)) {
|
||||||
|
return '下次接种日期不能早于接种日期';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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+重试 /
|
||||||
/// notFound(40401 防枚举三态同响应 → 提示后返回列表并刷新)。
|
/// notFound(40401 防枚举三态同响应 → 提示后返回列表并刷新)。
|
||||||
///
|
///
|
||||||
/// 体重、疫苗进度、健康时间线与提醒区块随 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;
|
||||||
|
|
||||||
|
/// 记录写入权限档 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
|
@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)。
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ class PetsController extends ChangeNotifier {
|
|||||||
|
|
||||||
final PetsRepository _repository;
|
final PetsRepository _repository;
|
||||||
|
|
||||||
|
/// 记录级页面(体重/疫苗/事件/提醒)直接经仓库取数(22 号报告 §7
|
||||||
|
/// 交接约定:页面级状态按页自建,不膨胀本控制器)。
|
||||||
|
PetsRepository get repository => _repository;
|
||||||
|
|
||||||
PetsLoadPhase _phase = PetsLoadPhase.initial;
|
PetsLoadPhase _phase = PetsLoadPhase.initial;
|
||||||
List<Pet> _pets = const [];
|
List<Pet> _pets = const [];
|
||||||
ApiException? _lastError;
|
ApiException? _lastError;
|
||||||
|
|||||||
@@ -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/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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
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)。
|
||||||
|
///
|
||||||
|
/// - `weightKg` 前端校验对齐契约:(0, 500]、最多两位小数;后端 40000 兜底。
|
||||||
|
/// - 称重时间默认「现在」,可回选日期(非今日取当日 12:00,保持
|
||||||
|
/// measured_at DESC 排序直觉);提交前转 UTC,ISO 8601 带 Z 上送。
|
||||||
|
/// - 错误分层沿用登录纵切:字段 errorText / InlineErrorBanner / SnackBar。
|
||||||
|
/// - 埋点:首次输入 health_record_create_started(recordType=weight),
|
||||||
|
/// 成功/失败按 06 §1.4 经 [HealthRecordAnalytics] 上报。
|
||||||
|
class WeightFormPage extends StatefulWidget {
|
||||||
|
const WeightFormPage({
|
||||||
|
required this.repository,
|
||||||
|
required this.petId,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
this.entryPoint = HealthRecordEntryPoint.recordList,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PetsRepository repository;
|
||||||
|
final String petId;
|
||||||
|
final HealthRecordAnalytics? analytics;
|
||||||
|
final HealthRecordEntryPoint entryPoint;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WeightFormPage> createState() => _WeightFormPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WeightFormPageState extends State<WeightFormPage> {
|
||||||
|
final _weightCtrl = TextEditingController();
|
||||||
|
final _noteCtrl = TextEditingController();
|
||||||
|
|
||||||
|
DateTime _measuredDate = DateTime.now();
|
||||||
|
String? _weightError;
|
||||||
|
String? _formError;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
bool _startedFired = false;
|
||||||
|
int _attemptSeq = 0;
|
||||||
|
late final DateTime _openedAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_openedAt = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_weightCtrl.dispose();
|
||||||
|
_noteCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markStarted() {
|
||||||
|
if (_startedFired) return;
|
||||||
|
_startedFired = true;
|
||||||
|
widget.analytics?.createStarted(
|
||||||
|
recordType: HealthRecordType.weight,
|
||||||
|
entryPoint: widget.entryPoint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
|
||||||
|
widget.analytics?.createFailed(
|
||||||
|
recordType: HealthRecordType.weight,
|
||||||
|
reason: reason,
|
||||||
|
attemptSeq: _attemptSeq,
|
||||||
|
errorCode: errorCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateWeight() {
|
||||||
|
final text = _weightCtrl.text.trim();
|
||||||
|
if (text.isEmpty) return '请输入体重';
|
||||||
|
if (parseWeightKgInput(text) == null) {
|
||||||
|
return '体重需大于 0 且不超过 500 公斤,最多两位小数';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFormError(String message) {
|
||||||
|
setState(() => _formError = message);
|
||||||
|
SemanticsService.sendAnnouncement(
|
||||||
|
View.of(context),
|
||||||
|
message,
|
||||||
|
TextDirection.ltr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 称重时刻:今日取此刻,历史日期取当日 12:00(本地),提交前转 UTC。
|
||||||
|
DateTime _measuredAt() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final sameDay =
|
||||||
|
_measuredDate.year == now.year &&
|
||||||
|
_measuredDate.month == now.month &&
|
||||||
|
_measuredDate.day == now.day;
|
||||||
|
final local = sameDay
|
||||||
|
? now
|
||||||
|
: DateTime(
|
||||||
|
_measuredDate.year,
|
||||||
|
_measuredDate.month,
|
||||||
|
_measuredDate.day,
|
||||||
|
12,
|
||||||
|
);
|
||||||
|
return local.toUtc();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
_attemptSeq++;
|
||||||
|
final weightError = _validateWeight();
|
||||||
|
if (weightError != null) {
|
||||||
|
setState(() => _weightError = weightError);
|
||||||
|
_trackFailed(HealthRecordFailureReason.validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_submitting = true;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final note = _noteCtrl.text.trim();
|
||||||
|
final record = await widget.repository.createWeight(
|
||||||
|
widget.petId,
|
||||||
|
CreateWeightRequest(
|
||||||
|
weightKg: parseWeightKgInput(_weightCtrl.text)!,
|
||||||
|
measuredAt: _measuredAt(),
|
||||||
|
note: note.isEmpty ? null : note,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
widget.analytics?.createSucceeded(
|
||||||
|
recordType: HealthRecordType.weight,
|
||||||
|
durationMs: DateTime.now().difference(_openedAt).inMilliseconds,
|
||||||
|
);
|
||||||
|
if (mounted) Navigator.of(context).pop(record);
|
||||||
|
} 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: [
|
||||||
|
AppTextField(
|
||||||
|
label: '体重(公斤)',
|
||||||
|
controller: _weightCtrl,
|
||||||
|
prefixIcon: Icons.monitor_weight_outlined,
|
||||||
|
errorText: _weightError,
|
||||||
|
helperText: '大于 0 且不超过 500,最多两位小数',
|
||||||
|
enabled: !_submitting,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
|
decimal: true,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
_markStarted();
|
||||||
|
if (_weightError != null || _formError != null) {
|
||||||
|
setState(() {
|
||||||
|
_weightError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
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: const Text('称重日期', style: TextStyle(fontSize: 14)),
|
||||||
|
subtitle: Text(
|
||||||
|
dateToJson(_measuredDate),
|
||||||
|
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 value = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _measuredDate,
|
||||||
|
firstDate: DateTime(1990),
|
||||||
|
lastDate: now,
|
||||||
|
);
|
||||||
|
if (value != null && mounted) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() => _measuredDate = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
AppTextField(
|
||||||
|
label: '备注(可选,如:饭后称重)',
|
||||||
|
controller: _noteCtrl,
|
||||||
|
prefixIcon: Icons.sticky_note_2_outlined,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onChanged: (_) => _markStarted(),
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
if (_formError != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
InlineErrorBanner(message: _formError!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
PrimaryButton(
|
||||||
|
label: '保存记录',
|
||||||
|
isLoading: _submitting,
|
||||||
|
onPressed: _submit,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
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/weight_form_page.dart';
|
||||||
|
|
||||||
|
enum _ListPhase { loading, ready, error }
|
||||||
|
|
||||||
|
/// 体重历史列表页(T2-13):cursor 分页(「加载更多」追加,末页收起),
|
||||||
|
/// 四态齐备;新记录经 [WeightFormPage] 录入后就地插入列表头。
|
||||||
|
///
|
||||||
|
/// 曝光埋点:每次进入首个成功加载上报一次
|
||||||
|
/// `health_record_viewed(recordType=weight, source=pet_detail)`
|
||||||
|
/// (工单口径:列表曝光;不随滚动逐条上报)。
|
||||||
|
class WeightRecordsPage extends StatefulWidget {
|
||||||
|
const WeightRecordsPage({
|
||||||
|
required this.repository,
|
||||||
|
required this.petId,
|
||||||
|
required this.canWrite,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
this.pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PetsRepository repository;
|
||||||
|
final String petId;
|
||||||
|
|
||||||
|
/// owner/caregiver 可写;viewer 隐藏录入入口(40300 语义前置)。
|
||||||
|
final bool canWrite;
|
||||||
|
|
||||||
|
final HealthRecordAnalytics? analytics;
|
||||||
|
|
||||||
|
/// 每页条数(测试注入小页验证分页;缺省走服务端默认 20)。
|
||||||
|
final int? pageSize;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WeightRecordsPage> createState() => _WeightRecordsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WeightRecordsPageState extends State<WeightRecordsPage> {
|
||||||
|
_ListPhase _phase = _ListPhase.loading;
|
||||||
|
List<WeightRecord> _records = const [];
|
||||||
|
String? _nextCursor;
|
||||||
|
bool _hasMore = false;
|
||||||
|
bool _loadingMore = false;
|
||||||
|
ApiException? _error;
|
||||||
|
bool _viewedFired = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadFirstPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadFirstPage() async {
|
||||||
|
setState(() {
|
||||||
|
_phase = _ListPhase.loading;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final page = await widget.repository.listWeights(
|
||||||
|
widget.petId,
|
||||||
|
limit: widget.pageSize,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_records = page.items;
|
||||||
|
_nextCursor = page.nextCursor;
|
||||||
|
_hasMore = page.hasMore;
|
||||||
|
_phase = _ListPhase.ready;
|
||||||
|
});
|
||||||
|
if (!_viewedFired) {
|
||||||
|
_viewedFired = true;
|
||||||
|
widget.analytics?.viewed(
|
||||||
|
recordType: HealthRecordType.weight,
|
||||||
|
source: HealthRecordViewSource.petDetail,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = error;
|
||||||
|
_phase = _ListPhase.error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMore() async {
|
||||||
|
if (_loadingMore || !_hasMore) return;
|
||||||
|
setState(() => _loadingMore = true);
|
||||||
|
try {
|
||||||
|
final page = await widget.repository.listWeights(
|
||||||
|
widget.petId,
|
||||||
|
limit: widget.pageSize,
|
||||||
|
cursor: _nextCursor,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_records = [..._records, ...page.items];
|
||||||
|
_nextCursor = page.nextCursor;
|
||||||
|
_hasMore = page.hasMore;
|
||||||
|
});
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(petLoadErrorMessage(error))));
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loadingMore = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openCreate() async {
|
||||||
|
final created = await Navigator.of(context).push<WeightRecord>(
|
||||||
|
fadePageRoute(
|
||||||
|
WeightFormPage(
|
||||||
|
repository: widget.repository,
|
||||||
|
petId: widget.petId,
|
||||||
|
analytics: widget.analytics,
|
||||||
|
),
|
||||||
|
// record_form:健康记录漏斗到达段页名(06 §1.6 / §2.2)。
|
||||||
|
settings: RouteSettings(name: AnalyticsPageName.recordForm.pageName),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (created != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
// 契约排序 measured_at DESC, id DESC:新记录通常最新,就地插头;
|
||||||
|
// 补录历史日期的位置以下次刷新为准(服务端是唯一事实来源)。
|
||||||
|
_records = [created, ..._records];
|
||||||
|
if (_phase != _ListPhase.ready) _phase = _ListPhase.ready;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('已记录体重')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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: _loadFirstPage,
|
||||||
|
child: const Text('重试'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ListPhase.ready when _records.isEmpty => Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: EmptyStateIllustration(
|
||||||
|
icon: Icons.monitor_weight_outlined,
|
||||||
|
title: '还没有体重记录',
|
||||||
|
description: '定期称重,看见毛孩子的成长曲线',
|
||||||
|
ctaLabel: widget.canWrite ? '记录第一条' : null,
|
||||||
|
onCtaPressed: widget.canWrite ? _openCreate : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ListPhase.ready => _list(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _list() {
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _loadFirstPage,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 30),
|
||||||
|
children: [
|
||||||
|
for (final record in _records) ...[
|
||||||
|
_WeightTile(record: record),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
if (_hasMore)
|
||||||
|
Center(
|
||||||
|
child: _loadingMore
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: TextButton(onPressed: _loadMore, child: const Text('加载更多')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 体重条目:RecordTypeDot(体重) + 大数值(primaryDark w800)+
|
||||||
|
/// 日期/备注副行(05 §3.3 时间线条目形态的数值型变体)。
|
||||||
|
class _WeightTile extends StatelessWidget {
|
||||||
|
const _WeightTile({required this.record});
|
||||||
|
|
||||||
|
final WeightRecord record;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final meta = StringBuffer(formatMeasuredAt(record.measuredAt));
|
||||||
|
if (record.note != null && record.note!.isNotEmpty) {
|
||||||
|
meta.write(' · ${record.note}');
|
||||||
|
}
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const RecordTypeDot(
|
||||||
|
type: RecordType.weight,
|
||||||
|
size: RecordTypeDotSize.md,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${formatWeightKg(record.weightKg)} kg',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
meta.toString(),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 称重时刻展示:本地时区 `YYYY-MM-DD HH:mm`。
|
||||||
|
String formatMeasuredAt(DateTime measuredAt) {
|
||||||
|
final local = measuredAt.toLocal();
|
||||||
|
final h = local.hour.toString().padLeft(2, '0');
|
||||||
|
final min = local.minute.toString().padLeft(2, '0');
|
||||||
|
return '${dateToJson(local)} $h:$min';
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late List<(String, Map<String, dynamic>?)> events;
|
||||||
|
late HealthRecordAnalytics analytics;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
events = [];
|
||||||
|
analytics = HealthRecordAnalytics(
|
||||||
|
(name, [props]) async => events.add((name, props)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createStarted:recordType + entryPoint 两属性', () {
|
||||||
|
analytics.createStarted(
|
||||||
|
recordType: HealthRecordType.weight,
|
||||||
|
entryPoint: HealthRecordEntryPoint.recordList,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events.single.$1, 'health_record_create_started');
|
||||||
|
expect(events.single.$2, {
|
||||||
|
'recordType': 'weight',
|
||||||
|
'entryPoint': 'record_list',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createSucceeded:durationMs + photoCount(M2 无媒体恒 0)', () {
|
||||||
|
analytics.createSucceeded(
|
||||||
|
recordType: HealthRecordType.vaccine,
|
||||||
|
durationMs: 1234,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events.single.$1, 'health_record_create_succeeded');
|
||||||
|
expect(events.single.$2, {
|
||||||
|
'recordType': 'vaccine',
|
||||||
|
'durationMs': 1234,
|
||||||
|
'photoCount': 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createFailed:五位业务码推导 httpStatus(40904 → 409)', () {
|
||||||
|
analytics.createFailed(
|
||||||
|
recordType: HealthRecordType.vaccine,
|
||||||
|
reason: HealthRecordFailureReason.validationError,
|
||||||
|
attemptSeq: 2,
|
||||||
|
errorCode: 40904,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events.single.$2, {
|
||||||
|
'recordType': 'vaccine',
|
||||||
|
'failureReason': 'validation_error',
|
||||||
|
'attemptSeq': 2,
|
||||||
|
'errorCode': 40904,
|
||||||
|
'httpStatus': 409,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createFailed:本地校验/网络失败无 errorCode 时可空属性整体缺席', () {
|
||||||
|
analytics.createFailed(
|
||||||
|
recordType: HealthRecordType.weight,
|
||||||
|
reason: HealthRecordFailureReason.networkError,
|
||||||
|
attemptSeq: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events.single.$2, {
|
||||||
|
'recordType': 'weight',
|
||||||
|
'failureReason': 'network_error',
|
||||||
|
'attemptSeq': 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viewed:recordType + source(health_event/reminder 取值随字典)', () {
|
||||||
|
analytics.viewed(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
source: HealthRecordViewSource.petDetail,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events.single.$1, 'health_record_viewed');
|
||||||
|
expect(events.single.$2, {
|
||||||
|
'recordType': 'health_event',
|
||||||
|
'source': 'pet_detail',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
|
||||||
|
import '../../helpers/pet_test_helpers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('parseWeightKgInput(契约 (0,500] 两位小数)', () {
|
||||||
|
test('合法值:整数、一位、两位小数、上边界 500', () {
|
||||||
|
expect(parseWeightKgInput('5'), 5);
|
||||||
|
expect(parseWeightKgInput(' 4.5 '), 4.5);
|
||||||
|
expect(parseWeightKgInput('4.35'), 4.35);
|
||||||
|
expect(parseWeightKgInput('500'), 500);
|
||||||
|
expect(parseWeightKgInput('0.01'), 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('非法值:0、越界、三位小数、非数字、负数、科学计数', () {
|
||||||
|
expect(parseWeightKgInput('0'), isNull);
|
||||||
|
expect(parseWeightKgInput('0.00'), isNull);
|
||||||
|
expect(parseWeightKgInput('500.01'), isNull);
|
||||||
|
expect(parseWeightKgInput('4.356'), isNull);
|
||||||
|
expect(parseWeightKgInput('abc'), isNull);
|
||||||
|
expect(parseWeightKgInput('-3'), isNull);
|
||||||
|
expect(parseWeightKgInput('1e2'), isNull);
|
||||||
|
expect(parseWeightKgInput(''), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatWeightKg:去尾零(4.35 / 4.5 / 5 / 400)', () {
|
||||||
|
expect(formatWeightKg(4.35), '4.35');
|
||||||
|
expect(formatWeightKg(4.50), '4.5');
|
||||||
|
expect(formatWeightKg(5.00), '5');
|
||||||
|
expect(formatWeightKg(400), '400');
|
||||||
|
});
|
||||||
|
|
||||||
|
group('疫苗展示映射', () {
|
||||||
|
test('状态标签与基色(TagPill 三态)', () {
|
||||||
|
expect(vaccinationStatusLabel(VaccinationStatus.scheduled), '计划中');
|
||||||
|
expect(vaccinationStatusLabel(VaccinationStatus.completed), '已完成');
|
||||||
|
expect(vaccinationStatusLabel(VaccinationStatus.cancelled), '已取消');
|
||||||
|
expect(
|
||||||
|
vaccinationStatusColor(VaccinationStatus.scheduled),
|
||||||
|
AppColors.accent,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
vaccinationStatusColor(VaccinationStatus.completed),
|
||||||
|
AppColors.success,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
vaccinationStatusColor(VaccinationStatus.cancelled),
|
||||||
|
AppColors.muted,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('剂次标签:doseLabel 优先,缺席回落「第 N 针」', () {
|
||||||
|
expect(vaccinationDoseLabel(buildVaccination('vx-1')), '第 1 针');
|
||||||
|
expect(
|
||||||
|
vaccinationDoseLabel(
|
||||||
|
buildVaccination('vx-2', overrides: {'doseLabel': '首免'}),
|
||||||
|
),
|
||||||
|
'首免',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('日期副行:计划 / 接种+下次 / 已取消 三态', () {
|
||||||
|
expect(vaccinationDateLine(buildVaccination('vx-1')), '计划 2026-10-01');
|
||||||
|
expect(
|
||||||
|
vaccinationDateLine(
|
||||||
|
buildVaccination(
|
||||||
|
'vx-2',
|
||||||
|
overrides: {
|
||||||
|
'status': 'completed',
|
||||||
|
'plannedOn': null,
|
||||||
|
'administeredOn': '2026-09-01',
|
||||||
|
'nextDueOn': '2027-09-01',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'接种 2026-09-01 · 下次 2027-09-01',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
vaccinationDateLine(
|
||||||
|
buildVaccination(
|
||||||
|
'vx-3',
|
||||||
|
overrides: {'status': 'cancelled', 'plannedOn': null},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'已取消',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('vaccinationDateRuleError(42201 规则前端拦截)', () {
|
||||||
|
test('scheduled 缺 plannedOn 拦截;补齐通过', () {
|
||||||
|
expect(
|
||||||
|
vaccinationDateRuleError(status: VaccinationStatus.scheduled),
|
||||||
|
'请选择计划接种日期',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
vaccinationDateRuleError(
|
||||||
|
status: VaccinationStatus.scheduled,
|
||||||
|
plannedOn: DateTime(2026, 10),
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completed 缺 administeredOn 拦截(未填接种日期就标完成)', () {
|
||||||
|
expect(
|
||||||
|
vaccinationDateRuleError(status: VaccinationStatus.completed),
|
||||||
|
'请选择接种日期',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
vaccinationDateRuleError(
|
||||||
|
status: VaccinationStatus.completed,
|
||||||
|
administeredOn: DateTime(2026, 9, 1),
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextDueOn 早于 administeredOn 拦截;同日与晚于通过', () {
|
||||||
|
expect(
|
||||||
|
vaccinationDateRuleError(
|
||||||
|
status: VaccinationStatus.completed,
|
||||||
|
administeredOn: DateTime(2026, 9, 8),
|
||||||
|
nextDueOn: DateTime(2026, 9, 7),
|
||||||
|
),
|
||||||
|
'下次接种日期不能早于接种日期',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
vaccinationDateRuleError(
|
||||||
|
status: VaccinationStatus.completed,
|
||||||
|
administeredOn: DateTime(2026, 9, 8),
|
||||||
|
nextDueOn: DateTime(2026, 9, 8),
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
vaccinationDateRuleError(
|
||||||
|
status: VaccinationStatus.completed,
|
||||||
|
administeredOn: DateTime(2026, 9, 8),
|
||||||
|
nextDueOn: DateTime(2027, 9, 8),
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
// 本单不消费 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
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/weight_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, 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: (_) => WeightFormPage(
|
||||||
|
repository: repository,
|
||||||
|
petId: 'p-1',
|
||||||
|
analytics: analytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Finder weightField() => find.widgetWithText(TextFormField, '体重(公斤)');
|
||||||
|
|
||||||
|
testWidgets('空值提交拦截 + failed(validation_error)', (tester) async {
|
||||||
|
await pumpForm(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.text('保存记录'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('请输入体重'), findsOneWidget);
|
||||||
|
final failed = eventsOf('health_record_create_failed');
|
||||||
|
expect(failed.single!['recordType'], 'weight');
|
||||||
|
expect(failed.single!['failureReason'], 'validation_error');
|
||||||
|
expect(failed.single!['attemptSeq'], 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('契约区间前端拦截:>500 与三位小数均不放行、不发请求', (tester) async {
|
||||||
|
var createCalls = 0;
|
||||||
|
repository.createWeightHandler = (petId, request) async {
|
||||||
|
createCalls++;
|
||||||
|
return buildWeight('w-x');
|
||||||
|
};
|
||||||
|
await pumpForm(tester);
|
||||||
|
|
||||||
|
await tester.enterText(weightField(), '500.5');
|
||||||
|
await tester.tap(find.text('保存记录'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('体重需大于 0 且不超过 500 公斤,最多两位小数'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.enterText(weightField(), '4.356');
|
||||||
|
await tester.tap(find.text('保存记录'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('体重需大于 0 且不超过 500 公斤,最多两位小数'), findsOneWidget);
|
||||||
|
|
||||||
|
expect(createCalls, 0);
|
||||||
|
expect(eventsOf('health_record_create_failed'), hasLength(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('首次输入触发 started(每次进入仅一次,recordType=weight)', (tester) async {
|
||||||
|
await pumpForm(tester);
|
||||||
|
|
||||||
|
expect(eventsOf('health_record_create_started'), isEmpty);
|
||||||
|
|
||||||
|
await tester.enterText(weightField(), '4');
|
||||||
|
await tester.enterText(weightField(), '4.3');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final started = eventsOf('health_record_create_started');
|
||||||
|
expect(started, hasLength(1));
|
||||||
|
expect(started.single, {
|
||||||
|
'recordType': 'weight',
|
||||||
|
'entryPoint': 'record_list',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('保存成功:请求对齐契约(UTC 时间戳、note 可选)、pop 回列表、succeeded', (tester) async {
|
||||||
|
CreateWeightRequest? captured;
|
||||||
|
repository.createWeightHandler = (petId, request) async {
|
||||||
|
expect(petId, 'p-1');
|
||||||
|
captured = request;
|
||||||
|
return buildWeight('w-new', weightKg: 4.35);
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpForm(tester);
|
||||||
|
await tester.enterText(weightField(), '4.35');
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, '备注(可选,如:饭后称重)'),
|
||||||
|
'饭后',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('保存记录'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final json = captured!.toJson();
|
||||||
|
expect(json['weightKg'], 4.35);
|
||||||
|
expect(json['note'], '饭后');
|
||||||
|
// 今日称重取此刻并转 UTC(ISO 带 Z 后缀,避免服务端时区歧义)。
|
||||||
|
expect(json['measuredAt'], endsWith('Z'));
|
||||||
|
expect(json.containsKey('source'), isFalse);
|
||||||
|
|
||||||
|
expect(find.text('列表基底'), findsOneWidget);
|
||||||
|
final succeeded = eventsOf('health_record_create_succeeded');
|
||||||
|
expect(succeeded.single!['recordType'], 'weight');
|
||||||
|
expect(succeeded.single!['durationMs'], isA<int>());
|
||||||
|
expect(succeeded.single!['photoCount'], 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('40000 后端兜底:横幅提示 + failed 带 errorCode/httpStatus', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
repository.createWeightHandler = (petId, request) async =>
|
||||||
|
throw const ApiBusinessException(code: 40000, message: '参数校验失败');
|
||||||
|
|
||||||
|
await pumpForm(tester);
|
||||||
|
await tester.enterText(weightField(), '4.35');
|
||||||
|
await tester.tap(find.text('保存记录'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('请检查填写内容后重试'), findsOneWidget);
|
||||||
|
expect(find.byType(WeightFormPage), findsOneWidget);
|
||||||
|
final failed = eventsOf('health_record_create_failed');
|
||||||
|
expect(failed.single!['errorCode'], 40000);
|
||||||
|
expect(failed.single!['httpStatus'], 400);
|
||||||
|
expect(failed.single!['failureReason'], 'validation_error');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('40300 viewer 越权兜底:横幅 + failed(permission_denied)', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
repository.createWeightHandler = (petId, request) async =>
|
||||||
|
throw const PetAccessDeniedException(message: '无权限');
|
||||||
|
|
||||||
|
await pumpForm(tester);
|
||||||
|
await tester.enterText(weightField(), '4.35');
|
||||||
|
await tester.tap(find.text('保存记录'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('你没有权限为该宠物添加记录'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
eventsOf('health_record_create_failed').single!['failureReason'],
|
||||||
|
'permission_denied',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('网络异常:SnackBar + 重试动作 + failed(network_error)', (tester) async {
|
||||||
|
repository.createWeightHandler = (petId, request) async =>
|
||||||
|
throw const ApiNetworkException('断网');
|
||||||
|
|
||||||
|
await pumpForm(tester);
|
||||||
|
await tester.enterText(weightField(), '4.35');
|
||||||
|
await tester.tap(find.text('保存记录'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
|
||||||
|
expect(find.text('重试'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
eventsOf('health_record_create_failed').single!['failureReason'],
|
||||||
|
'network_error',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
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/weight_form_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/weight_records_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> 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: (_) => WeightRecordsPage(
|
||||||
|
repository: repository,
|
||||||
|
petId: 'p-1',
|
||||||
|
canWrite: canWrite,
|
||||||
|
analytics: analytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
CursorPage<WeightRecord> page(
|
||||||
|
List<WeightRecord> items, {
|
||||||
|
String? nextCursor,
|
||||||
|
bool hasMore = false,
|
||||||
|
}) => CursorPage(items: items, nextCursor: nextCursor, hasMore: hasMore);
|
||||||
|
|
||||||
|
testWidgets('四态 · loading → ready:条目渲染体重值与时间;viewed 上报一次', (tester) async {
|
||||||
|
final completer = Completer<CursorPage<WeightRecord>>();
|
||||||
|
repository.listWeightsHandler = (petId, limit, cursor) => completer.future;
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
|
||||||
|
completer.complete(
|
||||||
|
page([
|
||||||
|
buildWeight('w-1', weightKg: 4.35),
|
||||||
|
buildWeight('w-2', weightKg: 4.2, overrides: {'note': '洗澡后'}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('4.35 kg'), findsOneWidget);
|
||||||
|
expect(find.text('4.2 kg'), findsOneWidget);
|
||||||
|
expect(find.textContaining('洗澡后'), findsOneWidget);
|
||||||
|
// 列表曝光(每次进入一次),source=pet_detail。
|
||||||
|
final viewed = eventsOf('health_record_viewed');
|
||||||
|
expect(viewed.single, {'recordType': 'weight', 'source': 'pet_detail'});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('四态 · empty:空态插画 + 记录 CTA(canWrite)', (tester) async {
|
||||||
|
repository.listWeightsHandler = (petId, limit, cursor) async =>
|
||||||
|
page(const []);
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
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.listWeightsHandler = (petId, limit, cursor) async {
|
||||||
|
calls++;
|
||||||
|
if (calls == 1) throw const ApiNetworkException('断网');
|
||||||
|
return page([buildWeight('w-1')]);
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('重试'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('4.35 kg'), findsOneWidget);
|
||||||
|
// 失败后的首个成功加载才计一次曝光。
|
||||||
|
expect(eventsOf('health_record_viewed'), hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('cursor 分页:加载更多追加下一页并透传游标,末页收起按钮', (tester) async {
|
||||||
|
final cursors = <String?>[];
|
||||||
|
repository.listWeightsHandler = (petId, limit, cursor) async {
|
||||||
|
cursors.add(cursor);
|
||||||
|
if (cursor == null) {
|
||||||
|
return page(
|
||||||
|
[buildWeight('w-1')],
|
||||||
|
nextCursor: 'CURSOR-1',
|
||||||
|
hasMore: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return page([buildWeight('w-2', weightKg: 4.1)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('加载更多'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('加载更多'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(cursors, [null, 'CURSOR-1']);
|
||||||
|
expect(find.text('4.35 kg'), findsOneWidget);
|
||||||
|
expect(find.text('4.1 kg'), findsOneWidget);
|
||||||
|
expect(find.text('加载更多'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('加载更多失败:SnackBar 提示,按钮保留可再试', (tester) async {
|
||||||
|
var calls = 0;
|
||||||
|
repository.listWeightsHandler = (petId, limit, cursor) async {
|
||||||
|
calls++;
|
||||||
|
if (calls == 1) {
|
||||||
|
return page([buildWeight('w-1')], nextCursor: 'C1', hasMore: true);
|
||||||
|
}
|
||||||
|
throw const ApiNetworkException('断网');
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('加载更多'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
|
||||||
|
expect(find.text('加载更多'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('viewer(canWrite=false):无添加入口、空态无 CTA', (tester) async {
|
||||||
|
repository.listWeightsHandler = (petId, limit, cursor) async =>
|
||||||
|
page(const []);
|
||||||
|
|
||||||
|
await pumpPage(tester, canWrite: false);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.add), findsNothing);
|
||||||
|
expect(find.text('记录第一条'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('录入闭环:添加 → 表单(record_form 路由名)→ 成功后插入列表头', (tester) async {
|
||||||
|
repository.listWeightsHandler = (petId, limit, cursor) async =>
|
||||||
|
page([buildWeight('w-1')]);
|
||||||
|
repository.createWeightHandler = (petId, request) async =>
|
||||||
|
buildWeight('w-new', weightKg: 4.6);
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.add));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(WeightFormPage), findsOneWidget);
|
||||||
|
final route = ModalRoute.of(tester.element(find.byType(WeightFormPage)))!;
|
||||||
|
expect(route.settings.name, 'record_form');
|
||||||
|
|
||||||
|
await tester.enterText(find.widgetWithText(TextFormField, '体重(公斤)'), '4.6');
|
||||||
|
await tester.tap(find.text('保存记录'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(WeightFormPage), findsNothing);
|
||||||
|
expect(find.text('已记录体重'), findsOneWidget);
|
||||||
|
expect(find.text('4.6 kg'), findsOneWidget);
|
||||||
|
expect(find.text('4.35 kg'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -129,6 +129,44 @@ Pet buildPet(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> sampleVaccineCatalogJson({
|
||||||
|
String id = 'v-1',
|
||||||
|
String code = 'rabies',
|
||||||
|
String name = '狂犬疫苗',
|
||||||
|
String species = 'dog',
|
||||||
|
}) => {
|
||||||
|
'id': id,
|
||||||
|
'code': code,
|
||||||
|
'name': name,
|
||||||
|
'species': species,
|
||||||
|
'description': null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 快速构造体重记录(widget 测试共用)。
|
||||||
|
WeightRecord buildWeight(
|
||||||
|
String id, {
|
||||||
|
double weightKg = 4.35,
|
||||||
|
String measuredAt = '2026-09-07T09:00:00+08:00',
|
||||||
|
Map<String, Object?> overrides = const {},
|
||||||
|
}) => WeightRecord.fromJson({
|
||||||
|
...sampleWeightJson(),
|
||||||
|
'id': id,
|
||||||
|
'weightKg': weightKg,
|
||||||
|
'measuredAt': measuredAt,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 快速构造疫苗记录。
|
||||||
|
Vaccination buildVaccination(
|
||||||
|
String id, {
|
||||||
|
Map<String, Object?> overrides = const {},
|
||||||
|
}) =>
|
||||||
|
Vaccination.fromJson({...sampleVaccinationJson(), 'id': id, ...overrides});
|
||||||
|
|
||||||
|
/// 快速构造摘要(缺省为「三聚合齐备」样本;overrides 可置 null 验证空态)。
|
||||||
|
PetSummary buildSummary({Map<String, Object?> overrides = const {}}) =>
|
||||||
|
PetSummary.fromJson({...samplePetSummaryJson(), ...overrides});
|
||||||
|
|
||||||
/// 假仓库:各方法可注入行为;listPets 默认空列表,其余未注入的方法抛
|
/// 假仓库:各方法可注入行为;listPets 默认空列表,其余未注入的方法抛
|
||||||
/// UnimplementedError(22 号报告 §7:widget 测试注入假仓库的共享实现)。
|
/// UnimplementedError(22 号报告 §7:widget 测试注入假仓库的共享实现)。
|
||||||
class FakePetsRepository implements PetsRepository {
|
class FakePetsRepository implements PetsRepository {
|
||||||
@@ -137,6 +175,16 @@ class FakePetsRepository implements PetsRepository {
|
|||||||
Future<Pet> Function(String)? getPetHandler;
|
Future<Pet> Function(String)? getPetHandler;
|
||||||
Future<Pet> Function(String, UpdatePetRequest)? updatePetHandler;
|
Future<Pet> Function(String, UpdatePetRequest)? updatePetHandler;
|
||||||
Future<List<Breed>> Function(PetSpecies?)? listBreedsHandler;
|
Future<List<Breed>> Function(PetSpecies?)? listBreedsHandler;
|
||||||
|
Future<CursorPage<WeightRecord>> Function(String, int?, String?)?
|
||||||
|
listWeightsHandler;
|
||||||
|
Future<WeightRecord> Function(String, CreateWeightRequest)?
|
||||||
|
createWeightHandler;
|
||||||
|
Future<List<VaccineCatalogItem>> Function(PetSpecies?)?
|
||||||
|
listVaccineCatalogHandler;
|
||||||
|
Future<List<Vaccination>> Function(String)? listVaccinationsHandler;
|
||||||
|
Future<Vaccination> Function(String, CreateVaccinationRequest)?
|
||||||
|
createVaccinationHandler;
|
||||||
|
Future<PetSummary> Function(String, String?)? getPetSummaryHandler;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Pet>> listPets() =>
|
Future<List<Pet>> listPets() =>
|
||||||
@@ -157,6 +205,53 @@ class FakePetsRepository implements PetsRepository {
|
|||||||
listBreedsHandler?.call(species) ??
|
listBreedsHandler?.call(species) ??
|
||||||
Future.value([Breed.fromJson(sampleBreedJson())]);
|
Future.value([Breed.fromJson(sampleBreedJson())]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<WeightRecord>> listWeights(
|
||||||
|
String petId, {
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
}) => listWeightsHandler!(petId, limit, cursor);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<WeightRecord> createWeight(
|
||||||
|
String petId,
|
||||||
|
CreateWeightRequest request,
|
||||||
|
) => createWeightHandler!(petId, request);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<VaccineCatalogItem>> listVaccineCatalog({PetSpecies? species}) =>
|
||||||
|
listVaccineCatalogHandler?.call(species) ??
|
||||||
|
Future.value([VaccineCatalogItem.fromJson(sampleVaccineCatalogJson())]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Vaccination>> listVaccinations(String petId) =>
|
||||||
|
listVaccinationsHandler!(petId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Vaccination> createVaccination(
|
||||||
|
String petId,
|
||||||
|
CreateVaccinationRequest request,
|
||||||
|
) => createVaccinationHandler!(petId, request);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PetSummary> getPetSummary(String petId, {String? tz}) =>
|
||||||
|
getPetSummaryHandler?.call(petId, tz) ??
|
||||||
|
// 缺省给「无任何记录」空摘要(契约 null 语义),既有详情页测试
|
||||||
|
// 不必逐个注入。
|
||||||
|
Future.value(
|
||||||
|
PetSummary.fromJson({
|
||||||
|
'petId': petId,
|
||||||
|
'latestWeight': null,
|
||||||
|
'vaccinationProgress': null,
|
||||||
|
'nextVaccination': null,
|
||||||
|
'monthlyExpense': {
|
||||||
|
'month': '2026-09',
|
||||||
|
'timezone': 'UTC',
|
||||||
|
'amountCents': 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
dynamic noSuchMethod(Invocation invocation) =>
|
dynamic noSuchMethod(Invocation invocation) =>
|
||||||
throw UnimplementedError('${invocation.memberName}');
|
throw UnimplementedError('${invocation.memberName}');
|
||||||
|
|||||||
Reference in New Issue
Block a user