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

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

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

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

截图如下

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

页面功能:

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

2. 数据结构

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

状态

Dart 复制代码
class _BadgePageState extends State<BadgePage> {
  List<BadgeData> _badges = [];    // 徽章列表
  int _currentLevel = 0;            // 当前用户等级(已解锁数量)
  bool _loading = true;             // 加载状态
}

徽章数据

Dart 复制代码
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 数据加载
页面打开时,调用接口获取徽章数据
Dart 复制代码
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 格式:

Dart 复制代码
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 徽章图标映射

根据等级和解锁状态,返回对应的图标路径:

Dart 复制代码
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 顶部提示信息

显示已解锁徽章数量:

Dart 复制代码
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 列网格:

Dart 复制代码
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 单个徽章项

每个徽章是一个卡片,包含等级、图标、标题、描述:

Dart 复制代码
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 下一级徽章进度

底部显示下一个要解锁的徽章:

Dart 复制代码
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 默认数据兜底

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

Dart 复制代码
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()

Dart 复制代码
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;
  }
}

返回的数据结构:

Dart 复制代码
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 个徽章的数据都写在代码里,虽然有点啰嗦,但至少保证页面能显示。

相关推荐
冬奇Lab9 小时前
一天一个开源项目(第35篇):GitHub Store - 跨平台的 GitHub Releases 应用商店
开源·github·资讯
Zsnoin能13 小时前
Flutter仿ios液态玻璃效果
flutter
傅里叶18 小时前
iOS相机权限获取
flutter·ios
Haha_bj18 小时前
Flutter—— 本地存储(shared_preferences)
flutter
心之语歌19 小时前
Flutter 存储权限:适配主流系统
flutter
Bigger19 小时前
为什么你的 Git 提交需要签名?—— Git Commit Signing 完全指南
git·开源·github
恋猫de小郭20 小时前
Android 官方正式官宣 AI 支持 AppFunctions ,Android 官方 MCP 和系统级 OpenClaw 雏形
android·前端·flutter
在人间耕耘1 天前
HarmonyOS Vision Kit 视觉AI实战:把官方 Demo 改造成一套能长期复用的组件库
人工智能·深度学习·harmonyos
MakeZero1 天前
Flutter那些事-布局篇
flutter
王码码20351 天前
Flutter for OpenHarmony:socket_io_client 实时通信的事实标准(Node.js 后端的最佳拍档) 深度解析与鸿蒙适配指南
android·flutter·ui·华为·node.js·harmonyos