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 createState() => _CareRemindersPageState(); } class _CareRemindersPageState extends State { _ListPhase _phase = _ListPhase.loading; List _reminders = const []; ApiException? _error; CareReminderStatus? _filter; bool _viewedFired = false; bool _mutating = false; @override void initState() { super.initState(); _load(); } Future _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 _openCreate() async { final created = await Navigator.of(context).push( 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 _complete(CareReminder reminder) async { final date = await showDialog( 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 _dismiss(CareReminder reminder) async { final confirmed = await showDialog( 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 _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('确认完成'), ), ], ); } }