/
DemienMedich
/
BodyweightBase
Обзор
Документация
Войти
/
DemienMedich
/
BodyweightBase
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
app/session_controller.cpp
2 991 строка
116 KB
DemienMedich
Add weight chart data source
24 июн 2026, 00:15
24 июн 2026, 00:15
a24aedc
Код
Авторство
О чём код?
#include "session_controller.h" #include <algorithm> #include <cmath> #include <QDir> #include <QDate> #include <QFileInfo> #include <QJsonArray> #include <QJsonDocument> #include <QJsonObject> #include <QLocale> #include <QCoreApplication> #include <QTime> #include <QFile> #include <QUrl> #include <QTextStream> #ifdef Q_OS_WIN #define NOMINMAX #include <windows.h> #endif #include "bodyweight/exercise_catalog.h" #include "bodyweight/legacy_migrator.h" using namespace bodyweight; namespace { #ifndef BODYWEIGHTBASE_VERSION #define BODYWEIGHTBASE_VERSION "0.0" #endif QString metricText(ExerciseMetric metric) { return metric == ExerciseMetric::seconds ? QStringLiteral("секунды") : QStringLiteral("повторы"); } QString metricKey(ExerciseMetric metric) { return metric == ExerciseMetric::seconds ? QStringLiteral("seconds") : QStringLiteral("repetitions"); } QString normalizedFeedback(QString value) { value = value.trimmed().toLower(); if (value == "легко" || value == "easy") return QStringLiteral("easy"); if (value == "тяжело" || value == "hard") return QStringLiteral("hard"); return QStringLiteral("normal"); } const ExerciseDefinition *findExercise(const QVector<ExerciseDefinition> &exercises, const QString &id) { auto found = std::find_if(exercises.cbegin(), exercises.cend(), [&id](const ExerciseDefinition &exercise) { return exercise.id == id; }); return found == exercises.cend() ? nullptr : &*found; } int roundToStep(double value, int step) { return qRound(value / step) * step; } QString formatDecimal(double value) { return QLocale(QLocale::Russian, QLocale::Russia).toString(value, 'f', 1); } std::optional<double> parseWeightKg(QString text) { text = text.trimmed().replace(',', '.'); bool ok = false; const double value = text.toDouble(&ok); if (!ok || value < 30.0 || value > 250.0) { return std::nullopt; } return std::round(value * 10.0) / 10.0; } std::optional<double> parseMeasurementCm(QString text, double minValue, double maxValue) { text = text.trimmed().replace(',', '.'); bool ok = false; const double value = text.toDouble(&ok); if (!ok || value < minValue || value > maxValue) { return std::nullopt; } return std::round(value * 10.0) / 10.0; } QString normalizeNutritionPreset(QString presetId) { presetId = presetId.trimmed().toLower(); if (presetId == "balanced" || presetId == "cheap" || presetId == "low-appetite") { return presetId; } return {}; } QString planIdFromName(QString name) { name = name.trimmed().toLower(); QString result; for (const QChar character : name) { if (character.isLetterOrNumber()) { result.append(character); } else if (!result.endsWith('-')) { result.append('-'); } } result = result.trimmed(); while (result.startsWith('-')) result.removeFirst(); while (result.endsWith('-')) result.chop(1); return result.isEmpty() ? QStringLiteral("custom-plan") : result.left(48); } QString uniquePlanId(QString baseId, const QVector<WorkoutPlan> &plans) { baseId = baseId.trimmed(); if (baseId.isEmpty()) { baseId = QStringLiteral("custom-plan"); } QString candidate = baseId; int suffix = 2; auto exists = [&plans](const QString &id) { return std::any_of(plans.cbegin(), plans.cend(), [&id](const WorkoutPlan &plan) { return plan.id.compare(id, Qt::CaseInsensitive) == 0; }); }; while (exists(candidate)) { candidate = QStringLiteral("%1-%2").arg(baseId).arg(suffix++); } return candidate; } QString profileIdFromName(QString name) { name = name.trimmed().toLower(); QString result; for (const QChar character : name) { if (character.isLetterOrNumber()) { result.append(character); } else if (!result.endsWith('-')) { result.append('-'); } } result = result.trimmed(); while (result.startsWith('-')) result.removeFirst(); while (result.endsWith('-')) result.chop(1); return result.isEmpty() ? QStringLiteral("profile") : result.left(48); } QJsonArray profilesArrayOrDefault(const QJsonObject &root) { QJsonArray profiles = root.value("profiles").toArray(); if (profiles.isEmpty()) { profiles.append(QJsonObject{{"id", "default"}, {"name", QStringLiteral("Семья")}}); } return profiles; } QString selectedProfileIdOrDefault(const QJsonObject &root) { const QString selected = root.value("selectedProfileId").toString().trimmed(); return selected.isEmpty() ? QStringLiteral("default") : selected; } QString sessionProfileIdOrDefault(const QJsonObject &session) { const QString profileId = session.value("profileId").toString().trimmed(); return profileId.isEmpty() ? QStringLiteral("default") : profileId; } bool sessionMatchesProfile(const QJsonObject &session, const QString &profileId) { return sessionProfileIdOrDefault(session).compare(profileId, Qt::CaseInsensitive) == 0; } QJsonArray objectsForProfile(const QJsonArray &array, const QString &profileId) { QJsonArray result; for (const QJsonValue &value : array) { if (!value.isObject()) continue; const QJsonObject object = value.toObject(); if (sessionMatchesProfile(object, profileId)) { result.append(object); } } return result; } QJsonArray objectsWithoutProfile(const QJsonArray &array, const QString &profileId) { QJsonArray result; for (const QJsonValue &value : array) { if (!value.isObject()) continue; const QJsonObject object = value.toObject(); if (!sessionMatchesProfile(object, profileId)) { result.append(object); } } return result; } bool profileExists(const QJsonArray &profiles, const QString &profileId) { for (const QJsonValue &value : profiles) { if (!value.isObject()) continue; if (value.toObject().value("id").toString().compare(profileId, Qt::CaseInsensitive) == 0) { return true; } } return false; } QString uniqueProfileId(QString baseId, const QJsonArray &profiles) { baseId = baseId.trimmed(); if (baseId.isEmpty()) baseId = QStringLiteral("profile"); QString candidate = baseId; int suffix = 2; while (profileExists(profiles, candidate)) { candidate = QStringLiteral("%1-%2").arg(baseId).arg(suffix++); } return candidate; } QString dateTimeText(const QString &isoText) { const QDateTime value = QDateTime::fromString(isoText, Qt::ISODate); return value.isValid() ? value.toString("dd.MM • HH:mm") : QStringLiteral("без даты"); } QString dateText(const QString &isoText) { const QDateTime value = QDateTime::fromString(isoText, Qt::ISODate); return value.isValid() ? value.toString("dd.MM") : isoText.left(10); } QString adaptationText(const QString &feedback, bool completedAll) { const QString normalized = normalizedFeedback(feedback); if (normalized == "easy" && completedAll) { return QStringLiteral("Следующая тренировка: мягко увеличить цели (+1 повтор или +5 сек)."); } if (normalized == "hard") { return QStringLiteral("Следующая тренировка: снизить цели на шаг и добавить отдых (+10 сек)."); } if (!completedAll) { return QStringLiteral("Следующая тренировка: оставить нагрузку и пройти без пропусков."); } return QStringLiteral("Следующая тренировка: оставить текущую нагрузку."); } bool applyAdaptationToPlan( QJsonObject &root, const QString &planId, const QVector<ExerciseDefinition> &exercises, const QString &feedback, bool completedAll) { const QString normalized = normalizedFeedback(feedback); if (normalized == "normal" || (normalized == "easy" && !completedAll)) { return false; } QJsonArray plans = root.value("plans").toArray(); bool changed = false; for (int planIndex = 0; planIndex < plans.size(); ++planIndex) { if (!plans.at(planIndex).isObject()) continue; QJsonObject plan = plans.at(planIndex).toObject(); if (plan.value("id").toString().compare(planId, Qt::CaseInsensitive) != 0) continue; QJsonArray steps = plan.value("steps").toArray(); for (int stepIndex = 0; stepIndex < steps.size(); ++stepIndex) { if (!steps.at(stepIndex).isObject()) continue; QJsonObject step = steps.at(stepIndex).toObject(); const QString exerciseId = step.value("exerciseId").toString(); const ExerciseDefinition *exercise = findExercise(exercises, exerciseId); const bool timed = exercise && exercise->metric == ExerciseMetric::seconds; const int defaultTarget = exercise ? exercise->defaultTarget : 1; const int defaultRest = exercise ? exercise->defaultRestSeconds : 0; const int target = step.value("targetOverride").isDouble() ? step.value("targetOverride").toInt() : defaultTarget; const int restSeconds = step.value("restSecondsOverride").isDouble() ? step.value("restSecondsOverride").toInt() : defaultRest; if (normalized == "easy") { step.insert("targetOverride", std::clamp(target + (timed ? 5 : 1), 1, 999)); } else if (normalized == "hard") { step.insert("targetOverride", std::clamp(target - (timed ? 5 : 1), 1, 999)); step.insert("restSecondsOverride", std::clamp(restSeconds + 10, 0, 900)); } steps.replace(stepIndex, step); changed = true; } plan.insert("steps", steps); plans.replace(planIndex, plan); break; } if (changed) { root.insert("plans", plans); } return changed; } QJsonArray sortedObjectsByDateDesc(const QJsonArray &array, const QString &dateKey) { QVector<QJsonObject> objects; for (const QJsonValue &value : array) { if (value.isObject()) objects.append(value.toObject()); } std::sort(objects.begin(), objects.end(), [&dateKey](const QJsonObject &left, const QJsonObject &right) { return left.value(dateKey).toString() > right.value(dateKey).toString(); }); QJsonArray result; for (const QJsonObject &object : objects) result.append(object); return result; } } // namespace SessionController::SessionController(QObject *parent) : SessionController( QDir(qEnvironmentVariable("APPDATA")).filePath("BodyweightBaseCpp"), QStringLiteral(":/resources/exercises.json"), QDir(qEnvironmentVariable("APPDATA")).filePath("BodyweightBase/state.json"), {}, parent) { } SessionController::SessionController( QString storageDirectory, QString exerciseCatalogPath, QString legacyStatePath, QString exerciseFrameDirectory, QObject *parent) : QObject(parent) , storageDirectory_(storageDirectory) { exerciseFrameDirectory_ = exerciseFrameDirectory.trimmed(); if (exerciseFrameDirectory_.isEmpty()) { exerciseFrameDirectory_ = QDir(QCoreApplication::applicationDirPath()).filePath("Assets/ExerciseFrames"); } const ExerciseCatalogResult catalog = importExerciseCatalogFile(exerciseCatalogPath); if (!catalog.ok) { status_ = catalog.error; return; } exercises_ = catalog.exercises; runner_ = std::make_unique<WorkoutSessionRunner>(exercises_); store_ = std::make_unique<StateStore>(storageDirectory); StoreLoadResult loaded = store_->load(); bool migratedLegacy = false; if (!loaded.ok && !legacyStatePath.isEmpty() && QFileInfo::exists(legacyStatePath)) { const LegacyMigrationResult migration = migrateLegacyStateFile(legacyStatePath, QDir(storageDirectory).filePath("legacy-archive")); QString saveError; if (!migration.ok || !store_->save(migration.nativeRoot, &saveError)) { status_ = migration.ok ? saveError : migration.error; return; } loaded = store_->load(); migratedLegacy = loaded.ok; } if (!loaded.ok) { status_ = loaded.error; return; } root_ = loaded.root; soundsEnabled_ = root_.value("soundsEnabled").toBool(true); uiScale_ = root_.value("uiScale").toDouble(1.0); darkMode_ = root_.value("darkMode").toBool(true); const ImportResult imported = importNativeState(QJsonDocument(root_).toJson()); if (!imported.ok) { status_ = imported.error; return; } state_ = imported.state; QString draftError; recoverableDraft_ = StateStore::pendingDraft(root_, &draftError); status_ = migratedLegacy ? QStringLiteral("Старые данные импортированы в C++ хранилище") : loaded.recoveredFromBackup ? QStringLiteral("Состояние восстановлено из резервной копии") : recoverableDraft_ ? QStringLiteral("Найдена незавершённая тренировка") : QStringLiteral("Данные загружены"); if (!draftError.isEmpty()) status_ = QStringLiteral("Черновик отклонён: %1").arg(draftError); } bool SessionController::ready() const { return runner_ && selectedPlan() && !pendingFinishedSession_ && !recoverableDraft_; } bool SessionController::active() const { const SessionPhase phase = snapshot().phase; return phase != SessionPhase::idle && phase != SessionPhase::completed; } bool SessionController::paused() const { return snapshot().phase == SessionPhase::paused; } bool SessionController::needsSave() const { return pendingFinishedSession_.has_value(); } bool SessionController::hasRecoverableDraft() const { return recoverableDraft_.has_value(); } QString SessionController::status() const { return status_; } QString SessionController::pendingResultSummary() const { if (!pendingFinishedSession_) return {}; const int workMinutes = pendingFinishedSession_->totalWorkSeconds <= 0 ? 0 : std::max(1, pendingFinishedSession_->totalWorkSeconds / 60); return QStringLiteral("%1 мин • %2 шагов • %3") .arg(workMinutes) .arg(pendingFinishedSession_->steps.size()) .arg(pendingFinishedSession_->completedAll ? QStringLiteral("закрыта") : QStringLiteral("частично")); } QString SessionController::recoverableDraftSummary() const { if (!recoverableDraft_) return {}; const SessionDraft &draft = recoverableDraft_->draft; const int stepCount = static_cast<int>(draft.plan.steps.size()); return QStringLiteral("%1 • шаг %2 / %3 • закрыто %4") .arg(draft.plan.name) .arg(std::min(draft.stepIndex + 1, stepCount)) .arg(stepCount) .arg(draft.completedSteps.size()); } QString SessionController::planName() const { return selectedPlan() ? selectedPlan()->name : QStringLiteral("План не найден"); } QString SessionController::planGoal() const { return selectedPlan() ? selectedPlan()->goal : QString(); } QVariantMap SessionController::preWorkoutRecommendation() const { const WorkoutPlan *plan = selectedPlan(); if (!plan) { return QVariantMap{ {"title", QStringLiteral("План не выбран")}, {"detail", QStringLiteral("Выбери рабочий план перед стартом.")}, {"color", QStringLiteral("#94A3B8")}, {"hasHistory", false} }; } QJsonObject latestSession; QDateTime latestEndedAt; const QJsonArray sessions = sessionsArray(); for (const QJsonValue &value : sessions) { if (!value.isObject()) continue; const QJsonObject session = value.toObject(); if (session.value("planId").toString().compare(plan->id, Qt::CaseInsensitive) != 0) continue; const QDateTime endedAt = QDateTime::fromString(session.value("endedAt").toString(), Qt::ISODate); if (!endedAt.isValid()) continue; if (!latestEndedAt.isValid() || endedAt > latestEndedAt) { latestEndedAt = endedAt; latestSession = session; } } if (latestSession.isEmpty()) { return QVariantMap{ {"title", QStringLiteral("Первый запуск плана")}, {"detail", QStringLiteral("Начни с текущих целей и оцени результат после тренировки.")}, {"color", QStringLiteral("#38BDF8")}, {"hasHistory", false} }; } const QString feedback = latestSession.value("feedback").toString(); const QString normalized = normalizedFeedback(feedback); const bool completedAll = latestSession.value("completedAll").toBool(); QString title = QStringLiteral("Держать нагрузку"); QString color = QStringLiteral("#38BDF8"); if (normalized == "easy" && completedAll) { title = QStringLiteral("Можно прибавить"); color = QStringLiteral("#22C55E"); } else if (normalized == "hard") { title = QStringLiteral("Снизить темп"); color = QStringLiteral("#F59E0B"); } else if (!completedAll) { title = QStringLiteral("Закрепить базу"); color = QStringLiteral("#F59E0B"); } return QVariantMap{ {"title", title}, {"detail", adaptationText(feedback, completedAll)}, {"color", color}, {"hasHistory", true}, {"lastFeedback", feedback.trimmed().isEmpty() ? QStringLiteral("без оценки") : feedback.trimmed()}, {"lastDateText", latestEndedAt.toString("dd.MM.yyyy")} }; } QString SessionController::phaseTitle() const { switch (snapshot().phase) { case SessionPhase::preparation: return QStringLiteral("Подготовка"); case SessionPhase::repetitionExercise: return QStringLiteral("Повторения"); case SessionPhase::timedExercise: return QStringLiteral("Рабочий таймер"); case SessionPhase::rest: return QStringLiteral("Отдых"); case SessionPhase::paused: return QStringLiteral("Пауза"); case SessionPhase::completed: return QStringLiteral("Тренировка завершена"); default: return QStringLiteral("Готов к старту"); } } QString SessionController::exerciseName() const { const SessionSnapshot view = snapshot(); if (!selectedPlan() || view.phase == SessionPhase::idle || view.phase == SessionPhase::completed) { return needsSave() ? QStringLiteral("Результат готов к сохранению") : QStringLiteral("Выбранный план готов к запуску"); } return exerciseNameForStep(view.stepIndex); } QString SessionController::nextExerciseName() const { const SessionSnapshot view = snapshot(); const WorkoutPlan *plan = selectedPlan(); if (!plan || view.phase == SessionPhase::idle || view.phase == SessionPhase::completed) return {}; const int nextIndex = view.stepIndex + 1; if (nextIndex < 0 || nextIndex >= plan->steps.size()) return QStringLiteral("Финиш тренировки"); return exerciseNameForStep(nextIndex); } QString SessionController::currentCoachNote() const { const SessionSnapshot view = snapshot(); const WorkoutPlan *plan = selectedPlan(); if (!plan || view.phase == SessionPhase::idle || view.phase == SessionPhase::completed || view.stepIndex < 0 || view.stepIndex >= plan->steps.size()) { return {}; } return plan->steps.at(view.stepIndex).coachNote; } QString SessionController::currentStepSummary() const { const SessionSnapshot view = snapshot(); const WorkoutPlan *plan = selectedPlan(); if (!plan || view.phase == SessionPhase::idle || view.phase == SessionPhase::completed || view.stepIndex < 0 || view.stepIndex >= plan->steps.size()) { return {}; } const WorkoutStep &step = plan->steps.at(view.stepIndex); const ExerciseDefinition *exercise = findExercise(exercises_, step.exerciseId); const int target = step.targetOverride.value_or(exercise ? exercise->defaultTarget : 0); const int restSeconds = step.restSecondsOverride.value_or(exercise ? exercise->defaultRestSeconds : 0); const ExerciseMetric metric = exercise ? exercise->metric : ExerciseMetric::repetitions; return QStringLiteral("%1 • цель %2 • отдых %3 сек") .arg(metricText(metric)) .arg(target) .arg(restSeconds); } QVariantMap SessionController::currentExerciseTechnique() const { const SessionSnapshot view = snapshot(); const WorkoutPlan *plan = selectedPlan(); if (!plan || view.phase == SessionPhase::idle || view.phase == SessionPhase::completed || view.stepIndex < 0 || view.stepIndex >= plan->steps.size()) { return {}; } const ExerciseDefinition *exercise = findExercise(exercises_, plan->steps.at(view.stepIndex).exerciseId); if (!exercise) return {}; return QVariantMap{ {"category", exercise->category}, {"equipment", exercise->equipment}, {"description", exercise->description}, {"primaryCue", exercise->primaryCue}, {"secondaryCue", exercise->secondaryCue}, {"loadRecommendation", exercise->loadRecommendation}, {"availabilityNote", exercise->availabilityNote} }; } QString SessionController::progressText() const { if (!selectedPlan()) return QStringLiteral("0 / 0"); return QStringLiteral("%1 / %2") .arg(std::min(snapshot().stepIndex + 1, static_cast<int>(selectedPlan()->steps.size()))) .arg(selectedPlan()->steps.size()); } QString SessionController::completedStepText() const { if (!selectedPlan()) return QStringLiteral("закрыто 0 / 0"); return QStringLiteral("закрыто %1 / %2") .arg(std::clamp(snapshot().completedStepCount, 0, static_cast<int>(selectedPlan()->steps.size()))) .arg(selectedPlan()->steps.size()); } QString SessionController::counterText() const { const SessionSnapshot view = snapshot(); if (view.phase == SessionPhase::preparation || view.phase == SessionPhase::timedExercise || view.phase == SessionPhase::rest || (view.phase == SessionPhase::paused && view.phaseBeforePause != SessionPhase::repetitionExercise)) { return QString::number(view.phaseRemainingSeconds); } if (view.phase == SessionPhase::repetitionExercise || (view.phase == SessionPhase::paused && view.phaseBeforePause == SessionPhase::repetitionExercise)) { return QString::number(view.currentValue); } return QStringLiteral("—"); } double SessionController::phaseProgress() const { const SessionSnapshot view = snapshot(); if (view.phaseDurationSeconds <= 0 || view.phase == SessionPhase::idle || view.phase == SessionPhase::completed || view.phase == SessionPhase::repetitionExercise || (view.phase == SessionPhase::paused && view.phaseBeforePause == SessionPhase::repetitionExercise)) { return 0.0; } return std::clamp( static_cast<double>(view.phaseElapsedSeconds) / static_cast<double>(view.phaseDurationSeconds), 0.0, 1.0); } QString SessionController::phaseProgressText() const { const SessionSnapshot view = snapshot(); if (view.phaseDurationSeconds <= 0 || view.phase == SessionPhase::idle || view.phase == SessionPhase::completed || view.phase == SessionPhase::repetitionExercise || (view.phase == SessionPhase::paused && view.phaseBeforePause == SessionPhase::repetitionExercise)) { return {}; } return QStringLiteral("%1 / %2 сек") .arg(std::clamp(view.phaseElapsedSeconds, 0, view.phaseDurationSeconds)) .arg(view.phaseDurationSeconds); } QString SessionController::phaseColor() const { const SessionSnapshot view = snapshot(); switch (view.phase) { case SessionPhase::preparation: return QStringLiteral("#38BDF8"); case SessionPhase::repetitionExercise: case SessionPhase::timedExercise: return QStringLiteral("#22C55E"); case SessionPhase::rest: return QStringLiteral("#F59E0B"); case SessionPhase::paused: return QStringLiteral("#94A3B8"); case SessionPhase::completed: return QStringLiteral("#A78BFA"); default: return QStringLiteral("#38BDF8"); } } int SessionController::currentStepIndex() const { const SessionSnapshot view = snapshot(); if (!selectedPlan() || view.phase == SessionPhase::idle || view.phase == SessionPhase::completed) { return 0; } return std::min(view.stepIndex + 1, static_cast<int>(selectedPlan()->steps.size())); } QVariantList SessionController::currentExerciseFrameUrls() const { const SessionSnapshot view = snapshot(); const WorkoutPlan *plan = selectedPlan(); if (!plan || view.phase == SessionPhase::idle || view.phase == SessionPhase::completed || view.stepIndex < 0 || view.stepIndex >= plan->steps.size()) { return {}; } return exerciseFrameUrls(plan->steps.at(view.stepIndex).exerciseId); } QString SessionController::currentLoadNote() const { return currentLoadNote_; } QVariantList SessionController::profiles() const { QVariantList result; const QJsonArray profiles = profilesArrayOrDefault(root_); const QString selectedId = selectedProfileId(); for (const QJsonValue &value : profiles) { if (!value.isObject()) continue; const QJsonObject object = value.toObject(); const QString id = object.value("id").toString(); if (id.isEmpty()) continue; result.append(QVariantMap{ {"id", id}, {"name", object.value("name").toString(id)}, {"selected", id.compare(selectedId, Qt::CaseInsensitive) == 0} }); } return result; } QString SessionController::selectedProfileId() const { return selectedProfileIdOrDefault(root_); } QString SessionController::selectedProfileName() const { const QString selectedId = selectedProfileId(); const QJsonArray profiles = profilesArrayOrDefault(root_); for (const QJsonValue &value : profiles) { if (!value.isObject()) continue; const QJsonObject object = value.toObject(); if (object.value("id").toString().compare(selectedId, Qt::CaseInsensitive) == 0) { return object.value("name").toString(selectedId); } } return QStringLiteral("Семья"); } QVariantList SessionController::plans() const { QVariantList result; for (const WorkoutPlan &plan : state_.workoutPlans) { result.append(QVariantMap{ {"id", plan.id}, {"name", plan.name}, {"goal", plan.goal}, {"description", plan.description}, {"stepCount", plan.steps.size()}, {"selected", plan.id.compare(state_.selectedPlanId, Qt::CaseInsensitive) == 0} }); } return result; } QString SessionController::selectedPlanId() const { return state_.selectedPlanId; } QVariantList SessionController::selectedPlanSteps() const { QVariantList result; const WorkoutPlan *plan = selectedPlan(); if (!plan) return result; for (int index = 0; index < plan->steps.size(); ++index) { const WorkoutStep &step = plan->steps.at(index); const ExerciseDefinition *exercise = findExercise(exercises_, step.exerciseId); const int target = step.targetOverride.value_or(exercise ? exercise->defaultTarget : 0); const int restSeconds = step.restSecondsOverride.value_or(exercise ? exercise->defaultRestSeconds : 0); const ExerciseMetric metric = exercise ? exercise->metric : ExerciseMetric::repetitions; result.append(QVariantMap{ {"index", index + 1}, {"exerciseId", step.exerciseId}, {"exerciseName", exercise ? exercise->name : step.exerciseId}, {"metric", metricKey(metric)}, {"metricText", metricText(metric)}, {"target", target}, {"restSeconds", restSeconds}, {"coachNote", step.coachNote}, {"category", exercise ? exercise->category : QString()}, {"equipment", exercise ? exercise->equipment : QString()}, {"description", exercise ? exercise->description : QString()}, {"primaryCue", exercise ? exercise->primaryCue : QString()}, {"secondaryCue", exercise ? exercise->secondaryCue : QString()}, {"loadRecommendation", exercise ? exercise->loadRecommendation : QString()}, {"availabilityNote", exercise ? exercise->availabilityNote : QString()}, {"frameUrls", exerciseFrameUrls(step.exerciseId)} }); } return result; } int SessionController::totalSessions() const { return sessionsArray().size(); } int SessionController::totalWorkMinutes() const { int seconds = 0; for (const QJsonValue &value : sessionsArray()) { if (value.isObject()) seconds += std::max(0, value.toObject().value("totalWorkSeconds").toInt()); } return seconds <= 0 ? 0 : std::max(1, seconds / 60); } QString SessionController::completionRateText() const { const QJsonArray sessions = sessionsArray(); if (sessions.isEmpty()) return QStringLiteral("0%"); int completed = 0; for (const QJsonValue &value : sessions) { if (value.isObject() && value.toObject().value("completedAll").toBool()) ++completed; } return QStringLiteral("%1%").arg(qRound(completed * 100.0 / sessions.size())); } QVariantList SessionController::recentSessions() const { QVariantList result; const QJsonArray sessions = sessionsArray(); for (int index = sessions.size() - 1; index >= 0 && result.size() < 5; --index) { if (!sessions.at(index).isObject()) continue; const QJsonObject session = sessions.at(index).toObject(); if (progressPlanFilter_ != "all" && session.value("planId").toString().compare(progressPlanFilter_, Qt::CaseInsensitive) != 0) { continue; } const QDateTime ended = QDateTime::fromString(session.value("endedAt").toString(), Qt::ISODate); const int workSeconds = std::max(0, session.value("totalWorkSeconds").toInt()); result.append(QVariantMap{ {"planName", session.value("planName").toString()}, {"endedAt", ended.isValid() ? ended.toString("dd.MM.yyyy HH:mm") : QStringLiteral("без даты")}, {"workMinutes", workSeconds <= 0 ? 0 : std::max(1, workSeconds / 60)}, {"completedAll", session.value("completedAll").toBool()}, {"feedback", session.value("feedback").toString()}, {"stepCount", session.value("steps").toArray().size()} }); } return result; } QVariantList SessionController::latestSessionSteps() const { const QJsonArray sessions = sessionsArray(); QJsonObject latestSession; QDateTime latestEndedAt; for (const QJsonValue &value : sessions) { if (!value.isObject()) continue; const QJsonObject session = value.toObject(); if (progressPlanFilter_ != "all" && session.value("planId").toString().compare(progressPlanFilter_, Qt::CaseInsensitive) != 0) { continue; } const QDateTime endedAt = QDateTime::fromString(session.value("endedAt").toString(), Qt::ISODate); if (!endedAt.isValid()) continue; if (!latestEndedAt.isValid() || endedAt > latestEndedAt) { latestEndedAt = endedAt; latestSession = session; } } QVariantList result; const QJsonArray steps = latestSession.value("steps").toArray(); for (int index = 0; index < steps.size(); ++index) { if (!steps.at(index).isObject()) continue; const QJsonObject step = steps.at(index).toObject(); const QString metric = step.value("metric").toString(QStringLiteral("repetitions")); result.append(QVariantMap{ {"index", index + 1}, {"exerciseName", step.value("exerciseName").toString(step.value("exerciseId").toString())}, {"metricText", metric == "seconds" ? QStringLiteral("сек") : QStringLiteral("повт")}, {"actualValue", std::max(0, step.value("actualValue").toInt())}, {"completed", step.value("completed").toBool()}, {"statusText", step.value("completed").toBool() ? QStringLiteral("выполнено") : QStringLiteral("пропущено")}, {"loadNote", step.value("loadNote").toString()} }); } return result; } QString SessionController::progressPlanFilter() const { return progressPlanFilter_; } QVariantList SessionController::progressDayBuckets() const { struct Bucket { QDate date; int sessionCount = 0; int completedCount = 0; int workSeconds = 0; }; QVector<Bucket> buckets; const QJsonArray sessions = sessionsArray(); for (const QJsonValue &value : sessions) { if (!value.isObject()) continue; const QJsonObject session = value.toObject(); if (progressPlanFilter_ != "all" && session.value("planId").toString().compare(progressPlanFilter_, Qt::CaseInsensitive) != 0) { continue; } const QDate date = QDateTime::fromString(session.value("endedAt").toString(), Qt::ISODate).date(); if (!date.isValid()) continue; auto found = std::find_if(buckets.begin(), buckets.end(), [&date](const Bucket &bucket) { return bucket.date == date; }); if (found == buckets.end()) { buckets.append(Bucket{date}); found = buckets.end() - 1; } ++found->sessionCount; if (session.value("completedAll").toBool()) { ++found->completedCount; } found->workSeconds += std::max(0, session.value("totalWorkSeconds").toInt()); } std::sort(buckets.begin(), buckets.end(), [](const Bucket &left, const Bucket &right) { return left.date > right.date; }); QVariantList result; for (const Bucket &bucket : buckets) { if (result.size() >= 7) break; const int workMinutes = bucket.workSeconds <= 0 ? 0 : std::max(1, bucket.workSeconds / 60); result.append(QVariantMap{ {"dateText", bucket.date.toString("dd.MM")}, {"sessionCount", bucket.sessionCount}, {"completedCount", bucket.completedCount}, {"workMinutes", workMinutes} }); } return result; } QVariantList SessionController::progressExerciseBuckets() const { struct Bucket { struct Entry { QDateTime endedAt; int actualValue = 0; bool completed = false; }; QString exerciseId; QString exerciseName; QString metric; int stepCount = 0; int completedCount = 0; int actualTotal = 0; QDateTime lastDoneAt; QVector<Entry> entries; }; QVector<Bucket> buckets; const QJsonArray sessions = sessionsArray(); for (const QJsonValue &sessionValue : sessions) { if (!sessionValue.isObject()) continue; const QJsonObject session = sessionValue.toObject(); if (progressPlanFilter_ != "all" && session.value("planId").toString().compare(progressPlanFilter_, Qt::CaseInsensitive) != 0) { continue; } const QDateTime endedAt = QDateTime::fromString(session.value("endedAt").toString(), Qt::ISODate); const QJsonArray steps = session.value("steps").toArray(); for (const QJsonValue &stepValue : steps) { if (!stepValue.isObject()) continue; const QJsonObject step = stepValue.toObject(); const QString exerciseId = step.value("exerciseId").toString(); if (exerciseId.isEmpty()) continue; auto found = std::find_if(buckets.begin(), buckets.end(), [&exerciseId](const Bucket &bucket) { return bucket.exerciseId.compare(exerciseId, Qt::CaseInsensitive) == 0; }); if (found == buckets.end()) { const ExerciseDefinition *definition = findExercise(exercises_, exerciseId); buckets.append(Bucket{ exerciseId, step.value("exerciseName").toString(definition ? definition->name : exerciseId), step.value("metric").toString(definition ? metricKey(definition->metric) : QStringLiteral("repetitions")) }); found = buckets.end() - 1; } ++found->stepCount; if (step.value("completed").toBool()) { ++found->completedCount; } found->actualTotal += std::max(0, step.value("actualValue").toInt()); if (endedAt.isValid() && (!found->lastDoneAt.isValid() || endedAt > found->lastDoneAt)) { found->lastDoneAt = endedAt; } if (endedAt.isValid()) { found->entries.append(Bucket::Entry{ endedAt, std::max(0, step.value("actualValue").toInt()), step.value("completed").toBool() }); } } } std::sort(buckets.begin(), buckets.end(), [](const Bucket &left, const Bucket &right) { if (left.stepCount != right.stepCount) return left.stepCount > right.stepCount; return left.exerciseName < right.exerciseName; }); QVariantList result; for (Bucket &bucket : buckets) { if (result.size() >= 8) break; std::sort(bucket.entries.begin(), bucket.entries.end(), [](const Bucket::Entry &left, const Bucket::Entry &right) { return left.endedAt > right.endedAt; }); QVariantList recentEntries; for (const Bucket::Entry &entry : bucket.entries) { if (recentEntries.size() >= 5) break; recentEntries.append(QVariantMap{ {"dateText", entry.endedAt.toString("dd.MM")}, {"actualValue", entry.actualValue}, {"completed", entry.completed} }); } result.append(QVariantMap{ {"exerciseId", bucket.exerciseId}, {"exerciseName", bucket.exerciseName}, {"metricText", bucket.metric == "seconds" ? QStringLiteral("сек") : QStringLiteral("повт")}, {"stepCount", bucket.stepCount}, {"completedCount", bucket.completedCount}, {"actualTotal", bucket.actualTotal}, {"averageActual", bucket.stepCount > 0 ? qRound(static_cast<double>(bucket.actualTotal) / bucket.stepCount) : 0}, {"lastDoneText", bucket.lastDoneAt.isValid() ? bucket.lastDoneAt.toString("dd.MM") : QStringLiteral("без даты")}, {"recentEntries", recentEntries} }); } return result; } QVariantList SessionController::exerciseLibrary() const { QVariantList result; const QString query = exerciseSearch_.trimmed(); for (const ExerciseDefinition &exercise : exercises_) { const QString metric = metricKey(exercise.metric); if (exerciseMetricFilter_ != "all" && exerciseMetricFilter_ != metric) continue; if (!query.isEmpty() && !exercise.name.contains(query, Qt::CaseInsensitive) && !exercise.id.contains(query, Qt::CaseInsensitive)) { continue; } result.append(QVariantMap{ {"id", exercise.id}, {"name", exercise.name}, {"metric", metric}, {"metricText", metricText(exercise.metric)}, {"defaultTarget", exercise.defaultTarget}, {"defaultRestSeconds", exercise.defaultRestSeconds}, {"category", exercise.category}, {"equipment", exercise.equipment}, {"description", exercise.description}, {"primaryCue", exercise.primaryCue}, {"secondaryCue", exercise.secondaryCue}, {"loadRecommendation", exercise.loadRecommendation}, {"availabilityNote", exercise.availabilityNote}, {"frameUrls", exerciseFrameUrls(exercise.id)} }); } return result; } QString SessionController::exerciseSearch() const { return exerciseSearch_; } QString SessionController::exerciseMetricFilter() const { return exerciseMetricFilter_; } void SessionController::setProgressPlanFilter(const QString &value) { QString normalized = value.trimmed(); if (normalized.isEmpty()) { normalized = QStringLiteral("all"); } if (progressPlanFilter_ == normalized) return; progressPlanFilter_ = normalized; notify(); } void SessionController::setCurrentLoadNote(const QString &value) { QString normalized = value.trimmed(); if (normalized.size() > 160) { normalized = normalized.left(160); } if (currentLoadNote_ == normalized) return; currentLoadNote_ = normalized; if (runner_) { runner_->setCurrentLoadNote(currentLoadNote_); } if (active()) { persistDraft(); } notify(); } QVariantMap SessionController::nutritionSummary() const { const QString profileId = selectedProfileId(); QString preset = normalizeNutritionPreset(root_.value("nutritionPresetId").toString("balanced")); if (preset.isEmpty()) { preset = QStringLiteral("balanced"); } const QJsonArray weights = sortedObjectsByDateDesc( objectsForProfile(root_.value("bodyweightHistory").toArray(), profileId), QStringLiteral("loggedAt")); const double weight = !weights.isEmpty() && weights.first().isObject() ? weights.first().toObject().value("weightKg").toDouble(75.0) : profileId == "default" ? root_.value("currentBodyWeightKg").toDouble(75.0) : 75.0; const int trainingCalories = roundToStep(weight * 40.0, 50); const int recoveryCalories = std::max(2200, trainingCalories - 250); const int proteinTarget = roundToStep(std::max(140.0, weight * 2.1), 5); const QJsonArray measurements = sortedObjectsByDateDesc(objectsForProfile(root_.value("bodyMeasurementHistory").toArray(), profileId), QStringLiteral("loggedAt")); const QJsonArray photos = sortedObjectsByDateDesc( objectsForProfile(root_.value("photoProgressHistory").toArray(), profileId), QStringLiteral("loggedAt")); const QJsonArray recovery = sortedObjectsByDateDesc(objectsForProfile(root_.value("recoveryCheckInHistory").toArray(), profileId), QStringLiteral("loggedAt")); const QJsonArray adherence = sortedObjectsByDateDesc(objectsForProfile(root_.value("nutritionAdherenceHistory").toArray(), profileId), QStringLiteral("date")); QString currentWeightText = QStringLiteral("%1 кг").arg(formatDecimal(weight)); QString weightLastLoggedText = QStringLiteral("Записей пока нет"); if (!weights.isEmpty() && weights.first().isObject()) { const QJsonObject latest = weights.first().toObject(); currentWeightText = QStringLiteral("%1 кг").arg(formatDecimal(latest.value("weightKg").toDouble(weight))); weightLastLoggedText = QStringLiteral("Последняя запись: %1").arg(dateTimeText(latest.value("loggedAt").toString())); } QString ratioText = QStringLiteral("Нужна первая запись замеров"); if (!measurements.isEmpty() && measurements.first().isObject()) { const QJsonObject latest = measurements.first().toObject(); const double waist = latest.value("waistCm").toDouble(); const double shoulders = latest.value("shouldersCm").toDouble(); if (waist > 0 && shoulders > 0) { ratioText = QStringLiteral("%1 плечи/талия").arg(formatDecimal(shoulders / waist)); } } QString recoveryText = QStringLiteral("Recovery check-in пока не заполнен"); if (!recovery.isEmpty()) { int count = 0; int energy = 0; int sleep = 0; int joints = 0; for (const QJsonValue &value : recovery) { if (!value.isObject() || count >= 7) continue; const QJsonObject item = value.toObject(); energy += item.value("energyScore").toInt(); sleep += item.value("sleepScore").toInt(); joints += item.value("jointScore").toInt(); ++count; } if (count > 0) { recoveryText = QStringLiteral("%1 отметок • энергия %2/5 • сон %3/5 • суставы %4/5") .arg(count) .arg(formatDecimal(static_cast<double>(energy) / count)) .arg(formatDecimal(static_cast<double>(sleep) / count)) .arg(formatDecimal(static_cast<double>(joints) / count)); } } QString adherenceText = QStringLiteral("Отметок питания пока нет"); if (!adherence.isEmpty()) { int count = 0; int good = 0; int partial = 0; int off = 0; for (const QJsonValue &value : adherence) { if (!value.isObject() || count >= 7) continue; const QString status = value.toObject().value("statusId").toString(); if (status == "good") ++good; else if (status == "partial") ++partial; else if (status == "off") ++off; ++count; } if (count > 0) { const int score = qRound(((good + partial * 0.5) * 100.0) / count); adherenceText = QStringLiteral("%1% за %2 отметок • %3 ok • %4 частично • %5 срыв") .arg(score) .arg(count) .arg(good) .arg(partial) .arg(off); } } return { {"trainingCaloriesText", QStringLiteral("%1–%2").arg(std::max(2600, trainingCalories - 100)).arg(trainingCalories + 100)}, {"recoveryCaloriesText", QStringLiteral("%1–%2").arg(std::max(2300, recoveryCalories - 100)).arg(recoveryCalories + 50)}, {"proteinTargetText", QStringLiteral("%1–%2 г").arg(proteinTarget).arg(proteinTarget + 10)}, {"currentWeightText", currentWeightText}, {"weightLastLoggedText", weightLastLoggedText}, {"ratioText", ratioText}, {"weightEntryCount", weights.size()}, {"measurementEntryCount", measurements.size()}, {"photoEntryCount", photos.size()}, {"adherenceEntryCount", adherence.size()}, {"adherenceText", adherenceText}, {"recoveryText", recoveryText}, {"presetText", preset} }; } QVariantList SessionController::nutritionMeals() const { QString preset = normalizeNutritionPreset(root_.value("nutritionPresetId").toString("balanced")); if (preset.isEmpty()) { preset = QStringLiteral("balanced"); } if (preset == "cheap") { return { QVariantMap{{"title", "1. Завтрак"}, {"description", "Овсянка, 4 яйца, банан, немного масла или пасты."}, {"macroText", "850–950 ккал • 35–40 г белка"}}, QVariantMap{{"title", "2. Обед"}, {"description", "Рис или макароны, куриное бедро, лук, морковь, капуста."}, {"macroText", "900–1000 ккал • 50–60 г белка"}}, QVariantMap{{"title", "3. После тренировки"}, {"description", "Творог, хлеб или лаваш, мед/варенье, яблоко."}, {"macroText", "500–650 ккал • 35–45 г белка"}}, QVariantMap{{"title", "4. Ужин"}, {"description", "Гречка или картофель, фасоль/сардина/скумбрия, овощи."}, {"macroText", "700–850 ккал • 30–40 г белка"}} }; } if (preset == "low-appetite") { return { QVariantMap{{"title", "1. Легкий старт"}, {"description", "Кефир, овсянка, банан, мед, 2 яйца."}, {"macroText", "650–800 ккал • 25–30 г белка"}}, QVariantMap{{"title", "2. Основной прием"}, {"description", "Рис, курица, овощи, немного масла."}, {"macroText", "850–950 ккал • 45–55 г белка"}}, QVariantMap{{"title", "3. Добор после тренировки"}, {"description", "Творог или йогурт, банан, хлеб, джем."}, {"macroText", "500–600 ккал • 30–40 г белка"}}, QVariantMap{{"title", "4. Вечерний прием"}, {"description", "Макароны или картофель, рыба/яйца, огурцы и помидоры."}, {"macroText", "700–850 ккал • 30–35 г белка"}} }; } return { QVariantMap{{"title", "1. Завтрак"}, {"description", "Овсянка 120 г, 4 яйца, 2 банана, 30 г арахисовой пасты."}, {"macroText", "850–950 ккал • 38–42 г белка"}}, QVariantMap{{"title", "2. Обед"}, {"description", "Рис или гречка, курица 300–350 г, овощи, немного масла."}, {"macroText", "900–1000 ккал • 50–60 г белка"}}, QVariantMap{{"title", "3. После тренировки"}, {"description", "Творог 250–300 г, мед/варенье, хлеб или лаваш, фрукт."}, {"macroText", "500–650 ккал • 35–45 г белка"}}, QVariantMap{{"title", "4. Ужин"}, {"description", "Макароны или картофель, рыба в банке или фасоль с яйцами."}, {"macroText", "700–850 ккал • 30–40 г белка"}} }; } QVariantList SessionController::nutritionWeightEntries() const { QVariantList result; const QJsonArray weights = sortedObjectsByDateDesc( objectsForProfile(root_.value("bodyweightHistory").toArray(), selectedProfileId()), QStringLiteral("loggedAt")); for (int index = 0; index < weights.size() && result.size() < 6; ++index) { const QJsonObject item = weights.at(index).toObject(); result.append(QVariantMap{ {"loggedAt", dateTimeText(item.value("loggedAt").toString())}, {"weightText", QStringLiteral("%1 кг").arg(formatDecimal(item.value("weightKg").toDouble()))} }); } return result; } QVariantList SessionController::weightChartData() const { QVariantList result; const QJsonArray weights = sortedObjectsByDateDesc( objectsForProfile(root_.value("bodyweightHistory").toArray(), selectedProfileId()), QStringLiteral("loggedAt")); const int count = qMin(weights.size(), 30); for (int index = count - 1; index >= 0; --index) { const QJsonObject item = weights.at(index).toObject(); const double weightKg = item.value("weightKg").toDouble(); if (weightKg > 0) { result.append(QVariantMap{ {"date", dateTimeText(item.value("loggedAt").toString())}, {"weight", weightKg} }); } } return result; } QVariantList SessionController::nutritionMeasurementEntries() const { QVariantList result; const QJsonArray measurements = sortedObjectsByDateDesc( objectsForProfile(root_.value("bodyMeasurementHistory").toArray(), selectedProfileId()), QStringLiteral("loggedAt")); for (int index = 0; index < measurements.size() && result.size() < 6; ++index) { const QJsonObject item = measurements.at(index).toObject(); const double waist = item.value("waistCm").toDouble(); const double shoulders = item.value("shouldersCm").toDouble(); result.append(QVariantMap{ {"loggedAt", dateTimeText(item.value("loggedAt").toString())}, {"waistText", QStringLiteral("%1 см").arg(formatDecimal(waist))}, {"shouldersText", QStringLiteral("%1 см").arg(formatDecimal(shoulders))}, {"chestText", QStringLiteral("%1 см").arg(formatDecimal(item.value("chestCm").toDouble()))}, {"armText", QStringLiteral("%1 см").arg(formatDecimal(item.value("armCm").toDouble()))}, {"ratioText", waist > 0 ? formatDecimal(shoulders / waist) : QStringLiteral("—")} }); } return result; } QVariantList SessionController::nutritionAdherenceDays() const { QVariantList result; const QJsonArray days = sortedObjectsByDateDesc( objectsForProfile(root_.value("nutritionAdherenceHistory").toArray(), selectedProfileId()), QStringLiteral("date")); for (int index = 0; index < days.size() && result.size() < 10; ++index) { const QJsonObject item = days.at(index).toObject(); const QString status = item.value("statusId").toString(); result.append(QVariantMap{ {"date", dateText(item.value("date").toString())}, {"statusId", status}, {"statusText", status == "good" ? QStringLiteral("выполнено") : status == "partial" ? QStringLiteral("частично") : status == "off" ? QStringLiteral("срыв") : QStringLiteral("нет данных")} }); } return result; } QVariantList SessionController::nutritionPhotoEntries() const { QVariantList result; const QJsonArray photos = sortedObjectsByDateDesc( objectsForProfile(root_.value("photoProgressHistory").toArray(), selectedProfileId()), QStringLiteral("loggedAt")); for (int index = 0; index < photos.size() && result.size() < 8; ++index) { const QJsonObject item = photos.at(index).toObject(); const QString imagePath = item.value("imagePath").toString(); result.append(QVariantMap{ {"loggedAt", dateTimeText(item.value("loggedAt").toString())}, {"imagePath", imagePath}, {"fileName", QFileInfo(imagePath).fileName().isEmpty() ? imagePath : QFileInfo(imagePath).fileName()} }); } return result; } QVariantList SessionController::recoveryCheckIns() const { QVariantList result; const QJsonArray checkIns = sortedObjectsByDateDesc( objectsForProfile(root_.value("recoveryCheckInHistory").toArray(), selectedProfileId()), QStringLiteral("loggedAt")); for (int index = 0; index < checkIns.size() && result.size() < 5; ++index) { const QJsonObject item = checkIns.at(index).toObject(); result.append(QVariantMap{ {"loggedAt", dateTimeText(item.value("loggedAt").toString())}, {"planName", item.value("planName").toString()}, {"scoreText", QStringLiteral("Энергия %1/5 • Сон %2/5 • Суставы %3/5") .arg(item.value("energyScore").toInt()) .arg(item.value("sleepScore").toInt()) .arg(item.value("jointScore").toInt())}, {"note", item.value("note").toString()} }); } return result; } QVariantList SessionController::completedStepsForCurrentExercise() const { QVariantList result; if (!runner_ || !active()) return result; const SessionSnapshot snap = runner_->snapshot(); const WorkoutPlan *plan = selectedPlan(); if (!plan || snap.stepIndex < 0 || snap.stepIndex >= plan->steps.size()) return result; const QString currentExerciseId = plan->steps.at(snap.stepIndex).exerciseId; const QJsonArray sessions = sessionsArray(); if (!sessions.isEmpty()) { const QJsonObject lastSession = sessions.last().toObject(); const QJsonArray steps = lastSession.value("steps").toArray(); for (const QJsonValue &v : steps) { if (!v.isObject()) continue; const QJsonObject step = v.toObject(); if (step.value("exerciseId").toString() == currentExerciseId) { result.append(QVariantMap{ {"actualValue", step.value("actualValue").toInt()}, {"completed", step.value("completed").toBool()}, {"loadNote", step.value("loadNote").toString()} }); } } } return result; } QVariantList SessionController::workoutCalendar() const { QVariantList result; const QDate today = QDate::currentDate(); const QDate monthStart = QDate(today.year(), today.month(), 1); const int daysInMonth = monthStart.daysInMonth(); const QJsonArray sessions = sessionsArray(); QHash<QDate, int> sessionsPerDay; QHash<QDate, bool> completedPerDay; for (const QJsonValue &value : sessions) { if (!value.isObject()) continue; const QJsonObject session = value.toObject(); const QDateTime endedAt = QDateTime::fromString(session.value("endedAt").toString(), Qt::ISODate); if (!endedAt.isValid()) continue; const QDate date = endedAt.date(); if (date.month() != today.month() || date.year() != today.year()) continue; ++sessionsPerDay[date]; if (session.value("completedAll").toBool()) completedPerDay[date] = true; } for (int day = 1; day <= daysInMonth; ++day) { const QDate date(today.year(), today.month(), day); const int count = sessionsPerDay.value(date, 0); result.append(QVariantMap{ {"day", day}, {"dayOfWeek", date.toString("ddd")}, {"sessionCount", count}, {"completed", completedPerDay.value(date, false)}, {"isToday", date == today}, {"isFuture", date > today}, {"hasWorkout", count > 0} }); } return result; } QVariantMap SessionController::weeklySummary() const { const QDate today = QDate::currentDate(); const QDate weekStart = today.addDays(-6); const QJsonArray sessions = sessionsArray(); int totalSessions = 0, totalWorkSeconds = 0, completedSessions = 0, totalSteps = 0, completedSteps = 0; QHash<QString, int> exerciseVolume; for (const QJsonValue &value : sessions) { if (!value.isObject()) continue; const QJsonObject session = value.toObject(); const QDateTime endedAt = QDateTime::fromString(session.value("endedAt").toString(), Qt::ISODate); if (!endedAt.isValid() || endedAt.date() < weekStart) continue; ++totalSessions; totalWorkSeconds += std::max(0, session.value("totalWorkSeconds").toInt()); if (session.value("completedAll").toBool()) ++completedSessions; const QJsonArray steps = session.value("steps").toArray(); for (const QJsonValue &sv : steps) { if (!sv.isObject()) continue; const QJsonObject step = sv.toObject(); ++totalSteps; if (step.value("completed").toBool()) ++completedSteps; const QString exId = step.value("exerciseId").toString(); if (!exId.isEmpty()) exerciseVolume[exId] += std::max(0, step.value("actualValue").toInt()); } } QVariantList topExercises; QVector<QPair<QString, int>> sorted; for (auto it = exerciseVolume.constBegin(); it != exerciseVolume.constEnd(); ++it) sorted.append({it.key(), it.value()}); std::sort(sorted.begin(), sorted.end(), [](const auto &a, const auto &b) { return a.second > b.second; }); for (int i = 0; i < std::min(static_cast<int>(sorted.size()), 5); ++i) { const ExerciseDefinition *def = findExercise(exercises_, sorted[i].first); topExercises.append(QVariantMap{{"exerciseId", sorted[i].first}, {"exerciseName", def ? def->name : sorted[i].first}, {"totalVolume", sorted[i].second}}); } return {{"totalSessions", totalSessions}, {"totalWorkMinutes", totalWorkSeconds <= 0 ? 0 : std::max(1, totalWorkSeconds / 60)}, {"completedSessions", completedSessions}, {"completionRate", totalSessions > 0 ? qRound(completedSessions * 100.0 / totalSessions) : 0}, {"totalSteps", totalSteps}, {"completedSteps", completedSteps}, {"weekStart", weekStart.toString("dd.MM")}, {"weekEnd", today.toString("dd.MM")}, {"topExercises", topExercises}}; } QVariantList SessionController::undertrainedExercises() const { const QDate today = QDate::currentDate(); const QDate twoWeeksAgo = today.addDays(-14); const QJsonArray sessions = sessionsArray(); QHash<QString, QDateTime> lastDone; QHash<QString, int> totalDone; for (const QJsonValue &value : sessions) { if (!value.isObject()) continue; const QJsonObject session = value.toObject(); const QDateTime endedAt = QDateTime::fromString(session.value("endedAt").toString(), Qt::ISODate); if (!endedAt.isValid()) continue; const QJsonArray steps = session.value("steps").toArray(); for (const QJsonValue &sv : steps) { if (!sv.isObject()) continue; const QJsonObject step = sv.toObject(); const QString exId = step.value("exerciseId").toString(); if (exId.isEmpty()) continue; ++totalDone[exId]; if (!lastDone.contains(exId) || endedAt > lastDone[exId]) lastDone[exId] = endedAt; } } QVariantList result; for (const ExerciseDefinition &exercise : exercises_) { const QDateTime last = lastDone.value(exercise.id); const int count = totalDone.value(exercise.id, 0); if (count == 0 || (last.isValid() && last.date() < twoWeeksAgo)) { result.append(QVariantMap{{"exerciseId", exercise.id}, {"exerciseName", exercise.name}, {"category", exercise.category}, {"lastDoneText", last.isValid() ? last.toString("dd.MM") : "никогда"}, {"totalSessions", count}}); } } return result; } QString SessionController::recommendedRestText() const { const SessionSnapshot view = snapshot(); if (view.phase != SessionPhase::rest) return {}; const WorkoutPlan *plan = selectedPlan(); if (!plan || view.stepIndex < 0 || view.stepIndex >= plan->steps.size()) return {}; const QString exerciseId = plan->steps.at(view.stepIndex).exerciseId; const ExerciseDefinition *exercise = findExercise(exercises_, exerciseId); if (!exercise) return {}; const int recommended = exercise->defaultRestSeconds; if (recommended <= 0) return {}; return QStringLiteral("Рекомендуется %1 сек отдыха").arg(recommended); } void SessionController::playBeep(int frequencyHz, int durationMs) { #ifdef Q_OS_WIN if (!soundsEnabled_) return; Beep(frequencyHz, durationMs); #else Q_UNUSED(frequencyHz) Q_UNUSED(durationMs) #endif } bool SessionController::soundsEnabled() const { return soundsEnabled_; } void SessionController::setSoundsEnabled(bool enabled) { if (soundsEnabled_ == enabled) return; soundsEnabled_ = enabled; root_.insert("soundsEnabled", enabled); if (store_) store_->save(root_); notify(); } double SessionController::uiScale() const { return uiScale_; } void SessionController::setUiScale(double scale) { scale = std::clamp(scale, 0.75, 2.0); if (qFuzzyCompare(uiScale_, scale)) return; uiScale_ = scale; root_.insert("uiScale", scale); if (store_) store_->save(root_); notify(); } bool SessionController::darkMode() const { return darkMode_; } void SessionController::setDarkMode(bool dark) { if (darkMode_ == dark) return; darkMode_ = dark; root_.insert("darkMode", dark); if (store_) store_->save(root_); notify(); } bool SessionController::canUndo() const { return !planUndoStack_.isEmpty(); } QString SessionController::lastSessionPlanId() const { const QJsonArray s = sessionsArray(); return s.isEmpty() ? QString() : s.last().toObject().value("planId").toString(); } void SessionController::repeatLastSession() { const QString id = lastSessionPlanId(); if (!id.isEmpty()) selectPlan(id); } void SessionController::undoLastPlanEdit() { if (planUndoStack_.isEmpty() || active() || needsSave() || hasRecoverableDraft() || !store_) return; const QJsonObject savedPlan = planUndoStack_.last().toObject(); planUndoStack_.removeLast(); const QString planId = savedPlan.value("id").toString(); QJsonArray allPlans = root_.value("plans").toArray(); for (int i = 0; i < allPlans.size(); ++i) { if (!allPlans.at(i).isObject()) continue; if (allPlans.at(i).toObject().value("id").toString().compare(planId, Qt::CaseInsensitive) == 0) { allPlans.replace(i, savedPlan); break; } } root_.insert("plans", allPlans); const ImportResult imported = importNativeState(QJsonDocument(root_).toJson()); if (imported.ok) state_ = imported.state; if (store_) store_->save(root_); status_ = QStringLiteral("Отменено"); notify(); } void SessionController::exportCsv(const QString &destinationPath) { if (!store_) return; QString normalizedPath = destinationPath.trimmed(); const QUrl url(normalizedPath); if (url.isLocalFile()) normalizedPath = url.toLocalFile(); if (normalizedPath.isEmpty()) { status_ = QStringLiteral("Выберите файл"); notify(); return; } QFile file(normalizedPath); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { status_ = QStringLiteral("Ошибка: %1").arg(file.errorString()); notify(); return; } QTextStream out(&file); out.setEncoding(QStringConverter::Utf8); out << "Дата,План,Упражнение,Метрика,Цель,Факт,Выполнено,Отдых,Заметка\n"; const QJsonArray sessions = sessionsArray(); for (const QJsonValue &value : sessions) { if (!value.isObject()) continue; const QJsonObject session = value.toObject(); const QJsonArray steps = session.value("steps").toArray(); for (const QJsonValue &sv : steps) { if (!sv.isObject()) continue; const QJsonObject step = sv.toObject(); out << session.value("endedAt").toString() << "," << session.value("planName").toString() << "," << step.value("exerciseName").toString() << "," << step.value("metric").toString("repetitions") << "," << step.value("targetValue").toInt() << "," << step.value("actualValue").toInt() << "," << (step.value("completed").toBool() ? "да" : "нет") << "," << step.value("restSeconds").toInt() << "," << step.value("loadNote").toString() << "\n"; } } file.close(); status_ = QStringLiteral("CSV экспортирован: %1").arg(QDir::toNativeSeparators(normalizedPath)); notify(); } QVariantMap SessionController::applicationInfo() const { return { {"applicationName", QStringLiteral("BodyweightBaseCpp")}, {"applicationVersion", QStringLiteral(BODYWEIGHTBASE_VERSION)}, {"uiStack", QStringLiteral("C++20 / Qt Quick")}, {"qtVersion", QString::fromLatin1(qVersion())}, {"storageDirectory", QDir::toNativeSeparators(storageDirectory_)}, {"exerciseFrameDirectory", QDir::toNativeSeparators(exerciseFrameDirectory_)}, {"thirdPartyNoticeFile", QStringLiteral("THIRD_PARTY_NOTICES.txt")}, {"privacyNoticeFile", QStringLiteral("PRIVACY_AND_DATA.txt")}, {"qtLicenseNotice", QStringLiteral("Qt runtime is dynamically deployed; see THIRD_PARTY_NOTICES.txt in the package.")}, {"privacyNotice", QStringLiteral("Данные хранятся локально; см. PRIVACY_AND_DATA.txt в portable-пакете.")} }; } void SessionController::setExerciseSearch(const QString &value) { if (exerciseSearch_ == value) return; exerciseSearch_ = value; notify(); } void SessionController::setExerciseMetricFilter(const QString &value) { const QString normalized = value == "seconds" || value == "repetitions" ? value : QStringLiteral("all"); if (exerciseMetricFilter_ == normalized) return; exerciseMetricFilter_ = normalized; notify(); } void SessionController::start() { if (!ready()) return; QString error; if (!runner_->start(*selectedPlan(), &error)) { status_ = error; } else { sessionStartedAtLocal_ = QDateTime::currentDateTime(); currentLoadNote_.clear(); status_ = QStringLiteral("Тренировка идёт"); persistDraft(); } notify(); } void SessionController::advanceOneSecond() { if (!runner_) return; runner_->advance(1); captureFinishedSession(); if (active() && ++secondsSinceDraftSave_ >= 5) persistDraft(); notify(); } void SessionController::skipCountdown() { if (!runner_) return; runner_->skipCountdown(); captureFinishedSession(); if (active()) persistDraft(); notify(); } void SessionController::completeCurrent() { if (!runner_) return; const SessionPhase phase = snapshot().phase; if (phase == SessionPhase::repetitionExercise) runner_->completeRepExercise(); else if (phase == SessionPhase::preparation || phase == SessionPhase::rest) runner_->skipCountdown(); captureFinishedSession(); if (active()) persistDraft(); notify(); } void SessionController::skipCurrentExercise() { if (!runner_) return; runner_->skipCurrentExercise(); captureFinishedSession(); if (active()) persistDraft(); notify(); } void SessionController::addRep() { if (runner_) { runner_->addRep(); persistDraft(); notify(); } } void SessionController::removeRep() { if (runner_) { runner_->removeRep(); persistDraft(); notify(); } } void SessionController::togglePause() { if (!runner_) return; paused() ? runner_->resume() : runner_->pause(); persistDraft(); notify(); } void SessionController::saveFinishedSession(const QString &feedbackLabel, bool applyAdaptation) { if (!pendingFinishedSession_ || !store_) return; QJsonObject updated = root_; QString error; if (!StateStore::appendFinishedSession( updated, *pendingFinishedSession_, sessionStartedAtLocal_, sessionEndedAtLocal_, feedbackLabel, &error)) { status_ = QStringLiteral("Ошибка сохранения: %1").arg(error); notify(); return; } QJsonArray sessions = updated.value("sessions").toArray(); if (!sessions.isEmpty() && sessions.last().isObject()) { QJsonObject savedSession = sessions.last().toObject(); savedSession.insert("profileId", selectedProfileId()); sessions.replace(sessions.size() - 1, savedSession); updated.insert("sessions", sessions); } const bool adapted = applyAdaptationToPlan( updated, pendingFinishedSession_->planId, exercises_, feedbackLabel, pendingFinishedSession_->completedAll) && applyAdaptation; if (!applyAdaptation) { updated.insert("plans", root_.value("plans")); } if ( !store_->save(updated, &error)) { status_ = QStringLiteral("Ошибка сохранения: %1").arg(error); notify(); return; } root_ = updated; const ImportResult imported = importNativeState(QJsonDocument(root_).toJson()); if (imported.ok) { state_ = imported.state; } pendingFinishedSession_.reset(); status_ = adapted ? QStringLiteral("Тренировка сохранена. План адаптирован на следующий раз.") : QStringLiteral("Тренировка сохранена. Создана резервная копия."); notify(); } QVariantMap SessionController::pendingResultDetails() const { if (!pendingFinishedSession_) return {}; const int workMinutes = pendingFinishedSession_->totalWorkSeconds <= 0 ? 0 : std::max(1, pendingFinishedSession_->totalWorkSeconds / 60); int completedSteps = 0; for (const CompletedExercise &step : pendingFinishedSession_->steps) { if (step.completed) ++completedSteps; } return QVariantMap{ {"planName", pendingFinishedSession_->planName}, {"workMinutes", workMinutes}, {"stepCount", pendingFinishedSession_->steps.size()}, {"completedStepCount", completedSteps}, {"completedAll", pendingFinishedSession_->completedAll}, {"summary", pendingResultSummary()} }; } QString SessionController::adaptationPreview(const QString &feedbackLabel) const { if (!pendingFinishedSession_) return {}; return adaptationText(feedbackLabel, pendingFinishedSession_->completedAll); } void SessionController::discardFinishedSession() { pendingFinishedSession_.reset(); QJsonObject updated = root_; StateStore::clearPendingDraft(updated); QString error; if (store_ && store_->save(updated, &error)) { root_ = updated; status_ = QStringLiteral("Результат тренировки не сохранён"); } else { status_ = QStringLiteral("Не удалось очистить черновик: %1").arg(error); } notify(); } void SessionController::abortActiveWorkout() { if (!runner_ || !active() || !store_) return; runner_->abort(); currentLoadNote_.clear(); QJsonObject updated = root_; StateStore::clearPendingDraft(updated); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось отменить тренировку: %1").arg(error); notify(); return; } root_ = updated; status_ = QStringLiteral("Тренировка отменена"); notify(); } void SessionController::restoreDraft() { if (!recoverableDraft_ || !runner_) return; QString error; if (!runner_->restore(recoverableDraft_->draft, &error)) { status_ = QStringLiteral("Черновик не восстановлен: %1").arg(error); notify(); return; } sessionStartedAtLocal_ = recoverableDraft_->sessionStartedAtLocal; currentLoadNote_ = recoverableDraft_->draft.currentLoadNote; recoverableDraft_.reset(); status_ = QStringLiteral("Незавершённая тренировка восстановлена"); notify(); } void SessionController::discardDraft() { if (!recoverableDraft_ || !store_) return; QJsonObject updated = root_; StateStore::clearPendingDraft(updated); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось удалить черновик: %1").arg(error); notify(); return; } root_ = updated; recoverableDraft_.reset(); status_ = QStringLiteral("Черновик удалён"); notify(); } void SessionController::selectProfile(const QString &profileId) { if (active() || needsSave() || hasRecoverableDraft() || !store_) return; const QString normalizedId = profileId.trimmed(); const QJsonArray profiles = profilesArrayOrDefault(root_); if (!profileExists(profiles, normalizedId)) { status_ = QStringLiteral("Профиль не найден"); notify(); return; } QJsonObject updated = root_; updated.insert("profiles", profiles); updated.insert("selectedProfileId", normalizedId); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось выбрать профиль: %1").arg(error); notify(); return; } root_ = updated; status_ = QStringLiteral("Активный профиль: %1").arg(selectedProfileName()); notify(); } void SessionController::createProfile(const QString &name) { if (active() || needsSave() || hasRecoverableDraft() || !store_) return; QString normalizedName = name.trimmed(); if (normalizedName.isEmpty()) { status_ = QStringLiteral("Введите имя профиля"); notify(); return; } if (normalizedName.size() > 40) { normalizedName = normalizedName.left(40); } QJsonArray profiles = profilesArrayOrDefault(root_); const QString id = uniqueProfileId(profileIdFromName(normalizedName), profiles); profiles.append(QJsonObject{{"id", id}, {"name", normalizedName}}); QJsonObject updated = root_; updated.insert("profiles", profiles); updated.insert("selectedProfileId", id); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось создать профиль: %1").arg(error); notify(); return; } root_ = updated; status_ = QStringLiteral("Создан профиль: %1").arg(normalizedName); notify(); } void SessionController::selectPlan(const QString &planId) { if (active() || needsSave() || hasRecoverableDraft() || !store_) return; auto found = std::find_if(state_.workoutPlans.cbegin(), state_.workoutPlans.cend(), [&planId](const WorkoutPlan &plan) { return plan.id.compare(planId, Qt::CaseInsensitive) == 0; }); if (found == state_.workoutPlans.cend()) { status_ = QStringLiteral("План не найден"); notify(); return; } QJsonObject updated = root_; updated.insert("selectedPlanId", found->id); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось сохранить выбор плана: %1").arg(error); notify(); return; } root_ = updated; state_.selectedPlanId = found->id; status_ = QStringLiteral("Выбран план: %1").arg(found->name); notify(); } void SessionController::duplicateSelectedPlan(const QString &name) { if (active() || needsSave() || hasRecoverableDraft() || !store_) return; QString normalizedName = name.trimmed(); if (normalizedName.isEmpty()) { status_ = QStringLiteral("Введите название нового плана"); notify(); return; } if (normalizedName.size() > 80) { normalizedName = normalizedName.left(80); } const WorkoutPlan *plan = selectedPlan(); if (!plan) { status_ = QStringLiteral("План не найден"); notify(); return; } if (state_.workoutPlans.size() >= 50) { status_ = QStringLiteral("Достигнут лимит 50 планов"); notify(); return; } const QString newId = uniquePlanId(planIdFromName(normalizedName), state_.workoutPlans); QJsonArray plans = root_.value("plans").toArray(); bool foundSource = false; for (const QJsonValue &value : root_.value("plans").toArray()) { if (!value.isObject()) continue; QJsonObject object = value.toObject(); if (object.value("id").toString().compare(plan->id, Qt::CaseInsensitive) != 0) continue; object.insert("id", newId); object.insert("name", normalizedName); object.insert("basePlanId", plan->id); plans.append(object); foundSource = true; break; } if (!foundSource) { status_ = QStringLiteral("План не найден в состоянии"); notify(); return; } QJsonObject updated = root_; updated.insert("plans", plans); updated.insert("selectedPlanId", newId); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось создать план: %1").arg(error); notify(); return; } root_ = updated; WorkoutPlan copy = *plan; copy.id = newId; copy.name = normalizedName; copy.basePlanId = plan->id; state_.workoutPlans.append(std::move(copy)); state_.selectedPlanId = newId; status_ = QStringLiteral("Создан план: %1").arg(normalizedName); notify(); } void SessionController::deleteSelectedPlan() { if (active() || needsSave() || hasRecoverableDraft() || !store_) return; if (state_.workoutPlans.size() <= 1) { status_ = QStringLiteral("Нельзя удалить последний план"); notify(); return; } const WorkoutPlan *plan = selectedPlan(); if (!plan) { status_ = QStringLiteral("План не найден"); notify(); return; } const QString deletedId = plan->id; QJsonArray updatedPlans; bool removedPlan = false; for (const QJsonValue &value : root_.value("plans").toArray()) { if (!value.isObject()) continue; const QJsonObject object = value.toObject(); if (object.value("id").toString().compare(deletedId, Qt::CaseInsensitive) == 0) { removedPlan = true; continue; } updatedPlans.append(object); } if (!removedPlan || updatedPlans.isEmpty()) { status_ = QStringLiteral("План не найден в состоянии"); notify(); return; } const QString nextSelectedId = updatedPlans.first().toObject().value("id").toString(); QJsonObject updated = root_; updated.insert("plans", updatedPlans); updated.insert("selectedPlanId", nextSelectedId); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось удалить план: %1").arg(error); notify(); return; } root_ = updated; for (int index = 0; index < state_.workoutPlans.size(); ++index) { if (state_.workoutPlans.at(index).id.compare(deletedId, Qt::CaseInsensitive) == 0) { state_.workoutPlans.removeAt(index); break; } } state_.selectedPlanId = nextSelectedId; status_ = QStringLiteral("План удалён"); notify(); } void SessionController::renameSelectedPlan(const QString &name, const QString &goal) { if (active() || needsSave() || hasRecoverableDraft() || !store_) return; QString normalizedName = name.trimmed(); QString normalizedGoal = goal.trimmed(); if (normalizedName.isEmpty()) { status_ = QStringLiteral("Введите название плана"); notify(); return; } if (normalizedName.size() > 80) { normalizedName = normalizedName.left(80); } if (normalizedGoal.size() > 160) { normalizedGoal = normalizedGoal.left(160); } const WorkoutPlan *plan = selectedPlan(); if (!plan) { status_ = QStringLiteral("План не найден"); notify(); return; } QJsonArray plans = root_.value("plans").toArray(); bool updatedPlan = false; for (int index = 0; index < plans.size(); ++index) { if (!plans.at(index).isObject()) continue; QJsonObject object = plans.at(index).toObject(); if (object.value("id").toString().compare(plan->id, Qt::CaseInsensitive) != 0) continue; object.insert("name", normalizedName); object.insert("goal", normalizedGoal); plans.replace(index, object); updatedPlan = true; break; } if (!updatedPlan) { status_ = QStringLiteral("План не найден в состоянии"); notify(); return; } QJsonObject updated = root_; updated.insert("plans", plans); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось сохранить план: %1").arg(error); notify(); return; } root_ = updated; for (WorkoutPlan &item : state_.workoutPlans) { if (item.id.compare(plan->id, Qt::CaseInsensitive) == 0) { item.name = normalizedName; item.goal = normalizedGoal; break; } } status_ = QStringLiteral("План сохранён: %1").arg(normalizedName); notify(); } void SessionController::updateSelectedPlanStep( int stepIndex, const QString &exerciseId, const QString &targetText, const QString &restSecondsText, const QString &coachNote) { if (active() || needsSave() || hasRecoverableDraft() || !store_) return; const WorkoutPlan *plan = selectedPlan(); if (!plan || stepIndex < 0 || stepIndex >= plan->steps.size()) { status_ = QStringLiteral("Шаг плана не найден"); notify(); return; } const QString normalizedExerciseId = exerciseId.trimmed(); const ExerciseDefinition *exercise = findExercise(exercises_, normalizedExerciseId); if (!exercise) { status_ = QStringLiteral("Упражнение не найдено в каталоге"); notify(); return; } bool targetOk = false; bool restOk = false; const int target = targetText.trimmed().toInt(&targetOk); const int restSeconds = restSecondsText.trimmed().toInt(&restOk); if (!targetOk || target < 1 || target > 999 || !restOk || restSeconds < 0 || restSeconds > 900) { status_ = QStringLiteral("Цель должна быть 1..999, отдых 0..900 секунд"); notify(); return; } QString normalizedNote = coachNote.trimmed(); if (normalizedNote.size() > 160) { normalizedNote = normalizedNote.left(160); } QJsonArray plans = root_.value("plans").toArray(); bool updatedStep = false; for (int planIndex = 0; planIndex < plans.size(); ++planIndex) { if (!plans.at(planIndex).isObject()) continue; QJsonObject planObject = plans.at(planIndex).toObject(); if (planObject.value("id").toString().compare(plan->id, Qt::CaseInsensitive) != 0) continue; QJsonArray steps = planObject.value("steps").toArray(); if (stepIndex >= steps.size() || !steps.at(stepIndex).isObject()) break; QJsonObject stepObject = steps.at(stepIndex).toObject(); stepObject.insert("exerciseId", normalizedExerciseId); stepObject.insert("targetOverride", target); stepObject.insert("restSecondsOverride", restSeconds); if (normalizedNote.isEmpty()) { stepObject.remove("coachNote"); } else { stepObject.insert("coachNote", normalizedNote); } steps.replace(stepIndex, stepObject); planObject.insert("steps", steps); plans.replace(planIndex, planObject); updatedStep = true; break; } if (!updatedStep) { status_ = QStringLiteral("Шаг плана не найден в состоянии"); notify(); return; } QJsonObject updated = root_; updated.insert("plans", plans); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось сохранить шаг плана: %1").arg(error); notify(); return; } root_ = updated; for (WorkoutPlan &item : state_.workoutPlans) { if (item.id.compare(plan->id, Qt::CaseInsensitive) != 0 || stepIndex >= item.steps.size()) continue; item.steps[stepIndex].exerciseId = normalizedExerciseId; item.steps[stepIndex].targetOverride = target; item.steps[stepIndex].restSecondsOverride = restSeconds; item.steps[stepIndex].coachNote = normalizedNote; break; } status_ = QStringLiteral("Шаг %1 сохранён: %2").arg(stepIndex + 1).arg(exercise->name); notify(); } void SessionController::appendSelectedPlanStep(const QString &exerciseId, const QString &coachNote) { if (active() || needsSave() || hasRecoverableDraft() || !store_) return; const WorkoutPlan *plan = selectedPlan(); if (!plan) { status_ = QStringLiteral("План не найден"); notify(); return; } // Push to undo stack QJsonArray allPlansU = root_.value("plans").toArray(); for (const QJsonValue &v : allPlansU) { if (!v.isObject()) continue; QJsonObject p = v.toObject(); if (p.value("id").toString().compare(plan->id, Qt::CaseInsensitive) == 0) { planUndoStack_.append(p); if (planUndoStack_.size() > maxPlanUndoSteps) planUndoStack_.removeFirst(); break; } } if (plan->steps.size() >= 80) { status_ = QStringLiteral("В плане уже 80 шагов"); notify(); return; } const QString normalizedExerciseId = exerciseId.trimmed(); const ExerciseDefinition *exercise = findExercise(exercises_, normalizedExerciseId); if (!exercise) { status_ = QStringLiteral("Упражнение не найдено в каталоге"); notify(); return; } QString normalizedNote = coachNote.trimmed(); if (normalizedNote.size() > 160) { normalizedNote = normalizedNote.left(160); } QJsonArray plans = root_.value("plans").toArray(); bool appendedStep = false; for (int planIndex = 0; planIndex < plans.size(); ++planIndex) { if (!plans.at(planIndex).isObject()) continue; QJsonObject planObject = plans.at(planIndex).toObject(); if (planObject.value("id").toString().compare(plan->id, Qt::CaseInsensitive) != 0) continue; QJsonArray steps = planObject.value("steps").toArray(); QJsonObject stepObject{ {"exerciseId", normalizedExerciseId}, {"targetOverride", exercise->defaultTarget}, {"restSecondsOverride", exercise->defaultRestSeconds} }; if (!normalizedNote.isEmpty()) { stepObject.insert("coachNote", normalizedNote); } steps.append(stepObject); planObject.insert("steps", steps); plans.replace(planIndex, planObject); appendedStep = true; break; } if (!appendedStep) { status_ = QStringLiteral("План не найден в состоянии"); notify(); return; } QJsonObject updated = root_; updated.insert("plans", plans); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось добавить шаг плана: %1").arg(error); notify(); return; } root_ = updated; for (WorkoutPlan &item : state_.workoutPlans) { if (item.id.compare(plan->id, Qt::CaseInsensitive) != 0) continue; WorkoutStep step; step.exerciseId = normalizedExerciseId; step.targetOverride = exercise->defaultTarget; step.restSecondsOverride = exercise->defaultRestSeconds; step.coachNote = normalizedNote; item.steps.append(std::move(step)); break; } status_ = QStringLiteral("Добавлен шаг: %1").arg(exercise->name); notify(); } void SessionController::duplicateSelectedPlanStep(int stepIndex) { if (active() || needsSave() || hasRecoverableDraft() || !store_) return; const WorkoutPlan *plan = selectedPlan(); if (!plan || stepIndex < 0 || stepIndex >= plan->steps.size()) { status_ = QStringLiteral("Шаг плана не найден"); notify(); return; } if (plan->steps.size() >= 80) { status_ = QStringLiteral("В плане уже 80 шагов"); notify(); return; } const QString planId = plan->id; QJsonArray plans = root_.value("plans").toArray(); bool duplicatedStep = false; for (int planIndex = 0; planIndex < plans.size(); ++planIndex) { if (!plans.at(planIndex).isObject()) continue; QJsonObject planObject = plans.at(planIndex).toObject(); if (planObject.value("id").toString().compare(planId, Qt::CaseInsensitive) != 0) continue; QJsonArray steps = planObject.value("steps").toArray(); if (stepIndex >= steps.size() || !steps.at(stepIndex).isObject()) break; steps.insert(stepIndex + 1, steps.at(stepIndex)); planObject.insert("steps", steps); plans.replace(planIndex, planObject); duplicatedStep = true; break; } if (!duplicatedStep) { status_ = QStringLiteral("Шаг плана не найден в состоянии"); notify(); return; } QJsonObject updated = root_; updated.insert("plans", plans); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось продублировать шаг плана: %1").arg(error); notify(); return; } root_ = updated; for (WorkoutPlan &item : state_.workoutPlans) { if (item.id.compare(planId, Qt::CaseInsensitive) != 0 || stepIndex >= item.steps.size()) continue; item.steps.insert(stepIndex + 1, item.steps.at(stepIndex)); break; } status_ = QStringLiteral("Шаг %1 продублирован").arg(stepIndex + 1); notify(); } void SessionController::removeSelectedPlanStep(int stepIndex) { if (active() || needsSave() || hasRecoverableDraft() || !store_) return; const WorkoutPlan *plan = selectedPlan(); if (!plan || stepIndex < 0 || stepIndex >= plan->steps.size()) { status_ = QStringLiteral("Шаг плана не найден"); notify(); return; } // Push to undo stack QJsonArray allPlansU = root_.value("plans").toArray(); for (const QJsonValue &v : allPlansU) { if (!v.isObject()) continue; QJsonObject p = v.toObject(); if (p.value("id").toString().compare(plan->id, Qt::CaseInsensitive) == 0) { planUndoStack_.append(p); if (planUndoStack_.size() > maxPlanUndoSteps) planUndoStack_.removeFirst(); break; } } if (plan->steps.size() <= 1) { status_ = QStringLiteral("Нельзя удалить последний шаг плана"); notify(); return; } QJsonArray plans = root_.value("plans").toArray(); bool removedStep = false; for (int planIndex = 0; planIndex < plans.size(); ++planIndex) { if (!plans.at(planIndex).isObject()) continue; QJsonObject planObject = plans.at(planIndex).toObject(); if (planObject.value("id").toString().compare(plan->id, Qt::CaseInsensitive) != 0) continue; QJsonArray steps = planObject.value("steps").toArray(); if (steps.size() <= 1 || stepIndex >= steps.size()) break; steps.removeAt(stepIndex); planObject.insert("steps", steps); plans.replace(planIndex, planObject); removedStep = true; break; } if (!removedStep) { status_ = QStringLiteral("Шаг плана не найден в состоянии"); notify(); return; } QJsonObject updated = root_; updated.insert("plans", plans); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось удалить шаг плана: %1").arg(error); notify(); return; } root_ = updated; for (WorkoutPlan &item : state_.workoutPlans) { if (item.id.compare(plan->id, Qt::CaseInsensitive) != 0 || stepIndex >= item.steps.size()) continue; item.steps.removeAt(stepIndex); break; } status_ = QStringLiteral("Шаг %1 удалён").arg(stepIndex + 1); notify(); } void SessionController::moveSelectedPlanStep(int stepIndex, int delta) { if (active() || needsSave() || hasRecoverableDraft() || !store_) return; const WorkoutPlan *plan = selectedPlan(); if (!plan || stepIndex < 0 || stepIndex >= plan->steps.size()) { status_ = QStringLiteral("Шаг плана не найден"); notify(); return; } // Push to undo stack QJsonArray allPlansU = root_.value("plans").toArray(); for (const QJsonValue &v : allPlansU) { if (!v.isObject()) continue; QJsonObject p = v.toObject(); if (p.value("id").toString().compare(plan->id, Qt::CaseInsensitive) == 0) { planUndoStack_.append(p); if (planUndoStack_.size() > maxPlanUndoSteps) planUndoStack_.removeFirst(); break; } } const int targetIndex = stepIndex + delta; if (targetIndex < 0 || targetIndex >= plan->steps.size()) { status_ = QStringLiteral("Шаг уже на границе плана"); notify(); return; } QJsonArray plans = root_.value("plans").toArray(); bool movedStep = false; for (int planIndex = 0; planIndex < plans.size(); ++planIndex) { if (!plans.at(planIndex).isObject()) continue; QJsonObject planObject = plans.at(planIndex).toObject(); if (planObject.value("id").toString().compare(plan->id, Qt::CaseInsensitive) != 0) continue; QJsonArray steps = planObject.value("steps").toArray(); if (stepIndex >= steps.size() || targetIndex >= steps.size()) break; const QJsonValue current = steps.at(stepIndex); const QJsonValue target = steps.at(targetIndex); steps.replace(targetIndex, current); steps.replace(stepIndex, target); planObject.insert("steps", steps); plans.replace(planIndex, planObject); movedStep = true; break; } if (!movedStep) { status_ = QStringLiteral("Шаг плана не найден в состоянии"); notify(); return; } QJsonObject updated = root_; updated.insert("plans", plans); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось переместить шаг плана: %1").arg(error); notify(); return; } root_ = updated; for (WorkoutPlan &item : state_.workoutPlans) { if (item.id.compare(plan->id, Qt::CaseInsensitive) != 0 || stepIndex >= item.steps.size() || targetIndex >= item.steps.size()) { continue; } std::swap(item.steps[stepIndex], item.steps[targetIndex]); break; } status_ = QStringLiteral("Шаг %1 перемещён").arg(stepIndex + 1); notify(); } void SessionController::logBodyWeight(const QString &weightText) { if (!store_) return; const std::optional<double> weight = parseWeightKg(weightText); if (!weight) { status_ = QStringLiteral("Введите вес от 30 до 250 кг"); notify(); return; } QJsonObject updated = root_; QJsonArray history = updated.value("bodyweightHistory").toArray(); history.prepend(QJsonObject{ {"profileId", selectedProfileId()}, {"loggedAt", QDateTime::currentDateTime().toString(Qt::ISODateWithMs)}, {"weightKg", *weight} }); while (history.size() > 180) { history.removeLast(); } updated.insert("bodyweightHistory", history); if (selectedProfileId() == "default") { updated.insert("currentBodyWeightKg", *weight); } QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось сохранить вес: %1").arg(error); notify(); return; } root_ = updated; if (selectedProfileId() == "default") { state_.currentBodyWeightKg = *weight; } status_ = QStringLiteral("Вес сохранён: %1 кг").arg(formatDecimal(*weight)); notify(); } void SessionController::logBodyMeasurements( const QString &waistText, const QString &shouldersText, const QString &chestText, const QString &armText) { if (!store_) return; const std::optional<double> waist = parseMeasurementCm(waistText, 30.0, 250.0); const std::optional<double> shoulders = parseMeasurementCm(shouldersText, 30.0, 250.0); const std::optional<double> chest = parseMeasurementCm(chestText, 30.0, 250.0); const std::optional<double> arm = parseMeasurementCm(armText, 10.0, 100.0); if (!waist || !shoulders || !chest || !arm) { status_ = QStringLiteral("Введите корректные замеры в сантиметрах"); notify(); return; } QJsonObject updated = root_; QJsonArray history = updated.value("bodyMeasurementHistory").toArray(); history.prepend(QJsonObject{ {"profileId", selectedProfileId()}, {"loggedAt", QDateTime::currentDateTime().toString(Qt::ISODateWithMs)}, {"waistCm", *waist}, {"shouldersCm", *shoulders}, {"chestCm", *chest}, {"armCm", *arm} }); while (history.size() > 180) { history.removeLast(); } updated.insert("bodyMeasurementHistory", history); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось сохранить замеры: %1").arg(error); notify(); return; } root_ = updated; status_ = QStringLiteral("Замеры сохранены: талия %1 см, плечи %2 см") .arg(formatDecimal(*waist), formatDecimal(*shoulders)); notify(); } void SessionController::markNutritionAdherence(const QString &statusId) { if (!store_) return; const QString normalized = statusId.trimmed().toLower(); if (normalized != "good" && normalized != "partial" && normalized != "off") { status_ = QStringLiteral("Неизвестная отметка питания"); notify(); return; } const QDate today = QDate::currentDate(); QJsonArray updatedHistory; for (const QJsonValue &value : root_.value("nutritionAdherenceHistory").toArray()) { if (!value.isObject()) continue; const QJsonObject item = value.toObject(); const QDate date = QDateTime::fromString(item.value("date").toString(), Qt::ISODate).date(); if (date != today || !sessionMatchesProfile(item, selectedProfileId())) { updatedHistory.append(item); } } updatedHistory.prepend(QJsonObject{ {"profileId", selectedProfileId()}, {"date", QDateTime(today, QTime(0, 0)).toString(Qt::ISODateWithMs)}, {"statusId", normalized} }); while (updatedHistory.size() > 180) { updatedHistory.removeLast(); } QJsonObject updated = root_; updated.insert("nutritionAdherenceHistory", updatedHistory); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось сохранить отметку питания: %1").arg(error); notify(); return; } root_ = updated; const QString label = normalized == "good" ? QStringLiteral("выполнено") : normalized == "partial" ? QStringLiteral("частично") : QStringLiteral("срыв"); status_ = QStringLiteral("Питание за сегодня: %1").arg(label); notify(); } void SessionController::selectNutritionPreset(const QString &presetId) { if (!store_) return; const QString normalized = normalizeNutritionPreset(presetId); if (normalized.isEmpty()) { status_ = QStringLiteral("Неизвестный пресет питания"); notify(); return; } QString current = normalizeNutritionPreset(root_.value("nutritionPresetId").toString("balanced")); if (current.isEmpty()) { current = QStringLiteral("balanced"); } if (current == normalized) { status_ = QStringLiteral("Пресет питания уже выбран"); notify(); return; } QJsonObject updated = root_; updated.insert("nutritionPresetId", normalized); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось сохранить пресет питания: %1").arg(error); notify(); return; } root_ = updated; status_ = QStringLiteral("Пресет питания сохранён: %1").arg(normalized); notify(); } void SessionController::logPhotoProgress(const QString &imagePath) { if (!store_) return; const QString normalizedPath = imagePath.trimmed(); if (normalizedPath.isEmpty() || normalizedPath.size() > 1024) { status_ = QStringLiteral("Введите путь к фото"); notify(); return; } QJsonObject updated = root_; QJsonArray history = updated.value("photoProgressHistory").toArray(); history.prepend(QJsonObject{ {"profileId", selectedProfileId()}, {"loggedAt", QDateTime::currentDateTime().toString(Qt::ISODateWithMs)}, {"imagePath", normalizedPath} }); while (history.size() > 180) { history.removeLast(); } updated.insert("photoProgressHistory", history); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось сохранить фото-прогресс: %1").arg(error); notify(); return; } root_ = updated; status_ = QStringLiteral("Фото-прогресс сохранён: %1").arg(QFileInfo(normalizedPath).fileName()); notify(); } void SessionController::logRecoveryCheckIn(int energyScore, int sleepScore, int jointScore, const QString ¬e) { if (!store_) return; if (energyScore < 1 || energyScore > 5 || sleepScore < 1 || sleepScore > 5 || jointScore < 1 || jointScore > 5) { status_ = QStringLiteral("Оценки восстановления должны быть от 1 до 5"); notify(); return; } QJsonObject updated = root_; QJsonArray history = updated.value("recoveryCheckInHistory").toArray(); QString trimmedNote = note.trimmed(); if (trimmedNote.size() > 240) { trimmedNote = trimmedNote.left(240); } history.prepend(QJsonObject{ {"profileId", selectedProfileId()}, {"sessionId", QJsonValue::Null}, {"planName", planName()}, {"loggedAt", QDateTime::currentDateTime().toString(Qt::ISODateWithMs)}, {"energyScore", energyScore}, {"sleepScore", sleepScore}, {"jointScore", jointScore}, {"note", trimmedNote} }); while (history.size() > 180) { history.removeLast(); } updated.insert("recoveryCheckInHistory", history); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось сохранить recovery: %1").arg(error); notify(); return; } root_ = updated; status_ = QStringLiteral("Recovery сохранён: энергия %1/5, сон %2/5, суставы %3/5") .arg(energyScore) .arg(sleepScore) .arg(jointScore); notify(); } void SessionController::exportLocalData(const QString &destinationPath) { if (!store_) return; QString normalizedPath = destinationPath.trimmed(); const QUrl url(normalizedPath); if (url.isLocalFile()) { normalizedPath = url.toLocalFile(); } if (normalizedPath.isEmpty()) { status_ = QStringLiteral("Выберите файл для экспорта данных"); notify(); return; } QFile source(store_->stateFilePath()); if (!source.open(QIODevice::ReadOnly)) { status_ = QStringLiteral("Не удалось открыть данные для экспорта: %1").arg(source.errorString()); notify(); return; } QFileInfo targetInfo(normalizedPath); QDir targetDirectory = targetInfo.dir(); if (!targetDirectory.exists() && !targetDirectory.mkpath(".")) { status_ = QStringLiteral("Не удалось создать каталог экспорта"); notify(); return; } QFile target(normalizedPath); if (!target.open(QIODevice::WriteOnly | QIODevice::Truncate)) { status_ = QStringLiteral("Не удалось записать экспорт: %1").arg(target.errorString()); notify(); return; } const QByteArray data = source.readAll(); if (target.write(data) != data.size() || !target.flush()) { status_ = QStringLiteral("Не удалось завершить экспорт: %1").arg(target.errorString()); notify(); return; } status_ = QStringLiteral("Данные экспортированы: %1").arg(QDir::toNativeSeparators(normalizedPath)); notify(); } void SessionController::exportSelectedPlan(const QString &destinationPath) { if (!store_) return; const WorkoutPlan *plan = selectedPlan(); if (!plan) { status_ = QStringLiteral("План не выбран"); notify(); return; } QString normalizedPath = destinationPath.trimmed(); const QUrl url(normalizedPath); if (url.isLocalFile()) normalizedPath = url.toLocalFile(); if (normalizedPath.isEmpty()) { status_ = QStringLiteral("Выберите файл"); notify(); return; } QJsonObject planJson; planJson.insert("id", plan->id); planJson.insert("name", plan->name); planJson.insert("goal", plan->goal); planJson.insert("description", plan->description); QJsonArray stepsArray; for (const WorkoutStep &step : plan->steps) { QJsonObject stepJson; stepJson.insert("exerciseId", step.exerciseId); if (step.targetOverride.has_value()) stepJson.insert("targetOverride", step.targetOverride.value()); if (step.restSecondsOverride.has_value()) stepJson.insert("restSecondsOverride", step.restSecondsOverride.value()); if (!step.coachNote.isEmpty()) stepJson.insert("coachNote", step.coachNote); stepsArray.append(stepJson); } planJson.insert("steps", stepsArray); QFile file(normalizedPath); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { status_ = QStringLiteral("Ошибка записи: %1").arg(file.errorString()); notify(); return; } file.write(QJsonDocument(planJson).toJson(QJsonDocument::Indented)); file.close(); status_ = QStringLiteral("План экспортирован: %1").arg(plan->name); notify(); } void SessionController::importPlan(const QString &sourcePath) { if (!store_) return; QString normalizedPath = sourcePath.trimmed(); const QUrl url(normalizedPath); if (url.isLocalFile()) normalizedPath = url.toLocalFile(); if (normalizedPath.isEmpty()) { status_ = QStringLiteral("Выберите файл"); notify(); return; } QFile file(normalizedPath); if (!file.open(QIODevice::ReadOnly)) { status_ = QStringLiteral("Ошибка чтения: %1").arg(file.errorString()); notify(); return; } const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); file.close(); if (!doc.isObject()) { status_ = QStringLiteral("Неверный формат JSON"); notify(); return; } QJsonObject planJson = doc.object(); const QString planName = planJson.value("name").toString().trimmed(); if (planName.isEmpty()) { status_ = QStringLiteral("Название плана пустое"); notify(); return; } QJsonArray stepsArray = planJson.value("steps").toArray(); if (stepsArray.isEmpty()) { status_ = QStringLiteral("План без шагов"); notify(); return; } WorkoutPlan newPlan; newPlan.id = uniquePlanId(planIdFromName(planName), state_.workoutPlans); newPlan.name = planName; newPlan.goal = planJson.value("goal").toString(); newPlan.description = planJson.value("description").toString(); for (const QJsonValue &stepValue : stepsArray) { if (!stepValue.isObject()) continue; const QJsonObject stepJson = stepValue.toObject(); const QString exerciseId = stepJson.value("exerciseId").toString().trimmed(); if (exerciseId.isEmpty()) continue; const ExerciseDefinition *exercise = findExercise(exercises_, exerciseId); if (!exercise) continue; WorkoutStep step; step.exerciseId = exerciseId; if (stepJson.contains("targetOverride")) step.targetOverride = stepJson.value("targetOverride").toInt(); if (stepJson.contains("restSecondsOverride")) step.restSecondsOverride = stepJson.value("restSecondsOverride").toInt(); step.coachNote = stepJson.value("coachNote").toString(); newPlan.steps.append(step); } if (newPlan.steps.isEmpty()) { status_ = QStringLiteral("Нет валидных шагов"); notify(); return; } state_.workoutPlans.append(newPlan); QJsonObject updated = root_; QJsonArray plansArray; for (const WorkoutPlan &p : state_.workoutPlans) { QJsonObject pObj; pObj.insert("id", p.id); pObj.insert("name", p.name); pObj.insert("goal", p.goal); pObj.insert("description", p.description); QJsonArray stepsArray; for (const WorkoutStep &s : p.steps) { QJsonObject sObj; sObj.insert("exerciseId", s.exerciseId); if (s.targetOverride.has_value()) sObj.insert("targetOverride", s.targetOverride.value()); if (s.restSecondsOverride.has_value()) sObj.insert("restSecondsOverride", s.restSecondsOverride.value()); if (!s.coachNote.isEmpty()) sObj.insert("coachNote", s.coachNote); stepsArray.append(sObj); } pObj.insert("steps", stepsArray); plansArray.append(pObj); } updated.insert("plans", plansArray); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Ошибка сохранения: %1").arg(error); notify(); return; } root_ = updated; status_ = QStringLiteral("План импортирован: %1 (%2 шагов)").arg(newPlan.name).arg(newPlan.steps.size()); notify(); } void SessionController::clearPersonalData() { if (!store_) return; if (runner_ && active()) { runner_->abort(); } pendingFinishedSession_.reset(); recoverableDraft_.reset(); currentLoadNote_.clear(); const QString profileId = selectedProfileId(); QJsonObject updated = root_; updated.insert("sessions", objectsWithoutProfile(root_.value("sessions").toArray(), profileId)); updated.insert("pendingDraft", QJsonValue::Null); updated.insert("bodyweightHistory", objectsWithoutProfile(root_.value("bodyweightHistory").toArray(), profileId)); updated.insert("bodyMeasurementHistory", objectsWithoutProfile(root_.value("bodyMeasurementHistory").toArray(), profileId)); updated.insert("nutritionAdherenceHistory", objectsWithoutProfile(root_.value("nutritionAdherenceHistory").toArray(), profileId)); updated.insert("photoProgressHistory", objectsWithoutProfile(root_.value("photoProgressHistory").toArray(), profileId)); updated.insert("recoveryCheckInHistory", objectsWithoutProfile(root_.value("recoveryCheckInHistory").toArray(), profileId)); if (profileId == "default") { updated.remove("currentBodyWeightKg"); updated.remove("nutritionPresetId"); } QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось очистить персональные данные: %1").arg(error); notify(); return; } const ImportResult imported = importNativeState(QJsonDocument(updated).toJson()); if (!imported.ok) { status_ = QStringLiteral("Данные очищены, но состояние не перечитано: %1").arg(imported.error); notify(); return; } root_ = updated; state_ = imported.state; sessionStartedAtLocal_ = {}; sessionEndedAtLocal_ = {}; secondsSinceDraftSave_ = 0; status_ = QStringLiteral("Персональные записи очищены. Backup создан автоматически."); notify(); } const WorkoutPlan *SessionController::selectedPlan() const { auto found = std::find_if(state_.workoutPlans.cbegin(), state_.workoutPlans.cend(), [this](const WorkoutPlan &plan) { return plan.id.compare(state_.selectedPlanId, Qt::CaseInsensitive) == 0; }); return found == state_.workoutPlans.cend() ? nullptr : &*found; } QString SessionController::exerciseNameForStep(int index) const { if (!selectedPlan() || index < 0 || index >= selectedPlan()->steps.size()) return {}; const QString id = selectedPlan()->steps.at(index).exerciseId; const ExerciseDefinition *exercise = findExercise(exercises_, id); return exercise ? exercise->name : id; } QVariantList SessionController::exerciseFrameUrls(const QString &exerciseId) const { QVariantList urls; if (exerciseId.trimmed().isEmpty() || exerciseFrameDirectory_.isEmpty()) return urls; const QDir directory(exerciseFrameDirectory_); for (int index = 1; index <= 8; ++index) { const QString path = directory.filePath(QStringLiteral("%1_%2.png").arg(exerciseId, QString::number(index))); if (!QFileInfo::exists(path)) { continue; } urls.append(QUrl::fromLocalFile(QFileInfo(path).absoluteFilePath()).toString()); } return urls; } SessionSnapshot SessionController::snapshot() const { return runner_ ? runner_->snapshot() : SessionSnapshot{}; } QJsonArray SessionController::sessionsArray() const { QJsonArray result; const QJsonArray sessions = root_.value("sessions").isArray() ? root_.value("sessions").toArray() : QJsonArray{}; const QString profileId = selectedProfileId(); for (const QJsonValue &value : sessions) { if (!value.isObject()) continue; const QJsonObject session = value.toObject(); if (sessionMatchesProfile(session, profileId)) { result.append(session); } } return result; } void SessionController::captureFinishedSession() { if (!runner_ || pendingFinishedSession_) return; std::optional<FinishedSession> finished = runner_->takeFinishedSession(); if (!finished) return; pendingFinishedSession_ = std::move(finished); sessionEndedAtLocal_ = QDateTime::currentDateTime(); currentLoadNote_.clear(); status_ = QStringLiteral("Подтвердите сохранение результата"); } void SessionController::persistDraft() { if (!runner_ || !store_) return; const std::optional<SessionDraft> draft = runner_->createDraft(); if (!draft) return; QJsonObject updated = root_; QString error; if (!StateStore::setPendingDraft(updated, *draft, sessionStartedAtLocal_, &error) || !store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось сохранить черновик: %1").arg(error); return; } root_ = updated; secondsSinceDraftSave_ = 0; } void SessionController::notify() { emit changed(); }