Files
lixi 5b34fa336b 新增:体重录入与历史列表接入真实数据(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>
2026-09-08 13:17:17 +08:00

289 lines
9.6 KiB
Dart
Raw Permalink 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/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 排序直觉);提交前转 UTCISO 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,
),
],
),
),
);
}
}