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