- 照护提醒页:status 过滤(服务端白名单视图)、due_at ASC、逾期红标 双通道标识,四态齐备;创建表单(四类)+ 完成(completedAt 必带、 支持补记)/ 忽略(禁带 completedAt)流转,42202/40902 兜底重拉 - 档案页:「健康提醒」卡改真实待办数据驱动(取代 demo 硬编码文案, 最近到期一条 + 逾期警示形态),照护提醒导航入口带待办数副行 - 疫苗列表(25 号报告遗留①②):scheduled 行「标记完成/取消登记」 PATCH 流转;完成对话框补录厂商/批号(契约可选字段); 42201/40902/40402 兜底 - 埋点:create 三事件 + viewed(recordType=reminder)挂通; edit_succeeded/failed 挂 vaccine 流转(failureReason 含 conflict); 提醒完成/忽略按 06 §7 缺口 3 既定取舍不埋 - 测试 250 → 272 全绿(+22,较基线 +48);analyze 0 问题;format 无 diff Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/semantics.dart';
|
||||
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
|
||||
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||
import 'package:patbond_flutter/core/widgets/primary_button.dart';
|
||||
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||
|
||||
/// 照护提醒创建表单页(T2-14)。
|
||||
///
|
||||
/// - 四类提醒类型(驱虫/体检/用药/其他)必选;标题必填;到期日期必选
|
||||
/// (今日取此刻、未来日期取当日 12:00,转 UTC——与记录表单同一约定)。
|
||||
/// - 契约:创建恒为 pending(不收 status);title/dueAt 无编辑端点,
|
||||
/// 改期路径为忽略后重建。
|
||||
/// - 埋点:health_record_create_started/succeeded/failed
|
||||
/// (recordType=reminder)。
|
||||
class CareReminderFormPage extends StatefulWidget {
|
||||
const CareReminderFormPage({
|
||||
required this.repository,
|
||||
required this.petId,
|
||||
super.key,
|
||||
this.analytics,
|
||||
this.entryPoint = HealthRecordEntryPoint.recordList,
|
||||
});
|
||||
|
||||
final PetsRepository repository;
|
||||
final String petId;
|
||||
final HealthRecordAnalytics? analytics;
|
||||
final HealthRecordEntryPoint entryPoint;
|
||||
|
||||
@override
|
||||
State<CareReminderFormPage> createState() => _CareReminderFormPageState();
|
||||
}
|
||||
|
||||
class _CareReminderFormPageState extends State<CareReminderFormPage> {
|
||||
final _titleCtrl = TextEditingController();
|
||||
|
||||
CareReminderType? _type;
|
||||
DateTime? _dueDate;
|
||||
String? _typeError;
|
||||
String? _titleError;
|
||||
String? _dueError;
|
||||
String? _formError;
|
||||
bool _submitting = false;
|
||||
|
||||
bool _startedFired = false;
|
||||
int _attemptSeq = 0;
|
||||
late final DateTime _openedAt;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_openedAt = DateTime.now();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _markStarted() {
|
||||
if (_startedFired) return;
|
||||
_startedFired = true;
|
||||
widget.analytics?.createStarted(
|
||||
recordType: HealthRecordType.reminder,
|
||||
entryPoint: widget.entryPoint,
|
||||
);
|
||||
}
|
||||
|
||||
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
|
||||
widget.analytics?.createFailed(
|
||||
recordType: HealthRecordType.reminder,
|
||||
reason: reason,
|
||||
attemptSeq: _attemptSeq,
|
||||
errorCode: errorCode,
|
||||
);
|
||||
}
|
||||
|
||||
void _showFormError(String message) {
|
||||
setState(() => _formError = message);
|
||||
SemanticsService.sendAnnouncement(
|
||||
View.of(context),
|
||||
message,
|
||||
TextDirection.ltr,
|
||||
);
|
||||
}
|
||||
|
||||
/// 到期时刻:今日取此刻,其余日期取当日 12:00(本地),提交前转 UTC。
|
||||
DateTime _dueAt() {
|
||||
final now = DateTime.now();
|
||||
final date = _dueDate!;
|
||||
final sameDay =
|
||||
date.year == now.year && date.month == now.month && date.day == now.day;
|
||||
final local = sameDay ? now : DateTime(date.year, date.month, date.day, 12);
|
||||
return local.toUtc();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_submitting) return;
|
||||
_attemptSeq++;
|
||||
final typeError = _type == null ? '请选择提醒类型' : null;
|
||||
final titleError = _titleCtrl.text.trim().isEmpty ? '请输入提醒内容' : null;
|
||||
final dueError = _dueDate == null ? '请选择到期日期' : null;
|
||||
if (typeError != null || titleError != null || dueError != null) {
|
||||
setState(() {
|
||||
_typeError = typeError;
|
||||
_titleError = titleError;
|
||||
_dueError = dueError;
|
||||
});
|
||||
_trackFailed(HealthRecordFailureReason.validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
_formError = null;
|
||||
});
|
||||
try {
|
||||
final reminder = await widget.repository.createCareReminder(
|
||||
widget.petId,
|
||||
CreateCareReminderRequest(
|
||||
reminderType: _type!,
|
||||
title: _titleCtrl.text.trim(),
|
||||
dueAt: _dueAt(),
|
||||
),
|
||||
);
|
||||
widget.analytics?.createSucceeded(
|
||||
recordType: HealthRecordType.reminder,
|
||||
durationMs: DateTime.now().difference(_openedAt).inMilliseconds,
|
||||
);
|
||||
if (mounted) Navigator.of(context).pop(reminder);
|
||||
} on PetAccessDeniedException {
|
||||
if (!mounted) return;
|
||||
_showFormError('你没有权限为该宠物添加提醒');
|
||||
_trackFailed(HealthRecordFailureReason.permissionDenied, 40300);
|
||||
} on PetNotFoundException {
|
||||
if (!mounted) return;
|
||||
final navigator = Navigator.of(context);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('宠物不存在或已被删除')));
|
||||
_trackFailed(HealthRecordFailureReason.notFound, 40401);
|
||||
navigator.pop();
|
||||
} on ApiRateLimitException {
|
||||
if (!mounted) return;
|
||||
_showFormError('操作过于频繁,请稍后再试');
|
||||
_trackFailed(HealthRecordFailureReason.rateLimited);
|
||||
} on ApiBusinessException catch (error) {
|
||||
if (!mounted) return;
|
||||
final isParam = error.code == ApiCodes.paramError;
|
||||
_showFormError(isParam ? '请检查填写内容后重试' : '保存失败,请稍后重试');
|
||||
_trackFailed(
|
||||
isParam
|
||||
? HealthRecordFailureReason.validationError
|
||||
: HealthRecordFailureReason.serverError,
|
||||
error.code,
|
||||
);
|
||||
} on ApiNetworkException {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('网络异常,请检查网络后重试'),
|
||||
action: SnackBarAction(label: '重试', onPressed: _submit),
|
||||
),
|
||||
);
|
||||
_trackFailed(HealthRecordFailureReason.networkError);
|
||||
} on SessionExpiredException {
|
||||
// 会话失效:认证状态机自动回登录页。
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
foregroundColor: AppColors.ink,
|
||||
title: const Text('添加照护提醒'),
|
||||
centerTitle: true,
|
||||
titleTextStyle: const TextStyle(
|
||||
color: AppColors.ink,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 30),
|
||||
children: [
|
||||
const Text(
|
||||
'提醒类型',
|
||||
style: TextStyle(
|
||||
color: AppColors.inkSoft,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final type in CareReminderType.values)
|
||||
ChoiceChip(
|
||||
label: Text(careReminderTypeLabel(type)),
|
||||
selected: _type == type,
|
||||
onSelected: _submitting
|
||||
? null
|
||||
: (_) {
|
||||
_markStarted();
|
||||
setState(() {
|
||||
_type = type;
|
||||
_typeError = null;
|
||||
_formError = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_typeError != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_typeError!,
|
||||
style: const TextStyle(color: AppColors.error, fontSize: 12),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 18),
|
||||
AppTextField(
|
||||
label: '提醒内容(如:体内外驱虫)',
|
||||
controller: _titleCtrl,
|
||||
prefixIcon: Icons.notifications_outlined,
|
||||
errorText: _titleError,
|
||||
enabled: !_submitting,
|
||||
textInputAction: TextInputAction.done,
|
||||
onChanged: (_) {
|
||||
_markStarted();
|
||||
if (_titleError != null || _formError != null) {
|
||||
setState(() {
|
||||
_titleError = null;
|
||||
_formError = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
tileColor: AppColors.surface,
|
||||
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
|
||||
title: const Text('到期日期', style: TextStyle(fontSize: 14)),
|
||||
subtitle: Text(
|
||||
_dueDate == null ? '未选择' : dateToJson(_dueDate!),
|
||||
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.calendar_month_outlined,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
enabled: !_submitting,
|
||||
onTap: () async {
|
||||
final now = DateTime.now();
|
||||
final value = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _dueDate ?? now,
|
||||
firstDate: now,
|
||||
lastDate: DateTime(now.year + 5),
|
||||
);
|
||||
if (value != null && mounted) {
|
||||
_markStarted();
|
||||
setState(() {
|
||||
_dueDate = value;
|
||||
_dueError = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
if (_dueError != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_dueError!,
|
||||
style: const TextStyle(color: AppColors.error, fontSize: 12),
|
||||
),
|
||||
],
|
||||
if (_formError != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
InlineErrorBanner(message: _formError!),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
PrimaryButton(
|
||||
label: '保存提醒',
|
||||
isLoading: _submitting,
|
||||
onPressed: _submit,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||
import 'package:patbond_flutter/core/navigation/fade_route.dart';
|
||||
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||
import 'package:patbond_flutter/features/pets/care_reminder_form_page.dart';
|
||||
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||
import 'package:patbond_flutter/features/pets/pet_display.dart';
|
||||
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||
import 'package:patbond_flutter/widgets/common.dart';
|
||||
|
||||
enum _ListPhase { loading, ready, error }
|
||||
|
||||
/// 照护提醒页(T2-14):契约不分页,`due_at ASC`(待办最先到期在前);
|
||||
/// `?status=` 过滤走服务端白名单视图;逾期待办有显性视觉标识。
|
||||
///
|
||||
/// - 完成 / 忽略:`PATCH /care-reminders/{id}` 状态流转
|
||||
/// (pending→completed 必带 completedAt,pending→dismissed 禁带);
|
||||
/// 42202 规则兜底、40902 并发流转抢先提示后重拉。
|
||||
/// - 提醒完成/忽略**不埋事件**(06 §7 缺口 3 既定取舍:完成率从
|
||||
/// care_reminders 事实表出数;M3+ 推送实验时再增补)。
|
||||
/// - 曝光埋点:每次进入首个成功加载上报一次
|
||||
/// `health_record_viewed(recordType=reminder, source=pet_detail)`。
|
||||
class CareRemindersPage extends StatefulWidget {
|
||||
const CareRemindersPage({
|
||||
required this.repository,
|
||||
required this.petId,
|
||||
required this.canWrite,
|
||||
super.key,
|
||||
this.analytics,
|
||||
});
|
||||
|
||||
final PetsRepository repository;
|
||||
final String petId;
|
||||
|
||||
/// owner/caregiver 可写;viewer 隐藏创建与完成/忽略入口。
|
||||
final bool canWrite;
|
||||
|
||||
final HealthRecordAnalytics? analytics;
|
||||
|
||||
@override
|
||||
State<CareRemindersPage> createState() => _CareRemindersPageState();
|
||||
}
|
||||
|
||||
class _CareRemindersPageState extends State<CareRemindersPage> {
|
||||
_ListPhase _phase = _ListPhase.loading;
|
||||
List<CareReminder> _reminders = const [];
|
||||
ApiException? _error;
|
||||
CareReminderStatus? _filter;
|
||||
bool _viewedFired = false;
|
||||
bool _mutating = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_phase = _ListPhase.loading;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final reminders = await widget.repository.listCareReminders(
|
||||
widget.petId,
|
||||
status: _filter,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_reminders = reminders;
|
||||
_phase = _ListPhase.ready;
|
||||
});
|
||||
if (!_viewedFired) {
|
||||
_viewedFired = true;
|
||||
widget.analytics?.viewed(
|
||||
recordType: HealthRecordType.reminder,
|
||||
source: HealthRecordViewSource.petDetail,
|
||||
);
|
||||
}
|
||||
} on ApiException catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = error;
|
||||
_phase = _ListPhase.error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openCreate() async {
|
||||
final created = await Navigator.of(context).push<CareReminder>(
|
||||
fadePageRoute(
|
||||
CareReminderFormPage(
|
||||
repository: widget.repository,
|
||||
petId: widget.petId,
|
||||
analytics: widget.analytics,
|
||||
),
|
||||
settings: RouteSettings(name: AnalyticsPageName.recordForm.pageName),
|
||||
),
|
||||
);
|
||||
if (created != null && mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('已添加提醒')));
|
||||
// 排序键 due_at ASC 在服务端,重新拉取而非本地猜位置。
|
||||
await _load();
|
||||
}
|
||||
}
|
||||
|
||||
/// 完成时刻:今日取此刻,补记历史日期取当日 12:00(本地),转 UTC。
|
||||
DateTime _completedAt(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final sameDay =
|
||||
date.year == now.year && date.month == now.month && date.day == now.day;
|
||||
final local = sameDay ? now : DateTime(date.year, date.month, date.day, 12);
|
||||
return local.toUtc();
|
||||
}
|
||||
|
||||
Future<void> _complete(CareReminder reminder) async {
|
||||
final date = await showDialog<DateTime>(
|
||||
context: context,
|
||||
builder: (context) => _CompleteDialog(title: reminder.title),
|
||||
);
|
||||
if (date == null || !mounted) return;
|
||||
await _mutate(
|
||||
reminder,
|
||||
UpdateCareReminderRequest(
|
||||
status: CareReminderStatus.completed,
|
||||
// 契约:pending→completed 必带 completedAt(客户端提交,允许补记)。
|
||||
completedAt: _completedAt(date),
|
||||
),
|
||||
successText: '已标记完成',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _dismiss(CareReminder reminder) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('忽略这条提醒?'),
|
||||
content: Text('「${reminder.title}」将不再出现在待办中,且不可恢复为待办。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('忽略'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
await _mutate(
|
||||
reminder,
|
||||
// 契约:pending→dismissed 禁带 completedAt。
|
||||
const UpdateCareReminderRequest(status: CareReminderStatus.dismissed),
|
||||
successText: '已忽略提醒',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _mutate(
|
||||
CareReminder reminder,
|
||||
UpdateCareReminderRequest request, {
|
||||
required String successText,
|
||||
}) async {
|
||||
if (_mutating) return;
|
||||
setState(() => _mutating = true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await widget.repository.updateCareReminder(reminder.id, request);
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(SnackBar(content: Text(successText)));
|
||||
await _load();
|
||||
} on CareReminderRuleException {
|
||||
if (!mounted) return;
|
||||
// 42202:状态机/completedAt 一致性兜底(前端结构上已按状态发字段)。
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('提醒状态不满足流转规则,已刷新,请重试')),
|
||||
);
|
||||
await _load();
|
||||
} on PetVersionConflictException {
|
||||
if (!mounted) return;
|
||||
// 40902:并发流转抢先(条件更新守卫落空),处理方式与乐观锁一致。
|
||||
messenger.showSnackBar(const SnackBar(content: Text('提醒已在其他设备被处理,已刷新')));
|
||||
await _load();
|
||||
} on PetRecordNotFoundException {
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(const SnackBar(content: Text('提醒不存在或已被删除,已刷新')));
|
||||
await _load();
|
||||
} on PetAccessDeniedException {
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(const SnackBar(content: Text('你没有权限操作该提醒')));
|
||||
} on ApiBusinessException {
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(const SnackBar(content: Text('操作失败,请稍后重试')));
|
||||
} on ApiNetworkException {
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(const SnackBar(content: Text('网络异常,请检查网络后重试')));
|
||||
} on SessionExpiredException {
|
||||
// 会话失效:认证状态机自动回登录页。
|
||||
} finally {
|
||||
if (mounted) setState(() => _mutating = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
foregroundColor: AppColors.ink,
|
||||
title: const Text('照护提醒'),
|
||||
centerTitle: true,
|
||||
titleTextStyle: const TextStyle(
|
||||
color: AppColors.ink,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
actions: [
|
||||
if (widget.canWrite && _phase == _ListPhase.ready)
|
||||
IconButton(
|
||||
tooltip: '添加提醒',
|
||||
onPressed: _openCreate,
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||
child: _filterChips(),
|
||||
),
|
||||
Expanded(
|
||||
child: switch (_phase) {
|
||||
_ListPhase.loading => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
_ListPhase.error => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
InlineErrorBanner(message: petLoadErrorMessage(_error)),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: _load, child: const Text('重试')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_ListPhase.ready when _reminders.isEmpty => Center(
|
||||
child: SingleChildScrollView(child: _emptyState()),
|
||||
),
|
||||
_ListPhase.ready => _list(),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 状态过滤(服务端白名单视图;「待办」即 ?status=pending 按 due_at 查询)。
|
||||
Widget _filterChips() {
|
||||
final options = <(String, CareReminderStatus?)>[
|
||||
('全部', null),
|
||||
('待办', CareReminderStatus.pending),
|
||||
('已完成', CareReminderStatus.completed),
|
||||
('已忽略', CareReminderStatus.dismissed),
|
||||
];
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final (label, status) in options)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(label),
|
||||
selected: _filter == status,
|
||||
onSelected: (_) {
|
||||
if (_filter == status) return;
|
||||
setState(() => _filter = status);
|
||||
_load();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _emptyState() {
|
||||
// 过滤视图下的空态不给 CTA(05 §4.2 筛选后空态惯例)。
|
||||
if (_filter != null) {
|
||||
return EmptyStateIllustration(
|
||||
icon: Icons.notifications_none_outlined,
|
||||
title: '暂无「${careReminderStatusLabel(_filter!)}」提醒',
|
||||
);
|
||||
}
|
||||
return EmptyStateIllustration(
|
||||
icon: Icons.notifications_none_outlined,
|
||||
title: '还没有照护提醒',
|
||||
description: '驱虫、体检、用药……到点不忘每一件照护小事',
|
||||
ctaLabel: widget.canWrite ? '添加第一条' : null,
|
||||
onCtaPressed: widget.canWrite ? _openCreate : null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _list() {
|
||||
final now = DateTime.now();
|
||||
return RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 30),
|
||||
children: [
|
||||
for (final reminder in _reminders) ...[
|
||||
_ReminderTile(
|
||||
reminder: reminder,
|
||||
now: now,
|
||||
// 完成/忽略仅对待办可用(终态不可迁;viewer 无写权限)。
|
||||
onComplete:
|
||||
widget.canWrite &&
|
||||
reminder.status == CareReminderStatus.pending &&
|
||||
!_mutating
|
||||
? () => _complete(reminder)
|
||||
: null,
|
||||
onDismiss:
|
||||
widget.canWrite &&
|
||||
reminder.status == CareReminderStatus.pending &&
|
||||
!_mutating
|
||||
? () => _dismiss(reminder)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 提醒条目:RecordTypeDot + 标题 + 时间副行 + 状态 TagPill(逾期红标),
|
||||
/// 待办行附「标记完成 / 忽略」动作(图标 + 文字双通道)。
|
||||
class _ReminderTile extends StatelessWidget {
|
||||
const _ReminderTile({
|
||||
required this.reminder,
|
||||
required this.now,
|
||||
this.onComplete,
|
||||
this.onDismiss,
|
||||
});
|
||||
|
||||
final CareReminder reminder;
|
||||
final DateTime now;
|
||||
final VoidCallback? onComplete;
|
||||
final VoidCallback? onDismiss;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final overdue = isReminderOverdue(reminder, now);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
RecordTypeDot(
|
||||
type: recordTypeForReminder(reminder.reminderType),
|
||||
size: RecordTypeDotSize.md,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
reminder.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${careReminderTypeLabel(reminder.reminderType)} · '
|
||||
'${reminderDateLine(reminder)}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
// 逾期副行同步警示色(与标签双位标识)。
|
||||
color: overdue
|
||||
? AppColors.errorDark
|
||||
: AppColors.inkSoft,
|
||||
fontSize: 12,
|
||||
fontWeight: overdue
|
||||
? FontWeight.w700
|
||||
: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TagPill(
|
||||
reminderStatusTag(reminder, now),
|
||||
color: reminderStatusColor(reminder, now),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (onComplete != null || onDismiss != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: onDismiss,
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.inkSoft,
|
||||
),
|
||||
label: const Text('忽略'),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
TextButton.icon(
|
||||
onPressed: onComplete,
|
||||
icon: const Icon(Icons.check_circle_outline, size: 16),
|
||||
label: const Text('标记完成'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记完成对话框:完成日期默认今天,可补记历史时刻(契约 completedAt
|
||||
/// 由客户端提交)。确认返回所选日期。
|
||||
class _CompleteDialog extends StatefulWidget {
|
||||
const _CompleteDialog({required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<_CompleteDialog> createState() => _CompleteDialogState();
|
||||
}
|
||||
|
||||
class _CompleteDialogState extends State<_CompleteDialog> {
|
||||
DateTime _date = DateTime.now();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('标记完成'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'「${widget.title}」',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
|
||||
title: const Text('完成日期', style: TextStyle(fontSize: 14)),
|
||||
subtitle: Text(
|
||||
dateToJson(_date),
|
||||
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||
),
|
||||
onTap: () async {
|
||||
final now = DateTime.now();
|
||||
final value = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _date,
|
||||
firstDate: DateTime(1990),
|
||||
lastDate: now,
|
||||
);
|
||||
if (value != null && mounted) {
|
||||
setState(() => _date = value);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(_date),
|
||||
child: const Text('确认完成'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)}',
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||
import 'package:patbond_flutter/core/widgets/pet_avatar.dart';
|
||||
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||
import 'package:patbond_flutter/features/pets/care_reminders_page.dart';
|
||||
import 'package:patbond_flutter/features/pets/health_events_page.dart';
|
||||
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||
@@ -24,6 +25,8 @@ enum _DetailPhase { loading, ready, error, notFound }
|
||||
|
||||
enum _SummaryPhase { loading, ready, error }
|
||||
|
||||
enum _RemindersPhase { loading, ready, error }
|
||||
|
||||
/// 宠物详情页(T2-12 / 05 号规范 §4.2 P2 的档案信息部分)。
|
||||
///
|
||||
/// 打开即用控制器内存副本首屏渲染,同时经 [PetsController.getPet]
|
||||
@@ -59,6 +62,9 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
||||
_SummaryPhase _summaryPhase = _SummaryPhase.loading;
|
||||
PetSummary? _summary;
|
||||
|
||||
_RemindersPhase _remindersPhase = _RemindersPhase.loading;
|
||||
List<CareReminder> _pendingReminders = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -66,6 +72,7 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
||||
if (_pet != null) _phase = _DetailPhase.ready;
|
||||
_load();
|
||||
_loadSummary();
|
||||
_loadPendingReminders();
|
||||
}
|
||||
|
||||
Pet? _fromController() {
|
||||
@@ -126,6 +133,27 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 待办提醒(?status=pending,due_at ASC):驱动「健康提醒」卡与
|
||||
/// 提醒入口副行——demo 时代的硬编码提醒文案自此为真实数据取代。
|
||||
/// 失败只降级为入口副行提示,不阻塞档案主链路。
|
||||
Future<void> _loadPendingReminders() async {
|
||||
setState(() => _remindersPhase = _RemindersPhase.loading);
|
||||
try {
|
||||
final reminders = await widget.controller.repository.listCareReminders(
|
||||
widget.petId,
|
||||
status: CareReminderStatus.pending,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_pendingReminders = reminders;
|
||||
_remindersPhase = _RemindersPhase.ready;
|
||||
});
|
||||
} on ApiException {
|
||||
if (!mounted) return;
|
||||
setState(() => _remindersPhase = _RemindersPhase.error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openEdit() async {
|
||||
final pet = _pet;
|
||||
if (pet == null) return;
|
||||
@@ -205,6 +233,21 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
||||
if (mounted) await _loadSummary();
|
||||
}
|
||||
|
||||
Future<void> _openReminders(Pet pet) async {
|
||||
await Navigator.of(context).push(
|
||||
fadePageRoute(
|
||||
CareRemindersPage(
|
||||
repository: widget.controller.repository,
|
||||
petId: pet.id,
|
||||
canWrite: _canWriteRecords,
|
||||
analytics: widget.healthAnalytics,
|
||||
),
|
||||
),
|
||||
);
|
||||
// 待办可能已变化(完成/忽略/新建):返回即重拉待办。
|
||||
if (mounted) await _loadPendingReminders();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -338,27 +381,72 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 记录导航区:健康时间线入口(T2-14;照护提醒入口随提醒半边落位)。
|
||||
/// 记录导航区:健康时间线与照护提醒入口(T2-14)。待办提醒非空时
|
||||
/// 上方渲染真实数据驱动的「健康提醒」卡(正典 alert-card 形态,
|
||||
/// 取代 demo 硬编码文案),点卡与点入口同去提醒页。
|
||||
Widget _recordsSection(Pet pet) {
|
||||
return SectionCard(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.event_note_outlined,
|
||||
color: AppColors.inkSoft,
|
||||
),
|
||||
title: const Text('健康时间线', style: TextStyle(fontSize: 14)),
|
||||
subtitle: const Text(
|
||||
'就医 · 喂养 · 驱虫 · 洗护 · 测量 · 随手记',
|
||||
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right, color: AppColors.muted),
|
||||
onTap: () => _openTimeline(pet),
|
||||
final nearest = _pendingReminders.isEmpty ? null : _pendingReminders.first;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (nearest != null) ...[
|
||||
_ReminderAlertCard(
|
||||
reminder: nearest,
|
||||
overdue: isReminderOverdue(nearest, DateTime.now()),
|
||||
onTap: () => _openReminders(pet),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
),
|
||||
SectionCard(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.event_note_outlined,
|
||||
color: AppColors.inkSoft,
|
||||
),
|
||||
title: const Text('健康时间线', style: TextStyle(fontSize: 14)),
|
||||
subtitle: const Text(
|
||||
'就医 · 喂养 · 驱虫 · 洗护 · 测量 · 随手记',
|
||||
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
onTap: () => _openTimeline(pet),
|
||||
),
|
||||
const Divider(height: 1, thickness: 1, color: AppColors.border),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.notifications_outlined,
|
||||
color: AppColors.inkSoft,
|
||||
),
|
||||
title: const Text('照护提醒', style: TextStyle(fontSize: 14)),
|
||||
subtitle: Text(
|
||||
switch (_remindersPhase) {
|
||||
_RemindersPhase.loading => '加载中…',
|
||||
_RemindersPhase.error => '提醒加载失败,点击查看',
|
||||
_RemindersPhase.ready when _pendingReminders.isEmpty =>
|
||||
'暂无待办提醒',
|
||||
_RemindersPhase.ready => '${_pendingReminders.length} 条待办',
|
||||
},
|
||||
style: const TextStyle(
|
||||
color: AppColors.inkSoft,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
onTap: () => _openReminders(pet),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -550,3 +638,62 @@ class _RowDivider extends StatelessWidget {
|
||||
return const Divider(height: 1, thickness: 1, color: AppColors.border);
|
||||
}
|
||||
}
|
||||
|
||||
/// 「健康提醒」卡(正典 alert-card:successSurface 底 + dot + 文字):
|
||||
/// 数据源为最近到期的待办提醒(真实数据驱动,取代 demo 硬编码文案);
|
||||
/// 逾期时文案切警示深色(双通道:前缀文字 + 颜色)。
|
||||
class _ReminderAlertCard extends StatelessWidget {
|
||||
const _ReminderAlertCard({
|
||||
required this.reminder,
|
||||
required this.overdue,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final CareReminder reminder;
|
||||
final bool overdue;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textColor = overdue ? AppColors.errorDark : AppColors.successInk;
|
||||
return Material(
|
||||
color: AppColors.successSurface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'健康提醒:${reminder.title}'
|
||||
'(${overdue ? '已逾期' : '${dateToJson(reminder.dueAt.toLocal())} 到期'})',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.chevron_right, size: 18, color: textColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||
import 'package:patbond_flutter/core/navigation/fade_route.dart';
|
||||
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
|
||||
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||||
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||||
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||||
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||
import 'package:patbond_flutter/features/pets/health_record_display.dart';
|
||||
import 'package:patbond_flutter/features/pets/pet_display.dart';
|
||||
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||
import 'package:patbond_flutter/features/pets/vaccination_form_page.dart';
|
||||
@@ -21,6 +23,10 @@ enum _ListPhase { loading, ready, error }
|
||||
/// 含 cancelled 行原样展示(取消后同剂次可重新登记的事实留痕)。
|
||||
/// 四态齐备;登记经 [VaccinationFormPage]。
|
||||
///
|
||||
/// T2-14 收尾(25 号报告 §7 遗留①②):scheduled 行支持「标记完成 /
|
||||
/// 取消登记」PATCH 流转;完成时可补录厂商/批号(契约可选字段);
|
||||
/// 挂 `health_record_edit_succeeded/failed`(recordType=vaccine)。
|
||||
///
|
||||
/// 曝光埋点:每次进入首个成功加载上报一次
|
||||
/// `health_record_viewed(recordType=vaccine, source=pet_detail)`。
|
||||
class VaccinationRecordsPage extends StatefulWidget {
|
||||
@@ -53,6 +59,7 @@ class _VaccinationRecordsPageState extends State<VaccinationRecordsPage> {
|
||||
List<Vaccination> _records = const [];
|
||||
ApiException? _error;
|
||||
bool _viewedFired = false;
|
||||
bool _mutating = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -109,6 +116,124 @@ class _VaccinationRecordsPageState extends State<VaccinationRecordsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _trackEditFailed(HealthRecordFailureReason reason, [int? errorCode]) {
|
||||
widget.analytics?.editFailed(
|
||||
recordType: HealthRecordType.vaccine,
|
||||
reason: reason,
|
||||
errorCode: errorCode,
|
||||
);
|
||||
}
|
||||
|
||||
/// 标记完成(25 号报告遗留①②):接种日期必填、下次接种/厂商/批号可选
|
||||
/// (厂商/批号为契约可选字段的补录入口)。
|
||||
Future<void> _markCompleted(Vaccination record) async {
|
||||
final result = await showDialog<_CompleteVaccinationResult>(
|
||||
context: context,
|
||||
builder: (context) => _CompleteVaccinationDialog(record: record),
|
||||
);
|
||||
if (result == null || !mounted) return;
|
||||
await _mutate(
|
||||
record,
|
||||
UpdateVaccinationRequest(
|
||||
version: record.version,
|
||||
status: VaccinationStatus.completed,
|
||||
administeredOn: result.administeredOn,
|
||||
nextDueOn: result.nextDueOn,
|
||||
manufacturer: result.manufacturer,
|
||||
batchNo: result.batchNo,
|
||||
),
|
||||
successText: '已标记完成',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _cancelRegistration(Vaccination record) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('取消这条登记?'),
|
||||
content: Text(
|
||||
'「${vaccinationDoseLabel(record)}」将标记为已取消;'
|
||||
'取消后同系列同剂次可重新登记。',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('返回'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('取消登记'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
await _mutate(
|
||||
record,
|
||||
UpdateVaccinationRequest(
|
||||
version: record.version,
|
||||
status: VaccinationStatus.cancelled,
|
||||
),
|
||||
successText: '已取消登记',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _mutate(
|
||||
Vaccination record,
|
||||
UpdateVaccinationRequest request, {
|
||||
required String successText,
|
||||
}) async {
|
||||
if (_mutating) return;
|
||||
setState(() => _mutating = true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await widget.repository.updateVaccination(record.id, request);
|
||||
widget.analytics?.editSucceeded(
|
||||
recordType: HealthRecordType.vaccine,
|
||||
fieldCount: request.toJson().length - 1,
|
||||
);
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(SnackBar(content: Text(successText)));
|
||||
await _load();
|
||||
} on PetVersionConflictException {
|
||||
if (!mounted) return;
|
||||
// 40902:并发修改抢先——刷新取新 version 后由用户重试动作。
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('记录已在其他设备被修改,已刷新,请重试')),
|
||||
);
|
||||
_trackEditFailed(HealthRecordFailureReason.conflict, 40902);
|
||||
await _load();
|
||||
} on VaccinationRuleException {
|
||||
if (!mounted) return;
|
||||
// 42201:状态机/状态-日期规则兜底(前端已按规则拦截主路径)。
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('接种状态与日期不符合规则,请核对后重试')),
|
||||
);
|
||||
_trackEditFailed(HealthRecordFailureReason.validationError, 42201);
|
||||
} on PetRecordNotFoundException {
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(const SnackBar(content: Text('记录不存在或已被删除,已刷新')));
|
||||
_trackEditFailed(HealthRecordFailureReason.notFound, 40402);
|
||||
await _load();
|
||||
} on PetAccessDeniedException {
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(const SnackBar(content: Text('你没有权限操作该记录')));
|
||||
_trackEditFailed(HealthRecordFailureReason.permissionDenied, 40300);
|
||||
} on ApiBusinessException catch (error) {
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(const SnackBar(content: Text('操作失败,请稍后重试')));
|
||||
_trackEditFailed(HealthRecordFailureReason.serverError, error.code);
|
||||
} on ApiNetworkException {
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(const SnackBar(content: Text('网络异常,请检查网络后重试')));
|
||||
_trackEditFailed(HealthRecordFailureReason.networkError);
|
||||
} on SessionExpiredException {
|
||||
// 会话失效:认证状态机自动回登录页。
|
||||
} finally {
|
||||
if (mounted) setState(() => _mutating = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -187,7 +312,24 @@ class _VaccinationRecordsPageState extends State<VaccinationRecordsPage> {
|
||||
);
|
||||
}
|
||||
children
|
||||
..add(_VaccinationTile(record: record))
|
||||
..add(
|
||||
_VaccinationTile(
|
||||
record: record,
|
||||
// 流转动作仅 scheduled 行可用(终态由服务端状态机守卫)。
|
||||
onComplete:
|
||||
widget.canWrite &&
|
||||
record.status == VaccinationStatus.scheduled &&
|
||||
!_mutating
|
||||
? () => _markCompleted(record)
|
||||
: null,
|
||||
onCancel:
|
||||
widget.canWrite &&
|
||||
record.status == VaccinationStatus.scheduled &&
|
||||
!_mutating
|
||||
? () => _cancelRegistration(record)
|
||||
: null,
|
||||
),
|
||||
)
|
||||
..add(const SizedBox(height: 10));
|
||||
}
|
||||
return RefreshIndicator(
|
||||
@@ -201,53 +343,252 @@ class _VaccinationRecordsPageState extends State<VaccinationRecordsPage> {
|
||||
}
|
||||
|
||||
/// 疫苗条目:RecordTypeDot(疫苗) + 剂次标题 + 日期副行 + 状态 TagPill
|
||||
/// (图标+文字双通道,不单靠颜色区分)。
|
||||
/// (图标+文字双通道,不单靠颜色区分);scheduled 行附
|
||||
/// 「标记完成 / 取消登记」流转动作。
|
||||
class _VaccinationTile extends StatelessWidget {
|
||||
const _VaccinationTile({required this.record});
|
||||
const _VaccinationTile({
|
||||
required this.record,
|
||||
this.onComplete,
|
||||
this.onCancel,
|
||||
});
|
||||
|
||||
final Vaccination record;
|
||||
final VoidCallback? onComplete;
|
||||
final VoidCallback? onCancel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const RecordTypeDot(
|
||||
type: RecordType.vaccine,
|
||||
size: RecordTypeDotSize.md,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
vaccinationDoseLabel(record),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
Row(
|
||||
children: [
|
||||
const RecordTypeDot(
|
||||
type: RecordType.vaccine,
|
||||
size: RecordTypeDotSize.md,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
vaccinationDoseLabel(record),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
vaccinationDateLine(record),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.inkSoft,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
vaccinationDateLine(record),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.inkSoft,
|
||||
fontSize: 12,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TagPill(
|
||||
vaccinationStatusLabel(record.status),
|
||||
color: vaccinationStatusColor(record.status),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (onComplete != null || onCancel != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: onCancel,
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.inkSoft,
|
||||
),
|
||||
label: const Text('取消登记'),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
TextButton.icon(
|
||||
onPressed: onComplete,
|
||||
icon: const Icon(Icons.check_circle_outline, size: 16),
|
||||
label: const Text('标记完成'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TagPill(
|
||||
vaccinationStatusLabel(record.status),
|
||||
color: vaccinationStatusColor(record.status),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记完成对话框返回值。
|
||||
class _CompleteVaccinationResult {
|
||||
const _CompleteVaccinationResult({
|
||||
required this.administeredOn,
|
||||
this.nextDueOn,
|
||||
this.manufacturer,
|
||||
this.batchNo,
|
||||
});
|
||||
|
||||
final DateTime administeredOn;
|
||||
final DateTime? nextDueOn;
|
||||
final String? manufacturer;
|
||||
final String? batchNo;
|
||||
}
|
||||
|
||||
/// 标记完成对话框:接种日期必填(默认今天)、下次接种可选;
|
||||
/// 厂商/批号补录(契约可选字段,25 号报告遗留②的落地入口)。
|
||||
/// 日期规则复用 [vaccinationDateRuleError](42201 前置拦截)。
|
||||
class _CompleteVaccinationDialog extends StatefulWidget {
|
||||
const _CompleteVaccinationDialog({required this.record});
|
||||
|
||||
final Vaccination record;
|
||||
|
||||
@override
|
||||
State<_CompleteVaccinationDialog> createState() =>
|
||||
_CompleteVaccinationDialogState();
|
||||
}
|
||||
|
||||
class _CompleteVaccinationDialogState
|
||||
extends State<_CompleteVaccinationDialog> {
|
||||
final _manufacturerCtrl = TextEditingController();
|
||||
final _batchNoCtrl = TextEditingController();
|
||||
DateTime _administeredOn = DateTime.now();
|
||||
DateTime? _nextDueOn;
|
||||
String? _dateError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_manufacturerCtrl.text = widget.record.manufacturer ?? '';
|
||||
_batchNoCtrl.text = widget.record.batchNo ?? '';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_manufacturerCtrl.dispose();
|
||||
_batchNoCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _confirm() {
|
||||
final error = vaccinationDateRuleError(
|
||||
status: VaccinationStatus.completed,
|
||||
administeredOn: _administeredOn,
|
||||
nextDueOn: _nextDueOn,
|
||||
);
|
||||
if (error != null) {
|
||||
setState(() => _dateError = error);
|
||||
return;
|
||||
}
|
||||
final manufacturer = _manufacturerCtrl.text.trim();
|
||||
final batchNo = _batchNoCtrl.text.trim();
|
||||
Navigator.of(context).pop(
|
||||
_CompleteVaccinationResult(
|
||||
administeredOn: _administeredOn,
|
||||
nextDueOn: _nextDueOn,
|
||||
manufacturer: manufacturer.isEmpty ? null : manufacturer,
|
||||
batchNo: batchNo.isEmpty ? null : batchNo,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dateTile({
|
||||
required String label,
|
||||
required DateTime? value,
|
||||
required bool allowFuture,
|
||||
required ValueChanged<DateTime> onPicked,
|
||||
}) {
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
|
||||
title: Text(label, style: const TextStyle(fontSize: 14)),
|
||||
subtitle: Text(
|
||||
value == null ? '未选择' : dateToJson(value),
|
||||
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||||
),
|
||||
onTap: () async {
|
||||
final now = DateTime.now();
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: value ?? now,
|
||||
firstDate: DateTime(1990),
|
||||
lastDate: allowFuture ? DateTime(now.year + 5) : now,
|
||||
);
|
||||
if (picked != null && mounted) {
|
||||
setState(() {
|
||||
onPicked(picked);
|
||||
_dateError = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('标记完成'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'「${vaccinationDoseLabel(widget.record)}」',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_dateTile(
|
||||
label: '接种日期',
|
||||
value: _administeredOn,
|
||||
allowFuture: false,
|
||||
onPicked: (value) => _administeredOn = value,
|
||||
),
|
||||
_dateTile(
|
||||
label: '下次接种日期(可选)',
|
||||
value: _nextDueOn,
|
||||
allowFuture: true,
|
||||
onPicked: (value) => _nextDueOn = value,
|
||||
),
|
||||
if (_dateError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_dateError!,
|
||||
style: const TextStyle(color: AppColors.error, fontSize: 12),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
AppTextField(
|
||||
label: '厂商(可选)',
|
||||
controller: _manufacturerCtrl,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppTextField(
|
||||
label: '批号(可选)',
|
||||
controller: _batchNoCtrl,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _confirm(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(onPressed: _confirm, child: const Text('确认完成')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user