e186ba3da9
- 时间线页: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>
352 lines
12 KiB
Dart
352 lines
12 KiB
Dart
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<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,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|