c91f18a845
CI / flutter-gates (push) Successful in 1m49s
- VaccinationFormPage:目录按宠物物种过滤(四态)、seriesKey 目录 code
预填、scheduled/completed 状态-日期规则结构化 + 纯函数双重前端拦截,
42201/40904 后端兜底横幅(均有测试)
- VaccinationRecordsPage:按系列分组、三态 TagPill(含 cancelled 留痕)、
四态齐备;viewer 隐藏登记入口
- 档案页数据卡行接 GET /pets/{id}/summary 实时聚合:最新体重/疫苗进度/
下一针三卡,null 语义为空态文案而非 0/0;从记录页返回即重拉摘要
- health_record 埋点装配(app→shell→pets→detail→记录页面族)
- 测试 205 → 224(+19)全绿;analyze 0 问题;format 无 diff
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
527 lines
18 KiB
Dart
527 lines
18 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-13)。
|
||
///
|
||
/// - 目录:vaccine-catalog 按宠物物种过滤(契约:疫苗 species 须与宠物
|
||
/// 一致),目录网络面自带 loading / 失败重试 / 空目录提示。
|
||
/// - 系列/剂次:seriesKey 选中疫苗后以目录 code 自动预填(可改);
|
||
/// doseNo 默认 1。
|
||
/// - 状态机:创建仅 scheduled / completed;状态-日期规则由
|
||
/// [vaccinationDateRuleError] 前端拦截(scheduled 态不渲染接种日期字段,
|
||
/// 结构上杜绝「scheduled 带 administeredOn」),后端 42201 兜底提示;
|
||
/// 同系列同剂次重复登记 40904 兜底提示。
|
||
/// - 埋点:health_record_create_started/succeeded/failed(recordType=vaccine)。
|
||
class VaccinationFormPage extends StatefulWidget {
|
||
const VaccinationFormPage({
|
||
required this.repository,
|
||
required this.petId,
|
||
required this.petSpecies,
|
||
super.key,
|
||
this.analytics,
|
||
this.entryPoint = HealthRecordEntryPoint.recordList,
|
||
});
|
||
|
||
final PetsRepository repository;
|
||
final String petId;
|
||
final PetSpecies petSpecies;
|
||
final HealthRecordAnalytics? analytics;
|
||
final HealthRecordEntryPoint entryPoint;
|
||
|
||
@override
|
||
State<VaccinationFormPage> createState() => _VaccinationFormPageState();
|
||
}
|
||
|
||
class _VaccinationFormPageState extends State<VaccinationFormPage> {
|
||
final _seriesCtrl = TextEditingController();
|
||
final _doseNoCtrl = TextEditingController(text: '1');
|
||
final _doseLabelCtrl = TextEditingController();
|
||
final _notesCtrl = TextEditingController();
|
||
|
||
List<VaccineCatalogItem>? _catalog;
|
||
bool _catalogLoading = false;
|
||
bool _catalogFailed = false;
|
||
String? _vaccineId;
|
||
|
||
/// seriesKey 是否仍处于「目录 code 自动预填」状态(用户一改即失效)。
|
||
bool _seriesAutoFilled = true;
|
||
|
||
VaccinationStatus _status = VaccinationStatus.scheduled;
|
||
DateTime? _plannedOn;
|
||
DateTime? _administeredOn;
|
||
DateTime? _nextDueOn;
|
||
|
||
String? _vaccineError;
|
||
String? _seriesError;
|
||
String? _doseNoError;
|
||
String? _dateError;
|
||
String? _formError;
|
||
bool _submitting = false;
|
||
|
||
bool _startedFired = false;
|
||
int _attemptSeq = 0;
|
||
late final DateTime _openedAt;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_openedAt = DateTime.now();
|
||
_loadCatalog();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_seriesCtrl.dispose();
|
||
_doseNoCtrl.dispose();
|
||
_doseLabelCtrl.dispose();
|
||
_notesCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _loadCatalog() async {
|
||
setState(() {
|
||
_catalogLoading = true;
|
||
_catalogFailed = false;
|
||
});
|
||
try {
|
||
final catalog = await widget.repository.listVaccineCatalog(
|
||
species: widget.petSpecies,
|
||
);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_catalog = catalog;
|
||
_catalogLoading = false;
|
||
});
|
||
} on ApiException {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_catalogLoading = false;
|
||
_catalogFailed = true;
|
||
});
|
||
}
|
||
}
|
||
|
||
void _markStarted() {
|
||
if (_startedFired) return;
|
||
_startedFired = true;
|
||
widget.analytics?.createStarted(
|
||
recordType: HealthRecordType.vaccine,
|
||
entryPoint: widget.entryPoint,
|
||
);
|
||
}
|
||
|
||
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
|
||
widget.analytics?.createFailed(
|
||
recordType: HealthRecordType.vaccine,
|
||
reason: reason,
|
||
attemptSeq: _attemptSeq,
|
||
errorCode: errorCode,
|
||
);
|
||
}
|
||
|
||
int? get _doseNo {
|
||
final value = int.tryParse(_doseNoCtrl.text.trim());
|
||
if (value == null || value < 1 || value > 32767) return null;
|
||
return value;
|
||
}
|
||
|
||
void _showFormError(String message) {
|
||
setState(() => _formError = message);
|
||
SemanticsService.sendAnnouncement(
|
||
View.of(context),
|
||
message,
|
||
TextDirection.ltr,
|
||
);
|
||
}
|
||
|
||
Future<void> _submit() async {
|
||
if (_submitting) return;
|
||
_attemptSeq++;
|
||
final vaccineError = _vaccineId == null ? '请选择疫苗' : null;
|
||
final seriesError = _seriesCtrl.text.trim().isEmpty ? '请输入系列名称' : null;
|
||
final doseNoError = _doseNo == null ? '剂次需为 1~32767 的整数' : null;
|
||
final dateError = vaccinationDateRuleError(
|
||
status: _status,
|
||
plannedOn: _plannedOn,
|
||
administeredOn: _administeredOn,
|
||
nextDueOn: _nextDueOn,
|
||
);
|
||
if (vaccineError != null ||
|
||
seriesError != null ||
|
||
doseNoError != null ||
|
||
dateError != null) {
|
||
setState(() {
|
||
_vaccineError = vaccineError;
|
||
_seriesError = seriesError;
|
||
_doseNoError = doseNoError;
|
||
_dateError = dateError;
|
||
});
|
||
_trackFailed(HealthRecordFailureReason.validationError);
|
||
return;
|
||
}
|
||
|
||
setState(() {
|
||
_submitting = true;
|
||
_formError = null;
|
||
});
|
||
try {
|
||
final scheduled = _status == VaccinationStatus.scheduled;
|
||
final doseLabel = _doseLabelCtrl.text.trim();
|
||
final notes = _notesCtrl.text.trim();
|
||
final record = await widget.repository.createVaccination(
|
||
widget.petId,
|
||
CreateVaccinationRequest(
|
||
vaccineId: _vaccineId!,
|
||
seriesKey: _seriesCtrl.text.trim(),
|
||
doseNo: _doseNo!,
|
||
status: _status,
|
||
// 状态-日期规则结构化落地:scheduled 只发 plannedOn,
|
||
// completed 只发 administeredOn(+可选 nextDueOn)。
|
||
plannedOn: scheduled ? _plannedOn : null,
|
||
administeredOn: scheduled ? null : _administeredOn,
|
||
nextDueOn: scheduled ? null : _nextDueOn,
|
||
doseLabel: doseLabel.isEmpty ? null : doseLabel,
|
||
notes: notes.isEmpty ? null : notes,
|
||
),
|
||
);
|
||
widget.analytics?.createSucceeded(
|
||
recordType: HealthRecordType.vaccine,
|
||
durationMs: DateTime.now().difference(_openedAt).inMilliseconds,
|
||
);
|
||
if (mounted) Navigator.of(context).pop(record);
|
||
} on VaccinationDoseExistsException {
|
||
if (!mounted) return;
|
||
_showFormError('该系列该剂次已有记录(40904);如登记有误,可取消原记录后重新登记');
|
||
_trackFailed(HealthRecordFailureReason.validationError, 40904);
|
||
} on VaccinationRuleException {
|
||
if (!mounted) return;
|
||
// 前端已拦截主路径,此处为后端状态-日期规则兜底(42201)。
|
||
_showFormError('接种状态与日期不符合规则,请核对后重试');
|
||
_trackFailed(HealthRecordFailureReason.validationError, 42201);
|
||
} 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: [
|
||
..._catalogSection(),
|
||
const SizedBox(height: 14),
|
||
AppTextField(
|
||
label: '系列名称',
|
||
controller: _seriesCtrl,
|
||
prefixIcon: Icons.tag_outlined,
|
||
errorText: _seriesError,
|
||
helperText: '区分初免/加强等系列,同系列同剂次唯一',
|
||
enabled: !_submitting,
|
||
textInputAction: TextInputAction.next,
|
||
onChanged: (_) {
|
||
_markStarted();
|
||
_seriesAutoFilled = false;
|
||
if (_seriesError != null || _formError != null) {
|
||
setState(() {
|
||
_seriesError = null;
|
||
_formError = null;
|
||
});
|
||
}
|
||
},
|
||
),
|
||
const SizedBox(height: 14),
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(
|
||
child: AppTextField(
|
||
label: '剂次',
|
||
controller: _doseNoCtrl,
|
||
prefixIcon: Icons.numbers_outlined,
|
||
errorText: _doseNoError,
|
||
enabled: !_submitting,
|
||
keyboardType: TextInputType.number,
|
||
textInputAction: TextInputAction.next,
|
||
onChanged: (_) {
|
||
_markStarted();
|
||
if (_doseNoError != null || _formError != null) {
|
||
setState(() {
|
||
_doseNoError = null;
|
||
_formError = null;
|
||
});
|
||
}
|
||
},
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: AppTextField(
|
||
label: '剂次标签(可选)',
|
||
controller: _doseLabelCtrl,
|
||
enabled: !_submitting,
|
||
textInputAction: TextInputAction.next,
|
||
onChanged: (_) => _markStarted(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 18),
|
||
const Text(
|
||
'接种状态',
|
||
style: TextStyle(
|
||
color: AppColors.inkSoft,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
SegmentedButton<VaccinationStatus>(
|
||
segments: const [
|
||
ButtonSegment(
|
||
value: VaccinationStatus.scheduled,
|
||
icon: Icon(Icons.event_outlined),
|
||
label: Text('计划接种'),
|
||
),
|
||
ButtonSegment(
|
||
value: VaccinationStatus.completed,
|
||
icon: Icon(Icons.check_circle_outline),
|
||
label: Text('已完成'),
|
||
),
|
||
],
|
||
selected: {_status},
|
||
onSelectionChanged: _submitting
|
||
? null
|
||
: (value) {
|
||
_markStarted();
|
||
setState(() {
|
||
_status = value.first;
|
||
_dateError = null;
|
||
_formError = null;
|
||
});
|
||
},
|
||
),
|
||
const SizedBox(height: 14),
|
||
if (_status == VaccinationStatus.scheduled)
|
||
_dateTile(
|
||
label: '计划接种日期',
|
||
value: _plannedOn,
|
||
allowFuture: true,
|
||
onPicked: (value) => setState(() {
|
||
_plannedOn = value;
|
||
_dateError = null;
|
||
}),
|
||
)
|
||
else ...[
|
||
_dateTile(
|
||
label: '接种日期',
|
||
value: _administeredOn,
|
||
allowFuture: false,
|
||
onPicked: (value) => setState(() {
|
||
_administeredOn = value;
|
||
_dateError = null;
|
||
}),
|
||
),
|
||
const SizedBox(height: 12),
|
||
_dateTile(
|
||
label: '下次接种日期(可选)',
|
||
value: _nextDueOn,
|
||
allowFuture: true,
|
||
onPicked: (value) => setState(() {
|
||
_nextDueOn = value;
|
||
_dateError = null;
|
||
}),
|
||
),
|
||
],
|
||
if (_dateError != null) ...[
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
_dateError!,
|
||
style: const TextStyle(color: AppColors.error, fontSize: 12),
|
||
),
|
||
],
|
||
const SizedBox(height: 14),
|
||
AppTextField(
|
||
label: '备注(可选)',
|
||
controller: _notesCtrl,
|
||
prefixIcon: Icons.sticky_note_2_outlined,
|
||
enabled: !_submitting,
|
||
textInputAction: TextInputAction.done,
|
||
onChanged: (_) => _markStarted(),
|
||
),
|
||
if (_formError != null) ...[
|
||
const SizedBox(height: 16),
|
||
InlineErrorBanner(message: _formError!),
|
||
],
|
||
const SizedBox(height: 24),
|
||
PrimaryButton(
|
||
label: '保存登记',
|
||
isLoading: _submitting,
|
||
onPressed: _submit,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
List<Widget> _catalogSection() {
|
||
if (_catalogLoading) {
|
||
return const [
|
||
SizedBox(
|
||
height: 52,
|
||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||
),
|
||
];
|
||
}
|
||
if (_catalogFailed) {
|
||
return [
|
||
Row(
|
||
children: [
|
||
const Expanded(
|
||
child: Text(
|
||
'疫苗目录加载失败,请重试后选择',
|
||
style: TextStyle(color: AppColors.error, fontSize: 12),
|
||
),
|
||
),
|
||
TextButton(onPressed: _loadCatalog, child: const Text('重试')),
|
||
],
|
||
),
|
||
];
|
||
}
|
||
final catalog = _catalog ?? const <VaccineCatalogItem>[];
|
||
if (catalog.isEmpty) {
|
||
return const [
|
||
Text(
|
||
'该物种暂无可选疫苗目录',
|
||
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||
),
|
||
];
|
||
}
|
||
return [
|
||
DropdownButtonFormField<String>(
|
||
initialValue: _vaccineId,
|
||
decoration: InputDecoration(labelText: '疫苗', errorText: _vaccineError),
|
||
items: [
|
||
for (final item in catalog)
|
||
DropdownMenuItem(value: item.id, child: Text(item.name)),
|
||
],
|
||
onChanged: _submitting
|
||
? null
|
||
: (value) {
|
||
_markStarted();
|
||
setState(() {
|
||
_vaccineId = value;
|
||
_vaccineError = null;
|
||
_formError = null;
|
||
// seriesKey 以目录 code 预填(用户未改过才覆盖)。
|
||
if (_seriesAutoFilled && value != null) {
|
||
final item = catalog.firstWhere((c) => c.id == value);
|
||
_seriesCtrl.text = item.code;
|
||
}
|
||
});
|
||
},
|
||
),
|
||
];
|
||
}
|
||
|
||
Widget _dateTile({
|
||
required String label,
|
||
required DateTime? value,
|
||
required bool allowFuture,
|
||
required ValueChanged<DateTime> onPicked,
|
||
}) {
|
||
return 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: Text(label, style: const TextStyle(fontSize: 14)),
|
||
subtitle: Text(
|
||
value == null ? '未选择' : dateToJson(value),
|
||
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 picked = await showDatePicker(
|
||
context: context,
|
||
initialDate: value ?? now,
|
||
firstDate: DateTime(1990),
|
||
lastDate: allowFuture ? DateTime(now.year + 5) : now,
|
||
);
|
||
if (picked != null && mounted) {
|
||
_markStarted();
|
||
onPicked(picked);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
}
|