ba503327f5
CI / flutter-gates (push) Successful in 2m12s
- 照护提醒页: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>
313 lines
10 KiB
Dart
313 lines
10 KiB
Dart
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,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|