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):顶层短路径 `PATCH /health-events/{id}`。 /// /// - 契约:仅 title / notes / amountCents 可编辑;eventType / occurredAt /// 为条目身份,静态展示不可改;不支持清空回 null(清空输入视为不变更)。 /// - 差量提交:只发送改动字段 + version;无变更不发 PATCH 直接返回。 /// - 40902 版本冲突照 T2-12 模式:明确提示 + 自动取最新版本更新乐观锁 /// 基线(保留用户输入),用户核对后重新保存。契约无按 id 读取端点, /// 最新版本经时间线分页检索取回。 /// - 埋点:health_record_edit_succeeded(fieldCount) / edit_failed /// (failureReason 含 conflict,recordType=health_event)。 class HealthEventEditPage extends StatefulWidget { const HealthEventEditPage({ required this.repository, required this.event, super.key, this.analytics, }); final PetsRepository repository; /// 编辑基线(含 version 乐观锁与差量比较基准)。 final HealthEvent event; final HealthRecordAnalytics? analytics; @override State createState() => _HealthEventEditPageState(); } class _HealthEventEditPageState extends State { late final TextEditingController _titleCtrl; late final TextEditingController _amountCtrl; late final TextEditingController _notesCtrl; /// 编辑基线:40902 冲突刷新后更新(version 与差量计算的比较基准)。 late HealthEvent _base; String? _titleError; String? _amountError; String? _formError; bool _submitting = false; @override void initState() { super.initState(); _base = widget.event; _titleCtrl = TextEditingController(text: _base.title); _amountCtrl = TextEditingController( text: _base.amountCents == null ? '' : formatCentsAsYuan(_base.amountCents!), ); _notesCtrl = TextEditingController(text: _base.notes ?? ''); } @override void dispose() { _titleCtrl.dispose(); _amountCtrl.dispose(); _notesCtrl.dispose(); super.dispose(); } void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) { widget.analytics?.editFailed( recordType: HealthRecordType.healthEvent, reason: reason, errorCode: errorCode, ); } void _showFormError(String message) { setState(() => _formError = message); SemanticsService.sendAnnouncement( View.of(context), message, TextDirection.ltr, ); } /// 差量请求:只含改动字段(契约不支持清空回 null——清空输入视为不变更)。 /// 无实际变更返回 null。 UpdateHealthEventRequest? _buildDiff() { final title = _titleCtrl.text.trim(); final notes = _notesCtrl.text.trim(); final amountText = _amountCtrl.text.trim(); final amountCents = amountText.isEmpty ? null : parseYuanToCents(amountText); final request = UpdateHealthEventRequest( version: _base.version, title: title != _base.title ? title : null, notes: notes.isNotEmpty && notes != (_base.notes ?? '') ? notes : null, amountCents: amountCents != null && amountCents != _base.amountCents ? amountCents : null, ); // 只剩 version 一个键 → 无实际变更。 return request.toJson().length == 1 ? null : request; } Future _submit() async { if (_submitting) return; final titleError = _titleCtrl.text.trim().isEmpty ? '标题不能为空' : null; final amountText = _amountCtrl.text.trim(); final amountError = amountText.isNotEmpty && parseYuanToCents(amountText) == null ? '金额格式不正确,最多两位小数' : null; if (titleError != null || amountError != null) { setState(() { _titleError = titleError; _amountError = amountError; }); _trackFailed(HealthRecordFailureReason.validationError); return; } final request = _buildDiff(); if (request == null) { Navigator.of(context).pop(); return; } setState(() { _submitting = true; _formError = null; }); try { final updated = await widget.repository.updateHealthEvent( _base.id, request, ); widget.analytics?.editSucceeded( recordType: HealthRecordType.healthEvent, fieldCount: request.toJson().length - 1, ); if (mounted) Navigator.of(context).pop(updated); } on PetVersionConflictException { _trackFailed(HealthRecordFailureReason.conflict, 40902); await _handleVersionConflict(); } on PetRecordNotFoundException { if (!mounted) return; final navigator = Navigator.of(context); ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('记录不存在或已被删除'))); _trackFailed(HealthRecordFailureReason.notFound, 40402); navigator.pop(); } on PetAccessDeniedException { if (!mounted) return; _showFormError('你没有权限修改该记录'); _trackFailed(HealthRecordFailureReason.permissionDenied, 40300); } 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); } } /// 40902:提示 + 刷新路径——契约无按 id 读取端点,经时间线分页检索 /// 取回最新版本更新乐观锁基线(保留用户输入),用户核对后重新保存。 Future _handleVersionConflict() async { try { final fresh = await _fetchLatest(); if (!mounted) return; if (fresh == null) { final navigator = Navigator.of(context); ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('记录不存在或已被删除'))); navigator.pop(); return; } setState(() => _base = fresh); _showFormError('记录已在其他设备被修改,已获取最新版本,请核对后重新保存'); } on ApiException { if (!mounted) return; _showFormError('记录已在其他设备被修改,请返回后刷新重试'); } } /// 按 occurred_at DESC 分页检索本记录(上限 10 页防御性截断)。 Future _fetchLatest() async { String? cursor; for (var page = 0; page < 10; page++) { final result = await widget.repository.listHealthEvents( _base.petId, limit: 50, cursor: cursor, ); for (final item in result.items) { if (item.id == _base.id) return item; } if (!result.hasMore) return null; cursor = result.nextCursor; } return null; } @override Widget build(BuildContext context) { final style = recordTypeStyles[recordTypeForHealthEvent(_base.eventType)]!; 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: [ // 事件身份静态区:类型与发生时间不可编辑(契约不在请求体)。 Row( children: [ RecordTypeDot( type: recordTypeForHealthEvent(_base.eventType), size: RecordTypeDotSize.md, ), const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( healthEventTypeLabel(_base.eventType), style: TextStyle( color: style.inkColor, fontSize: 14, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 2), Text( formatOccurredAt(_base.occurredAt), style: const TextStyle( color: AppColors.inkSoft, fontSize: 12, ), ), ], ), ], ), const SizedBox(height: 18), AppTextField( label: '标题', controller: _titleCtrl, prefixIcon: Icons.title_outlined, errorText: _titleError, enabled: !_submitting, textInputAction: TextInputAction.next, onChanged: (_) { if (_titleError != null || _formError != null) { setState(() { _titleError = null; _formError = null; }); } }, ), const SizedBox(height: 14), AppTextField( label: '金额(元)', controller: _amountCtrl, prefixIcon: Icons.payments_outlined, errorText: _amountError, helperText: '以元填写,最多两位小数;清空视为不变更', enabled: !_submitting, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), textInputAction: TextInputAction.next, onChanged: (_) { 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, onSubmitted: (_) => _submit(), ), if (_formError != null) ...[ const SizedBox(height: 16), InlineErrorBanner(message: _formError!), ], const SizedBox(height: 24), PrimaryButton( label: '保存修改', isLoading: _submitting, onPressed: _submit, ), ], ), ), ); } }