Files
patbond-flutter/lib/features/pets/health_event_edit_page.dart
T
lixi e186ba3da9 新增:健康事件时间线接入真实数据(T2-14 时间线半边)
- 时间线页:occurred_at DESC cursor 分页 + 月分组组头,四态齐备;
  六类事件经 RecordTypeDot 映射(增补喂养/洗护/测量三型,色族复用
  已审计色对),类型 TagPill 双通道
- 事件录入表单:六类类型选择器、金额以元录入/整数分传输(money.dart
  换算)、UTC 时间戳约定与体重表单一致
- 事件编辑页:顶层 PATCH 差量提交(title/notes/amountCents + version),
  40902 照 T2-12 模式自动经时间线检索取新 version 重提(保留输入)
- 档案页:月度花费卡接 summary.monthlyExpense(分→元展示)、tz 透传
  设备时区固定偏移(T2-13 遗留③)、健康时间线导航入口
- 埋点:health_record 域 create 三事件 + viewed(recordType=health_event)
  挂通;新增 edit_succeeded/failed 封装(failureReason 含 conflict)
- 测试 224 → 250 全绿(+26);analyze 0 问题;format 无 diff

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 13:49:48 +08:00

352 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 含 conflictrecordType=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<HealthEventEditPage> createState() => _HealthEventEditPageState();
}
class _HealthEventEditPageState extends State<HealthEventEditPage> {
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<void> _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<void> _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<HealthEvent?> _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,
),
],
),
),
);
}
}