在这里插入图片描述

免费抽奖不能无限次,不然就没意思了。这一篇我们来实现抽奖次数的管理:每天有固定次数,用完了要等恢复,也可以看广告换次数。

设计次数规则

先定好规则:

  • 每天最多抽5次
  • 总次数上限10次(可以攒着)
  • 用完后每30分钟恢复1次
  • 看广告可以额外获得1次

这些规则在LotteryManager中用常量定义:

class LotteryManager extends ChangeNotifier {
  static const int maxDailyChances = 5;
  static const int maxTotalChances = 10;
  static const Duration recoveryInterval = Duration(minutes: 30);
  
  int _totalChances = 5;
  int _dailyUsed = 0;
  DateTime _lastRecoveryTime = DateTime.now();
  DateTime _lastResetDate = DateTime.now();
  Timer? _recoveryTimer;
}

_totalChances是当前可用次数,_dailyUsed是今天已经用了多少次。两个时间字段分别记录上次恢复时间和上次重置日期。

单例模式

次数管理器全局只需要一个实例,用单例模式实现:

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

这样在任何地方调用LotteryManager()都会拿到同一个实例,数据不会乱。

判断能否抽奖

提供一个getter让外部判断当前能不能抽奖:

int get dailyRemaining => maxDailyChances - _dailyUsed;
bool get canSpin => _totalChances > 0 && dailyRemaining > 0;

两个条件都要满足:总次数大于0,今天剩余次数也大于0。

消耗次数

用户点击抽奖时,调用useChance方法:

bool useChance() {
  _checkDailyReset();
  if (!canSpin) return false;
  
  _totalChances--;
  _dailyUsed++;
  _saveState();
  notifyListeners();
  return true;
}

先检查是否需要重置每日次数,然后判断能不能抽,能抽就扣除次数并保存状态。返回值告诉调用方是否成功。

每日重置

每天凌晨,今日已用次数要清零:

void _checkDailyReset() {
  final now = DateTime.now();
  final today = DateTime(now.year, now.month, now.day);
  final lastReset = DateTime(_lastResetDate.year, _lastResetDate.month, _lastResetDate.day);
  
  if (today.isAfter(lastReset)) {
    _dailyUsed = 0;
    _lastResetDate = now;
    _saveState();
    notifyListeners();
  }
}

比较的是日期而不是时间戳,所以用DateTime(year, month, day)只保留年月日。如果今天比上次重置日期晚,就执行重置。

自动恢复次数

次数用完后,每30分钟恢复1次。用定时器实现:

void _startRecoveryTimer() {
  _recoveryTimer?.cancel();
  _recoveryTimer = Timer.periodic(const Duration(seconds: 1), (_) {
    _checkDailyReset();
    _tryRecover();
    if (_totalChances < maxTotalChances) {
      notifyListeners();
    }
  });
}

每秒检查一次,如果到了恢复时间就增加次数。为什么每秒都检查?因为UI上要显示倒计时,需要实时更新。

恢复逻辑在_tryRecover方法中:

void _tryRecover() {
  if (_totalChances >= maxTotalChances) return;
  
  final now = DateTime.now();
  final elapsed = now.difference(_lastRecoveryTime);
  
  if (elapsed >= recoveryInterval) {
    final recoveryCount = elapsed.inMinutes ~/ recoveryInterval.inMinutes;
    _totalChances = (_totalChances + recoveryCount).clamp(0, maxTotalChances);
    _lastRecoveryTime = now;
    _saveState();
    notifyListeners();
  }
}

如果已经满了就不恢复。计算距离上次恢复过了多久,如果超过30分钟就恢复相应的次数。clamp确保不会超过上限。

计算恢复倒计时

UI上需要显示距离下次恢复还有多久:

Duration get timeUntilNextRecovery {
  if (_totalChances >= maxTotalChances) return Duration.zero;
  final nextRecovery = _lastRecoveryTime.add(recoveryInterval);
  final remaining = nextRecovery.difference(DateTime.now());
  return remaining.isNegative ? Duration.zero : remaining;
}

如果已经满了返回0,否则计算下次恢复时间减去当前时间。

看广告获得次数

提供一个方法让用户通过看广告获得额外次数:

void watchAdForChance() {
  addChances(1);
}

void addChances(int count) {
  _totalChances = (_totalChances + count).clamp(0, maxTotalChances);
  _saveState();
  notifyListeners();
}

实际项目中,watchAdForChance应该先调用广告SDK,确认用户看完了再加次数。这里简化处理,直接加。

在UI中显示次数

在转盘页面顶部显示当前次数状态:

Widget _buildChanceIndicator() {
  final remaining = _lotteryManager.timeUntilNextRecovery;
  final minutes = remaining.inMinutes;
  final seconds = remaining.inSeconds % 60;
  final isFull = _lotteryManager.totalChances >= LotteryManager.maxTotalChances;
  
  return Container(
    padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
    decoration: BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.circular(20),
      boxShadow: [
        BoxShadow(color: Colors.black.withOpacity(0.1), blurRadius: 8, offset: const Offset(0, 2)),
      ],
    ),
    child: Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        // 总次数
        Container(
          padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
          decoration: BoxDecoration(
            color: Colors.amber.shade100,
            borderRadius: BorderRadius.circular(12),
          ),
          child: Row(
            children: [
              const Icon(Icons.confirmation_number, color: Colors.amber, size: 18),
              const SizedBox(width: 4),
              Text(
                '${_lotteryManager.totalChances}/${LotteryManager.maxTotalChances}',
                style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.amber),
              ),
            ],
          ),
        ),
        const SizedBox(width: 12),
        // 今日剩余
        Container(
          padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
          decoration: BoxDecoration(
            color: Colors.blue.shade50,
            borderRadius: BorderRadius.circular(12),
          ),
          child: Row(
            children: [
              const Icon(Icons.today, color: Colors.blue, size: 18),
              const SizedBox(width: 4),
              Text(
                '今日 ${_lotteryManager.dailyRemaining}/${LotteryManager.maxDailyChances}',
                style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blue.shade700),
              ),
            ],
          ),
        ),
        // 恢复倒计时
        if (!isFull && remaining > Duration.zero) ...[
          const SizedBox(width: 12),
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
            decoration: BoxDecoration(
              color: Colors.green.shade50,
              borderRadius: BorderRadius.circular(12),
            ),
            child: Row(
              children: [
                Icon(Icons.timer, color: Colors.green.shade600, size: 18),
                const SizedBox(width: 4),
                Text(
                  '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}',
                  style: TextStyle(fontWeight: FontWeight.bold, color: Colors.green.shade700),
                ),
              ],
            ),
          ),
        ],
      ],
    ),
  );
}

三个小标签分别显示:总次数、今日剩余、恢复倒计时。倒计时只在次数未满时显示。

次数不足提示

用户点击抽奖但次数不足时,弹出提示:

void _showNoChanceDialog() {
  final remaining = _lotteryManager.timeUntilNextRecovery;
  final minutes = remaining.inMinutes;
  final seconds = remaining.inSeconds % 60;
  
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
      title: const Row(
        children: [
          Icon(Icons.hourglass_empty, color: Colors.orange),
          SizedBox(width: 8),
          Text('次数不足'),
        ],
      ),
      content: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          if (_lotteryManager.dailyRemaining <= 0)
            const Text('今日抽奖次数已用完,明天再来吧!')
          else ...[
            Text('当前剩余 ${_lotteryManager.totalChances} 次抽奖机会'),
            const SizedBox(height: 8),
            if (remaining > Duration.zero)
              Text('下次恢复: ${minutes}${seconds}秒后', style: TextStyle(color: Colors.grey.shade600)),
          ],
        ],
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('知道了'),
        ),
        ElevatedButton.icon(
          onPressed: () {
            Navigator.pop(context);
            _watchAdForChance();
          },
          icon: const Icon(Icons.play_circle_outline, size: 18),
          label: const Text('看广告得次数'),
        ),
      ],
    ),
  );
}

弹窗里区分两种情况:今日次数用完和总次数用完。同时提供"看广告"的选项,给用户一个获取次数的途径。

模拟看广告

看广告的流程简化为一个2秒的加载动画:

void _watchAdForChance() {
  showDialog(
    context: context,
    barrierDismissible: false,
    builder: (context) => const AlertDialog(
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          CircularProgressIndicator(),
          SizedBox(height: 16),
          Text('广告加载中...'),
        ],
      ),
    ),
  );
  
  Future.delayed(const Duration(seconds: 2), () {
    Navigator.pop(context);
    _lotteryManager.watchAdForChance();
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('🎉 获得1次抽奖机会!'), backgroundColor: Colors.green),
    );
  });
}

实际项目中要接入真正的广告SDK,这里只是演示流程。

监听数据变化

initState中注册监听,数据变化时刷新UI:


void initState() {
  super.initState();
  _lotteryManager.addListener(_onManagerChanged);
}

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


void dispose() {
  _lotteryManager.removeListener(_onManagerChanged);
  super.dispose();
}

mounted检查是为了避免组件已销毁时调用setState导致报错。


抽奖次数管理就完成了。用户每天有固定次数,用完了可以等恢复或者看广告。下一篇我们来实现数据持久化,让这些数据在应用重启后还能保留。


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

Logo

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

更多推荐