欢迎加入开源鸿蒙跨平台社区: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.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<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 了。

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

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

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

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

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐