Files
patbond-flutter/lib/features/pets/pets_page.dart
T
lixi 97a1f46e3f
CI / flutter-gates (push) Successful in 1m31s
重构:宠物列表/详情/编辑页接入真实数据(T2-12,四态齐备)
- 档案 Tab 替换为真实宠物列表(05 号规范 P1):PetsController 四态
  驱动(loading/empty/error+重试/ready),空态插画 + 建档 CTA,
  下拉刷新,虚线添加卡;页面一律走 T2-11 数据层、不直连 ApiClient
- 新增 PetDetailPage(P2 档案信息部分):内存副本首屏 + getPet 刷新,
  40401 防枚举 → 不存在态返回列表并刷新;仅 owner 显示编辑入口
  (40300 语义);体重/疫苗/时间线区块留待 T2-13/14 接摘要与记录接口
- 新增 PetFormPage(建档/编辑):品种目录接口 + 自定义品种互斥
  (目录失败回落自定义 + 重试)、性别契约必填、生日+估算、芯片号;
  失焦+提交双校验、错误三层模型沿用登录纵切
- 40902 版本冲突:明确提示 + 自动拉取最新 version 重提路径(有测试);
  40903 芯片号冲突字段级报错(有测试)
- 埋点挂接:pet_create_started(表单首次输入、每次进入一次)/
  succeeded / failed 三事件接线;建宠表单路由名 pet_form、详情页
  pet_detail 进入既有 RouteObserver 采集;档案 Tab 页名 pet_archive
  改报 pet_list(字典 v2);编辑表单不带路由名(漏斗到达段不掺编辑)
- app.dart 装配:pet 服务 ApiClient(:8083)共享 TokenRefresher;
  登出 reset 控制器防跨账号泄漏;PetsController.loadBreeds 按物种缓存
- AppState 清理:pets 页原 demo 消费方移除,vaccines 及其模型/持久化
  删除;pet demo 仅存首页问候/创作页/主壳头像(后续工单收敛)
- 头像按 ADR-010 本地占位,不做上传
- 测试 145 → 177 全绿(列表/详情/表单四态与冲突路径 widget 测试、
  控制器 loadBreeds/reset、展示纯函数);analyze 0;format 无 diff

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

299 lines
9.3 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/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});
final PetsController controller;
final PetAnalytics? analytics;
@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,
),
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;
}