Compare commits
2 Commits
c91f18a845
...
ba503327f5
| Author | SHA1 | Date | |
|---|---|---|---|
| ba503327f5 | |||
| e186ba3da9 |
@@ -1,8 +1,19 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
|
||||||
/// M2 记录类型(05 号规范 §2 五种,「其他」为扩展兜底)。
|
/// M2 记录类型(05 号规范 §2 五种,「其他」为扩展兜底;
|
||||||
enum RecordType { weight, vaccine, deworming, medical, other }
|
/// T2-14 为六类健康事件增补 feeding / grooming / measurement 三型,
|
||||||
|
/// 色族复用 05 §2 已审计的四个色对,仅图标与文案区分——对比度结论不变)。
|
||||||
|
enum RecordType {
|
||||||
|
weight,
|
||||||
|
vaccine,
|
||||||
|
deworming,
|
||||||
|
medical,
|
||||||
|
other,
|
||||||
|
feeding,
|
||||||
|
grooming,
|
||||||
|
measurement,
|
||||||
|
}
|
||||||
|
|
||||||
/// 类型 → 图标 + 三色(dot 底 8% 淡染基色 / 图标色 / 文字标签色)+ 文案的
|
/// 类型 → 图标 + 三色(dot 底 8% 淡染基色 / 图标色 / 文字标签色)+ 文案的
|
||||||
/// 唯一映射出口(05 §3.2:映射只存在于本文件一处,杜绝散落硬编码)。
|
/// 唯一映射出口(05 §3.2:映射只存在于本文件一处,杜绝散落硬编码)。
|
||||||
@@ -69,6 +80,28 @@ const Map<RecordType, RecordTypeStyle> recordTypeStyles = {
|
|||||||
inkColor: AppColors.inkSoft,
|
inkColor: AppColors.inkSoft,
|
||||||
label: '其他',
|
label: '其他',
|
||||||
),
|
),
|
||||||
|
// —— T2-14 健康事件增补(色族复用已审计色对)——
|
||||||
|
RecordType.feeding: RecordTypeStyle(
|
||||||
|
icon: Icons.restaurant_outlined,
|
||||||
|
baseColor: AppColors.success,
|
||||||
|
iconColor: AppColors.successInk,
|
||||||
|
inkColor: AppColors.successInk,
|
||||||
|
label: '喂养',
|
||||||
|
),
|
||||||
|
RecordType.grooming: RecordTypeStyle(
|
||||||
|
icon: Icons.content_cut,
|
||||||
|
baseColor: AppColors.accent,
|
||||||
|
iconColor: AppColors.accentDark,
|
||||||
|
inkColor: AppColors.accentDark,
|
||||||
|
label: '洗护',
|
||||||
|
),
|
||||||
|
RecordType.measurement: RecordTypeStyle(
|
||||||
|
icon: Icons.straighten_outlined,
|
||||||
|
baseColor: AppColors.primary,
|
||||||
|
iconColor: AppColors.primaryStrong,
|
||||||
|
inkColor: AppColors.primaryDark,
|
||||||
|
label: '测量',
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 圆标尺寸档(05 §3.2)。图标尺寸 = dot 的 50%。
|
/// 圆标尺寸档(05 §3.2)。图标尺寸 = dot 的 50%。
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 必带 completedAt,pending→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('确认完成'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
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/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/money.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):顶层短路径 `PATCH /health-events/{id}`。
|
||||||
|
///
|
||||||
|
/// - 契约:仅 title / notes / amountCents 可编辑;eventType / occurredAt
|
||||||
|
/// 为条目身份,静态展示不可改;不支持清空回 null(清空输入视为不变更)。
|
||||||
|
/// - 差量提交:只发送改动字段 + version;无变更不发 PATCH 直接返回。
|
||||||
|
/// - 40902 版本冲突照 T2-12 模式:明确提示 + 自动取最新版本更新乐观锁
|
||||||
|
/// 基线(保留用户输入),用户核对后重新保存。契约无按 id 读取端点,
|
||||||
|
/// 最新版本经时间线分页检索取回。
|
||||||
|
/// - 埋点:health_record_edit_succeeded(fieldCount) / edit_failed
|
||||||
|
/// (failureReason 含 conflict,recordType=health_event)。
|
||||||
|
class HealthEventEditPage extends StatefulWidget {
|
||||||
|
const HealthEventEditPage({
|
||||||
|
required this.repository,
|
||||||
|
required this.event,
|
||||||
|
super.key,
|
||||||
|
this.analytics,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PetsRepository repository;
|
||||||
|
|
||||||
|
/// 编辑基线(含 version 乐观锁与差量比较基准)。
|
||||||
|
final HealthEvent event;
|
||||||
|
|
||||||
|
final HealthRecordAnalytics? analytics;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HealthEventEditPage> createState() => _HealthEventEditPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HealthEventEditPageState extends State<HealthEventEditPage> {
|
||||||
|
late final TextEditingController _titleCtrl;
|
||||||
|
late final TextEditingController _amountCtrl;
|
||||||
|
late final TextEditingController _notesCtrl;
|
||||||
|
|
||||||
|
/// 编辑基线:40902 冲突刷新后更新(version 与差量计算的比较基准)。
|
||||||
|
late HealthEvent _base;
|
||||||
|
|
||||||
|
String? _titleError;
|
||||||
|
String? _amountError;
|
||||||
|
String? _formError;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_base = widget.event;
|
||||||
|
_titleCtrl = TextEditingController(text: _base.title);
|
||||||
|
_amountCtrl = TextEditingController(
|
||||||
|
text: _base.amountCents == null
|
||||||
|
? ''
|
||||||
|
: formatCentsAsYuan(_base.amountCents!),
|
||||||
|
);
|
||||||
|
_notesCtrl = TextEditingController(text: _base.notes ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleCtrl.dispose();
|
||||||
|
_amountCtrl.dispose();
|
||||||
|
_notesCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
|
||||||
|
widget.analytics?.editFailed(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
reason: reason,
|
||||||
|
errorCode: errorCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFormError(String message) {
|
||||||
|
setState(() => _formError = message);
|
||||||
|
SemanticsService.sendAnnouncement(
|
||||||
|
View.of(context),
|
||||||
|
message,
|
||||||
|
TextDirection.ltr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 差量请求:只含改动字段(契约不支持清空回 null——清空输入视为不变更)。
|
||||||
|
/// 无实际变更返回 null。
|
||||||
|
UpdateHealthEventRequest? _buildDiff() {
|
||||||
|
final title = _titleCtrl.text.trim();
|
||||||
|
final notes = _notesCtrl.text.trim();
|
||||||
|
final amountText = _amountCtrl.text.trim();
|
||||||
|
final amountCents = amountText.isEmpty
|
||||||
|
? null
|
||||||
|
: parseYuanToCents(amountText);
|
||||||
|
final request = UpdateHealthEventRequest(
|
||||||
|
version: _base.version,
|
||||||
|
title: title != _base.title ? title : null,
|
||||||
|
notes: notes.isNotEmpty && notes != (_base.notes ?? '') ? notes : null,
|
||||||
|
amountCents: amountCents != null && amountCents != _base.amountCents
|
||||||
|
? amountCents
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
// 只剩 version 一个键 → 无实际变更。
|
||||||
|
return request.toJson().length == 1 ? null : request;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
final titleError = _titleCtrl.text.trim().isEmpty ? '标题不能为空' : null;
|
||||||
|
final amountText = _amountCtrl.text.trim();
|
||||||
|
final amountError =
|
||||||
|
amountText.isNotEmpty && parseYuanToCents(amountText) == null
|
||||||
|
? '金额格式不正确,最多两位小数'
|
||||||
|
: null;
|
||||||
|
if (titleError != null || amountError != null) {
|
||||||
|
setState(() {
|
||||||
|
_titleError = titleError;
|
||||||
|
_amountError = amountError;
|
||||||
|
});
|
||||||
|
_trackFailed(HealthRecordFailureReason.validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final request = _buildDiff();
|
||||||
|
if (request == null) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_submitting = true;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final updated = await widget.repository.updateHealthEvent(
|
||||||
|
_base.id,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
widget.analytics?.editSucceeded(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
fieldCount: request.toJson().length - 1,
|
||||||
|
);
|
||||||
|
if (mounted) Navigator.of(context).pop(updated);
|
||||||
|
} on PetVersionConflictException {
|
||||||
|
_trackFailed(HealthRecordFailureReason.conflict, 40902);
|
||||||
|
await _handleVersionConflict();
|
||||||
|
} on PetRecordNotFoundException {
|
||||||
|
if (!mounted) return;
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('记录不存在或已被删除')));
|
||||||
|
_trackFailed(HealthRecordFailureReason.notFound, 40402);
|
||||||
|
navigator.pop();
|
||||||
|
} on PetAccessDeniedException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('你没有权限修改该记录');
|
||||||
|
_trackFailed(HealthRecordFailureReason.permissionDenied, 40300);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40902:提示 + 刷新路径——契约无按 id 读取端点,经时间线分页检索
|
||||||
|
/// 取回最新版本更新乐观锁基线(保留用户输入),用户核对后重新保存。
|
||||||
|
Future<void> _handleVersionConflict() async {
|
||||||
|
try {
|
||||||
|
final fresh = await _fetchLatest();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (fresh == null) {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('记录不存在或已被删除')));
|
||||||
|
navigator.pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _base = fresh);
|
||||||
|
_showFormError('记录已在其他设备被修改,已获取最新版本,请核对后重新保存');
|
||||||
|
} on ApiException {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showFormError('记录已在其他设备被修改,请返回后刷新重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按 occurred_at DESC 分页检索本记录(上限 10 页防御性截断)。
|
||||||
|
Future<HealthEvent?> _fetchLatest() async {
|
||||||
|
String? cursor;
|
||||||
|
for (var page = 0; page < 10; page++) {
|
||||||
|
final result = await widget.repository.listHealthEvents(
|
||||||
|
_base.petId,
|
||||||
|
limit: 50,
|
||||||
|
cursor: cursor,
|
||||||
|
);
|
||||||
|
for (final item in result.items) {
|
||||||
|
if (item.id == _base.id) return item;
|
||||||
|
}
|
||||||
|
if (!result.hasMore) return null;
|
||||||
|
cursor = result.nextCursor;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final style = recordTypeStyles[recordTypeForHealthEvent(_base.eventType)]!;
|
||||||
|
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: [
|
||||||
|
// 事件身份静态区:类型与发生时间不可编辑(契约不在请求体)。
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
RecordTypeDot(
|
||||||
|
type: recordTypeForHealthEvent(_base.eventType),
|
||||||
|
size: RecordTypeDotSize.md,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
healthEventTypeLabel(_base.eventType),
|
||||||
|
style: TextStyle(
|
||||||
|
color: style.inkColor,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
formatOccurredAt(_base.occurredAt),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
AppTextField(
|
||||||
|
label: '标题',
|
||||||
|
controller: _titleCtrl,
|
||||||
|
prefixIcon: Icons.title_outlined,
|
||||||
|
errorText: _titleError,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_titleError != null || _formError != null) {
|
||||||
|
setState(() {
|
||||||
|
_titleError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
AppTextField(
|
||||||
|
label: '金额(元)',
|
||||||
|
controller: _amountCtrl,
|
||||||
|
prefixIcon: Icons.payments_outlined,
|
||||||
|
errorText: _amountError,
|
||||||
|
helperText: '以元填写,最多两位小数;清空视为不变更',
|
||||||
|
enabled: !_submitting,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
|
decimal: true,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_amountError != null || _formError != null) {
|
||||||
|
setState(() {
|
||||||
|
_amountError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
AppTextField(
|
||||||
|
label: '备注',
|
||||||
|
controller: _notesCtrl,
|
||||||
|
prefixIcon: Icons.sticky_note_2_outlined,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
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,416 @@
|
|||||||
|
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/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/money.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)。
|
||||||
|
///
|
||||||
|
/// - 六类事件类型选择(05 §4.4 类型选择器形态:RecordTypeDot sm + 标签,
|
||||||
|
/// 图标 + 文字双通道);类型必选。
|
||||||
|
/// - 发生时间默认「现在」,可回选日期(非今日取当日 12:00,与体重表单
|
||||||
|
/// 同一约定);提交前转 UTC,ISO 8601 带 Z 上送。
|
||||||
|
/// - 金额可选:**UI 以元录入/展示,传输为整数分**(money.dart 换算,
|
||||||
|
/// 最多两位小数,非法拦截);后端提交小数 400/40000 兜底。
|
||||||
|
/// - 埋点:health_record_create_started/succeeded/failed
|
||||||
|
/// (recordType=health_event)。
|
||||||
|
class HealthEventFormPage extends StatefulWidget {
|
||||||
|
const HealthEventFormPage({
|
||||||
|
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<HealthEventFormPage> createState() => _HealthEventFormPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HealthEventFormPageState extends State<HealthEventFormPage> {
|
||||||
|
final _titleCtrl = TextEditingController();
|
||||||
|
final _amountCtrl = TextEditingController();
|
||||||
|
final _notesCtrl = TextEditingController();
|
||||||
|
|
||||||
|
HealthEventType? _type;
|
||||||
|
DateTime _occurredDate = DateTime.now();
|
||||||
|
String? _typeError;
|
||||||
|
String? _titleError;
|
||||||
|
String? _amountError;
|
||||||
|
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();
|
||||||
|
_amountCtrl.dispose();
|
||||||
|
_notesCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markStarted() {
|
||||||
|
if (_startedFired) return;
|
||||||
|
_startedFired = true;
|
||||||
|
widget.analytics?.createStarted(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
entryPoint: widget.entryPoint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
|
||||||
|
widget.analytics?.createFailed(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
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 _occurredAt() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final sameDay =
|
||||||
|
_occurredDate.year == now.year &&
|
||||||
|
_occurredDate.month == now.month &&
|
||||||
|
_occurredDate.day == now.day;
|
||||||
|
final local = sameDay
|
||||||
|
? now
|
||||||
|
: DateTime(
|
||||||
|
_occurredDate.year,
|
||||||
|
_occurredDate.month,
|
||||||
|
_occurredDate.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 amountText = _amountCtrl.text.trim();
|
||||||
|
final amountError =
|
||||||
|
amountText.isNotEmpty && parseYuanToCents(amountText) == null
|
||||||
|
? '金额格式不正确,最多两位小数'
|
||||||
|
: null;
|
||||||
|
if (typeError != null || titleError != null || amountError != null) {
|
||||||
|
setState(() {
|
||||||
|
_typeError = typeError;
|
||||||
|
_titleError = titleError;
|
||||||
|
_amountError = amountError;
|
||||||
|
});
|
||||||
|
_trackFailed(HealthRecordFailureReason.validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_submitting = true;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final notes = _notesCtrl.text.trim();
|
||||||
|
final record = await widget.repository.createHealthEvent(
|
||||||
|
widget.petId,
|
||||||
|
CreateHealthEventRequest(
|
||||||
|
eventType: _type!,
|
||||||
|
occurredAt: _occurredAt(),
|
||||||
|
title: _titleCtrl.text.trim(),
|
||||||
|
notes: notes.isEmpty ? null : notes,
|
||||||
|
// 元 → 整数分换算,DTO 层保持整数分(开发计划 §4.3)。
|
||||||
|
amountCents: amountText.isEmpty ? null : parseYuanToCents(amountText),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
widget.analytics?.createSucceeded(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
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: [
|
||||||
|
const Text(
|
||||||
|
'事件类型',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_typeSelector(),
|
||||||
|
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.title_outlined,
|
||||||
|
errorText: _titleError,
|
||||||
|
enabled: !_submitting,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
_markStarted();
|
||||||
|
if (_titleError != null || _formError != null) {
|
||||||
|
setState(() {
|
||||||
|
_titleError = 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(_occurredDate),
|
||||||
|
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: _occurredDate,
|
||||||
|
firstDate: DateTime(1990),
|
||||||
|
lastDate: now,
|
||||||
|
);
|
||||||
|
if (value != null && mounted) {
|
||||||
|
_markStarted();
|
||||||
|
setState(() => _occurredDate = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
AppTextField(
|
||||||
|
label: '金额(元,可选)',
|
||||||
|
controller: _amountCtrl,
|
||||||
|
prefixIcon: Icons.payments_outlined,
|
||||||
|
errorText: _amountError,
|
||||||
|
helperText: '以元填写,最多两位小数,如 128.50',
|
||||||
|
enabled: !_submitting,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
|
decimal: true,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) {
|
||||||
|
_markStarted();
|
||||||
|
if (_amountError != null || _formError != null) {
|
||||||
|
setState(() {
|
||||||
|
_amountError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
AppTextField(
|
||||||
|
label: '备注(可选)',
|
||||||
|
controller: _notesCtrl,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 六类类型选择器(05 §4.4:RecordTypeDot sm 上、标签下;
|
||||||
|
/// 选中 surfaceTint 底 + primaryDark 标签,未选中 inkSoft)。
|
||||||
|
Widget _typeSelector() {
|
||||||
|
return Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final type in HealthEventType.values)
|
||||||
|
_TypeUnit(
|
||||||
|
type: type,
|
||||||
|
selected: _type == type,
|
||||||
|
enabled: !_submitting,
|
||||||
|
onTap: () {
|
||||||
|
_markStarted();
|
||||||
|
setState(() {
|
||||||
|
_type = type;
|
||||||
|
_typeError = null;
|
||||||
|
_formError = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TypeUnit extends StatelessWidget {
|
||||||
|
const _TypeUnit({
|
||||||
|
required this.type,
|
||||||
|
required this.selected,
|
||||||
|
required this.enabled,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final HealthEventType type;
|
||||||
|
final bool selected;
|
||||||
|
final bool enabled;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: selected ? AppColors.surfaceTint : AppColors.surface,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: BorderSide(
|
||||||
|
color: selected ? AppColors.primaryStrong : AppColors.border,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: enabled ? onTap : null,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(minWidth: 64, minHeight: 52),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
RecordTypeDot(
|
||||||
|
type: recordTypeForHealthEvent(type),
|
||||||
|
size: RecordTypeDotSize.sm,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
healthEventTypeLabel(type),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
|
||||||
|
color: selected ? AppColors.primaryDark : AppColors.inkSoft,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
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_event_edit_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_event_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/money.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/widgets/common.dart';
|
||||||
|
|
||||||
|
enum _ListPhase { loading, ready, error }
|
||||||
|
|
||||||
|
/// 健康事件时间线页(T2-14):cursor 分页(occurred_at DESC,
|
||||||
|
/// 「加载更多」追加,末页收起),按月分组(05 §4.2 组头),四态齐备。
|
||||||
|
///
|
||||||
|
/// - 六类事件经 [recordTypeForHealthEvent] 映射 RecordTypeDot,
|
||||||
|
/// 类型标签 TagPill 双通道呈现;金额以元展示(传输为整数分)。
|
||||||
|
/// - 录入经 [HealthEventFormPage];成功后重拉首页(排序与月分组以
|
||||||
|
/// 服务端为准,不本地猜位置)。
|
||||||
|
/// - 编辑经 [HealthEventEditPage](canWrite 点条目进入);成功就地替换。
|
||||||
|
/// - 曝光埋点:每次进入首个成功加载上报一次
|
||||||
|
/// `health_record_viewed(recordType=health_event, source=pet_detail)`。
|
||||||
|
class HealthEventsPage extends StatefulWidget {
|
||||||
|
const HealthEventsPage({
|
||||||
|
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<HealthEventsPage> createState() => _HealthEventsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HealthEventsPageState extends State<HealthEventsPage> {
|
||||||
|
_ListPhase _phase = _ListPhase.loading;
|
||||||
|
List<HealthEvent> _events = 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.listHealthEvents(
|
||||||
|
widget.petId,
|
||||||
|
limit: widget.pageSize,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_events = page.items;
|
||||||
|
_nextCursor = page.nextCursor;
|
||||||
|
_hasMore = page.hasMore;
|
||||||
|
_phase = _ListPhase.ready;
|
||||||
|
});
|
||||||
|
if (!_viewedFired) {
|
||||||
|
_viewedFired = true;
|
||||||
|
widget.analytics?.viewed(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
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.listHealthEvents(
|
||||||
|
widget.petId,
|
||||||
|
limit: widget.pageSize,
|
||||||
|
cursor: _nextCursor,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_events = [..._events, ...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<HealthEvent>(
|
||||||
|
fadePageRoute(
|
||||||
|
HealthEventFormPage(
|
||||||
|
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) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('已记录健康事件')));
|
||||||
|
// occurred_at DESC + 月分组:补录历史日期的位置由服务端定,
|
||||||
|
// 重拉首页而非本地猜位置。
|
||||||
|
await _loadFirstPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openEdit(HealthEvent event) async {
|
||||||
|
final updated = await Navigator.of(context).push<HealthEvent>(
|
||||||
|
// 编辑页不带路由名:record_form 专属创建漏斗到达段(T2-12 先例)。
|
||||||
|
fadePageRoute(
|
||||||
|
HealthEventEditPage(
|
||||||
|
repository: widget.repository,
|
||||||
|
event: event,
|
||||||
|
analytics: widget.analytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (updated != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
// occurredAt 不可编辑 → 排序/分组位置不变,就地替换安全。
|
||||||
|
_events = [
|
||||||
|
for (final item in _events)
|
||||||
|
if (item.id == updated.id) updated else item,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
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 _events.isEmpty => Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: EmptyStateIllustration(
|
||||||
|
icon: Icons.event_note_outlined,
|
||||||
|
title: '还没有健康记录',
|
||||||
|
description: '就医、驱虫、洗护……随手记下毛孩子的健康点滴',
|
||||||
|
ctaLabel: widget.canWrite ? '记录第一条' : null,
|
||||||
|
onCtaPressed: widget.canWrite ? _openCreate : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ListPhase.ready => _list(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按月分组渲染:服务端 occurred_at DESC 保证同月相邻,
|
||||||
|
/// 月份变化处插组头(05 §4.2「2026 年 9 月」)。
|
||||||
|
Widget _list() {
|
||||||
|
final children = <Widget>[];
|
||||||
|
String? currentMonth;
|
||||||
|
for (final event in _events) {
|
||||||
|
final month = healthEventMonthHeader(event.occurredAt);
|
||||||
|
if (month != currentMonth) {
|
||||||
|
currentMonth = month;
|
||||||
|
children.add(
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(top: children.isEmpty ? 0 : 14, bottom: 8),
|
||||||
|
child: Text(
|
||||||
|
month,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
children
|
||||||
|
..add(
|
||||||
|
_HealthEventTile(
|
||||||
|
event: event,
|
||||||
|
onTap: widget.canWrite ? () => _openEdit(event) : null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(const SizedBox(height: 10));
|
||||||
|
}
|
||||||
|
if (_hasMore) {
|
||||||
|
children.add(
|
||||||
|
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('加载更多')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _loadFirstPage,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 30),
|
||||||
|
children: children,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 时间线条目(05 §3.3 形态):RecordTypeDot(六类映射) + 标题 +
|
||||||
|
/// 日期/备注副行;尾部类型 TagPill(双通道)与金额(元展示,
|
||||||
|
/// 15/w800 类型文字色)。
|
||||||
|
class _HealthEventTile extends StatelessWidget {
|
||||||
|
const _HealthEventTile({required this.event, this.onTap});
|
||||||
|
|
||||||
|
final HealthEvent event;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final recordType = recordTypeForHealthEvent(event.eventType);
|
||||||
|
final style = recordTypeStyles[recordType]!;
|
||||||
|
final meta = StringBuffer(formatOccurredAt(event.occurredAt));
|
||||||
|
if (event.notes != null && event.notes!.isNotEmpty) {
|
||||||
|
meta.write(' · ${event.notes}');
|
||||||
|
}
|
||||||
|
return Card(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
RecordTypeDot(type: recordType, size: RecordTypeDotSize.md),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
event.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
meta.toString(),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TagPill(
|
||||||
|
healthEventTypeLabel(event.eventType),
|
||||||
|
color: style.baseColor,
|
||||||
|
),
|
||||||
|
if (event.amountCents != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'¥${formatCentsAsYuan(event.amountCents!)}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: style.inkColor,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,9 @@ import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
|||||||
/// 已扩充就绪,24 号报告 §2.2)。沿用 13 号规范 §3.1 惯例:枚举编译期
|
/// 已扩充就绪,24 号报告 §2.2)。沿用 13 号规范 §3.1 惯例:枚举编译期
|
||||||
/// 锁死,业务代码禁止手拼事件名与属性。
|
/// 锁死,业务代码禁止手拼事件名与属性。
|
||||||
///
|
///
|
||||||
/// T2-13 挂接创建漏斗三事件 + viewed;edit/deleted 事件的挂接随
|
/// T2-13 挂接创建漏斗三事件 + viewed;T2-14 补挂 edit_succeeded/failed
|
||||||
/// 编辑/删除交互落地(「标记完成」等)另行接线,见 25 号报告遗留。
|
/// (健康事件编辑、疫苗标记完成/取消)。`health_record_deleted` 因 M2
|
||||||
|
/// 契约无删除端点暂无挂接点,留待删除交互落地。
|
||||||
|
|
||||||
/// 记录类型(06 §1.4 recordType 枚举,四类记录接口对应)。
|
/// 记录类型(06 §1.4 recordType 枚举,四类记录接口对应)。
|
||||||
enum HealthRecordType {
|
enum HealthRecordType {
|
||||||
@@ -30,7 +31,8 @@ enum HealthRecordEntryPoint {
|
|||||||
final String value;
|
final String value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建失败原因(06 §1.4 基底 + M2 验收新增三值)。与 pet 域同款
|
/// 创建/编辑失败原因(06 §1.4 基底 + M2 验收新增三值;`conflict`
|
||||||
|
/// 仅编辑链路会出现——40902 乐观锁/条件更新守卫落空)。与 pet 域同款
|
||||||
/// 网络归并口径:断网/超时/5xx 均并入 network_error,server_error
|
/// 网络归并口径:断网/超时/5xx 均并入 network_error,server_error
|
||||||
/// 保留给无法归类的兜底。
|
/// 保留给无法归类的兜底。
|
||||||
enum HealthRecordFailureReason {
|
enum HealthRecordFailureReason {
|
||||||
@@ -39,7 +41,8 @@ enum HealthRecordFailureReason {
|
|||||||
notFound('not_found'),
|
notFound('not_found'),
|
||||||
rateLimited('rate_limited'),
|
rateLimited('rate_limited'),
|
||||||
networkError('network_error'),
|
networkError('network_error'),
|
||||||
serverError('server_error');
|
serverError('server_error'),
|
||||||
|
conflict('conflict');
|
||||||
|
|
||||||
const HealthRecordFailureReason(this.value);
|
const HealthRecordFailureReason(this.value);
|
||||||
|
|
||||||
@@ -122,4 +125,34 @@ class HealthRecordAnalytics {
|
|||||||
'source': source.value,
|
'source': source.value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 编辑保存成功响应(06 §1.4:编辑不设 started)。
|
||||||
|
///
|
||||||
|
/// [fieldCount] 为本次变更字段数(差量 PATCH 的键数,不含 version)。
|
||||||
|
void editSucceeded({
|
||||||
|
required HealthRecordType recordType,
|
||||||
|
required int fieldCount,
|
||||||
|
}) {
|
||||||
|
_track('health_record_edit_succeeded', {
|
||||||
|
'recordType': recordType.value,
|
||||||
|
'fieldCount': fieldCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编辑保存失败(失败原因含 `conflict`——40902 版本/状态守卫冲突,
|
||||||
|
/// M2 验收「并发冲突明确」场景的数据面)。属性集无 attemptSeq
|
||||||
|
/// (白名单对齐 06 §1.5)。
|
||||||
|
void editFailed({
|
||||||
|
required HealthRecordType recordType,
|
||||||
|
required HealthRecordFailureReason reason,
|
||||||
|
int? errorCode,
|
||||||
|
}) {
|
||||||
|
_track('health_record_edit_failed', {
|
||||||
|
'recordType': recordType.value,
|
||||||
|
'failureReason': reason.value,
|
||||||
|
'errorCode': ?errorCode,
|
||||||
|
if (errorCode != null && errorCode >= 10000)
|
||||||
|
'httpStatus': errorCode ~/ 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
/// 体重 / 疫苗展示与输入解析的纯函数集合(列表 / 表单共用,可单测)。
|
/// 体重 / 疫苗 / 健康事件 / 提醒展示与输入解析的纯函数集合
|
||||||
|
/// (列表 / 表单共用,可单测)。
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||||
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
|
||||||
/// 体重输入解析:契约区间 (0, 500]、最多两位小数(numeric(6,2))。
|
/// 体重输入解析:契约区间 (0, 500]、最多两位小数(numeric(6,2))。
|
||||||
@@ -84,3 +86,105 @@ String? vaccinationDateRuleError({
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 健康事件(T2-14)----
|
||||||
|
|
||||||
|
/// 契约六类健康事件 → 视觉记录类型(RecordTypeDot 映射唯一出口)。
|
||||||
|
/// note(随手记)归入「其他」视觉族;其余五类各有专属图标。
|
||||||
|
RecordType recordTypeForHealthEvent(HealthEventType type) => switch (type) {
|
||||||
|
HealthEventType.medical => RecordType.medical,
|
||||||
|
HealthEventType.feeding => RecordType.feeding,
|
||||||
|
HealthEventType.deworming => RecordType.deworming,
|
||||||
|
HealthEventType.grooming => RecordType.grooming,
|
||||||
|
HealthEventType.measurement => RecordType.measurement,
|
||||||
|
HealthEventType.note => RecordType.other,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 六类事件中文文案(类型选择器 / 条目标签共用;
|
||||||
|
/// 图标 + 文字双通道,不单靠颜色区分)。
|
||||||
|
String healthEventTypeLabel(HealthEventType type) => switch (type) {
|
||||||
|
HealthEventType.medical => '就医',
|
||||||
|
HealthEventType.feeding => '喂养',
|
||||||
|
HealthEventType.deworming => '驱虫',
|
||||||
|
HealthEventType.grooming => '洗护',
|
||||||
|
HealthEventType.measurement => '测量',
|
||||||
|
HealthEventType.note => '随手记',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 事件发生时刻展示:本地时区 `YYYY-MM-DD HH:mm`。
|
||||||
|
String formatOccurredAt(DateTime occurredAt) {
|
||||||
|
final local = occurredAt.toLocal();
|
||||||
|
final h = local.hour.toString().padLeft(2, '0');
|
||||||
|
final min = local.minute.toString().padLeft(2, '0');
|
||||||
|
return '${dateToJson(local)} $h:$min';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 时间线月分组组头(05 §4.2:「2026 年 9 月」),按本地时区归月。
|
||||||
|
String healthEventMonthHeader(DateTime occurredAt) {
|
||||||
|
final local = occurredAt.toLocal();
|
||||||
|
return '${local.year} 年 ${local.month} 月';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设备时区 → summary `tz` 参数(契约接受固定偏移形如 `+08:00`;
|
||||||
|
/// Flutter 无 IANA 名可取,固定偏移语义等价——只作用于月度窗口)。
|
||||||
|
String tzOffsetQueryValue(Duration offset) {
|
||||||
|
final sign = offset.isNegative ? '-' : '+';
|
||||||
|
final abs = offset.abs();
|
||||||
|
final h = abs.inHours.toString().padLeft(2, '0');
|
||||||
|
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)}',
|
||||||
|
};
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ 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/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_analytics.dart';
|
||||||
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/money.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';
|
||||||
@@ -22,6 +25,8 @@ enum _DetailPhase { loading, ready, error, notFound }
|
|||||||
|
|
||||||
enum _SummaryPhase { loading, ready, error }
|
enum _SummaryPhase { loading, ready, error }
|
||||||
|
|
||||||
|
enum _RemindersPhase { loading, ready, error }
|
||||||
|
|
||||||
/// 宠物详情页(T2-12 / 05 号规范 §4.2 P2 的档案信息部分)。
|
/// 宠物详情页(T2-12 / 05 号规范 §4.2 P2 的档案信息部分)。
|
||||||
///
|
///
|
||||||
/// 打开即用控制器内存副本首屏渲染,同时经 [PetsController.getPet]
|
/// 打开即用控制器内存副本首屏渲染,同时经 [PetsController.getPet]
|
||||||
@@ -57,6 +62,9 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
_SummaryPhase _summaryPhase = _SummaryPhase.loading;
|
_SummaryPhase _summaryPhase = _SummaryPhase.loading;
|
||||||
PetSummary? _summary;
|
PetSummary? _summary;
|
||||||
|
|
||||||
|
_RemindersPhase _remindersPhase = _RemindersPhase.loading;
|
||||||
|
List<CareReminder> _pendingReminders = const [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -64,6 +72,7 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
if (_pet != null) _phase = _DetailPhase.ready;
|
if (_pet != null) _phase = _DetailPhase.ready;
|
||||||
_load();
|
_load();
|
||||||
_loadSummary();
|
_loadSummary();
|
||||||
|
_loadPendingReminders();
|
||||||
}
|
}
|
||||||
|
|
||||||
Pet? _fromController() {
|
Pet? _fromController() {
|
||||||
@@ -104,11 +113,14 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 摘要实时聚合(40401 由主链路 notFound 态承载,摘要只降级为 error 态)。
|
/// 摘要实时聚合(40401 由主链路 notFound 态承载,摘要只降级为 error 态)。
|
||||||
|
/// `tz` 透传设备时区固定偏移(T2-13 遗留③):monthlyExpense 的月度
|
||||||
|
/// 窗口随设备时区取边界,与用户直觉一致。
|
||||||
Future<void> _loadSummary() async {
|
Future<void> _loadSummary() async {
|
||||||
setState(() => _summaryPhase = _SummaryPhase.loading);
|
setState(() => _summaryPhase = _SummaryPhase.loading);
|
||||||
try {
|
try {
|
||||||
final summary = await widget.controller.repository.getPetSummary(
|
final summary = await widget.controller.repository.getPetSummary(
|
||||||
widget.petId,
|
widget.petId,
|
||||||
|
tz: tzOffsetQueryValue(DateTime.now().timeZoneOffset),
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -121,6 +133,27 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 待办提醒(?status=pending,due_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 {
|
Future<void> _openEdit() async {
|
||||||
final pet = _pet;
|
final pet = _pet;
|
||||||
if (pet == null) return;
|
if (pet == null) return;
|
||||||
@@ -185,6 +218,36 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
if (mounted) await _loadSummary();
|
if (mounted) await _loadSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openTimeline(Pet pet) async {
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
fadePageRoute(
|
||||||
|
HealthEventsPage(
|
||||||
|
repository: widget.controller.repository,
|
||||||
|
petId: pet.id,
|
||||||
|
canWrite: _canWriteRecords,
|
||||||
|
analytics: widget.healthAnalytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// 事件可能已变化:返回即重拉摘要(月度花费实时聚合)。
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -271,6 +334,8 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
Text('健康数据', style: Theme.of(context).textTheme.titleLarge),
|
Text('健康数据', style: Theme.of(context).textTheme.titleLarge),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_summarySection(pet),
|
_summarySection(pet),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_recordsSection(pet),
|
||||||
const SizedBox(height: 24),
|
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),
|
||||||
@@ -316,7 +381,76 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 数据卡行(05 §4.2 stat-row):三卡取数全部来自 summary 实时聚合,
|
/// 记录导航区:健康时间线与照护提醒入口(T2-14)。待办提醒非空时
|
||||||
|
/// 上方渲染真实数据驱动的「健康提醒」卡(正典 alert-card 形态,
|
||||||
|
/// 取代 demo 硬编码文案),点卡与点入口同去提醒页。
|
||||||
|
Widget _recordsSection(Pet 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 数据卡行(05 §4.2 stat-row):四卡取数全部来自 summary 实时聚合,
|
||||||
/// 不落任何本地展示字符串(第 4.3 节红线)。null 语义 → 空态文案。
|
/// 不落任何本地展示字符串(第 4.3 节红线)。null 语义 → 空态文案。
|
||||||
Widget _summarySection(Pet pet) {
|
Widget _summarySection(Pet pet) {
|
||||||
switch (_summaryPhase) {
|
switch (_summaryPhase) {
|
||||||
@@ -348,13 +482,15 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
final weight = summary.latestWeight;
|
final weight = summary.latestWeight;
|
||||||
final progress = summary.vaccinationProgress;
|
final progress = summary.vaccinationProgress;
|
||||||
final next = summary.nextVaccination;
|
final next = summary.nextVaccination;
|
||||||
|
final expense = summary.monthlyExpense;
|
||||||
return IntrinsicHeight(
|
return IntrinsicHeight(
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _SummaryCard(
|
child: _SummaryCard(
|
||||||
type: RecordType.weight,
|
icon: recordTypeStyles[RecordType.weight]!.icon,
|
||||||
|
iconColor: recordTypeStyles[RecordType.weight]!.iconColor,
|
||||||
value: weight == null
|
value: weight == null
|
||||||
? '暂无记录'
|
? '暂无记录'
|
||||||
: '${formatWeightKg(weight.weightKg)} kg',
|
: '${formatWeightKg(weight.weightKg)} kg',
|
||||||
@@ -366,7 +502,8 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _SummaryCard(
|
child: _SummaryCard(
|
||||||
type: RecordType.vaccine,
|
icon: recordTypeStyles[RecordType.vaccine]!.icon,
|
||||||
|
iconColor: recordTypeStyles[RecordType.vaccine]!.iconColor,
|
||||||
// 契约:totalDoses=0 → 整体 null(不是 0/0)→ 空态文案。
|
// 契约:totalDoses=0 → 整体 null(不是 0/0)→ 空态文案。
|
||||||
value: progress == null
|
value: progress == null
|
||||||
? '未登记'
|
? '未登记'
|
||||||
@@ -379,13 +516,26 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _SummaryCard(
|
child: _SummaryCard(
|
||||||
type: RecordType.vaccine,
|
icon: recordTypeStyles[RecordType.vaccine]!.icon,
|
||||||
|
iconColor: recordTypeStyles[RecordType.vaccine]!.iconColor,
|
||||||
value: next == null ? '暂无安排' : dateToJson(next.dueOn),
|
value: next == null ? '暂无安排' : dateToJson(next.dueOn),
|
||||||
emphasized: next != null,
|
emphasized: next != null,
|
||||||
label: next == null ? '下一针' : '下一针·${next.vaccineName}',
|
label: next == null ? '下一针' : '下一针·${next.vaccineName}',
|
||||||
onTap: () => _openVaccinations(pet),
|
onTap: () => _openVaccinations(pet),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: _SummaryCard(
|
||||||
|
icon: Icons.payments_outlined,
|
||||||
|
iconColor: AppColors.primaryStrong,
|
||||||
|
// monthlyExpense 恒非 null(契约);金额整数分 → 元展示。
|
||||||
|
value: '¥${formatCentsAsYuan(expense.amountCents)}',
|
||||||
|
emphasized: expense.amountCents > 0,
|
||||||
|
label: '本月花费',
|
||||||
|
onTap: () => _openTimeline(pet),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -393,18 +543,20 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 数据卡(正典 stat-card 形态):类型图标 + 数值 15/w800 + 标签 12 inkSoft;
|
/// 数据卡(正典 stat-card 形态):图标 + 数值 15/w800 + 标签 12 inkSoft;
|
||||||
/// 空态数值降级 inkSoft 常规字重(区分「有数据」与「空态」两种视觉)。
|
/// 空态数值降级 inkSoft 常规字重(区分「有数据」与「空态」两种视觉)。
|
||||||
class _SummaryCard extends StatelessWidget {
|
class _SummaryCard extends StatelessWidget {
|
||||||
const _SummaryCard({
|
const _SummaryCard({
|
||||||
required this.type,
|
required this.icon,
|
||||||
|
required this.iconColor,
|
||||||
required this.value,
|
required this.value,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.emphasized,
|
required this.emphasized,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
final RecordType type;
|
final IconData icon;
|
||||||
|
final Color iconColor;
|
||||||
final String value;
|
final String value;
|
||||||
final String label;
|
final String label;
|
||||||
final bool emphasized;
|
final bool emphasized;
|
||||||
@@ -412,7 +564,6 @@ class _SummaryCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final style = recordTypeStyles[type]!;
|
|
||||||
return Card(
|
return Card(
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
@@ -422,7 +573,7 @@ class _SummaryCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(style.icon, size: 18, color: style.iconColor),
|
Icon(icon, size: 18, color: iconColor),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
@@ -487,3 +638,62 @@ class _RowDivider extends StatelessWidget {
|
|||||||
return const Divider(height: 1, thickness: 1, color: AppColors.border);
|
return const Divider(height: 1, thickness: 1, color: AppColors.border);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 「健康提醒」卡(正典 alert-card:successSurface 底 + 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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/navigation/fade_route.dart';
|
||||||
import 'package:patbond_flutter/core/network/api_exception.dart';
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.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/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/record_type_dot.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_analytics.dart';
|
||||||
import 'package:patbond_flutter/features/pets/health_record_display.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_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/pet_models.dart';
|
||||||
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
import 'package:patbond_flutter/features/pets/vaccination_form_page.dart';
|
import 'package:patbond_flutter/features/pets/vaccination_form_page.dart';
|
||||||
@@ -21,6 +23,10 @@ enum _ListPhase { loading, ready, error }
|
|||||||
/// 含 cancelled 行原样展示(取消后同剂次可重新登记的事实留痕)。
|
/// 含 cancelled 行原样展示(取消后同剂次可重新登记的事实留痕)。
|
||||||
/// 四态齐备;登记经 [VaccinationFormPage]。
|
/// 四态齐备;登记经 [VaccinationFormPage]。
|
||||||
///
|
///
|
||||||
|
/// T2-14 收尾(25 号报告 §7 遗留①②):scheduled 行支持「标记完成 /
|
||||||
|
/// 取消登记」PATCH 流转;完成时可补录厂商/批号(契约可选字段);
|
||||||
|
/// 挂 `health_record_edit_succeeded/failed`(recordType=vaccine)。
|
||||||
|
///
|
||||||
/// 曝光埋点:每次进入首个成功加载上报一次
|
/// 曝光埋点:每次进入首个成功加载上报一次
|
||||||
/// `health_record_viewed(recordType=vaccine, source=pet_detail)`。
|
/// `health_record_viewed(recordType=vaccine, source=pet_detail)`。
|
||||||
class VaccinationRecordsPage extends StatefulWidget {
|
class VaccinationRecordsPage extends StatefulWidget {
|
||||||
@@ -53,6 +59,7 @@ class _VaccinationRecordsPageState extends State<VaccinationRecordsPage> {
|
|||||||
List<Vaccination> _records = const [];
|
List<Vaccination> _records = const [];
|
||||||
ApiException? _error;
|
ApiException? _error;
|
||||||
bool _viewedFired = false;
|
bool _viewedFired = false;
|
||||||
|
bool _mutating = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -187,7 +312,24 @@ class _VaccinationRecordsPageState extends State<VaccinationRecordsPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
children
|
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));
|
..add(const SizedBox(height: 10));
|
||||||
}
|
}
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
@@ -201,53 +343,252 @@ class _VaccinationRecordsPageState extends State<VaccinationRecordsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 疫苗条目:RecordTypeDot(疫苗) + 剂次标题 + 日期副行 + 状态 TagPill
|
/// 疫苗条目:RecordTypeDot(疫苗) + 剂次标题 + 日期副行 + 状态 TagPill
|
||||||
/// (图标+文字双通道,不单靠颜色区分)。
|
/// (图标+文字双通道,不单靠颜色区分);scheduled 行附
|
||||||
|
/// 「标记完成 / 取消登记」流转动作。
|
||||||
class _VaccinationTile extends StatelessWidget {
|
class _VaccinationTile extends StatelessWidget {
|
||||||
const _VaccinationTile({required this.record});
|
const _VaccinationTile({
|
||||||
|
required this.record,
|
||||||
|
this.onComplete,
|
||||||
|
this.onCancel,
|
||||||
|
});
|
||||||
|
|
||||||
final Vaccination record;
|
final Vaccination record;
|
||||||
|
final VoidCallback? onComplete;
|
||||||
|
final VoidCallback? onCancel;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const RecordTypeDot(
|
Row(
|
||||||
type: RecordType.vaccine,
|
children: [
|
||||||
size: RecordTypeDotSize.md,
|
const RecordTypeDot(
|
||||||
),
|
type: RecordType.vaccine,
|
||||||
const SizedBox(width: 12),
|
size: RecordTypeDotSize.md,
|
||||||
Expanded(
|
),
|
||||||
child: Column(
|
const SizedBox(width: 12),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Expanded(
|
||||||
children: [
|
child: Column(
|
||||||
Text(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
vaccinationDoseLabel(record),
|
children: [
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
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(
|
const SizedBox(width: 8),
|
||||||
vaccinationDateLine(record),
|
TagPill(
|
||||||
maxLines: 1,
|
vaccinationStatusLabel(record.status),
|
||||||
overflow: TextOverflow.ellipsis,
|
color: vaccinationStatusColor(record.status),
|
||||||
style: const TextStyle(
|
),
|
||||||
color: AppColors.inkSoft,
|
],
|
||||||
fontSize: 12,
|
),
|
||||||
|
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('确认完成')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import 'package:patbond_flutter/core/theme/app_theme.dart';
|
|||||||
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('五类映射齐备:图标 + 双色(05 §2 表)', (tester) async {
|
testWidgets('全类型映射齐备:图标 + 双色(05 §2 表 + T2-14 增补三型)', (tester) async {
|
||||||
// 映射表唯一出口:五类各有图标、底色、图标色、文字色、文案。
|
// 映射表唯一出口:各类型均有图标、底色、图标色、文字色、文案。
|
||||||
expect(recordTypeStyles.length, RecordType.values.length);
|
expect(recordTypeStyles.length, RecordType.values.length);
|
||||||
expect(
|
expect(
|
||||||
recordTypeStyles[RecordType.weight]!.iconColor,
|
recordTypeStyles[RecordType.weight]!.iconColor,
|
||||||
@@ -26,6 +26,25 @@ void main() {
|
|||||||
expect(recordTypeStyles[RecordType.medical]!.inkColor, AppColors.errorDark);
|
expect(recordTypeStyles[RecordType.medical]!.inkColor, AppColors.errorDark);
|
||||||
expect(recordTypeStyles[RecordType.other]!.inkColor, AppColors.inkSoft);
|
expect(recordTypeStyles[RecordType.other]!.inkColor, AppColors.inkSoft);
|
||||||
expect(recordTypeStyles[RecordType.medical]!.label, '就医');
|
expect(recordTypeStyles[RecordType.medical]!.label, '就医');
|
||||||
|
// T2-14 增补三型:色族复用已审计色对,仅图标/文案区分。
|
||||||
|
expect(
|
||||||
|
recordTypeStyles[RecordType.feeding]!.inkColor,
|
||||||
|
AppColors.successInk,
|
||||||
|
);
|
||||||
|
expect(recordTypeStyles[RecordType.feeding]!.label, '喂养');
|
||||||
|
expect(
|
||||||
|
recordTypeStyles[RecordType.grooming]!.inkColor,
|
||||||
|
AppColors.accentDark,
|
||||||
|
);
|
||||||
|
expect(recordTypeStyles[RecordType.grooming]!.label, '洗护');
|
||||||
|
expect(
|
||||||
|
recordTypeStyles[RecordType.measurement]!.inkColor,
|
||||||
|
AppColors.primaryDark,
|
||||||
|
);
|
||||||
|
expect(recordTypeStyles[RecordType.measurement]!.label, '测量');
|
||||||
|
// 三型图标彼此不同(与既有五型也不重复),保证图标通道可辨。
|
||||||
|
final icons = {for (final style in recordTypeStyles.values) style.icon};
|
||||||
|
expect(icons.length, RecordType.values.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('圆标渲染:尺寸档正确、图标为 dot 的 50%、底为基色 8% 淡染', (tester) async {
|
testWidgets('圆标渲染:尺寸档正确、图标为 dot 的 50%、底为基色 8% 淡染', (tester) async {
|
||||||
|
|||||||
@@ -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 dismissed,completedAt 键缺席。
|
||||||
|
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('viewer(canWrite=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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
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_event_edit_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,
|
||||||
|
];
|
||||||
|
|
||||||
|
HealthEvent baseEvent({int version = 3}) =>
|
||||||
|
buildHealthEvent('e-1', overrides: {'notes': '医院复查', 'version': version});
|
||||||
|
|
||||||
|
Future<void> pumpEdit(WidgetTester tester, {HealthEvent? event}) 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<HealthEvent>(
|
||||||
|
builder: (_) => HealthEventEditPage(
|
||||||
|
repository: repository,
|
||||||
|
event: event ?? baseEvent(),
|
||||||
|
analytics: analytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('打开编辑'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('打开编辑'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('预填与身份静态区:类型/发生时间不可编辑,金额以元回显', (tester) async {
|
||||||
|
await pumpEdit(tester);
|
||||||
|
|
||||||
|
// 身份区:类型文案 + 发生时间(无输入控件)。
|
||||||
|
expect(find.text('就医'), findsOneWidget);
|
||||||
|
// 预填:标题 / 金额(12850 分 → 128.50 元)/ 备注。
|
||||||
|
expect(find.text('皮肤检查'), findsOneWidget);
|
||||||
|
expect(find.text('128.50'), findsOneWidget);
|
||||||
|
expect(find.text('医院复查'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('差量提交:只发送改动字段 + version;成功回传并报 edit_succeeded(fieldCount)', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
UpdateHealthEventRequest? captured;
|
||||||
|
repository.updateHealthEventHandler = (eventId, request) async {
|
||||||
|
captured = request;
|
||||||
|
return buildHealthEvent('e-1', overrides: {'version': 4});
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpEdit(tester);
|
||||||
|
await tester.enterText(find.widgetWithText(TextFormField, '标题'), '皮肤复查');
|
||||||
|
await tester.enterText(find.widgetWithText(TextFormField, '金额(元)'), '99');
|
||||||
|
await tester.tap(find.text('保存修改'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 备注未改不出现;金额 99 元 → 9900 分。
|
||||||
|
expect(captured!.toJson(), {
|
||||||
|
'version': 3,
|
||||||
|
'title': '皮肤复查',
|
||||||
|
'amountCents': 9900,
|
||||||
|
});
|
||||||
|
final succeeded = eventsOf('health_record_edit_succeeded');
|
||||||
|
expect(succeeded.single, {'recordType': 'health_event', 'fieldCount': 2});
|
||||||
|
expect(find.byType(HealthEventEditPage), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('无变更不发 PATCH 直接返回;清空可选字段视为不变更(契约不支持清空回 null)', (tester) async {
|
||||||
|
var called = false;
|
||||||
|
repository.updateHealthEventHandler = (eventId, request) async {
|
||||||
|
called = true;
|
||||||
|
return baseEvent();
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpEdit(tester);
|
||||||
|
// 清空金额与备注:契约不支持清空回 null → 视为不变更。
|
||||||
|
await tester.enterText(find.widgetWithText(TextFormField, '金额(元)'), '');
|
||||||
|
await tester.enterText(find.widgetWithText(TextFormField, '备注'), '');
|
||||||
|
await tester.tap(find.text('保存修改'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(called, isFalse);
|
||||||
|
expect(find.byType(HealthEventEditPage), findsNothing);
|
||||||
|
expect(eventsOf('health_record_edit_succeeded'), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('40902 冲突:明确提示 + 经时间线检索自动取新 version(保留输入),重提成功', (tester) async {
|
||||||
|
final submittedVersions = <int>[];
|
||||||
|
var conflictOnce = true;
|
||||||
|
repository.updateHealthEventHandler = (eventId, request) async {
|
||||||
|
submittedVersions.add(request.version);
|
||||||
|
if (conflictOnce) {
|
||||||
|
conflictOnce = false;
|
||||||
|
throw const PetVersionConflictException(message: '数据已被修改');
|
||||||
|
}
|
||||||
|
return buildHealthEvent('e-1', overrides: {'version': 8});
|
||||||
|
};
|
||||||
|
// 契约无按 id 读取端点:40902 后经时间线分页检索取回最新版本(version 7)。
|
||||||
|
final listCaptured = <String?>[];
|
||||||
|
repository.listHealthEventsHandler = (petId, limit, cursor) async {
|
||||||
|
listCaptured.add(cursor);
|
||||||
|
return CursorPage(
|
||||||
|
items: [buildHealthEvent('e-other'), baseEvent(version: 7)],
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpEdit(tester);
|
||||||
|
await tester.enterText(find.widgetWithText(TextFormField, '标题'), '皮肤复查');
|
||||||
|
await tester.tap(find.text('保存修改'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 明确提示 + 用户输入保留。
|
||||||
|
expect(find.text('记录已在其他设备被修改,已获取最新版本,请核对后重新保存'), findsOneWidget);
|
||||||
|
expect(find.text('皮肤复查'), findsOneWidget);
|
||||||
|
expect(listCaptured, [null]);
|
||||||
|
|
||||||
|
// conflict 失败事件(M2 验收「并发冲突明确」数据面)。
|
||||||
|
final failed = eventsOf('health_record_edit_failed');
|
||||||
|
expect(failed.single, {
|
||||||
|
'recordType': 'health_event',
|
||||||
|
'failureReason': 'conflict',
|
||||||
|
'errorCode': 40902,
|
||||||
|
'httpStatus': 409,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重提:自动用检索回的新 version。
|
||||||
|
await tester.tap(find.text('保存修改'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(submittedVersions, [3, 7]);
|
||||||
|
expect(find.byType(HealthEventEditPage), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('40402 记录不存在:SnackBar + 返回;40300 横幅;网络 SnackBar', (tester) async {
|
||||||
|
var attempt = 0;
|
||||||
|
repository.updateHealthEventHandler = (eventId, request) async {
|
||||||
|
attempt++;
|
||||||
|
switch (attempt) {
|
||||||
|
case 1:
|
||||||
|
throw const PetAccessDeniedException(message: '无权限');
|
||||||
|
case 2:
|
||||||
|
throw const ApiNetworkException('断网');
|
||||||
|
default:
|
||||||
|
throw const PetRecordNotFoundException(message: '不存在');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpEdit(tester);
|
||||||
|
await tester.enterText(find.widgetWithText(TextFormField, '标题'), '皮肤复查');
|
||||||
|
|
||||||
|
await tester.tap(find.text('保存修改'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('你没有权限修改该记录'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('保存修改'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
|
||||||
|
|
||||||
|
// 经 SnackBar「重试」重提(顺带锁定重试动作接线)→ 40402。
|
||||||
|
await tester.tap(find.text('重试'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('记录不存在或已被删除'), findsOneWidget);
|
||||||
|
expect(find.byType(HealthEventEditPage), findsNothing);
|
||||||
|
|
||||||
|
final failed = eventsOf('health_record_edit_failed');
|
||||||
|
expect(failed.length, 3);
|
||||||
|
expect(failed[0]!['failureReason'], 'permission_denied');
|
||||||
|
expect(failed[1]!['failureReason'], 'network_error');
|
||||||
|
expect(failed[2]!['failureReason'], 'not_found');
|
||||||
|
expect(failed[2]!['errorCode'], 40402);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
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_event_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, 1700);
|
||||||
|
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<HealthEvent>(
|
||||||
|
builder: (_) => HealthEventFormPage(
|
||||||
|
repository: repository,
|
||||||
|
petId: 'p-1',
|
||||||
|
analytics: analytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('打开表单'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('打开表单'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fillValid(WidgetTester tester, {String? amount}) async {
|
||||||
|
await tester.tap(find.text('就医'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, '标题(如:皮肤检查)'),
|
||||||
|
'皮肤检查',
|
||||||
|
);
|
||||||
|
if (amount != null) {
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, '金额(元,可选)'),
|
||||||
|
amount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('本地校验:类型/标题缺失与金额非法拦截,不发请求且报 validation_error', (tester) async {
|
||||||
|
var called = false;
|
||||||
|
repository.createHealthEventHandler = (petId, request) async {
|
||||||
|
called = true;
|
||||||
|
return buildHealthEvent('e-1');
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpForm(tester);
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, '金额(元,可选)'),
|
||||||
|
'12.345',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('保存记录'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('请选择事件类型'), findsOneWidget);
|
||||||
|
expect(find.text('请输入标题'), findsOneWidget);
|
||||||
|
expect(find.text('金额格式不正确,最多两位小数'), findsOneWidget);
|
||||||
|
expect(called, isFalse);
|
||||||
|
final failed = eventsOf('health_record_create_failed');
|
||||||
|
expect(failed.single!['recordType'], 'health_event');
|
||||||
|
expect(failed.single!['failureReason'], 'validation_error');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('成功请求形状:六类类型、UTC 时间戳、金额元→整数分、无金额时键缺席', (tester) async {
|
||||||
|
CreateHealthEventRequest? captured;
|
||||||
|
repository.createHealthEventHandler = (petId, request) async {
|
||||||
|
captured = request;
|
||||||
|
return buildHealthEvent('e-1');
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpForm(tester);
|
||||||
|
await fillValid(tester, amount: '128.50');
|
||||||
|
await tester.tap(find.text('保存记录'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final json = captured!.toJson();
|
||||||
|
expect(json['eventType'], 'medical');
|
||||||
|
expect(json['title'], '皮肤检查');
|
||||||
|
// 金额以元录入 → 整数分传输(工单硬项)。
|
||||||
|
expect(json['amountCents'], 12850);
|
||||||
|
// 今日默认此刻,转 UTC 带 Z 上送。
|
||||||
|
expect(json['occurredAt'], endsWith('Z'));
|
||||||
|
expect(json.containsKey('notes'), isFalse);
|
||||||
|
|
||||||
|
final succeeded = eventsOf('health_record_create_succeeded');
|
||||||
|
expect(succeeded.single!['recordType'], 'health_event');
|
||||||
|
expect(succeeded.single!['durationMs'], isA<int>());
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('无金额提交:amountCents 键整体缺席(非 0、非 null)', (tester) async {
|
||||||
|
CreateHealthEventRequest? captured;
|
||||||
|
repository.createHealthEventHandler = (petId, request) async {
|
||||||
|
captured = request;
|
||||||
|
return buildHealthEvent('e-1');
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpForm(tester);
|
||||||
|
await fillValid(tester);
|
||||||
|
await tester.tap(find.text('保存记录'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(captured!.toJson().containsKey('amountCents'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
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': 'health_event',
|
||||||
|
'entryPoint': 'record_list',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('40300 横幅 / 40000 横幅 / 网络 SnackBar 三兜底 + 失败事件', (tester) async {
|
||||||
|
var attempt = 0;
|
||||||
|
repository.createHealthEventHandler = (petId, request) async {
|
||||||
|
attempt++;
|
||||||
|
switch (attempt) {
|
||||||
|
case 1:
|
||||||
|
throw const PetAccessDeniedException(message: '无权限');
|
||||||
|
case 2:
|
||||||
|
throw const ApiBusinessException(code: 40000, message: '参数错误');
|
||||||
|
default:
|
||||||
|
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);
|
||||||
|
|
||||||
|
await tester.tap(find.text('保存记录'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
|
||||||
|
|
||||||
|
final failed = eventsOf('health_record_create_failed');
|
||||||
|
expect(failed.length, 3);
|
||||||
|
expect(failed[0]!['failureReason'], 'permission_denied');
|
||||||
|
expect(failed[0]!['errorCode'], 40300);
|
||||||
|
expect(failed[1]!['failureReason'], 'validation_error');
|
||||||
|
expect(failed[1]!['errorCode'], 40000);
|
||||||
|
expect(failed[2]!['failureReason'], 'network_error');
|
||||||
|
expect(failed[2]!['attemptSeq'], 3);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
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_event_edit_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/health_event_form_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/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,
|
||||||
|
int? pageSize,
|
||||||
|
}) 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: (_) => HealthEventsPage(
|
||||||
|
repository: repository,
|
||||||
|
petId: 'p-1',
|
||||||
|
canWrite: canWrite,
|
||||||
|
analytics: analytics,
|
||||||
|
pageSize: pageSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
CursorPage<HealthEvent> page(
|
||||||
|
List<HealthEvent> items, {
|
||||||
|
String? next,
|
||||||
|
bool hasMore = false,
|
||||||
|
}) => CursorPage(items: items, nextCursor: next, hasMore: hasMore);
|
||||||
|
|
||||||
|
testWidgets('四态 · ready:六类条目、月分组组头、金额元展示、类型标签;viewed 一次', (tester) async {
|
||||||
|
repository.listHealthEventsHandler = (petId, limit, cursor) async => page([
|
||||||
|
buildHealthEvent(
|
||||||
|
'e-1',
|
||||||
|
overrides: {'occurredAt': '2026-09-05T14:00:00+08:00'},
|
||||||
|
),
|
||||||
|
buildHealthEvent(
|
||||||
|
'e-2',
|
||||||
|
overrides: {
|
||||||
|
'eventType': 'deworming',
|
||||||
|
'title': '体内驱虫',
|
||||||
|
'occurredAt': '2026-09-01T10:00:00+08:00',
|
||||||
|
'amountCents': null,
|
||||||
|
'notes': '博来恩',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
buildHealthEvent(
|
||||||
|
'e-3',
|
||||||
|
overrides: {
|
||||||
|
'eventType': 'note',
|
||||||
|
'title': '换粮观察',
|
||||||
|
'occurredAt': '2026-08-20T10:00:00+08:00',
|
||||||
|
'amountCents': null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 月分组:9 月两条 + 8 月一条各一枚组头。
|
||||||
|
expect(find.text('2026 年 9 月'), findsOneWidget);
|
||||||
|
expect(find.text('2026 年 8 月'), findsOneWidget);
|
||||||
|
// 条目标题与副行(日期 · 备注)。
|
||||||
|
expect(find.text('皮肤检查'), findsOneWidget);
|
||||||
|
expect(find.textContaining('· 博来恩'), findsOneWidget);
|
||||||
|
// 类型 TagPill(图标 + 文字双通道)。
|
||||||
|
expect(find.widgetWithText(TagPill, '就医'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(TagPill, '驱虫'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(TagPill, '随手记'), findsOneWidget);
|
||||||
|
// 金额:12850 分 → 元展示;无金额条目不渲染金额。
|
||||||
|
expect(find.text('¥128.50'), findsOneWidget);
|
||||||
|
|
||||||
|
final viewed = eventsOf('health_record_viewed');
|
||||||
|
expect(viewed.single, {
|
||||||
|
'recordType': 'health_event',
|
||||||
|
'source': 'pet_detail',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('四态 · loading / empty:空态插画 + 录入 CTA', (tester) async {
|
||||||
|
final completer = Completer<CursorPage<HealthEvent>>();
|
||||||
|
repository.listHealthEventsHandler = (petId, limit, cursor) =>
|
||||||
|
completer.future;
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
|
||||||
|
completer.complete(page(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.listHealthEventsHandler = (petId, limit, cursor) async {
|
||||||
|
calls++;
|
||||||
|
if (calls == 1) throw const ApiNetworkException('断网');
|
||||||
|
return page([buildHealthEvent('e-1')]);
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('重试'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('皮肤检查'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('cursor 分页:透传游标追加不重不漏,末页收起按钮;翻页失败保留重试', (tester) async {
|
||||||
|
final captured = <(int?, String?)>[];
|
||||||
|
var moreFails = true;
|
||||||
|
repository.listHealthEventsHandler = (petId, limit, cursor) async {
|
||||||
|
captured.add((limit, cursor));
|
||||||
|
if (cursor == null) {
|
||||||
|
return page([buildHealthEvent('e-1')], next: 'CUR-1', hasMore: true);
|
||||||
|
}
|
||||||
|
if (moreFails) {
|
||||||
|
moreFails = false;
|
||||||
|
throw const ApiNetworkException('断网');
|
||||||
|
}
|
||||||
|
return page([
|
||||||
|
buildHealthEvent(
|
||||||
|
'e-2',
|
||||||
|
overrides: {
|
||||||
|
'title': '洗澡美容',
|
||||||
|
'eventType': 'grooming',
|
||||||
|
'occurredAt': '2026-09-01T10:00:00+08:00',
|
||||||
|
'amountCents': null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
await pumpPage(tester, pageSize: 1);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('皮肤检查'), findsOneWidget);
|
||||||
|
expect(find.text('加载更多'), findsOneWidget);
|
||||||
|
|
||||||
|
// 第一次翻页失败:SnackBar + 按钮保留。
|
||||||
|
await tester.tap(find.text('加载更多'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
|
||||||
|
expect(find.text('加载更多'), findsOneWidget);
|
||||||
|
|
||||||
|
// 重试成功:追加且不重复,末页收起按钮。
|
||||||
|
await tester.tap(find.text('加载更多'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('皮肤检查'), findsOneWidget);
|
||||||
|
expect(find.text('洗澡美容'), findsOneWidget);
|
||||||
|
expect(find.text('加载更多'), findsNothing);
|
||||||
|
|
||||||
|
expect(captured, [(1, null), (1, 'CUR-1'), (1, 'CUR-1')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('录入闭环:CTA → 表单(record_form 路由名)→ 成功后重拉首页', (tester) async {
|
||||||
|
var listCalls = 0;
|
||||||
|
repository.listHealthEventsHandler = (petId, limit, cursor) async {
|
||||||
|
listCalls++;
|
||||||
|
return listCalls == 1
|
||||||
|
? page(const [])
|
||||||
|
: page([
|
||||||
|
buildHealthEvent('e-new', overrides: {'title': '首次体检'}),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
repository.createHealthEventHandler = (petId, request) async =>
|
||||||
|
buildHealthEvent('e-new', overrides: {'title': '首次体检'});
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('记录第一条'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(HealthEventFormPage), findsOneWidget);
|
||||||
|
final route = ModalRoute.of(
|
||||||
|
tester.element(find.byType(HealthEventFormPage)),
|
||||||
|
)!;
|
||||||
|
expect(route.settings.name, 'record_form');
|
||||||
|
|
||||||
|
await tester.tap(find.text('就医'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, '标题(如:皮肤检查)'),
|
||||||
|
'首次体检',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('保存记录'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(HealthEventFormPage), findsNothing);
|
||||||
|
expect(find.text('已记录健康事件'), findsOneWidget);
|
||||||
|
// 排序/月分组以服务端为准:成功后重拉首页。
|
||||||
|
expect(listCalls, 2);
|
||||||
|
expect(find.text('首次体检'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('编辑闭环:点条目 → 编辑页(无路由名)→ 成功后就地替换', (tester) async {
|
||||||
|
repository.listHealthEventsHandler = (petId, limit, cursor) async =>
|
||||||
|
page([buildHealthEvent('e-1')]);
|
||||||
|
repository.updateHealthEventHandler = (eventId, request) async =>
|
||||||
|
buildHealthEvent('e-1', overrides: {'title': '皮肤复查', 'version': 2});
|
||||||
|
|
||||||
|
await pumpPage(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('皮肤检查'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(HealthEventEditPage), findsOneWidget);
|
||||||
|
final route = ModalRoute.of(
|
||||||
|
tester.element(find.byType(HealthEventEditPage)),
|
||||||
|
)!;
|
||||||
|
// 编辑页不带路由名:record_form 专属创建漏斗到达段。
|
||||||
|
expect(route.settings.name, isNull);
|
||||||
|
|
||||||
|
await tester.enterText(find.widgetWithText(TextFormField, '标题'), '皮肤复查');
|
||||||
|
await tester.tap(find.text('保存修改'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(HealthEventEditPage), findsNothing);
|
||||||
|
expect(find.text('已保存修改'), findsOneWidget);
|
||||||
|
expect(find.text('皮肤复查'), findsOneWidget);
|
||||||
|
expect(find.text('皮肤检查'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('viewer(canWrite=false):无录入入口、空态无 CTA、点条目不进编辑', (tester) async {
|
||||||
|
repository.listHealthEventsHandler = (petId, limit, cursor) async =>
|
||||||
|
page([buildHealthEvent('e-1')]);
|
||||||
|
|
||||||
|
await pumpPage(tester, canWrite: false);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.add), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.text('皮肤检查'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(HealthEventEditPage), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -82,4 +82,45 @@ void main() {
|
|||||||
'source': 'pet_detail',
|
'source': 'pet_detail',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('editSucceeded:recordType + fieldCount(差量键数,不含 version)', () {
|
||||||
|
analytics.editSucceeded(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
fieldCount: 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events.single.$1, 'health_record_edit_succeeded');
|
||||||
|
expect(events.single.$2, {'recordType': 'health_event', 'fieldCount': 2});
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'editFailed:conflict(40902)带 errorCode 与 httpStatus 推导,无 attemptSeq',
|
||||||
|
() {
|
||||||
|
analytics.editFailed(
|
||||||
|
recordType: HealthRecordType.vaccine,
|
||||||
|
reason: HealthRecordFailureReason.conflict,
|
||||||
|
errorCode: 40902,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events.single.$1, 'health_record_edit_failed');
|
||||||
|
expect(events.single.$2, {
|
||||||
|
'recordType': 'vaccine',
|
||||||
|
'failureReason': 'conflict',
|
||||||
|
'errorCode': 40902,
|
||||||
|
'httpStatus': 409,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('editFailed:网络失败可空属性整体缺席', () {
|
||||||
|
analytics.editFailed(
|
||||||
|
recordType: HealthRecordType.healthEvent,
|
||||||
|
reason: HealthRecordFailureReason.networkError,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events.single.$2, {
|
||||||
|
'recordType': 'health_event',
|
||||||
|
'failureReason': 'network_error',
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||||
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||||
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
|
||||||
@@ -147,4 +148,133 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('T2-14 · 健康事件展示纯函数', () {
|
||||||
|
test('六类事件 → RecordType 映射齐备(note 归「其他」,其余五类专属)', () {
|
||||||
|
expect(
|
||||||
|
recordTypeForHealthEvent(HealthEventType.medical),
|
||||||
|
RecordType.medical,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
recordTypeForHealthEvent(HealthEventType.feeding),
|
||||||
|
RecordType.feeding,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
recordTypeForHealthEvent(HealthEventType.deworming),
|
||||||
|
RecordType.deworming,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
recordTypeForHealthEvent(HealthEventType.grooming),
|
||||||
|
RecordType.grooming,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
recordTypeForHealthEvent(HealthEventType.measurement),
|
||||||
|
RecordType.measurement,
|
||||||
|
);
|
||||||
|
expect(recordTypeForHealthEvent(HealthEventType.note), RecordType.other);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('六类事件中文文案', () {
|
||||||
|
expect(
|
||||||
|
[for (final t in HealthEventType.values) healthEventTypeLabel(t)],
|
||||||
|
['就医', '喂养', '驱虫', '洗护', '测量', '随手记'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('月分组组头:按本地时区归月', () {
|
||||||
|
expect(healthEventMonthHeader(DateTime(2026, 9, 5, 14)), '2026 年 9 月');
|
||||||
|
expect(healthEventMonthHeader(DateTime(2025, 12, 31)), '2025 年 12 月');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('T2-14 · tz 固定偏移(summary tz 参数)', () {
|
||||||
|
test('正/负/零/半小时偏移格式', () {
|
||||||
|
expect(tzOffsetQueryValue(const Duration(hours: 8)), '+08:00');
|
||||||
|
expect(
|
||||||
|
tzOffsetQueryValue(const Duration(hours: -5, minutes: -30)),
|
||||||
|
'-05:30',
|
||||||
|
);
|
||||||
|
expect(tzOffsetQueryValue(Duration.zero), '+00:00');
|
||||||
|
expect(
|
||||||
|
tzOffsetQueryValue(const Duration(hours: 5, minutes: 45)),
|
||||||
|
'+05:45',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:patbond_flutter/core/network/api_exception.dart';
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.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';
|
import 'package:patbond_flutter/features/pets/pet_detail_page.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';
|
||||||
@@ -24,8 +27,9 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Future<void> pumpDetail(WidgetTester tester, {String petId = 'p-1'}) async {
|
Future<void> pumpDetail(WidgetTester tester, {String petId = 'p-1'}) async {
|
||||||
// 详情页自 T2-13 增加健康数据卡行,加高视口保证底部按钮在栏内。
|
// 详情页自 T2-13 增加健康数据卡行、T2-14 增加花费卡与记录导航区,
|
||||||
tester.view.physicalSize = const Size(700, 1800);
|
// 加高视口保证底部按钮在栏内。
|
||||||
|
tester.view.physicalSize = const Size(700, 2200);
|
||||||
tester.view.devicePixelRatio = 1.0;
|
tester.view.devicePixelRatio = 1.0;
|
||||||
addTearDown(tester.view.reset);
|
addTearDown(tester.view.reset);
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -184,7 +188,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('T2-13 · 数据卡行接 summary', () {
|
group('T2-13 · 数据卡行接 summary', () {
|
||||||
testWidgets('三卡取数:最新体重 / 疫苗进度 / 下一针(含疫苗名)', (tester) async {
|
testWidgets('四卡取数:最新体重 / 疫苗进度 / 下一针 / 本月花费(tz 透传)', (tester) async {
|
||||||
repository.getPetHandler = (petId) async => buildPet('p-1');
|
repository.getPetHandler = (petId) async => buildPet('p-1');
|
||||||
String? capturedTz;
|
String? capturedTz;
|
||||||
repository.getPetSummaryHandler = (petId, tz) async {
|
repository.getPetSummaryHandler = (petId, tz) async {
|
||||||
@@ -202,8 +206,12 @@ void main() {
|
|||||||
expect(find.text('疫苗进度'), findsOneWidget);
|
expect(find.text('疫苗进度'), findsOneWidget);
|
||||||
expect(find.text('2026-08-01'), findsOneWidget);
|
expect(find.text('2026-08-01'), findsOneWidget);
|
||||||
expect(find.text('下一针·狂犬疫苗'), findsOneWidget);
|
expect(find.text('下一针·狂犬疫苗'), findsOneWidget);
|
||||||
// 本单不消费 monthlyExpense(T2-14),tz 不传走服务端缺省 UTC。
|
// T2-14:月度花费卡接 monthlyExpense(12850 分 → 元展示)。
|
||||||
expect(capturedTz, isNull);
|
expect(find.text('¥128.50'), findsOneWidget);
|
||||||
|
expect(find.text('本月花费'), findsOneWidget);
|
||||||
|
// T2-13 遗留③:tz 透传设备时区固定偏移(月度窗口随设备时区)。
|
||||||
|
expect(capturedTz, tzOffsetQueryValue(DateTime.now().timeZoneOffset));
|
||||||
|
expect(capturedTz, matches(RegExp(r'^[+-]\d{2}:\d{2}$')));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('null 语义:无登记显示空态而非 0/0', (tester) async {
|
testWidgets('null 语义:无登记显示空态而非 0/0', (tester) async {
|
||||||
@@ -292,4 +300,156 @@ void main() {
|
|||||||
expect(find.byIcon(Icons.add), findsNothing);
|
expect(find.byIcon(Icons.add), findsNothing);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('T2-14 · 时间线入口', () {
|
||||||
|
testWidgets('点本月花费卡 → 健康时间线页;返回后重拉摘要', (tester) async {
|
||||||
|
repository.getPetHandler = (petId) async => buildPet('p-1');
|
||||||
|
var summaryCalls = 0;
|
||||||
|
repository.getPetSummaryHandler = (petId, tz) async {
|
||||||
|
summaryCalls++;
|
||||||
|
return buildSummary();
|
||||||
|
};
|
||||||
|
repository.listHealthEventsHandler = (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(HealthEventsPage), 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.listHealthEventsHandler = (petId, limit, cursor) async =>
|
||||||
|
const CursorPage(items: [], nextCursor: null, hasMore: false);
|
||||||
|
|
||||||
|
await pumpDetail(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final tile = find.widgetWithText(ListTile, '健康时间线');
|
||||||
|
expect(tile, findsOneWidget);
|
||||||
|
await tester.ensureVisible(tile);
|
||||||
|
await tester.tap(tile);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(HealthEventsPage), findsOneWidget);
|
||||||
|
// viewer:空态无 CTA、AppBar 无添加入口。
|
||||||
|
expect(find.text('记录第一条'), findsNothing);
|
||||||
|
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/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/features/pets/health_record_analytics.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/pet_models.dart';
|
||||||
import 'package:patbond_flutter/features/pets/vaccination_form_page.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/features/pets/vaccination_records_page.dart';
|
||||||
@@ -185,4 +186,173 @@ void main() {
|
|||||||
expect(find.byIcon(Icons.add), findsNothing);
|
expect(find.byIcon(Icons.add), findsNothing);
|
||||||
expect(find.text('登记第一针'), 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('viewer:scheduled 行也无流转动作', (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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,23 @@ Vaccination buildVaccination(
|
|||||||
}) =>
|
}) =>
|
||||||
Vaccination.fromJson({...sampleVaccinationJson(), 'id': id, ...overrides});
|
Vaccination.fromJson({...sampleVaccinationJson(), 'id': id, ...overrides});
|
||||||
|
|
||||||
|
/// 快速构造健康事件。
|
||||||
|
HealthEvent buildHealthEvent(
|
||||||
|
String id, {
|
||||||
|
Map<String, Object?> overrides = const {},
|
||||||
|
}) =>
|
||||||
|
HealthEvent.fromJson({...sampleHealthEventJson(), 'id': id, ...overrides});
|
||||||
|
|
||||||
|
/// 快速构造照护提醒。
|
||||||
|
CareReminder buildReminder(
|
||||||
|
String id, {
|
||||||
|
Map<String, Object?> overrides = const {},
|
||||||
|
}) => CareReminder.fromJson({
|
||||||
|
...sampleCareReminderJson(),
|
||||||
|
'id': id,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
/// 快速构造摘要(缺省为「三聚合齐备」样本;overrides 可置 null 验证空态)。
|
/// 快速构造摘要(缺省为「三聚合齐备」样本;overrides 可置 null 验证空态)。
|
||||||
PetSummary buildSummary({Map<String, Object?> overrides = const {}}) =>
|
PetSummary buildSummary({Map<String, Object?> overrides = const {}}) =>
|
||||||
PetSummary.fromJson({...samplePetSummaryJson(), ...overrides});
|
PetSummary.fromJson({...samplePetSummaryJson(), ...overrides});
|
||||||
@@ -184,6 +201,20 @@ class FakePetsRepository implements PetsRepository {
|
|||||||
Future<List<Vaccination>> Function(String)? listVaccinationsHandler;
|
Future<List<Vaccination>> Function(String)? listVaccinationsHandler;
|
||||||
Future<Vaccination> Function(String, CreateVaccinationRequest)?
|
Future<Vaccination> Function(String, CreateVaccinationRequest)?
|
||||||
createVaccinationHandler;
|
createVaccinationHandler;
|
||||||
|
Future<Vaccination> Function(String, UpdateVaccinationRequest)?
|
||||||
|
updateVaccinationHandler;
|
||||||
|
Future<CursorPage<HealthEvent>> Function(String, int?, String?)?
|
||||||
|
listHealthEventsHandler;
|
||||||
|
Future<HealthEvent> Function(String, CreateHealthEventRequest)?
|
||||||
|
createHealthEventHandler;
|
||||||
|
Future<HealthEvent> Function(String, UpdateHealthEventRequest)?
|
||||||
|
updateHealthEventHandler;
|
||||||
|
Future<List<CareReminder>> Function(String, CareReminderStatus?)?
|
||||||
|
listCareRemindersHandler;
|
||||||
|
Future<CareReminder> Function(String, CreateCareReminderRequest)?
|
||||||
|
createCareReminderHandler;
|
||||||
|
Future<CareReminder> Function(String, UpdateCareReminderRequest)?
|
||||||
|
updateCareReminderHandler;
|
||||||
Future<PetSummary> Function(String, String?)? getPetSummaryHandler;
|
Future<PetSummary> Function(String, String?)? getPetSummaryHandler;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -233,6 +264,52 @@ class FakePetsRepository implements PetsRepository {
|
|||||||
CreateVaccinationRequest request,
|
CreateVaccinationRequest request,
|
||||||
) => createVaccinationHandler!(petId, request);
|
) => createVaccinationHandler!(petId, request);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Vaccination> updateVaccination(
|
||||||
|
String vaccinationId,
|
||||||
|
UpdateVaccinationRequest request,
|
||||||
|
) => updateVaccinationHandler!(vaccinationId, request);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<HealthEvent>> listHealthEvents(
|
||||||
|
String petId, {
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
}) => listHealthEventsHandler!(petId, limit, cursor);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HealthEvent> createHealthEvent(
|
||||||
|
String petId,
|
||||||
|
CreateHealthEventRequest request,
|
||||||
|
) => createHealthEventHandler!(petId, request);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HealthEvent> updateHealthEvent(
|
||||||
|
String eventId,
|
||||||
|
UpdateHealthEventRequest request,
|
||||||
|
) => updateHealthEventHandler!(eventId, request);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<CareReminder>> listCareReminders(
|
||||||
|
String petId, {
|
||||||
|
CareReminderStatus? status,
|
||||||
|
}) =>
|
||||||
|
listCareRemindersHandler?.call(petId, status) ??
|
||||||
|
// 缺省空列表:既有详情页测试不必逐个注入。
|
||||||
|
Future.value(const []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CareReminder> createCareReminder(
|
||||||
|
String petId,
|
||||||
|
CreateCareReminderRequest request,
|
||||||
|
) => createCareReminderHandler!(petId, request);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CareReminder> updateCareReminder(
|
||||||
|
String reminderId,
|
||||||
|
UpdateCareReminderRequest request,
|
||||||
|
) => updateCareReminderHandler!(reminderId, request);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<PetSummary> getPetSummary(String petId, {String? tz}) =>
|
Future<PetSummary> getPetSummary(String petId, {String? tz}) =>
|
||||||
getPetSummaryHandler?.call(petId, tz) ??
|
getPetSummaryHandler?.call(petId, tz) ??
|
||||||
|
|||||||
Reference in New Issue
Block a user