7d5c84d06d
CI / flutter-gates (push) Successful in 3m24s
卡片标签硬编码「本月花费」,而服务端 summary.monthlyExpense 本就返回 month
(ISO year-month,按 tz 归月)。用户看不到实际月份,所以无法自证「我这条记录
到底落在哪个月」,把正确的 ¥0 当成统计故障。
已核实不改后端聚合:用户记录落在 2026-04-09、当天是 2026-09-10,
「本月花费 ¥0」是正确行为。本单只让客户端把口径亮出来。
- health_record_display 新增纯函数 monthlyExpenseCardLabel(month, {now}):
同年「9 月花费」(窄卡一行放得下,故不取「2026-09 花费」);跨年(服务端
归月年份 ≠ 设备当前年份)「2026/12 花费」补年份消歧;串非法退回
「本月花费」不崩不显示脏值。
- 可点提示:_SummaryCard 在 onTap 非空时右上角补 chevron_right。四张卡本都
可点进明细页却无任何视觉提示(用户反馈不知道能点),提示形态沿用项目既有
可点行/卡(宠物列表卡、健康提醒卡、资料页设置行)的 chevron_right,不自创。
- 整卡包 MergeSemantics:读屏一次读全「¥128.50,9 月花费,按钮」而非两段
孤立文字。没有用 excludeSemantics——那会连带丢掉 InkWell 的可激活性。
既有测试口径调整:pet_detail_page_test 两处断言改为实际月份,且样本
monthlyExpense.month 改用当月串使断言不随年份漂移(跨年格式由纯函数单测覆盖)。
新增 integration_test/client_ux_live_test.dart(环境变量门控,默认 skip,
沿用 M3 既有 live 测试形态):compose 六容器 + 真实 App 走完三项修复——
日期选择器全中文/品牌配色/手输录入/一键今天,以及花费卡实际月份 + chevron。
本机是 Wayland 会话、X11 import -window root 取不到根窗口,改为把整棵 App 包
一层 RepaintBoundary 后 toImage() 直出真实渲染像素落 build/ux-live/。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
212 lines
9.1 KiB
Dart
212 lines
9.1 KiB
Dart
/// 体重 / 疫苗 / 健康事件 / 提醒展示与输入解析的纯函数集合
|
||
/// (列表 / 表单共用,可单测)。
|
||
library;
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
|
||
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||
|
||
/// 体重输入解析:契约区间 (0, 500]、最多两位小数(numeric(6,2))。
|
||
/// 非法(非数字、越界、三位小数)返回 null,由表单层给 errorText。
|
||
double? parseWeightKgInput(String raw) {
|
||
final text = raw.trim();
|
||
if (!RegExp(r'^\d{1,3}(\.\d{1,2})?$').hasMatch(text)) return null;
|
||
final value = double.parse(text);
|
||
if (value <= 0 || value > 500) return null;
|
||
return value;
|
||
}
|
||
|
||
/// 体重展示:去掉无意义尾零(4.35 → 4.35、5.00 → 5、4.50 → 4.5)。
|
||
String formatWeightKg(double weightKg) {
|
||
var text = weightKg.toStringAsFixed(2);
|
||
if (text.contains('.')) {
|
||
text = text.replaceFirst(RegExp(r'0+$'), '');
|
||
text = text.replaceFirst(RegExp(r'\.$'), '');
|
||
}
|
||
return text;
|
||
}
|
||
|
||
String vaccinationStatusLabel(VaccinationStatus status) => switch (status) {
|
||
VaccinationStatus.scheduled => '计划中',
|
||
VaccinationStatus.completed => '已完成',
|
||
VaccinationStatus.cancelled => '已取消',
|
||
};
|
||
|
||
/// 状态标签基色(TagPill 淡染底;文字深变体由 TagPill 内置映射)。
|
||
Color vaccinationStatusColor(VaccinationStatus status) => switch (status) {
|
||
VaccinationStatus.scheduled => AppColors.accent,
|
||
VaccinationStatus.completed => AppColors.success,
|
||
VaccinationStatus.cancelled => AppColors.muted,
|
||
};
|
||
|
||
/// 剂次展示:doseLabel 优先,缺席回落「第 N 针」。
|
||
String vaccinationDoseLabel(Vaccination vaccination) =>
|
||
vaccination.doseLabel ?? '第 ${vaccination.doseNo} 针';
|
||
|
||
/// 疫苗条目副行:按状态给日期语义(计划 / 接种 + 下次到期 / 取消)。
|
||
String vaccinationDateLine(
|
||
Vaccination vaccination,
|
||
) => switch (vaccination.status) {
|
||
VaccinationStatus.scheduled =>
|
||
'计划 ${vaccination.plannedOn == null ? '未定' : dateToJson(vaccination.plannedOn!)}',
|
||
VaccinationStatus.completed =>
|
||
'接种 ${vaccination.administeredOn == null ? '—' : dateToJson(vaccination.administeredOn!)}'
|
||
'${vaccination.nextDueOn == null ? '' : ' · 下次 ${dateToJson(vaccination.nextDueOn!)}'}',
|
||
VaccinationStatus.cancelled => '已取消',
|
||
};
|
||
|
||
/// 摘要疫苗进度展示:契约 null 语义——无登记为 null(不是 0/0),
|
||
/// 调用方对 null 自行给空态文案。
|
||
String vaccinationProgressLabel(SummaryVaccinationProgress progress) =>
|
||
'${progress.completedDoses}/${progress.totalDoses}';
|
||
|
||
/// 疫苗状态-日期规则前端校验(契约 42201 规则的前置拦截;纯函数可单测):
|
||
/// scheduled 必有 plannedOn;completed 必有 administeredOn;
|
||
/// nextDueOn 与 administeredOn 同时存在时须 nextDueOn ≥ administeredOn。
|
||
/// 通过返回 null,违反返回给用户的拦截文案。
|
||
String? vaccinationDateRuleError({
|
||
required VaccinationStatus status,
|
||
DateTime? plannedOn,
|
||
DateTime? administeredOn,
|
||
DateTime? nextDueOn,
|
||
}) {
|
||
if (status == VaccinationStatus.scheduled && plannedOn == null) {
|
||
return '请选择计划接种日期';
|
||
}
|
||
if (status == VaccinationStatus.completed && administeredOn == null) {
|
||
return '请选择接种日期';
|
||
}
|
||
if (administeredOn != null &&
|
||
nextDueOn != null &&
|
||
nextDueOn.isBefore(
|
||
DateTime(administeredOn.year, administeredOn.month, administeredOn.day),
|
||
)) {
|
||
return '下次接种日期不能早于接种日期';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ---- 健康事件(T2-14)----
|
||
|
||
/// 契约六类健康事件 → 视觉记录类型(RecordTypeDot 映射唯一出口)。
|
||
/// note(随手记)归入「其他」视觉族;其余五类各有专属图标。
|
||
RecordType recordTypeForHealthEvent(HealthEventType type) => switch (type) {
|
||
HealthEventType.medical => RecordType.medical,
|
||
HealthEventType.feeding => RecordType.feeding,
|
||
HealthEventType.deworming => RecordType.deworming,
|
||
HealthEventType.grooming => RecordType.grooming,
|
||
HealthEventType.measurement => RecordType.measurement,
|
||
HealthEventType.note => RecordType.other,
|
||
};
|
||
|
||
/// 六类事件中文文案(类型选择器 / 条目标签共用;
|
||
/// 图标 + 文字双通道,不单靠颜色区分)。
|
||
String healthEventTypeLabel(HealthEventType type) => switch (type) {
|
||
HealthEventType.medical => '就医',
|
||
HealthEventType.feeding => '喂养',
|
||
HealthEventType.deworming => '驱虫',
|
||
HealthEventType.grooming => '洗护',
|
||
HealthEventType.measurement => '测量',
|
||
HealthEventType.note => '随手记',
|
||
};
|
||
|
||
/// 事件发生时刻展示:本地时区 `YYYY-MM-DD HH:mm`。
|
||
String formatOccurredAt(DateTime occurredAt) {
|
||
final local = occurredAt.toLocal();
|
||
final h = local.hour.toString().padLeft(2, '0');
|
||
final min = local.minute.toString().padLeft(2, '0');
|
||
return '${dateToJson(local)} $h:$min';
|
||
}
|
||
|
||
/// 时间线月分组组头(05 §4.2:「2026 年 9 月」),按本地时区归月。
|
||
String healthEventMonthHeader(DateTime occurredAt) {
|
||
final local = occurredAt.toLocal();
|
||
return '${local.year} 年 ${local.month} 月';
|
||
}
|
||
|
||
/// 月度花费卡标签(M3.5-03)。
|
||
///
|
||
/// 根因备忘:卡片标签此前硬编码「本月花费」,用户无法自证「本月」到底是
|
||
/// 哪个月——他把当月记录误录到 2026-04 后看到「本月花费 ¥0」,以为聚合坏了。
|
||
/// 服务端 `summary.monthlyExpense.month` 本就返回 ISO year-month(如 `2026-09`,
|
||
/// 按 `tz` 归月),直接展示即可自查,无需改后端。
|
||
///
|
||
/// [month] 形如 `2026-09`。窄卡(一行四卡)只放得下 4~5 个字,故取「9 月花费」
|
||
/// 而非「2026-09 花费」;跨年(服务端归月的年份与设备当前年份不一致,如设备
|
||
/// 已跨到 1 月而窗口仍是去年 12 月)时补年份消歧。解析失败退回「本月花费」。
|
||
String monthlyExpenseCardLabel(String month, {DateTime? now}) {
|
||
final match = RegExp(r'^(\d{4})-(\d{2})$').firstMatch(month);
|
||
if (match == null) return '本月花费';
|
||
final year = int.parse(match.group(1)!);
|
||
final monthNo = int.parse(match.group(2)!);
|
||
if (monthNo < 1 || monthNo > 12) return '本月花费';
|
||
final currentYear = (now ?? DateTime.now()).year;
|
||
if (year != currentYear) return '$year/$monthNo 花费';
|
||
return '$monthNo 月花费';
|
||
}
|
||
|
||
/// 设备时区 → summary `tz` 参数(契约接受固定偏移形如 `+08:00`;
|
||
/// Flutter 无 IANA 名可取,固定偏移语义等价——只作用于月度窗口)。
|
||
String tzOffsetQueryValue(Duration offset) {
|
||
final sign = offset.isNegative ? '-' : '+';
|
||
final abs = offset.abs();
|
||
final h = abs.inHours.toString().padLeft(2, '0');
|
||
final m = (abs.inMinutes % 60).toString().padLeft(2, '0');
|
||
return '$sign$h:$m';
|
||
}
|
||
|
||
// ---- 照护提醒(T2-14)----
|
||
|
||
/// 四类提醒中文文案。
|
||
String careReminderTypeLabel(CareReminderType type) => switch (type) {
|
||
CareReminderType.deworming => '驱虫',
|
||
CareReminderType.checkup => '体检',
|
||
CareReminderType.medication => '用药',
|
||
CareReminderType.other => '其他',
|
||
};
|
||
|
||
/// 提醒类型 → 视觉记录类型(RecordTypeDot 复用):驱虫沿用驱虫族,
|
||
/// 体检/用药归就医族,其他归兜底族;类型文字由标签承载(双通道)。
|
||
RecordType recordTypeForReminder(CareReminderType type) => switch (type) {
|
||
CareReminderType.deworming => RecordType.deworming,
|
||
CareReminderType.checkup => RecordType.medical,
|
||
CareReminderType.medication => RecordType.medical,
|
||
CareReminderType.other => RecordType.other,
|
||
};
|
||
|
||
String careReminderStatusLabel(CareReminderStatus status) => switch (status) {
|
||
CareReminderStatus.pending => '待办',
|
||
CareReminderStatus.completed => '已完成',
|
||
CareReminderStatus.dismissed => '已忽略',
|
||
};
|
||
|
||
/// 逾期判定:待办且 dueAt 已过(工单硬项:逾期视觉标识)。
|
||
bool isReminderOverdue(CareReminder reminder, DateTime now) =>
|
||
reminder.status == CareReminderStatus.pending &&
|
||
reminder.dueAt.isBefore(now);
|
||
|
||
/// 提醒状态标签文案(逾期的待办以「已逾期」显性标识)。
|
||
String reminderStatusTag(CareReminder reminder, DateTime now) =>
|
||
isReminderOverdue(reminder, now)
|
||
? '已逾期'
|
||
: careReminderStatusLabel(reminder.status);
|
||
|
||
/// 提醒状态标签基色(TagPill 淡染底;文字深变体由 TagPill 内置映射)。
|
||
Color reminderStatusColor(CareReminder reminder, DateTime now) =>
|
||
switch (reminder.status) {
|
||
CareReminderStatus.pending =>
|
||
isReminderOverdue(reminder, now) ? AppColors.error : AppColors.accent,
|
||
CareReminderStatus.completed => AppColors.success,
|
||
CareReminderStatus.dismissed => AppColors.muted,
|
||
};
|
||
|
||
/// 提醒条目副行:按状态给时间语义(到期 / 完成于 / 已忽略)。
|
||
String reminderDateLine(CareReminder reminder) => switch (reminder.status) {
|
||
CareReminderStatus.pending => '到期 ${formatOccurredAt(reminder.dueAt)}',
|
||
CareReminderStatus.completed =>
|
||
'完成于 ${reminder.completedAt == null ? '—' : formatOccurredAt(reminder.completedAt!)}',
|
||
CareReminderStatus.dismissed =>
|
||
'已忽略 · 原到期 ${formatOccurredAt(reminder.dueAt)}',
|
||
};
|