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_date_picker.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)。 /// /// - `weightKg` 前端校验对齐契约:(0, 500]、最多两位小数;后端 40000 兜底。 /// - 称重时间默认「现在」,可回选日期(非今日取当日 12:00,保持 /// measured_at DESC 排序直觉);提交前转 UTC,ISO 8601 带 Z 上送。 /// - 错误分层沿用登录纵切:字段 errorText / InlineErrorBanner / SnackBar。 /// - 埋点:首次输入 health_record_create_started(recordType=weight), /// 成功/失败按 06 §1.4 经 [HealthRecordAnalytics] 上报。 class WeightFormPage extends StatefulWidget { const WeightFormPage({ 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() => _WeightFormPageState(); } class _WeightFormPageState extends State { final _weightCtrl = TextEditingController(); final _noteCtrl = TextEditingController(); DateTime _measuredDate = DateTime.now(); String? _weightError; 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() { _weightCtrl.dispose(); _noteCtrl.dispose(); super.dispose(); } void _markStarted() { if (_startedFired) return; _startedFired = true; widget.analytics?.createStarted( recordType: HealthRecordType.weight, entryPoint: widget.entryPoint, ); } void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) { widget.analytics?.createFailed( recordType: HealthRecordType.weight, reason: reason, attemptSeq: _attemptSeq, errorCode: errorCode, ); } String? _validateWeight() { final text = _weightCtrl.text.trim(); if (text.isEmpty) return '请输入体重'; if (parseWeightKgInput(text) == null) { return '体重需大于 0 且不超过 500 公斤,最多两位小数'; } return null; } void _showFormError(String message) { setState(() => _formError = message); SemanticsService.sendAnnouncement( View.of(context), message, TextDirection.ltr, ); } /// 称重时刻:今日取此刻,历史日期取当日 12:00(本地),提交前转 UTC。 DateTime _measuredAt() { final now = DateTime.now(); final sameDay = _measuredDate.year == now.year && _measuredDate.month == now.month && _measuredDate.day == now.day; final local = sameDay ? now : DateTime( _measuredDate.year, _measuredDate.month, _measuredDate.day, 12, ); return local.toUtc(); } Future _submit() async { if (_submitting) return; _attemptSeq++; final weightError = _validateWeight(); if (weightError != null) { setState(() => _weightError = weightError); _trackFailed(HealthRecordFailureReason.validationError); return; } setState(() { _submitting = true; _formError = null; }); try { final note = _noteCtrl.text.trim(); final record = await widget.repository.createWeight( widget.petId, CreateWeightRequest( weightKg: parseWeightKgInput(_weightCtrl.text)!, measuredAt: _measuredAt(), note: note.isEmpty ? null : note, ), ); widget.analytics?.createSucceeded( recordType: HealthRecordType.weight, 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: [ AppTextField( label: '体重(公斤)', controller: _weightCtrl, prefixIcon: Icons.monitor_weight_outlined, errorText: _weightError, helperText: '大于 0 且不超过 500,最多两位小数', enabled: !_submitting, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), textInputAction: TextInputAction.next, onChanged: (_) { _markStarted(); if (_weightError != null || _formError != null) { setState(() { _weightError = 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(_measuredDate), style: const TextStyle(color: AppColors.inkSoft, fontSize: 12), ), trailing: AppDateFieldTrailing( firstDate: DateTime(1990), lastDate: DateTime.now(), enabled: !_submitting, onToday: (value) { _markStarted(); setState(() => _measuredDate = value); }, ), enabled: !_submitting, onTap: () async { final now = DateTime.now(); final value = await pickAppDate( context: context, initialDate: _measuredDate, firstDate: DateTime(1990), lastDate: now, ); if (value != null && mounted) { _markStarted(); setState(() => _measuredDate = value); } }, ), const SizedBox(height: 14), AppTextField( label: '备注(可选,如:饭后称重)', controller: _noteCtrl, 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, ), ], ), ), ); } }