Files
patbond-flutter/lib/features/pets/pets_page.dart
T
lixi c91f18a845
CI / flutter-gates (push) Successful in 1m49s
新增:疫苗登记/记录列表与档案页摘要卡接入(T2-13 疫苗半边)
- VaccinationFormPage:目录按宠物物种过滤(四态)、seriesKey 目录 code
  预填、scheduled/completed 状态-日期规则结构化 + 纯函数双重前端拦截,
  42201/40904 后端兜底横幅(均有测试)
- VaccinationRecordsPage:按系列分组、三态 TagPill(含 cancelled 留痕)、
  四态齐备;viewer 隐藏登记入口
- 档案页数据卡行接 GET /pets/{id}/summary 实时聚合:最新体重/疫苗进度/
  下一针三卡,null 语义为空态文案而非 0/0;从记录页返回即重拉摘要
- health_record 埋点装配(app→shell→pets→detail→记录页面族)
- 测试 205 → 224(+19)全绿;analyze 0 问题;format 无 diff

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 13:20:04 +08:00

309 lines
9.6 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/analytics/analytics_page_name.dart';
import 'package:patbond_flutter/core/navigation/fade_route.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/pet_avatar.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_detail_page.dart';
import 'package:patbond_flutter/features/pets/pet_display.dart';
import 'package:patbond_flutter/features/pets/pet_form_page.dart';
import 'package:patbond_flutter/features/pets/pets_controller.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/widgets/common.dart';
/// 档案 Tab 落地页:宠物列表(T2-12 / 05 号规范 §4.1 P1)。
///
/// 真实数据经 [PetsController] 四态驱动:loading(居中转圈)、
/// empty(空态插画 + 建档 CTA)、error(横幅 + 重试)、ready(列表)。
/// 页面不直连 ApiClient,不读写 AppState demo 数据。
class PetsPage extends StatefulWidget {
const PetsPage({
required this.controller,
super.key,
this.analytics,
this.healthAnalytics,
});
final PetsController controller;
final PetAnalytics? analytics;
/// health_record 域埋点(T2-13,记录页面族透传)。
final HealthRecordAnalytics? healthAnalytics;
@override
State<PetsPage> createState() => _PetsPageState();
}
class _PetsPageState extends State<PetsPage> {
@override
void initState() {
super.initState();
// 主壳挂载即预取(IndexedStack 各 Tab 同时构建);重登后控制器
// 已被 reset 回 initial,会重新拉取。
if (widget.controller.phase == PetsLoadPhase.initial) {
widget.controller.refresh();
}
}
Future<void> _openCreate(PetCreateEntryPoint entryPoint) async {
final created = await Navigator.of(context).push<Pet>(
fadePageRoute(
PetFormPage.create(
controller: widget.controller,
analytics: widget.analytics,
entryPoint: entryPoint,
),
// 建宠表单曝光由既有 RouteObserver 采集(06 §1.6 pet_form)。
settings: RouteSettings(name: AnalyticsPageName.petForm.pageName),
),
);
if (created != null && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('已为「${created.name}」建立档案 🐾')));
}
}
void _openDetail(Pet pet) {
Navigator.of(context).push(
fadePageRoute(
PetDetailPage(
controller: widget.controller,
petId: pet.id,
analytics: widget.analytics,
healthAnalytics: widget.healthAnalytics,
),
settings: RouteSettings(name: AnalyticsPageName.petDetail.pageName),
),
);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.controller,
builder: (context, _) {
final controller = widget.controller;
switch (controller.phase) {
case PetsLoadPhase.initial:
case PetsLoadPhase.loading:
return const Center(child: CircularProgressIndicator());
case PetsLoadPhase.error:
return _LoadErrorView(
message: petLoadErrorMessage(controller.lastError),
onRetry: controller.refresh,
);
case PetsLoadPhase.ready:
if (controller.isEmpty) {
return Center(
child: SingleChildScrollView(
child: EmptyStateIllustration(
icon: Icons.pets,
title: '还没有宠物档案',
description: '添加毛孩子,开始记录 TA 的健康点滴',
ctaLabel: '添加宠物',
onCtaPressed: () =>
_openCreate(PetCreateEntryPoint.profileEmptyState),
),
),
);
}
return _petList(controller.pets);
}
},
);
}
Widget _petList(List<Pet> pets) {
return RefreshIndicator(
onRefresh: widget.controller.refresh,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 30),
children: [
Row(
children: [
Text('我的宠物', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
TextButton.icon(
onPressed: () => _openCreate(PetCreateEntryPoint.petList),
icon: const Icon(Icons.add, size: 18),
label: const Text('添加'),
),
],
),
const SizedBox(height: 12),
for (final pet in pets) ...[
_PetCard(pet: pet, onTap: () => _openDetail(pet)),
const SizedBox(height: 10),
],
_AddPetCard(onTap: () => _openCreate(PetCreateEntryPoint.petList)),
],
),
);
}
}
/// 加载失败态:InlineErrorBanner + 重试按钮(05 §4.2 错误三层模型)。
class _LoadErrorView extends StatelessWidget {
const _LoadErrorView({required this.message, required this.onRetry});
final String message;
final Future<void> Function() onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
InlineErrorBanner(message: message),
const SizedBox(height: 16),
FilledButton(onPressed: onRetry, child: const Text('重试')),
],
),
),
);
}
}
/// 宠物卡(05 §4.1):头像 lg + 名字 + 元信息 + chevron;非 active
/// 状态以 TagPill 标示(图文双通道由状态文案承担)。
class _PetCard extends StatelessWidget {
const _PetCard({required this.pet, required this.onTap});
final Pet pet;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.xl),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
const PetAvatar(size: PetAvatarSize.lg),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pet.name,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
petMetaLine(pet),
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
),
),
],
),
),
if (pet.status != PetStatus.active)
TagPill(
petStatusLabel(pet.status),
color: pet.status == PetStatus.lost
? AppColors.error
: AppColors.muted,
)
else
const Icon(Icons.chevron_right, color: AppColors.muted),
],
),
),
),
);
}
}
/// 虚线「添加宠物」卡(05 §4.1border 色 1.5px dashedradius 24,高 64)。
class _AddPetCard extends StatelessWidget {
const _AddPetCard({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _DashedBorderPainter(
color: AppColors.border,
strokeWidth: 1.5,
radius: AppRadius.xl,
),
child: SizedBox(
height: 64,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.xl),
child: const Center(
child: Text(
' 添加宠物',
style: TextStyle(
color: AppColors.primaryStrong,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
);
}
}
class _DashedBorderPainter extends CustomPainter {
const _DashedBorderPainter({
required this.color,
required this.strokeWidth,
required this.radius,
});
final Color color;
final double strokeWidth;
final double radius;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke;
final path = Path()
..addRRect(
RRect.fromRectAndRadius(Offset.zero & size, Radius.circular(radius)),
);
const dashWidth = 6.0;
const dashGap = 4.0;
for (final metric in path.computeMetrics()) {
var distance = 0.0;
while (distance < metric.length) {
canvas.drawPath(
metric.extractPath(distance, distance + dashWidth),
paint,
);
distance += dashWidth + dashGap;
}
}
}
@override
bool shouldRepaint(_DashedBorderPainter oldDelegate) =>
color != oldDelegate.color ||
strokeWidth != oldDelegate.strokeWidth ||
radius != oldDelegate.radius;
}