Files
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

173 lines
4.9 KiB
Dart
Raw Permalink 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/core/theme/app_theme.dart';
import 'package:patbond_flutter/widgets/common.dart';
/// 头像尺寸档(05 号规范 §3.1)。
enum PetAvatarSize {
/// 96:档案页头部(收敛现 104 → 96,与表单页一致)。
xl(96),
/// 64:宠物列表卡。
lg(64),
/// 44:头部宠物切换器(最小触控目标)。
md(44),
/// 32:记录详情等行内。
sm(32);
const PetAvatarSize(this.dimension);
final double dimension;
/// 编辑徽标仅 xl / lg 档提供(05 §3.1)。
bool get supportsBadge => this == xl || this == lg;
}
/// 宠物头像(05 号规范 §3.1):统一原档案页头部 / 编辑 sheet 各写一遍的
/// 头像实现。圆形裁切 + 3px `surface` 白描边 + 轻投影(正典
/// `.patbond-avatar` 规格),可选右下编辑徽标与品牌渐变环。
///
/// M2 首版不做头像上传(ADR-010 / D2-1 裁决);T3.5-09 起 [url] 由服务端
/// `Pet.avatarUrl`(预签名 GET)驱动,[url] 为 null 时仍渲染本地占位形态
/// `surfaceTint` 底 + `Icons.pets`)——签一个必然 404 的 URL 比返回 null
/// 更糟,故服务端对非 ready 的 asset 直接给 null,客户端只有一种占位逻辑。
/// 有图时经 [RemoteImage] 加载(缓存 key 已剥签名参数;loading / 失败兜底
/// 由其内置)。
class PetAvatar extends StatelessWidget {
const PetAvatar({
required this.size,
super.key,
this.url,
this.onTap,
this.showEditBadge = false,
this.ring = false,
this.enabled = true,
this.semanticLabel,
});
final PetAvatarSize size;
/// 头像图 URL;null 为本地占位形态(无头像 / asset 非 ready / 存储未配置)。
final String? url;
/// 可点击时整体 [InkWell] 圆形 ripple。
final VoidCallback? onTap;
/// 右下编辑徽标(`primaryStrong` 底 + 白 edit 图标,白/primaryStrong
/// 4.49:1 达非文字 3:1;修订现实现的 `primary` 底 2.75:1)。
final bool showEditBadge;
/// 2px `brandGradient` 外环(正典 story 环),仅作「当前选中」纯装饰指示。
final bool ring;
/// 禁用态 60% 不透明度(对齐 AppTextField 禁用惯例)。
final bool enabled;
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final dimension = size.dimension;
// 3px 白描边内的图面尺寸。
final imageDimension = dimension - 6;
Widget avatar = Container(
width: dimension,
height: dimension,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.surface,
boxShadow: [
BoxShadow(
color: Color(0x14000000), // rgba(0,0,0,0.08)
offset: Offset(0, 4),
blurRadius: 10,
),
],
),
padding: const EdgeInsets.all(3),
child: ClipOval(
child: url == null
? ColoredBox(
color: AppColors.surfaceTint,
child: Icon(
Icons.pets,
color: AppColors.muted,
size: imageDimension * 0.45,
),
)
: RemoteImage(
url: url!,
width: imageDimension,
height: imageDimension,
borderRadius: BorderRadius.circular(imageDimension / 2),
),
),
);
if (ring) {
avatar = Container(
padding: const EdgeInsets.all(2),
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: AppColors.brandGradient,
),
child: avatar,
);
}
if (showEditBadge && size.supportsBadge) {
avatar = Stack(
clipBehavior: Clip.none,
children: [
avatar,
const Positioned(right: 0, bottom: 0, child: _EditBadge()),
],
);
}
if (onTap != null) {
avatar = InkWell(
onTap: enabled ? onTap : null,
customBorder: const CircleBorder(),
child: avatar,
);
}
if (!enabled) {
avatar = Opacity(opacity: 0.6, child: avatar);
}
if (semanticLabel != null) {
avatar = Semantics(
label: semanticLabel,
button: onTap != null,
child: avatar,
);
}
return avatar;
}
}
/// 32 圆编辑徽标:`primaryStrong` 底 + 白 edit 1505 §3.1 无障碍修订)。
class _EditBadge extends StatelessWidget {
const _EditBadge();
@override
Widget build(BuildContext context) {
return Container(
width: 32,
height: 32,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.primaryStrong,
border: Border.fromBorderSide(
BorderSide(color: AppColors.surface, width: 2),
),
),
child: const Icon(Icons.edit, color: Colors.white, size: 15),
);
}
}