/
DemienMedich
/
BodyweightBase
Обзор
Документация
Войти
/
DemienMedich
/
BodyweightBase
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
develop
core/src/state_store.cpp
505 строк
20 KB
DemienMedich
feat: continue quick workouts into full sessions
23 июл 2026, 16:34
23 июл 2026, 16:34
a2ccab1
Код
Авторство
О чём код?
#include "bodyweight/state_store.h" #include <algorithm> #include <QDir> #include <QFile> #include <QJsonArray> #include <QJsonDocument> #include <QJsonParseError> #include <QSaveFile> #include <QUuid> namespace bodyweight { namespace { QJsonObject completedExerciseToJson(const CompletedExercise &step) { return { {"exerciseId", step.exerciseId}, {"exerciseName", step.exerciseName}, {"metric", step.metric == ExerciseMetric::seconds ? "seconds" : "repetitions"}, {"targetValue", step.targetValue}, {"actualValue", step.actualValue}, {"workSeconds", step.workSeconds}, {"restSeconds", step.restSeconds}, {"completed", step.completed}, {"loadNote", step.loadNote}, {"externalLoadKg", step.externalLoadKg}, {"effortRating", step.effortRating}, {"discomfortRating", step.discomfortRating} }; } QJsonObject stepToJson(const WorkoutStep &step) { QJsonObject result{ {"exerciseId", step.exerciseId}, {"coachNote", step.coachNote} }; result.insert("targetOverride", step.targetOverride ? QJsonValue(*step.targetOverride) : QJsonValue::Null); result.insert("restSecondsOverride", step.restSecondsOverride ? QJsonValue(*step.restSecondsOverride) : QJsonValue::Null); result.insert("sets", step.sets); result.insert("isWarmup", step.isWarmup); result.insert("isCooldown", step.isCooldown); return result; } QJsonObject planToJson(const WorkoutPlan &plan) { QJsonArray steps; for (const WorkoutStep &step : plan.steps) steps.append(stepToJson(step)); return { {"id", plan.id}, {"profileId", plan.profileId}, {"basePlanId", plan.basePlanId}, {"name", plan.name}, {"goal", plan.goal}, {"description", plan.description}, {"variantId", plan.variantId}, {"roundCount", plan.roundCount}, {"warmupIncluded", plan.warmupIncluded}, {"warmupStepCount", plan.warmupStepCount}, {"cooldownIncluded", plan.cooldownIncluded}, {"cooldownStepCount", plan.cooldownStepCount}, {"roundStepCount", plan.roundStepCount}, {"recommendedRoundsText", plan.recommendedRoundsText}, {"steps", steps} }; } int draftPhaseToJson(SessionPhase phase) { switch (phase) { case SessionPhase::repetitionExercise: return 1; case SessionPhase::timedExercise: return 2; case SessionPhase::preparation: return 3; case SessionPhase::rest: return 4; case SessionPhase::completed: return 5; case SessionPhase::paused: return 6; case SessionPhase::quickDecision: return 7; default: return 0; } } SessionPhase draftPhaseFromJson(int phase) { switch (phase) { case 1: return SessionPhase::repetitionExercise; case 2: return SessionPhase::timedExercise; case 3: return SessionPhase::preparation; case 4: return SessionPhase::rest; case 5: return SessionPhase::completed; case 6: return SessionPhase::paused; case 7: return SessionPhase::quickDecision; default: return SessionPhase::idle; } } WorkoutStep stepFromJson(const QJsonObject &object) { WorkoutStep step; step.exerciseId = object.value("exerciseId").toString(); step.coachNote = object.value("coachNote").toString(); if (object.value("targetOverride").isDouble()) step.targetOverride = object.value("targetOverride").toInt(); if (object.value("restSecondsOverride").isDouble()) step.restSecondsOverride = object.value("restSecondsOverride").toInt(); step.sets = std::max(1, object.value("sets").toInt(1)); step.isWarmup = object.value("isWarmup").toBool(); step.isCooldown = object.value("isCooldown").toBool(); return step; } WorkoutPlan planFromJson(const QJsonObject &object) { WorkoutPlan plan; plan.id = object.value("id").toString(); plan.profileId = object.value("profileId").toString(); plan.basePlanId = object.value("basePlanId").toString(); plan.name = object.value("name").toString(); plan.goal = object.value("goal").toString(); plan.description = object.value("description").toString(); plan.variantId = object.value("variantId").toString("full"); plan.roundCount = object.value("roundCount").toInt(1); plan.warmupIncluded = object.value("warmupIncluded").toBool(); plan.warmupStepCount = object.value("warmupStepCount").toInt(); plan.cooldownIncluded = object.value("cooldownIncluded").toBool(); plan.cooldownStepCount = object.value("cooldownStepCount").toInt(); plan.roundStepCount = object.value("roundStepCount").toInt(); plan.recommendedRoundsText = object.value("recommendedRoundsText").toString(); for (const QJsonValue &value : object.value("steps").toArray()) { if (value.isObject()) plan.steps.append(stepFromJson(value.toObject())); } return plan; } CompletedExercise completedExerciseFromJson(const QJsonObject &object) { return { object.value("exerciseId").toString(), object.value("exerciseName").toString(), object.value("metric").toString() == "seconds" ? ExerciseMetric::seconds : ExerciseMetric::repetitions, object.value("targetValue").toInt(), object.value("actualValue").toInt(), object.value("workSeconds").toInt(), object.value("restSeconds").toInt(), object.value("completed").toBool(), object.value("loadNote").toString(), std::clamp(object.value("externalLoadKg").toDouble(), 0.0, 500.0), std::clamp(object.value("effortRating").toInt(), 0, 10), std::clamp(object.value("discomfortRating").toInt(), 0, 10) }; } QString localTimestamp(const QDateTime &value) { return value.toString(Qt::ISODateWithMs); } bool writeAtomically(const QString &path, const QByteArray &data, QString *error) { QSaveFile file(path); if (!file.open(QIODevice::WriteOnly)) { if (error) { *error = file.errorString(); } return false; } if (file.write(data) != data.size()) { file.cancelWriting(); if (error) { *error = file.errorString(); } return false; } if (!file.commit()) { if (error) { *error = file.errorString(); } return false; } return true; } } // namespace StateStore::StateStore(QString storageDirectory) : storageDirectory_(std::move(storageDirectory)) { } QString StateStore::stateFilePath() const { return QDir(storageDirectory_).filePath(QStringLiteral("native-state.json")); } QString StateStore::backupFilePath() const { return QDir(storageDirectory_).filePath(QStringLiteral("native-state.backup.json")); } StoreLoadResult StateStore::load() const { StoreLoadResult result; QString primaryError; if (readObject(stateFilePath(), &result.root, &primaryError)) { result.ok = true; return result; } if (QFile::exists(stateFilePath())) { result.preservedCorruptPath = preserveCorruptState(); if (result.preservedCorruptPath.isEmpty()) { result.error = QStringLiteral("Invalid state could not be preserved: %1").arg(primaryError); return result; } } QString backupError; if (readObject(backupFilePath(), &result.root, &backupError)) { result.ok = true; result.recoveredFromBackup = true; QString saveError; if (!save(result.root, &saveError)) { result.ok = false; result.error = QStringLiteral("Backup loaded but could not be restored: %1").arg(saveError); } return result; } result.error = QFile::exists(backupFilePath()) ? QStringLiteral("Neither primary state nor backup is valid. Primary: %1 Backup: %2") .arg(primaryError, backupError) : QStringLiteral("No valid state file is available: %1").arg(primaryError); return result; } bool StateStore::save(QJsonObject root, QString *error) const { QDir directory; if (!directory.mkpath(storageDirectory_)) { if (error) { *error = QStringLiteral("Could not create storage directory."); } return false; } root.insert("schemaVersion", schemaVersion); root.insert("lastSavedAt", localTimestamp(QDateTime::currentDateTime())); if (QFile::exists(stateFilePath())) { QFile current(stateFilePath()); if (!current.open(QIODevice::ReadOnly)) { if (error) { *error = QStringLiteral("Could not read current state for backup: %1").arg(current.errorString()); } return false; } QString backupError; if (!writeAtomically(backupFilePath(), current.readAll(), &backupError)) { if (error) { *error = QStringLiteral("Could not create state backup: %1").arg(backupError); } return false; } } const QByteArray json = QJsonDocument(root).toJson(QJsonDocument::Indented); return writeAtomically(stateFilePath(), json, error); } bool StateStore::appendFinishedSession( QJsonObject &root, const FinishedSession &session, const QDateTime &startedAtLocal, const QDateTime &endedAtLocal, const QString &feedbackLabel, QString *error) { if (session.planId.isEmpty() || session.steps.isEmpty() || !startedAtLocal.isValid() || !endedAtLocal.isValid() || endedAtLocal < startedAtLocal) { if (error) { *error = QStringLiteral("Finished session is incomplete or has invalid timestamps."); } return false; } if (root.contains("sessions") && !root.value("sessions").isArray()) { if (error) { *error = QStringLiteral("sessions is not an array."); } return false; } QJsonArray steps; for (const CompletedExercise &step : session.steps) { steps.append(completedExerciseToJson(step)); } QJsonArray history = root.value("sessions").toArray(); history.append(QJsonObject{ {"id", QUuid::createUuid().toString(QUuid::WithoutBraces)}, {"planId", session.planId}, {"planName", session.planName}, {"sessionMode", session.sessionMode}, {"startedAt", localTimestamp(startedAtLocal)}, {"endedAt", localTimestamp(endedAtLocal)}, {"totalWorkSeconds", session.totalWorkSeconds}, {"totalRestSeconds", session.totalRestSeconds}, {"completedAll", session.completedAll}, {"expectedStepCount", session.expectedStepCount}, {"feedback", feedbackLabel.trimmed()}, {"steps", steps} }); while (history.size() > StateStore::maxSessionHistoryItems) { history.removeFirst(); } root.insert("sessions", history); root.insert("pendingDraft", QJsonValue::Null); root.insert("pendingResult", QJsonValue::Null); return true; } bool StateStore::setPendingDraft( QJsonObject &root, const SessionDraft &draft, const QDateTime &sessionStartedAtLocal, QString *error) { if (!sessionStartedAtLocal.isValid() || draft.plan.steps.isEmpty() || draft.phase == SessionPhase::idle || draft.phase == SessionPhase::completed) { if (error) *error = QStringLiteral("Session draft is incomplete."); return false; } QJsonArray completed; for (const CompletedExercise &step : draft.completedSteps) completed.append(completedExerciseToJson(step)); root.insert("pendingDraft", QJsonObject{ {"plan", planToJson(draft.plan)}, {"stepIndex", draft.stepIndex}, {"currentSet", draft.currentSet}, {"setCount", draft.setCount}, {"phase", draftPhaseToJson(draft.phase)}, {"phaseBeforePause", draftPhaseToJson(draft.phaseBeforePause)}, {"phaseDurationSeconds", draft.phaseDurationSeconds}, {"phaseElapsedSeconds", draft.phaseElapsedSeconds}, {"currentValue", draft.currentValue}, {"sessionStartedAt", localTimestamp(sessionStartedAtLocal)}, {"completedSteps", completed}, {"totalRestSeconds", draft.totalRestSeconds}, {"currentLoadNote", draft.currentLoadNote}, {"currentExternalLoadKg", draft.currentExternalLoadKg}, {"currentEffortRating", draft.currentEffortRating}, {"currentDiscomfortRating", draft.currentDiscomfortRating}, {"quickCheckpointStepIndex", draft.quickCheckpointStepIndex}, {"savedAt", localTimestamp(QDateTime::currentDateTime())} }); root.insert("pendingResult", QJsonValue::Null); return true; } std::optional<StoredSessionDraft> StateStore::pendingDraft(const QJsonObject &root, QString *error) { const QJsonValue value = root.value("pendingDraft"); if (value.isNull() || value.isUndefined()) return std::nullopt; if (!value.isObject()) { if (error) *error = QStringLiteral("PendingSessionDraft is not an object."); return std::nullopt; } const QJsonObject object = value.toObject(); if (!object.value("plan").isObject() || !object.value("completedSteps").isArray()) { if (error) *error = QStringLiteral("PendingSessionDraft has invalid plan or completed steps."); return std::nullopt; } StoredSessionDraft stored; stored.draft.plan = planFromJson(object.value("plan").toObject()); stored.draft.stepIndex = object.value("stepIndex").toInt(-1); stored.draft.currentSet = object.value("currentSet").toInt(1); stored.draft.setCount = object.value("setCount").toInt(1); stored.draft.phase = draftPhaseFromJson(object.value("phase").toInt()); stored.draft.phaseBeforePause = draftPhaseFromJson(object.value("phaseBeforePause").toInt()); stored.draft.phaseDurationSeconds = object.value("phaseDurationSeconds").toInt(-1); stored.draft.phaseElapsedSeconds = object.value("phaseElapsedSeconds").toInt(-1); stored.draft.currentValue = object.value("currentValue").toInt(-1); stored.draft.totalRestSeconds = object.value("totalRestSeconds").toInt(-1); stored.draft.currentLoadNote = object.value("currentLoadNote").toString(); stored.draft.currentExternalLoadKg = std::clamp( object.value("currentExternalLoadKg").toDouble(), 0.0, 500.0); stored.draft.currentEffortRating = std::clamp( object.value("currentEffortRating").toInt(), 0, 10); stored.draft.currentDiscomfortRating = std::clamp( object.value("currentDiscomfortRating").toInt(), 0, 10); stored.draft.quickCheckpointStepIndex = object.value("quickCheckpointStepIndex").toInt(-1); stored.sessionStartedAtLocal = QDateTime::fromString(object.value("sessionStartedAt").toString(), Qt::ISODate); for (const QJsonValue &step : object.value("completedSteps").toArray()) { if (!step.isObject()) { if (error) *error = QStringLiteral("PendingSessionDraft contains invalid completed step."); return std::nullopt; } stored.draft.completedSteps.append(completedExerciseFromJson(step.toObject())); } if (!stored.sessionStartedAtLocal.isValid()) { if (error) *error = QStringLiteral("PendingSessionDraft has invalid start time."); return std::nullopt; } return stored; } void StateStore::clearPendingDraft(QJsonObject &root) { root.insert("pendingDraft", QJsonValue::Null); } bool StateStore::setPendingFinishedSession( QJsonObject &root, const FinishedSession &session, const QDateTime &sessionStartedAtLocal, const QDateTime &sessionEndedAtLocal, const QString &profileId, QString *error) { if (session.planId.trimmed().isEmpty() || !sessionStartedAtLocal.isValid() || !sessionEndedAtLocal.isValid() || sessionEndedAtLocal < sessionStartedAtLocal || profileId.trimmed().isEmpty()) { if (error) *error = QStringLiteral("Pending finished session is incomplete."); return false; } QJsonArray steps; for (const CompletedExercise &step : session.steps) { steps.append(completedExerciseToJson(step)); } root.insert("pendingResult", QJsonObject{ {"profileId", profileId.trimmed()}, {"planId", session.planId}, {"planName", session.planName}, {"sessionMode", session.sessionMode}, {"totalWorkSeconds", session.totalWorkSeconds}, {"totalRestSeconds", session.totalRestSeconds}, {"completedAll", session.completedAll}, {"expectedStepCount", session.expectedStepCount}, {"steps", steps}, {"sessionStartedAt", localTimestamp(sessionStartedAtLocal)}, {"sessionEndedAt", localTimestamp(sessionEndedAtLocal)}, {"savedAt", localTimestamp(QDateTime::currentDateTime())} }); root.insert("pendingDraft", QJsonValue::Null); return true; } std::optional<StoredFinishedSession> StateStore::pendingFinishedSession( const QJsonObject &root, QString *error) { const QJsonValue value = root.value("pendingResult"); if (value.isNull() || value.isUndefined()) return std::nullopt; if (!value.isObject()) { if (error) *error = QStringLiteral("Pending finished session is not an object."); return std::nullopt; } const QJsonObject object = value.toObject(); if (!object.value("steps").isArray()) { if (error) *error = QStringLiteral("Pending finished session has invalid steps."); return std::nullopt; } StoredFinishedSession stored; stored.profileId = object.value("profileId").toString().trimmed(); stored.session.planId = object.value("planId").toString(); stored.session.planName = object.value("planName").toString(); stored.session.sessionMode = object.value("sessionMode").toString(QStringLiteral("full")); stored.session.totalWorkSeconds = std::max(0, object.value("totalWorkSeconds").toInt()); stored.session.totalRestSeconds = std::max(0, object.value("totalRestSeconds").toInt()); stored.session.completedAll = object.value("completedAll").toBool(); for (const QJsonValue &step : object.value("steps").toArray()) { if (!step.isObject()) { if (error) *error = QStringLiteral("Pending finished session contains an invalid step."); return std::nullopt; } stored.session.steps.append(completedExerciseFromJson(step.toObject())); } stored.session.expectedStepCount = std::max( static_cast<int>(stored.session.steps.size()), object.value("expectedStepCount").toInt(static_cast<int>(stored.session.steps.size()))); stored.sessionStartedAtLocal = QDateTime::fromString( object.value("sessionStartedAt").toString(), Qt::ISODate); stored.sessionEndedAtLocal = QDateTime::fromString( object.value("sessionEndedAt").toString(), Qt::ISODate); if (stored.profileId.isEmpty() || stored.session.planId.isEmpty() || !stored.sessionStartedAtLocal.isValid() || !stored.sessionEndedAtLocal.isValid() || stored.sessionEndedAtLocal < stored.sessionStartedAtLocal) { if (error) *error = QStringLiteral("Pending finished session has invalid metadata."); return std::nullopt; } return stored; } void StateStore::clearPendingFinishedSession(QJsonObject &root) { root.insert("pendingResult", QJsonValue::Null); } QString StateStore::preserveCorruptState() const { const QString name = QStringLiteral("native-state.corrupt.%1.%2.json") .arg(QDateTime::currentDateTime().toString("yyyyMMdd-HHmmss")) .arg(QUuid::createUuid().toString(QUuid::WithoutBraces)); const QString destination = QDir(storageDirectory_).filePath(name); return QFile::rename(stateFilePath(), destination) ? destination : QString(); } bool StateStore::readObject(const QString &path, QJsonObject *root, QString *error) { QFile file(path); if (!file.open(QIODevice::ReadOnly)) { if (error) { *error = file.errorString(); } return false; } QJsonParseError parseError; const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); if (parseError.error != QJsonParseError::NoError || !document.isObject()) { if (error) { *error = parseError.error == QJsonParseError::NoError ? QStringLiteral("State root is not an object.") : parseError.errorString(); } return false; } *root = document.object(); return true; } } // namespace bodyweight