当前位置: 首页 > news >正文

实用指南:【收尾以及复盘】flutter开发鸿蒙APP之成就徽章页面

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

1.成就徽章页面先看截图效果

这个是徽章上面的是得到的徽章,下面的是未得到的徽章。

截图如下

这是一个展示用户成就徽章的页面,就是那种"我解锁了哪些成就"的展示页。

页面功能:

  • 顶部显示已解锁徽章数量(比如 3/10)
  • 中间是 2 列网格,显示所有徽章
  • 已解锁的徽章是彩色的,未解锁的是灰色半透明
  • 每个徽章显示等级、图标、标题、描述
  • 底部显示下一级徽章的进度提示
  • 支持下拉刷新

2. 数据结构

页面用的是 StatefulWidget,需要管理徽章列表和当前等级。

状态

class _BadgePageState extends State {List _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 _loadBadges() async {setState(() => _loading = true);final response = await CheckInApi.getUserBadges();if (response != null && mounted) {final allBadges = [];// 添加已获得的徽章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.pngvip1-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(Colors.white),minHeight: 8,),),],),),],),);
}

进度条的 value 目前是写死的 0,实际应该根据用户的真实进度计算。比如下一级需要打卡 7 天,用户已经打卡 4 天。

3.8 默认数据兜底

如果接口失败,用本地默认数据:

List _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 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 earned;      // 已获得的徽章final List 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 了。

后端返回的数据结构有点奇怪,分成 earnednotEarned 两个列表,还要自己合并排序。其实后端直接返回一个列表,每个徽章带个 isUnlocked 字段就行了,省得前端还要处理。

徽章的解锁状态用颜色和透明度区分,已解锁的是彩色的,未解锁的是灰色半透明。这个视觉效果还不错,一眼就能看出来哪些解锁了。

下一级进度那块,进度条的值目前是写死的 0.6,实际应该根据用户的真实进度计算。但后端接口没返回进度数据,所以暂时先写死了。后面要改的话,需要后端加个字段,比如 progress: { current: 4, target: 7 },前端再算 current / target

默认数据兜底很重要,不然接口挂了页面就白屏了。10 个徽章的数据都写在代码里,虽然有点啰嗦,但至少保证页面能显示。

http://www.jsqmd.com/news/453614/

相关文章:

  • OpenClaw入门篇
  • 打开网站显示HTTP 错误 403.19 - Forbidden 错误怎么办|已解决
  • EHViewer官方正版-ehviewer绿色版2.2.0.1最新版本v2.2.0.1
  • 2026年用户口碑实证:厦门中式风格装修公司推荐与五大服务商真实案例对比 - 十大品牌推荐
  • 为什么92%的感知算法工程师写的C++代码达不到ASIL-D时序要求?3个被LLM忽略的编译器级实时语义漏洞
  • TurboVNC + VirtualGL + noVNC(浏览器远程桌面配置)
  • 【独家】Dify官方未公开的RAG性能开关:启用Hybrid Fusion Mode后QPS提升2.8倍、MRR@10达0.89的实测配置清单
  • OFA视觉蕴含模型惊艳效果:艺术风格图像与诗意文本的匹配探索
  • 2026光伏行业风口下,霍尔电流传感器核心应用与选型全解析
  • IEEE 39节点Simulink模型:灵活扩建、高速响应、波形细腻,呈现丝滑美观体验
  • N1盒子飞牛NAS外接硬盘盒掉速/断连/掉盘?一招禁用 UAS 驱动,彻底解决 JMicron 兼容性问题
  • Wireshark抓包实战:从入门到精通
  • Gemini 3.1 Pro 技术深度解析:从架构跃迁到工程落地的全面评估
  • 仅剩18个月!C++27契约编程将成为新项目准入强制要求——权威解读ISO PDTS 24752安全合规条款及迁移路线图
  • OFA-VE生产环境实践:日均万次请求的视觉蕴含服务稳定性保障
  • 【Linux系统编程】(四十五)线程池基础:日志系统设计与策略模式的优雅落地
  • 低配硬件也能跑AI?DeepSeek-R1-Distill-Qwen-1.5B 4GB显存实测部署指南
  • C盘红了怎么清理?2026年最新不重装系统、安全释放空间的通用方法
  • 2026全新|4500+计算机毕业设计项目精选(学术深耕+技术前沿,源码+论文+PPT+学术支撑一站式配齐)
  • 2.计算器实现
  • 【仅限Tier1与芯片原厂内部传阅】:ADAS域控制器上C++多传感器融合的确定性执行模型(附可运行时序约束Checklist)
  • 机票+火车票聚合查询与预订系统
  • 别感谢我,这是你应得的
  • 大数据开源工具大全:从ETL到BI的全套解决方案
  • 线程、进程、协程区别总结
  • LiuJuan20260223Zimage网站内容分析与SEO优化建议生成
  • 某教育企业智能合规平台架构实践:用AI满足教育行业合规
  • Ubuntu下丝滑地安装OpenClaw
  • Z-Image-GGUF算力优化:KSampler参数调优使单卡吞吐量提升2.3倍
  • SenseVoice-Small入门实战:快速构建个人语音笔记应用