新增:档案页共享组件三件 + TagPill 深变体映射(DEBT-1 偿还)

- PetAvatar 四尺寸档(96/64/44/32)收敛头像重复实现:3px 白描边 +
  轻投影,编辑徽标底改 primaryStrong(白图标 4.49:1 达非文字 3:1),
  M2 无上传(ADR-010),url 缺省渲染本地占位形态
- RecordTypeDot 三尺寸档 + 五类记录类型的图标/三色映射唯一出口
  (05 号规范 §2 表,供 T2-13/14 时间线复用)
- EmptyStateIllustration 空态插画区(112 圆 + 标题 + 说明 + 可选 CTA)
- TagPill 增可选 inkColor,缺省按 color 查深变体映射
  (primary→primaryDark 8.74:1 / success→successInk 7.39:1 /
  accent→accentDark 7.07:1 / error→errorDark 5.78:1 / 未命中→ink),
  既有调用零参数回归
- AppColors 新增 errorDark #B02C25、inkSoft #6B5A4A(05 号规范 D8)

flutter test 141/141 全绿;analyze 0 问题(T2-12 第一部分)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 11:57:31 +08:00
parent 7fb9031f8d
commit 3179528c57
9 changed files with 648 additions and 2 deletions
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
/// 空态插画区(05 号规范 §3.4):承载引导动作的空态,向上兼容现有
/// `EmptyState`42 图标 + 一行 bodySmall 的纯提示版继续留用)。
///
/// 布局:112 圆形插画区(`surfaceTint` 底 + 56 `primary` 图标,大面积
/// 装饰用法)→ 标题 → 说明(12 `inkSoft` ≤2 行居中)→ 可选 CTA。
/// v1 插画用 Material 图标,正式素材待品牌侧供给后原位替换。
class EmptyStateIllustration extends StatelessWidget {
const EmptyStateIllustration({
required this.icon,
required this.title,
super.key,
this.description,
this.ctaLabel,
this.onCtaPressed,
});
final IconData icon;
final String title;
final String? description;
/// CTA 文案;null 则不渲染按钮(如筛选后空态)。
final String? ctaLabel;
final VoidCallback? onCtaPressed;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 112,
height: 112,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.surfaceTint,
),
child: Icon(icon, size: 56, color: AppColors.primary),
),
const SizedBox(height: 16),
Text(
title,
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
if (description != null) ...[
const SizedBox(height: 8),
Text(
description!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.inkSoft,
fontSize: 12,
height: 1.4,
),
),
],
if (ctaLabel != null) ...[
const SizedBox(height: 24),
FilledButton(
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24),
),
onPressed: onCtaPressed,
child: Text(ctaLabel!),
),
],
],
),
);
}
}
+169
View File
@@ -0,0 +1,169 @@
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 裁决),[url] 为 null 时渲染
/// 本地占位形态(`surfaceTint` 底 + `Icons.pets`);有图时经
/// [RemoteImage] 加载(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 为本地占位形态(M2 默认)。
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),
);
}
}
+111
View File
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
/// M2 记录类型(05 号规范 §2 五种,「其他」为扩展兜底)。
enum RecordType { weight, vaccine, deworming, medical, other }
/// 类型 → 图标 + 三色(dot 底 8% 淡染基色 / 图标色 / 文字标签色)+ 文案的
/// 唯一映射出口(05 §3.2:映射只存在于本文件一处,杜绝散落硬编码)。
///
/// 对比度依据 05 §2 自查表:图标于 dot 底 4.167.39:1(非文字 ≥3:1)、
/// 文字于 dot 底 5.788.74:1AA ≥4.5:1)均达标。类型必须以
/// 「图标 + 文字」双通道呈现,不得单靠颜色区分(色盲可辨性)。
class RecordTypeStyle {
const RecordTypeStyle({
required this.icon,
required this.baseColor,
required this.iconColor,
required this.inkColor,
required this.label,
});
final IconData icon;
/// dot 底基色(渲染时 `withAlpha(20)` 即 8% 淡染)。
final Color baseColor;
final Color iconColor;
/// 文字/标签色,供 TagPill、详情页复用。
final Color inkColor;
final String label;
}
/// 五类映射常量(05 §2 表逐行对应)。
const Map<RecordType, RecordTypeStyle> recordTypeStyles = {
RecordType.weight: RecordTypeStyle(
icon: Icons.monitor_weight_outlined,
baseColor: AppColors.primary,
iconColor: AppColors.primaryStrong,
inkColor: AppColors.primaryDark,
label: '体重',
),
RecordType.vaccine: RecordTypeStyle(
icon: Icons.vaccines_outlined,
baseColor: AppColors.success,
iconColor: AppColors.successInk,
inkColor: AppColors.successInk,
label: '疫苗',
),
RecordType.deworming: RecordTypeStyle(
icon: Icons.pest_control,
baseColor: AppColors.accent,
iconColor: AppColors.accentDark,
inkColor: AppColors.accentDark,
label: '驱虫',
),
RecordType.medical: RecordTypeStyle(
icon: Icons.medical_services_outlined,
baseColor: AppColors.error,
iconColor: AppColors.error,
inkColor: AppColors.errorDark,
label: '就医',
),
RecordType.other: RecordTypeStyle(
icon: Icons.sticky_note_2_outlined,
baseColor: AppColors.muted,
iconColor: AppColors.inkSoft,
inkColor: AppColors.inkSoft,
label: '其他',
),
};
/// 圆标尺寸档(05 §3.2)。图标尺寸 = dot 的 50%。
enum RecordTypeDotSize {
/// 24:表单类型选择器内。
sm(24),
/// 40:时间线条目。
md(40),
/// 56:记录详情页头。
lg(56);
const RecordTypeDotSize(this.dimension);
final double dimension;
}
/// 记录类型圆标(05 号规范 §3.2):正圆淡染底 + 类型图标。
/// 无自身点击态(点击归属父容器)、无禁用态。
class RecordTypeDot extends StatelessWidget {
const RecordTypeDot({required this.type, required this.size, super.key});
final RecordType type;
final RecordTypeDotSize size;
@override
Widget build(BuildContext context) {
final style = recordTypeStyles[type]!;
return Container(
width: size.dimension,
height: size.dimension,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: style.baseColor.withAlpha(20),
),
child: Icon(style.icon, color: style.iconColor, size: size.dimension / 2),
);
}
}