欢迎光临
我们一直在努力

【Flutter x HarmonyOS 6】记录页面的逻辑实现

上次我们聊了记录页面的 UI 设计,这篇深入看看记录页面的逻辑实现。

记录页面是整个应用的核心数据层——成绩的录入、分组、统计、PB 追踪都在这里完成。这篇我们逐一拆解。


一、数据模型

1.1 SolveEntry:成绩条目

class SolveEntry {
const SolveEntry({
required this.rawDuration,
required this.scramble,
required this.recordedAt,
this.penalty = SolvePenalty.none,
this.note = '',
this.groupId = SolveGroup.defaultGroupId,
this.hiveKey,
});

final Duration rawDuration;
final String scramble;
final DateTime recordedAt;
final SolvePenalty penalty;
final String note;
final String groupId;
final int? hiveKey;
}

关键字段:

  • rawDuration:原始计时时间。
  • scramble:打乱公式。
  • penalty:罚时类型(无 / +2 / DNF)。
  • groupId:所属分组。
  • hiveKey:Hive 中的主键,用于更新和删除。

1.2 罚时计算

enum SolvePenalty {
none,
plusTwo,
dnf,
}

Duration? get effectiveDuration {
if (isDNF) {
return null;
}
if (hasPlusTwo) {
return rawDuration + const Duration(seconds: 2);
}
return rawDuration;
}

effectiveDuration 是统计计算的核心:

  • DNF 返回 null,表示无效成绩。
  • +2 在原始时间上加 2 秒。
  • 无罚时直接返回原始时间。

1.3 SolveGroup:成绩分组

class SolveGroup {
static const String defaultGroupId = 'group_default_333';
static const String defaultGroupName = '三阶速拧';
static const WcaEvent defaultEvent = WcaEvent.threeByThree;

final String id;
final String name;
final WcaEvent event;
final int colorValue;
final DateTime createdAt;
final String note;
}

每个分组绑定一个 WCA 项目,有自己的颜色和名称。默认分组是"三阶速拧"。

1.4 PersonalBestRecord:个人最佳记录

enum PBType {
single, // 单次最佳
ao5, // 5次平均最佳
ao12, // 12次平均最佳
ao100; // 100次平均最佳
}

class PersonalBestRecord {
final PBType type;
final Duration duration;
final DateTime achievedAt;
final Duration? previousBest;
final List<String> solveIds;
final String? scramble;

Duration? get improvement => previousBest != null ? previousBest! – duration : null;
bool get isNew => previousBest == null;
}

PB 记录保存了:

  • 当前最佳成绩。
  • 达成时间。
  • 上一次最佳(用于计算提升幅度)。
  • 关联的成绩 ID 列表。

二、SolvesController:核心控制器

SolvesController 是记录页面的核心,管理成绩和分组的所有业务逻辑:

class SolvesController extends ChangeNotifier {
SolvesController(this._repository, this._groupRepository, this._pbRepository);

final SolveRepository _repository;
final SolveGroupRepository _groupRepository;
final PersonalBestRepository _pbRepository;

final List<SolveEntry> _entries = <SolveEntry>[];
final Map<String, List<SolveEntry>> _entriesByGroup = <String, List<SolveEntry>>{};
final Map<String, SolveStatsSummary> _statsByGroup = <String, SolveStatsSummary>{};
final Map<String, int> _countsByGroup = <String, int>{};

List<SolveGroup> _groups = <SolveGroup>[];
bool _isInitialized = false;
String? _selectedGroupId;
}

内存缓存结构:

  • _entries:所有成绩的全量列表。
  • _entriesByGroup:按分组 ID 索引的成绩列表。
  • _statsByGroup:按分组 ID 索引的统计摘要。
  • _countsByGroup:按分组 ID 索引的成绩数量。

2.1 初始化流程

Future<void> initialize() async {
if (_isInitialized) return;

await _groupRepository.initialize();
await _repository.initialize();
await _pbRepository.initialize();

final storedGroups = _groupRepository.fetchAll();
if (storedGroups.isEmpty) {
final defaultGroup = await _groupRepository.ensureDefaultGroup();
_groups = <SolveGroup>[defaultGroup];
} else {
_groups = List<SolveGroup>.from(storedGroups);
}
_selectedGroupId = _groups.first.id;

_entries
..clear()
..addAll(_repository.fetchAllSolves());

await _migrateLegacyEntries();
_rebuildGroupCaches();

_isInitialized = true;
notifyListeners();
}

初始化步骤:

  • 初始化三个 Repository。
  • 加载分组,确保至少有一个默认分组。
  • 加载全量成绩。
  • 迁移旧数据(没有分组的成绩归入默认分组)。
  • 重建内存缓存。
  • 2.2 旧数据迁移

    Future<void> _migrateLegacyEntries() async {
    final knownIds = _groups.map((group) => group.id).toSet();
    final defaultGroup = await _groupRepository.ensureDefaultGroup();
    if (!knownIds.contains(defaultGroup.id)) {
    _groups.insert(0, defaultGroup);
    knownIds.add(defaultGroup.id);
    }

    bool mutated = false;
    for (var i = 0; i < _entries.length; i++) {
    final entry = _entries[i];
    if (!knownIds.contains(entry.groupId)) {
    final updated = entry.copyWith(groupId: defaultGroup.id);
    await _repository.updateSolve(updated);
    _entries[i] = updated;
    mutated = true;
    }
    }
    if (mutated) {
    debugPrint('迁移完成:已为旧成绩补齐分组信息');
    }
    }

    这个方法确保所有成绩都有有效的分组 ID,兼容从旧版本升级的用户。


    三、成绩录入

    3.1 记录新成绩

    Future<SolveEntry> recordSolve(
    Duration rawDuration,
    String scramble,
    SolvePenalty penalty, {
    String? groupId,
    }) async {
    final targetGroupId = groupId ?? selectedGroupId;
    final entry = SolveEntry(
    rawDuration: rawDuration,
    scramble: scramble,
    recordedAt: DateTime.now(),
    penalty: penalty,
    groupId: targetGroupId,
    );
    final storedEntry = await _repository.addSolve(entry);
    _entries.add(storedEntry);
    _rebuildGroupCaches();

    // 检查并更新 PB
    await _checkAndUpdatePBs(targetGroupId);

    notifyListeners();
    return storedEntry;
    }

    录入流程:

  • 创建 SolveEntry 对象。
  • 持久化到 Repository。
  • 加入内存列表。
  • 重建缓存(统计、分组索引等)。
  • 检查 PB(个人最佳)。
  • 通知 UI 刷新。
  • 3.2 更新成绩

    Future<SolveEntry> updateSolve({
    required SolveEntry entry,
    SolvePenalty? penalty,
    String? note,
    String? groupId,
    }) async {
    if (entry.hiveKey == null) {
    throw StateError('该成绩无法编辑');
    }

    final targetGroupId = groupId ?? entry.groupId;
    SolveGroup? targetGroup;
    if (groupId != null) {
    targetGroup = _groups.firstWhere(
    (candidate) => candidate.id == targetGroupId,
    orElse: () => throw StateError('目标分组不存在'),
    );
    if (targetGroupId != entry.groupId) {
    final sourceGroup = _groups.firstWhere(
    (candidate) => candidate.id == entry.groupId,
    orElse: () => targetGroup!,
    );
    if (sourceGroup.event != targetGroup.event) {
    throw StateError('只能移动到同一项目的分组');
    }
    }
    }

    final updated = entry.copyWith(
    penalty: penalty ?? entry.penalty,
    note: note ?? entry.note,
    groupId: targetGroupId,
    );
    await _repository.updateSolve(updated);
    _replaceEntry(updated);
    _rebuildGroupCaches();
    notifyListeners();
    return updated;
    }

    更新成绩时的校验:

    • 成绩必须有 hiveKey(可编辑)。
    • 移动分组时,目标分组必须存在。
    • 只能移动到同一项目的分组(不能把三阶成绩移到二阶分组)。

    3.3 删除成绩

    Future<void> deleteSolve(SolveEntry entry) async {
    if (entry.hiveKey == null) {
    throw StateError('该成绩无法删除');
    }
    _entries.removeWhere((current) => current.hiveKey == entry.hiveKey);
    await _repository.deleteSolve(entry);
    _rebuildGroupCaches();
    notifyListeners();
    }


    四、分组管理

    4.1 创建分组

    Future<SolveGroup> createGroup({
    required String name,
    required WcaEvent event,
    String note = '',
    int? colorValue,
    }) async {
    final group = await _groupRepository.createGroup(
    name: name,
    event: event,
    note: note,
    colorValue: colorValue,
    );
    _groups.add(group);
    _selectedGroupId = group.id;
    _rebuildGroupCaches();
    notifyListeners();
    return group;
    }

    创建后自动选中新分组。

    4.2 删除分组

    Future<void> deleteGroup(String groupId, {String? transferTargetGroupId}) async {
    if (groupId == SolveGroup.defaultGroupId) {
    throw StateError('默认分组无法删除');
    }
    if (!_groups.any((group) => group.id == groupId)) {
    return;
    }
    final fallbackId = transferTargetGroupId ?? SolveGroup.defaultGroupId;
    if (!_groups.any((group) => group.id == fallbackId)) {
    final fallback = await _groupRepository.ensureDefaultGroup();
    _groups.add(fallback);
    }

    // 将被删除分组的成绩转移到目标分组
    final affectedEntries = _entries.where((entry) => entry.groupId == groupId).toList();
    for (final entry in affectedEntries) {
    final updated = entry.copyWith(groupId: fallbackId);
    await _repository.updateSolve(updated);
    _replaceEntry(updated);
    }

    await _groupRepository.deleteGroup(groupId);
    _groups.removeWhere((group) => group.id == groupId);
    if (_selectedGroupId == groupId) {
    _selectedGroupId = fallbackId;
    }
    _rebuildGroupCaches();
    notifyListeners();
    }

    删除分组的逻辑:

  • 默认分组不可删除。
  • 被删除分组的成绩转移到目标分组(或默认分组)。
  • 如果当前选中的就是被删除的分组,自动切换到目标分组。

  • 五、统计计算

    5.1 统计摘要

    class SolveStatsSummary {
    const SolveStatsSummary({
    required this.best,
    required this.average,
    required this.ao5,
    required this.ao12,
    });

    final SolveAggregateValue best;
    final SolveAggregateValue average;
    final SolveAggregateValue ao5;
    final SolveAggregateValue ao12;
    }

    每个统计项使用 SolveAggregateValue 封装:

    enum SolveAggregateStatus { notEnoughData, valid, dnf }

    class SolveAggregateValue {
    final Duration? duration;
    final SolveAggregateStatus status;

    bool get isDNF => status == SolveAggregateStatus.dnf;
    bool get hasValue => status == SolveAggregateStatus.valid && duration != null;
    }

    三种状态:

    • notEnoughData:数据不足(如 ao5 需要至少 5 次成绩)。
    • valid:有效值。
    • dnf:DNF 过多导致整体无效。

    5.2 缓存重建

    void _rebuildGroupCaches() {
    _entriesByGroup..clear();
    for (final entry in _entries) {
    final groupEntries = _entriesByGroup.putIfAbsent(entry.groupId, () => <SolveEntry>[]);
    groupEntries.add(entry);
    }
    for (final groupEntries in _entriesByGroup.values) {
    groupEntries.sort((a, b) => a.recordedAt.compareTo(b.recordedAt));
    }

    _statsByGroup..clear();
    _countsByGroup..clear();
    for (final group in _groups) {
    final groupEntries = _entriesByGroup[group.id] ?? <SolveEntry>[];
    _countsByGroup[group.id] = groupEntries.length;
    _statsByGroup[group.id] = _recalculateStats(groupEntries);
    }
    }

    每次数据变更后,重建所有缓存:

  • 按分组 ID 重新索引成绩。
  • 每个分组内的成绩按时间排序。
  • 重新计算每个分组的统计摘要。
  • 5.3 滚动平均计算

    SolveAggregateValue _rollingAverage(List<SolveEntry> source, int windowSize) {
    if (source.length < windowSize) {
    return const SolveAggregateValue.notEnough();
    }

    final recentEntries = source.sublist(source.length – windowSize);
    final durations = recentEntries.map((entry) => entry.effectiveDuration).toList();
    final dnfs = durations.where((duration) => duration == null).length;

    // DNF >= 2 则整体 DNF
    if (dnfs >= 2) {
    return const SolveAggregateValue.dnf();
    }

    // 去掉最好成绩
    final bestIndex = _minDurationIndex(durations);
    if (bestIndex != null) {
    durations.removeAt(bestIndex);
    }

    // 去掉最差成绩(如果有 DNF,优先去掉 DNF)
    final worstIndex = dnfs == 1
    ? durations.indexWhere((duration) => duration == null)
    : _maxDurationIndex(durations);

    if (worstIndex != null && worstIndex != -1) {
    durations.removeAt(worstIndex);
    }

    if (durations.any((duration) => duration == null)) {
    return const SolveAggregateValue.dnf();
    }

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

    return SolveAggregateValue.valid(_averageDuration(trimmedDurations));
    }

    WCA 标准的滚动平均算法:

  • 取最近 N 次成绩。
  • DNF >= 2 次,整体 DNF。
  • 去掉最好和最差成绩。
  • 如果有 1 次 DNF,它就是最差成绩,被去掉。
  • 剩余成绩求平均。

  • 六、PB 追踪

    6.1 PB 检查时机

    每次记录新成绩后,自动检查 PB:

    Future<SolveEntry> recordSolve(…) async {
    // … 录入成绩 …
    await _checkAndUpdatePBs(targetGroupId);
    notifyListeners();
    return storedEntry;
    }

    6.2 PB 检查逻辑

    Future<List<PersonalBestRecord>> _checkAndUpdatePBs(String groupId) async {
    final group = _groups.firstWhere(
    (g) => g.id == groupId,
    orElse: () => selectedGroup,
    );
    final event = group.event;
    final eventEntries = entriesForEvent(event);

    if (eventEntries.isEmpty) return [];

    final newPBs = <PersonalBestRecord>[];

    // 检查 Single PB
    final singlePB = await _checkSinglePB(event, eventEntries);
    if (singlePB != null) newPBs.add(singlePB);

    // 检查 Ao5 PB
    if (eventEntries.length >= 5) {
    final ao5PB = await _checkAveragePB(event, eventEntries, PBType.ao5, 5);
    if (ao5PB != null) newPBs.add(ao5PB);
    }

    // 检查 Ao12 PB
    if (eventEntries.length >= 12) {
    final ao12PB = await _checkAveragePB(event, eventEntries, PBType.ao12, 12);
    if (ao12PB != null) newPBs.add(ao12PB);
    }

    // 检查 Ao100 PB
    if (eventEntries.length >= 100) {
    final ao100PB = await _checkAveragePB(event, eventEntries, PBType.ao100, 100);
    if (ao100PB != null) newPBs.add(ao100PB);
    }

    return newPBs;
    }

    PB 检查是跨分组的——按项目(WcaEvent)汇总所有成绩后检查。

    6.3 单次 PB 检查

    Future<PersonalBestRecord?> _checkSinglePB(
    WcaEvent event,
    List<SolveEntry> entries,
    ) async {
    final validDurations = entries
    .map((e) => e.effectiveDuration)
    .whereType<Duration>()
    .toList();

    if (validDurations.isEmpty) return null;

    final currentBest = validDurations.reduce(_minDuration);
    final currentPB = _pbRepository.getPB(event, PBType.single);

    // 如果没有旧 PB,或者新成绩更好
    if (currentPB == null || currentBest < currentPB.duration) {
    final bestEntry = entries.firstWhere(
    (e) => e.effectiveDuration == currentBest,
    );

    final newPB = PersonalBestRecord(
    type: PBType.single,
    duration: currentBest,
    achievedAt: bestEntry.recordedAt,
    previousBest: currentPB?.duration,
    scramble: bestEntry.scramble,
    solveIds: [bestEntry.hiveKey?.toString() ?? ''],
    );

    await _pbRepository.savePB(event, newPB);
    return newPB;
    }

    return null;
    }

    6.4 平均 PB 检查

    Future<PersonalBestRecord?> _checkAveragePB(
    WcaEvent event,
    List<SolveEntry> entries,
    PBType type,
    int windowSize,
    ) async {
    if (entries.length < windowSize) return null;

    final sortedEntries = List<SolveEntry>.from(entries)
    ..sort((a, b) => a.recordedAt.compareTo(b.recordedAt));

    // 计算所有可能的滚动平均,找出最佳
    Duration? bestAverage;
    List<SolveEntry>? bestWindow;

    for (var i = 0; i <= sortedEntries.length – windowSize; i++) {
    final window = sortedEntries.sublist(i, i + windowSize);
    final avgValue = _rollingAverage(window, windowSize);

    if (avgValue.hasValue && avgValue.duration != null) {
    if (bestAverage == null || avgValue.duration! < bestAverage) {
    bestAverage = avgValue.duration;
    bestWindow = window;
    }
    }
    }

    if (bestAverage == null || bestWindow == null) return null;

    final currentPB = _pbRepository.getPB(event, type);

    if (currentPB == null || bestAverage < currentPB.duration) {
    final newPB = PersonalBestRecord(
    type: type,
    duration: bestAverage,
    achievedAt: bestWindow.last.recordedAt,
    previousBest: currentPB?.duration,
    solveIds: bestWindow
    .map((e) => e.hiveKey?.toString() ?? '')
    .where((id) => id.isNotEmpty)
    .toList(),
    );

    await _pbRepository.savePB(event, newPB);
    return newPB;
    }

    return null;
    }

    平均 PB 的检查逻辑:

  • 按时间排序所有成绩。
  • 遍历所有可能的窗口,计算每个窗口的滚动平均。
  • 取最佳平均。
  • 与当前 PB 比较,如果更好则更新。
  • 6.5 PB 持久化

    class PersonalBestRepository {
    static const String _boxName = 'personal_bests';

    Future<void> savePB(WcaEvent event, PersonalBestRecord record) async {
    final key = _makeKey(event, record.type);
    await box.put(key, record.toMap());
    }

    PersonalBestRecord? getPB(WcaEvent event, PBType type) {
    final key = _makeKey(event, type);
    final data = box.get(key);
    if (data == null) return null;
    return PersonalBestRecord.fromMap(data);
    }

    String _makeKey(WcaEvent event, PBType type) {
    return '${event.code}_${type.name}'; // 例如 "333_single"
    }
    }

    PB 使用 Hive 存储,key 格式为 项目代码_类型,如 333_single、333_ao5。


    七、跨分组统计

    记录页面有"项目统计"视图,按 WCA 项目汇总所有分组的成绩:

    /// 获取有记录的所有 WCA 项目
    List<WcaEvent> getEventsWithRecords() {
    final events = <WcaEvent>{};
    for (final group in _groups) {
    final count = _countsByGroup[group.id] ?? 0;
    if (count > 0) {
    events.add(group.event);
    }
    }
    final sorted = events.toList()..sort((a, b) => a.code.compareTo(b.code));
    return sorted;
    }

    /// 获取指定 WCA 项目的所有成绩(跨所有分组)
    List<SolveEntry> entriesForEvent(WcaEvent event) {
    final result = <SolveEntry>[];
    for (final group in _groups) {
    if (group.event == event) {
    final groupEntries = _entriesByGroup[group.id] ?? <SolveEntry>[];
    result.addAll(groupEntries);
    }
    }
    result.sort((a, b) => b.recordedAt.compareTo(a.recordedAt));
    return result;
    }

    /// 获取指定 WCA 项目的统计数据(跨所有分组)
    SolveStatsSummary statsForEvent(WcaEvent event) {
    final eventEntries = <SolveEntry>[];
    for (final group in _groups) {
    if (group.event == event) {
    eventEntries.addAll(_entriesByGroup[group.id] ?? <SolveEntry>[]);
    }
    }
    eventEntries.sort((a, b) => a.recordedAt.compareTo(b.recordedAt));
    return _recalculateStats(eventEntries);
    }

    这些方法遍历所有分组,按项目汇总成绩和统计,实现"项目统计"视图的数据支撑。


    八、Tab 栏可见性控制

    记录页面有一个独立的控制器控制 Tab 栏的显示/隐藏:

    class RecordsTabBarVisibilityController extends ChangeNotifier {
    bool _isVisible = true;

    bool get isVisible => _isVisible;

    void toggle() {
    _isVisible = !_isVisible;
    notifyListeners();
    }
    }

    在页面中使用 AnimatedCrossFade 实现平滑切换:

    AnimatedCrossFade(
    duration: const Duration(milliseconds: 300),
    crossFadeState: isTabBarVisible
    ? CrossFadeState.showFirst
    : CrossFadeState.showSecond,
    firstChild: Column(children: [/* Tab 栏 */]),
    secondChild: const SizedBox.shrink(),
    ),


    九、打乱收藏

    9.1 ScrambleFavoritesController

    class ScrambleFavoritesController extends ChangeNotifier {
    final ScrambleFavoriteRepository _repository;
    final List<ScrambleFavorite> _favorites = <ScrambleFavorite>[];

    Future<ScrambleFavorite> addFavorite({
    required String scramble,
    required WcaEvent event,
    String group = '',
    String note = '',
    Duration? duration,
    }) async {
    if (exists(scramble: scramble, event: event, group: group)) {
    throw StateError('该公式已收藏');
    }
    final favorite = ScrambleFavorite(
    scramble: scramble,
    event: event,
    group: group,
    note: note,
    duration: duration,
    createdAt: DateTime.now(),
    );
    final stored = await _repository.add(favorite);
    _favorites.insert(0, stored);
    _sortByCreatedAtDesc();
    notifyListeners();
    return stored;
    }
    }

    收藏逻辑:

    • 添加前检查是否已存在(去重)。
    • 新收藏插入列表头部。
    • 按创建时间倒序排列。

    十、总结

    这篇我们深入梳理了记录页面的逻辑实现:

  • 数据模型:SolveEntry(成绩)、SolveGroup(分组)、PersonalBestRecord(PB),罚时通过 effectiveDuration 统一处理。
  • SolvesController:核心控制器,管理成绩的录入、更新、删除,维护内存缓存。
  • 旧数据迁移:确保所有成绩都有有效分组 ID,兼容版本升级。
  • 分组管理:创建、更新、删除分组,删除时自动转移成绩。
  • 统计计算:WCA 标准的滚动平均算法(去最好去最差求平均),缓存重建机制。
  • PB 追踪:跨分组按项目检查,支持 Single / Ao5 / Ao12 / Ao100 四种类型。
  • 跨分组统计:按 WCA 项目汇总所有分组的成绩和统计。
  • Tab 栏控制:独立的可见性控制器,AnimatedCrossFade 平滑切换。
  • 打乱收藏:去重检查,按时间倒序排列。
  • 记录页面的逻辑实现,体现了"数据即状态"的设计理念:成绩变更触发缓存重建和 PB 检查,UI 通过 ChangeNotifier 自动响应。

    赞(0)
    未经允许不得转载:171主机测评 » 【Flutter x HarmonyOS 6】记录页面的逻辑实现
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址