新增:体重录入与历史列表接入真实数据(T2-13 体重半边)
- WeightRecordsPage:cursor 分页(加载更多/末页收起/翻页失败保留重试)、 loading/empty/error/retry 四态、下拉刷新;viewer 隐藏录入入口 - WeightFormPage:weightKg 契约区间 (0,500] 与两位小数前端校验 + 40000 兜底;称重时间转 UTC 上送;错误三层分层沿用登录纵切 - health_record 域埋点强类型封装(06 §1.4:create_started/succeeded/ failed + viewed,httpStatus 由业务码推导;viewed 按工单口径取列表曝光) - health_record_display 纯函数(体重解析/展示、疫苗状态映射、42201 规则前端拦截函数);PetsController 暴露 repository(22 号 §7 交接) - 测试 177 → 205(+28)全绿 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
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/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<WeightFormPage> createState() => _WeightFormPageState();
|
||||
}
|
||||
|
||||
class _WeightFormPageState extends State<WeightFormPage> {
|
||||
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<void> _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: const Icon(
|
||||
Icons.calendar_month_outlined,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
enabled: !_submitting,
|
||||
onTap: () async {
|
||||
final now = DateTime.now();
|
||||
final value = await showDatePicker(
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user