Files
patbond-flutter/lib/features/pets/pet_detail_page.dart
T
lixi eff3526840 新增:宠物头像接线——铅笔角标接上传、列表与详情展示真实头像(T3.5-09)
- `Pet.avatarUrl` 入模型;列表卡与详情头像改用真实预签名 URL(无图仍回退
  爪印占位——服务端对非 ready 的 asset 直接给 null,客户端只有一种占位)。
- 详情页铅笔角标自此有功能:接 `MediaUploader`(purpose=pet_avatar);已有
  头像时先给「更换 / 移除」二选一,移除即三态显式 null。
- 权限按 **WRITE 档**呈现(ADR-022 D3.5-3:owner + caregiver 可改头像,
  viewer 无入口),与资料编辑的 MANAGE 档刻意不同。因此纯头像 PATCH 只发
  `version` + `avatarAssetId`,**不夹带任何资料字段**——夹带会让服务端按更严
  的一半定档,caregiver 立刻 403。
- `UpdatePetRequest.avatarAssetId` 是该 schema 唯一的三态字段,其余字段保持
  M2 两态语义(它们的 CHECK 约束本就不允许空值,「清空」无意义)。
- 40902 冲突后重取档案让用户自行决定重来,不静默重放(头像是用户可见的
  覆盖操作,不该自动生效两次)。

测试 588 → 597(+9)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 15:37:25 +08:00

838 lines
30 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/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/avatar_upload_sheet.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/pet_avatar.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/pets/care_reminders_page.dart';
import 'package:patbond_flutter/features/pets/health_events_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/money.dart';
import 'package:patbond_flutter/features/pets/pet_analytics.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_form_page.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_controller.dart';
import 'package:patbond_flutter/features/pets/vaccination_records_page.dart';
import 'package:patbond_flutter/features/pets/weight_records_page.dart';
import 'package:patbond_flutter/widgets/common.dart';
enum _DetailPhase { loading, ready, error, notFound }
enum _SummaryPhase { loading, ready, error }
enum _RemindersPhase { loading, ready, error }
/// 宠物详情页(T2-12 / 05 号规范 §4.2 P2 的档案信息部分)。
///
/// 打开即用控制器内存副本首屏渲染,同时经 [PetsController.getPet]
/// 拉取最新详情(同步列表副本)。四态:loading / ready / error+重试 /
/// notFound40401 防枚举三态同响应 → 提示后返回列表并刷新)。
///
/// T2-13:数据卡行接 `GET /pets/{id}/summary` 实时聚合(最新体重 /
/// 疫苗进度 / 下一针,null 语义为空态文案而非 0/0),并作为体重历史、
/// 疫苗记录两页的导航入口;从记录页返回即重拉摘要。
class PetDetailPage extends StatefulWidget {
const PetDetailPage({
required this.controller,
required this.petId,
super.key,
this.analytics,
this.healthAnalytics,
this.avatarUploaderBuilder,
});
final PetsController controller;
final String petId;
final PetAnalytics? analytics;
final HealthRecordAnalytics? healthAnalytics;
/// 头像上传编排器构造口(T3.5-09);null 即本次构建未装配上传能力,
/// 隐藏头像编辑入口(生产装配恒注入,见 `app.dart`)。
final AvatarUploaderBuilder? avatarUploaderBuilder;
@override
State<PetDetailPage> createState() => _PetDetailPageState();
}
class _PetDetailPageState extends State<PetDetailPage> {
_DetailPhase _phase = _DetailPhase.loading;
Pet? _pet;
ApiException? _error;
_SummaryPhase _summaryPhase = _SummaryPhase.loading;
PetSummary? _summary;
_RemindersPhase _remindersPhase = _RemindersPhase.loading;
List<CareReminder> _pendingReminders = const [];
@override
void initState() {
super.initState();
_pet = _fromController();
if (_pet != null) _phase = _DetailPhase.ready;
_load();
_loadSummary();
_loadPendingReminders();
}
Pet? _fromController() {
for (final pet in widget.controller.pets) {
if (pet.id == widget.petId) return pet;
}
return null;
}
Future<void> _load() async {
if (_pet == null) {
setState(() => _phase = _DetailPhase.loading);
}
try {
final pet = await widget.controller.getPet(widget.petId);
if (!mounted) return;
setState(() {
_pet = pet;
_phase = _DetailPhase.ready;
});
} on PetNotFoundException {
if (!mounted) return;
setState(() => _phase = _DetailPhase.notFound);
} on ApiException catch (error) {
if (!mounted) return;
if (_pet == null) {
setState(() {
_error = error;
_phase = _DetailPhase.error;
});
} else {
// 已有内存副本:刷新失败降级为瞬态提示,不打断阅读。
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(petLoadErrorMessage(error))));
}
}
}
/// 摘要实时聚合(40401 由主链路 notFound 态承载,摘要只降级为 error 态)。
/// `tz` 透传设备时区固定偏移(T2-13 遗留③):monthlyExpense 的月度
/// 窗口随设备时区取边界,与用户直觉一致。
Future<void> _loadSummary() async {
setState(() => _summaryPhase = _SummaryPhase.loading);
try {
final summary = await widget.controller.repository.getPetSummary(
widget.petId,
tz: tzOffsetQueryValue(DateTime.now().timeZoneOffset),
);
if (!mounted) return;
setState(() {
_summary = summary;
_summaryPhase = _SummaryPhase.ready;
});
} on ApiException {
if (!mounted) return;
setState(() => _summaryPhase = _SummaryPhase.error);
}
}
/// 待办提醒(?status=pendingdue_at ASC):驱动「健康提醒」卡与
/// 提醒入口副行——demo 时代的硬编码提醒文案自此为真实数据取代。
/// 失败只降级为入口副行提示,不阻塞档案主链路。
Future<void> _loadPendingReminders() async {
setState(() => _remindersPhase = _RemindersPhase.loading);
try {
final reminders = await widget.controller.repository.listCareReminders(
widget.petId,
status: CareReminderStatus.pending,
);
if (!mounted) return;
setState(() {
_pendingReminders = reminders;
_remindersPhase = _RemindersPhase.ready;
});
} on ApiException {
if (!mounted) return;
setState(() => _remindersPhase = _RemindersPhase.error);
}
}
Future<void> _openEdit() async {
final pet = _pet;
if (pet == null) return;
final updated = await Navigator.of(context).push<Pet>(
// 编辑态不带路由名:pet_form 专属建宠漏斗到达段(06 §1.6),
// 编辑曝光不计入,避免「到达→动笔」分母虚高。
fadePageRoute(
PetFormPage.edit(
controller: widget.controller,
pet: pet,
analytics: widget.analytics,
),
),
);
if (updated != null && mounted) {
setState(() {
_pet = updated;
_phase = _DetailPhase.ready;
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已保存修改')));
}
}
/// 仅 owner 可改档案(40300caregiver/viewer 改档案被拒 → 隐藏写入口)。
bool get _canEdit =>
_phase == _DetailPhase.ready && _pet?.myRole == PetRole.owner;
/// 记录写入权限档 WRITEowner + caregiver);viewer 隐藏录入入口。
bool get _canWriteRecords => _pet?.myRole != PetRole.viewer;
/// 头像写入权限档 **WRITE**ADR-022 决策 D3.5-3:头像属日常照护信息,
/// 与体重/疫苗同档,owner + caregiver 均可改;viewer 只读)。
///
/// 注意这与 [_canEdit]MANAGE,仅 owner**刻意不同**:服务端按「本次
/// 请求触及了哪些字段」定档——只带 avatarAssetId 走 WRITE,碰任一资料
/// 字段即 MANAGE。故这里只发纯头像 PATCH,不夹带任何资料字段。
bool get _canEditAvatar =>
_phase == _DetailPhase.ready &&
_pet?.myRole != PetRole.viewer &&
widget.avatarUploaderBuilder != null;
/// 头像入口:已有头像时先给「更换 / 移除」二选一,没有则直接拉起上传。
Future<void> _onAvatarTap() async {
final pet = _pet;
if (pet == null || !_canEditAvatar) return;
if (pet.avatarUrl == null) {
await _uploadAvatar();
return;
}
final action = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('更换头像'),
onTap: () => Navigator.pop(context, 'replace'),
),
ListTile(
leading: const Icon(
Icons.delete_outline,
color: AppColors.errorDark,
),
title: const Text(
'移除头像',
style: TextStyle(color: AppColors.errorDark),
),
onTap: () => Navigator.pop(context, 'clear'),
),
],
),
),
);
if (!mounted || action == null) return;
if (action == 'replace') {
await _uploadAvatar();
} else {
// 三态「显式 null」= 清除头像(缺省是「不改」,两者在 JSON 上不同)。
await _patchAvatar(const PatchField<String>.clear(), '已移除头像');
}
}
Future<void> _uploadAvatar() async {
final builder = widget.avatarUploaderBuilder;
if (builder == null) return;
final assetId = await showAvatarUploadSheet(
context,
builder: builder,
// 用途即引用侧的类型检查:帖图或用户头像挂到宠物上会被答 404/40405。
purpose: MediaPurpose.petAvatar,
);
if (assetId == null || !mounted) return;
await _patchAvatar(PatchField<String>.value(assetId), '头像已更新');
}
/// 纯头像 PATCH:请求体只有 `version` + `avatarAssetId`**不带任何资料
/// 字段**——夹带资料字段会把权限档从 WRITE 抬到 MANAGEcaregiver 立刻
/// 403(服务端刻意按更严的一半判,堵「夹带改名」)。
Future<void> _patchAvatar(PatchField<String> intent, String okMessage) async {
final pet = _pet;
if (pet == null) return;
try {
final updated = await widget.controller.updatePet(
pet.id,
UpdatePetRequest(version: pet.version, avatarAssetId: intent),
);
if (!mounted) return;
setState(() {
_pet = updated;
_phase = _DetailPhase.ready;
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(okMessage)));
} on PetVersionConflictException catch (error) {
// 40902:他人已改过这行。重取档案拿新 version,让用户自行决定是否重来
// (不静默重放——头像是用户可见的覆盖操作,不该自动生效两次)。
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(petAvatarSaveErrorMessage(error))));
await _load();
} on ApiException catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(petAvatarSaveErrorMessage(error))));
}
}
Future<void> _openWeights(Pet pet) async {
await Navigator.of(context).push(
// 列表页页名不在字典 v2 枚举内(06 §5.2 验收 4:字典外不上报),
// 曝光由 health_record_viewed 承载。
fadePageRoute(
WeightRecordsPage(
repository: widget.controller.repository,
petId: pet.id,
canWrite: _canWriteRecords,
analytics: widget.healthAnalytics,
),
),
);
// 记录可能已变化:返回即重拉摘要(服务端实时聚合是唯一事实来源)。
if (mounted) await _loadSummary();
}
Future<void> _openVaccinations(Pet pet) async {
await Navigator.of(context).push(
fadePageRoute(
VaccinationRecordsPage(
repository: widget.controller.repository,
petId: pet.id,
petSpecies: pet.species,
canWrite: _canWriteRecords,
analytics: widget.healthAnalytics,
),
),
);
if (mounted) await _loadSummary();
}
Future<void> _openTimeline(Pet pet) async {
await Navigator.of(context).push(
fadePageRoute(
HealthEventsPage(
repository: widget.controller.repository,
petId: pet.id,
canWrite: _canWriteRecords,
analytics: widget.healthAnalytics,
),
),
);
// 事件可能已变化:返回即重拉摘要(月度花费实时聚合)。
if (mounted) await _loadSummary();
}
Future<void> _openReminders(Pet pet) async {
await Navigator.of(context).push(
fadePageRoute(
CareRemindersPage(
repository: widget.controller.repository,
petId: pet.id,
canWrite: _canWriteRecords,
analytics: widget.healthAnalytics,
),
),
);
// 待办可能已变化(完成/忽略/新建):返回即重拉待办。
if (mounted) await _loadPendingReminders();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: AppColors.ink,
actions: [
if (_canEdit)
IconButton(
tooltip: '编辑资料',
onPressed: _openEdit,
icon: const Icon(Icons.edit_outlined),
),
],
),
body: switch (_phase) {
_DetailPhase.loading => const Center(
child: CircularProgressIndicator(),
),
_DetailPhase.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('重试')),
],
),
),
),
_DetailPhase.notFound => Center(
child: SingleChildScrollView(
child: EmptyStateIllustration(
icon: Icons.search_off_rounded,
title: '宠物不存在或已被删除',
description: '档案可能已被移除,返回列表查看最新档案',
ctaLabel: '返回列表',
onCtaPressed: () {
widget.controller.refresh();
Navigator.of(context).pop();
},
),
),
),
_DetailPhase.ready => _content(_pet!),
},
);
}
Widget _content(Pet pet) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 30),
children: [
Column(
children: [
// T3.5-09:头像展示真实预签名 URL(无图回退爪印占位);铅笔角标
// 自此有功能——接 MediaUploader 上传 pet_avatarWRITE 档可见。
PetAvatar(
size: PetAvatarSize.xl,
url: pet.avatarUrl,
showEditBadge: _canEditAvatar,
onTap: _canEditAvatar ? _onAvatarTap : null,
semanticLabel: _canEditAvatar ? '更换宠物头像' : null,
),
const SizedBox(height: 12),
Text(pet.name, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 4),
Text(
petMetaLine(pet),
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
if (pet.status != PetStatus.active) ...[
const SizedBox(height: 8),
TagPill(
petStatusLabel(pet.status),
color: pet.status == PetStatus.lost
? AppColors.error
: AppColors.muted,
),
],
],
),
const SizedBox(height: 24),
Text('健康数据', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 10),
_summarySection(pet),
const SizedBox(height: 16),
_recordsSection(pet),
const SizedBox(height: 24),
Text('基本资料', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 10),
SectionCard(
child: Column(
children: [
_InfoRow(label: '物种', value: petSpeciesLabel(pet.species)),
const _RowDivider(),
_InfoRow(label: '品种', value: petBreedLabel(pet)),
const _RowDivider(),
_InfoRow(label: '性别', value: petSexLabel(pet.sex)),
const _RowDivider(),
_InfoRow(
label: '生日',
value: pet.birthDate == null
? '未填写'
: dateToJson(pet.birthDate!) +
(pet.birthDateEstimated ? '(估算)' : ''),
),
const _RowDivider(),
_InfoRow(label: '芯片号', value: pet.microchipNo ?? '未填写'),
const _RowDivider(),
_InfoRow(label: '性格', value: pet.personality ?? '未填写'),
const _RowDivider(),
_InfoRow(
label: '绝育日期',
value: pet.sterilizedOn == null
? '未填写'
: dateToJson(pet.sterilizedOn!),
),
],
),
),
if (_canEdit) ...[
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: _openEdit,
icon: const Icon(Icons.edit_outlined, size: 17),
label: const Text('编辑资料'),
),
],
],
);
}
/// 记录导航区:健康时间线与照护提醒入口(T2-14)。待办提醒非空时
/// 上方渲染真实数据驱动的「健康提醒」卡(正典 alert-card 形态,
/// 取代 demo 硬编码文案),点卡与点入口同去提醒页。
Widget _recordsSection(Pet pet) {
final nearest = _pendingReminders.isEmpty ? null : _pendingReminders.first;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (nearest != null) ...[
_ReminderAlertCard(
reminder: nearest,
overdue: isReminderOverdue(nearest, DateTime.now()),
onTap: () => _openReminders(pet),
),
const SizedBox(height: 10),
],
SectionCard(
padding: EdgeInsets.zero,
child: Column(
children: [
ListTile(
leading: const Icon(
Icons.event_note_outlined,
color: AppColors.inkSoft,
),
title: const Text('健康时间线', style: TextStyle(fontSize: 14)),
subtitle: const Text(
'就医 · 喂养 · 驱虫 · 洗护 · 测量 · 随手记',
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
trailing: const Icon(
Icons.chevron_right,
color: AppColors.muted,
),
onTap: () => _openTimeline(pet),
),
const Divider(height: 1, thickness: 1, color: AppColors.border),
ListTile(
leading: const Icon(
Icons.notifications_outlined,
color: AppColors.inkSoft,
),
title: const Text('照护提醒', style: TextStyle(fontSize: 14)),
subtitle: Text(
switch (_remindersPhase) {
_RemindersPhase.loading => '加载中…',
_RemindersPhase.error => '提醒加载失败,点击查看',
_RemindersPhase.ready when _pendingReminders.isEmpty =>
'暂无待办提醒',
_RemindersPhase.ready => '${_pendingReminders.length} 条待办',
},
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
),
),
trailing: const Icon(
Icons.chevron_right,
color: AppColors.muted,
),
onTap: () => _openReminders(pet),
),
],
),
),
],
);
}
/// 数据卡行(05 §4.2 stat-row):四卡取数全部来自 summary 实时聚合,
/// 不落任何本地展示字符串(第 4.3 节红线)。null 语义 → 空态文案。
Widget _summarySection(Pet pet) {
switch (_summaryPhase) {
case _SummaryPhase.loading:
return const SizedBox(
height: 86,
child: Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
case _SummaryPhase.error:
return Row(
children: [
const Expanded(
child: Text(
'健康数据加载失败',
style: TextStyle(color: AppColors.error, fontSize: 12),
),
),
TextButton(onPressed: _loadSummary, child: const Text('重试')),
],
);
case _SummaryPhase.ready:
final summary = _summary!;
final weight = summary.latestWeight;
final progress = summary.vaccinationProgress;
final next = summary.nextVaccination;
final expense = summary.monthlyExpense;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: _SummaryCard(
icon: recordTypeStyles[RecordType.weight]!.icon,
iconColor: recordTypeStyles[RecordType.weight]!.iconColor,
value: weight == null
? '暂无记录'
: '${formatWeightKg(weight.weightKg)} kg',
emphasized: weight != null,
label: '最新体重',
onTap: () => _openWeights(pet),
),
),
const SizedBox(width: 10),
Expanded(
child: _SummaryCard(
icon: recordTypeStyles[RecordType.vaccine]!.icon,
iconColor: recordTypeStyles[RecordType.vaccine]!.iconColor,
// 契约:totalDoses=0 → 整体 null(不是 0/0)→ 空态文案。
value: progress == null
? '未登记'
: '${vaccinationProgressLabel(progress)} 针',
emphasized: progress != null,
label: '疫苗进度',
onTap: () => _openVaccinations(pet),
),
),
const SizedBox(width: 10),
Expanded(
child: _SummaryCard(
icon: recordTypeStyles[RecordType.vaccine]!.icon,
iconColor: recordTypeStyles[RecordType.vaccine]!.iconColor,
value: next == null ? '暂无安排' : dateToJson(next.dueOn),
emphasized: next != null,
label: next == null ? '下一针' : '下一针·${next.vaccineName}',
onTap: () => _openVaccinations(pet),
),
),
const SizedBox(width: 10),
Expanded(
child: _SummaryCard(
icon: Icons.payments_outlined,
iconColor: AppColors.primaryStrong,
// monthlyExpense 恒非 null(契约);金额整数分 → 元展示。
value: ${formatCentsAsYuan(expense.amountCents)}',
emphasized: expense.amountCents > 0,
// M3.5-03:标签展示服务端归月的实际月份(此前硬编码
// 「本月花费」,用户无法自证记录落在哪个月)。
label: monthlyExpenseCardLabel(expense.month),
onTap: () => _openTimeline(pet),
),
),
],
),
);
}
}
}
/// 数据卡(正典 stat-card 形态):图标 + 数值 15/w800 + 标签 12 inkSoft
/// 空态数值降级 inkSoft 常规字重(区分「有数据」与「空态」两种视觉)。
///
/// M3.5-03:可点卡([onTap] 非空)右上角补 `chevron_right`——四张卡本都可点进
/// 明细页,但此前无任何视觉提示,用户实测反馈不知道能点。提示形态沿用项目
/// 既有可点行/卡(宠物列表卡、健康提醒卡、资料页设置行)的 `chevron_right`
/// 不自创。同时 [MergeSemantics] 把「数值 + 标签」并进 InkWell 的 button 节点,
/// 读屏一次读全「¥0,9 月花费,按钮」,而不是两段孤立文字(tap 动作仍在
/// InkWell 上,不用 `excludeSemantics` 以免连带丢掉可激活性)。
class _SummaryCard extends StatelessWidget {
const _SummaryCard({
required this.icon,
required this.iconColor,
required this.value,
required this.label,
required this.emphasized,
this.onTap,
});
final IconData icon;
final Color iconColor;
final String value;
final String label;
final bool emphasized;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return MergeSemantics(
child: Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.xl),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: iconColor),
const Spacer(),
if (onTap != null)
const Icon(
Icons.chevron_right,
size: 16,
color: AppColors.muted,
),
],
),
const SizedBox(height: 8),
Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: emphasized ? AppColors.ink : AppColors.inkSoft,
fontSize: emphasized ? 15 : 13,
fontWeight: emphasized ? FontWeight.w800 : FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 11,
),
),
],
),
),
),
),
);
}
}
/// 键值行(05 §4.3 P3 风格:字段名 12 inkSoft / 值 bodyMedium ink)。
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 72,
child: Text(
label,
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
),
),
Expanded(
child: Text(value, style: Theme.of(context).textTheme.bodyMedium),
),
],
),
);
}
}
class _RowDivider extends StatelessWidget {
const _RowDivider();
@override
Widget build(BuildContext context) {
return const Divider(height: 1, thickness: 1, color: AppColors.border);
}
}
/// 「健康提醒」卡(正典 alert-cardsuccessSurface 底 + dot + 文字):
/// 数据源为最近到期的待办提醒(真实数据驱动,取代 demo 硬编码文案);
/// 逾期时文案切警示深色(双通道:前缀文字 + 颜色)。
class _ReminderAlertCard extends StatelessWidget {
const _ReminderAlertCard({
required this.reminder,
required this.overdue,
this.onTap,
});
final CareReminder reminder;
final bool overdue;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final textColor = overdue ? AppColors.errorDark : AppColors.successInk;
return Material(
color: AppColors.successSurface,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: textColor,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'健康提醒:${reminder.title}'
'${overdue ? '已逾期' : '${dateToJson(reminder.dueAt.toLocal())} 到期'}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: textColor,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
Icon(Icons.chevron_right, size: 18, color: textColor),
],
),
),
),
);
}
}