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/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/money.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)。 /// /// - 六类事件类型选择(05 §4.4 类型选择器形态:RecordTypeDot sm + 标签, /// 图标 + 文字双通道);类型必选。 /// - 发生时间默认「现在」,可回选日期(非今日取当日 12:00,与体重表单 /// 同一约定);提交前转 UTC,ISO 8601 带 Z 上送。 /// - 金额可选:**UI 以元录入/展示,传输为整数分**(money.dart 换算, /// 最多两位小数,非法拦截);后端提交小数 400/40000 兜底。 /// - 埋点:health_record_create_started/succeeded/failed /// (recordType=health_event)。 class HealthEventFormPage extends StatefulWidget { const HealthEventFormPage({ 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 createState() => _HealthEventFormPageState(); } class _HealthEventFormPageState extends State { final _titleCtrl = TextEditingController(); final _amountCtrl = TextEditingController(); final _notesCtrl = TextEditingController(); HealthEventType? _type; DateTime _occurredDate = DateTime.now(); String? _typeError; String? _titleError; String? _amountError; 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(); _amountCtrl.dispose(); _notesCtrl.dispose(); super.dispose(); } void _markStarted() { if (_startedFired) return; _startedFired = true; widget.analytics?.createStarted( recordType: HealthRecordType.healthEvent, entryPoint: widget.entryPoint, ); } void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) { widget.analytics?.createFailed( recordType: HealthRecordType.healthEvent, 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 _occurredAt() { final now = DateTime.now(); final sameDay = _occurredDate.year == now.year && _occurredDate.month == now.month && _occurredDate.day == now.day; final local = sameDay ? now : DateTime( _occurredDate.year, _occurredDate.month, _occurredDate.day, 12, ); return local.toUtc(); } Future _submit() async { if (_submitting) return; _attemptSeq++; final typeError = _type == null ? '请选择事件类型' : null; final titleError = _titleCtrl.text.trim().isEmpty ? '请输入标题' : null; final amountText = _amountCtrl.text.trim(); final amountError = amountText.isNotEmpty && parseYuanToCents(amountText) == null ? '金额格式不正确,最多两位小数' : null; if (typeError != null || titleError != null || amountError != null) { setState(() { _typeError = typeError; _titleError = titleError; _amountError = amountError; }); _trackFailed(HealthRecordFailureReason.validationError); return; } setState(() { _submitting = true; _formError = null; }); try { final notes = _notesCtrl.text.trim(); final record = await widget.repository.createHealthEvent( widget.petId, CreateHealthEventRequest( eventType: _type!, occurredAt: _occurredAt(), title: _titleCtrl.text.trim(), notes: notes.isEmpty ? null : notes, // 元 → 整数分换算,DTO 层保持整数分(开发计划 §4.3)。 amountCents: amountText.isEmpty ? null : parseYuanToCents(amountText), ), ); widget.analytics?.createSucceeded( recordType: HealthRecordType.healthEvent, durationMs: DateTime.now().difference(_openedAt).inMilliseconds, ); if (mounted) Navigator.of(context).pop(record); } 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), _typeSelector(), 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.title_outlined, errorText: _titleError, enabled: !_submitting, textInputAction: TextInputAction.next, onChanged: (_) { _markStarted(); if (_titleError != null || _formError != null) { setState(() { _titleError = null; _formError = null; }); } }, ), 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( dateToJson(_occurredDate), 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: _occurredDate, firstDate: DateTime(1990), lastDate: now, ); if (value != null && mounted) { _markStarted(); setState(() => _occurredDate = value); } }, ), const SizedBox(height: 14), AppTextField( label: '金额(元,可选)', controller: _amountCtrl, prefixIcon: Icons.payments_outlined, errorText: _amountError, helperText: '以元填写,最多两位小数,如 128.50', enabled: !_submitting, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), textInputAction: TextInputAction.next, onChanged: (_) { _markStarted(); if (_amountError != null || _formError != null) { setState(() { _amountError = null; _formError = null; }); } }, ), const SizedBox(height: 14), AppTextField( label: '备注(可选)', controller: _notesCtrl, prefixIcon: Icons.sticky_note_2_outlined, enabled: !_submitting, textInputAction: TextInputAction.done, onChanged: (_) => _markStarted(), onSubmitted: (_) => _submit(), ), if (_formError != null) ...[ const SizedBox(height: 16), InlineErrorBanner(message: _formError!), ], const SizedBox(height: 24), PrimaryButton( label: '保存记录', isLoading: _submitting, onPressed: _submit, ), ], ), ), ); } /// 六类类型选择器(05 §4.4:RecordTypeDot sm 上、标签下; /// 选中 surfaceTint 底 + primaryDark 标签,未选中 inkSoft)。 Widget _typeSelector() { return Wrap( spacing: 8, runSpacing: 8, children: [ for (final type in HealthEventType.values) _TypeUnit( type: type, selected: _type == type, enabled: !_submitting, onTap: () { _markStarted(); setState(() { _type = type; _typeError = null; _formError = null; }); }, ), ], ); } } class _TypeUnit extends StatelessWidget { const _TypeUnit({ required this.type, required this.selected, required this.enabled, required this.onTap, }); final HealthEventType type; final bool selected; final bool enabled; final VoidCallback onTap; @override Widget build(BuildContext context) { return Material( color: selected ? AppColors.surfaceTint : AppColors.surface, shape: RoundedRectangleBorder( side: BorderSide( color: selected ? AppColors.primaryStrong : AppColors.border, ), borderRadius: BorderRadius.circular(AppRadius.md), ), child: InkWell( onTap: enabled ? onTap : null, borderRadius: BorderRadius.circular(AppRadius.md), child: Container( constraints: const BoxConstraints(minWidth: 64, minHeight: 52), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Column( mainAxisSize: MainAxisSize.min, children: [ RecordTypeDot( type: recordTypeForHealthEvent(type), size: RecordTypeDotSize.sm, ), const SizedBox(height: 4), Text( healthEventTypeLabel(type), style: TextStyle( fontSize: 11, fontWeight: selected ? FontWeight.w700 : FontWeight.w600, color: selected ? AppColors.primaryDark : AppColors.inkSoft, ), ), ], ), ), ), ); } }