【Flutter x HarmonyOS 6】挑战详情页面的逻辑实现

【Flutter x HarmonyOS 6】挑战详情页面的逻辑实现

上一篇我们聊了挑战详情页面的 UI 设计,这篇深入看看挑战详情页面的逻辑实现

挑战详情页面涉及三个子页面的业务逻辑:挑战详情页、挑战计时页、挑战尝试详情页。这篇我们从数据模型到交互逻辑逐一拆解。

挑战详情页面
挑战详情页面空状态


一、ChallengeAttempt 数据模型

ChallengeAttempt 是挑战尝试的核心数据模型:

class ChallengeAttempt {
  const ChallengeAttempt({
    required this.id,
    required this.challengeId,
    required this.windowSize,
    required this.targetDuration,
    required this.startedAt,
    required this.solves,
    this.finishedAt,
    this.averageStatus,
    this.averageDuration,
    this.succeeded,
  });

  final String id;
  final String challengeId;
  final int windowSize;
  final Duration targetDuration;
  final DateTime startedAt;
  final DateTime? finishedAt;

  final ChallengeAverageStatus? averageStatus;
  final Duration? averageDuration;
  final bool? succeeded;

  final List<ChallengeSolve> solves;

  bool get isFinished => finishedAt != null;
}

关键字段分为两组:

  • 必填(创建时确定):id、challengeId、windowSize、targetDuration、startedAt、solves。
  • 选填(完成后填充):finishedAt、averageStatus、averageDuration、succeeded。

isFinished 通过 finishedAt != null 判断,简洁高效。

1.1 ChallengeSolve

class ChallengeSolve {
  const ChallengeSolve({
    required this.rawDuration,
    required this.scramble,
    required this.recordedAt,
    this.penalty = SolvePenalty.none,
    this.note = '',
    this.linkedSolveHiveKey,
  });

  final Duration rawDuration;
  final String scramble;
  final DateTime recordedAt;
  final SolvePenalty penalty;
  final String note;
  final int? linkedSolveHiveKey;

  Duration? get effectiveDuration {
    if (isDNF) return null;
    if (penalty == SolvePenalty.plusTwo) return rawDuration + const Duration(seconds: 2);
    return rawDuration;
  }
}

linkedSolveHiveKey 是一个重要的关联字段:如果挑战配置了"计入记录/统计",每把成绩会同时写入 SolvesController,这里保存对应 SolveEntry 的 Hive 主键。


二、挑战详情页面逻辑

2.1 数据获取

class ChallengeDetailPage extends StatelessWidget {
  
  Widget build(BuildContext context) {
    final controller = context.watch<ChallengesController>();
    final challenge = controller.findChallengeById(challengeId);
    if (challenge == null) {
      return const Scaffold(body: Center(child: Text('挑战不存在')));
    }

    final attempts = controller.attemptsForChallenge(challengeId);
    // ...
  }
}

使用 context.watch 监听控制器变化,任何数据变更(如添加成绩、放弃挑战)都会自动触发重建。

2.2 查找进行中的挑战

ChallengeAttempt? ongoingAttempt;
for (final attempt in attempts) {
  if (!attempt.isFinished) {
    ongoingAttempt = attempt;
    break;
  }
}

遍历所有尝试,找到第一个未完成的。用 for 循环而非 firstWhere,是因为找不到时需要返回 null 而非抛异常。

2.3 开始挑战

Future<void> _startChallenge(BuildContext context, Challenge challenge) async {
  final controller = context.read<ChallengesController>();
  final attempt = await controller.startAttempt(challenge);

  if (!context.mounted) return;

  await Navigator.of(context).push(
    MaterialPageRoute<void>(
      builder: (_) => ChallengeTimerPage(
        challengeId: challenge.id,
        attemptId: attempt.id,
      ),
    ),
  );
}

流程:

  1. 调用 controller.startAttempt() 创建新的尝试。
  2. 检查 context.mounted,防止异步操作后页面已销毁。
  3. 跳转到计时页面。

2.4 继续挑战

Future<void> _resumeAttempt(
  BuildContext context,
  Challenge challenge,
  ChallengeAttempt attempt,
) async {
  await Navigator.of(context).push(
    MaterialPageRoute<void>(
      builder: (_) => ChallengeTimerPage(
        challengeId: challenge.id,
        attemptId: attempt.id,
      ),
    ),
  );
}

继续挑战不需要创建新尝试,直接用已有的 attemptId 跳转。

2.5 放弃挑战

Future<void> _confirmAbandonAttempt(
  BuildContext context,
  String attemptId,
  Challenge challenge,
) async {
  final warning = challenge.includeInRecords && challenge.targetGroupId != null
      ? '\n\n注意:如果该挑战已将部分成绩计入分组/记录页,放弃本轮不会删除那些已写入的成绩。'
      : '';

  final confirmed = await showDialog<bool>(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('放弃本轮挑战?'),
      content: Text('本轮尚未完成,放弃后将不会保留这一轮挑战内记录。$warning'),
      actions: [
        TextButton(
          onPressed: () => Navigator.of(context).pop(false),
          child: const Text('取消'),
        ),
        FilledButton(
          onPressed: () => Navigator.of(context).pop(true),
          style: FilledButton.styleFrom(
            backgroundColor: Theme.of(context).colorScheme.error,
          ),
          child: const Text('放弃'),
        ),
      ],
    ),
  );
  if (confirmed != true || !context.mounted) return;
  await context.read<ChallengesController>().abandonAttempt(attemptId);
  if (context.mounted) {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('已放弃本轮挑战')),
    );
  }
}

放弃逻辑的细节:

  1. 条件警告:如果挑战配置了"计入记录/统计",额外提示已写入的成绩不会被删除。
  2. 危险按钮样式:放弃按钮使用 colorScheme.error 背景,视觉上强调这是不可逆操作。
  3. 双重 mounted 检查showDialogabandonAttempt 都是异步操作,每次回调前都要检查。

2.6 删除挑战

Future<void> _confirmDelete(BuildContext context, Challenge challenge) async {
  final warning = challenge.includeInRecords && challenge.targetGroupId != null
      ? '\n\n注意:该挑战已设置为"计入记录/统计"。删除挑战不会删除已写入分组/记录页的成绩,只会删除挑战本身与挑战历史。'
      : '';

  final confirmed = await showDialog<bool>(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('删除挑战'),
      content: Text('确定要删除该挑战及其所有挑战历史记录吗?该操作不可恢复。$warning'),
      actions: [
        TextButton(
          onPressed: () => Navigator.of(context).pop(false),
          child: const Text('取消'),
        ),
        FilledButton(
          onPressed: () => Navigator.of(context).pop(true),
          style: FilledButton.styleFrom(
            backgroundColor: Theme.of(context).colorScheme.error,
          ),
          child: const Text('删除'),
        ),
      ],
    ),
  );
  if (confirmed != true || !context.mounted) return;
  await context.read<ChallengesController>().deleteChallenge(challenge.id);
  if (context.mounted) {
    Navigator.of(context).pop();
  }
}

删除挑战后调用 Navigator.pop() 返回上一页(挑战列表页),因为当前详情页的数据已不存在。


三、ChallengesController 核心方法

3.1 开始尝试

Future<ChallengeAttempt> startAttempt(Challenge challenge) async {
  final attemptId = _repository.generateAttemptId();
  final attempt = ChallengeAttempt(
    id: attemptId,
    challengeId: challenge.id,
    windowSize: challenge.averageType.windowSize,
    targetDuration: challenge.targetDuration,
    startedAt: DateTime.now(),
    solves: const <ChallengeSolve>[],
  );
  await _repository.upsertAttempt(attempt);
  final current = _attemptsByChallenge[challenge.id] ?? <ChallengeAttempt>[];
  _attemptsByChallenge[challenge.id] = <ChallengeAttempt>[attempt, ...current];
  notifyListeners();
  return attempt;
}

新尝试插入列表头部,保证最新的在前面。

3.2 添加成绩

Future<ChallengeAttempt> addSolveToAttempt(
  String attemptId,
  ChallengeSolve solve,
) async {
  final current = _repository.findAttemptById(attemptId);
  if (current == null) {
    throw StateError('挑战尝试不存在');
  }
  if (current.isFinished) {
    return current;
  }

  final nextSolves = <ChallengeSolve>[...current.solves, solve];
  var next = current.copyWith(solves: nextSolves);

  if (nextSolves.length >= current.windowSize) {
    next = _finalizeAttempt(next);
  }

  await _repository.upsertAttempt(next);

  final list = _attemptsByChallenge[current.challengeId];
  if (list != null) {
    final index = list.indexWhere((a) => a.id == next.id);
    if (index != -1) {
      list[index] = next;
    }
  }
  notifyListeners();
  return next;
}

添加成绩的流程:

  1. 从 Repository 获取最新的尝试数据。
  2. 如果已完成,直接返回(防止重复添加)。
  3. 追加新成绩到 solves 列表。
  4. 关键判断:成绩数量 >= windowSize 时,调用 _finalizeAttempt 完成挑战。
  5. 持久化并更新内存缓存。

3.3 完成挑战

ChallengeAttempt _finalizeAttempt(ChallengeAttempt attempt) {
  final durations = attempt.solves
      .take(attempt.windowSize)
      .map((solve) => solve.effectiveDuration)
      .toList();

  final aggregate = computeAverageOfN(durations);

  if (aggregate.isDNF) {
    return attempt.copyWith(
      finishedAt: DateTime.now(),
      averageStatus: ChallengeAverageStatus.dnf,
      averageDuration: null,
      succeeded: false,
    );
  }

  if (!aggregate.hasValue) {
    return attempt.copyWith(
      finishedAt: DateTime.now(),
      averageStatus: ChallengeAverageStatus.dnf,
      averageDuration: null,
      succeeded: false,
    );
  }

  final avg = aggregate.duration!;
  final succeeded = avg <= attempt.targetDuration;

  return attempt.copyWith(
    finishedAt: DateTime.now(),
    averageStatus: ChallengeAverageStatus.valid,
    averageDuration: avg,
    succeeded: succeeded,
  );
}

完成挑战的核心逻辑:

  1. 取前 windowSize 个成绩的有效时间。
  2. 调用 computeAverageOfN 计算 WCA 标准平均。
  3. DNF 或无有效值 → 标记为失败。
  4. 有效值 → 与目标时间比较,avg <= targetDuration 即为成功。

3.4 更新成绩

Future<ChallengeAttempt> updateSolveInAttempt({
  required String attemptId,
  required int solveIndex,
  SolvePenalty? penalty,
  String? note,
}) async {
  final current = _repository.findAttemptById(attemptId);
  if (current == null) {
    throw StateError('挑战尝试不存在');
  }
  if (solveIndex < 0 || solveIndex >= current.solves.length) {
    throw StateError('挑战成绩不存在');
  }

  final solves = List<ChallengeSolve>.from(current.solves);
  final original = solves[solveIndex];
  solves[solveIndex] = original.copyWith(
    penalty: penalty ?? original.penalty,
    note: note ?? original.note,
  );

  var next = current.copyWith(solves: solves);
  if (next.isFinished && solves.length >= next.windowSize) {
    next = _finalizeAttempt(next);
  }

  await _repository.upsertAttempt(next);
  // 更新缓存...
  notifyListeners();
  return next;
}

更新成绩后重新计算平均——修改罚时可能影响最终结果。例如把一个 +2 改为 DNF,可能导致平均从有效变为 DNF。


四、挑战计时页面逻辑

4.1 计时状态机

计时页面使用 TimerController 管理状态,有四种阶段:

idle → holding → inspection → running → idle
                  ↓                      ↓
              (无观察时)              (完成)
                  ↓
              running → idle
  • idle:空闲,显示信息卡片。
  • holding:长按准备中。
  • inspection:15 秒观察倒计时。
  • running:计时中。

4.2 长按手势

void _handleLongPressStart(LongPressStartDetails details) {
  final controller = _timerController;
  if (controller == null) return;
  final phase = controller.state.phase;
  if (phase != TimerPhase.idle && phase != TimerPhase.holding) return;
  controller.startHold();
}

void _handleLongPressEnd(LongPressEndDetails details) {
  final controller = _timerController;
  if (controller == null) return;
  if (!controller.isHolding) return;
  if (_inspectionEnabled) {
    _startInspectionCountdown();
  } else {
    _startTiming();
  }
}

void _handleLongPressCancel() {
  final controller = _timerController;
  if (controller == null) return;
  if (controller.isInspection) return;
  controller.cancelHold();
}

手势逻辑:

  • 长按开始 → 进入 holding 状态。
  • 长按结束 → 根据是否启用观察,进入观察或直接开始计时。
  • 长按取消 → 回到 idle(但观察中不取消)。

4.3 键盘支持

KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
  final focused = FocusManager.instance.primaryFocus;
  final focusedWidget = focused?.context?.widget;
  if (focusedWidget is EditableText) {
    return KeyEventResult.ignored;
  }

  if (event.logicalKey != LogicalKeyboardKey.space) {
    return KeyEventResult.ignored;
  }

  if (event is KeyDownEvent) {
    if (_spacePressed) return KeyEventResult.handled;
    _spacePressed = true;
    _handleSpaceDown();
    return KeyEventResult.handled;
  }

  if (event is KeyUpEvent) {
    _spacePressed = false;
    _handleSpaceUp();
    return KeyEventResult.handled;
  }

  return KeyEventResult.handled;
}

鸿蒙 2in1 设备和桌面端支持空格键操控计时器:

  • 空格按下 = 长按开始。
  • 空格松开 = 长按结束。
  • 计时中按空格 = 停止。
  • 输入框内不打断(EditableText 检测)。

4.4 观察倒计时

void _startInspectionCountdown() {
  final controller = _timerController;
  if (controller == null || controller.isRunning || !controller.isHolding) return;
  _inspectionTimer?.cancel();
  _inspectionPenaltyNotified = false;
  _inspectionStart = DateTime.now();
  controller.startInspection(_inspectionLimit);
  _inspectionTimer = Timer.periodic(
    const Duration(milliseconds: 100),
    _handleInspectionTick,
  );
}

void _handleInspectionTick(Timer timer) {
  final controller = _timerController;
  final start = _inspectionStart;
  if (controller == null || start == null || !controller.isInspection) {
    timer.cancel();
    return;
  }
  final elapsed = DateTime.now().difference(start);
  final remaining = _inspectionLimit - elapsed;
  controller.updateInspectionRemaining(remaining.isNegative ? Duration.zero : remaining);

  if (elapsed > _inspectionPlusTwoLimit) {
    _applyInspectionPenalty(SolvePenalty.dnf);
  } else if (elapsed > _inspectionLimit) {
    _applyInspectionPenalty(SolvePenalty.plusTwo);
  }
}

观察逻辑:

  • 100ms 一次 tick,更新剩余时间。
  • 超过 15 秒自动 +2。
  • 超过 17 秒自动 DNF。
  • _inspectionPenaltyNotified 防止重复弹 SnackBar。

4.5 停止计时与成绩录入

Future<void> _handleTapStop() async {
  final controller = _timerController;
  if (controller == null || !controller.isRunning) return;
  _ticker.stop();
  _stopwatch.stop();
  final result = _stopwatch.elapsed;
  _stopwatch.reset();

  final challengeController = context.read<ChallengesController>();
  final challenge = challengeController.findChallengeById(widget.challengeId);
  if (challenge == null) {
    controller.completeSolve();
    controller.refreshScramble();
    return;
  }

  final inspectionPenalty = controller.state.inspectionPenalty;
  SolvePenalty penalty = inspectionPenalty ?? SolvePenalty.none;
  final scramble = controller.state.scramble;

  controller.completeSolve();

  // 如果没有自动判罚,让用户手动选择
  if (inspectionPenalty == null) {
    _spacePressed = false;
    final selected = await _showPenaltyDialog(result);
    _keyboardFocusNode.requestFocus();
    if (selected == null) {
      controller.refreshScramble();
      return;
    }
    penalty = selected;
  }

  if (!mounted) return;

  // 如果配置了"计入记录/统计",同步写入 SolvesController
  int? linkedKey;
  if (challenge.includeInRecords) {
    final targetGroupId = challenge.targetGroupId;
    if (targetGroupId != null) {
      final solvesController = context.read<SolvesController>();
      final stored = await solvesController.recordSolve(
        result, scramble, penalty,
        groupId: targetGroupId,
      );
      linkedKey = stored.hiveKey;
    }
  }

  final solve = ChallengeSolve(
    rawDuration: result,
    scramble: scramble,
    recordedAt: DateTime.now(),
    penalty: penalty,
    linkedSolveHiveKey: linkedKey,
  );

  final updatedAttempt = await challengeController.addSolveToAttempt(
    widget.attemptId, solve,
  );

  controller.refreshScramble();

  if (!mounted) return;

  // 挑战完成,显示结果弹窗
  if (updatedAttempt.isFinished) {
    await _showResultDialog(context, challenge, updatedAttempt);
    if (mounted) {
      Navigator.of(context).pop();
    }
  }
}

这是计时页面最核心的方法,流程:

  1. 停止计时:停止 Ticker 和 Stopwatch,获取原始时间。
  2. 确定罚时
    • 观察超时 → 自动判罚(+2 或 DNF)。
    • 无自动判罚 → 弹出罚时选择对话框。
  3. 同步记录(如果配置了"计入记录/统计"):
    • 写入 SolvesController
    • 保存 linkedSolveHiveKey 关联记录成绩。
  4. 添加成绩:调用 addSolveToAttempt,可能触发 _finalizeAttempt
  5. 完成处理:如果挑战完成,显示结果弹窗并返回详情页。

4.6 罚时选择对话框

class _PenaltyDialog extends StatelessWidget {
  
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final baseText = _formatDuration(baseDuration);
    final plusTwoText = _formatDuration(baseDuration + const Duration(seconds: 2));

    return AlertDialog(
      title: const Text('确认本次处罚'),
      content: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(baseText, style: theme.textTheme.headlineMedium?.copyWith(
            fontWeight: FontWeight.w700,
          )),
          const SizedBox(height: 12),
          Text('请选择本次成绩的处罚选项', style: theme.textTheme.bodyMedium),
          const SizedBox(height: 8),
          Text('+2 之后为 $plusTwoText', style: theme.textTheme.bodySmall?.copyWith(
            color: theme.colorScheme.primary,
          )),
        ],
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.of(context).pop(SolvePenalty.dnf),
          child: const Text('DNF'),
        ),
        TextButton(
          onPressed: () => Navigator.of(context).pop(SolvePenalty.plusTwo),
          child: const Text('+2'),
        ),
        FilledButton(
          onPressed: () => Navigator.of(context).pop(SolvePenalty.none),
          child: const Text('无惩罚'),
        ),
      ],
    );
  }
}

对话框的设计:

  • barrierDismissible: false,强制用户做出选择。
  • 显示原始时间和 +2 后的时间,帮助用户决策。
  • "无惩罚"用 FilledButton 突出,因为这是最常见的选项。

4.7 退出确认

Future<bool> _confirmAbandon() async {
  final controller = context.read<TimerController>();
  if (controller.state.phase != TimerPhase.idle) {
    return false;
  }

  final attemptsController = context.read<ChallengesController>();
  final attempt = attemptsController.findAttemptById(widget.attemptId);
  if (attempt == null || attempt.isFinished) {
    return true;
  }

  final hasProgress = attempt.solves.isNotEmpty;
  if (!hasProgress) {
    await attemptsController.abandonAttempt(widget.attemptId);
    return true;
  }

  final decision = await showDialog<_ChallengeExitDecision>(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('退出本轮挑战?'),
      content: Text('你可以暂存后退出,稍后在挑战详情页继续;或直接放弃本轮(不保留)。$warning'),
      actions: [
        TextButton(
          onPressed: () => Navigator.of(context).pop(_ChallengeExitDecision.continueRun),
          child: const Text('继续'),
        ),
        TextButton(
          onPressed: () => Navigator.of(context).pop(_ChallengeExitDecision.saveAndExit),
          child: const Text('暂存退出'),
        ),
        FilledButton(
          onPressed: () => Navigator.of(context).pop(_ChallengeExitDecision.abandon),
          style: FilledButton.styleFrom(
            backgroundColor: Theme.of(context).colorScheme.error,
          ),
          child: const Text('放弃'),
        ),
      ],
    ),
  );

  if (decision == _ChallengeExitDecision.abandon) {
    await attemptsController.abandonAttempt(widget.attemptId);
    return true;
  }
  if (decision == _ChallengeExitDecision.saveAndExit) {
    return true;
  }
  return false;
}

退出确认的三种决策:

  • 继续:留在计时页面。
  • 暂存退出:保留进度,返回详情页,稍后可继续。
  • 放弃:删除本轮所有数据,返回详情页。

注意:如果已有成绩(hasProgress),才弹确认对话框;如果没有成绩,直接删除并退出。


五、挑战尝试详情页面逻辑

5.1 成绩详情查看

Future<void> _openSolveDetail(
  BuildContext context,
  ChallengeAttempt attempt,
  int index,
) async {
  final challengesController = context.read<ChallengesController>();
  final solve = attempt.solves[index];

  final linkedKey = solve.linkedSolveHiveKey;
  if (linkedKey != null) {
    // 成绩已同步到记录页,使用关联的 SolveEntry
    final solvesController = context.read<SolvesController>();
    final linked = solvesController.allEntries
        .cast<SolveEntry>()
        .firstWhere(
          (entry) => entry.hiveKey == linkedKey,
          orElse: () => SolveEntry(
            rawDuration: Duration.zero,
            scramble: '',
            recordedAt: DateTime.fromMillisecondsSinceEpoch(0),
          ),
        );

    if (linked.hiveKey == null) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('未找到对应的记录成绩')),
      );
      return;
    }

    await showSolveDetailSheet(
      context,
      entry: linked,
      delegate: CallbackSolveDetailDelegate(
        groups: const <SolveGroup>[],
        canEditEntry: (entry) => entry.isMutable,
        onUpdate: (entry, {penalty, note, groupId}) async {
          final updated = await solvesController.updateSolve(
            entry: entry,
            penalty: penalty,
            note: note,
          );
          // 同步更新挑战内的成绩
          await challengesController.updateSolveInAttempt(
            attemptId: attemptId,
            solveIndex: index,
            penalty: updated.penalty,
            note: updated.note,
          );
          return updated;
        },
        onDelete: (entry) async {
          // 挑战复盘页暂不支持删除
        },
      ),
      allowGroupChange: false,
      allowDelete: false,
    );
    return;
  }

  // 成绩仅在挑战内,构建临时 SolveEntry
  final entry = SolveEntry(
    rawDuration: solve.rawDuration,
    scramble: solve.scramble,
    recordedAt: solve.recordedAt,
    penalty: solve.penalty,
    note: solve.note,
  );

  await showSolveDetailSheet(
    context,
    entry: entry,
    delegate: CallbackSolveDetailDelegate(
      groups: const <SolveGroup>[],
      canEditEntry: (_) => true,
      onUpdate: (entry, {penalty, note, groupId}) async {
        final updatedAttempt = await challengesController.updateSolveInAttempt(
          attemptId: attemptId,
          solveIndex: index,
          penalty: penalty,
          note: note,
        );
        final updatedSolve = updatedAttempt.solves[index];
        return entry.copyWith(
          penalty: updatedSolve.penalty,
          note: updatedSolve.note,
        );
      },
      onDelete: (entry) async {
        // 挑战内成绩暂不支持删除
      },
    ),
    allowGroupChange: false,
    allowDelete: false,
  );
}

查看成绩详情时,根据 linkedSolveHiveKey 分为两种情况:

  1. 有关联记录linkedSolveHiveKey != null):

    • SolvesController 查找关联的 SolveEntry
    • 编辑时先更新 SolvesController,再同步更新挑战内的成绩。
    • 双向保持一致性。
  2. 无关联记录(仅挑战内保存):

    • ChallengeSolve 的数据构建临时 SolveEntry
    • 编辑时只更新挑战内的成绩。

两种情况下都禁用分组变更和删除,因为挑战成绩的结构是固定的。


六、WCA 标准平均计算

SolveAggregateValue computeAverageOfN(List<Duration?> durations) {
  if (durations.isEmpty) {
    return const SolveAggregateValue.notEnough();
  }

  final dnfs = durations.where((value) => value == null).length;
  if (dnfs >= 2) {
    return const SolveAggregateValue.dnf();
  }

  final working = List<Duration?>.from(durations);

  // 去掉最好成绩
  int? minIndex;
  Duration? currentMin;
  for (var i = 0; i < working.length; i++) {
    final value = working[i];
    if (value == null) continue;
    if (currentMin == null || value < currentMin) {
      currentMin = value;
      minIndex = i;
    }
  }
  if (minIndex != null) {
    working.removeAt(minIndex);
  }

  // 去掉最差成绩
  int? worstIndex;
  if (dnfs == 1) {
    // DNF 是最差的,优先去掉
    worstIndex = working.indexWhere((value) => value == null);
  } else {
    Duration? currentMax;
    int? currentIndex;
    for (var i = 0; i < working.length; i++) {
      final value = working[i];
      if (value == null) continue;
      if (currentMax == null || value > currentMax) {
        currentMax = value;
        currentIndex = i;
      }
    }
    worstIndex = currentIndex;
  }
  if (worstIndex != null && worstIndex != -1) {
    working.removeAt(worstIndex);
  }

  if (working.any((value) => value == null)) {
    return const SolveAggregateValue.dnf();
  }

  final trimmed = working.cast<Duration>();
  if (trimmed.isEmpty) {
    return const SolveAggregateValue.dnf();
  }

  final totalMs = trimmed.fold<int>(0, (sum, d) => sum + d.inMilliseconds);
  return SolveAggregateValue.valid(Duration(milliseconds: totalMs ~/ trimmed.length));
}

这个算法与记录页的滚动平均完全一致,复用同一套规则:

  1. DNF >= 2 → 整体 DNF。
  2. 去掉最好成绩。
  3. 去掉最差成绩(1 个 DNF 时优先去掉 DNF)。
  4. 剩余成绩求算术平均。

七、系统 UI 适配

计时页面在计时和空闲两种状态下,需要不同的系统 UI 配置:

WidgetsBinding.instance.addPostFrameCallback((_) {
  final brightness = Theme.of(context).brightness;
  final inTimer = timerState.phase != TimerPhase.idle;

  if (inTimer) {
    SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
    SystemChrome.setSystemUIOverlayStyle(
      const SystemUiOverlayStyle(
        systemNavigationBarColor: Colors.transparent,
        systemNavigationBarIconBrightness: Brightness.light,
      ),
    );
  } else {
    SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
    SystemChrome.setSystemUIOverlayStyle(
      SystemUiOverlayStyle(
        statusBarColor: Colors.transparent,
        statusBarIconBrightness:
            brightness == Brightness.dark ? Brightness.light : Brightness.dark,
        systemNavigationBarColor: colorScheme.surface,
        systemNavigationBarIconBrightness:
            brightness == Brightness.dark ? Brightness.light : Brightness.dark,
      ),
    );
  }

  context
      .read<NavigationVisibilityController>()
      .setVisible(timerState.phase == TimerPhase.idle);
});
  • 计时中:沉浸式全屏,隐藏状态栏和导航栏。
  • 空闲时:边到边显示,状态栏透明,导航栏跟随主题色。

八、总结

这篇我们深入梳理了挑战详情页面的逻辑实现:

  1. 数据模型ChallengeAttempt 通过 finishedAt 判断是否完成,ChallengeSolve 通过 linkedSolveHiveKey 关联记录成绩。
  2. 挑战详情页:开始/继续/放弃/删除挑战,条件警告已写入的成绩不受影响。
  3. ChallengesControlleraddSolveToAttempt 在成绩数达标时自动调用 _finalizeAttempt 完成挑战;updateSolveInAttempt 修改罚时后重新计算平均。
  4. 计时页面:四阶段状态机,长按手势 + 键盘空格双操控,观察 15s 自动判罚,完成后弹出结果并返回。
  5. 退出确认:三种决策(继续/暂存/放弃),无进度时直接退出。
  6. 成绩详情:根据 linkedSolveHiveKey 区分两种编辑路径,双向同步保持一致。
  7. WCA 平均算法:与记录页复用同一套 computeAverageOfN,保证计算口径统一。
  8. 系统 UI 适配:计时中沉浸式全屏,空闲时恢复边到边显示。

挑战详情页面的逻辑实现,核心是"成绩即触发"——每次成绩录入都可能触发挑战完成,每次罚时修改都可能改变成功/失败的结果。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值