e186ba3da9
- 时间线页:occurred_at DESC cursor 分页 + 月分组组头,四态齐备; 六类事件经 RecordTypeDot 映射(增补喂养/洗护/测量三型,色族复用 已审计色对),类型 TagPill 双通道 - 事件录入表单:六类类型选择器、金额以元录入/整数分传输(money.dart 换算)、UTC 时间戳约定与体重表单一致 - 事件编辑页:顶层 PATCH 差量提交(title/notes/amountCents + version), 40902 照 T2-12 模式自动经时间线检索取新 version 重提(保留输入) - 档案页:月度花费卡接 summary.monthlyExpense(分→元展示)、tz 透传 设备时区固定偏移(T2-13 遗留③)、健康时间线导航入口 - 埋点:health_record 域 create 三事件 + viewed(recordType=health_event) 挂通;新增 edit_succeeded/failed 封装(failureReason 含 conflict) - 测试 224 → 250 全绿(+26);analyze 0 问题;format 无 diff Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
369 lines
12 KiB
Dart
369 lines
12 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/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/health_event_edit_page.dart';
|
||
import 'package:patbond_flutter/features/pets/health_event_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/money.dart';
|
||
import 'package:patbond_flutter/features/pets/pet_display.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):cursor 分页(occurred_at DESC,
|
||
/// 「加载更多」追加,末页收起),按月分组(05 §4.2 组头),四态齐备。
|
||
///
|
||
/// - 六类事件经 [recordTypeForHealthEvent] 映射 RecordTypeDot,
|
||
/// 类型标签 TagPill 双通道呈现;金额以元展示(传输为整数分)。
|
||
/// - 录入经 [HealthEventFormPage];成功后重拉首页(排序与月分组以
|
||
/// 服务端为准,不本地猜位置)。
|
||
/// - 编辑经 [HealthEventEditPage](canWrite 点条目进入);成功就地替换。
|
||
/// - 曝光埋点:每次进入首个成功加载上报一次
|
||
/// `health_record_viewed(recordType=health_event, source=pet_detail)`。
|
||
class HealthEventsPage extends StatefulWidget {
|
||
const HealthEventsPage({
|
||
required this.repository,
|
||
required this.petId,
|
||
required this.canWrite,
|
||
super.key,
|
||
this.analytics,
|
||
this.pageSize,
|
||
});
|
||
|
||
final PetsRepository repository;
|
||
final String petId;
|
||
|
||
/// owner/caregiver 可写;viewer 隐藏录入/编辑入口(40300 语义前置)。
|
||
final bool canWrite;
|
||
|
||
final HealthRecordAnalytics? analytics;
|
||
|
||
/// 每页条数(测试注入小页验证分页;缺省走服务端默认 20)。
|
||
final int? pageSize;
|
||
|
||
@override
|
||
State<HealthEventsPage> createState() => _HealthEventsPageState();
|
||
}
|
||
|
||
class _HealthEventsPageState extends State<HealthEventsPage> {
|
||
_ListPhase _phase = _ListPhase.loading;
|
||
List<HealthEvent> _events = const [];
|
||
String? _nextCursor;
|
||
bool _hasMore = false;
|
||
bool _loadingMore = false;
|
||
ApiException? _error;
|
||
bool _viewedFired = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadFirstPage();
|
||
}
|
||
|
||
Future<void> _loadFirstPage() async {
|
||
setState(() {
|
||
_phase = _ListPhase.loading;
|
||
_error = null;
|
||
});
|
||
try {
|
||
final page = await widget.repository.listHealthEvents(
|
||
widget.petId,
|
||
limit: widget.pageSize,
|
||
);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_events = page.items;
|
||
_nextCursor = page.nextCursor;
|
||
_hasMore = page.hasMore;
|
||
_phase = _ListPhase.ready;
|
||
});
|
||
if (!_viewedFired) {
|
||
_viewedFired = true;
|
||
widget.analytics?.viewed(
|
||
recordType: HealthRecordType.healthEvent,
|
||
source: HealthRecordViewSource.petDetail,
|
||
);
|
||
}
|
||
} on ApiException catch (error) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_error = error;
|
||
_phase = _ListPhase.error;
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _loadMore() async {
|
||
if (_loadingMore || !_hasMore) return;
|
||
setState(() => _loadingMore = true);
|
||
try {
|
||
final page = await widget.repository.listHealthEvents(
|
||
widget.petId,
|
||
limit: widget.pageSize,
|
||
cursor: _nextCursor,
|
||
);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_events = [..._events, ...page.items];
|
||
_nextCursor = page.nextCursor;
|
||
_hasMore = page.hasMore;
|
||
});
|
||
} on ApiException catch (error) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(SnackBar(content: Text(petLoadErrorMessage(error))));
|
||
} finally {
|
||
if (mounted) setState(() => _loadingMore = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _openCreate() async {
|
||
final created = await Navigator.of(context).push<HealthEvent>(
|
||
fadePageRoute(
|
||
HealthEventFormPage(
|
||
repository: widget.repository,
|
||
petId: widget.petId,
|
||
analytics: widget.analytics,
|
||
),
|
||
// record_form:健康记录漏斗到达段页名(06 §1.6 / §2.2)。
|
||
settings: RouteSettings(name: AnalyticsPageName.recordForm.pageName),
|
||
),
|
||
);
|
||
if (created != null && mounted) {
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(const SnackBar(content: Text('已记录健康事件')));
|
||
// occurred_at DESC + 月分组:补录历史日期的位置由服务端定,
|
||
// 重拉首页而非本地猜位置。
|
||
await _loadFirstPage();
|
||
}
|
||
}
|
||
|
||
Future<void> _openEdit(HealthEvent event) async {
|
||
final updated = await Navigator.of(context).push<HealthEvent>(
|
||
// 编辑页不带路由名:record_form 专属创建漏斗到达段(T2-12 先例)。
|
||
fadePageRoute(
|
||
HealthEventEditPage(
|
||
repository: widget.repository,
|
||
event: event,
|
||
analytics: widget.analytics,
|
||
),
|
||
),
|
||
);
|
||
if (updated != null && mounted) {
|
||
setState(() {
|
||
// occurredAt 不可编辑 → 排序/分组位置不变,就地替换安全。
|
||
_events = [
|
||
for (final item in _events)
|
||
if (item.id == updated.id) updated else item,
|
||
];
|
||
});
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(const SnackBar(content: Text('已保存修改')));
|
||
}
|
||
}
|
||
|
||
@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: 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: _loadFirstPage,
|
||
child: const Text('重试'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
_ListPhase.ready when _events.isEmpty => Center(
|
||
child: SingleChildScrollView(
|
||
child: EmptyStateIllustration(
|
||
icon: Icons.event_note_outlined,
|
||
title: '还没有健康记录',
|
||
description: '就医、驱虫、洗护……随手记下毛孩子的健康点滴',
|
||
ctaLabel: widget.canWrite ? '记录第一条' : null,
|
||
onCtaPressed: widget.canWrite ? _openCreate : null,
|
||
),
|
||
),
|
||
),
|
||
_ListPhase.ready => _list(),
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 按月分组渲染:服务端 occurred_at DESC 保证同月相邻,
|
||
/// 月份变化处插组头(05 §4.2「2026 年 9 月」)。
|
||
Widget _list() {
|
||
final children = <Widget>[];
|
||
String? currentMonth;
|
||
for (final event in _events) {
|
||
final month = healthEventMonthHeader(event.occurredAt);
|
||
if (month != currentMonth) {
|
||
currentMonth = month;
|
||
children.add(
|
||
Padding(
|
||
padding: EdgeInsets.only(top: children.isEmpty ? 0 : 14, bottom: 8),
|
||
child: Text(
|
||
month,
|
||
style: const TextStyle(
|
||
color: AppColors.inkSoft,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
children
|
||
..add(
|
||
_HealthEventTile(
|
||
event: event,
|
||
onTap: widget.canWrite ? () => _openEdit(event) : null,
|
||
),
|
||
)
|
||
..add(const SizedBox(height: 10));
|
||
}
|
||
if (_hasMore) {
|
||
children.add(
|
||
Center(
|
||
child: _loadingMore
|
||
? const Padding(
|
||
padding: EdgeInsets.all(12),
|
||
child: SizedBox(
|
||
width: 22,
|
||
height: 22,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
),
|
||
)
|
||
: TextButton(onPressed: _loadMore, child: const Text('加载更多')),
|
||
),
|
||
);
|
||
}
|
||
return RefreshIndicator(
|
||
onRefresh: _loadFirstPage,
|
||
child: ListView(
|
||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 30),
|
||
children: children,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 时间线条目(05 §3.3 形态):RecordTypeDot(六类映射) + 标题 +
|
||
/// 日期/备注副行;尾部类型 TagPill(双通道)与金额(元展示,
|
||
/// 15/w800 类型文字色)。
|
||
class _HealthEventTile extends StatelessWidget {
|
||
const _HealthEventTile({required this.event, this.onTap});
|
||
|
||
final HealthEvent event;
|
||
final VoidCallback? onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final recordType = recordTypeForHealthEvent(event.eventType);
|
||
final style = recordTypeStyles[recordType]!;
|
||
final meta = StringBuffer(formatOccurredAt(event.occurredAt));
|
||
if (event.notes != null && event.notes!.isNotEmpty) {
|
||
meta.write(' · ${event.notes}');
|
||
}
|
||
return Card(
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(14),
|
||
child: Row(
|
||
children: [
|
||
RecordTypeDot(type: recordType, size: RecordTypeDotSize.md),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
event.title,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: Theme.of(context).textTheme.titleMedium,
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
meta.toString(),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
color: AppColors.inkSoft,
|
||
fontSize: 12,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
TagPill(
|
||
healthEventTypeLabel(event.eventType),
|
||
color: style.baseColor,
|
||
),
|
||
if (event.amountCents != null) ...[
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
'¥${formatCentsAsYuan(event.amountCents!)}',
|
||
style: TextStyle(
|
||
color: style.inkColor,
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w800,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|