新增:照护提醒接入与疫苗流转收尾(T2-14 提醒半边 + T2-13 遗留)
CI / flutter-gates (push) Successful in 2m12s

- 照护提醒页:status 过滤(服务端白名单视图)、due_at ASC、逾期红标
  双通道标识,四态齐备;创建表单(四类)+ 完成(completedAt 必带、
  支持补记)/ 忽略(禁带 completedAt)流转,42202/40902 兜底重拉
- 档案页:「健康提醒」卡改真实待办数据驱动(取代 demo 硬编码文案,
  最近到期一条 + 逾期警示形态),照护提醒导航入口带待办数副行
- 疫苗列表(25 号报告遗留①②):scheduled 行「标记完成/取消登记」
  PATCH 流转;完成对话框补录厂商/批号(契约可选字段);
  42201/40902/40402 兜底
- 埋点:create 三事件 + viewed(recordType=reminder)挂通;
  edit_succeeded/failed 挂 vaccine 流转(failureReason 含 conflict);
  提醒完成/忽略按 06 §7 缺口 3 既定取舍不埋
- 测试 250 → 272 全绿(+22,较基线 +48);analyze 0 问题;format 无 diff

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 14:01:17 +08:00
parent e186ba3da9
commit ba503327f5
10 changed files with 2241 additions and 48 deletions
@@ -0,0 +1,312 @@
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-14)。
///
/// - 四类提醒类型(驱虫/体检/用药/其他)必选;标题必填;到期日期必选
/// (今日取此刻、未来日期取当日 12:00,转 UTC——与记录表单同一约定)。
/// - 契约:创建恒为 pending(不收 status);title/dueAt 无编辑端点,
/// 改期路径为忽略后重建。
/// - 埋点:health_record_create_started/succeeded/failed
/// recordType=reminder)。
class CareReminderFormPage extends StatefulWidget {
const CareReminderFormPage({
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<CareReminderFormPage> createState() => _CareReminderFormPageState();
}
class _CareReminderFormPageState extends State<CareReminderFormPage> {
final _titleCtrl = TextEditingController();
CareReminderType? _type;
DateTime? _dueDate;
String? _typeError;
String? _titleError;
String? _dueError;
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() {
_titleCtrl.dispose();
super.dispose();
}
void _markStarted() {
if (_startedFired) return;
_startedFired = true;
widget.analytics?.createStarted(
recordType: HealthRecordType.reminder,
entryPoint: widget.entryPoint,
);
}
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
widget.analytics?.createFailed(
recordType: HealthRecordType.reminder,
reason: reason,
attemptSeq: _attemptSeq,
errorCode: errorCode,
);
}
void _showFormError(String message) {
setState(() => _formError = message);
SemanticsService.sendAnnouncement(
View.of(context),
message,
TextDirection.ltr,
);
}
/// 到期时刻:今日取此刻,其余日期取当日 12:00(本地),提交前转 UTC。
DateTime _dueAt() {
final now = DateTime.now();
final date = _dueDate!;
final sameDay =
date.year == now.year && date.month == now.month && date.day == now.day;
final local = sameDay ? now : DateTime(date.year, date.month, date.day, 12);
return local.toUtc();
}
Future<void> _submit() async {
if (_submitting) return;
_attemptSeq++;
final typeError = _type == null ? '请选择提醒类型' : null;
final titleError = _titleCtrl.text.trim().isEmpty ? '请输入提醒内容' : null;
final dueError = _dueDate == null ? '请选择到期日期' : null;
if (typeError != null || titleError != null || dueError != null) {
setState(() {
_typeError = typeError;
_titleError = titleError;
_dueError = dueError;
});
_trackFailed(HealthRecordFailureReason.validationError);
return;
}
setState(() {
_submitting = true;
_formError = null;
});
try {
final reminder = await widget.repository.createCareReminder(
widget.petId,
CreateCareReminderRequest(
reminderType: _type!,
title: _titleCtrl.text.trim(),
dueAt: _dueAt(),
),
);
widget.analytics?.createSucceeded(
recordType: HealthRecordType.reminder,
durationMs: DateTime.now().difference(_openedAt).inMilliseconds,
);
if (mounted) Navigator.of(context).pop(reminder);
} 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: [
const Text(
'提醒类型',
style: TextStyle(
color: AppColors.inkSoft,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final type in CareReminderType.values)
ChoiceChip(
label: Text(careReminderTypeLabel(type)),
selected: _type == type,
onSelected: _submitting
? null
: (_) {
_markStarted();
setState(() {
_type = type;
_typeError = null;
_formError = null;
});
},
),
],
),
if (_typeError != null) ...[
const SizedBox(height: 6),
Text(
_typeError!,
style: const TextStyle(color: AppColors.error, fontSize: 12),
),
],
const SizedBox(height: 18),
AppTextField(
label: '提醒内容(如:体内外驱虫)',
controller: _titleCtrl,
prefixIcon: Icons.notifications_outlined,
errorText: _titleError,
enabled: !_submitting,
textInputAction: TextInputAction.done,
onChanged: (_) {
_markStarted();
if (_titleError != null || _formError != null) {
setState(() {
_titleError = null;
_formError = null;
});
}
},
onSubmitted: (_) => _submit(),
),
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(
_dueDate == null ? '未选择' : dateToJson(_dueDate!),
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: _dueDate ?? now,
firstDate: now,
lastDate: DateTime(now.year + 5),
);
if (value != null && mounted) {
_markStarted();
setState(() {
_dueDate = value;
_dueError = null;
});
}
},
),
if (_dueError != null) ...[
const SizedBox(height: 6),
Text(
_dueError!,
style: const TextStyle(color: AppColors.error, fontSize: 12),
),
],
if (_formError != null) ...[
const SizedBox(height: 16),
InlineErrorBanner(message: _formError!),
],
const SizedBox(height: 24),
PrimaryButton(
label: '保存提醒',
isLoading: _submitting,
onPressed: _submit,
),
],
),
),
);
}
}
+512
View File
@@ -0,0 +1,512 @@
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/care_reminder_form_page.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/health_record_display.dart';
import 'package:patbond_flutter/features/pets/pet_display.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
import 'package:patbond_flutter/widgets/common.dart';
enum _ListPhase { loading, ready, error }
/// 照护提醒页(T2-14):契约不分页,`due_at ASC`(待办最先到期在前);
/// `?status=` 过滤走服务端白名单视图;逾期待办有显性视觉标识。
///
/// - 完成 / 忽略:`PATCH /care-reminders/{id}` 状态流转
/// pending→completed 必带 completedAtpending→dismissed 禁带);
/// 42202 规则兜底、40902 并发流转抢先提示后重拉。
/// - 提醒完成/忽略**不埋事件**(06 §7 缺口 3 既定取舍:完成率从
/// care_reminders 事实表出数;M3+ 推送实验时再增补)。
/// - 曝光埋点:每次进入首个成功加载上报一次
/// `health_record_viewed(recordType=reminder, source=pet_detail)`。
class CareRemindersPage extends StatefulWidget {
const CareRemindersPage({
required this.repository,
required this.petId,
required this.canWrite,
super.key,
this.analytics,
});
final PetsRepository repository;
final String petId;
/// owner/caregiver 可写;viewer 隐藏创建与完成/忽略入口。
final bool canWrite;
final HealthRecordAnalytics? analytics;
@override
State<CareRemindersPage> createState() => _CareRemindersPageState();
}
class _CareRemindersPageState extends State<CareRemindersPage> {
_ListPhase _phase = _ListPhase.loading;
List<CareReminder> _reminders = const [];
ApiException? _error;
CareReminderStatus? _filter;
bool _viewedFired = false;
bool _mutating = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_phase = _ListPhase.loading;
_error = null;
});
try {
final reminders = await widget.repository.listCareReminders(
widget.petId,
status: _filter,
);
if (!mounted) return;
setState(() {
_reminders = reminders;
_phase = _ListPhase.ready;
});
if (!_viewedFired) {
_viewedFired = true;
widget.analytics?.viewed(
recordType: HealthRecordType.reminder,
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<CareReminder>(
fadePageRoute(
CareReminderFormPage(
repository: widget.repository,
petId: widget.petId,
analytics: widget.analytics,
),
settings: RouteSettings(name: AnalyticsPageName.recordForm.pageName),
),
);
if (created != null && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已添加提醒')));
// 排序键 due_at ASC 在服务端,重新拉取而非本地猜位置。
await _load();
}
}
/// 完成时刻:今日取此刻,补记历史日期取当日 12:00(本地),转 UTC。
DateTime _completedAt(DateTime date) {
final now = DateTime.now();
final sameDay =
date.year == now.year && date.month == now.month && date.day == now.day;
final local = sameDay ? now : DateTime(date.year, date.month, date.day, 12);
return local.toUtc();
}
Future<void> _complete(CareReminder reminder) async {
final date = await showDialog<DateTime>(
context: context,
builder: (context) => _CompleteDialog(title: reminder.title),
);
if (date == null || !mounted) return;
await _mutate(
reminder,
UpdateCareReminderRequest(
status: CareReminderStatus.completed,
// 契约:pending→completed 必带 completedAt(客户端提交,允许补记)。
completedAt: _completedAt(date),
),
successText: '已标记完成',
);
}
Future<void> _dismiss(CareReminder reminder) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('忽略这条提醒?'),
content: Text('${reminder.title}」将不再出现在待办中,且不可恢复为待办。'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('忽略'),
),
],
),
);
if (confirmed != true || !mounted) return;
await _mutate(
reminder,
// 契约:pending→dismissed 禁带 completedAt。
const UpdateCareReminderRequest(status: CareReminderStatus.dismissed),
successText: '已忽略提醒',
);
}
Future<void> _mutate(
CareReminder reminder,
UpdateCareReminderRequest request, {
required String successText,
}) async {
if (_mutating) return;
setState(() => _mutating = true);
final messenger = ScaffoldMessenger.of(context);
try {
await widget.repository.updateCareReminder(reminder.id, request);
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(successText)));
await _load();
} on CareReminderRuleException {
if (!mounted) return;
// 42202:状态机/completedAt 一致性兜底(前端结构上已按状态发字段)。
messenger.showSnackBar(
const SnackBar(content: Text('提醒状态不满足流转规则,已刷新,请重试')),
);
await _load();
} on PetVersionConflictException {
if (!mounted) return;
// 40902:并发流转抢先(条件更新守卫落空),处理方式与乐观锁一致。
messenger.showSnackBar(const SnackBar(content: Text('提醒已在其他设备被处理,已刷新')));
await _load();
} on PetRecordNotFoundException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('提醒不存在或已被删除,已刷新')));
await _load();
} on PetAccessDeniedException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('你没有权限操作该提醒')));
} on ApiBusinessException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('操作失败,请稍后重试')));
} on ApiNetworkException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('网络异常,请检查网络后重试')));
} on SessionExpiredException {
// 会话失效:认证状态机自动回登录页。
} finally {
if (mounted) setState(() => _mutating = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: AppColors.ink,
title: const Text('照护提醒'),
centerTitle: true,
titleTextStyle: const TextStyle(
color: AppColors.ink,
fontSize: 16,
fontWeight: FontWeight.w800,
),
actions: [
if (widget.canWrite && _phase == _ListPhase.ready)
IconButton(
tooltip: '添加提醒',
onPressed: _openCreate,
icon: const Icon(Icons.add),
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
child: _filterChips(),
),
Expanded(
child: 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 _reminders.isEmpty => Center(
child: SingleChildScrollView(child: _emptyState()),
),
_ListPhase.ready => _list(),
},
),
],
),
);
}
/// 状态过滤(服务端白名单视图;「待办」即 ?status=pending 按 due_at 查询)。
Widget _filterChips() {
final options = <(String, CareReminderStatus?)>[
('全部', null),
('待办', CareReminderStatus.pending),
('已完成', CareReminderStatus.completed),
('已忽略', CareReminderStatus.dismissed),
];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final (label, status) in options)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(label),
selected: _filter == status,
onSelected: (_) {
if (_filter == status) return;
setState(() => _filter = status);
_load();
},
),
),
],
),
);
}
Widget _emptyState() {
// 过滤视图下的空态不给 CTA(05 §4.2 筛选后空态惯例)。
if (_filter != null) {
return EmptyStateIllustration(
icon: Icons.notifications_none_outlined,
title: '暂无「${careReminderStatusLabel(_filter!)}」提醒',
);
}
return EmptyStateIllustration(
icon: Icons.notifications_none_outlined,
title: '还没有照护提醒',
description: '驱虫、体检、用药……到点不忘每一件照护小事',
ctaLabel: widget.canWrite ? '添加第一条' : null,
onCtaPressed: widget.canWrite ? _openCreate : null,
);
}
Widget _list() {
final now = DateTime.now();
return RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 30),
children: [
for (final reminder in _reminders) ...[
_ReminderTile(
reminder: reminder,
now: now,
// 完成/忽略仅对待办可用(终态不可迁;viewer 无写权限)。
onComplete:
widget.canWrite &&
reminder.status == CareReminderStatus.pending &&
!_mutating
? () => _complete(reminder)
: null,
onDismiss:
widget.canWrite &&
reminder.status == CareReminderStatus.pending &&
!_mutating
? () => _dismiss(reminder)
: null,
),
const SizedBox(height: 10),
],
],
),
);
}
}
/// 提醒条目:RecordTypeDot + 标题 + 时间副行 + 状态 TagPill(逾期红标),
/// 待办行附「标记完成 / 忽略」动作(图标 + 文字双通道)。
class _ReminderTile extends StatelessWidget {
const _ReminderTile({
required this.reminder,
required this.now,
this.onComplete,
this.onDismiss,
});
final CareReminder reminder;
final DateTime now;
final VoidCallback? onComplete;
final VoidCallback? onDismiss;
@override
Widget build(BuildContext context) {
final overdue = isReminderOverdue(reminder, now);
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
RecordTypeDot(
type: recordTypeForReminder(reminder.reminderType),
size: RecordTypeDotSize.md,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
reminder.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'${careReminderTypeLabel(reminder.reminderType)} · '
'${reminderDateLine(reminder)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
// 逾期副行同步警示色(与标签双位标识)。
color: overdue
? AppColors.errorDark
: AppColors.inkSoft,
fontSize: 12,
fontWeight: overdue
? FontWeight.w700
: FontWeight.w400,
),
),
],
),
),
const SizedBox(width: 8),
TagPill(
reminderStatusTag(reminder, now),
color: reminderStatusColor(reminder, now),
),
],
),
if (onComplete != null || onDismiss != null) ...[
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: onDismiss,
icon: const Icon(Icons.close, size: 16),
style: TextButton.styleFrom(
foregroundColor: AppColors.inkSoft,
),
label: const Text('忽略'),
),
const SizedBox(width: 4),
TextButton.icon(
onPressed: onComplete,
icon: const Icon(Icons.check_circle_outline, size: 16),
label: const Text('标记完成'),
),
],
),
],
],
),
),
);
}
}
/// 标记完成对话框:完成日期默认今天,可补记历史时刻(契约 completedAt
/// 由客户端提交)。确认返回所选日期。
class _CompleteDialog extends StatefulWidget {
const _CompleteDialog({required this.title});
final String title;
@override
State<_CompleteDialog> createState() => _CompleteDialogState();
}
class _CompleteDialogState extends State<_CompleteDialog> {
DateTime _date = DateTime.now();
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('标记完成'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${widget.title}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
title: const Text('完成日期', style: TextStyle(fontSize: 14)),
subtitle: Text(
dateToJson(_date),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
onTap: () async {
final now = DateTime.now();
final value = await showDatePicker(
context: context,
initialDate: _date,
firstDate: DateTime(1990),
lastDate: now,
);
if (value != null && mounted) {
setState(() => _date = value);
}
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(_date),
child: const Text('确认完成'),
),
],
);
}
}
@@ -134,3 +134,57 @@ String tzOffsetQueryValue(Duration offset) {
final m = (abs.inMinutes % 60).toString().padLeft(2, '0');
return '$sign$h:$m';
}
// ---- 照护提醒(T2-14----
/// 四类提醒中文文案。
String careReminderTypeLabel(CareReminderType type) => switch (type) {
CareReminderType.deworming => '驱虫',
CareReminderType.checkup => '体检',
CareReminderType.medication => '用药',
CareReminderType.other => '其他',
};
/// 提醒类型 → 视觉记录类型(RecordTypeDot 复用):驱虫沿用驱虫族,
/// 体检/用药归就医族,其他归兜底族;类型文字由标签承载(双通道)。
RecordType recordTypeForReminder(CareReminderType type) => switch (type) {
CareReminderType.deworming => RecordType.deworming,
CareReminderType.checkup => RecordType.medical,
CareReminderType.medication => RecordType.medical,
CareReminderType.other => RecordType.other,
};
String careReminderStatusLabel(CareReminderStatus status) => switch (status) {
CareReminderStatus.pending => '待办',
CareReminderStatus.completed => '已完成',
CareReminderStatus.dismissed => '已忽略',
};
/// 逾期判定:待办且 dueAt 已过(工单硬项:逾期视觉标识)。
bool isReminderOverdue(CareReminder reminder, DateTime now) =>
reminder.status == CareReminderStatus.pending &&
reminder.dueAt.isBefore(now);
/// 提醒状态标签文案(逾期的待办以「已逾期」显性标识)。
String reminderStatusTag(CareReminder reminder, DateTime now) =>
isReminderOverdue(reminder, now)
? '已逾期'
: careReminderStatusLabel(reminder.status);
/// 提醒状态标签基色(TagPill 淡染底;文字深变体由 TagPill 内置映射)。
Color reminderStatusColor(CareReminder reminder, DateTime now) =>
switch (reminder.status) {
CareReminderStatus.pending =>
isReminderOverdue(reminder, now) ? AppColors.error : AppColors.accent,
CareReminderStatus.completed => AppColors.success,
CareReminderStatus.dismissed => AppColors.muted,
};
/// 提醒条目副行:按状态给时间语义(到期 / 完成于 / 已忽略)。
String reminderDateLine(CareReminder reminder) => switch (reminder.status) {
CareReminderStatus.pending => '到期 ${formatOccurredAt(reminder.dueAt)}',
CareReminderStatus.completed =>
'完成于 ${reminder.completedAt == null ? '' : formatOccurredAt(reminder.completedAt!)}',
CareReminderStatus.dismissed =>
'已忽略 · 原到期 ${formatOccurredAt(reminder.dueAt)}',
};
+165 -18
View File
@@ -6,6 +6,7 @@ import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
import 'package:patbond_flutter/features/pets/care_reminders_page.dart';
import 'package:patbond_flutter/features/pets/health_events_page.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/health_record_display.dart';
@@ -24,6 +25,8 @@ enum _DetailPhase { loading, ready, error, notFound }
enum _SummaryPhase { loading, ready, error }
enum _RemindersPhase { loading, ready, error }
/// 宠物详情页(T2-12 / 05 号规范 §4.2 P2 的档案信息部分)。
///
/// 打开即用控制器内存副本首屏渲染,同时经 [PetsController.getPet]
@@ -59,6 +62,9 @@ class _PetDetailPageState extends State<PetDetailPage> {
_SummaryPhase _summaryPhase = _SummaryPhase.loading;
PetSummary? _summary;
_RemindersPhase _remindersPhase = _RemindersPhase.loading;
List<CareReminder> _pendingReminders = const [];
@override
void initState() {
super.initState();
@@ -66,6 +72,7 @@ class _PetDetailPageState extends State<PetDetailPage> {
if (_pet != null) _phase = _DetailPhase.ready;
_load();
_loadSummary();
_loadPendingReminders();
}
Pet? _fromController() {
@@ -126,6 +133,27 @@ class _PetDetailPageState extends State<PetDetailPage> {
}
}
/// 待办提醒(?status=pendingdue_at ASC):驱动「健康提醒」卡与
/// 提醒入口副行——demo 时代的硬编码提醒文案自此为真实数据取代。
/// 失败只降级为入口副行提示,不阻塞档案主链路。
Future<void> _loadPendingReminders() async {
setState(() => _remindersPhase = _RemindersPhase.loading);
try {
final reminders = await widget.controller.repository.listCareReminders(
widget.petId,
status: CareReminderStatus.pending,
);
if (!mounted) return;
setState(() {
_pendingReminders = reminders;
_remindersPhase = _RemindersPhase.ready;
});
} on ApiException {
if (!mounted) return;
setState(() => _remindersPhase = _RemindersPhase.error);
}
}
Future<void> _openEdit() async {
final pet = _pet;
if (pet == null) return;
@@ -205,6 +233,21 @@ class _PetDetailPageState extends State<PetDetailPage> {
if (mounted) await _loadSummary();
}
Future<void> _openReminders(Pet pet) async {
await Navigator.of(context).push(
fadePageRoute(
CareRemindersPage(
repository: widget.controller.repository,
petId: pet.id,
canWrite: _canWriteRecords,
analytics: widget.healthAnalytics,
),
),
);
// 待办可能已变化(完成/忽略/新建):返回即重拉待办。
if (mounted) await _loadPendingReminders();
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -338,27 +381,72 @@ class _PetDetailPageState extends State<PetDetailPage> {
);
}
/// 记录导航区:健康时间线入口(T2-14;照护提醒入口随提醒半边落位)。
/// 记录导航区:健康时间线与照护提醒入口(T2-14)。待办提醒非空时
/// 上方渲染真实数据驱动的「健康提醒」卡(正典 alert-card 形态,
/// 取代 demo 硬编码文案),点卡与点入口同去提醒页。
Widget _recordsSection(Pet pet) {
return SectionCard(
padding: EdgeInsets.zero,
child: Column(
children: [
ListTile(
leading: const Icon(
Icons.event_note_outlined,
color: AppColors.inkSoft,
),
title: const Text('健康时间线', style: TextStyle(fontSize: 14)),
subtitle: const Text(
'就医 · 喂养 · 驱虫 · 洗护 · 测量 · 随手记',
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
trailing: const Icon(Icons.chevron_right, color: AppColors.muted),
onTap: () => _openTimeline(pet),
final nearest = _pendingReminders.isEmpty ? null : _pendingReminders.first;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (nearest != null) ...[
_ReminderAlertCard(
reminder: nearest,
overdue: isReminderOverdue(nearest, DateTime.now()),
onTap: () => _openReminders(pet),
),
const SizedBox(height: 10),
],
),
SectionCard(
padding: EdgeInsets.zero,
child: Column(
children: [
ListTile(
leading: const Icon(
Icons.event_note_outlined,
color: AppColors.inkSoft,
),
title: const Text('健康时间线', style: TextStyle(fontSize: 14)),
subtitle: const Text(
'就医 · 喂养 · 驱虫 · 洗护 · 测量 · 随手记',
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
trailing: const Icon(
Icons.chevron_right,
color: AppColors.muted,
),
onTap: () => _openTimeline(pet),
),
const Divider(height: 1, thickness: 1, color: AppColors.border),
ListTile(
leading: const Icon(
Icons.notifications_outlined,
color: AppColors.inkSoft,
),
title: const Text('照护提醒', style: TextStyle(fontSize: 14)),
subtitle: Text(
switch (_remindersPhase) {
_RemindersPhase.loading => '加载中…',
_RemindersPhase.error => '提醒加载失败,点击查看',
_RemindersPhase.ready when _pendingReminders.isEmpty =>
'暂无待办提醒',
_RemindersPhase.ready => '${_pendingReminders.length} 条待办',
},
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
),
),
trailing: const Icon(
Icons.chevron_right,
color: AppColors.muted,
),
onTap: () => _openReminders(pet),
),
],
),
),
],
);
}
@@ -550,3 +638,62 @@ class _RowDivider extends StatelessWidget {
return const Divider(height: 1, thickness: 1, color: AppColors.border);
}
}
/// 「健康提醒」卡(正典 alert-cardsuccessSurface 底 + dot + 文字):
/// 数据源为最近到期的待办提醒(真实数据驱动,取代 demo 硬编码文案);
/// 逾期时文案切警示深色(双通道:前缀文字 + 颜色)。
class _ReminderAlertCard extends StatelessWidget {
const _ReminderAlertCard({
required this.reminder,
required this.overdue,
this.onTap,
});
final CareReminder reminder;
final bool overdue;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final textColor = overdue ? AppColors.errorDark : AppColors.successInk;
return Material(
color: AppColors.successSurface,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: textColor,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'健康提醒:${reminder.title}'
'${overdue ? '已逾期' : '${dateToJson(reminder.dueAt.toLocal())} 到期'}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: textColor,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
Icon(Icons.chevron_right, size: 18, color: textColor),
],
),
),
),
);
}
}
+371 -30
View File
@@ -3,12 +3,14 @@ import 'package:patbond_flutter/analytics/analytics_page_name.dart';
import 'package:patbond_flutter/core/navigation/fade_route.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/health_record_display.dart';
import 'package:patbond_flutter/features/pets/pet_display.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
import 'package:patbond_flutter/features/pets/vaccination_form_page.dart';
@@ -21,6 +23,10 @@ enum _ListPhase { loading, ready, error }
/// 含 cancelled 行原样展示(取消后同剂次可重新登记的事实留痕)。
/// 四态齐备;登记经 [VaccinationFormPage]。
///
/// T2-14 收尾(25 号报告 §7 遗留①②):scheduled 行支持「标记完成 /
/// 取消登记」PATCH 流转;完成时可补录厂商/批号(契约可选字段);
/// 挂 `health_record_edit_succeeded/failed`recordType=vaccine)。
///
/// 曝光埋点:每次进入首个成功加载上报一次
/// `health_record_viewed(recordType=vaccine, source=pet_detail)`。
class VaccinationRecordsPage extends StatefulWidget {
@@ -53,6 +59,7 @@ class _VaccinationRecordsPageState extends State<VaccinationRecordsPage> {
List<Vaccination> _records = const [];
ApiException? _error;
bool _viewedFired = false;
bool _mutating = false;
@override
void initState() {
@@ -109,6 +116,124 @@ class _VaccinationRecordsPageState extends State<VaccinationRecordsPage> {
}
}
void _trackEditFailed(HealthRecordFailureReason reason, [int? errorCode]) {
widget.analytics?.editFailed(
recordType: HealthRecordType.vaccine,
reason: reason,
errorCode: errorCode,
);
}
/// 标记完成(25 号报告遗留①②):接种日期必填、下次接种/厂商/批号可选
/// (厂商/批号为契约可选字段的补录入口)。
Future<void> _markCompleted(Vaccination record) async {
final result = await showDialog<_CompleteVaccinationResult>(
context: context,
builder: (context) => _CompleteVaccinationDialog(record: record),
);
if (result == null || !mounted) return;
await _mutate(
record,
UpdateVaccinationRequest(
version: record.version,
status: VaccinationStatus.completed,
administeredOn: result.administeredOn,
nextDueOn: result.nextDueOn,
manufacturer: result.manufacturer,
batchNo: result.batchNo,
),
successText: '已标记完成',
);
}
Future<void> _cancelRegistration(Vaccination record) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('取消这条登记?'),
content: Text(
'${vaccinationDoseLabel(record)}」将标记为已取消;'
'取消后同系列同剂次可重新登记。',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('返回'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('取消登记'),
),
],
),
);
if (confirmed != true || !mounted) return;
await _mutate(
record,
UpdateVaccinationRequest(
version: record.version,
status: VaccinationStatus.cancelled,
),
successText: '已取消登记',
);
}
Future<void> _mutate(
Vaccination record,
UpdateVaccinationRequest request, {
required String successText,
}) async {
if (_mutating) return;
setState(() => _mutating = true);
final messenger = ScaffoldMessenger.of(context);
try {
await widget.repository.updateVaccination(record.id, request);
widget.analytics?.editSucceeded(
recordType: HealthRecordType.vaccine,
fieldCount: request.toJson().length - 1,
);
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(successText)));
await _load();
} on PetVersionConflictException {
if (!mounted) return;
// 40902:并发修改抢先——刷新取新 version 后由用户重试动作。
messenger.showSnackBar(
const SnackBar(content: Text('记录已在其他设备被修改,已刷新,请重试')),
);
_trackEditFailed(HealthRecordFailureReason.conflict, 40902);
await _load();
} on VaccinationRuleException {
if (!mounted) return;
// 42201:状态机/状态-日期规则兜底(前端已按规则拦截主路径)。
messenger.showSnackBar(
const SnackBar(content: Text('接种状态与日期不符合规则,请核对后重试')),
);
_trackEditFailed(HealthRecordFailureReason.validationError, 42201);
} on PetRecordNotFoundException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('记录不存在或已被删除,已刷新')));
_trackEditFailed(HealthRecordFailureReason.notFound, 40402);
await _load();
} on PetAccessDeniedException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('你没有权限操作该记录')));
_trackEditFailed(HealthRecordFailureReason.permissionDenied, 40300);
} on ApiBusinessException catch (error) {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('操作失败,请稍后重试')));
_trackEditFailed(HealthRecordFailureReason.serverError, error.code);
} on ApiNetworkException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('网络异常,请检查网络后重试')));
_trackEditFailed(HealthRecordFailureReason.networkError);
} on SessionExpiredException {
// 会话失效:认证状态机自动回登录页。
} finally {
if (mounted) setState(() => _mutating = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -187,7 +312,24 @@ class _VaccinationRecordsPageState extends State<VaccinationRecordsPage> {
);
}
children
..add(_VaccinationTile(record: record))
..add(
_VaccinationTile(
record: record,
// 流转动作仅 scheduled 行可用(终态由服务端状态机守卫)。
onComplete:
widget.canWrite &&
record.status == VaccinationStatus.scheduled &&
!_mutating
? () => _markCompleted(record)
: null,
onCancel:
widget.canWrite &&
record.status == VaccinationStatus.scheduled &&
!_mutating
? () => _cancelRegistration(record)
: null,
),
)
..add(const SizedBox(height: 10));
}
return RefreshIndicator(
@@ -201,53 +343,252 @@ class _VaccinationRecordsPageState extends State<VaccinationRecordsPage> {
}
/// 疫苗条目:RecordTypeDot(疫苗) + 剂次标题 + 日期副行 + 状态 TagPill
/// (图标+文字双通道,不单靠颜色区分)
/// (图标+文字双通道,不单靠颜色区分)scheduled 行附
/// 「标记完成 / 取消登记」流转动作。
class _VaccinationTile extends StatelessWidget {
const _VaccinationTile({required this.record});
const _VaccinationTile({
required this.record,
this.onComplete,
this.onCancel,
});
final Vaccination record;
final VoidCallback? onComplete;
final VoidCallback? onCancel;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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,
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(height: 4),
Text(
vaccinationDateLine(record),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
),
const SizedBox(width: 8),
TagPill(
vaccinationStatusLabel(record.status),
color: vaccinationStatusColor(record.status),
),
],
),
if (onComplete != null || onCancel != null) ...[
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.close, size: 16),
style: TextButton.styleFrom(
foregroundColor: AppColors.inkSoft,
),
label: const Text('取消登记'),
),
const SizedBox(width: 4),
TextButton.icon(
onPressed: onComplete,
icon: const Icon(Icons.check_circle_outline, size: 16),
label: const Text('标记完成'),
),
],
),
),
const SizedBox(width: 8),
TagPill(
vaccinationStatusLabel(record.status),
color: vaccinationStatusColor(record.status),
),
],
],
),
),
);
}
}
/// 标记完成对话框返回值。
class _CompleteVaccinationResult {
const _CompleteVaccinationResult({
required this.administeredOn,
this.nextDueOn,
this.manufacturer,
this.batchNo,
});
final DateTime administeredOn;
final DateTime? nextDueOn;
final String? manufacturer;
final String? batchNo;
}
/// 标记完成对话框:接种日期必填(默认今天)、下次接种可选;
/// 厂商/批号补录(契约可选字段,25 号报告遗留②的落地入口)。
/// 日期规则复用 [vaccinationDateRuleError]42201 前置拦截)。
class _CompleteVaccinationDialog extends StatefulWidget {
const _CompleteVaccinationDialog({required this.record});
final Vaccination record;
@override
State<_CompleteVaccinationDialog> createState() =>
_CompleteVaccinationDialogState();
}
class _CompleteVaccinationDialogState
extends State<_CompleteVaccinationDialog> {
final _manufacturerCtrl = TextEditingController();
final _batchNoCtrl = TextEditingController();
DateTime _administeredOn = DateTime.now();
DateTime? _nextDueOn;
String? _dateError;
@override
void initState() {
super.initState();
_manufacturerCtrl.text = widget.record.manufacturer ?? '';
_batchNoCtrl.text = widget.record.batchNo ?? '';
}
@override
void dispose() {
_manufacturerCtrl.dispose();
_batchNoCtrl.dispose();
super.dispose();
}
void _confirm() {
final error = vaccinationDateRuleError(
status: VaccinationStatus.completed,
administeredOn: _administeredOn,
nextDueOn: _nextDueOn,
);
if (error != null) {
setState(() => _dateError = error);
return;
}
final manufacturer = _manufacturerCtrl.text.trim();
final batchNo = _batchNoCtrl.text.trim();
Navigator.of(context).pop(
_CompleteVaccinationResult(
administeredOn: _administeredOn,
nextDueOn: _nextDueOn,
manufacturer: manufacturer.isEmpty ? null : manufacturer,
batchNo: batchNo.isEmpty ? null : batchNo,
),
);
}
Widget _dateTile({
required String label,
required DateTime? value,
required bool allowFuture,
required ValueChanged<DateTime> onPicked,
}) {
return ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
title: Text(label, style: const TextStyle(fontSize: 14)),
subtitle: Text(
value == null ? '未选择' : dateToJson(value),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
onTap: () async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: value ?? now,
firstDate: DateTime(1990),
lastDate: allowFuture ? DateTime(now.year + 5) : now,
);
if (picked != null && mounted) {
setState(() {
onPicked(picked);
_dateError = null;
});
}
},
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('标记完成'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${vaccinationDoseLabel(widget.record)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
_dateTile(
label: '接种日期',
value: _administeredOn,
allowFuture: false,
onPicked: (value) => _administeredOn = value,
),
_dateTile(
label: '下次接种日期(可选)',
value: _nextDueOn,
allowFuture: true,
onPicked: (value) => _nextDueOn = value,
),
if (_dateError != null) ...[
const SizedBox(height: 4),
Text(
_dateError!,
style: const TextStyle(color: AppColors.error, fontSize: 12),
),
],
const SizedBox(height: 12),
AppTextField(
label: '厂商(可选)',
controller: _manufacturerCtrl,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
AppTextField(
label: '批号(可选)',
controller: _batchNoCtrl,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _confirm(),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
FilledButton(onPressed: _confirm, child: const Text('确认完成')),
],
);
}
}
@@ -0,0 +1,127 @@
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/care_reminder_form_page.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 '../../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: Builder(
builder: (context) => Scaffold(
body: Center(
child: TextButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<CareReminder>(
builder: (_) => CareReminderFormPage(
repository: repository,
petId: 'p-1',
analytics: analytics,
),
),
),
child: const Text('打开表单'),
),
),
),
),
),
);
await tester.tap(find.text('打开表单'));
await tester.pumpAndSettle();
}
Future<void> fillValid(WidgetTester tester) async {
await tester.tap(find.text('用药'));
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, '提醒内容(如:体内外驱虫)'),
'心丝虫预防药',
);
await tester.tap(find.text('到期日期'));
await tester.pumpAndSettle();
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
}
testWidgets('started 去重:首次输入一次;四类类型可选', (tester) async {
await pumpForm(tester);
await fillValid(tester);
await tester.enterText(
find.widgetWithText(TextFormField, '提醒内容(如:体内外驱虫)'),
'改个名字',
);
await tester.pump();
final started = eventsOf('health_record_create_started');
expect(started.single, {
'recordType': 'reminder',
'entryPoint': 'record_list',
});
// 四类类型齐备。
for (final label in ['驱虫', '体检', '用药', '其他']) {
expect(find.text(label), findsOneWidget);
}
});
testWidgets('40300 横幅 / 网络 SnackBar 兜底 + 失败事件', (tester) async {
var attempt = 0;
repository.createCareReminderHandler = (petId, request) async {
attempt++;
if (attempt == 1) {
throw const PetAccessDeniedException(message: '无权限');
}
throw const ApiNetworkException('断网');
};
await pumpForm(tester);
await fillValid(tester);
await tester.tap(find.text('保存提醒'));
await tester.pumpAndSettle();
expect(find.text('你没有权限为该宠物添加提醒'), findsOneWidget);
await tester.tap(find.text('保存提醒'));
await tester.pumpAndSettle();
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
final failed = eventsOf('health_record_create_failed');
expect(failed.length, 2);
expect(failed[0], {
'recordType': 'reminder',
'failureReason': 'permission_denied',
'attemptSeq': 1,
'errorCode': 40300,
'httpStatus': 403,
});
expect(failed[1]!['failureReason'], 'network_error');
expect(failed[1]!['attemptSeq'], 2);
});
}
@@ -0,0 +1,350 @@
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/care_reminder_form_page.dart';
import 'package:patbond_flutter/features/pets/care_reminders_page.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/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: (_) => CareRemindersPage(
repository: repository,
petId: 'p-1',
canWrite: canWrite,
analytics: analytics,
),
),
),
);
await tester.pump();
}
/// 相对当前时刻的待办样本(逾期判定依赖真实 now)。
CareReminder pendingIn(
String id,
Duration offset, {
Map<String, Object?> overrides = const {},
}) => buildReminder(
id,
overrides: {
'dueAt': DateTime.now().add(offset).toUtc().toIso8601String(),
...overrides,
},
);
testWidgets('四态 · ready:类型/时间/状态齐备,逾期待办红标;viewed 一次', (tester) async {
repository.listCareRemindersHandler = (petId, status) async => [
pendingIn(
'r-1',
const Duration(days: -3),
overrides: {'title': '体内外驱虫', 'reminderType': 'deworming'},
),
pendingIn('r-2', const Duration(days: 5)),
buildReminder(
'r-3',
overrides: {
'title': '疫苗加强针',
'reminderType': 'medication',
'status': 'completed',
'completedAt': '2026-09-02T10:00:00+08:00',
},
),
];
await pumpPage(tester);
await tester.pumpAndSettle();
expect(find.text('体内外驱虫'), findsOneWidget);
expect(find.text('年度体检'), findsOneWidget);
expect(find.text('疫苗加强针'), findsOneWidget);
// 逾期视觉标识(工单硬项):过期待办标「已逾期」,未到期标「待办」。
expect(find.widgetWithText(TagPill, '已逾期'), findsOneWidget);
expect(find.widgetWithText(TagPill, '待办'), findsOneWidget);
expect(find.widgetWithText(TagPill, '已完成'), findsOneWidget);
// 待办行有完成/忽略动作,终态行没有。
expect(find.text('标记完成'), findsNWidgets(2));
expect(find.text('忽略'), findsNWidgets(2));
final viewed = eventsOf('health_record_viewed');
expect(viewed.single, {'recordType': 'reminder', 'source': 'pet_detail'});
});
testWidgets('四态 · loading / empty:空态插画 + CTA;过滤空态无 CTA', (tester) async {
final completer = Completer<List<CareReminder>>();
final captured = <CareReminderStatus?>[];
repository.listCareRemindersHandler = (petId, status) {
captured.add(status);
if (captured.length == 1) return completer.future;
return Future.value(const []);
};
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);
// 切「已完成」过滤:status 参数透传服务端;过滤空态不给 CTA。
await tester.tap(find.text('已完成'));
await tester.pumpAndSettle();
expect(captured, [null, CareReminderStatus.completed]);
expect(find.text('暂无「已完成」提醒'), findsOneWidget);
expect(find.text('添加第一条'), findsNothing);
});
testWidgets('四态 · error/retry:横幅 + 重试恢复', (tester) async {
var calls = 0;
repository.listCareRemindersHandler = (petId, status) async {
calls++;
if (calls == 1) throw const ApiNetworkException('断网');
return [pendingIn('r-1', const Duration(days: 5))];
};
await pumpPage(tester);
await tester.pumpAndSettle();
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
await tester.tap(find.text('重试'));
await tester.pumpAndSettle();
expect(find.text('年度体检'), findsOneWidget);
});
testWidgets('创建闭环:CTA → 表单(record_form 路由名)→ 请求形状与事件 → 成功重拉', (tester) async {
var listCalls = 0;
repository.listCareRemindersHandler = (petId, status) async {
listCalls++;
return listCalls == 1
? const []
: [
pendingIn(
'r-new',
const Duration(days: 30),
overrides: {'title': '体内外驱虫'},
),
];
};
CreateCareReminderRequest? captured;
repository.createCareReminderHandler = (petId, request) async {
captured = request;
return pendingIn(
'r-new',
const Duration(days: 30),
overrides: {'title': '体内外驱虫'},
);
};
await pumpPage(tester);
await tester.pumpAndSettle();
await tester.tap(find.text('添加第一条'));
await tester.pumpAndSettle();
expect(find.byType(CareReminderFormPage), findsOneWidget);
final route = ModalRoute.of(
tester.element(find.byType(CareReminderFormPage)),
)!;
expect(route.settings.name, 'record_form');
// 类型必选 + 标题必填 + 到期日期必选:先空提交拦截。
await tester.tap(find.text('保存提醒'));
await tester.pump();
expect(find.text('请选择提醒类型'), findsOneWidget);
expect(find.text('请输入提醒内容'), findsOneWidget);
expect(find.text('请选择到期日期'), findsOneWidget);
await tester.tap(find.text('驱虫'));
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, '提醒内容(如:体内外驱虫)'),
'体内外驱虫',
);
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();
final json = captured!.toJson();
expect(json['reminderType'], 'deworming');
expect(json['title'], '体内外驱虫');
expect(json['dueAt'], endsWith('Z'));
expect(json.containsKey('status'), isFalse);
expect(find.byType(CareReminderFormPage), findsNothing);
expect(find.text('已添加提醒'), findsOneWidget);
expect(listCalls, 2);
// 创建漏斗事件(recordType=reminder)。
final started = eventsOf('health_record_create_started');
expect(started.single, {
'recordType': 'reminder',
'entryPoint': 'record_list',
});
final failed = eventsOf('health_record_create_failed');
expect(failed.single!['failureReason'], 'validation_error');
final succeeded = eventsOf('health_record_create_succeeded');
expect(succeeded.single!['recordType'], 'reminder');
});
testWidgets('标记完成:completedAt 必带(UTC);忽略:禁带 completedAt;成功重拉', (
tester,
) async {
var listCalls = 0;
repository.listCareRemindersHandler = (petId, status) async {
listCalls++;
return [
pendingIn('r-1', const Duration(days: 5)),
pendingIn(
'r-2',
const Duration(days: 9),
overrides: {'title': '体内外驱虫'},
),
];
};
final captured = <(String, Map<String, Object?>)>[];
repository.updateCareReminderHandler = (reminderId, request) async {
captured.add((reminderId, request.toJson()));
return buildReminder(
reminderId,
overrides: {
'status': request.status.name,
'completedAt': request.completedAt?.toIso8601String(),
},
);
};
await pumpPage(tester);
await tester.pumpAndSettle();
// 完成 r-1:对话框(默认今天)确认 → PATCH completed + completedAt。
await tester.tap(find.text('标记完成').first);
await tester.pumpAndSettle();
await tester.tap(find.text('确认完成'));
await tester.pumpAndSettle();
expect(captured.length, 1);
expect(captured[0].$1, 'r-1');
expect(captured[0].$2['status'], 'completed');
expect(captured[0].$2['completedAt'], endsWith('Z'));
expect(find.text('已标记完成'), findsOneWidget);
expect(listCalls, 2);
// 忽略 r-2:确认对话框 → PATCH dismissedcompletedAt 键缺席。
await tester.tap(find.text('忽略').last);
await tester.pumpAndSettle();
expect(find.text('忽略这条提醒?'), findsOneWidget);
// 对话框主按钮文案与动作同名,用 FilledButton 定位。
await tester.tap(find.widgetWithText(FilledButton, '忽略'));
await tester.pumpAndSettle();
expect(captured.length, 2);
expect(captured[1].$2, {'status': 'dismissed'});
expect(listCalls, 3);
// 提醒完成/忽略不埋事件(06 §7 缺口 3 既定取舍)。
expect(eventsOf('health_record_edit_succeeded'), isEmpty);
});
testWidgets('42202 规则兜底:提示 + 重拉', (tester) async {
var listCalls = 0;
repository.listCareRemindersHandler = (petId, status) async {
listCalls++;
return [pendingIn('r-1', const Duration(days: 5))];
};
repository.updateCareReminderHandler = (reminderId, request) async {
throw const CareReminderRuleException(message: '规则违反');
};
await pumpPage(tester);
await tester.pumpAndSettle();
await tester.tap(find.text('标记完成'));
await tester.pumpAndSettle();
await tester.tap(find.text('确认完成'));
await tester.pumpAndSettle();
expect(find.text('提醒状态不满足流转规则,已刷新,请重试'), findsOneWidget);
expect(listCalls, 2);
});
testWidgets('40902 并发流转抢先:提示已被处理 + 重拉', (tester) async {
var listCalls = 0;
repository.listCareRemindersHandler = (petId, status) async {
listCalls++;
return [pendingIn('r-1', const Duration(days: 5))];
};
repository.updateCareReminderHandler = (reminderId, request) async {
throw const PetVersionConflictException(message: '数据已被修改');
};
await pumpPage(tester);
await tester.pumpAndSettle();
await tester.tap(find.text('标记完成'));
await tester.pumpAndSettle();
await tester.tap(find.text('确认完成'));
await tester.pumpAndSettle();
expect(find.text('提醒已在其他设备被处理,已刷新'), findsOneWidget);
expect(listCalls, 2);
});
testWidgets('viewercanWrite=false):无添加入口、无完成/忽略动作、空态无 CTA', (tester) async {
repository.listCareRemindersHandler = (petId, status) async => [
pendingIn('r-1', const Duration(days: 5)),
];
await pumpPage(tester, canWrite: false);
await tester.pumpAndSettle();
expect(find.byIcon(Icons.add), findsNothing);
expect(find.text('标记完成'), findsNothing);
expect(find.text('忽略'), findsNothing);
});
}
@@ -201,4 +201,80 @@ void main() {
);
});
});
group('T2-14 · 照护提醒展示纯函数', () {
test('四类提醒文案与视觉映射(体检/用药归就医族)', () {
expect(
[for (final t in CareReminderType.values) careReminderTypeLabel(t)],
['驱虫', '体检', '用药', '其他'],
);
expect(
recordTypeForReminder(CareReminderType.deworming),
RecordType.deworming,
);
expect(
recordTypeForReminder(CareReminderType.checkup),
RecordType.medical,
);
expect(
recordTypeForReminder(CareReminderType.medication),
RecordType.medical,
);
expect(recordTypeForReminder(CareReminderType.other), RecordType.other);
});
test('逾期判定:仅待办且 dueAt 已过;标签与基色随之切换', () {
final now = DateTime(2026, 9, 8, 12);
final overduePending = buildReminder(
'r-1',
overrides: {'dueAt': '2026-09-01T00:00:00+08:00'},
);
final futurePending = buildReminder(
'r-2',
overrides: {'dueAt': '2026-10-01T00:00:00+08:00'},
);
// 已完成的过期提醒不算逾期(终态)。
final completedPast = buildReminder(
'r-3',
overrides: {
'dueAt': '2026-09-01T00:00:00+08:00',
'status': 'completed',
'completedAt': '2026-09-02T10:00:00+08:00',
},
);
expect(isReminderOverdue(overduePending, now), isTrue);
expect(isReminderOverdue(futurePending, now), isFalse);
expect(isReminderOverdue(completedPast, now), isFalse);
expect(reminderStatusTag(overduePending, now), '已逾期');
expect(reminderStatusColor(overduePending, now), AppColors.error);
expect(reminderStatusTag(futurePending, now), '待办');
expect(reminderStatusColor(futurePending, now), AppColors.accent);
expect(reminderStatusTag(completedPast, now), '已完成');
expect(reminderStatusColor(completedPast, now), AppColors.success);
});
test('时间副行按状态给语义(到期 / 完成于 / 已忽略)', () {
final now = DateTime(2026, 9, 8);
final pending = buildReminder('r-1');
final completed = buildReminder(
'r-2',
overrides: {
'status': 'completed',
'completedAt': '2026-09-02T10:00:00+08:00',
},
);
final dismissed = buildReminder(
'r-3',
overrides: {'status': 'dismissed'},
);
expect(reminderDateLine(pending), startsWith('到期 '));
expect(reminderDateLine(completed), startsWith('完成于 '));
expect(reminderDateLine(dismissed), startsWith('已忽略 · 原到期 '));
expect(reminderStatusTag(dismissed, now), '已忽略');
expect(reminderStatusColor(dismissed, now), AppColors.muted);
});
});
}
@@ -4,6 +4,7 @@ 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/care_reminders_page.dart';
import 'package:patbond_flutter/features/pets/health_events_page.dart';
import 'package:patbond_flutter/features/pets/health_record_display.dart';
import 'package:patbond_flutter/features/pets/pet_detail_page.dart';
@@ -348,4 +349,107 @@ void main() {
expect(find.byIcon(Icons.add), findsNothing);
});
});
group('T2-14 · 健康提醒真实数据驱动(取代 demo 硬编码)', () {
testWidgets('有待办:alert 卡显示最近到期提醒;入口副行显示待办数;点卡进提醒页并回拉', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1');
final due = DateTime.now().add(const Duration(days: 30));
var reminderCalls = 0;
final capturedStatus = <CareReminderStatus?>[];
repository.listCareRemindersHandler = (petId, status) async {
reminderCalls++;
capturedStatus.add(status);
return [
buildReminder(
'r-1',
overrides: {
'title': '已经半年没有进行体内外驱虫',
'reminderType': 'deworming',
'dueAt': due.toUtc().toIso8601String(),
},
),
buildReminder('r-2'),
];
};
await pumpDetail(tester);
await tester.pumpAndSettle();
// alert 卡取 due_at ASC 首条(最近到期)真实数据。
expect(
find.text('健康提醒:已经半年没有进行体内外驱虫(${dateToJson(due)} 到期)'),
findsOneWidget,
);
expect(find.text('2 条待办'), findsOneWidget);
// 详情页只拉待办视图。
expect(capturedStatus.first, CareReminderStatus.pending);
final alert = find.textContaining('健康提醒:');
await tester.ensureVisible(alert);
await tester.tap(alert);
await tester.pumpAndSettle();
expect(find.byType(CareRemindersPage), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
// 返回后重拉待办(详情页 1 次 + 提醒页自身 1 次 + 返回重拉 1 次)。
expect(reminderCalls, 3);
});
testWidgets('逾期待办:alert 卡切警示形态', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1');
repository.listCareRemindersHandler = (petId, status) async => [
buildReminder(
'r-1',
overrides: {
'dueAt': DateTime.now()
.subtract(const Duration(days: 7))
.toUtc()
.toIso8601String(),
},
),
];
await pumpDetail(tester);
await tester.pumpAndSettle();
expect(find.text('健康提醒:年度体检(已逾期)'), findsOneWidget);
});
testWidgets('无待办:不渲染 alert 卡(无 demo 占位),入口副行「暂无待办提醒」', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1');
repository.listCareRemindersHandler = (petId, status) async => const [];
await pumpDetail(tester);
await tester.pumpAndSettle();
expect(find.textContaining('健康提醒:'), findsNothing);
expect(find.text('暂无待办提醒'), findsOneWidget);
});
testWidgets('待办加载失败:主链路不受阻,入口副行降级提示且仍可进提醒页', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1');
var calls = 0;
repository.listCareRemindersHandler = (petId, status) async {
calls++;
if (calls == 1) throw const ApiNetworkException('断网');
return const [];
};
await pumpDetail(tester);
await tester.pumpAndSettle();
expect(find.text('豆豆'), findsOneWidget);
expect(find.text('提醒加载失败,点击查看'), findsOneWidget);
final tile = find.widgetWithText(ListTile, '照护提醒');
await tester.ensureVisible(tile);
await tester.tap(tile);
await tester.pumpAndSettle();
expect(find.byType(CareRemindersPage), findsOneWidget);
});
});
}
@@ -6,6 +6,7 @@ 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_exceptions.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';
@@ -185,4 +186,173 @@ void main() {
expect(find.byIcon(Icons.add), findsNothing);
expect(find.text('登记第一针'), findsNothing);
});
group('T2-14 · 流转动作(25 号报告遗留①②)', () {
testWidgets('标记完成:厂商/批号补录 + 请求形状 + edit_succeeded;动作仅 scheduled 行', (
tester,
) async {
var listCalls = 0;
repository.listVaccinationsHandler = (petId) async {
listCalls++;
return [
buildVaccination('vx-1'),
buildVaccination(
'vx-2',
overrides: {
'doseNo': 2,
'status': 'completed',
'plannedOn': null,
'administeredOn': '2026-06-12',
},
),
];
};
final captured = <(String, Map<String, Object?>)>[];
repository.updateVaccinationHandler = (vaccinationId, request) async {
captured.add((vaccinationId, request.toJson()));
return buildVaccination(
'vx-1',
overrides: {'status': 'completed', 'version': 2},
);
};
await pumpPage(tester);
await tester.pumpAndSettle();
// 动作仅 scheduled 行渲染(completed/cancelled 终态无动作)。
expect(find.text('标记完成'), findsOneWidget);
expect(find.text('取消登记'), findsOneWidget);
await tester.tap(find.text('标记完成'));
await tester.pumpAndSettle();
// 完成对话框:接种日期默认今天;补录厂商/批号(契约可选字段)。
await tester.enterText(
find.widgetWithText(TextFormField, '厂商(可选)'),
'硕腾',
);
await tester.enterText(
find.widgetWithText(TextFormField, '批号(可选)'),
'LOT-2026-09',
);
await tester.tap(find.text('确认完成'));
await tester.pumpAndSettle();
expect(captured.length, 1);
expect(captured[0].$1, 'vx-1');
final json = captured[0].$2;
expect(json['version'], 1);
expect(json['status'], 'completed');
expect(json['administeredOn'], isA<String>());
expect(json['manufacturer'], '硕腾');
expect(json['batchNo'], 'LOT-2026-09');
// 未选下次接种:键缺席。
expect(json.containsKey('nextDueOn'), isFalse);
expect(find.text('已标记完成'), findsOneWidget);
expect(listCalls, 2);
final succeeded = eventsOf('health_record_edit_succeeded');
expect(succeeded.single, {'recordType': 'vaccine', 'fieldCount': 4});
});
testWidgets('取消登记:确认后仅发 version + status=cancelled', (tester) async {
var listCalls = 0;
repository.listVaccinationsHandler = (petId) async {
listCalls++;
return [buildVaccination('vx-1')];
};
final captured = <Map<String, Object?>>[];
repository.updateVaccinationHandler = (vaccinationId, request) async {
captured.add(request.toJson());
return buildVaccination(
'vx-1',
overrides: {'status': 'cancelled', 'version': 2},
);
};
await pumpPage(tester);
await tester.pumpAndSettle();
await tester.tap(find.text('取消登记'));
await tester.pumpAndSettle();
expect(find.text('取消这条登记?'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, '取消登记'));
await tester.pumpAndSettle();
expect(captured.single, {'version': 1, 'status': 'cancelled'});
expect(find.text('已取消登记'), findsOneWidget);
expect(listCalls, 2);
final succeeded = eventsOf('health_record_edit_succeeded');
expect(succeeded.single, {'recordType': 'vaccine', 'fieldCount': 1});
});
testWidgets('40902 并发修改:提示 + 重拉 + edit_failed(conflict)', (tester) async {
var listCalls = 0;
repository.listVaccinationsHandler = (petId) async {
listCalls++;
return [buildVaccination('vx-1')];
};
repository.updateVaccinationHandler = (vaccinationId, request) async {
throw const PetVersionConflictException(message: '数据已被修改');
};
await pumpPage(tester);
await tester.pumpAndSettle();
await tester.tap(find.text('标记完成'));
await tester.pumpAndSettle();
await tester.tap(find.text('确认完成'));
await tester.pumpAndSettle();
expect(find.text('记录已在其他设备被修改,已刷新,请重试'), findsOneWidget);
expect(listCalls, 2);
final failed = eventsOf('health_record_edit_failed');
expect(failed.single, {
'recordType': 'vaccine',
'failureReason': 'conflict',
'errorCode': 40902,
'httpStatus': 409,
});
});
testWidgets('42201 规则兜底:提示核对重试 + edit_failed(validation_error)', (
tester,
) async {
repository.listVaccinationsHandler = (petId) async => [
buildVaccination('vx-1'),
];
repository.updateVaccinationHandler = (vaccinationId, request) async {
throw const VaccinationRuleException(message: '规则违反');
};
await pumpPage(tester);
await tester.pumpAndSettle();
await tester.tap(find.text('标记完成'));
await tester.pumpAndSettle();
await tester.tap(find.text('确认完成'));
await tester.pumpAndSettle();
expect(find.text('接种状态与日期不符合规则,请核对后重试'), findsOneWidget);
final failed = eventsOf('health_record_edit_failed');
expect(failed.single!['failureReason'], 'validation_error');
expect(failed.single!['errorCode'], 42201);
});
testWidgets('viewerscheduled 行也无流转动作', (tester) async {
repository.listVaccinationsHandler = (petId) async => [
buildVaccination('vx-1'),
];
await pumpPage(tester, canWrite: false);
await tester.pumpAndSettle();
expect(find.text('标记完成'), findsNothing);
expect(find.text('取消登记'), findsNothing);
});
});
}