欢迎光临
我们一直在努力

【Flutter x HarmonyOS 6】训练页面的逻辑实现

之前我们聊了训练页面的 UI 设计,这篇深入看看训练页面的逻辑实现。

训练功能的核心是让用户规划每周训练目标,并追踪完成进度。这篇我们从数据模型、控制器、编辑器、进度追踪四个层面逐一拆解。

训练页面


一、数据模型

训练功能涉及三个核心数据模型,形成层级关系:

TrainingPlan
└── TrainingDayGoal (1~7 个,对应周一到周日)
└── TrainingSessionGoal (0~N 个,每天的训练目标)

1.1 TrainingPlan:训练计划

class TrainingPlan {
const TrainingPlan({
required this.id,
required this.name,
required this.createdAt,
required this.dayGoals,
this.startDate,
this.endDate,
this.colorValue = defaultColor,
this.isArchived = false,
this.note = '',
});

static const int defaultColor = 0xFF0E5AD7;

final String id;
final String name;
final DateTime createdAt;
final DateTime? startDate;
final DateTime? endDate;
final int colorValue;
final bool isArchived;
final String note;
final List<TrainingDayGoal> dayGoals;
}

计划包含:

  • 基本信息:名称、备注、颜色。
  • 时间范围:开始日期、结束日期(可选)。
  • 每日目标:dayGoals 列表。
  • 归档状态:isArchived。

1.2 计划状态判断

bool get hasDateRange => startDate != null || endDate != null;

bool get isExpired {
if (endDate == null) return false;
return _dateOnly(endDate!).isBefore(_dateOnly(DateTime.now()));
}

bool get isUpcoming {
if (startDate == null) return false;
return _dateOnly(startDate!).isAfter(_dateOnly(DateTime.now()));
}

bool isActiveOn(DateTime date) {
final target = _dateOnly(date);
if (startDate != null && _dateOnly(startDate!).isAfter(target)) return false;
if (endDate != null && _dateOnly(endDate!).isBefore(target)) return false;
return true;
}

三种状态:

  • 已过期(isExpired):结束日期在今天之前。
  • 未开始(isUpcoming):开始日期在今天之后。
  • 进行中:既不过期也未开始。

isActiveOn 判断某个日期是否在计划周期内,用于进度追踪。

1.3 TrainingDayGoal:每日目标

class TrainingDayGoal {
const TrainingDayGoal({
required this.weekday,
required this.sessions,
}) : assert(weekday >= 1 && weekday <= 7, 'weekday 需为 1~7');

final int weekday; // 1 = 周一, 7 = 周日
final List<TrainingSessionGoal> sessions;
}

weekday 使用 1~7 对应周一到周日,与 Dart 的 DateTime.weekday 一致。

1.4 TrainingSessionGoal:单次训练目标

class TrainingSessionGoal {
const TrainingSessionGoal({
required this.id,
required this.title,
required this.event,
required this.targetCount,
this.expectedAverageDuration,
this.note = '',
});

final String id;
final String title;
final WcaEvent event;
final int targetCount;
final Duration? expectedAverageDuration;
final String note;
}

每个目标包含:

  • 训练项目(如三阶魔方)。
  • 目标次数(如 20 次)。
  • 预期平均时间(可选,用于设定训练节奏)。

二、TrainingPlansController

TrainingPlansController 是训练页面的核心控制器:

class TrainingPlansController extends ChangeNotifier {
TrainingPlansController(this.repository);

final TrainingPlanRepository repository;

bool _isInitializing = false;
bool _isInitialized = false;
List<TrainingPlan> _plans = const <TrainingPlan>[];
String? _selectedPlanId;

bool get isInitialized => _isInitialized;
bool get isInitializing => _isInitializing;
List<TrainingPlan> get plans => _plans;
}

2.1 初始化

Future<void> initialize() async {
if (_isInitializing) return;
_isInitializing = true;
notifyListeners();
await _loadPlans();
_isInitializing = false;
_isInitialized = true;
notifyListeners();
}

初始化时设置 _isInitializing 标志,UI 可以据此显示加载状态。双重 notifyListeners() 分别通知"开始加载"和"加载完成"。

2.2 加载计划

Future<void> _loadPlans({String? selectPlanId}) async {
final plans = repository.fetchPlans();
_plans = plans;
if (plans.isEmpty) {
_selectedPlanId = null;
return;
}
if (selectPlanId != null) {
_selectedPlanId = selectPlanId;
return;
}
final hasSelection = _selectedPlanId != null &&
plans.any((plan) => plan.id == _selectedPlanId);
if (!hasSelection) {
_selectedPlanId = plans.first.id;
}
}

加载逻辑:

  • 从 Repository 获取所有计划。
  • 如果指定了 selectPlanId,选中该计划(用于新建后自动选中)。
  • 否则检查当前选中是否还有效,无效则选中第一个。
  • 2.3 选中计划

    TrainingPlan? get selectedPlan {
    if (_plans.isEmpty) return null;
    if (_selectedPlanId == null) return _plans.first;
    try {
    return _plans.firstWhere((plan) => plan.id == _selectedPlanId);
    } catch (_) {
    return _plans.first;
    }
    }

    void selectPlan(String planId) {
    if (_selectedPlanId == planId) return;
    _selectedPlanId = planId;
    notifyListeners();
    }

    selectedPlan 的容错处理:如果没有选中或选中无效,返回第一个计划。

    2.4 快速创建计划

    Future<TrainingPlan?> createQuickPlan(String name) async {
    final plan = await repository.createPlan(
    name: name.isEmpty ? '未命名计划' : name.trim(),
    );
    await _loadPlans(selectPlanId: plan.id);
    notifyListeners();
    return plan;
    }

    快速创建只指定名称,不配置每日目标。创建后自动选中新计划。

    2.5 删除计划

    Future<void> deletePlan(String planId) async {
    await repository.deletePlan(planId);
    if (_selectedPlanId == planId) {
    _selectedPlanId = null;
    }
    await _loadPlans();
    notifyListeners();
    }

    删除后清除选中状态,_loadPlans 会自动选中第一个可用计划。

    2.6 归档计划

    Future<void> toggleArchive(String planId, {required bool archive}) async {
    await repository.toggleArchive(planId, archive: archive);
    await _loadPlans();
    notifyListeners();
    }


    三、TrainingPlanRepository

    3.1 双 Box 设计

    Repository 管理两个 Hive Box:

    class TrainingPlanRepository {
    static const String _plansBoxName = 'training_plans';
    static const String _progressBoxName = 'training_progress';

    Box<Map<dynamic, dynamic>>? _plansBox;
    Box<Map<dynamic, dynamic>>? _progressBox;
    }

    • training_plans:存储训练计划。
    • training_progress:存储训练进度。

    3.2 ID 生成

    String _generateId(String prefix) {
    final timestamp = DateTime.now().microsecondsSinceEpoch;
    final randomBits = _random.nextInt(1 << 20)
    .toRadixString(16)
    .padLeft(5, '0');
    return '${prefix}_${timestamp}_$randomBits';
    }

    ID 格式:前缀_时间戳_随机数,如 plan_1700000000_1a2b3。时间戳保证有序,随机数防碰撞。

    3.3 删除计划的级联清理

    Future<void> deletePlan(String planId) async {
    final box = _ensurePlansBox();
    await box.delete(planId);

    // 同时删除该计划的所有进度记录
    final progressBox = _ensureProgressBox();
    final keysToDelete = <dynamic>[];
    for (var i = 0; i < progressBox.length; i++) {
    final value = progressBox.getAt(i);
    if (value == null) continue;
    if (value['planId'] == planId) {
    keysToDelete.add(progressBox.keyAt(i));
    }
    }
    await progressBox.deleteAll(keysToDelete);
    }

    删除计划时,级联删除关联的进度记录,避免孤立数据。

    3.4 进度查询

    List<TrainingProgressEntry> fetchProgressEntries({
    String? planId,
    DateTime? start,
    DateTime? end,
    }) {
    final box = _ensureProgressBox();
    final entries = <TrainingProgressEntry>[];
    for (var i = 0; i < box.length; i++) {
    final raw = box.getAt(i);
    if (raw == null) continue;
    final entry = TrainingProgressEntry.fromMap(raw);
    if (planId != null && entry.planId != planId) continue;
    if (start != null && entry.recordedAt.isBefore(start)) continue;
    if (end != null && entry.recordedAt.isAfter(end)) continue;
    entries.add(entry);
    }
    entries.sort((a, b) => a.recordedAt.compareTo(b.recordedAt));
    return entries;
    }

    支持按计划、时间范围过滤进度记录。


    四、训练计划编辑器

    4.1 编辑器状态管理

    class _TrainingPlanEditorPageState
    extends State<TrainingPlanEditorPage> {
    late final bool _isNew = widget.plan == null;
    late final TextEditingController _nameController;
    late final TextEditingController _noteController;
    late Map<int, List<TrainingSessionGoal>> _daySessions;
    DateTime? _startDate;
    DateTime? _endDate;
    int _colorValue = TrainingPlan.defaultColor;
    bool _isSaving = false;

    编辑器用 Map<int, List<TrainingSessionGoal>> 管理每日目标,key 是 weekday(1~7)。

    4.2 初始化每日目标

    Map<int, List<TrainingSessionGoal>> _initDaySessions(
    List<TrainingDayGoal> goals) {
    final map = <int, List<TrainingSessionGoal>>{
    for (var weekday = 1; weekday <= 7; weekday++)
    weekday: <TrainingSessionGoal>[],
    };
    for (final goal in goals) {
    map[goal.weekday] = List<TrainingSessionGoal>.from(goal.sessions);
    }
    return map;
    }

    初始化时先创建 7 个空列表,再将已有目标填入对应日期。

    4.3 添加训练目标

    Future<void> _addSession(int weekday) async {
    final result = await _showSessionEditor();
    if (result == null) return;
    setState(() {
    final list = _daySessions[weekday] ?? <TrainingSessionGoal>[];
    list.add(result.copyWith(id: _generateSessionId()));
    _daySessions[weekday] = List<TrainingSessionGoal>.from(list);
    });
    }

    通过底部表单 _SessionEditorSheet 收集用户输入,返回 TrainingSessionGoal 对象。

    4.4 复制到其他日期

    Future<void> _copyDayToOthers(int fromWeekday) async {
    final source = _daySessions[fromWeekday] ?? const <TrainingSessionGoal>[];
    if (source.isEmpty) {
    _showSnack('该日暂无可复制的目标');
    return;
    }
    final result = await _showCopyDialog(fromWeekday);
    if (result == null || result.targets.isEmpty) return;

    setState(() {
    for (final target in result.targets) {
    final cloned = _cloneSessions(source);
    if (result.append) {
    final existing = List<TrainingSessionGoal>.from(
    _daySessions[target] ?? const <TrainingSessionGoal>[]);
    existing.addAll(cloned);
    _daySessions[target] = existing;
    } else {
    _daySessions[target] = cloned;
    }
    }
    });
    }

    复制逻辑支持两种模式:

    • 覆盖:替换目标日期的所有目标。
    • 追加:在目标日期已有目标后添加。

    4.5 填充全周

    Future<void> _showFillWeekDialog() async {
    final result = await showDialog<_FillWeekConfig>(...);
    if (result == null) return;

    final source = _daySessions[result.referenceWeekday] ??
    const <TrainingSessionGoal>[];
    if (source.isEmpty) {
    _showSnack('${_weekdayLabel(result.referenceWeekday)}暂无可复制的目标');
    return;
    }

    setState(() {
    for (var weekday = 1; weekday <= 7; weekday++) {
    if (result.append) {
    final existing = List<TrainingSessionGoal>.from(
    _daySessions[weekday] ?? const <TrainingSessionGoal>[]);
    existing.addAll(_cloneSessions(source));
    _daySessions[weekday] = existing;
    } else {
    _daySessions[weekday] = _cloneSessions(source);
    }
    }
    });
    }

    "填充全周"功能让用户选择一个参考日,将其目标复制到周一到周日。

    4.6 保存逻辑

    Future<void> _handleSave() async {
    final name = _nameController.text.trim();
    if (name.isEmpty) {
    _showSnack('请输入计划名称');
    return;
    }
    if (_startDate != null &&
    _endDate != null &&
    _endDate!.isBefore(_startDate!)) {
    _showSnack('结束日期不能早于开始日期');
    return;
    }

    final dayGoals = _buildDayGoals();

    setState(() => _isSaving = true);
    try {
    final repository = context.read<TrainingPlanRepository>();
    late final TrainingPlan savedPlan;

    if (_isNew) {
    savedPlan = await repository.createPlan(
    name: name,
    note: _noteController.text.trim(),
    startDate: _startDate,
    endDate: _endDate,
    colorValue: _colorValue,
    dayGoals: dayGoals,
    );
    } else {
    final plan = widget.plan!;
    savedPlan = plan.copyWith(
    name: name,
    note: _noteController.text.trim(),
    startDate: _startDate,
    endDate: _endDate,
    colorValue: _colorValue,
    dayGoals: dayGoals,
    );
    await repository.upsertPlan(savedPlan);
    }

    if (!mounted) return;
    Navigator.of(context).pop(savedPlan.id);
    } catch (error) {
    _showSnack('保存失败:$error');
    } finally {
    if (mounted) {
    setState(() => _isSaving = false);
    }
    }
    }

    保存时的校验:

  • 名称不能为空。
  • 结束日期不能早于开始日期。
  • 新建和编辑的区别:

    • 新建:调用 repository.createPlan()。
    • 编辑:使用 copyWith 更新已有计划,调用 repository.upsertPlan()。

    4.7 构建每日目标

    List<TrainingDayGoal> _buildDayGoals() {
    final goals = <TrainingDayGoal>[];
    _daySessions.forEach((weekday, sessions) {
    if (sessions.isEmpty) return; // 跳过没有目标的日期
    goals.add(
    TrainingDayGoal(
    weekday: weekday,
    sessions: sessions
    .map((session) => TrainingSessionGoal(
    id: session.id,
    title: session.title,
    event: session.event,
    targetCount: session.targetCount,
    expectedAverageDuration: session.expectedAverageDuration,
    note: session.note,
    ))
    .toList(growable: false),
    ),
    );
    });
    return goals;
    }

    保存时只包含有目标的日期,空日期不生成 TrainingDayGoal。


    五、训练进度追踪

    5.1 TrainingProgressEntry

    class TrainingProgressEntry {
    const TrainingProgressEntry({
    required this.id,
    required this.planId,
    required this.sessionId,
    required this.recordedAt,
    required this.completedCount,
    this.completedDuration,
    this.note = '',
    this.metTarget = false,
    });

    final String id;
    final String planId;
    final String sessionId;
    final DateTime recordedAt;
    final int completedCount;
    final Duration? completedDuration;
    final String note;
    final bool metTarget;
    }

    进度记录关联:

    • planId:属于哪个计划。
    • sessionId:属于计划中的哪个训练目标。
    • completedCount:完成了多少次。
    • completedDuration:总耗时。
    • metTarget:是否达标。

    5.2 创建进度记录

    Future<TrainingProgressEntry> createProgressEntry({
    required String planId,
    required String sessionId,
    required DateTime recordedAt,
    required int completedCount,
    Duration? completedDuration,
    String note = '',
    bool metTarget = false,
    }) async {
    final entry = TrainingProgressEntry(
    id: _generateId('progress'),
    planId: planId,
    sessionId: sessionId,
    recordedAt: recordedAt,
    completedCount: completedCount,
    completedDuration: completedDuration,
    note: note,
    metTarget: metTarget,
    );
    await upsertProgressEntry(entry);
    return entry;
    }

    metTarget 由调用方根据 completedCount >= session.targetCount 判断。


    六、序列化与反序列化

    6.1 TrainingPlan 序列化

    Map<String, dynamic> toMap() {
    return <String, dynamic>{
    'id': id,
    'name': name,
    'createdAt': createdAt.toIso8601String(),
    'startDate': startDate?.toIso8601String(),
    'endDate': endDate?.toIso8601String(),
    'colorValue': colorValue,
    'isArchived': isArchived,
    'note': note,
    'dayGoals': dayGoals.map((goal) => goal.toMap()).toList(),
    };
    }

    factory TrainingPlan.fromMap(Map<dynamic, dynamic> map) {
    final rawGoals = map['dayGoals'] as List<dynamic>? ?? const <dynamic>[];
    return TrainingPlan(
    id: map['id'] as String? ?? '',
    name: map['name'] as String? ?? '训练计划',
    createdAt: DateTime.tryParse(map['createdAt'] as String? ?? '') ?? DateTime.now(),
    startDate: DateTime.tryParse(map['startDate'] as String? ?? ''),
    endDate: DateTime.tryParse(map['endDate'] as String? ?? ''),
    colorValue: map['colorValue'] as int? ?? defaultColor,
    isArchived: map['isArchived'] as bool? ?? false,
    note: map['note'] as String? ?? '',
    dayGoals: rawGoals
    .whereType<Map<dynamic, dynamic>>()
    .map(TrainingDayGoal.fromMap)
    .toList(growable: false),
    );
    }

    嵌套的 dayGoals 通过递归调用 toMap / fromMap 完成序列化。fromMap 中使用 ?? 默认值 的模式,确保旧版本数据兼容。


    七、总结

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

  • 数据模型:三层嵌套结构——TrainingPlan → TrainingDayGoal → TrainingSessionGoal,计划状态通过日期自动判断。
  • TrainingPlansController:管理计划列表和选中状态,初始化时双重通知加载状态,选中逻辑带容错处理。
  • TrainingPlanRepository:双 Box 设计(计划 + 进度),删除计划时级联清理进度记录,ID 生成使用时间戳 + 随机数。
  • 计划编辑器:Map<int, List> 管理每日目标,支持复制到其他日期和填充全周,保存时校验名称和日期。
  • 进度追踪:TrainingProgressEntry 记录完成次数、耗时、是否达标,支持按计划和时间范围查询。
  • 序列化:嵌套 toMap/fromMap 递归序列化,fromMap 使用默认值兼容旧版本数据。
  • 训练页面的逻辑实现,体现了"计划即数据"的设计理念:计划是数据的快照,进度是数据的追加,两者通过 ID 关联但独立存储。

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

    评论 抢沙发

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