新增:健康事件时间线接入真实数据(T2-14 时间线半边)
- 时间线页:occurred_at DESC cursor 分页 + 月分组组头,四态齐备; 六类事件经 RecordTypeDot 映射(增补喂养/洗护/测量三型,色族复用 已审计色对),类型 TagPill 双通道 - 事件录入表单:六类类型选择器、金额以元录入/整数分传输(money.dart 换算)、UTC 时间戳约定与体重表单一致 - 事件编辑页:顶层 PATCH 差量提交(title/notes/amountCents + version), 40902 照 T2-12 模式自动经时间线检索取新 version 重提(保留输入) - 档案页:月度花费卡接 summary.monthlyExpense(分→元展示)、tz 透传 设备时区固定偏移(T2-13 遗留③)、健康时间线导航入口 - 埋点:health_record 域 create 三事件 + viewed(recordType=health_event) 挂通;新增 edit_succeeded/failed 封装(failureReason 含 conflict) - 测试 224 → 250 全绿(+26);analyze 0 问题;format 无 diff Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
|
||||
/// M2 记录类型(05 号规范 §2 五种,「其他」为扩展兜底)。
|
||||
enum RecordType { weight, vaccine, deworming, medical, other }
|
||||
/// M2 记录类型(05 号规范 §2 五种,「其他」为扩展兜底;
|
||||
/// T2-14 为六类健康事件增补 feeding / grooming / measurement 三型,
|
||||
/// 色族复用 05 §2 已审计的四个色对,仅图标与文案区分——对比度结论不变)。
|
||||
enum RecordType {
|
||||
weight,
|
||||
vaccine,
|
||||
deworming,
|
||||
medical,
|
||||
other,
|
||||
feeding,
|
||||
grooming,
|
||||
measurement,
|
||||
}
|
||||
|
||||
/// 类型 → 图标 + 三色(dot 底 8% 淡染基色 / 图标色 / 文字标签色)+ 文案的
|
||||
/// 唯一映射出口(05 §3.2:映射只存在于本文件一处,杜绝散落硬编码)。
|
||||
@@ -69,6 +80,28 @@ const Map<RecordType, RecordTypeStyle> recordTypeStyles = {
|
||||
inkColor: AppColors.inkSoft,
|
||||
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%。
|
||||
|
||||
@@ -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 惯例:枚举编译期
|
||||
/// 锁死,业务代码禁止手拼事件名与属性。
|
||||
///
|
||||
/// T2-13 挂接创建漏斗三事件 + viewed;edit/deleted 事件的挂接随
|
||||
/// 编辑/删除交互落地(「标记完成」等)另行接线,见 25 号报告遗留。
|
||||
/// T2-13 挂接创建漏斗三事件 + viewed;T2-14 补挂 edit_succeeded/failed
|
||||
/// (健康事件编辑、疫苗标记完成/取消)。`health_record_deleted` 因 M2
|
||||
/// 契约无删除端点暂无挂接点,留待删除交互落地。
|
||||
|
||||
/// 记录类型(06 §1.4 recordType 枚举,四类记录接口对应)。
|
||||
enum HealthRecordType {
|
||||
@@ -30,7 +31,8 @@ enum HealthRecordEntryPoint {
|
||||
final String value;
|
||||
}
|
||||
|
||||
/// 创建失败原因(06 §1.4 基底 + M2 验收新增三值)。与 pet 域同款
|
||||
/// 创建/编辑失败原因(06 §1.4 基底 + M2 验收新增三值;`conflict`
|
||||
/// 仅编辑链路会出现——40902 乐观锁/条件更新守卫落空)。与 pet 域同款
|
||||
/// 网络归并口径:断网/超时/5xx 均并入 network_error,server_error
|
||||
/// 保留给无法归类的兜底。
|
||||
enum HealthRecordFailureReason {
|
||||
@@ -39,7 +41,8 @@ enum HealthRecordFailureReason {
|
||||
notFound('not_found'),
|
||||
rateLimited('rate_limited'),
|
||||
networkError('network_error'),
|
||||
serverError('server_error');
|
||||
serverError('server_error'),
|
||||
conflict('conflict');
|
||||
|
||||
const HealthRecordFailureReason(this.value);
|
||||
|
||||
@@ -122,4 +125,34 @@ class HealthRecordAnalytics {
|
||||
'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;
|
||||
|
||||
import 'package:flutter/material.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';
|
||||
|
||||
/// 体重输入解析:契约区间 (0, 500]、最多两位小数(numeric(6,2))。
|
||||
@@ -84,3 +86,51 @@ String? vaccinationDateRuleError({
|
||||
}
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
|
||||
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||
import 'package:patbond_flutter/features/pets/health_events_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_analytics.dart';
|
||||
import 'package:patbond_flutter/features/pets/pet_display.dart';
|
||||
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||
@@ -104,11 +106,14 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
||||
}
|
||||
|
||||
/// 摘要实时聚合(40401 由主链路 notFound 态承载,摘要只降级为 error 态)。
|
||||
/// `tz` 透传设备时区固定偏移(T2-13 遗留③):monthlyExpense 的月度
|
||||
/// 窗口随设备时区取边界,与用户直觉一致。
|
||||
Future<void> _loadSummary() async {
|
||||
setState(() => _summaryPhase = _SummaryPhase.loading);
|
||||
try {
|
||||
final summary = await widget.controller.repository.getPetSummary(
|
||||
widget.petId,
|
||||
tz: tzOffsetQueryValue(DateTime.now().timeZoneOffset),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
@@ -185,6 +190,21 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
||||
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();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -271,6 +291,8 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
||||
Text('健康数据', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 10),
|
||||
_summarySection(pet),
|
||||
const SizedBox(height: 16),
|
||||
_recordsSection(pet),
|
||||
const SizedBox(height: 24),
|
||||
Text('基本资料', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 10),
|
||||
@@ -316,7 +338,31 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 数据卡行(05 §4.2 stat-row):三卡取数全部来自 summary 实时聚合,
|
||||
/// 记录导航区:健康时间线入口(T2-14;照护提醒入口随提醒半边落位)。
|
||||
Widget _recordsSection(Pet pet) {
|
||||
return SectionCard(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.event_note_outlined,
|
||||
color: AppColors.inkSoft,
|
||||
),
|
||||
title: const Text('健康时间线', style: TextStyle(fontSize: 14)),
|
||||
subtitle: const Text(
|
||||
'就医 · 喂养 · 驱虫 · 洗护 · 测量 · 随手记',
|
||||
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right, color: AppColors.muted),
|
||||
onTap: () => _openTimeline(pet),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 数据卡行(05 §4.2 stat-row):四卡取数全部来自 summary 实时聚合,
|
||||
/// 不落任何本地展示字符串(第 4.3 节红线)。null 语义 → 空态文案。
|
||||
Widget _summarySection(Pet pet) {
|
||||
switch (_summaryPhase) {
|
||||
@@ -348,13 +394,15 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
||||
final weight = summary.latestWeight;
|
||||
final progress = summary.vaccinationProgress;
|
||||
final next = summary.nextVaccination;
|
||||
final expense = summary.monthlyExpense;
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
type: RecordType.weight,
|
||||
icon: recordTypeStyles[RecordType.weight]!.icon,
|
||||
iconColor: recordTypeStyles[RecordType.weight]!.iconColor,
|
||||
value: weight == null
|
||||
? '暂无记录'
|
||||
: '${formatWeightKg(weight.weightKg)} kg',
|
||||
@@ -366,7 +414,8 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
type: RecordType.vaccine,
|
||||
icon: recordTypeStyles[RecordType.vaccine]!.icon,
|
||||
iconColor: recordTypeStyles[RecordType.vaccine]!.iconColor,
|
||||
// 契约:totalDoses=0 → 整体 null(不是 0/0)→ 空态文案。
|
||||
value: progress == null
|
||||
? '未登记'
|
||||
@@ -379,13 +428,26 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _SummaryCard(
|
||||
type: RecordType.vaccine,
|
||||
icon: recordTypeStyles[RecordType.vaccine]!.icon,
|
||||
iconColor: recordTypeStyles[RecordType.vaccine]!.iconColor,
|
||||
value: next == null ? '暂无安排' : dateToJson(next.dueOn),
|
||||
emphasized: next != null,
|
||||
label: next == null ? '下一针' : '下一针·${next.vaccineName}',
|
||||
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 +455,20 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 数据卡(正典 stat-card 形态):类型图标 + 数值 15/w800 + 标签 12 inkSoft;
|
||||
/// 数据卡(正典 stat-card 形态):图标 + 数值 15/w800 + 标签 12 inkSoft;
|
||||
/// 空态数值降级 inkSoft 常规字重(区分「有数据」与「空态」两种视觉)。
|
||||
class _SummaryCard extends StatelessWidget {
|
||||
const _SummaryCard({
|
||||
required this.type,
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.value,
|
||||
required this.label,
|
||||
required this.emphasized,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final RecordType type;
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final String value;
|
||||
final String label;
|
||||
final bool emphasized;
|
||||
@@ -412,7 +476,6 @@ class _SummaryCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = recordTypeStyles[type]!;
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
@@ -422,7 +485,7 @@ class _SummaryCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(style.icon, size: 18, color: style.iconColor),
|
||||
Icon(icon, size: 18, color: iconColor),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
|
||||
Reference in New Issue
Block a user