294fc4a781
用户实测已因日期选择器误录:Flutter 原生日历只给年份网格、月份必须靠 < > 逐月点,从 9 月回 4 月要点 5 次,他把当月(2026-09)的就医记录记成了 2026-04-09,进而误判「本月花费 ¥0」是聚合坏了。 新增 lib/core/widgets/app_date_picker.dart,全仓 7 处裸 showDatePicker 收口 (改造后 lib/ 下 showDatePicker 只出现在该文件内部一次): - pickAppDate(...):calendar 首屏 + 保留头部铅笔切手输;initialDate 自动夹进 [firstDate, lastDate] 防原生越界断言(调用方常传「当前值 ?? 今天」,而 「到期日期」的 firstDate 就是今天,历史值可能已越界);返回值统一抹时分秒; 中文文案一律交给 zh-CN 本地化,不硬编码,避免两处文案漂移。 - AppDateFieldTrailing(...):7 处日期行统一「今天 + 日历图标」。今天越界自动 隐藏按钮、提交中禁用、触控 44×44、primaryStrong 白底 4.49:1。 入口模式取舍:不用 calendarOnly——它恰好会砍掉手输按钮,把「录一个已知日期」 这条唯一快路堵死;也不用 input 首屏——「记今天」这类高频场景敲 8 个数字更慢。 两条路都留着最省事。 「今天」为何放表单行而非弹窗内:原生 showDatePicker 无法注入自定义动作 (builder 只能包裹整个 Dialog,拿不到内部选中态;塞进 Column 还会因 Dialog 在无界高度下贪心布局而溢出)。放表单行反而更快——一键落值连弹窗都不用开, 把容易走错的月份导航整段绕开,且一次实现 7 处形态完全一致。 各调用点原有的 firstDate/lastDate 业务约束原样传入、一字未改(健康事件 不许未来、到期日不许补记过去、疫苗 allowFuture 双态、生日不许未来), 并由 widget 测试直接断言 DatePickerDialog.firstDate/lastDate 防后续悄悄放宽。 顺带修配色:此前从未定制 datePickerTheme,选中日直接吃 ColorScheme.fromSeed 由珊瑚橙派生的暗红棕,与品牌脱节。新增 _datePickerTheme 只复用 05 号规范 (iteration-2/05、iteration-3/05)已审计的色对,不新造色值:头部 surfaceTint + primaryDark 7.98:1(选中 chip 同款)、选中日/年 primaryStrong 实底白字 4.49:1、今日 primaryStrong 1.5px 描边、星期表头 inkSoft 6.59:1、 越界日 muted(DEBT-2 允许的禁用态用途)。headerHeadlineStyle 取 22px (默认 32):中文「9月10日周四」在横屏侧栏头部 26px 起就折行。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
423 lines
14 KiB
Dart
423 lines
14 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_date_picker.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)。
|
||
///
|
||
/// - 六类事件类型选择(05 §4.4 类型选择器形态:RecordTypeDot sm + 标签,
|
||
/// 图标 + 文字双通道);类型必选。
|
||
/// - 发生时间默认「现在」,可回选日期(非今日取当日 12:00,与体重表单
|
||
/// 同一约定);提交前转 UTC,ISO 8601 带 Z 上送。
|
||
/// - 金额可选:**UI 以元录入/展示,传输为整数分**(money.dart 换算,
|
||
/// 最多两位小数,非法拦截);后端提交小数 400/40000 兜底。
|
||
/// - 埋点:health_record_create_started/succeeded/failed
|
||
/// (recordType=health_event)。
|
||
class HealthEventFormPage extends StatefulWidget {
|
||
const HealthEventFormPage({
|
||
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<HealthEventFormPage> createState() => _HealthEventFormPageState();
|
||
}
|
||
|
||
class _HealthEventFormPageState extends State<HealthEventFormPage> {
|
||
final _titleCtrl = TextEditingController();
|
||
final _amountCtrl = TextEditingController();
|
||
final _notesCtrl = TextEditingController();
|
||
|
||
HealthEventType? _type;
|
||
DateTime _occurredDate = DateTime.now();
|
||
String? _typeError;
|
||
String? _titleError;
|
||
String? _amountError;
|
||
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() {
|
||
_titleCtrl.dispose();
|
||
_amountCtrl.dispose();
|
||
_notesCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _markStarted() {
|
||
if (_startedFired) return;
|
||
_startedFired = true;
|
||
widget.analytics?.createStarted(
|
||
recordType: HealthRecordType.healthEvent,
|
||
entryPoint: widget.entryPoint,
|
||
);
|
||
}
|
||
|
||
void _trackFailed(HealthRecordFailureReason reason, [int? errorCode]) {
|
||
widget.analytics?.createFailed(
|
||
recordType: HealthRecordType.healthEvent,
|
||
reason: reason,
|
||
attemptSeq: _attemptSeq,
|
||
errorCode: errorCode,
|
||
);
|
||
}
|
||
|
||
void _showFormError(String message) {
|
||
setState(() => _formError = message);
|
||
SemanticsService.sendAnnouncement(
|
||
View.of(context),
|
||
message,
|
||
TextDirection.ltr,
|
||
);
|
||
}
|
||
|
||
/// 发生时刻:今日取此刻,历史日期取当日 12:00(本地),提交前转 UTC
|
||
/// (与体重表单同一约定,规避无时区后缀的解析歧义)。
|
||
DateTime _occurredAt() {
|
||
final now = DateTime.now();
|
||
final sameDay =
|
||
_occurredDate.year == now.year &&
|
||
_occurredDate.month == now.month &&
|
||
_occurredDate.day == now.day;
|
||
final local = sameDay
|
||
? now
|
||
: DateTime(
|
||
_occurredDate.year,
|
||
_occurredDate.month,
|
||
_occurredDate.day,
|
||
12,
|
||
);
|
||
return local.toUtc();
|
||
}
|
||
|
||
Future<void> _submit() async {
|
||
if (_submitting) return;
|
||
_attemptSeq++;
|
||
final typeError = _type == null ? '请选择事件类型' : null;
|
||
final titleError = _titleCtrl.text.trim().isEmpty ? '请输入标题' : null;
|
||
final amountText = _amountCtrl.text.trim();
|
||
final amountError =
|
||
amountText.isNotEmpty && parseYuanToCents(amountText) == null
|
||
? '金额格式不正确,最多两位小数'
|
||
: null;
|
||
if (typeError != null || titleError != null || amountError != null) {
|
||
setState(() {
|
||
_typeError = typeError;
|
||
_titleError = titleError;
|
||
_amountError = amountError;
|
||
});
|
||
_trackFailed(HealthRecordFailureReason.validationError);
|
||
return;
|
||
}
|
||
|
||
setState(() {
|
||
_submitting = true;
|
||
_formError = null;
|
||
});
|
||
try {
|
||
final notes = _notesCtrl.text.trim();
|
||
final record = await widget.repository.createHealthEvent(
|
||
widget.petId,
|
||
CreateHealthEventRequest(
|
||
eventType: _type!,
|
||
occurredAt: _occurredAt(),
|
||
title: _titleCtrl.text.trim(),
|
||
notes: notes.isEmpty ? null : notes,
|
||
// 元 → 整数分换算,DTO 层保持整数分(开发计划 §4.3)。
|
||
amountCents: amountText.isEmpty ? null : parseYuanToCents(amountText),
|
||
),
|
||
);
|
||
widget.analytics?.createSucceeded(
|
||
recordType: HealthRecordType.healthEvent,
|
||
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: [
|
||
const Text(
|
||
'事件类型',
|
||
style: TextStyle(
|
||
color: AppColors.inkSoft,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
_typeSelector(),
|
||
if (_typeError != null) ...[
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
_typeError!,
|
||
style: const TextStyle(color: AppColors.error, fontSize: 12),
|
||
),
|
||
],
|
||
const SizedBox(height: 18),
|
||
AppTextField(
|
||
label: '标题(如:皮肤检查)',
|
||
controller: _titleCtrl,
|
||
prefixIcon: Icons.title_outlined,
|
||
errorText: _titleError,
|
||
enabled: !_submitting,
|
||
textInputAction: TextInputAction.next,
|
||
onChanged: (_) {
|
||
_markStarted();
|
||
if (_titleError != null || _formError != null) {
|
||
setState(() {
|
||
_titleError = 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(_occurredDate),
|
||
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||
),
|
||
trailing: AppDateFieldTrailing(
|
||
firstDate: DateTime(1990),
|
||
lastDate: DateTime.now(),
|
||
enabled: !_submitting,
|
||
onToday: (value) {
|
||
_markStarted();
|
||
setState(() => _occurredDate = value);
|
||
},
|
||
),
|
||
enabled: !_submitting,
|
||
onTap: () async {
|
||
final now = DateTime.now();
|
||
final value = await pickAppDate(
|
||
context: context,
|
||
initialDate: _occurredDate,
|
||
firstDate: DateTime(1990),
|
||
lastDate: now,
|
||
);
|
||
if (value != null && mounted) {
|
||
_markStarted();
|
||
setState(() => _occurredDate = value);
|
||
}
|
||
},
|
||
),
|
||
const SizedBox(height: 14),
|
||
AppTextField(
|
||
label: '金额(元,可选)',
|
||
controller: _amountCtrl,
|
||
prefixIcon: Icons.payments_outlined,
|
||
errorText: _amountError,
|
||
helperText: '以元填写,最多两位小数,如 128.50',
|
||
enabled: !_submitting,
|
||
keyboardType: const TextInputType.numberWithOptions(
|
||
decimal: true,
|
||
),
|
||
textInputAction: TextInputAction.next,
|
||
onChanged: (_) {
|
||
_markStarted();
|
||
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,
|
||
onChanged: (_) => _markStarted(),
|
||
onSubmitted: (_) => _submit(),
|
||
),
|
||
if (_formError != null) ...[
|
||
const SizedBox(height: 16),
|
||
InlineErrorBanner(message: _formError!),
|
||
],
|
||
const SizedBox(height: 24),
|
||
PrimaryButton(
|
||
label: '保存记录',
|
||
isLoading: _submitting,
|
||
onPressed: _submit,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 六类类型选择器(05 §4.4:RecordTypeDot sm 上、标签下;
|
||
/// 选中 surfaceTint 底 + primaryDark 标签,未选中 inkSoft)。
|
||
Widget _typeSelector() {
|
||
return Wrap(
|
||
spacing: 8,
|
||
runSpacing: 8,
|
||
children: [
|
||
for (final type in HealthEventType.values)
|
||
_TypeUnit(
|
||
type: type,
|
||
selected: _type == type,
|
||
enabled: !_submitting,
|
||
onTap: () {
|
||
_markStarted();
|
||
setState(() {
|
||
_type = type;
|
||
_typeError = null;
|
||
_formError = null;
|
||
});
|
||
},
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _TypeUnit extends StatelessWidget {
|
||
const _TypeUnit({
|
||
required this.type,
|
||
required this.selected,
|
||
required this.enabled,
|
||
required this.onTap,
|
||
});
|
||
|
||
final HealthEventType type;
|
||
final bool selected;
|
||
final bool enabled;
|
||
final VoidCallback onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Material(
|
||
color: selected ? AppColors.surfaceTint : AppColors.surface,
|
||
shape: RoundedRectangleBorder(
|
||
side: BorderSide(
|
||
color: selected ? AppColors.primaryStrong : AppColors.border,
|
||
),
|
||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||
),
|
||
child: InkWell(
|
||
onTap: enabled ? onTap : null,
|
||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||
child: Container(
|
||
constraints: const BoxConstraints(minWidth: 64, minHeight: 52),
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
RecordTypeDot(
|
||
type: recordTypeForHealthEvent(type),
|
||
size: RecordTypeDotSize.sm,
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
healthEventTypeLabel(type),
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
|
||
color: selected ? AppColors.primaryDark : AppColors.inkSoft,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|