Files
patbond-flutter/lib/features/pets/care_reminders_page.dart
T
lixi 294fc4a781 修复:日期录入收口共享层——「今天」快捷键 + 品牌配色 datePickerTheme(M3.5-02)
用户实测已因日期选择器误录: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>
2026-09-10 17:59:52 +08:00

519 lines
18 KiB
Dart
Raw 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:patbond_flutter/analytics/analytics_page_name.dart';
import 'package:patbond_flutter/core/navigation/fade_route.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/empty_state_illustration.dart';
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
import 'package:patbond_flutter/features/pets/care_reminder_form_page.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_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';
import 'package:patbond_flutter/widgets/common.dart';
enum _ListPhase { loading, ready, error }
/// 照护提醒页(T2-14):契约不分页,`due_at ASC`(待办最先到期在前);
/// `?status=` 过滤走服务端白名单视图;逾期待办有显性视觉标识。
///
/// - 完成 / 忽略:`PATCH /care-reminders/{id}` 状态流转
/// pending→completed 必带 completedAtpending→dismissed 禁带);
/// 42202 规则兜底、40902 并发流转抢先提示后重拉。
/// - 提醒完成/忽略**不埋事件**(06 §7 缺口 3 既定取舍:完成率从
/// care_reminders 事实表出数;M3+ 推送实验时再增补)。
/// - 曝光埋点:每次进入首个成功加载上报一次
/// `health_record_viewed(recordType=reminder, source=pet_detail)`。
class CareRemindersPage extends StatefulWidget {
const CareRemindersPage({
required this.repository,
required this.petId,
required this.canWrite,
super.key,
this.analytics,
});
final PetsRepository repository;
final String petId;
/// owner/caregiver 可写;viewer 隐藏创建与完成/忽略入口。
final bool canWrite;
final HealthRecordAnalytics? analytics;
@override
State<CareRemindersPage> createState() => _CareRemindersPageState();
}
class _CareRemindersPageState extends State<CareRemindersPage> {
_ListPhase _phase = _ListPhase.loading;
List<CareReminder> _reminders = const [];
ApiException? _error;
CareReminderStatus? _filter;
bool _viewedFired = false;
bool _mutating = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_phase = _ListPhase.loading;
_error = null;
});
try {
final reminders = await widget.repository.listCareReminders(
widget.petId,
status: _filter,
);
if (!mounted) return;
setState(() {
_reminders = reminders;
_phase = _ListPhase.ready;
});
if (!_viewedFired) {
_viewedFired = true;
widget.analytics?.viewed(
recordType: HealthRecordType.reminder,
source: HealthRecordViewSource.petDetail,
);
}
} on ApiException catch (error) {
if (!mounted) return;
setState(() {
_error = error;
_phase = _ListPhase.error;
});
}
}
Future<void> _openCreate() async {
final created = await Navigator.of(context).push<CareReminder>(
fadePageRoute(
CareReminderFormPage(
repository: widget.repository,
petId: widget.petId,
analytics: widget.analytics,
),
settings: RouteSettings(name: AnalyticsPageName.recordForm.pageName),
),
);
if (created != null && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已添加提醒')));
// 排序键 due_at ASC 在服务端,重新拉取而非本地猜位置。
await _load();
}
}
/// 完成时刻:今日取此刻,补记历史日期取当日 12:00(本地),转 UTC。
DateTime _completedAt(DateTime date) {
final now = DateTime.now();
final sameDay =
date.year == now.year && date.month == now.month && date.day == now.day;
final local = sameDay ? now : DateTime(date.year, date.month, date.day, 12);
return local.toUtc();
}
Future<void> _complete(CareReminder reminder) async {
final date = await showDialog<DateTime>(
context: context,
builder: (context) => _CompleteDialog(title: reminder.title),
);
if (date == null || !mounted) return;
await _mutate(
reminder,
UpdateCareReminderRequest(
status: CareReminderStatus.completed,
// 契约:pending→completed 必带 completedAt(客户端提交,允许补记)。
completedAt: _completedAt(date),
),
successText: '已标记完成',
);
}
Future<void> _dismiss(CareReminder reminder) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('忽略这条提醒?'),
content: Text('「${reminder.title}」将不再出现在待办中,且不可恢复为待办。'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('忽略'),
),
],
),
);
if (confirmed != true || !mounted) return;
await _mutate(
reminder,
// 契约:pending→dismissed 禁带 completedAt。
const UpdateCareReminderRequest(status: CareReminderStatus.dismissed),
successText: '已忽略提醒',
);
}
Future<void> _mutate(
CareReminder reminder,
UpdateCareReminderRequest request, {
required String successText,
}) async {
if (_mutating) return;
setState(() => _mutating = true);
final messenger = ScaffoldMessenger.of(context);
try {
await widget.repository.updateCareReminder(reminder.id, request);
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(successText)));
await _load();
} on CareReminderRuleException {
if (!mounted) return;
// 42202:状态机/completedAt 一致性兜底(前端结构上已按状态发字段)。
messenger.showSnackBar(
const SnackBar(content: Text('提醒状态不满足流转规则,已刷新,请重试')),
);
await _load();
} on PetVersionConflictException {
if (!mounted) return;
// 40902:并发流转抢先(条件更新守卫落空),处理方式与乐观锁一致。
messenger.showSnackBar(const SnackBar(content: Text('提醒已在其他设备被处理,已刷新')));
await _load();
} on PetRecordNotFoundException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('提醒不存在或已被删除,已刷新')));
await _load();
} on PetAccessDeniedException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('你没有权限操作该提醒')));
} on ApiBusinessException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('操作失败,请稍后重试')));
} on ApiNetworkException {
if (!mounted) return;
messenger.showSnackBar(const SnackBar(content: Text('网络异常,请检查网络后重试')));
} on SessionExpiredException {
// 会话失效:认证状态机自动回登录页。
} finally {
if (mounted) setState(() => _mutating = 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,
),
actions: [
if (widget.canWrite && _phase == _ListPhase.ready)
IconButton(
tooltip: '添加提醒',
onPressed: _openCreate,
icon: const Icon(Icons.add),
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
child: _filterChips(),
),
Expanded(
child: switch (_phase) {
_ListPhase.loading => const Center(
child: CircularProgressIndicator(),
),
_ListPhase.error => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
InlineErrorBanner(message: petLoadErrorMessage(_error)),
const SizedBox(height: 16),
FilledButton(onPressed: _load, child: const Text('重试')),
],
),
),
),
_ListPhase.ready when _reminders.isEmpty => Center(
child: SingleChildScrollView(child: _emptyState()),
),
_ListPhase.ready => _list(),
},
),
],
),
);
}
/// 状态过滤(服务端白名单视图;「待办」即 ?status=pending 按 due_at 查询)。
Widget _filterChips() {
final options = <(String, CareReminderStatus?)>[
('全部', null),
('待办', CareReminderStatus.pending),
('已完成', CareReminderStatus.completed),
('已忽略', CareReminderStatus.dismissed),
];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final (label, status) in options)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(label),
selected: _filter == status,
onSelected: (_) {
if (_filter == status) return;
setState(() => _filter = status);
_load();
},
),
),
],
),
);
}
Widget _emptyState() {
// 过滤视图下的空态不给 CTA(05 §4.2 筛选后空态惯例)。
if (_filter != null) {
return EmptyStateIllustration(
icon: Icons.notifications_none_outlined,
title: '暂无「${careReminderStatusLabel(_filter!)}」提醒',
);
}
return EmptyStateIllustration(
icon: Icons.notifications_none_outlined,
title: '还没有照护提醒',
description: '驱虫、体检、用药……到点不忘每一件照护小事',
ctaLabel: widget.canWrite ? '添加第一条' : null,
onCtaPressed: widget.canWrite ? _openCreate : null,
);
}
Widget _list() {
final now = DateTime.now();
return RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 30),
children: [
for (final reminder in _reminders) ...[
_ReminderTile(
reminder: reminder,
now: now,
// 完成/忽略仅对待办可用(终态不可迁;viewer 无写权限)。
onComplete:
widget.canWrite &&
reminder.status == CareReminderStatus.pending &&
!_mutating
? () => _complete(reminder)
: null,
onDismiss:
widget.canWrite &&
reminder.status == CareReminderStatus.pending &&
!_mutating
? () => _dismiss(reminder)
: null,
),
const SizedBox(height: 10),
],
],
),
);
}
}
/// 提醒条目:RecordTypeDot + 标题 + 时间副行 + 状态 TagPill(逾期红标),
/// 待办行附「标记完成 / 忽略」动作(图标 + 文字双通道)。
class _ReminderTile extends StatelessWidget {
const _ReminderTile({
required this.reminder,
required this.now,
this.onComplete,
this.onDismiss,
});
final CareReminder reminder;
final DateTime now;
final VoidCallback? onComplete;
final VoidCallback? onDismiss;
@override
Widget build(BuildContext context) {
final overdue = isReminderOverdue(reminder, now);
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
RecordTypeDot(
type: recordTypeForReminder(reminder.reminderType),
size: RecordTypeDotSize.md,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
reminder.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'${careReminderTypeLabel(reminder.reminderType)} · '
'${reminderDateLine(reminder)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
// 逾期副行同步警示色(与标签双位标识)。
color: overdue
? AppColors.errorDark
: AppColors.inkSoft,
fontSize: 12,
fontWeight: overdue
? FontWeight.w700
: FontWeight.w400,
),
),
],
),
),
const SizedBox(width: 8),
TagPill(
reminderStatusTag(reminder, now),
color: reminderStatusColor(reminder, now),
),
],
),
if (onComplete != null || onDismiss != null) ...[
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: onDismiss,
icon: const Icon(Icons.close, size: 16),
style: TextButton.styleFrom(
foregroundColor: AppColors.inkSoft,
),
label: const Text('忽略'),
),
const SizedBox(width: 4),
TextButton.icon(
onPressed: onComplete,
icon: const Icon(Icons.check_circle_outline, size: 16),
label: const Text('标记完成'),
),
],
),
],
],
),
),
);
}
}
/// 标记完成对话框:完成日期默认今天,可补记历史时刻(契约 completedAt
/// 由客户端提交)。确认返回所选日期。
class _CompleteDialog extends StatefulWidget {
const _CompleteDialog({required this.title});
final String title;
@override
State<_CompleteDialog> createState() => _CompleteDialogState();
}
class _CompleteDialogState extends State<_CompleteDialog> {
DateTime _date = DateTime.now();
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('标记完成'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'「${widget.title}」',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.event_outlined, color: AppColors.muted),
title: const Text('完成日期', style: TextStyle(fontSize: 14)),
subtitle: Text(
dateToJson(_date),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
onTap: () async {
final now = DateTime.now();
final value = await pickAppDate(
context: context,
initialDate: _date,
firstDate: DateTime(1990),
lastDate: now,
);
if (value != null && mounted) {
setState(() => _date = value);
}
},
trailing: AppDateFieldTrailing(
firstDate: DateTime(1990),
lastDate: DateTime.now(),
onToday: (value) => setState(() => _date = value),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(_date),
child: const Text('确认完成'),
),
],
);
}
}