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/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'; import 'package:patbond_flutter/widgets/common.dart'; enum _ListPhase { loading, ready, error } /// 疫苗记录列表页(T2-13):契约不分页,服务端按 /// `series_key, dose_no, created_at, id` 排序,客户端按系列直接分组; /// 含 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 { const VaccinationRecordsPage({ required this.repository, required this.petId, required this.petSpecies, required this.canWrite, super.key, this.analytics, }); final PetsRepository repository; final String petId; /// 疫苗目录按宠物物种过滤(契约:疫苗 species 须与宠物一致)。 final PetSpecies petSpecies; /// owner/caregiver 可写;viewer 隐藏登记入口。 final bool canWrite; final HealthRecordAnalytics? analytics; @override State createState() => _VaccinationRecordsPageState(); } class _VaccinationRecordsPageState extends State { _ListPhase _phase = _ListPhase.loading; List _records = const []; ApiException? _error; bool _viewedFired = false; bool _mutating = false; @override void initState() { super.initState(); _load(); } Future _load() async { setState(() { _phase = _ListPhase.loading; _error = null; }); try { final records = await widget.repository.listVaccinations(widget.petId); if (!mounted) return; setState(() { _records = records; _phase = _ListPhase.ready; }); if (!_viewedFired) { _viewedFired = true; widget.analytics?.viewed( recordType: HealthRecordType.vaccine, 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( VaccinationFormPage( repository: widget.repository, petId: widget.petId, petSpecies: widget.petSpecies, analytics: widget.analytics, ), settings: RouteSettings(name: AnalyticsPageName.recordForm.pageName), ), ); if (created != null && mounted) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('已登记疫苗'))); // 排序键在服务端(series_key, dose_no),重新拉取而非本地猜位置。 await _load(); } } 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( 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: 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 _records.isEmpty => Center( child: SingleChildScrollView( child: EmptyStateIllustration( icon: Icons.vaccines_outlined, title: '还没有疫苗记录', description: '登记接种计划与完成情况,不错过每一针', ctaLabel: widget.canWrite ? '登记第一针' : null, onCtaPressed: widget.canWrite ? _openCreate : null, ), ), ), _ListPhase.ready => _list(), }, ); } /// 按系列分组渲染:服务端排序保证同系列相邻,系列变化处插组头 /// (疫苗名 · 系列键)。 Widget _list() { final children = []; String? currentSeries; for (final record in _records) { final seriesId = '${record.vaccineId}/${record.seriesKey}'; if (seriesId != currentSeries) { currentSeries = seriesId; children.add( Padding( padding: EdgeInsets.only(top: children.isEmpty ? 0 : 14, bottom: 8), child: Text( '${record.vaccineName} · ${record.seriesKey}', style: const TextStyle( color: AppColors.inkSoft, fontSize: 12, fontWeight: FontWeight.w700, ), ), ), ); } children ..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( onRefresh: _load, child: ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 30), children: children, ), ); } } /// 疫苗条目:RecordTypeDot(疫苗) + 剂次标题 + 日期副行 + 状态 TagPill /// (图标+文字双通道,不单靠颜色区分);scheduled 行附 /// 「标记完成 / 取消登记」流转动作。 class _VaccinationTile extends StatelessWidget { 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: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ 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(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('标记完成'), ), ], ), ], ], ), ), ); } } /// 标记完成对话框返回值。 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('确认完成')), ], ); } }