【收尾以及复盘】flutter开发鸿蒙APP之成就徽章页面
本文介绍了一个成就徽章展示页面的Flutter实现方案。页面采用两列网格布局展示10个等级徽章,已解锁徽章显示为彩色,未解锁显示为灰色半透明。顶部显示已解锁数量(如3/10),底部展示下一级徽章进度提示。实现要点包括:1.数据结构采用BadgeData类管理徽章信息;2.支持从API获取数据并合并排序,失败时使用本地默认数据兜底;3.通过图标映射实现不同状态的视觉区分;4.采用GridView构建
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
1.成就徽章页面先看截图效果
这个是徽章上面的是得到的徽章,下面的是未得到的徽章。

截图如下


这是一个展示用户成就徽章的页面,就是那种"我解锁了哪些成就"的展示页。
页面功能:
- 顶部显示已解锁徽章数量(比如 3/10)
- 中间是 2 列网格,显示所有徽章
- 已解锁的徽章是彩色的,未解锁的是灰色半透明
- 每个徽章显示等级、图标、标题、描述
- 底部显示下一级徽章的进度提示
- 支持下拉刷新
2. 数据结构
页面用的是 StatefulWidget,需要管理徽章列表和当前等级。
状态
class _BadgePageState extends State<BadgePage> {
List<BadgeData> _badges = []; // 徽章列表
int _currentLevel = 0; // 当前用户等级(已解锁数量)
bool _loading = true; // 加载状态
}
徽章数据
class BadgeData {
final int level; // 等级(1-10)
final String title; // 标题,比如"萌芽新手"
final String description; // 描述,比如"完成 1 天打卡"
final String iconNo; // 未解锁图标路径
final String iconYes; // 已解锁图标路径
final bool isUnlocked; // 是否已解锁
}
3. 功能实现
3.1 数据加载
页面打开时,调用接口获取徽章数据
Future<void> _loadBadges() async {
setState(() => _loading = true);
final response = await CheckInApi.getUserBadges();
if (response != null && mounted) {
final allBadges = <BadgeData>[];
// 添加已获得的徽章
for (var badge in response.earned) {
allBadges.add(_convertBadge(badge, true));
}
// 添加未获得的徽章
for (var badge in response.notEarned) {
allBadges.add(_convertBadge(badge, false));
}
// 按等级排序
allBadges.sort((a, b) => a.level.compareTo(b.level));
setState(() {
_badges = allBadges;
_currentLevel = response.earned.length;
_loading = false;
});
} else {
// 如果API失败,使用默认数据
setState(() {
_badges = _getDefaultBadges();
_currentLevel = 1;
_loading = false;
});
}
}
关键点:
- 后端返回的是两个列表:
earned(已获得)和notEarned(未获得)
- 要合并成一个列表,然后按等级排序
- 如果接口失败,用本地默认数据兜底
3.2 徽章数据转换
后端返回的徽章数据要转成本地的 BadgeData 格式:
BadgeData _convertBadge(dynamic badge, bool isUnlocked) {
// 从 "LV.3" 这种字符串里提取数字
final levelNum = int.tryParse((badge.level as String).replaceAll('LV.', '')) ?? 1;
return BadgeData(
level: levelNum,
title: badge.name as String,
description: badge.description as String,
iconNo: _getBadgeIcon(levelNum, false),
iconYes: _getBadgeIcon(levelNum, true),
isUnlocked: isUnlocked,
);
}
3.3 徽章图标映射
根据等级和解锁状态,返回对应的图标路径:
String _getBadgeIcon(int level, bool isUnlocked) {
if (isUnlocked) {
switch (level) {
case 1: return AppImages.vip1Yes;
case 2: return AppImages.vip2Yes;
case 3: return AppImages.vip3Yes;
// ... 其他等级
case 10: return AppImages.vip10Yes;
default: return AppImages.vip1Yes;
}
} else {
switch (level) {
case 1: return AppImages.vip1No;
case 2: return AppImages.vip2No;
case 3: return AppImages.vip3No;
// ... 其他等级
case 10: return AppImages.vip10No;
default: return AppImages.vip1No;
}
}
}
图标文件在 assets/images/badge/ 目录下,命名规则是 vip1-yes.png、vip1-no.png 这样。
3.4 顶部提示信息
显示已解锁徽章数量:
Widget _buildHeaderInfo() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFE8F5E9), // 淡绿色背景
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.emoji_events, color: Color(0xFF008236), size: 24),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'解锁更多徽章,成为水果专家',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF1F2937),
),
),
const SizedBox(height: 4),
Text(
'已解锁 $_currentLevel/10',
style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280)),
),
],
),
),
],
),
);
}
3.5 徽章网格
用 GridView.builder 渲染 2 列网格:
Widget _buildBadgeGrid() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // 2 列
childAspectRatio: 0.85, // 宽高比
crossAxisSpacing: 12, // 列间距
mainAxisSpacing: 12, // 行间距
),
itemCount: badges.length,
itemBuilder: (context, index) {
return _buildBadgeItem(badges[index]);
},
),
);
}
3.6 单个徽章项
每个徽章是一个卡片,包含等级、图标、标题、描述:
Widget _buildBadgeItem(BadgeData badge) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: badge.isUnlocked
? const Color(0xFFE8F5E9) // 已解锁:淡绿色
: const Color(0xFFF5F5F5), // 未解锁:浅灰色
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: badge.isUnlocked
? const Color(0xFF008236).withOpacity(0.3) // 已解锁:绿色边框
: Colors.transparent, // 未解锁:无边框
width: 1,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 等级标签
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: badge.isUnlocked
? const Color(0xFF008236) // 已解锁:深绿色
: const Color(0xFFE0E0E0), // 未解锁:灰色
borderRadius: BorderRadius.circular(8),
),
child: Text(
'LV.${badge.level}',
style: TextStyle(
fontSize: 10,
color: badge.isUnlocked ? Colors.white : const Color(0xFF9E9E9E),
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 12),
// 徽章图标
Opacity(
opacity: badge.isUnlocked ? 1.0 : 0.4, // 未解锁的图标半透明
child: Image.asset(
badge.isUnlocked ? badge.iconYes : badge.iconYes,
width: 32,
height: 32,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
// 图片加载失败显示默认图标
return Icon(
Icons.emoji_events,
size: 32,
color: badge.isUnlocked
? const Color(0xFF008236)
: const Color(0xFFE0E0E0),
);
},
),
),
const SizedBox(height: 12),
// 徽章标题
Text(
badge.title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: badge.isUnlocked
? const Color(0xFF1F2937) // 已解锁:深色
: const Color(0xFF9E9E9E), // 未解锁:灰色
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
// 徽章描述
Text(
badge.description,
style: TextStyle(
fontSize: 11,
color: badge.isUnlocked
? const Color(0xFF6B7280) // 已解锁:中灰
: const Color(0xFFBDBDBD), // 未解锁:浅灰
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
视觉规则:
- 已解锁:淡绿色背景 + 绿色边框 + 彩色图标 + 深色文字
- 未解锁:浅灰色背景 + 无边框 + 半透明图标 + 灰色文字
3.7 下一级徽章进度
底部显示下一个要解锁的徽章:
Widget _buildNextBadgeProgress() {
// 如果已经全部解锁
if (_currentLevel >= 10) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFF008236),
borderRadius: BorderRadius.circular(8),
),
child: const Row(
children: [
Icon(Icons.emoji_events, color: Colors.white, size: 40),
SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('恭喜你!', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
SizedBox(height: 4),
Text('已解锁全部徽章', style: TextStyle(fontSize: 14, color: Colors.white)),
],
),
),
],
),
);
}
// 显示下一级徽章
if (_currentLevel >= badges.length) {
return const SizedBox.shrink();
}
final nextBadge = badges[_currentLevel];
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFF008236),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Image.asset(
nextBadge.iconNo,
width: 32,
height: 32,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Icon(Icons.emoji_events, size: 50, color: Colors.white);
},
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'距离下一级:${nextBadge.title}',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 8),
Text(
nextBadge.description,
style: const TextStyle(fontSize: 12, color: Colors.white),
),
const SizedBox(height: 12),
// 进度条
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: 0.6, // 这里写死了 60%,实际应该根据真实进度计算
backgroundColor: Colors.white.withOpacity(0.3),
valueColor: const AlwaysStoppedAnimation<Color>(Colors.white),
minHeight: 8,
),
),
],
),
),
],
),
);
}
进度条的 value 目前是写死的 0,实际应该根据用户的真实进度计算。比如下一级需要打卡 7 天,用户已经打卡 4 天。
3.8 默认数据兜底
如果接口失败,用本地默认数据:
List<BadgeData> _getDefaultBadges() {
return [
BadgeData(
level: 1,
title: '萌芽新手',
description: '完成 1 天打卡',
iconNo: AppImages.vip1No,
iconYes: AppImages.vip1Yes,
isUnlocked: _currentLevel >= 1,
),
BadgeData(
level: 2,
title: '嫩土小叶',
description: '连续打卡 3 天',
iconNo: AppImages.vip2No,
iconYes: AppImages.vip2Yes,
isUnlocked: _currentLevel >= 2,
),
// ... 其他 8 个徽章
];
}
这样即使后端挂了,页面也能正常显示。
4. API 接口
调用的是 CheckInApi.getUserBadges():
static Future<BadgesResponse?> getUserBadges() async {
try {
final response = await httpClient.get('/api/check-in/badges');
if (response.success && response.data != null) {
return BadgesResponse.fromJson(response.data);
}
return null;
} catch (e) {
return null;
}
}
返回的数据结构:
class BadgesResponse {
final List<Badge> earned; // 已获得的徽章
final List<Badge> notEarned; // 未获得的徽章
}
class Badge {
final String type; // 类型
final String level; // 等级,比如 "LV.3"
final String name; // 名称
final String description; // 描述
final String icon; // 图标(后端返回的,但我们没用)
final String? earnedAt; // 获得时间
}
5. 图标资源
徽章图标在 assets/images/badge/ 目录下,每个等级有两张图:
vip1-no.png // 未解锁(灰色)
vip1-yes.png // 已解锁(彩色)
vip2-no.png
vip2-yes.png
...
vip10-no.png
vip10-yes.png
在 lib/core/constants/app_images.dart 里定义路径常量:
class AppImages {
static const String vip1No = 'assets/images/badge/vip1-no.png';
static const String vip1Yes = 'assets/images/badge/vip1-yes.png';
static const String vip2No = 'assets/images/badge/vip2-no.png';
static const String vip2Yes = 'assets/images/badge/vip2-yes.png';
// ... 其他等级
}
6. 总结
这页面实现起来不算复杂,主要就是数据展示。
最麻烦的是图标映射那块,10 个等级 x 2 种状态 = 20 张图,要写两个大 switch 语句。本来想用数组或者 Map 简化的,但 Dart 的常量限制比较多,最后还是用 switch 了。
后端返回的数据结构有点奇怪,分成 earned 和 notEarned 两个列表,还要自己合并排序。其实后端直接返回一个列表,每个徽章带个 isUnlocked 字段就行了,省得前端还要处理。
徽章的解锁状态用颜色和透明度区分,已解锁的是彩色的,未解锁的是灰色半透明。这个视觉效果还不错,一眼就能看出来哪些解锁了。
下一级进度那块,进度条的值目前是写死的 0.6,实际应该根据用户的真实进度计算。但后端接口没返回进度数据,所以暂时先写死了。后面要改的话,需要后端加个字段,比如 progress: { current: 4, target: 7 },前端再算 current / target。
默认数据兜底很重要,不然接口挂了页面就白屏了。10 个徽章的数据都写在代码里,虽然有点啰嗦,但至少保证页面能显示。
更多推荐



所有评论(0)