eff3526840
- `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>
173 lines
4.9 KiB
Dart
173 lines
4.9 KiB
Dart
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 15(05 §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),
|
||
);
|
||
}
|
||
}
|