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 createState() => _HealthEventsPageState(); } class _HealthEventsPageState extends State { _ListPhase _phase = _ListPhase.loading; List _events = const []; String? _nextCursor; bool _hasMore = false; bool _loadingMore = false; ApiException? _error; bool _viewedFired = false; @override void initState() { super.initState(); _loadFirstPage(); } Future _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 _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 _openCreate() async { final created = await Navigator.of(context).push( 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 _openEdit(HealthEvent event) async { final updated = await Navigator.of(context).push( // 编辑页不带路由名: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 = []; 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, ), ), ], ], ), ], ), ), ), ); } }