【收尾以及复盘】flutter开发鸿蒙APP之本月数据统计页面

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

1.本月数据统计页面先看截图效果

这是一个数据可视化页面,把用户的打卡数据用图表展示出来。

2.页面功能

  • 顶部 4 个统计卡片:今日摄入、连续打卡、本月打卡、累计打卡
  • 中间是月度打卡趋势图,用柱状图显示本月每天的打卡情况
  • 点击柱状图的某一天,弹窗显示那天的打卡详情
  • 底部是水果类别统计,用环形图显示本月吃了哪些水果的占比
  • 支持下拉刷新

3. 数据结构

页面用的是 StatefulWidget,需要管理多个数据源。

Dart 复制代码
class _StatisticsPageState extends State<StatisticsPage> {
  DataStatistics? _statistics;              // 数据统计(累计打卡、连续打卡等)
  MonthlyStatistics? _monthlyStats;         // 月度统计
  List<CalendarDate> _calendarData = [];    // 日历数据(用于柱状图)
  List<CheckInHistoryRecord> _historyRecords = []; // 打卡历史(用于水果统计)
  bool _loading = true;                     // 加载状态
}

数据模型(在 check_in_model.dart 里)

Dart 复制代码
class DataStatistics {
  final int totalCheckInDays;    // 累计打卡天数
  final int currentStreak;       // 连续打卡天数
  final int longestStreak;       // 最长连续打卡
  final int totalEnergyPoints;   // 总能量值
}

class CalendarDate {
  final String date;             // 日期 "2025-02-05"
  final bool hasCheckedIn;       // 是否打卡
  final String? checkInTime;     // 打卡时间
  final int energyPoints;        // 能量值
}

class CheckInHistoryRecord {
  final String date;             // 日期
  final String time;             // 时间
  final String fruitName;        // 水果名称
  final int fruitCount;          // 数量
  // ... 其他字段
}

4. 功能实现

4.1 数据加载

页面打开时,并行加载 4 个接口的数据

Dart 复制代码
Future<void> _loadStatistics() async {
  setState(() => _loading = true);

  final now = DateTime.now();

  // 并行加载 4 个接口
  final results = await Future.wait([
    CheckInApi.getDataStatistics(),                              // 数据统计
    CheckInApi.getMonthlyStatistics(),                           // 月度统计
    CheckInApi.getCheckInCalendar(year: now.year, month: now.month), // 日历数据
    CheckInApi.getCheckInHistory(page: 1, pageSize: 100),        // 打卡历史
  ]);

  if (mounted) {
    setState(() {
      _statistics = results[0] as DataStatistics?;
      _monthlyStats = results[1] as MonthlyStatistics?;
      final calendarResponse = results[2] as CalendarResponse?;
      _calendarData = calendarResponse?.calendar ?? [];
      final historyResponse = results[3] as CheckInHistoryResponse?;
      _historyRecords = historyResponse?.records ?? [];
      _loading = false;
    });
  }
}

Future.wait 并行请求,比一个一个请求快多了。

4.2 统计卡片网格

顶部 4 个卡片,2x2 布局

Dart 复制代码
Widget _buildStatsGrid() {
  final totalDays = _statistics?.totalCheckInDays ?? 0;
  final currentStreak = _statistics?.currentStreak ?? 0;

  // 计算今日摄入(从日历数据里找今天的能量值)
  final now = DateTime.now();
  final todayStr = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
  int todayEnergyPoints = 0;

  try {
    final todayData = _calendarData.firstWhere(
      (d) => d.date == todayStr && d.hasCheckedIn,
      orElse: () => CalendarDate(date: todayStr, hasCheckedIn: false, energyPoints: 0),
    );
    todayEnergyPoints = todayData.energyPoints;
  } catch (e) {}

  // 计算本月打卡天数
  final checkedInDays = _calendarData.where((d) => d.hasCheckedIn).length;

  return Padding(
    padding: const EdgeInsets.symmetric(horizontal: 16),
    child: Column(
      children: [
        Row(
          children: [
            Expanded(child: _buildStatCard('$todayEnergyPoints', '点', '今日摄入', Icons.restaurant, Color(0xFF3B82F6))),
            const SizedBox(width: 12),
            Expanded(child: _buildStatCard('$currentStreak', '天', '连续打卡', Icons.local_fire_department, Color(0xFFF59E0B))),
          ],
        ),
        const SizedBox(height: 12),
        Row(
          children: [
            Expanded(child: _buildStatCard('$checkedInDays', '天', '本月打卡', Icons.eco, Color(0xFF10B981))),
            const SizedBox(width: 12),
            Expanded(child: _buildStatCard('$totalDays', '天', '累计打卡', Icons.calendar_today, Color(0xFFEC4899))),
          ],
        ),
      ],
    ),
  );
}

每个卡片的结构

Dart 复制代码
Widget _buildStatCard(String value, String unit, String label, IconData icon, Color color) {
  return Container(
    padding: const EdgeInsets.all(16),
    decoration: BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.circular(8),
    ),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Icon(icon, color: color, size: 24),
        const SizedBox(height: 12),
        Row(
          crossAxisAlignment: CrossAxisAlignment.end,
          children: [
            Text(value, style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: color)),
            const SizedBox(width: 4),
            Padding(
              padding: const EdgeInsets.only(bottom: 4),
              child: Text(unit, style: TextStyle(fontSize: 14, color: color)),
            ),
          ],
        ),
        const SizedBox(height: 4),
        Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280))),
      ],
    ),
  );
}
4.3 月度打卡趋势图

这是个简化版的柱状图,每天一根柱子,打卡了是深绿色,没打卡是淡绿色。

Dart 复制代码
Widget _buildSimpleBarChart() {
  if (_calendarData.isEmpty) {
    return SizedBox(
      height: 150,
      child: Center(child: Text('暂无数据', style: TextStyle(color: Colors.grey[400], fontSize: 14))),
    );
  }

  final now = DateTime.now();
  final daysInMonth = DateTime(now.year, now.month + 1, 0).day;

  // 创建每天的打卡状态数组
  final List<int> dailyCheckIns = List.filled(daysInMonth, 0);

  // 填充实际打卡数据
  for (var calendarDate in _calendarData) {
    try {
      final date = DateTime.parse(calendarDate.date);
      if (date.month == now.month && date.year == now.year) {
        final day = date.day;
        if (day >= 1 && day <= daysInMonth) {
          dailyCheckIns[day - 1] = calendarDate.hasCheckedIn ? 1 : 0;
        }
      }
    } catch (e) {}
  }

  // 渲染柱状图
  return SizedBox(
    height: 150,
    child: Row(
      crossAxisAlignment: CrossAxisAlignment.end,
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: List.generate(daysInMonth, (index) {
        final hasCheckedIn = dailyCheckIns[index] == 1;
        final day = index + 1;

        // 查找该天的打卡详情
        CalendarDate? dayData;
        try {
          final dateStr = '${now.year}-${now.month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
          dayData = _calendarData.firstWhere(
            (d) => d.date == dateStr,
            orElse: () => CalendarDate(date: dateStr, hasCheckedIn: false, energyPoints: 0),
          );
        } catch (e) {}

        return Expanded(
          child: GestureDetector(
            onTap: () => _showDayDetailDialog(day, dayData),
            child: Container(
              margin: const EdgeInsets.symmetric(horizontal: 1),
              height: 130.0,  // 所有柱子都是满高度
              decoration: BoxDecoration(
                color: hasCheckedIn
                    ? const Color(0xFF4CAF50)  // 已打卡:深绿色
                    : const Color(0xFFE8F5E9), // 未打卡:淡绿色
                borderRadius: BorderRadius.circular(2),
              ),
            ),
          ),
        );
      }),
    ),
  );
}

关键点:

  • 所有柱子高度一样(130),不是按数值比例变化的
  • 用颜色区分打卡状态,不是用高度
  • 每根柱子都可以点击,弹窗显示详情
4.4 点击柱子显示详情

点击某一天的柱子,弹窗显示那天的打卡详情

Dart 复制代码
void _showDayDetailDialog(int day, CalendarDate? dayData) {
  final now = DateTime.now();
  final year = now.year;
  final month = now.month;

  showDialog(
    context: context,
    builder: (BuildContext context) {
      return AlertDialog(
        backgroundColor: Colors.white,
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
        title: Row(
          children: [
            Icon(
              dayData?.hasCheckedIn == true ? Icons.check_circle : Icons.cancel,
              color: dayData?.hasCheckedIn == true ? const Color(0xFF4CAF50) : Colors.grey,
              size: 28,
            ),
            const SizedBox(width: 12),
            Text('$year年$month月$day日', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
          ],
        ),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            _buildDetailRow('打卡状态', dayData?.hasCheckedIn == true ? '已打卡' : '未打卡', 
                dayData?.hasCheckedIn == true ? const Color(0xFF4CAF50) : Colors.grey),
            const SizedBox(height: 12),
            if (dayData?.hasCheckedIn == true) ...[
              _buildDetailRow('打卡时间', dayData?.checkInTime ?? '未知', const Color(0xFF6B7280)),
              const SizedBox(height: 12),
              _buildDetailRow('获得能量', '${dayData?.energyPoints ?? 0} 点', const Color(0xFFF59E0B)),
            ] else ...[
              const Text('该日期未打卡', style: TextStyle(fontSize: 14, color: Color(0xFF6B7280))),
            ],
          ],
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(context).pop(),
            child: const Text('关闭', style: TextStyle(color: Color(0xFF4CAF50), fontSize: 16)),
          ),
        ],
      );
    },
  );
}

Widget _buildDetailRow(String label, String value, Color valueColor) {
  return Row(
    mainAxisAlignment: MainAxisAlignment.spaceBetween,
    children: [
      Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF6B7280))),
      Text(value, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: valueColor)),
    ],
  );
}
4.5 水果类别统计(环形图)

底部的环形图,显示本月吃了哪些水果的占比。

4.5.1 数据统计

先统计本月每种水果的打卡次数

Dart 复制代码
Widget _buildFruitCategoryChart() {
  final now = DateTime.now();
  final Map<String, int> fruitCounts = {};
  int totalCount = 0;

  // 统计本月每种水果的打卡次数
  for (var record in _historyRecords) {
    try {
      final recordDate = DateTime.parse(record.date);
      if (recordDate.year == now.year && recordDate.month == now.month) {
        final fruitName = record.fruitName;
        fruitCounts[fruitName] = (fruitCounts[fruitName] ?? 0) + 1;
        totalCount++;
      }
    } catch (e) {}
  }

  // 按数量排序,取前 4 个
  final sortedFruits = fruitCounts.entries.toList()
    ..sort((a, b) => b.value.compareTo(a.value));

  // 准备图表数据
  final List<Map<String, dynamic>> chartData = [];
  final colors = [
    const Color(0xFFF59E0B),  // 橙色
    const Color(0xFF10B981),  // 绿色
    const Color(0xFF3B82F6),  // 蓝色
    const Color(0xFFEC4899),  // 粉色
  ];

  for (int i = 0; i < sortedFruits.length && i < 4; i++) {
    final entry = sortedFruits[i];
    final percentage = totalCount > 0 ? (entry.value / totalCount * 100) : 0;
    chartData.add({
      'name': entry.key,
      'count': entry.value,
      'percentage': percentage,
      'color': colors[i],
    });
  }

  // ... 渲染环形图和图例
}
4.5.2 环形图渲染

环形图用 CustomPaint 自己画:

Dart 复制代码
// 环形图
SizedBox(
  width: 120,
  height: 120,
  child: CustomPaint(painter: _DonutChartPainter(chartData)),
)

自定义绘制器:

Dart 复制代码
class _DonutChartPainter extends CustomPainter {
  final List<Map<String, dynamic>> data;

  _DonutChartPainter(this.data);

  @override
  void paint(Canvas canvas, Size size) {
    if (data.isEmpty) return;

    final center = Offset(size.width / 2, size.height / 2);
    final radius = size.width / 2;
    final strokeWidth = 24.0;  // 环形宽度

    final paint = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth
      ..strokeCap = StrokeCap.round;  // 圆角端点

    double startAngle = -90 * (3.14159 / 180);  // 从顶部开始(-90度)

    // 先画阴影
    final shadowPaint = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth
      ..strokeCap = StrokeCap.round
      ..color = Colors.black.withOpacity(0.1)
      ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3);

    for (var item in data) {
      final percentage = item['percentage'] as double;
      final sweepAngle = (percentage / 100 * 360) * (3.14159 / 180);  // 转成弧度

      // 绘制阴影
      canvas.drawArc(
        Rect.fromCircle(center: center, radius: radius - strokeWidth / 2),
        startAngle,
        sweepAngle,
        false,
        shadowPaint,
      );

      // 绘制彩色环形
      paint.color = item['color'] as Color;
      canvas.drawArc(
        Rect.fromCircle(center: center, radius: radius - strokeWidth / 2),
        startAngle,
        sweepAngle,
        false,
        paint,
      );

      startAngle += sweepAngle;  // 下一段从这里开始
    }

    // 绘制中心白色圆形(增强立体感)
    final centerCirclePaint = Paint()
      ..color = Colors.white
      ..style = PaintingStyle.fill;

    canvas.drawCircle(center, radius - strokeWidth - 2, centerCirclePaint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

关键点:

  • drawArc 画圆弧,参数是起始角度和扫过的角度
  • 角度要转成弧度:角度 * (π / 180)
  • -90 度是从顶部开始(12 点钟方向)
  • strokeCap: StrokeCap.round 让端点是圆角的
  • 中间画个白色圆,形成环形效果
4.5.3 图例

环形图右边是图例,显示每种水果的名称和占比:

Dart 复制代码
Widget _buildLegendItem(String label, Color color, String percentage) {
  return Row(
    children: [
      Container(
        width: 12,
        height: 12,
        decoration: BoxDecoration(color: color, shape: BoxShape.circle),
      ),
      const SizedBox(width: 8),
      Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF6B7280))),
      const Spacer(),
      Text(percentage, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF1F2937))),
    ],
  );
}
4.6 下拉刷新

整个页面用 RefreshIndicator 包裹,支持下拉刷新:

Dart 复制代码
body: _loading
    ? const Center(child: CircularProgressIndicator())
    : RefreshIndicator(
        onRefresh: _loadStatistics,  // 刷新时重新加载数据
        child: SingleChildScrollView(
          physics: const AlwaysScrollableScrollPhysics(),  // 即使内容不够也能下拉
          child: Column(children: [...]),
        ),
      ),

5. API 接口

调用了 4 个接口:

// 1. 数据统计

CheckInApi.getDataStatistics()

// 返回:累计打卡、连续打卡、最长连续、总能量

// 2. 月度统计

CheckInApi.getMonthlyStatistics()

// 返回:本月打卡天数、完成率等

// 3. 日历数据

CheckInApi.getCheckInCalendar(year: year, month: month)

// 返回:每天的打卡状态和能量值

// 4. 打卡历史

CheckInApi.getCheckInHistory(page: 1, pageSize: 100)

// 返回:打卡记录列表(包含水果名称)

6. 总结

这页面最麻烦的是环形图,要自己用 CustomPaint 画。

drawArc 这个方法参数有点绕,起始角度和扫过的角度都要用弧度,不是角度。而且坐标系是从 3 点钟方向(0 度)开始顺时针转的,要从 12 点钟开始就得用 -90 度。

百分比转角度也要算对:percentage / 100 * 360,然后再转成弧度 * (π / 180)。每画完一段,下一段的起始角度要加上这段的扫过角度,不然就重叠了。

柱状图倒是简单,就是一排 Container,用 Expanded 让它们平分宽度。所有柱子高度一样,只是颜色不同。本来想按能量值调整高度的,但那样视觉效果不好,还是统一高度看着舒服。

统计卡片那块要注意,今日摄入的能量值要从日历数据里找今天的记录,不是从 DataStatistics 里拿的。因为 DataStatistics 返回的是累计总能量,不是今天的。

水果统计那里,要遍历打卡历史记录,过滤出本月的,然后按水果名称分组计数。用 Map<String, int> 存,key 是水果名,value 是次数。最后排个序取前 4 个,超过 4 种就不显示了,不然环形图太挤。

并行加载 4 个接口用 Future.wait 很方便,比一个一个 await 快多了。但要注意类型转换,results[0] 拿到的是 Object?,要手动转成对应的类型。

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