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


项目效果

本文实现的是一个基于 Flutter for OpenHarmony 的习惯养成进度仪表盘应用。项目中使用 Flutter 第三方库 percent_indicator 实现圆形进度条和线性进度条效果,用于展示饮水、阅读、运动、专注和睡眠等习惯任务的完成进度。

最终运行效果如下:

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

页面主要包含以下内容:

  • 顶部标题栏;
  • 今日习惯总览卡片;
  • 圆形总体完成进度;
  • 习惯分类筛选按钮;
  • 多个线性习惯进度条;
  • 当前习惯详情卡片;
  • 快速增加进度按钮;
  • 一键完成当前习惯按钮;
  • 重置当前习惯按钮;
  • 第三方库使用说明;
  • 页面整体采用 Flutter Material 风格布局。

本文重点是演示如何在 Flutter for OpenHarmony 项目中使用 Flutter 第三方库 percent_indicator。项目代码写在 lib/main.dart 中,依赖配置写在 pubspec.yaml 中,符合 Flutter for OpenHarmony 第三方库实践方向。


前言

在移动应用开发中,进度展示是非常常见的功能。例如学习计划、健身记录、饮水打卡、阅读目标、课程完成度、任务进度和项目统计页面,都经常需要用进度条展示当前完成情况。

如果只用普通文字写“已完成 60%”,用户当然也能看懂,但视觉表现比较弱。进度条可以让完成情况更加直观,用户一眼就能判断自己今天还差多少。人类对圆圈和条形图的信任程度有时候比对自己还高,离谱,但确实好用。

Flutter 原生提供了 LinearProgressIndicatorCircularProgressIndicator,可以实现基础进度效果。但如果需要更灵活的圆形百分比文本、动画效果、中心文字、进度颜色、线性进度条圆角等样式,就需要写更多自定义代码。

因此本文选择使用 Flutter 第三方库 percent_indicator 来实现百分比进度展示。它可以快速创建圆形百分比进度条和线性百分比进度条,非常适合 Flutter for OpenHarmony 项目中的统计面板、目标打卡和任务进度页面。

本项目以“习惯养成进度仪表盘应用”为例,使用 percent_indicator 展示多个习惯任务的完成进度,并结合分类筛选、进度更新、详情展示和状态统计完成一个完整页面。


一、项目目标

本次实践主要实现以下目标:

  • 创建 Flutter for OpenHarmony 项目;
  • pubspec.yaml 中添加第三方库 percent_indicator
  • 使用 flutter pub get 获取依赖;
  • lib/main.dart 中引入 percent_indicator
  • 使用 CircularPercentIndicator 构建圆形百分比进度;
  • 使用 LinearPercentIndicator 构建线性习惯进度条;
  • 使用动画效果展示进度变化;
  • 实现习惯分类筛选;
  • 实现当前习惯详情展示;
  • 实现增加习惯进度;
  • 实现一键完成当前习惯;
  • 实现重置当前习惯;
  • 使用 Flutter Material 组件构建完整页面;
  • 将应用运行到 OpenHarmony 设备或模拟器中。

二、技术栈

类型 内容
开发方向 Flutter for OpenHarmony
开发语言 Dart
UI 框架 Flutter
第三方库 percent_indicator
功能场景 习惯打卡 / 进度展示 / 仪表盘
核心组件 CircularPercentIndicator / LinearPercentIndicator
项目入口 lib/main.dart
依赖配置 pubspec.yaml
运行平台 OpenHarmony 设备或模拟器

三、为什么选择 percent_indicator

在实际开发中,百分比进度展示可以用于很多场景,例如:

  • 学习计划完成度;
  • 阅读目标进度;
  • 健身训练完成度;
  • 饮水打卡进度;
  • 项目任务完成率;
  • 文件上传进度;
  • 课程学习进度;
  • 睡眠目标统计;
  • 每日目标仪表盘;
  • 用户成长等级进度。

Flutter 原生进度条功能比较基础,如果想要做出更完整的百分比仪表盘效果,需要额外处理中心文字、圆形进度、线性进度、颜色区分和动画刷新。

percent_indicator 已经封装好了圆形和线性百分比组件,可以直接通过 CircularPercentIndicatorLinearPercentIndicator 实现进度展示。

在本项目中,percent_indicator 主要完成以下工作:

  • 展示今日习惯总体完成度;
  • 展示每个习惯的线性进度条;
  • 展示当前选中习惯的圆形进度;
  • 通过百分比文本显示完成情况;
  • 使用动画提升进度变化效果;
  • 配合按钮实现进度动态更新;
  • 减少手写自定义进度条的代码量。

四、创建 Flutter for OpenHarmony 项目

在已经配置好 Flutter for OpenHarmony 开发环境的前提下,可以创建一个 Flutter 项目。

示例项目名称:

flutter create habit_progress_demo

进入项目目录:

cd habit_progress_demo

项目创建完成后,主要关注两个文件:

habit_progress_demo
 ├── pubspec.yaml
 └── lib
     └── main.dart

其中:

文件 作用
pubspec.yaml 配置 Flutter 项目依赖
lib/main.dart 编写 Flutter 页面和业务逻辑

五、添加 percent_indicator 第三方库

打开项目根目录下的 pubspec.yaml 文件,在 dependencies 中添加 percent_indicator

示例配置如下:

dependencies:
  flutter:
    sdk: flutter

  percent_indicator: ^4.2.5

完整结构大致如下:

name: habit_progress_demo
description: A Flutter for OpenHarmony percent indicator demo.
publish_to: 'none'

version: 1.0.0+1

environment:
  sdk: '>=3.4.0 <4.0.0'

dependencies:
  flutter:
    sdk: flutter

  percent_indicator: ^4.2.5

dev_dependencies:
  flutter_test:
    sdk: flutter

flutter:
  uses-material-design: true

添加完成后,在终端执行:

flutter pub get

执行成功后,就可以在 Dart 代码中使用 percent_indicator 了。


六、项目结构

本项目主要修改 lib/main.dart 文件:

lib
 └── main.dart

本项目不需要编写 OpenHarmony 原生 ArkTS 页面,也不需要修改 Index.ets

因为这是 Flutter for OpenHarmony 项目,页面主体应该是 Flutter 代码。审核重点会看:

  • 是否使用 pubspec.yaml 添加 Flutter 第三方库;
  • 是否在 Dart 文件中 import package
  • 是否在 lib/main.dart 中实际调用第三方库;
  • 是否属于 Flutter for OpenHarmony 项目。

看到 pubspec.yamllib/main.dartimport 'package:percent_indicator/percent_indicator.dart';,这才是正确方向。不是在标题里写 Flutter for OpenHarmony,项目就会自己变成 Flutter 项目,世界还没这么听话。


七、核心实现思路

本项目的核心流程如下:

  1. pubspec.yaml 中添加 percent_indicator
  2. main.dart 中引入第三方库;
  3. 定义习惯目标数据模型;
  4. 准备多个习惯目标数据;
  5. 使用 CircularPercentIndicator 构建总体进度圆环;
  6. 使用 LinearPercentIndicator 构建每个习惯的进度条;
  7. 使用分类按钮筛选不同类型的习惯;
  8. 使用当前选中习惯展示详细进度;
  9. 使用按钮增加习惯进度;
  10. 使用按钮完成当前习惯;
  11. 使用按钮重置当前习惯;
  12. 使用 setState() 更新页面状态;
  13. 使用 Flutter Material 组件构建完整页面。

第三方库引入代码如下:

import 'package:percent_indicator/percent_indicator.dart';

圆形进度条核心代码如下:

CircularPercentIndicator(
  radius: 68,
  lineWidth: 12,
  percent: goal.percent,
  animation: true,
  circularStrokeCap: CircularStrokeCap.round,
  center: Text('${(goal.percent * 100).round()}%'),
  progressColor: goal.color,
  backgroundColor: goal.color.withOpacity(0.12),
)

线性进度条核心代码如下:

LinearPercentIndicator(
  lineHeight: 10,
  percent: goal.percent,
  animation: true,
  barRadius: const Radius.circular(10),
  progressColor: goal.color,
  backgroundColor: goal.color.withOpacity(0.12),
)

这两段代码是本文重点,说明项目确实使用了 Flutter 第三方库实现进度展示效果。


八、main.dart 完整代码

打开文件:

lib/main.dart

将其中内容替换为下面代码:

import 'package:flutter/material.dart';
import 'package:percent_indicator/percent_indicator.dart';

void main() {
  runApp(const HabitProgressApp());
}

class HabitProgressApp extends StatelessWidget {
  const HabitProgressApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Habit Progress Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.cyan,
          brightness: Brightness.light,
        ),
        useMaterial3: true,
      ),
      home: const HabitProgressHomePage(),
    );
  }
}

class HabitGoal {
  HabitGoal({
    required this.id,
    required this.title,
    required this.category,
    required this.description,
    required this.current,
    required this.target,
    required this.unit,
    required this.icon,
    required this.color,
    required this.tip,
  });

  final int id;
  final String title;
  final String category;
  final String description;
  int current;
  final int target;
  final String unit;
  final IconData icon;
  final Color color;
  final String tip;

  double get percent {
    if (target <= 0) {
      return 0;
    }

    final double value = current / target;

    if (value < 0) {
      return 0;
    }

    if (value > 1) {
      return 1;
    }

    return value;
  }

  bool get completed {
    return current >= target;
  }
}

class HabitProgressHomePage extends StatefulWidget {
  const HabitProgressHomePage({super.key});

  
  State<HabitProgressHomePage> createState() => _HabitProgressHomePageState();
}

class _HabitProgressHomePageState extends State<HabitProgressHomePage> {
  final List<String> _categories = const [
    '全部',
    '健康',
    '学习',
    '效率',
    '生活',
  ];

  String _selectedCategory = '全部';
  int _selectedGoalId = 1;

  final List<HabitGoal> _goals = [
    HabitGoal(
      id: 1,
      title: '今日饮水',
      category: '健康',
      description: '记录今天的饮水量,目标是让身体别像被遗忘的盆栽一样干着。',
      current: 1200,
      target: 2000,
      unit: '毫升',
      icon: Icons.water_drop,
      color: Colors.blue,
      tip: '每次喝水后可以点一次增加进度。',
    ),
    HabitGoal(
      id: 2,
      title: '英语阅读',
      category: '学习',
      description: '完成英文短文阅读,积累句子表达,不要只背孤零零的单词。',
      current: 18,
      target: 30,
      unit: '分钟',
      icon: Icons.menu_book,
      color: Colors.indigo,
      tip: '建议把不熟的表达记录到笔记里。',
    ),
    HabitGoal(
      id: 3,
      title: '专注学习',
      category: '效率',
      description: '进行一段不刷手机的专注学习时间,手机不是空气,离开它也能活。',
      current: 35,
      target: 60,
      unit: '分钟',
      icon: Icons.timer,
      color: Colors.deepPurple,
      tip: '可以分成两段完成,不一定一次做完。',
    ),
    HabitGoal(
      id: 4,
      title: '宿舍运动',
      category: '健康',
      description: '完成简单宿舍训练,例如深蹲、靠墙俯卧撑、哑铃动作或拉伸。',
      current: 12,
      target: 20,
      unit: '分钟',
      icon: Icons.fitness_center,
      color: Colors.orange,
      tip: '动作要标准,不要为了数量把自己练成故障机器。',
    ),
    HabitGoal(
      id: 5,
      title: '整理文件',
      category: '生活',
      description: '整理项目文件、截图和文章素材,避免桌面变成电子垃圾场。',
      current: 6,
      target: 10,
      unit: '项',
      icon: Icons.folder_copy,
      color: Colors.teal,
      tip: '建议按项目名新建文件夹,截图和代码分开放。',
    ),
    HabitGoal(
      id: 6,
      title: '复盘总结',
      category: '学习',
      description: '记录今天完成了什么、卡在哪里、明天先做哪一步。',
      current: 1,
      target: 3,
      unit: '条',
      icon: Icons.edit_note,
      color: Colors.green,
      tip: '复盘不是写作文,写清楚问题和下一步就行。',
    ),
  ];

  List<HabitGoal> get _visibleGoals {
    if (_selectedCategory == '全部') {
      return _goals;
    }

    return _goals.where((goal) {
      return goal.category == _selectedCategory;
    }).toList();
  }

  HabitGoal get _selectedGoal {
    return _goals.firstWhere(
      (goal) {
        return goal.id == _selectedGoalId;
      },
      orElse: () {
        return _goals.first;
      },
    );
  }

  int get _completedCount {
    return _goals.where((goal) {
      return goal.completed;
    }).length;
  }

  int get _unfinishedCount {
    return _goals.length - _completedCount;
  }

  double get _overallPercent {
    if (_goals.isEmpty) {
      return 0;
    }

    double total = 0;

    for (final HabitGoal goal in _goals) {
      total += goal.percent;
    }

    return total / _goals.length;
  }

  double get _visibleAveragePercent {
    final List<HabitGoal> goals = _visibleGoals;

    if (goals.isEmpty) {
      return 0;
    }

    double total = 0;

    for (final HabitGoal goal in goals) {
      total += goal.percent;
    }

    return total / goals.length;
  }

  int get _totalTargetCount {
    int total = 0;

    for (final HabitGoal goal in _goals) {
      total += goal.target;
    }

    return total;
  }

  void _selectCategory(String category) {
    setState(() {
      _selectedCategory = category;

      final List<HabitGoal> visibleGoals = _visibleGoals;
      if (visibleGoals.isNotEmpty) {
        _selectedGoalId = visibleGoals.first.id;
      }
    });
  }

  void _selectGoal(HabitGoal goal) {
    setState(() {
      _selectedGoalId = goal.id;
    });
  }

  void _increaseGoal(HabitGoal goal) {
    setState(() {
      final int step = _getStep(goal);
      goal.current += step;

      if (goal.current > goal.target) {
        goal.current = goal.target;
      }

      _selectedGoalId = goal.id;
    });

    _showMessage('${goal.title} 进度已更新');
  }

  void _finishSelectedGoal() {
    setState(() {
      _selectedGoal.current = _selectedGoal.target;
    });

    _showMessage('当前习惯已完成');
  }

  void _resetSelectedGoal() {
    setState(() {
      _selectedGoal.current = 0;
    });

    _showMessage('当前习惯已重置');
  }

  void _resetAllGoals() {
    setState(() {
      for (final HabitGoal goal in _goals) {
        goal.current = 0;
      }
    });

    _showMessage('所有习惯进度已重置');
  }

  int _getStep(HabitGoal goal) {
    if (goal.unit == '毫升') {
      return 250;
    }

    if (goal.unit == '分钟') {
      return 5;
    }

    return 1;
  }

  void _showMessage(String text) {
    ScaffoldMessenger.of(context)
      ..clearSnackBars()
      ..showSnackBar(
        SnackBar(
          content: Text(text),
          behavior: SnackBarBehavior.floating,
          duration: const Duration(milliseconds: 1200),
        ),
      );
  }

  String get _overviewText {
    if (_completedCount == _goals.length) {
      return '今日习惯目标已全部完成';
    }

    if (_completedCount == 0) {
      return '今天还需要启动第一个习惯';
    }

    return '已完成 $_completedCount 项,还剩 $_unfinishedCount 项';
  }

  
  Widget build(BuildContext context) {
    final ThemeData theme = Theme.of(context);
    final List<HabitGoal> visibleGoals = _visibleGoals;

    return Scaffold(
      appBar: AppBar(
        title: const Text('习惯进度仪表盘'),
        centerTitle: true,
      ),
      body: SafeArea(
        child: ListView(
          padding: const EdgeInsets.all(16),
          children: [
            _buildOverviewCard(theme),
            const SizedBox(height: 16),
            _buildCategoryCard(theme),
            const SizedBox(height: 16),
            _buildGoalListCard(theme, visibleGoals),
            const SizedBox(height: 16),
            _buildSelectedGoalCard(theme),
            const SizedBox(height: 16),
            _buildActionCard(theme),
            const SizedBox(height: 16),
            _buildLibraryCard(theme),
          ],
        ),
      ),
    );
  }

  Widget _buildOverviewCard(ThemeData theme) {
    final int percentText = (_overallPercent * 100).round();

    return Card(
      elevation: 3,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(22),
      ),
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          children: [
            CircularPercentIndicator(
              radius: 72,
              lineWidth: 13,
              percent: _overallPercent,
              animation: true,
              animationDuration: 700,
              circularStrokeCap: CircularStrokeCap.round,
              progressColor: theme.colorScheme.primary,
              backgroundColor: theme.colorScheme.primary.withOpacity(0.12),
              center: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text(
                    '$percentText%',
                    style: theme.textTheme.headlineSmall?.copyWith(
                      fontWeight: FontWeight.bold,
                      color: theme.colorScheme.primary,
                    ),
                  ),
                  Text(
                    '总进度',
                    style: theme.textTheme.bodySmall?.copyWith(
                      color: theme.colorScheme.onSurfaceVariant,
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(height: 20),
            Text(
              'Flutter for OpenHarmony',
              style: theme.textTheme.headlineSmall?.copyWith(
                fontWeight: FontWeight.bold,
              ),
              textAlign: TextAlign.center,
            ),
            const SizedBox(height: 8),
            Text(
              '使用 percent_indicator 构建圆形进度和线性进度结合的习惯养成仪表盘',
              style: theme.textTheme.bodyMedium?.copyWith(
                color: theme.colorScheme.onSurfaceVariant,
                height: 1.5,
              ),
              textAlign: TextAlign.center,
            ),
            const SizedBox(height: 14),
            Text(
              _overviewText,
              style: theme.textTheme.titleMedium?.copyWith(
                color: theme.colorScheme.primary,
                fontWeight: FontWeight.bold,
              ),
            ),
            const SizedBox(height: 18),
            Row(
              children: [
                _buildStatItem(
                  theme,
                  title: '习惯数',
                  value: '${_goals.length}',
                  icon: Icons.flag,
                ),
                _buildStatItem(
                  theme,
                  title: '已完成',
                  value: '$_completedCount',
                  icon: Icons.done_all,
                ),
                _buildStatItem(
                  theme,
                  title: '未完成',
                  value: '$_unfinishedCount',
                  icon: Icons.pending_actions,
                ),
                _buildStatItem(
                  theme,
                  title: '目标量',
                  value: '$_totalTargetCount',
                  icon: Icons.data_usage,
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildStatItem(
    ThemeData theme, {
    required String title,
    required String value,
    required IconData icon,
  }) {
    return Expanded(
      child: Column(
        children: [
          Icon(
            icon,
            color: theme.colorScheme.primary,
          ),
          const SizedBox(height: 6),
          Text(
            value,
            style: theme.textTheme.titleMedium?.copyWith(
              fontWeight: FontWeight.bold,
              color: theme.colorScheme.primary,
            ),
            overflow: TextOverflow.ellipsis,
          ),
          const SizedBox(height: 2),
          Text(
            title,
            style: theme.textTheme.bodySmall?.copyWith(
              color: theme.colorScheme.onSurfaceVariant,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildCategoryCard(ThemeData theme) {
    final int averageText = (_visibleAveragePercent * 100).round();

    return Card(
      elevation: 2,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(18),
      ),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            Row(
              children: [
                Icon(
                  Icons.filter_alt,
                  color: theme.colorScheme.primary,
                ),
                const SizedBox(width: 8),
                Expanded(
                  child: Text(
                    '习惯分类筛选',
                    style: theme.textTheme.titleMedium?.copyWith(
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
                Text(
                  '平均 $averageText%',
                  style: theme.textTheme.bodyMedium?.copyWith(
                    color: theme.colorScheme.primary,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ],
            ),
            const SizedBox(height: 14),
            Wrap(
              spacing: 10,
              runSpacing: 10,
              children: _categories.map((category) {
                final bool selected = category == _selectedCategory;

                return ChoiceChip(
                  label: Text(category),
                  selected: selected,
                  selectedColor: theme.colorScheme.primaryContainer,
                  labelStyle: TextStyle(
                    color: selected
                        ? theme.colorScheme.onPrimaryContainer
                        : theme.colorScheme.onSurface,
                    fontWeight: selected ? FontWeight.bold : FontWeight.normal,
                  ),
                  onSelected: (_) {
                    _selectCategory(category);
                  },
                );
              }).toList(),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildGoalListCard(
    ThemeData theme,
    List<HabitGoal> visibleGoals,
  ) {
    return Card(
      elevation: 2,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(18),
      ),
      child: Padding(
        padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Expanded(
                  child: Text(
                    '今日习惯进度',
                    style: theme.textTheme.titleLarge?.copyWith(
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
                Text(
                  '显示 ${visibleGoals.length} 项',
                  style: theme.textTheme.bodyMedium?.copyWith(
                    color: theme.colorScheme.primary,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ],
            ),
            const SizedBox(height: 8),
            Text(
              '点击习惯可以查看详情,点击右侧按钮可以快速增加进度',
              style: theme.textTheme.bodySmall?.copyWith(
                color: theme.colorScheme.onSurfaceVariant,
              ),
            ),
            const SizedBox(height: 16),
            if (visibleGoals.isEmpty)
              Padding(
                padding: const EdgeInsets.all(24),
                child: Center(
                  child: Text(
                    '当前分类下暂无习惯目标',
                    style: theme.textTheme.titleMedium?.copyWith(
                      color: theme.colorScheme.onSurfaceVariant,
                    ),
                  ),
                ),
              )
            else
              ...visibleGoals.map((goal) {
                return _buildGoalItem(theme, goal);
              }),
          ],
        ),
      ),
    );
  }

  Widget _buildGoalItem(ThemeData theme, HabitGoal goal) {
    final bool selected = goal.id == _selectedGoalId;
    final int percentText = (goal.percent * 100).round();

    return GestureDetector(
      onTap: () {
        _selectGoal(goal);
      },
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 220),
        margin: const EdgeInsets.only(bottom: 12),
        padding: const EdgeInsets.all(14),
        decoration: BoxDecoration(
          color: selected
              ? goal.color.withOpacity(0.16)
              : goal.color.withOpacity(0.08),
          borderRadius: BorderRadius.circular(18),
          border: Border.all(
            color: selected
                ? goal.color.withOpacity(0.72)
                : goal.color.withOpacity(0.20),
            width: selected ? 2 : 1,
          ),
        ),
        child: Row(
          children: [
            Container(
              width: 54,
              height: 54,
              decoration: BoxDecoration(
                color: goal.color.withOpacity(0.18),
                borderRadius: BorderRadius.circular(18),
              ),
              child: Icon(
                goal.icon,
                color: goal.color,
                size: 28,
              ),
            ),
            const SizedBox(width: 14),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Row(
                    children: [
                      Expanded(
                        child: Text(
                          goal.title,
                          style: theme.textTheme.titleMedium?.copyWith(
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                      ),
                      Text(
                        '$percentText%',
                        style: theme.textTheme.bodyMedium?.copyWith(
                          color: goal.color,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                    ],
                  ),
                  const SizedBox(height: 6),
                  LinearPercentIndicator(
                    padding: EdgeInsets.zero,
                    lineHeight: 10,
                    percent: goal.percent,
                    animation: true,
                    animationDuration: 500,
                    barRadius: const Radius.circular(10),
                    progressColor: goal.color,
                    backgroundColor: goal.color.withOpacity(0.12),
                  ),
                  const SizedBox(height: 7),
                  Text(
                    '${goal.current}/${goal.target}${goal.unit} · ${goal.category}',
                    style: theme.textTheme.bodySmall?.copyWith(
                      color: theme.colorScheme.onSurfaceVariant,
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(width: 10),
            IconButton(
              onPressed: () {
                _increaseGoal(goal);
              },
              icon: const Icon(Icons.add_circle),
              color: goal.color,
              tooltip: '增加进度',
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildSelectedGoalCard(ThemeData theme) {
    final HabitGoal goal = _selectedGoal;
    final int percentText = (goal.percent * 100).round();

    return Card(
      elevation: 2,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(18),
      ),
      child: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          children: [
            Row(
              children: [
                Icon(
                  Icons.insights,
                  color: goal.color,
                ),
                const SizedBox(width: 10),
                Expanded(
                  child: Text(
                    '当前习惯详情',
                    style: theme.textTheme.titleLarge?.copyWith(
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 18),
            CircularPercentIndicator(
              radius: 68,
              lineWidth: 12,
              percent: goal.percent,
              animation: true,
              animationDuration: 600,
              circularStrokeCap: CircularStrokeCap.round,
              progressColor: goal.color,
              backgroundColor: goal.color.withOpacity(0.12),
              center: Text(
                '$percentText%',
                style: theme.textTheme.headlineSmall?.copyWith(
                  fontWeight: FontWeight.bold,
                  color: goal.color,
                ),
              ),
            ),
            const SizedBox(height: 18),
            _buildInfoRow(
              theme,
              title: '习惯',
              value: goal.title,
              color: goal.color,
            ),
            _buildInfoRow(
              theme,
              title: '分类',
              value: goal.category,
              color: goal.color,
            ),
            _buildInfoRow(
              theme,
              title: '进度',
              value: '${goal.current}/${goal.target}${goal.unit}',
              color: goal.color,
            ),
            _buildInfoRow(
              theme,
              title: '状态',
              value: goal.completed ? '已完成' : '进行中',
              color: goal.color,
            ),
            _buildInfoRow(
              theme,
              title: '说明',
              value: goal.description,
              color: goal.color,
            ),
            _buildInfoRow(
              theme,
              title: '提示',
              value: goal.tip,
              color: goal.color,
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildInfoRow(
    ThemeData theme, {
    required String title,
    required String value,
    required Color color,
  }) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 12),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            width: 7,
            height: 7,
            margin: const EdgeInsets.only(top: 8),
            decoration: BoxDecoration(
              color: color,
              shape: BoxShape.circle,
            ),
          ),
          const SizedBox(width: 10),
          SizedBox(
            width: 58,
            child: Text(
              title,
              style: theme.textTheme.bodyMedium?.copyWith(
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
          Expanded(
            child: Text(
              value,
              style: theme.textTheme.bodyMedium?.copyWith(
                color: theme.colorScheme.onSurfaceVariant,
                height: 1.5,
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildActionCard(ThemeData theme) {
    return Card(
      elevation: 2,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(18),
      ),
      child: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          children: [
            Row(
              children: [
                Expanded(
                  child: ElevatedButton.icon(
                    onPressed: () {
                      _increaseGoal(_selectedGoal);
                    },
                    icon: const Icon(Icons.add),
                    label: const Text('增加当前'),
                  ),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: ElevatedButton.icon(
                    onPressed: _finishSelectedGoal,
                    icon: const Icon(Icons.check_circle),
                    label: const Text('完成当前'),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 12),
            Row(
              children: [
                Expanded(
                  child: OutlinedButton.icon(
                    onPressed: _resetSelectedGoal,
                    icon: const Icon(Icons.restart_alt),
                    label: const Text('重置当前'),
                  ),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: OutlinedButton.icon(
                    onPressed: _resetAllGoals,
                    icon: const Icon(Icons.refresh),
                    label: const Text('全部重置'),
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildLibraryCard(ThemeData theme) {
    return Card(
      elevation: 2,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(18),
      ),
      child: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              '第三方库说明',
              style: theme.textTheme.titleLarge?.copyWith(
                fontWeight: FontWeight.bold,
              ),
            ),
            const SizedBox(height: 12),
            _buildLibraryInfoRow(
              theme,
              title: '库名称',
              value: 'percent_indicator',
            ),
            _buildLibraryInfoRow(
              theme,
              title: '配置文件',
              value: 'pubspec.yaml',
            ),
            _buildLibraryInfoRow(
              theme,
              title: '导入方式',
              value: "import 'package:percent_indicator/percent_indicator.dart';",
            ),
            _buildLibraryInfoRow(
              theme,
              title: '核心组件',
              value: 'CircularPercentIndicator / LinearPercentIndicator',
            ),
            _buildLibraryInfoRow(
              theme,
              title: '核心能力',
              value: '圆形百分比进度、线性百分比进度、动画进度、中心文本展示',
            ),
            _buildLibraryInfoRow(
              theme,
              title: '应用场景',
              value: '习惯打卡、学习进度、任务完成率、健身记录、项目统计',
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildLibraryInfoRow(
    ThemeData theme, {
    required String title,
    required String value,
  }) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 10),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          SizedBox(
            width: 82,
            child: Text(
              title,
              style: theme.textTheme.bodyMedium?.copyWith(
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
          Expanded(
            child: Text(
              value,
              style: theme.textTheme.bodyMedium?.copyWith(
                color: theme.colorScheme.onSurfaceVariant,
                height: 1.5,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

九、代码实现说明

1. 引入 percent_indicator 第三方库

代码开头引入第三方库:

import 'package:percent_indicator/percent_indicator.dart';

这说明项目确实使用了 Flutter 第三方库,而不是 OpenHarmony 原生库。

本项目中主要使用以下组件:

CircularPercentIndicator
LinearPercentIndicator
CircularStrokeCap

其中:

组件 作用
CircularPercentIndicator 构建圆形百分比进度条
LinearPercentIndicator 构建线性百分比进度条
CircularStrokeCap 设置圆形进度条线段端点样式

2. 使用 CircularPercentIndicator 构建圆形进度

项目顶部总览区域使用了圆形进度条:

CircularPercentIndicator(
  radius: 72,
  lineWidth: 13,
  percent: _overallPercent,
  animation: true,
  circularStrokeCap: CircularStrokeCap.round,
  progressColor: theme.colorScheme.primary,
  backgroundColor: theme.colorScheme.primary.withOpacity(0.12),
  center: Text('$percentText%'),
)

参数说明如下:

参数 作用
radius 圆形进度条半径
lineWidth 进度条线宽
percent 当前百分比,范围为 0 到 1
animation 是否开启动画
circularStrokeCap 圆形进度条端点样式
progressColor 已完成部分颜色
backgroundColor 未完成背景颜色
center 圆形进度条中间显示的内容

这里的 _overallPercent 表示全部习惯的平均完成度。


3. 使用 LinearPercentIndicator 构建线性进度条

每个习惯列表项中都使用了线性进度条:

LinearPercentIndicator(
  padding: EdgeInsets.zero,
  lineHeight: 10,
  percent: goal.percent,
  animation: true,
  barRadius: const Radius.circular(10),
  progressColor: goal.color,
  backgroundColor: goal.color.withOpacity(0.12),
)

参数说明如下:

参数 作用
padding 进度条内边距
lineHeight 线性进度条高度
percent 当前进度百分比
animation 是否开启动画
barRadius 进度条圆角
progressColor 已完成部分颜色
backgroundColor 未完成背景颜色

这种线性进度条适合展示多个习惯任务的进度,信息密度比较高,页面也不会太乱。终于不是一堆纯文字堆在屏幕上互相折磨了。


4. 定义习惯目标数据模型

项目中定义了 HabitGoal 模型:

class HabitGoal {
  HabitGoal({
    required this.id,
    required this.title,
    required this.category,
    required this.description,
    required this.current,
    required this.target,
    required this.unit,
    required this.icon,
    required this.color,
    required this.tip,
  });

  final int id;
  final String title;
  final String category;
  final String description;
  int current;
  final int target;
  final String unit;
  final IconData icon;
  final Color color;
  final String tip;
}

字段说明如下:

字段 作用
id 习惯编号
title 习惯名称
category 习惯分类
description 习惯说明
current 当前完成量
target 目标完成量
unit 单位
icon 图标
color 主题色
tip 提示内容

其中 current 是可变化字段,用于记录当前完成进度。


5. 计算百分比进度

HabitGoal 中定义了 percent

double get percent {
  if (target <= 0) {
    return 0;
  }

  final double value = current / target;

  if (value < 0) {
    return 0;
  }

  if (value > 1) {
    return 1;
  }

  return value;
}

这里将当前完成量除以目标量,得到百分比。

由于 percent_indicatorpercent 参数范围是 01,所以代码中限制了最小值和最大值。

例如:

current target percent
500 2000 0.25
1000 2000 0.5
2000 2000 1.0

这样就可以把数据转换成进度条能识别的形式。


6. 实现总体完成进度

总体完成进度通过 _overallPercent 计算:

double get _overallPercent {
  if (_goals.isEmpty) {
    return 0;
  }

  double total = 0;

  for (final HabitGoal goal in _goals) {
    total += goal.percent;
  }

  return total / _goals.length;
}

它会把所有习惯的百分比加起来,再除以习惯数量,得到平均完成度。

这个值用于顶部的圆形总进度。


7. 实现习惯分类筛选

页面提供以下分类:

final List<String> _categories = const [
  '全部',
  '健康',
  '学习',
  '效率',
  '生活',
];

筛选逻辑如下:

List<HabitGoal> get _visibleGoals {
  if (_selectedCategory == '全部') {
    return _goals;
  }

  return _goals.where((goal) {
    return goal.category == _selectedCategory;
  }).toList();
}

如果选择“全部”,展示所有习惯。

如果选择“健康”,只展示健康分类下的习惯。

分类切换代码如下:

void _selectCategory(String category) {
  setState(() {
    _selectedCategory = category;

    final List<HabitGoal> visibleGoals = _visibleGoals;
    if (visibleGoals.isNotEmpty) {
      _selectedGoalId = visibleGoals.first.id;
    }
  });
}

切换分类后,当前选中习惯会自动切换到该分类下的第一项。


8. 实现增加习惯进度

增加进度的方法如下:

void _increaseGoal(HabitGoal goal) {
  setState(() {
    final int step = _getStep(goal);
    goal.current += step;

    if (goal.current > goal.target) {
      goal.current = goal.target;
    }

    _selectedGoalId = goal.id;
  });

  _showMessage('${goal.title} 进度已更新');
}

不同单位对应不同增加步长:

int _getStep(HabitGoal goal) {
  if (goal.unit == '毫升') {
    return 250;
  }

  if (goal.unit == '分钟') {
    return 5;
  }

  return 1;
}

例如:

单位 每次增加
毫升 250
分钟 5
1
1

这样点击增加按钮时,习惯进度会动态变化。


9. 实现完成当前习惯

一键完成当前习惯的方法如下:

void _finishSelectedGoal() {
  setState(() {
    _selectedGoal.current = _selectedGoal.target;
  });

  _showMessage('当前习惯已完成');
}

这里直接将当前完成量设置为目标量。

进度条会变成 100%,状态也会变成“已完成”。


10. 实现重置当前习惯

重置当前习惯的方法如下:

void _resetSelectedGoal() {
  setState(() {
    _selectedGoal.current = 0;
  });

  _showMessage('当前习惯已重置');
}

执行后,当前习惯的进度会回到 0。

这个功能适合测试页面效果,也适合用户重新开始记录。


11. 展示当前习惯详情

当前选中习惯通过 _selectedGoal 获取:

HabitGoal get _selectedGoal {
  return _goals.firstWhere(
    (goal) {
      return goal.id == _selectedGoalId;
    },
    orElse: () {
      return _goals.first;
    },
  );
}

点击某个习惯时,会执行:

void _selectGoal(HabitGoal goal) {
  setState(() {
    _selectedGoalId = goal.id;
  });
}

详情卡片中展示:

  • 习惯名称;
  • 习惯分类;
  • 当前进度;
  • 完成状态;
  • 习惯说明;
  • 操作提示。

这样列表和详情区域可以联动更新。至少页面终于知道用户点了哪个,而不是像某些系统一样装死。


12. 使用 SnackBar 显示操作反馈

项目中封装了提示方法:

void _showMessage(String text) {
  ScaffoldMessenger.of(context)
    ..clearSnackBars()
    ..showSnackBar(
      SnackBar(
        content: Text(text),
        behavior: SnackBarBehavior.floating,
        duration: const Duration(milliseconds: 1200),
      ),
    );
}

当用户增加进度、完成当前、重置当前或全部重置时,页面底部会显示提示。

这样可以让用户明确知道操作已经生效,不至于点完按钮后怀疑自己是不是在和空气互动。


十、运行项目

完成代码后,在终端执行:

flutter pub get

然后连接 OpenHarmony 设备或启动 OpenHarmony 模拟器。

查看设备:

flutter devices

运行项目:

flutter run

如果环境配置正确,应用会运行到 OpenHarmony 设备或模拟器中。

运行成功后,页面会显示“习惯进度仪表盘”。用户可以查看今日所有习惯的总体完成度,也可以通过分类筛选、进度按钮、完成按钮和重置按钮来管理不同习惯的完成情况。


十一、开发中遇到的问题

1. percent_indicator 依赖没有生效

如果代码中出现找不到 percent_indicator 的问题,可以检查 pubspec.yaml 中是否添加了:

percent_indicator: ^4.2.5

然后重新执行:

flutter pub get

如果还是不行,可以重启编辑器。编辑器有时候像没睡醒,依赖装好了还假装看不见,软件行业的经典保留节目。


2. import 导入报错

如果下面代码报错:

import 'package:percent_indicator/percent_indicator.dart';

通常有几种原因:

  • pubspec.yaml 中没有添加依赖;
  • 没有执行 flutter pub get
  • YAML 缩进错误;
  • 包名写错;
  • 编辑器没有刷新依赖。

其中 YAML 缩进最容易出问题。依赖必须写在 dependencies 下面,并且缩进要正确。一个空格就能毁掉运行结果,编程世界真是优雅得像陷阱。


3. 进度条没有显示

如果进度条没有显示,可以检查:

  • 是否正确引入 percent_indicator
  • 是否执行了 flutter pub get
  • percent 是否在 0 到 1 之间;
  • progressColor 是否和背景色太接近;
  • radiuslineHeight 是否设置过小;
  • 页面是否成功运行。

基础写法如下:

CircularPercentIndicator(
  radius: 60,
  lineWidth: 10,
  percent: 0.6,
  center: Text('60%'),
)

4. percent 数值报错

percent_indicator 中的 percent 参数应该是 01 之间的小数。

错误写法:

percent: 60

正确写法:

percent: 0.6

如果把 60 当成百分比传进去,组件当然会不高兴。它要的是 0.6,不是你脑子里的 60%。


5. 进度超过 100%

如果当前完成量超过目标量,需要限制最大值:

if (goal.current > goal.target) {
  goal.current = goal.target;
}

这样可以避免进度超过 100%。

本项目中 percent 也做了限制:

if (value > 1) {
  return 1;
}

双重保护比较稳,省得进度条像喝多了一样冲出边界。


6. LinearPercentIndicator 宽度异常

如果线性进度条宽度显示异常,可以检查它所在的父组件是否有足够空间。

本项目中 LinearPercentIndicator 放在 Expanded 内部的 Column 中,能够正常适应列表项宽度。

同时使用:

padding: EdgeInsets.zero

可以避免进度条因为默认内边距导致对齐不自然。


7. 圆形进度中心文字不居中

如果圆形进度中心文字不居中,可以将 center 设置为一个简单的 Text 或者使用 Column 并设置:

mainAxisAlignment: MainAxisAlignment.center

本项目顶部总进度使用了:

center: Column(
  mainAxisAlignment: MainAxisAlignment.center,
  children: [
    Text('$percentText%'),
    Text('总进度'),
  ],
)

这样中心内容会垂直居中显示。


8. 点击按钮后页面没有刷新

如果点击按钮后进度没有更新,需要检查数据修改是否放在 setState() 中:

setState(() {
  goal.current += step;
});

Flutter 不会自己读心。你不告诉它状态变了,它就继续假装世界和平。


9. 运行不到 OpenHarmony 设备

如果项目无法运行到 OpenHarmony 设备或模拟器,可以检查:

  • Flutter for OpenHarmony 环境是否配置完成;
  • 设备是否连接成功;
  • flutter devices 是否能识别设备;
  • 是否执行了 flutter pub get
  • 是否选择了正确的运行设备;
  • 项目是否为 Flutter 项目,而不是原生鸿蒙项目。

如果 flutter devices 都识别不到设备,那应该先处理环境问题,而不是盯着 percent_indicator 怀疑人生。进度条没有权限把设备藏起来。


十二、本文和原生鸿蒙项目的区别

本文是 Flutter for OpenHarmony 第三方库实践,不是 OpenHarmony 原生 ArkTS 项目。

主要区别如下:

对比项 本文写法 原生鸿蒙写法
UI 技术 Flutter ArkUI
主要语言 Dart ArkTS
页面入口 lib/main.dart Index.ets
依赖配置 pubspec.yaml oh-package.json5
依赖安装 flutter pub get ohpm install
第三方库 percent_indicator OpenHarmony 原生库
页面组件 MaterialApp / Scaffold / CircularPercentIndicator @Entry / @Component

因此本文符合 Flutter for OpenHarmony 第三方库实践方向。


十三、总结

本篇完成了一个基于 percent_indicator 的 Flutter for OpenHarmony 习惯养成进度仪表盘应用。项目通过 Flutter 第三方库实现了圆形百分比进度条、线性百分比进度条、进度动画、中心百分比文本和多习惯进度展示,并结合分类筛选、进度更新、当前习惯详情和操作反馈完成了一个完整页面。

通过本次实践,我主要完成了以下内容:

  • 创建 Flutter for OpenHarmony 项目;
  • pubspec.yaml 中添加 percent_indicator 依赖;
  • 使用 flutter pub get 获取第三方库;
  • lib/main.dart 中引入 percent_indicator
  • 使用 CircularPercentIndicator 构建圆形进度条;
  • 使用 LinearPercentIndicator 构建线性进度条;
  • 使用数据模型管理习惯目标;
  • 使用 ChoiceChip 实现习惯分类筛选;
  • 使用 setState() 实现进度动态更新;
  • 使用 SnackBar 显示操作反馈;
  • 使用 Flutter Material 组件构建完整页面;
  • 将项目运行到 OpenHarmony 设备或模拟器中。

这个项目虽然只是一个基础习惯进度页面,但完整展示了 Flutter for OpenHarmony 项目中第三方库的使用流程。

后续可以在这个基础上继续扩展,例如:

  • 添加习惯新增功能;
  • 添加习惯编辑功能;
  • 添加习惯删除功能;
  • 添加每日打卡记录;
  • 添加本地数据保存;
  • 添加连续打卡天数;
  • 添加周报统计;
  • 添加图表分析;
  • 添加暗色主题;
  • 添加通知提醒;
  • 添加云端同步功能。

整体来看,percent_indicator 可以帮助 Flutter 开发者快速实现百分比进度展示效果。通过这个项目,可以理解 Flutter for OpenHarmony 中第三方库依赖配置、圆形进度条使用、线性进度条使用、状态管理和页面交互更新之间的基本关系。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐