From e186ba3da9a2d5976a4c5f292ce95cf7bae827cf Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Tue, 8 Sep 2026 13:49:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E5=81=A5=E5=BA=B7?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=97=B6=E9=97=B4=E7=BA=BF=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E6=95=B0=E6=8D=AE=EF=BC=88T2-14=20=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=BA=BF=E5=8D=8A=E8=BE=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 时间线页: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 --- lib/core/widgets/record_type_dot.dart | 37 +- lib/features/pets/health_event_edit_page.dart | 351 +++++++++++++++ lib/features/pets/health_event_form_page.dart | 416 ++++++++++++++++++ lib/features/pets/health_events_page.dart | 368 ++++++++++++++++ .../pets/health_record_analytics.dart | 41 +- lib/features/pets/health_record_display.dart | 52 ++- lib/features/pets/pet_detail_page.dart | 81 +++- test/core/widgets/record_type_dot_test.dart | 23 +- .../pets/health_event_edit_page_test.dart | 207 +++++++++ .../pets/health_event_form_page_test.dart | 195 ++++++++ .../pets/health_events_page_test.dart | 288 ++++++++++++ .../pets/health_record_analytics_test.dart | 41 ++ .../pets/health_record_display_test.dart | 54 +++ test/features/pets/pet_detail_page_test.dart | 66 ++- test/helpers/pet_test_helpers.dart | 77 ++++ 15 files changed, 2274 insertions(+), 23 deletions(-) create mode 100644 lib/features/pets/health_event_edit_page.dart create mode 100644 lib/features/pets/health_event_form_page.dart create mode 100644 lib/features/pets/health_events_page.dart create mode 100644 test/features/pets/health_event_edit_page_test.dart create mode 100644 test/features/pets/health_event_form_page_test.dart create mode 100644 test/features/pets/health_events_page_test.dart diff --git a/lib/core/widgets/record_type_dot.dart b/lib/core/widgets/record_type_dot.dart index 728880a..8638022 100644 --- a/lib/core/widgets/record_type_dot.dart +++ b/lib/core/widgets/record_type_dot.dart @@ -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 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%。 diff --git a/lib/features/pets/health_event_edit_page.dart b/lib/features/pets/health_event_edit_page.dart new file mode 100644 index 0000000..cf7ec5a --- /dev/null +++ b/lib/features/pets/health_event_edit_page.dart @@ -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 createState() => _HealthEventEditPageState(); +} + +class _HealthEventEditPageState extends State { + 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 _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 _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 _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, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/pets/health_event_form_page.dart b/lib/features/pets/health_event_form_page.dart new file mode 100644 index 0000000..baec899 --- /dev/null +++ b/lib/features/pets/health_event_form_page.dart @@ -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 createState() => _HealthEventFormPageState(); +} + +class _HealthEventFormPageState extends State { + 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 _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, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/pets/health_events_page.dart b/lib/features/pets/health_events_page.dart new file mode 100644 index 0000000..8eba7fa --- /dev/null +++ b/lib/features/pets/health_events_page.dart @@ -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 createState() => _HealthEventsPageState(); +} + +class _HealthEventsPageState extends State { + _ListPhase _phase = _ListPhase.loading; + List _events = const []; + String? _nextCursor; + bool _hasMore = false; + bool _loadingMore = false; + ApiException? _error; + bool _viewedFired = false; + + @override + void initState() { + super.initState(); + _loadFirstPage(); + } + + Future _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 _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 _openCreate() async { + final created = await Navigator.of(context).push( + 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 _openEdit(HealthEvent event) async { + final updated = await Navigator.of(context).push( + // 编辑页不带路由名: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 = []; + 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, + ), + ), + ], + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/pets/health_record_analytics.dart b/lib/features/pets/health_record_analytics.dart index 4bda5c9..faf15a8 100644 --- a/lib/features/pets/health_record_analytics.dart +++ b/lib/features/pets/health_record_analytics.dart @@ -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, + }); + } } diff --git a/lib/features/pets/health_record_display.dart b/lib/features/pets/health_record_display.dart index a183dce..81a88c2 100644 --- a/lib/features/pets/health_record_display.dart +++ b/lib/features/pets/health_record_display.dart @@ -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'; +} diff --git a/lib/features/pets/pet_detail_page.dart b/lib/features/pets/pet_detail_page.dart index 9285421..f76cda0 100644 --- a/lib/features/pets/pet_detail_page.dart +++ b/lib/features/pets/pet_detail_page.dart @@ -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 { } /// 摘要实时聚合(40401 由主链路 notFound 态承载,摘要只降级为 error 态)。 + /// `tz` 透传设备时区固定偏移(T2-13 遗留③):monthlyExpense 的月度 + /// 窗口随设备时区取边界,与用户直觉一致。 Future _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 { if (mounted) await _loadSummary(); } + Future _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 { 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 { ); } - /// 数据卡行(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 { 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 { 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 { 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 { } } -/// 数据卡(正典 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, diff --git a/test/core/widgets/record_type_dot_test.dart b/test/core/widgets/record_type_dot_test.dart index 7427bc1..6117b39 100644 --- a/test/core/widgets/record_type_dot_test.dart +++ b/test/core/widgets/record_type_dot_test.dart @@ -4,8 +4,8 @@ import 'package:patbond_flutter/core/theme/app_theme.dart'; import 'package:patbond_flutter/core/widgets/record_type_dot.dart'; void main() { - testWidgets('五类映射齐备:图标 + 双色(05 §2 表)', (tester) async { - // 映射表唯一出口:五类各有图标、底色、图标色、文字色、文案。 + testWidgets('全类型映射齐备:图标 + 双色(05 §2 表 + T2-14 增补三型)', (tester) async { + // 映射表唯一出口:各类型均有图标、底色、图标色、文字色、文案。 expect(recordTypeStyles.length, RecordType.values.length); expect( recordTypeStyles[RecordType.weight]!.iconColor, @@ -26,6 +26,25 @@ void main() { expect(recordTypeStyles[RecordType.medical]!.inkColor, AppColors.errorDark); expect(recordTypeStyles[RecordType.other]!.inkColor, AppColors.inkSoft); 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 { diff --git a/test/features/pets/health_event_edit_page_test.dart b/test/features/pets/health_event_edit_page_test.dart new file mode 100644 index 0000000..dfae1ca --- /dev/null +++ b/test/features/pets/health_event_edit_page_test.dart @@ -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?)> events; + late HealthRecordAnalytics analytics; + + setUp(() { + repository = FakePetsRepository(); + events = []; + analytics = HealthRecordAnalytics( + (name, [props]) async => events.add((name, props)), + ); + }); + + List?> 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 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( + 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 = []; + 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 = []; + 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); + }); +} diff --git a/test/features/pets/health_event_form_page_test.dart b/test/features/pets/health_event_form_page_test.dart new file mode 100644 index 0000000..9f0d920 --- /dev/null +++ b/test/features/pets/health_event_form_page_test.dart @@ -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?)> events; + late HealthRecordAnalytics analytics; + + setUp(() { + repository = FakePetsRepository(); + events = []; + analytics = HealthRecordAnalytics( + (name, [props]) async => events.add((name, props)), + ); + }); + + List?> eventsOf(String name) => [ + for (final e in events) + if (e.$1 == name) e.$2, + ]; + + Future 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( + builder: (_) => HealthEventFormPage( + repository: repository, + petId: 'p-1', + analytics: analytics, + ), + ), + ), + child: const Text('打开表单'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('打开表单')); + await tester.pumpAndSettle(); + } + + Future 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()); + }); + + 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); + }); +} diff --git a/test/features/pets/health_events_page_test.dart b/test/features/pets/health_events_page_test.dart new file mode 100644 index 0000000..2bfbb55 --- /dev/null +++ b/test/features/pets/health_events_page_test.dart @@ -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?)> events; + late HealthRecordAnalytics analytics; + + setUp(() { + repository = FakePetsRepository(); + events = []; + analytics = HealthRecordAnalytics( + (name, [props]) async => events.add((name, props)), + ); + }); + + List?> eventsOf(String name) => [ + for (final e in events) + if (e.$1 == name) e.$2, + ]; + + Future 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(find.byType(Navigator)); + unawaited( + navigator.push( + MaterialPageRoute( + builder: (_) => HealthEventsPage( + repository: repository, + petId: 'p-1', + canWrite: canWrite, + analytics: analytics, + pageSize: pageSize, + ), + ), + ), + ); + await tester.pump(); + } + + CursorPage page( + List 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>(); + 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); + }); +} diff --git a/test/features/pets/health_record_analytics_test.dart b/test/features/pets/health_record_analytics_test.dart index 8a90f21..b1ab5ec 100644 --- a/test/features/pets/health_record_analytics_test.dart +++ b/test/features/pets/health_record_analytics_test.dart @@ -82,4 +82,45 @@ void main() { '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', + }); + }); } diff --git a/test/features/pets/health_record_display_test.dart b/test/features/pets/health_record_display_test.dart index 24ffedc..aed5319 100644 --- a/test/features/pets/health_record_display_test.dart +++ b/test/features/pets/health_record_display_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.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/pet_models.dart'; @@ -147,4 +148,57 @@ 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', + ); + }); + }); } diff --git a/test/features/pets/pet_detail_page_test.dart b/test/features/pets/pet_detail_page_test.dart index 5365fe1..95a460f 100644 --- a/test/features/pets/pet_detail_page_test.dart +++ b/test/features/pets/pet_detail_page_test.dart @@ -4,6 +4,8 @@ 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_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_exceptions.dart'; import 'package:patbond_flutter/features/pets/pet_form_page.dart'; @@ -24,8 +26,9 @@ void main() { }); Future pumpDetail(WidgetTester tester, {String petId = 'p-1'}) async { - // 详情页自 T2-13 增加健康数据卡行,加高视口保证底部按钮在栏内。 - tester.view.physicalSize = const Size(700, 1800); + // 详情页自 T2-13 增加健康数据卡行、T2-14 增加花费卡与记录导航区, + // 加高视口保证底部按钮在栏内。 + tester.view.physicalSize = const Size(700, 2200); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); await tester.pumpWidget( @@ -184,7 +187,7 @@ void main() { }); group('T2-13 · 数据卡行接 summary', () { - testWidgets('三卡取数:最新体重 / 疫苗进度 / 下一针(含疫苗名)', (tester) async { + testWidgets('四卡取数:最新体重 / 疫苗进度 / 下一针 / 本月花费(tz 透传)', (tester) async { repository.getPetHandler = (petId) async => buildPet('p-1'); String? capturedTz; repository.getPetSummaryHandler = (petId, tz) async { @@ -202,8 +205,12 @@ void main() { expect(find.text('疫苗进度'), findsOneWidget); expect(find.text('2026-08-01'), findsOneWidget); expect(find.text('下一针·狂犬疫苗'), findsOneWidget); - // 本单不消费 monthlyExpense(T2-14),tz 不传走服务端缺省 UTC。 - expect(capturedTz, isNull); + // T2-14:月度花费卡接 monthlyExpense(12850 分 → 元展示)。 + 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 { @@ -292,4 +299,53 @@ void main() { 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); + }); + }); } diff --git a/test/helpers/pet_test_helpers.dart b/test/helpers/pet_test_helpers.dart index fcfe916..221a1f1 100644 --- a/test/helpers/pet_test_helpers.dart +++ b/test/helpers/pet_test_helpers.dart @@ -163,6 +163,23 @@ Vaccination buildVaccination( }) => Vaccination.fromJson({...sampleVaccinationJson(), 'id': id, ...overrides}); +/// 快速构造健康事件。 +HealthEvent buildHealthEvent( + String id, { + Map overrides = const {}, +}) => + HealthEvent.fromJson({...sampleHealthEventJson(), 'id': id, ...overrides}); + +/// 快速构造照护提醒。 +CareReminder buildReminder( + String id, { + Map overrides = const {}, +}) => CareReminder.fromJson({ + ...sampleCareReminderJson(), + 'id': id, + ...overrides, +}); + /// 快速构造摘要(缺省为「三聚合齐备」样本;overrides 可置 null 验证空态)。 PetSummary buildSummary({Map overrides = const {}}) => PetSummary.fromJson({...samplePetSummaryJson(), ...overrides}); @@ -184,6 +201,20 @@ class FakePetsRepository implements PetsRepository { Future> Function(String)? listVaccinationsHandler; Future Function(String, CreateVaccinationRequest)? createVaccinationHandler; + Future Function(String, UpdateVaccinationRequest)? + updateVaccinationHandler; + Future> Function(String, int?, String?)? + listHealthEventsHandler; + Future Function(String, CreateHealthEventRequest)? + createHealthEventHandler; + Future Function(String, UpdateHealthEventRequest)? + updateHealthEventHandler; + Future> Function(String, CareReminderStatus?)? + listCareRemindersHandler; + Future Function(String, CreateCareReminderRequest)? + createCareReminderHandler; + Future Function(String, UpdateCareReminderRequest)? + updateCareReminderHandler; Future Function(String, String?)? getPetSummaryHandler; @override @@ -233,6 +264,52 @@ class FakePetsRepository implements PetsRepository { CreateVaccinationRequest request, ) => createVaccinationHandler!(petId, request); + @override + Future updateVaccination( + String vaccinationId, + UpdateVaccinationRequest request, + ) => updateVaccinationHandler!(vaccinationId, request); + + @override + Future> listHealthEvents( + String petId, { + int? limit, + String? cursor, + }) => listHealthEventsHandler!(petId, limit, cursor); + + @override + Future createHealthEvent( + String petId, + CreateHealthEventRequest request, + ) => createHealthEventHandler!(petId, request); + + @override + Future updateHealthEvent( + String eventId, + UpdateHealthEventRequest request, + ) => updateHealthEventHandler!(eventId, request); + + @override + Future> listCareReminders( + String petId, { + CareReminderStatus? status, + }) => + listCareRemindersHandler?.call(petId, status) ?? + // 缺省空列表:既有详情页测试不必逐个注入。 + Future.value(const []); + + @override + Future createCareReminder( + String petId, + CreateCareReminderRequest request, + ) => createCareReminderHandler!(petId, request); + + @override + Future updateCareReminder( + String reminderId, + UpdateCareReminderRequest request, + ) => updateCareReminderHandler!(reminderId, request); + @override Future getPetSummary(String petId, {String? tz}) => getPetSummaryHandler?.call(petId, tz) ??