Files
patbond-flutter/lib/features/pets/pets_page.dart
T
lixi eff3526840 新增:宠物头像接线——铅笔角标接上传、列表与详情展示真实头像(T3.5-09)
- `Pet.avatarUrl` 入模型;列表卡与详情头像改用真实预签名 URL(无图仍回退
  爪印占位——服务端对非 ready 的 asset 直接给 null,客户端只有一种占位)。
- 详情页铅笔角标自此有功能:接 `MediaUploader`(purpose=pet_avatar);已有
  头像时先给「更换 / 移除」二选一,移除即三态显式 null。
- 权限按 **WRITE 档**呈现(ADR-022 D3.5-3:owner + caregiver 可改头像,
  viewer 无入口),与资料编辑的 MANAGE 档刻意不同。因此纯头像 PATCH 只发
  `version` + `avatarAssetId`,**不夹带任何资料字段**——夹带会让服务端按更严
  的一半定档,caregiver 立刻 403。
- `UpdatePetRequest.avatarAssetId` 是该 schema 唯一的三态字段,其余字段保持
  M2 两态语义(它们的 CHECK 约束本就不允许空值,「清空」无意义)。
- 40902 冲突后重取档案让用户自行决定重来,不静默重放(头像是用户可见的
  覆盖操作,不该自动生效两次)。

测试 588 → 597(+9)。

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

316 lines
10 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/avatar_upload_sheet.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,
this.avatarUploaderBuilder,
});
final PetsController controller;
final PetAnalytics? analytics;
/// health_record 域埋点(T2-13,记录页面族透传)。
final HealthRecordAnalytics? healthAnalytics;
/// 头像上传编排器构造口(T3.5-09,透传详情页)。
final AvatarUploaderBuilder? avatarUploaderBuilder;
@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,
avatarUploaderBuilder: widget.avatarUploaderBuilder,
),
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: [
// T3.5-09:列表卡展示真实头像(预签名 URL,无图回退爪印占位)。
PetAvatar(size: PetAvatarSize.lg, url: pet.avatarUrl),
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;
}