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>
519 lines
18 KiB
Dart
519 lines
18 KiB
Dart
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 必带 completedAt,pending→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('确认完成'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|