在这里插入图片描述

用户抽了这么多次奖,总想看看自己的"战绩"。这一篇我们来实现数据统计页面,展示抽奖次数、中奖率、连续抽奖天数等数据。

为什么需要数据统计

数据统计不仅仅是展示数字,它有几个重要作用:

  1. 增加用户粘性:连续天数、最佳记录等数据会激励用户每天回来
  2. 提供成就感:看到自己的"战绩"会让用户有满足感
  3. 游戏化设计:里程碑机制让简单的抽奖变得更有趣
  4. 数据洞察:用户可以了解自己的中奖概率和偏好

统计指标设计

在设计统计功能之前,我先梳理了需要追踪的核心指标:

指标 说明 计算方式
总抽奖次数 用户累计抽奖的次数 每次抽奖 +1
中奖率 真正中奖的比例 中奖次数 / 总次数 × 100%
当前连续天数 连续每天抽奖的天数 每天首次抽奖时计算
最佳连续记录 历史最长连续天数 取连续天数的最大值
各奖品中奖次数 每种奖品抽中的次数 按奖品名称分组统计

统计管理器

先在 lib/utils/ 目录下创建 stats_manager.dart,实现统计逻辑:

class StatsManager extends ChangeNotifier {
  static final StatsManager _instance = StatsManager._internal();
  factory StatsManager() => _instance;
  StatsManager._internal();

  final StorageService _storage = StorageService();
  
  int _totalSpins = 0;
  int _consecutiveDays = 0;
  int _bestStreak = 0;
  DateTime? _lastSpinDate;

  int get totalSpins => _totalSpins;
  int get consecutiveDays => _consecutiveDays;
  int get bestStreak => _bestStreak;
  DateTime? get lastSpinDate => _lastSpinDate;
}

四个核心指标:总抽奖次数、当前连续天数、最佳连续记录、上次抽奖日期。

单例模式的应用

统计管理器使用单例模式,确保整个应用只有一个实例:

static final StatsManager _instance = StatsManager._internal();
factory StatsManager() => _instance;
StatsManager._internal();

这样做的好处:

  • 任何页面获取的都是同一个实例
  • 数据状态全局一致
  • 避免重复初始化和内存浪费

为什么继承 ChangeNotifier

ChangeNotifier 是 Flutter 提供的简单状态管理方案:

class StatsManager extends ChangeNotifier {
  void recordSpin() {
    _totalSpins++;
    notifyListeners();  // 通知所有监听者
  }
}

// 在页面中监听
_statsManager.addListener(_onDataChanged);

当数据变化时调用 notifyListeners(),所有注册的监听者都会收到通知并更新 UI。

初始化统计数据

应用启动时从本地存储加载数据:

Future<void> init() async {
  if (_initialized) return;
  
  _totalSpins = _storage.totalSpins;
  _consecutiveDays = _storage.consecutiveDays;
  _bestStreak = _storage.bestStreak;
  _lastSpinDate = _storage.lastSpinDate;
  
  _checkConsecutiveDays();
  _initialized = true;
  notifyListeners();
}

_initialized 标志防止重复初始化。加载数据后立即检查连续天数是否中断。

记录抽奖

每次抽奖时调用 recordSpin 方法:

void recordSpin() {
  _totalSpins++;
  _updateConsecutiveDays();
  _saveStats();
  notifyListeners();
}

增加总次数,更新连续天数,保存数据,通知监听者。这四步缺一不可。

连续天数计算

连续天数的逻辑稍微复杂一点,需要处理多种情况:

void _updateConsecutiveDays() {
  final now = DateTime.now();
  final today = DateTime(now.year, now.month, now.day);
  
  if (_lastSpinDate == null) {
    _consecutiveDays = 1;
  } else {
    final lastDate = DateTime(_lastSpinDate!.year, _lastSpinDate!.month, _lastSpinDate!.day);
    final difference = today.difference(lastDate).inDays;
    
    if (difference == 0) {
      // 同一天,不增加
    } else if (difference == 1) {
      // 连续天数+1
      _consecutiveDays++;
    } else {
      // 断了,重新计数
      _consecutiveDays = 1;
    }
  }
  
  if (_consecutiveDays > _bestStreak) {
    _bestStreak = _consecutiveDays;
  }
  
  _lastSpinDate = now;
}

日期比较的技巧

注意这里创建了"纯日期"对象来比较:

final today = DateTime(now.year, now.month, now.day);
final lastDate = DateTime(_lastSpinDate!.year, _lastSpinDate!.month, _lastSpinDate!.day);

这样做是为了忽略时分秒,只比较日期部分。如果直接用 now.difference(_lastSpinDate) 可能会因为时间差不足 24 小时而计算错误。

四种情况的处理

情况 difference 处理方式
第一次抽奖 - 连续天数 = 1
同一天再次抽奖 0 不变
连续第二天 1 连续天数 + 1
中断超过一天 > 1 连续天数 = 1

同时更新最佳记录,取连续天数和历史最佳的较大值。

检查连续是否中断

应用启动时检查连续天数是否已经中断:

void _checkConsecutiveDays() {
  if (_lastSpinDate == null) return;
  
  final now = DateTime.now();
  final today = DateTime(now.year, now.month, now.day);
  final lastDate = DateTime(_lastSpinDate!.year, _lastSpinDate!.month, _lastSpinDate!.day);
  final difference = today.difference(lastDate).inDays;
  
  if (difference > 1) {
    _consecutiveDays = 0;
    _saveStats();
  }
}

如果超过 1 天没抽奖,连续天数归零。这个检查在 init() 中调用,确保用户看到的是最新状态。

为什么归零而不是设为 1

这里有个细节:中断后设为 0 而不是 1。因为用户今天还没抽奖,只有抽奖后才会变成 1。如果直接设为 1,会给用户错误的信息。

保存统计数据

数据变化后需要持久化保存:

Future<void> _saveStats() async {
  await _storage.setTotalSpins(_totalSpins);
  await _storage.setConsecutiveDays(_consecutiveDays);
  await _storage.setBestStreak(_bestStreak);
  if (_lastSpinDate != null) {
    await _storage.setLastSpinDate(_lastSpinDate!);
  }
}

使用 StorageService 保存到本地,下次启动时可以恢复。

统计页面布局

RankingPage 分三个区域:核心数据卡片、连续抽奖卡片、中奖统计:


Widget build(BuildContext context) {
  final prizeStats = _getPrizeStats();
  final sortedPrizes = prizeStats.entries.toList()
    ..sort((a, b) => b.value.compareTo(a.value));

  return Scaffold(
    appBar: AppBar(
      title: const Text('数据统计'),
      centerTitle: true,
      backgroundColor: Theme.of(context).colorScheme.inversePrimary,
    ),
    body: SingleChildScrollView(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _buildStatsCards(),
          const SizedBox(height: 24),
          _buildStreakCard(),
          const SizedBox(height: 24),
          _buildPrizeStatsSection(sortedPrizes),
        ],
      ),
    ),
  );
}

级联操作符的妙用

注意这行代码:

final sortedPrizes = prizeStats.entries.toList()
  ..sort((a, b) => b.value.compareTo(a.value));

.. 是 Dart 的级联操作符,它允许对同一个对象连续调用多个方法。等价于:

final sortedPrizes = prizeStats.entries.toList();
sortedPrizes.sort((a, b) => b.value.compareTo(a.value));

级联操作符让代码更简洁,特别适合链式调用。

核心数据卡片

顶部两个卡片并排显示总抽奖次数和中奖率:

Widget _buildStatsCards() {
  return Row(
    children: [
      Expanded(
        child: _buildStatCard(
          icon: Icons.casino,
          title: '总抽奖次数',
          value: '${_statsManager.totalSpins}',
          color: Colors.blue,
        ),
      ),
      const SizedBox(width: 12),
      Expanded(
        child: _buildStatCard(
          icon: Icons.emoji_events,
          title: '中奖率',
          value: '${_getWinRate().toStringAsFixed(1)}%',
          color: Colors.amber,
        ),
      ),
    ],
  );
}

中奖率的计算

中奖率的计算排除"谢谢参与":

double _getWinRate() {
  if (_prizeManager.records.isEmpty) return 0;
  final wins = _prizeManager.records.where((r) => !r.prize.name.contains('谢谢参与')).length;
  return wins / _prizeManager.records.length * 100;
}

where 方法过滤出真正中奖的记录,然后计算比例。注意要处理空列表的情况,避免除以零。

数字格式化

toStringAsFixed(1) 保留一位小数:

75.333.toStringAsFixed(1)  // "75.3"
100.0.toStringAsFixed(1)   // "100.0"

这样中奖率显示为 “75.3%” 而不是 “75.33333…%”。

统计卡片样式

单个卡片的样式设计:

Widget _buildStatCard({
  required IconData icon,
  required String title,
  required String value,
  required Color color,
}) {
  return Container(
    padding: const EdgeInsets.all(16),
    decoration: BoxDecoration(
      gradient: LinearGradient(
        begin: Alignment.topLeft,
        end: Alignment.bottomRight,
        colors: [color, color.withOpacity(0.7)],
      ),
      borderRadius: BorderRadius.circular(16),
      boxShadow: [
        BoxShadow(
          color: color.withOpacity(0.3),
          blurRadius: 8,
          offset: const Offset(0, 4),
        ),
      ],
    ),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Icon(icon, color: Colors.white, size: 28),
        const SizedBox(height: 12),
        Text(
          value,
          style: const TextStyle(
            color: Colors.white,
            fontSize: 28,
            fontWeight: FontWeight.bold,
          ),
        ),
        const SizedBox(height: 4),
        Text(
          title,
          style: TextStyle(
            color: Colors.white.withOpacity(0.9),
            fontSize: 14,
          ),
        ),
      ],
    ),
  );
}

渐变背景配合彩色阴影,让卡片有立体感和品质感。

卡片设计原则

好的数据卡片应该遵循以下原则:

  1. 数字突出:数值用大字号、粗体,一眼就能看到
  2. 标签次要:标签用小字号、浅色,不抢夺注意力
  3. 图标辅助:图标帮助用户快速理解数据含义
  4. 颜色区分:不同卡片用不同颜色,便于区分

连续抽奖卡片

展示当前连续天数和最佳记录:

Widget _buildStreakCard() {
  return Container(
    padding: const EdgeInsets.all(20),
    decoration: BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.circular(16),
      boxShadow: [
        BoxShadow(
          color: Colors.grey.withOpacity(0.1),
          blurRadius: 10,
          offset: const Offset(0, 4),
        ),
      ],
    ),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          children: [
            Container(
              padding: const EdgeInsets.all(10),
              decoration: BoxDecoration(
                color: Colors.orange.shade50,
                borderRadius: BorderRadius.circular(12),
              ),
              child: Icon(Icons.local_fire_department, color: Colors.orange.shade600, size: 24),
            ),
            const SizedBox(width: 12),
            const Text(
              '连续抽奖',
              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
            ),
          ],
        ),
        const SizedBox(height: 20),
        Row(
          children: [
            Expanded(
              child: _buildStreakItem(
                label: '当前连续',
                value: '${_statsManager.consecutiveDays}',
                unit: '天',
                color: Colors.orange,
              ),
            ),
            Container(width: 1, height: 50, color: Colors.grey.shade200),
            Expanded(
              child: _buildStreakItem(
                label: '最佳记录',
                value: '${_statsManager.bestStreak}',
                unit: '天',
                color: Colors.red,
              ),
            ),
          ],
        ),
        const SizedBox(height: 16),
        _buildStreakProgress(),
      ],
    ),
  );
}

分隔线的设计

两个数据之间用一条细线分隔:

Container(width: 1, height: 50, color: Colors.grey.shade200),

这种设计比用 Divider 更灵活,可以精确控制高度和颜色。

连续天数项

单个连续天数的展示:

Widget _buildStreakItem({
  required String label,
  required String value,
  required String unit,
  required Color color,
}) {
  return Column(
    children: [
      Row(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.end,
        children: [
          Text(
            value,
            style: TextStyle(
              fontSize: 32,
              fontWeight: FontWeight.bold,
              color: color,
            ),
          ),
          Padding(
            padding: const EdgeInsets.only(bottom: 4),
            child: Text(
              unit,
              style: TextStyle(fontSize: 14, color: color),
            ),
          ),
        ],
      ),
      const SizedBox(height: 4),
      Text(label, style: TextStyle(color: Colors.grey.shade600, fontSize: 12)),
    ],
  );
}

数字和单位用 crossAxisAlignment: CrossAxisAlignment.end 底部对齐,单位稍微上移一点(bottom: 4),视觉上更协调。

里程碑进度

用圆形图标展示连续天数的里程碑:

Widget _buildStreakProgress() {
  const milestones = [3, 7, 14, 30];
  final currentStreak = _statsManager.consecutiveDays;
  
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Text('里程碑', style: TextStyle(color: Colors.grey.shade600, fontSize: 12)),
      const SizedBox(height: 8),
      Row(
        children: milestones.map((milestone) {
          final achieved = currentStreak >= milestone;
          return Expanded(
            child: Column(
              children: [
                Container(
                  width: 36,
                  height: 36,
                  decoration: BoxDecoration(
                    color: achieved ? Colors.orange : Colors.grey.shade200,
                    shape: BoxShape.circle,
                  ),
                  child: Center(
                    child: achieved
                        ? const Icon(Icons.check, color: Colors.white, size: 20)
                        : Text(
                            '$milestone',
                            style: TextStyle(
                              color: Colors.grey.shade500,
                              fontWeight: FontWeight.bold,
                            ),
                          ),
                  ),
                ),
                const SizedBox(height: 4),
                Text(
                  '$milestone天',
                  style: TextStyle(
                    fontSize: 10,
                    color: achieved ? Colors.orange : Colors.grey.shade500,
                  ),
                ),
              ],
            ),
          );
        }).toList(),
      ),
    ],
  );
}

达成的里程碑显示橙色打勾,未达成的显示灰色数字。

里程碑的游戏化设计

里程碑是一种常见的游戏化设计手法:

  • 3 天:入门级,容易达成,给用户信心
  • 7 天:一周,有一定挑战性
  • 14 天:两周,需要一定毅力
  • 30 天:一个月,高级成就

这种递进式的目标设计能持续激励用户。

map 方法的使用

milestones.map((milestone) {
  // 返回一个 Widget
}).toList()

map 方法将列表中的每个元素转换为 Widget,最后用 toList() 转回列表。这是 Flutter 中常用的列表渲染方式。

中奖统计

统计每种奖品的中奖次数:

Map<String, int> _getPrizeStats() {
  final stats = <String, int>{};
  for (final record in _prizeManager.records) {
    stats[record.prize.name] = (stats[record.prize.name] ?? 0) + 1;
  }
  return stats;
}

遍历所有记录,按奖品名称分组计数。?? 0 处理首次出现的情况。

更简洁的写法

也可以用 fold 方法实现:

Map<String, int> _getPrizeStats() {
  return _prizeManager.records.fold<Map<String, int>>(
    {},
    (stats, record) {
      stats[record.prize.name] = (stats[record.prize.name] ?? 0) + 1;
      return stats;
    },
  );
}

fold 是函数式编程中的"归约"操作,将列表归约为单个值(这里是 Map)。

中奖统计区域

按次数排序后展示:

Widget _buildPrizeStatsSection(List<MapEntry<String, int>> sortedPrizes) {
  return Container(
    padding: const EdgeInsets.all(20),
    decoration: BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.circular(16),
      boxShadow: [
        BoxShadow(
          color: Colors.grey.withOpacity(0.1),
          blurRadius: 10,
          offset: const Offset(0, 4),
        ),
      ],
    ),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          children: [
            Container(
              padding: const EdgeInsets.all(10),
              decoration: BoxDecoration(
                color: Colors.purple.shade50,
                borderRadius: BorderRadius.circular(12),
              ),
              child: Icon(Icons.bar_chart, color: Colors.purple.shade600, size: 24),
            ),
            const SizedBox(width: 12),
            const Text(
              '中奖统计',
              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
            ),
            const Spacer(),
            Text(
              '共 ${_prizeManager.records.length} 次',
              style: TextStyle(color: Colors.grey.shade600, fontSize: 14),
            ),
          ],
        ),
        const SizedBox(height: 16),
        if (sortedPrizes.isEmpty)
          _buildEmptyState()
        else
          ...sortedPrizes.map((entry) => _buildPrizeStatItem(entry.key, entry.value)),
      ],
    ),
  );
}

右上角显示总次数,方便用户了解整体情况。

奖品统计项

每个奖品用进度条展示占比:

Widget _buildPrizeStatItem(String prizeName, int count) {
  final total = _prizeManager.records.length;
  final percentage = total > 0 ? count / total : 0.0;
  
  Color color = Colors.grey;
  for (final prize in _prizeManager.prizes) {
    if (prize.name == prizeName) {
      color = prize.color;
      break;
    }
  }

  return Padding(
    padding: const EdgeInsets.only(bottom: 12),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Text(prizeName, style: const TextStyle(fontWeight: FontWeight.w500)),
            Text(
              '$count 次 (${(percentage * 100).toStringAsFixed(1)}%)',
              style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
            ),
          ],
        ),
        const SizedBox(height: 6),
        ClipRRect(
          borderRadius: BorderRadius.circular(4),
          child: LinearProgressIndicator(
            value: percentage,
            backgroundColor: Colors.grey.shade200,
            valueColor: AlwaysStoppedAnimation<Color>(color),
            minHeight: 8,
          ),
        ),
      ],
    ),
  );
}

进度条颜色跟奖品颜色一致,视觉上更直观。

LinearProgressIndicator 的使用

LinearProgressIndicator 是 Flutter 内置的进度条组件:

LinearProgressIndicator(
  value: 0.75,                    // 进度值,0.0 到 1.0
  backgroundColor: Colors.grey,   // 背景色
  valueColor: AlwaysStoppedAnimation<Color>(Colors.blue),  // 进度条颜色
  minHeight: 8,                   // 高度
)

ClipRRect 用来给进度条添加圆角,让它看起来更柔和。

监听数据变化

initState 中注册监听:


void initState() {
  super.initState();
  _statsManager.addListener(_onDataChanged);
  _prizeManager.addListener(_onDataChanged);
}

void _onDataChanged() {
  if (mounted) setState(() {});
}


void dispose() {
  _statsManager.removeListener(_onDataChanged);
  _prizeManager.removeListener(_onDataChanged);
  super.dispose();
}

用户抽奖后,统计页面会自动更新。

mounted 检查的重要性

if (mounted) setState(() {});

mounted 检查确保 Widget 还在树中。如果用户快速切换页面,可能在回调触发时 Widget 已经被销毁,直接调用 setState 会报错。

记得移除监听器

dispose 中移除监听器非常重要:


void dispose() {
  _statsManager.removeListener(_onDataChanged);
  _prizeManager.removeListener(_onDataChanged);
  super.dispose();
}

如果不移除,会导致:

  1. 内存泄漏:Widget 无法被垃圾回收
  2. 报错:尝试更新已销毁的 Widget

在抽奖时记录统计

WheelPage_spin 方法中调用统计记录:

void _spin() {
  if (_isSpinning) return;
  
  // 检查抽奖次数
  if (!_lotteryManager.canSpin) {
    _showNoChanceDialog();
    return;
  }
  
  // 消耗一次抽奖机会
  _lotteryManager.useChance();
  // 记录统计
  _statsManager.recordSpin();
  
  // ... 开始转盘动画
}

每次抽奖都会触发统计记录,数据实时更新。

扩展:添加更多统计维度

可以考虑添加更多有趣的统计:

1. 时间维度统计

// 今日抽奖次数
int get todaySpins {
  final today = DateTime.now();
  return _records.where((r) => 
    r.time.year == today.year && 
    r.time.month == today.month && 
    r.time.day == today.day
  ).length;
}

// 本周抽奖次数
int get weekSpins {
  final now = DateTime.now();
  final weekStart = now.subtract(Duration(days: now.weekday - 1));
  return _records.where((r) => r.time.isAfter(weekStart)).length;
}

2. 幸运指数

// 根据中奖率计算幸运指数
String get luckyLevel {
  final rate = _getWinRate();
  if (rate >= 80) return '🌟 超级幸运';
  if (rate >= 60) return '✨ 非常幸运';
  if (rate >= 40) return '🍀 比较幸运';
  if (rate >= 20) return '😊 一般般';
  return '💪 继续加油';
}

3. 最常中的奖品

String? get mostWonPrize {
  final stats = _getPrizeStats();
  if (stats.isEmpty) return null;
  return stats.entries.reduce((a, b) => a.value > b.value ? a : b).key;
}

小结

这篇文章我们实现了数据统计功能,涵盖了以下知识点:

  • 单例模式的应用
  • ChangeNotifier 状态管理
  • 日期比较和连续天数计算
  • 级联操作符的使用
  • 数据卡片的设计原则
  • 里程碑的游戏化设计
  • LinearProgressIndicator 进度条
  • 监听器的注册和移除
  • mounted 检查的重要性

数据统计页面让用户能看到自己的抽奖历史、中奖率、连续天数等数据,增加了应用的可玩性和用户粘性。下一篇是这个系列的最后一篇,我们来做个总结,聊聊可以扩展的方向。


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

Logo

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

更多推荐