/// 体重 / 疫苗 / 健康事件 / 提醒展示与输入解析的纯函数集合 /// (列表 / 表单共用,可单测)。 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} 月'; } /// 设备时区 → 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)}', };