Flutter & OpenHarmony 运动App运动数据统计分析组件开发

前言

运动数据统计分析是帮助用户了解自身运动表现的重要功能。通过对历史数据的汇总、趋势分析和对比,用户可以清晰地看到自己的进步和需要改进的地方。本文将详细介绍如何在Flutter与OpenHarmony平台上实现运动数据统计分析组件,包括数据聚合、趋势计算、周期对比等功能模块的完整实现方案。

Flutter统计数据模型

dart 复制代码
class WorkoutStatistics {
  final StatisticsPeriod period;
  final DateTime startDate;
  final DateTime endDate;
  final int totalWorkouts;
  final Duration totalDuration;
  final double totalDistance;
  final double totalCalories;
  final double avgHeartRate;
  final double avgPace;
  
  WorkoutStatistics({
    required this.period,
    required this.startDate,
    required this.endDate,
    required this.totalWorkouts,
    required this.totalDuration,
    required this.totalDistance,
    required this.totalCalories,
    required this.avgHeartRate,
    required this.avgPace,
  });
  
  double get avgDistancePerWorkout => totalWorkouts > 0 ? totalDistance / totalWorkouts : 0;
  Duration get avgDurationPerWorkout => totalWorkouts > 0 
      ? Duration(seconds: totalDuration.inSeconds ~/ totalWorkouts) 
      : Duration.zero;
}

enum StatisticsPeriod { day, week, month, year, custom }

class TrendData {
  final List<TrendPoint> points;
  final double changePercent;
  final TrendDirection direction;
  
  TrendData({required this.points, required this.changePercent, required this.direction});
}

class TrendPoint {
  final DateTime date;
  final double value;
  
  TrendPoint({required this.date, required this.value});
}

enum TrendDirection { up, down, stable }

统计数据模型定义了汇总数据和趋势的结构。WorkoutStatistics包含指定周期内的运动次数、总时长、总距离、总消耗和平均值。计算属性avgDistancePerWorkout和avgDurationPerWorkout提供每次运动的平均数据。TrendData记录趋势点列表、变化百分比和方向,用于展示数据走势。StatisticsPeriod支持日、周、月、年和自定义周期。

OpenHarmony数据聚合服务

typescript 复制代码
import relationalStore from '@ohos.data.relationalStore';

class StatisticsAggregator {
  private rdbStore: relationalStore.RdbStore | null = null;
  
  async aggregateByPeriod(period: string, startDate: number, endDate: number): Promise<object> {
    if (!this.rdbStore) return this.getEmptyStats();
    
    let predicates = new relationalStore.RdbPredicates('workouts');
    predicates.between('timestamp', startDate, endDate);
    
    let resultSet = await this.rdbStore.query(predicates, [
      'COUNT(*) as count',
      'SUM(duration) as totalDuration',
      'SUM(distance) as totalDistance',
      'SUM(calories) as totalCalories',
      'AVG(avgHeartRate) as avgHeartRate',
    ]);
    
    let stats = this.getEmptyStats();
    if (resultSet.goToFirstRow()) {
      stats = {
        period: period,
        startDate: startDate,
        endDate: endDate,
        totalWorkouts: resultSet.getLong(resultSet.getColumnIndex('count')),
        totalDuration: resultSet.getLong(resultSet.getColumnIndex('totalDuration')),
        totalDistance: resultSet.getDouble(resultSet.getColumnIndex('totalDistance')),
        totalCalories: resultSet.getDouble(resultSet.getColumnIndex('totalCalories')),
        avgHeartRate: resultSet.getDouble(resultSet.getColumnIndex('avgHeartRate')),
      };
    }
    resultSet.close();
    
    return stats;
  }
  
  private getEmptyStats(): object {
    return {
      totalWorkouts: 0,
      totalDuration: 0,
      totalDistance: 0,
      totalCalories: 0,
      avgHeartRate: 0,
    };
  }
}

数据聚合服务从数据库查询并汇总运动数据。使用RdbPredicates的between方法筛选指定时间范围的记录,通过SQL聚合函数COUNT、SUM、AVG计算统计值。查询结果包含运动次数、总时长、总距离、总消耗和平均心率。这种数据库级别的聚合比在应用层遍历计算效率更高。

Flutter周期选择器组件

dart 复制代码
class PeriodSelector extends StatelessWidget {
  final StatisticsPeriod selectedPeriod;
  final Function(StatisticsPeriod) onPeriodChanged;
  
  const PeriodSelector({
    Key? key,
    required this.selectedPeriod,
    required this.onPeriodChanged,
  }) : super(key: key);
  
  @override
  Widget build(BuildContext context) {
    return Container(
      padding: EdgeInsets.symmetric(vertical: 8),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: StatisticsPeriod.values
            .where((p) => p != StatisticsPeriod.custom)
            .map((period) => _buildPeriodButton(period))
            .toList(),
      ),
    );
  }
  
  Widget _buildPeriodButton(StatisticsPeriod period) {
    bool isSelected = period == selectedPeriod;
    return GestureDetector(
      onTap: () => onPeriodChanged(period),
      child: Container(
        padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
        decoration: BoxDecoration(
          color: isSelected ? Colors.blue : Colors.transparent,
          borderRadius: BorderRadius.circular(20),
          border: Border.all(color: isSelected ? Colors.blue : Colors.grey.shade300),
        ),
        child: Text(
          _getPeriodLabel(period),
          style: TextStyle(
            color: isSelected ? Colors.white : Colors.grey.shade600,
            fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
          ),
        ),
      ),
    );
  }
  
  String _getPeriodLabel(StatisticsPeriod period) {
    switch (period) {
      case StatisticsPeriod.day: return '日';
      case StatisticsPeriod.week: return '周';
      case StatisticsPeriod.month: return '月';
      case StatisticsPeriod.year: return '年';
      case StatisticsPeriod.custom: return '自定义';
    }
  }
}

周期选择器让用户切换统计时间范围。四个按钮分别代表日、周、月、年视图,选中状态使用蓝色填充背景。点击按钮触发onPeriodChanged回调,父组件根据新周期重新加载数据。这种分段控件设计简洁直观,用户可以快速切换不同时间维度查看数据。

OpenHarmony趋势计算服务

typescript 复制代码
class TrendCalculator {
  calculateTrend(data: Array<object>, valueKey: string): object {
    if (data.length < 2) {
      return { points: data, changePercent: 0, direction: 'stable' };
    }
    
    let points = data.map(item => ({
      date: item['date'],
      value: item[valueKey] || 0,
    }));
    
    // 计算变化百分比
    let firstHalf = points.slice(0, Math.floor(points.length / 2));
    let secondHalf = points.slice(Math.floor(points.length / 2));
    
    let firstAvg = this.calculateAverage(firstHalf);
    let secondAvg = this.calculateAverage(secondHalf);
    
    let changePercent = firstAvg > 0 ? ((secondAvg - firstAvg) / firstAvg) * 100 : 0;
    
    let direction: string;
    if (changePercent > 5) direction = 'up';
    else if (changePercent < -5) direction = 'down';
    else direction = 'stable';
    
    return {
      points: points,
      changePercent: Math.round(changePercent * 10) / 10,
      direction: direction,
    };
  }
  
  private calculateAverage(points: Array<object>): number {
    if (points.length === 0) return 0;
    let sum = points.reduce((acc, p) => acc + (p['value'] as number), 0);
    return sum / points.length;
  }
  
  calculateMovingAverage(data: Array<object>, valueKey: string, window: number): Array<object> {
    let result: Array<object> = [];
    
    for (let i = 0; i < data.length; i++) {
      let start = Math.max(0, i - window + 1);
      let windowData = data.slice(start, i + 1);
      let avg = this.calculateAverage(windowData.map(d => ({ value: d[valueKey] })));
      
      result.push({
        date: data[i]['date'],
        value: avg,
        originalValue: data[i][valueKey],
      });
    }
    
    return result;
  }
}

趋势计算服务分析数据变化方向和幅度。calculateTrend方法将数据分为前后两半,比较平均值计算变化百分比。变化超过5%判定为上升或下降,否则为稳定。calculateMovingAverage计算移动平均,平滑数据波动便于观察长期趋势。这些计算帮助用户理解自己的运动表现是在进步还是退步。

Flutter统计卡片组件

dart 复制代码
class StatisticsCard extends StatelessWidget {
  final String title;
  final String value;
  final String unit;
  final IconData icon;
  final TrendDirection? trend;
  final double? changePercent;
  
  const StatisticsCard({
    Key? key,
    required this.title,
    required this.value,
    required this.unit,
    required this.icon,
    this.trend,
    this.changePercent,
  }) : super(key: key);
  
  @override
  Widget build(BuildContext context) {
    return Card(
      elevation: 2,
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Icon(icon, size: 20, color: Colors.blue),
                SizedBox(width: 8),
                Text(title, style: TextStyle(color: Colors.grey.shade600, fontSize: 14)),
              ],
            ),
            SizedBox(height: 12),
            Row(
              crossAxisAlignment: CrossAxisAlignment.end,
              children: [
                Text(value, style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
                SizedBox(width: 4),
                Padding(
                  padding: EdgeInsets.only(bottom: 4),
                  child: Text(unit, style: TextStyle(color: Colors.grey, fontSize: 14)),
                ),
              ],
            ),
            if (trend != null && changePercent != null) ...[
              SizedBox(height: 8),
              _buildTrendIndicator(),
            ],
          ],
        ),
      ),
    );
  }
  
  Widget _buildTrendIndicator() {
    Color color;
    IconData trendIcon;
    
    switch (trend!) {
      case TrendDirection.up:
        color = Colors.green;
        trendIcon = Icons.trending_up;
        break;
      case TrendDirection.down:
        color = Colors.red;
        trendIcon = Icons.trending_down;
        break;
      case TrendDirection.stable:
        color = Colors.grey;
        trendIcon = Icons.trending_flat;
        break;
    }
    
    return Row(
      children: [
        Icon(trendIcon, size: 16, color: color),
        SizedBox(width: 4),
        Text(
          '${changePercent! >= 0 ? '+' : ''}${changePercent!.toStringAsFixed(1)}%',
          style: TextStyle(color: color, fontSize: 12),
        ),
      ],
    );
  }
}

统计卡片组件展示单项统计数据。顶部显示图标和标题,中间是大字号的数值和单位,底部可选显示趋势指示器。趋势使用颜色和箭头图标区分:绿色向上表示增长,红色向下表示下降,灰色水平表示稳定。这种卡片设计信息层次清晰,用户可以快速获取关键数据。

OpenHarmony周期对比服务

typescript 复制代码
class PeriodComparisonService {
  async comparePeriods(
    aggregator: object,
    currentStart: number,
    currentEnd: number,
    previousStart: number,
    previousEnd: number
  ): Promise<object> {
    let current = await aggregator['aggregateByPeriod']('current', currentStart, currentEnd);
    let previous = await aggregator['aggregateByPeriod']('previous', previousStart, previousEnd);
    
    return {
      current: current,
      previous: previous,
      comparison: {
        workoutsChange: this.calculateChange(previous['totalWorkouts'], current['totalWorkouts']),
        distanceChange: this.calculateChange(previous['totalDistance'], current['totalDistance']),
        durationChange: this.calculateChange(previous['totalDuration'], current['totalDuration']),
        caloriesChange: this.calculateChange(previous['totalCalories'], current['totalCalories']),
      },
    };
  }
  
  private calculateChange(previous: number, current: number): object {
    if (previous === 0) {
      return { percent: current > 0 ? 100 : 0, direction: current > 0 ? 'up' : 'stable' };
    }
    
    let percent = ((current - previous) / previous) * 100;
    let direction: string;
    
    if (percent > 5) direction = 'up';
    else if (percent < -5) direction = 'down';
    else direction = 'stable';
    
    return {
      percent: Math.round(percent * 10) / 10,
      direction: direction,
      difference: current - previous,
    };
  }
  
  getPreviousPeriodRange(period: string, currentStart: number, currentEnd: number): object {
    let duration = currentEnd - currentStart;
    return {
      start: currentStart - duration,
      end: currentEnd - duration,
    };
  }
}

周期对比服务比较当前周期与上一周期的数据。comparePeriods方法分别查询两个周期的统计数据,然后计算各项指标的变化。calculateChange计算变化百分比和方向,同时返回绝对差值。getPreviousPeriodRange根据当前周期自动计算上一周期的时间范围。这种对比帮助用户了解自己的运动量是增加还是减少。

Flutter统计概览页面

dart 复制代码
class StatisticsOverviewPage extends StatefulWidget {
  @override
  State<StatisticsOverviewPage> createState() => _StatisticsOverviewPageState();
}

class _StatisticsOverviewPageState extends State<StatisticsOverviewPage> {
  StatisticsPeriod _selectedPeriod = StatisticsPeriod.week;
  WorkoutStatistics? _statistics;
  Map<String, TrendData>? _trends;
  bool _isLoading = true;
  
  @override
  void initState() {
    super.initState();
    _loadStatistics();
  }
  
  Future<void> _loadStatistics() async {
    setState(() => _isLoading = true);
    // 加载统计数据
    setState(() => _isLoading = false);
  }
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('运动统计')),
      body: Column(
        children: [
          PeriodSelector(
            selectedPeriod: _selectedPeriod,
            onPeriodChanged: (period) {
              setState(() => _selectedPeriod = period);
              _loadStatistics();
            },
          ),
          Expanded(
            child: _isLoading
                ? Center(child: CircularProgressIndicator())
                : _buildStatisticsContent(),
          ),
        ],
      ),
    );
  }
  
  Widget _buildStatisticsContent() {
    if (_statistics == null) {
      return Center(child: Text('暂无数据'));
    }
    
    return SingleChildScrollView(
      padding: EdgeInsets.all(16),
      child: Column(
        children: [
          Row(
            children: [
              Expanded(child: StatisticsCard(
                title: '运动次数',
                value: '${_statistics!.totalWorkouts}',
                unit: '次',
                icon: Icons.fitness_center,
                trend: _trends?['workouts']?.direction,
                changePercent: _trends?['workouts']?.changePercent,
              )),
              SizedBox(width: 12),
              Expanded(child: StatisticsCard(
                title: '总距离',
                value: (_statistics!.totalDistance / 1000).toStringAsFixed(1),
                unit: '公里',
                icon: Icons.straighten,
                trend: _trends?['distance']?.direction,
                changePercent: _trends?['distance']?.changePercent,
              )),
            ],
          ),
          SizedBox(height: 12),
          Row(
            children: [
              Expanded(child: StatisticsCard(
                title: '总时长',
                value: '${_statistics!.totalDuration.inHours}',
                unit: '小时',
                icon: Icons.timer,
              )),
              SizedBox(width: 12),
              Expanded(child: StatisticsCard(
                title: '消耗热量',
                value: '${_statistics!.totalCalories.toInt()}',
                unit: '千卡',
                icon: Icons.local_fire_department,
              )),
            ],
          ),
        ],
      ),
    );
  }
}

统计概览页面整合所有统计组件。顶部是周期选择器,下方是统计卡片网格。使用两列布局展示运动次数、总距离、总时长和消耗热量四个核心指标。切换周期时显示加载指示器,数据加载完成后更新界面。这种布局让用户一目了然地看到关键统计数据。

OpenHarmony数据导出格式化

typescript 复制代码
class StatisticsExporter {
  formatForExport(statistics: object, period: string): object {
    let startDate = new Date(statistics['startDate']);
    let endDate = new Date(statistics['endDate']);
    
    return {
      reportTitle: `运动统计报告 - ${this.getPeriodLabel(period)}`,
      dateRange: `${this.formatDate(startDate)} 至 ${this.formatDate(endDate)}`,
      summary: {
        totalWorkouts: `${statistics['totalWorkouts']} 次`,
        totalDistance: `${(statistics['totalDistance'] / 1000).toFixed(2)} 公里`,
        totalDuration: this.formatDuration(statistics['totalDuration']),
        totalCalories: `${Math.round(statistics['totalCalories'])} 千卡`,
        avgHeartRate: `${Math.round(statistics['avgHeartRate'])} BPM`,
      },
      averages: {
        avgDistancePerWorkout: `${(statistics['totalDistance'] / statistics['totalWorkouts'] / 1000).toFixed(2)} 公里/次`,
        avgDurationPerWorkout: this.formatDuration(statistics['totalDuration'] / statistics['totalWorkouts']),
        avgCaloriesPerWorkout: `${Math.round(statistics['totalCalories'] / statistics['totalWorkouts'])} 千卡/次`,
      },
      generatedAt: new Date().toISOString(),
    };
  }
  
  private getPeriodLabel(period: string): string {
    let labels = { day: '今日', week: '本周', month: '本月', year: '本年' };
    return labels[period] || period;
  }
  
  private formatDate(date: Date): string {
    return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`;
  }
  
  private formatDuration(seconds: number): string {
    let hours = Math.floor(seconds / 3600);
    let minutes = Math.floor((seconds % 3600) / 60);
    return `${hours}小时${minutes}分钟`;
  }
}

统计导出服务将数据格式化为可读的报告格式。formatForExport方法生成包含标题、日期范围、汇总数据和平均值的结构化对象。数值转换为带单位的字符串,时长格式化为小时分钟形式。这种格式化数据可以直接用于生成PDF报告或分享到社交平台。

Flutter趋势图表组件

dart 复制代码
class TrendChartWidget extends StatelessWidget {
  final TrendData trendData;
  final String title;
  final Color color;
  
  const TrendChartWidget({
    Key? key,
    required this.trendData,
    required this.title,
    this.color = Colors.blue,
  }) : super(key: key);
  
  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
                _buildChangeIndicator(),
              ],
            ),
            SizedBox(height: 16),
            SizedBox(
              height: 150,
              child: CustomPaint(
                size: Size.infinite,
                painter: TrendChartPainter(
                  points: trendData.points,
                  color: color,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
  
  Widget _buildChangeIndicator() {
    Color indicatorColor = trendData.direction == TrendDirection.up
        ? Colors.green
        : trendData.direction == TrendDirection.down
            ? Colors.red
            : Colors.grey;
    
    return Container(
      padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
      decoration: BoxDecoration(
        color: indicatorColor.withOpacity(0.1),
        borderRadius: BorderRadius.circular(12),
      ),
      child: Text(
        '${trendData.changePercent >= 0 ? '+' : ''}${trendData.changePercent}%',
        style: TextStyle(color: indicatorColor, fontWeight: FontWeight.bold),
      ),
    );
  }
}

class TrendChartPainter extends CustomPainter {
  final List<TrendPoint> points;
  final Color color;
  
  TrendChartPainter({required this.points, required this.color});
  
  @override
  void paint(Canvas canvas, Size size) {
    if (points.isEmpty) return;
    
    double maxValue = points.map((p) => p.value).reduce((a, b) => a > b ? a : b);
    double minValue = points.map((p) => p.value).reduce((a, b) => a < b ? a : b);
    double range = maxValue - minValue;
    if (range == 0) range = 1;
    
    Path path = Path();
    for (int i = 0; i < points.length; i++) {
      double x = i * size.width / (points.length - 1);
      double y = size.height - ((points[i].value - minValue) / range * size.height);
      
      if (i == 0) {
        path.moveTo(x, y);
      } else {
        path.lineTo(x, y);
      }
    }
    
    Paint linePaint = Paint()
      ..color = color
      ..strokeWidth = 2
      ..style = PaintingStyle.stroke;
    
    canvas.drawPath(path, linePaint);
  }
  
  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

趋势图表组件可视化展示数据变化趋势。顶部显示标题和变化百分比标签,下方是折线图。TrendChartPainter使用CustomPaint绘制折线,自动计算数值范围进行归一化。颜色可自定义,变化指示器根据趋势方向显示不同颜色。这种图表直观展示数据走势,帮助用户发现运动规律。

总结

本文全面介绍了Flutter与OpenHarmony平台上运动数据统计分析组件的实现方案。从数据聚合到趋势计算,从周期对比到图表展示,涵盖了统计分析功能的各个方面。通过这些统计工具,用户可以深入了解自己的运动表现,发现进步和不足,从而更科学地规划运动计划。

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

相关推荐
2501_944446002 小时前
Flutter&OpenHarmony拖拽排序功能实现
android·javascript·flutter
2501_944441752 小时前
Flutter&OpenHarmony商城App下拉刷新组件开发
javascript·flutter·ajax
2501_944441752 小时前
Flutter&OpenHarmony商城App图片预览组件开发
flutter
2501_944446002 小时前
Flutter&OpenHarmony应用生命周期管理
android·javascript·flutter
纟 冬2 小时前
Flutter & OpenHarmony 运动App运动天气服务组件开发
flutter
我是人机不吃鸭梨2 小时前
Flutter AI 集成革命(2025版):从 Gemini 模型到智能表单验证器的终极方案
开发语言·javascript·人工智能·flutter·microsoft·架构
2501_946233892 小时前
Flutter与OpenHarmony作品详情页面开发
flutter
恋猫de小郭3 小时前
Flutter 小技巧之帮网友理解 SliverConstraints overlap
android·前端·flutter
纟 冬3 小时前
Flutter & OpenHarmony 运动App运动数据图表组件开发
flutter·信息可视化