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_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_models.dart'; import 'package:patbond_flutter/features/pets/pets_repository.dart'; import 'package:patbond_flutter/features/pets/weight_form_page.dart'; enum _ListPhase { loading, ready, error } /// 体重历史列表页(T2-13):cursor 分页(「加载更多」追加,末页收起), /// 四态齐备;新记录经 [WeightFormPage] 录入后就地插入列表头。 /// /// 曝光埋点:每次进入首个成功加载上报一次 /// `health_record_viewed(recordType=weight, source=pet_detail)` /// (工单口径:列表曝光;不随滚动逐条上报)。 class WeightRecordsPage extends StatefulWidget { const WeightRecordsPage({ 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() => _WeightRecordsPageState(); } class _WeightRecordsPageState extends State { _ListPhase _phase = _ListPhase.loading; List _records = 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.listWeights( widget.petId, limit: widget.pageSize, ); if (!mounted) return; setState(() { _records = page.items; _nextCursor = page.nextCursor; _hasMore = page.hasMore; _phase = _ListPhase.ready; }); if (!_viewedFired) { _viewedFired = true; widget.analytics?.viewed( recordType: HealthRecordType.weight, 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.listWeights( widget.petId, limit: widget.pageSize, cursor: _nextCursor, ); if (!mounted) return; setState(() { _records = [..._records, ...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( WeightFormPage( 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) { setState(() { // 契约排序 measured_at DESC, id DESC:新记录通常最新,就地插头; // 补录历史日期的位置以下次刷新为准(服务端是唯一事实来源)。 _records = [created, ..._records]; if (_phase != _ListPhase.ready) _phase = _ListPhase.ready; }); 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 _records.isEmpty => Center( child: SingleChildScrollView( child: EmptyStateIllustration( icon: Icons.monitor_weight_outlined, title: '还没有体重记录', description: '定期称重,看见毛孩子的成长曲线', ctaLabel: widget.canWrite ? '记录第一条' : null, onCtaPressed: widget.canWrite ? _openCreate : null, ), ), ), _ListPhase.ready => _list(), }, ); } Widget _list() { return RefreshIndicator( onRefresh: _loadFirstPage, child: ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 30), children: [ for (final record in _records) ...[ _WeightTile(record: record), const SizedBox(height: 10), ], if (_hasMore) 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('加载更多')), ), ], ), ); } } /// 体重条目:RecordTypeDot(体重) + 大数值(primaryDark w800)+ /// 日期/备注副行(05 §3.3 时间线条目形态的数值型变体)。 class _WeightTile extends StatelessWidget { const _WeightTile({required this.record}); final WeightRecord record; @override Widget build(BuildContext context) { final meta = StringBuffer(formatMeasuredAt(record.measuredAt)); if (record.note != null && record.note!.isNotEmpty) { meta.write(' · ${record.note}'); } return Card( child: Padding( padding: const EdgeInsets.all(14), child: Row( children: [ const RecordTypeDot( type: RecordType.weight, size: RecordTypeDotSize.md, ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${formatWeightKg(record.weightKg)} kg', style: const TextStyle( color: AppColors.primaryDark, fontSize: 15, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 4), Text( meta.toString(), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.inkSoft, fontSize: 12, ), ), ], ), ), ], ), ), ); } } /// 称重时刻展示:本地时区 `YYYY-MM-DD HH:mm`。 String formatMeasuredAt(DateTime measuredAt) { final local = measuredAt.toLocal(); final h = local.hour.toString().padLeft(2, '0'); final min = local.minute.toString().padLeft(2, '0'); return '${dateToJson(local)} $h:$min'; }