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 createState() => _VaccinationFormPageState(); } class _VaccinationFormPageState extends State { final _seriesCtrl = TextEditingController(); final _doseNoCtrl = TextEditingController(text: '1'); final _doseLabelCtrl = TextEditingController(); final _notesCtrl = TextEditingController(); List? _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 _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 _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( 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 _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 []; if (catalog.isEmpty) { return const [ Text( '该物种暂无可选疫苗目录', style: TextStyle(color: AppColors.inkSoft, fontSize: 12), ), ]; } return [ DropdownButtonFormField( 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 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); } }, ); } }