/
DemienMedich
/
BodyweightBase
Обзор
Документация
Войти
/
DemienMedich
/
BodyweightBase
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
develop
app/session_controller.cpp
6 722 строки
291 KB
DemienMedich
feat: add profile weekly goals
03 авг 2026, 01:15
03 авг 2026, 01:15
a8f4bdb
Код
Авторство
О чём код?
#include "session_controller.h" #include <algorithm> #include <cmath> #include <iterator> #include <QDir> #include <QDate> #include <QFileInfo> #include <QJsonArray> #include <QJsonDocument> #include <QJsonObject> #include <QJsonParseError> #include <QLocale> #include <QCoreApplication> #include <QGuiApplication> #include <QTime> #include <QFile> #include <QHash> #include <QSet> #include <QStringList> #include <QStandardPaths> #include <QUrl> #include <QTextStream> #ifdef Q_OS_ANDROID #include <QJniObject> #include <QtCore/qcoreapplication_platform.h> #endif #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> parseExternalLoadKg(QString text) { text = text.trimmed().replace(',', '.'); if (text.isEmpty()) return 0.0; bool ok = false; const double value = text.toDouble(&ok); if (!ok || value < 0.0 || value > 500.0) return std::nullopt; return std::round(value * 10.0) / 10.0; } int sessionHeartRateLoad(const QJsonObject &session) { if (session.value("heartRateSampleCount").toInt() <= 0) return 0; const int workSeconds = std::max(0, session.value("totalWorkSeconds").toInt()); const double heartRateAvg = session.value("heartRateAvg").toDouble(); if (workSeconds <= 0 || heartRateAvg <= 0.0) return 0; return std::max(1, qRound((workSeconds / 60.0) * heartRateAvg / 100.0)); } 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; } QJsonObject profileSettingsFor(const QJsonObject &root, const QString &profileId); QJsonObject withProfileSettings(QJsonObject root, const QString &profileId, const QJsonObject &settings); QString canonicalProfileId(const QJsonArray &profiles, const QString &profileId) { for (const QJsonValue &value : profiles) { if (!value.isObject()) continue; const QString candidate = value.toObject().value("id").toString().trimmed(); if (candidate.compare(profileId.trimmed(), Qt::CaseInsensitive) == 0) return candidate; } return {}; } bool planObjectBelongsToProfile(const QJsonObject &plan, const QString &profileId) { return plan.value("profileId").toString().trimmed().compare(profileId, Qt::CaseInsensitive) == 0; } bool planObjectMatchesProfile( const QJsonObject &plan, const QString &planId, const QString &profileId) { return plan.value("id").toString().compare(planId, Qt::CaseInsensitive) == 0 && planObjectBelongsToProfile(plan, profileId); } bool planIdBelongsToProfile(const QJsonObject &root, const QString &planId, const QString &profileId) { if (planId.trimmed().isEmpty()) return false; for (const QJsonValue &value : root.value("plans").toArray()) { if (!value.isObject()) continue; const QJsonObject plan = value.toObject(); if (plan.value("id").toString().compare(planId, Qt::CaseInsensitive) == 0 && planObjectBelongsToProfile(plan, profileId)) { return true; } } return false; } QString firstPlanIdForProfile(const QJsonObject &root, const QString &profileId) { for (const QJsonValue &value : root.value("plans").toArray()) { if (!value.isObject()) continue; const QJsonObject plan = value.toObject(); if (!planObjectBelongsToProfile(plan, profileId)) continue; const QString id = plan.value("id").toString().trimmed(); if (!id.isEmpty()) return id; } return {}; } QString selectedPlanIdForProfile(const QJsonObject &root, const QString &profileId) { const QString stored = profileSettingsFor(root, profileId).value("selectedPlanId").toString().trimmed(); if (planIdBelongsToProfile(root, stored, profileId)) return stored; if (selectedProfileIdOrDefault(root).compare(profileId, Qt::CaseInsensitive) == 0) { const QString legacy = root.value("selectedPlanId").toString().trimmed(); if (planIdBelongsToProfile(root, legacy, profileId)) return legacy; } return firstPlanIdForProfile(root, profileId); } void setSelectedPlanIdForProfile(QJsonObject &root, const QString &profileId, const QString &planId) { QJsonObject settings = profileSettingsFor(root, profileId); settings.insert("selectedPlanId", planId); root = withProfileSettings(root, profileId, settings); if (selectedProfileIdOrDefault(root).compare(profileId, Qt::CaseInsensitive) == 0) { root.insert("selectedPlanId", planId); } } QString inferredHealthConnectOwnerProfileId(const QJsonObject &root, const QJsonArray &profiles) { QHash<QString, int> counts; const auto countProfile = [&counts, &profiles](const QJsonObject &object) { const QString profileId = canonicalProfileId(profiles, object.value("profileId").toString()); if (!profileId.isEmpty()) counts[profileId] += 1; }; for (const QJsonValue &value : root.value("wearableDailyHistory").toArray()) { if (value.isObject()) countProfile(value.toObject()); } for (const QJsonValue &value : root.value("bodyweightHistory").toArray()) { if (!value.isObject()) continue; const QJsonObject object = value.toObject(); if (object.value("source").toString() == QStringLiteral("health-connect")) countProfile(object); } QString result = canonicalProfileId(profiles, selectedProfileIdOrDefault(root)); int bestCount = counts.value(result); for (auto it = counts.cbegin(); it != counts.cend(); ++it) { if (it.value() > bestCount) { result = it.key(); bestCount = it.value(); } } return result; } QString healthConnectOwnerProfileIdOrDefault(const QJsonObject &root) { const QJsonArray profiles = profilesArrayOrDefault(root); const QString stored = canonicalProfileId( profiles, root.value("healthConnectOwnerProfileId").toString()); if (!stored.isEmpty()) return stored; const QString inferred = inferredHealthConnectOwnerProfileId(root, profiles); return inferred.isEmpty() ? QStringLiteral("default") : inferred; } QString mostRepresentedProfileId(const QHash<QString, int> &counts, const QJsonArray &profiles) { QString result; int bestCount = 0; bool tied = false; for (const QJsonValue &value : profiles) { if (!value.isObject()) continue; const QString profileId = value.toObject().value("id").toString().trimmed(); const int count = counts.value(profileId); if (profileId.isEmpty() || count <= 0) continue; if (count > bestCount) { result = profileId; bestCount = count; tied = false; } else if (count == bestCount) { tied = true; } } return tied ? QString{} : result; } const QStringList &profileScopedArrayKeys() { static const QStringList keys{ QStringLiteral("sessions"), QStringLiteral("bodyweightHistory"), QStringLiteral("bodyMeasurementHistory"), QStringLiteral("nutritionAdherenceHistory"), QStringLiteral("photoProgressHistory"), QStringLiteral("recoveryCheckInHistory"), QStringLiteral("wearableDailyHistory"), QStringLiteral("trainingCycles"), QStringLiteral("trainingCycleReviews") }; return keys; } QString inferLegacyOwnerProfileId( const QJsonObject &root, const QJsonArray &profiles, const QJsonArray &plans, const QString &activeProfileId) { const QString storedHealthOwner = canonicalProfileId( profiles, root.value("healthConnectOwnerProfileId").toString()); if (!storedHealthOwner.isEmpty()) return storedHealthOwner; QHash<QString, int> healthCounts; const auto countOwner = [&profiles](QHash<QString, int> &counts, const QJsonObject &object) { const QString owner = canonicalProfileId(profiles, object.value("profileId").toString()); if (!owner.isEmpty()) counts[owner] += 1; }; for (const QJsonValue &value : root.value("wearableDailyHistory").toArray()) { if (value.isObject()) countOwner(healthCounts, value.toObject()); } for (const QJsonValue &value : root.value("bodyweightHistory").toArray()) { if (!value.isObject()) continue; const QJsonObject object = value.toObject(); if (object.value("source").toString() == QStringLiteral("health-connect")) { countOwner(healthCounts, object); } } QString owner = mostRepresentedProfileId(healthCounts, profiles); if (!owner.isEmpty()) return owner; QHash<QString, int> historyCounts; for (const QString &key : profileScopedArrayKeys()) { for (const QJsonValue &value : root.value(key).toArray()) { if (value.isObject()) countOwner(historyCounts, value.toObject()); } } owner = mostRepresentedProfileId(historyCounts, profiles); if (!owner.isEmpty()) return owner; QHash<QString, int> planCounts; for (const QJsonValue &value : plans) { if (value.isObject()) countOwner(planCounts, value.toObject()); } owner = mostRepresentedProfileId(planCounts, profiles); if (!owner.isEmpty()) return owner; const QString legacySelectedPlanId = root.value("selectedPlanId").toString().trimmed(); for (const QJsonValue &value : plans) { if (!value.isObject()) continue; const QJsonObject plan = value.toObject(); if (plan.value("id").toString().compare(legacySelectedPlanId, Qt::CaseInsensitive) == 0 && plan.value("profileId").toString().trimmed().isEmpty()) { return activeProfileId; } } owner = canonicalProfileId(profiles, QStringLiteral("default")); return owner.isEmpty() ? profiles.first().toObject().value("id").toString(activeProfileId) : owner; } bool ensureProfileOwnershipState(QJsonObject &root) { const QJsonObject before = root; QJsonArray profiles = profilesArrayOrDefault(root); QString activeProfileId = canonicalProfileId(profiles, selectedProfileIdOrDefault(root)); if (activeProfileId.isEmpty()) { activeProfileId = profiles.first().toObject().value("id").toString(QStringLiteral("default")); } root.insert("profiles", profiles); root.insert("selectedProfileId", activeProfileId); QJsonArray plans = root.value("plans").toArray(); const QString legacyOwnerProfileId = inferLegacyOwnerProfileId( root, profiles, plans, activeProfileId); for (int index = 0; index < plans.size(); ++index) { if (!plans.at(index).isObject()) continue; QJsonObject plan = plans.at(index).toObject(); const QString rawOwner = plan.value("profileId").toString().trimmed(); const QString owner = canonicalProfileId(profiles, rawOwner); if (rawOwner.isEmpty()) plan.insert("profileId", legacyOwnerProfileId); else if (!owner.isEmpty() && owner != rawOwner) plan.insert("profileId", owner); plans.replace(index, plan); } root.insert("plans", plans); for (const QString &key : profileScopedArrayKeys()) { QJsonArray items = root.value(key).toArray(); for (int index = 0; index < items.size(); ++index) { if (!items.at(index).isObject()) continue; QJsonObject item = items.at(index).toObject(); const QString rawOwner = item.value("profileId").toString().trimmed(); const QString owner = canonicalProfileId(profiles, rawOwner); if (rawOwner.isEmpty()) item.insert("profileId", legacyOwnerProfileId); else if (!owner.isEmpty() && owner != rawOwner) item.insert("profileId", owner); items.replace(index, item); } if (!items.isEmpty() || root.contains(key)) root.insert(key, items); } QJsonObject pendingDraft = root.value("pendingDraft").toObject(); if (pendingDraft.value("plan").isObject()) { QJsonObject draftPlan = pendingDraft.value("plan").toObject(); const QString rawOwner = draftPlan.value("profileId").toString().trimmed(); const QString owner = canonicalProfileId(profiles, rawOwner); if (rawOwner.isEmpty()) draftPlan.insert("profileId", legacyOwnerProfileId); else if (!owner.isEmpty() && owner != rawOwner) draftPlan.insert("profileId", owner); pendingDraft.insert("plan", draftPlan); root.insert("pendingDraft", pendingDraft); } for (const QJsonValue &value : profiles) { if (!value.isObject()) continue; const QString profileId = value.toObject().value("id").toString().trimmed(); if (profileId.isEmpty()) continue; setSelectedPlanIdForProfile(root, profileId, selectedPlanIdForProfile(root, profileId)); } root.insert("selectedPlanId", selectedPlanIdForProfile(root, activeProfileId)); root.insert("healthConnectOwnerProfileId", healthConnectOwnerProfileIdOrDefault(root)); return root != before; } 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; } bool isQuickSession(const QJsonObject &session) { return session.value("sessionMode").toString().compare( QStringLiteral("quick"), 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 QString &profileId, 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 (!planObjectMatchesProfile(plan, planId, profileId)) 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; } QJsonObject profileSettingsFor(const QJsonObject &root, const QString &profileId) { return root.value("profileSettings").toObject().value(profileId).toObject(); } QJsonObject withProfileSettings(QJsonObject root, const QString &profileId, const QJsonObject &settings) { QJsonObject all = root.value("profileSettings").toObject(); all.insert(profileId, settings); root.insert("profileSettings", all); return root; } bool ensureNastyaProfileSettings(QJsonObject &root) { QString nastyaId; for (const QJsonValue &value : profilesArrayOrDefault(root)) { const QJsonObject profile = value.toObject(); if (profile.value("name").toString().compare(QStringLiteral("Настя"), Qt::CaseInsensitive) == 0) { nastyaId = profile.value("id").toString(); break; } } if (nastyaId.isEmpty()) return false; QJsonObject settings = profileSettingsFor(root, nastyaId); bool changed = false; const auto setIfMissing = [&settings, &changed](const QString &key, const QJsonValue &value) { if (!settings.contains(key)) { settings.insert(key, value); changed = true; } }; setIfMissing(QStringLiteral("initialWeightKg"), 60.0); setIfMissing(QStringLiteral("weightGoalKg"), 54.0); setIfMissing(QStringLiteral("heightCm"), 163); setIfMissing(QStringLiteral("nutritionPresetId"), QStringLiteral("balanced")); setIfMissing(QStringLiteral("workoutReminderEnabled"), true); setIfMissing(QStringLiteral("workoutReminderTime"), QStringLiteral("18:00")); if (changed) root = withProfileSettings(root, nastyaId, settings); return changed; } bool ensureProfileWeeklyGoalSettings(QJsonObject &root) { bool changed = false; const QJsonArray profiles = profilesArrayOrDefault(root); const QJsonArray plans = root.value("plans").toArray(); for (const QJsonValue &value : profiles) { if (!value.isObject()) continue; const QJsonObject profile = value.toObject(); const QString profileId = profile.value("id").toString().trimmed(); if (profileId.isEmpty()) continue; const QString identity = (profileId + QLatin1Char(' ') + profile.value("name").toString()).toLower(); int goalMin = 3; int goalMax = 3; int rescueMin = 1; if (identity.contains(QStringLiteral("роман")) || identity.contains(QStringLiteral("roman"))) { goalMin = 4; goalMax = 6; rescueMin = 2; } else if (identity.contains(QStringLiteral("настя")) || identity.contains(QStringLiteral("nastya"))) { goalMin = 3; goalMax = 3; rescueMin = 1; } else { QSet<int> scheduledDays; for (const QJsonValue &planValue : plans) { if (!planValue.isObject()) continue; const QJsonObject plan = planValue.toObject(); if (!planObjectBelongsToProfile(plan, profileId)) continue; const int day = plan.value("dayOfWeek").toInt(); if (day >= 1 && day <= 7) scheduledDays.insert(day); } if (!scheduledDays.isEmpty()) { goalMin = scheduledDays.size(); goalMax = scheduledDays.size(); rescueMin = std::max(1, goalMin / 2); } } QJsonObject settings = profileSettingsFor(root, profileId); const auto setIfMissing = [&settings, &changed](const QString &key, int amount) { if (!settings.contains(key)) { settings.insert(key, amount); changed = true; } }; setIfMissing(QStringLiteral("weeklyGoalMin"), goalMin); setIfMissing(QStringLiteral("weeklyGoalMax"), goalMax); setIfMissing(QStringLiteral("rescueWeeklyGoalMin"), rescueMin); root = withProfileSettings(root, profileId, settings); } return changed; } bool migrateLegacyWingChunStructurePlan(QJsonObject &root) { const QString migrationId = QStringLiteral("wingchun-structure-10-step-v1"); if (root.contains("contentMigrations") && !root.value("contentMigrations").isArray()) return false; QJsonArray contentMigrations = root.value("contentMigrations").toArray(); for (const QJsonValue &value : contentMigrations) { if (value.toString() == migrationId) return false; } struct LegacyStep { const char *exerciseId; int target; int sets; int restSeconds; }; static constexpr LegacyStep legacySteps[] = { {"yijkym_stance", 90, 2, 20}, {"siu_lim_tao", 180, 1, 30}, {"tan_sao_drill", 45, 2, 15}, {"bong_sao_drill", 45, 2, 15}, {"pak_sa_drill", 20, 3, 20}, {"chain_punch_round", 60, 4, 30}, {"footer_kick", 10, 3, 25}, {"wrist_extension_lean", 25, 2, 20}, }; QJsonArray plans = root.value("plans").toArray(); int matchingPlanIndex = -1; for (int index = 0; index < plans.size(); ++index) { if (!plans.at(index).isObject()) continue; if (plans.at(index).toObject().value("id").toString() != QStringLiteral("wingchun-structure")) { continue; } if (matchingPlanIndex >= 0) return false; matchingPlanIndex = index; } if (matchingPlanIndex < 0) return false; QJsonObject plan = plans.at(matchingPlanIndex).toObject(); const QJsonArray steps = plan.value("steps").toArray(); if (steps.size() != static_cast<int>(std::size(legacySteps))) return false; for (int index = 0; index < steps.size(); ++index) { if (!steps.at(index).isObject()) return false; const QJsonObject step = steps.at(index).toObject(); const LegacyStep &expected = legacySteps[index]; if (step.value("exerciseId").toString() != QString::fromLatin1(expected.exerciseId) || !step.value("targetOverride").isDouble() || step.value("targetOverride").toDouble() != expected.target || !step.value("sets").isDouble() || step.value("sets").toDouble() != expected.sets || !step.value("restSecondsOverride").isDouble() || step.value("restSecondsOverride").toDouble() != expected.restSeconds) { return false; } } const QJsonArray upgradedSteps{ QJsonObject{ {"coachNote", QStringLiteral("Макушка вверх, таз собран, стопы укоренены. Сохраняй спокойное дыхание.")}, {"exerciseId", "yijkym_stance"}, {"restSecondsOverride", 20}, {"sets", 2}, {"targetOverride", 60}}, QJsonObject{ {"coachNote", QStringLiteral("Выполни форму без спешки: локти тяжёлые, плечи и трапеции расслаблены.")}, {"exerciseId", "siu_lim_tao"}, {"restSecondsOverride", 30}, {"sets", 1}, {"targetOverride", 120}}, QJsonObject{ {"coachNote", QStringLiteral("По 30 секунд на сторону. Локоть направлен вниз, кисть остаётся мягкой.")}, {"exerciseId", "tan_sao_drill"}, {"restSecondsOverride", 15}, {"sets", 1}, {"targetOverride", 30}}, QJsonObject{ {"coachNote", QStringLiteral("По 30 секунд на сторону. Не поднимай плечо и не заламывай запястье.")}, {"exerciseId", "bong_sao_drill"}, {"restSecondsOverride", 15}, {"sets", 1}, {"targetOverride", 30}}, QJsonObject{ {"coachNote", QStringLiteral("Меняй стороны плавно: одна рука контролирует центр, вторая сохраняет структуру.")}, {"exerciseId", "dan_chi_sao"}, {"restSecondsOverride", 15}, {"sets", 1}, {"targetOverride", 30}}, QJsonObject{ {"coachNote", QStringLiteral("По 10 на сторону. Короткий сбив из локтя и немедленный возврат в защиту.")}, {"exerciseId", "pak_sa_drill"}, {"restSecondsOverride", 20}, {"sets", 2}, {"targetOverride", 10}}, QJsonObject{ {"coachNote", QStringLiteral("По 8 на сторону. Срезай линию предплечьем, не раскрывая локоть в сторону.")}, {"exerciseId", "gaun_sa_drill"}, {"restSecondsOverride", 20}, {"sets", 1}, {"targetOverride", 8}}, QJsonObject{ {"coachNote", QStringLiteral("Ровный быстрый темп. Локти держатся у центра, плечи остаются свободными.")}, {"exerciseId", "chain_punch_round"}, {"restSecondsOverride", 30}, {"sets", 3}, {"targetOverride", 45}}, QJsonObject{ {"coachNote", QStringLiteral("По 6 на ногу. Сначала подними колено, затем распрями и быстро верни стопу.")}, {"exerciseId", "footer_kick"}, {"restSecondsOverride", 25}, {"sets", 2}, {"targetOverride", 6}}, QJsonObject{ {"coachNote", QStringLiteral("Умеренное натяжение без боли: не продавливай кисть и сохраняй локоть мягким.")}, {"exerciseId", "wrist_extension_lean"}, {"restSecondsOverride", 20}, {"sets", 1}, {"targetOverride", 25}}, }; plan.insert("steps", upgradedSteps); plans.replace(matchingPlanIndex, plan); root.insert("plans", plans); contentMigrations.append(migrationId); root.insert("contentMigrations", contentMigrations); return true; } bool migrateQigongBaduanjin4ToTimed(QJsonObject &root) { const QString migrationId = QStringLiteral("qigong-baduanjin4-timed-v1"); if (root.contains("contentMigrations") && !root.value("contentMigrations").isArray()) return false; QJsonArray contentMigrations = root.value("contentMigrations").toArray(); for (const QJsonValue &value : contentMigrations) { if (value.toString() == migrationId) return false; } static constexpr const char *legacyExerciseIds[] = { "wuji_breathing", "embrace_tree", "baduanjin_1", "baduanjin_2", "baduanjin_3", "baduanjin_4", "baduanjin_5", "baduanjin_6", "baduanjin_7", "baduanjin_8", "shadow_box_round", "burpee_interval", "mountain_climber", "world_greatest_stretch", "hip_flexor_stretch", }; QJsonArray plans = root.value("plans").toArray(); int matchingPlanIndex = -1; for (int index = 0; index < plans.size(); ++index) { if (!plans.at(index).isObject()) continue; if (plans.at(index).toObject().value("id").toString() != QStringLiteral("qigong-mobility")) { continue; } if (matchingPlanIndex >= 0) return false; matchingPlanIndex = index; } if (matchingPlanIndex < 0) return false; QJsonObject plan = plans.at(matchingPlanIndex).toObject(); QJsonArray steps = plan.value("steps").toArray(); if (steps.size() != static_cast<int>(std::size(legacyExerciseIds))) return false; for (int index = 0; index < steps.size(); ++index) { if (!steps.at(index).isObject() || steps.at(index).toObject().value("exerciseId").toString() != QString::fromLatin1(legacyExerciseIds[index])) { return false; } } constexpr int baduanjin4Index = 5; QJsonObject baduanjin4 = steps.at(baduanjin4Index).toObject(); if (!baduanjin4.value("targetOverride").isDouble() || baduanjin4.value("targetOverride").toDouble() != 10 || !baduanjin4.value("sets").isDouble() || baduanjin4.value("sets").toDouble() != 1 || !baduanjin4.value("restSecondsOverride").isDouble() || baduanjin4.value("restSecondsOverride").toDouble() != 15) { return false; } baduanjin4.insert("targetOverride", 45); baduanjin4.insert( "coachNote", QStringLiteral("Мягко поворачивай шею и грудной отдел, без рывка и запрокидывания головы.")); steps.replace(baduanjin4Index, baduanjin4); plan.insert("steps", steps); plans.replace(matchingPlanIndex, plan); root.insert("plans", plans); contentMigrations.append(migrationId); root.insert("contentMigrations", contentMigrations); return true; } QJsonObject planStepToJson(const WorkoutStep &step) { QJsonObject object; object.insert("exerciseId", step.exerciseId); if (step.targetOverride.has_value()) object.insert("targetOverride", step.targetOverride.value()); if (step.restSecondsOverride.has_value()) object.insert("restSecondsOverride", step.restSecondsOverride.value()); if (!step.coachNote.isEmpty()) object.insert("coachNote", step.coachNote); object.insert("sets", std::max(1, step.sets)); if (step.isWarmup) object.insert("isWarmup", true); if (step.isCooldown) object.insert("isCooldown", true); return object; } QJsonObject planToJson(const WorkoutPlan &plan) { QJsonArray steps; for (const WorkoutStep &step : plan.steps) { steps.append(planStepToJson(step)); } QJsonObject object; object.insert("id", plan.id); object.insert("profileId", plan.profileId); object.insert("basePlanId", plan.basePlanId); object.insert("name", plan.name); object.insert("goal", plan.goal); object.insert("description", plan.description); object.insert("variantId", plan.variantId); object.insert("roundCount", plan.roundCount); object.insert("warmupIncluded", plan.warmupIncluded); object.insert("warmupStepCount", plan.warmupStepCount); object.insert("cooldownIncluded", plan.cooldownIncluded); object.insert("cooldownStepCount", plan.cooldownStepCount); object.insert("roundStepCount", plan.roundStepCount); object.insert("recommendedRoundsText", plan.recommendedRoundsText); object.insert("steps", steps); return object; } 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; } struct CycleWeekSpec { QString phase; QString focus; int targetPercent = 100; int setDelta = 0; int restDeltaSeconds = 0; }; CycleWeekSpec cycleWeekSpec(int weekNumber) { if (weekNumber == 25) { return {QStringLiteral("Контрольная"), QStringLiteral("Чистая техника и контрольные результаты"), 100, 0, 5}; } if (weekNumber >= 26) { return {QStringLiteral("Переходная"), QStringLiteral("Восстановление и подготовка следующего цикла"), 80, -1, 10}; } static const QStringList phases{ QStringLiteral("Адаптация"), QStringLiteral("Гипертрофия"), QStringLiteral("Объём"), QStringLiteral("Силовая выносливость"), QStringLiteral("V-специализация"), QStringLiteral("Закрепление") }; const int block = std::clamp((weekNumber - 1) / 4, 0, static_cast<int>(phases.size()) - 1); const int position = (weekNumber - 1) % 4 + 1; if (position == 4) { return {QStringLiteral("Разгрузка"), QStringLiteral("Снизить объём и восстановить суставы"), 80, -1, 10}; } if (block == 0) { const int percent = position == 1 ? 90 : position == 2 ? 95 : 100; return {phases.at(block), QStringLiteral("Освоить темп и оставить 1–2 повтора в запасе"), percent, position == 1 ? -1 : 0, position == 1 ? 5 : 0}; } const int percent = position == 1 ? 100 : position == 2 ? 105 : 110; const int restDelta = position == 3 ? -5 : 0; QString focus = QStringLiteral("Последовательное увеличение повторов при чистой технике"); if (block == 3) focus = QStringLiteral("Плотность работы, Вин-Чун и кардио без отказа"); if (block == 4) focus = QStringLiteral("Дополнительный акцент на спину и плечевой пояс"); return {phases.at(block), focus, percent, 0, restDelta}; } QJsonObject trainingCycleForProfile(const QJsonObject &root, const QString &profileId) { for (const QJsonValue &value : root.value("trainingCycles").toArray()) { if (!value.isObject()) continue; const QJsonObject cycle = value.toObject(); if (sessionMatchesProfile(cycle, profileId)) return cycle; } return {}; } QString cycleAdjustmentText(const CycleWeekSpec &spec) { QString setsText = spec.setDelta < 0 ? QStringLiteral(" • по одному подходу меньше") : QString(); QString restText; if (spec.restDeltaSeconds > 0) restText = QStringLiteral(" • отдых +%1 сек").arg(spec.restDeltaSeconds); if (spec.restDeltaSeconds < 0) restText = QStringLiteral(" • отдых %1 сек").arg(spec.restDeltaSeconds); return QStringLiteral("Цели %1%%%2%3").arg(spec.targetPercent).arg(setsText, restText); } struct RecoverySignal { int targetPercent = 100; int extraRestSeconds = 0; bool reduceSets = false; QString title; QString detail; }; RecoverySignal wearableRecoverySignal(const QJsonObject &root, const QString &profileId, const QDate &today) { const QJsonArray days = sortedObjectsByDateDesc( objectsForProfile(root.value("wearableDailyHistory").toArray(), profileId), QStringLiteral("date")); for (const QJsonValue &value : days) { const QJsonObject day = value.toObject(); const QDate date = QDate::fromString(day.value("date").toString(), Qt::ISODate); const int sleepMinutes = day.value("sleepMinutes").toInt(); if (!date.isValid() || sleepMinutes <= 0) continue; if (date.daysTo(today) > 2) break; if (sleepMinutes < 360) { return {80, 15, true, QStringLiteral("Восстановительная нагрузка"), QStringLiteral("Сон меньше 6 часов: цели снижены на 20%, отдых увеличен.")}; } if (sleepMinutes < 420) { return {90, 10, false, QStringLiteral("Умеренная нагрузка"), QStringLiteral("Сон меньше 7 часов: цели снижены на 10%.")}; } break; } return {}; } RecoverySignal checkInRecoverySignal(const QJsonObject &root, const QString &profileId, const QDate &today) { const QJsonArray entries = sortedObjectsByDateDesc( objectsForProfile(root.value("recoveryCheckInHistory").toArray(), profileId), QStringLiteral("loggedAt")); for (const QJsonValue &value : entries) { const QJsonObject item = value.toObject(); const QDateTime loggedAt = QDateTime::fromString(item.value("loggedAt").toString(), Qt::ISODate); if (!loggedAt.isValid()) continue; const int ageDays = loggedAt.date().daysTo(today); if (ageDays < 0) continue; if (ageDays > 1) break; const int energy = item.value("energyScore").toInt(); const int sleep = item.value("sleepScore").toInt(); const int joints = item.value("jointScore").toInt(); if (energy < 1 || energy > 5 || sleep < 1 || sleep > 5 || joints < 1 || joints > 5) continue; if (joints <= 2 || energy == 1 || sleep == 1) { return {80, 15, true, QStringLiteral("Восстановительная нагрузка"), QStringLiteral("Recovery: энергия %1/5, сон %2/5, суставы %3/5. Цели −20%, подходов меньше, отдых длиннее.") .arg(energy).arg(sleep).arg(joints)}; } if (energy == 2 || sleep == 2) { return {90, 10, false, QStringLiteral("Умеренная нагрузка"), QStringLiteral("Recovery: энергия %1/5, сон %2/5, суставы %3/5. Цели −10%, отдых длиннее.") .arg(energy).arg(sleep).arg(joints)}; } break; } return {}; } RecoverySignal currentRecoverySignal(const QJsonObject &root, const QString &profileId, const QDate &today) { const RecoverySignal wearable = wearableRecoverySignal(root, profileId, today); const RecoverySignal checkIn = checkInRecoverySignal(root, profileId, today); if (checkIn.targetPercent < wearable.targetPercent) return checkIn; if (wearable.targetPercent < checkIn.targetPercent) return wearable; return checkIn.targetPercent < 100 ? checkIn : wearable; } RecoverySignal trainingLoadSignal(const QJsonObject &root, const QString &profileId, const QDate &today) { const QDate weekStart = today.addDays(-6); const QDate previousWeekStart = today.addDays(-13); const QDate previousWeekEnd = today.addDays(-7); int currentLoad = 0; int previousLoad = 0; const QJsonArray sessions = objectsForProfile(root.value("sessions").toArray(), profileId); 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() < previousWeekStart || endedAt.date() > today) continue; const int heartRateLoad = sessionHeartRateLoad(session); if (heartRateLoad <= 0) continue; if (endedAt.date() >= weekStart) { currentLoad += heartRateLoad; } else if (endedAt.date() <= previousWeekEnd) { previousLoad += heartRateLoad; } } const int loadDelta = currentLoad - previousLoad; const int loadChangePercent = previousLoad > 0 ? qRound(loadDelta * 100.0 / previousLoad) : 0; if (previousLoad >= 20 && currentLoad >= previousLoad + 20 && loadChangePercent >= 50) { return {90, 10, false, QStringLiteral("Умеренная нагрузка"), QStringLiteral("Пульсовая нагрузка выросла: %1 → %2 за 7 дней (%3%). Цели снижены на 10%, отдых длиннее.") .arg(previousLoad).arg(currentLoad).arg(loadChangePercent)}; } return {}; } QString defaultStorageDirectory() { #ifdef Q_OS_ANDROID return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); #else return QDir(qEnvironmentVariable("APPDATA")).filePath("BodyweightBaseCpp"); #endif } QString defaultLegacyStatePath() { #ifdef Q_OS_ANDROID return {}; #else return QDir(qEnvironmentVariable("APPDATA")).filePath("BodyweightBase/state.json"); #endif } QString defaultExerciseFrameDirectory() { #ifdef Q_OS_ANDROID return QStringLiteral(":/exercise-frames"); #else return {}; #endif } } // namespace SessionController::SessionController(QObject *parent) : SessionController( defaultStorageDirectory(), QStringLiteral(":/resources/exercises.json"), defaultLegacyStatePath(), defaultExerciseFrameDirectory(), parent) { } SessionController::SessionController( QString storageDirectory, QString exerciseCatalogPath, QString legacyStatePath, QString exerciseFrameDirectory, QObject *parent) : QObject(parent) , storageDirectory_(storageDirectory) { healthSyncFilePath_ = QDir(storageDirectory_).filePath(QStringLiteral("health-connect-latest.json")); dataTransferImportPath_ = QDir(storageDirectory_).filePath(QStringLiteral("native-state-import.json")); healthSyncTimer_.setInterval(750); connect(&healthSyncTimer_, &QTimer::timeout, this, [this] { if (!QFileInfo::exists(healthSyncFilePath_)) { if (--healthSyncPollsRemaining_ <= 0) { healthSyncTimer_.stop(); healthSyncInProgress_ = false; healthSyncStatusText_ = QStringLiteral("Health Connect не вернул данные"); notify(); } return; } healthSyncTimer_.stop(); healthSyncInProgress_ = false; importHealthConnectData(healthSyncFilePath_); QFile::remove(healthSyncFilePath_); }); dataTransferTimer_.setInterval(750); connect(&dataTransferTimer_, &QTimer::timeout, this, [this] { if (!QFileInfo::exists(dataTransferImportPath_)) { if (--dataTransferPollsRemaining_ <= 0) dataTransferTimer_.stop(); return; } if (active() || needsSave() || hasRecoverableDraft()) { dataTransferTimer_.stop(); status_ = QStringLiteral("Импорт отложен: сначала завершите текущую тренировку"); notify(); return; } dataTransferTimer_.stop(); QFile file(dataTransferImportPath_); if (!file.open(QIODevice::ReadOnly)) return; const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); QFile::remove(dataTransferImportPath_); if (!document.isObject()) { status_ = QStringLiteral("Импорт: JSON повреждён"); notify(); return; } QJsonObject importedRoot = document.object(); ensureProfileOwnershipState(importedRoot); ensureProfileWeeklyGoalSettings(importedRoot); const QString importedHealthOwner = resolvedHealthConnectOwnerForRoot(importedRoot); importedRoot.insert("healthConnectOwnerProfileId", importedHealthOwner); const ImportResult imported = importNativeState(QJsonDocument(importedRoot).toJson()); if (!imported.ok) { status_ = QStringLiteral("Импорт: %1").arg(imported.error); notify(); return; } QString error; if (!store_->save(importedRoot, &error)) { status_ = QStringLiteral("Импорт не сохранён: %1").arg(error); notify(); return; } root_ = importedRoot; state_ = imported.state; healthConnectOwnerProfileId_ = importedHealthOwner; soundsEnabled_ = root_.value("soundsEnabled").toBool(true); uiScale_ = root_.value("uiScale").toDouble(1.0); darkMode_ = root_.value("darkMode").toBool(true); planUndoStack_ = QJsonArray{}; recoverableDraft_ = StateStore::pendingDraft(root_); applyAndroidDevicePreferences(); status_ = QStringLiteral("Полное состояние импортировано"); notify(); }); 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; } bool installedStarterState = false; if (!loaded.ok && !QFileInfo::exists(store_->stateFilePath()) && !QFileInfo::exists(store_->backupFilePath())) { QFile starterFile(QStringLiteral(":/resources/default-state.json")); if (!starterFile.open(QIODevice::ReadOnly)) { status_ = QStringLiteral("Не удалось открыть стартовые данные: %1").arg(starterFile.errorString()); return; } QJsonParseError starterParseError; const QJsonDocument starterDocument = QJsonDocument::fromJson(starterFile.readAll(), &starterParseError); if (starterParseError.error != QJsonParseError::NoError || !starterDocument.isObject()) { status_ = QStringLiteral("Стартовые данные повреждены: %1").arg(starterParseError.errorString()); return; } QString starterSaveError; if (!store_->save(starterDocument.object(), &starterSaveError)) { status_ = QStringLiteral("Не удалось создать стартовые данные: %1").arg(starterSaveError); return; } loaded = store_->load(); installedStarterState = loaded.ok; } if (!loaded.ok) { status_ = loaded.error; return; } root_ = loaded.root; const bool migratedProfileOwnership = ensureProfileOwnershipState(root_); healthConnectOwnerProfileId_ = resolvedHealthConnectOwnerForRoot(root_); const bool updatedHealthOwner = root_.value("healthConnectOwnerProfileId").toString() != healthConnectOwnerProfileId_; root_.insert("healthConnectOwnerProfileId", healthConnectOwnerProfileId_); const bool updatedProfileSettings = ensureNastyaProfileSettings(root_); const bool updatedWeeklyGoals = ensureProfileWeeklyGoalSettings(root_); const bool migratedWingChunPlan = migrateLegacyWingChunStructurePlan(root_); const bool migratedQigongPlan = migrateQigongBaduanjin4ToTimed(root_); if (migratedProfileOwnership || updatedHealthOwner || updatedProfileSettings || updatedWeeklyGoals || migratedWingChunPlan || migratedQigongPlan) { QString settingsError; if (!store_->save(root_, &settingsError)) { status_ = migratedProfileOwnership || updatedWeeklyGoals || migratedWingChunPlan || migratedQigongPlan ? QStringLiteral("Не удалось обновить встроенные данные: %1").arg(settingsError) : QStringLiteral("Не удалось обновить настройки профиля Насти: %1").arg(settingsError); return; } } const QString activeProfileId = selectedProfileIdOrDefault(root_); const QJsonArray activeProfileWeights = sortedObjectsByDateDesc( objectsForProfile(root_.value("bodyweightHistory").toArray(), activeProfileId), QStringLiteral("loggedAt")); for (const QJsonValue &value : activeProfileWeights) { const QJsonObject item = value.toObject(); if (item.value("source").toString() != QStringLiteral("health-connect")) continue; const double syncedWeight = item.value("weightKg").toDouble(); if (syncedWeight < 30.0 || syncedWeight > 250.0) continue; if (activeProfileId == QStringLiteral("default") && !qFuzzyCompare(root_.value("currentBodyWeightKg").toDouble() + 1.0, syncedWeight + 1.0)) { root_.insert("currentBodyWeightKg", syncedWeight); store_->save(root_); } break; } 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; applyAndroidDevicePreferences(); // Older native files predate dayOfWeek. Recover the schedule in memory // from the existing "Пн • ..." plan names without writing on startup. QJsonArray persistedPlans = root_.value("plans").toArray(); bool repairedSchedule = false; for (int index = 0; index < state_.workoutPlans.size() && index < persistedPlans.size(); ++index) { WorkoutPlan &plan = state_.workoutPlans[index]; if (plan.dayOfWeek != 0 || !persistedPlans.at(index).isObject()) continue; const int inferredDay = inferPlanDayOfWeek(plan.name); if (inferredDay == 0) continue; plan.dayOfWeek = inferredDay; QJsonObject planObject = persistedPlans.at(index).toObject(); planObject.insert("dayOfWeek", inferredDay); persistedPlans.replace(index, planObject); repairedSchedule = true; } if (repairedSchedule) { root_.insert("plans", persistedPlans); } scheduleDate_ = QDate::currentDate(); if (const WorkoutPlan *todayPlan = scheduledPlanForDate(scheduleDate_)) { if (state_.selectedPlanId.compare(todayPlan->id, Qt::CaseInsensitive) != 0) { state_.selectedPlanId = todayPlan->id; setSelectedPlanIdForProfile(root_, activeProfileId, todayPlan->id); } } QString pendingResultError; const std::optional<StoredFinishedSession> storedFinished = StateStore::pendingFinishedSession(root_, &pendingResultError); if (storedFinished) { pendingFinishedSession_ = storedFinished->session; pendingFinishedSessionProfileId_ = storedFinished->profileId; sessionStartedAtLocal_ = storedFinished->sessionStartedAtLocal; sessionEndedAtLocal_ = storedFinished->sessionEndedAtLocal; } QString draftError; recoverableDraft_ = pendingFinishedSession_ ? std::nullopt : StateStore::pendingDraft(root_, &draftError); status_ = migratedLegacy ? QStringLiteral("Старые данные импортированы в C++ хранилище") : installedStarterState ? QStringLiteral("Персональные планы установлены") : loaded.recoveredFromBackup ? QStringLiteral("Состояние восстановлено из резервной копии") : pendingFinishedSession_ ? QStringLiteral("Найдён результат, ожидающий сохранения") : recoverableDraft_ ? QStringLiteral("Найдена незавершённая тренировка") : QStringLiteral("Данные загружены"); if (!pendingResultError.isEmpty()) { status_ = QStringLiteral("Ожидающий результат отклонён: %1").arg(pendingResultError); } 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::quickDecisionPending() const { return snapshot().phase == SessionPhase::quickDecision; } 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); const bool completed = pendingFinishedSession_->completedAll; const int expectedSteps = std::max( static_cast<int>(pendingFinishedSession_->steps.size()), pendingFinishedSession_->expectedStepCount); return QStringLiteral("%1 мин • %2 шагов • %3") .arg(workMinutes) .arg(expectedSteps) .arg(completed ? 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 = displayPlan(); if (!plan) { return QVariantMap{ {"title", QStringLiteral("План не выбран")}, {"detail", QStringLiteral("Выбери рабочий план перед стартом.")}, {"color", QStringLiteral("#94A3B8")}, {"hasHistory", false} }; } const QVariantMap continuity = trainingContinuityForDate(QDate::currentDate()); const int returnPercent = continuity.value("targetPercent", 100).toInt(); const RecoverySignal recovery = currentRecoverySignal(root_, selectedProfileId(), QDate::currentDate()); if (continuity.value("returnMode").toBool() && returnPercent < recovery.targetPercent) { return QVariantMap{{"title", continuity.value("title")}, {"detail", continuity.value("detail")}, {"color", QStringLiteral("#F59E0B")}, {"hasHistory", true}, {"returnPercent", returnPercent}, {"recommendedMaxRounds", continuity.value("recommendedMaxRounds")}}; } if (recovery.targetPercent < 100) { return QVariantMap{{"title", recovery.title}, {"detail", recovery.detail}, {"color", QStringLiteral("#F59E0B")}, {"hasHistory", true}, {"recoveryPercent", recovery.targetPercent}}; } const RecoverySignal load = trainingLoadSignal(root_, selectedProfileId(), QDate::currentDate()); if (continuity.value("returnMode").toBool() && returnPercent <= load.targetPercent) { return QVariantMap{{"title", continuity.value("title")}, {"detail", continuity.value("detail")}, {"color", QStringLiteral("#F59E0B")}, {"hasHistory", true}, {"returnPercent", returnPercent}, {"recommendedMaxRounds", continuity.value("recommendedMaxRounds")}}; } if (load.targetPercent < 100) { return QVariantMap{{"title", load.title}, {"detail", load.detail}, {"color", QStringLiteral("#F59E0B")}, {"hasHistory", true}, {"loadPercent", load.targetPercent}}; } 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 { const SessionSnapshot view = snapshot(); const QVariantMap position = currentWorkoutPosition(); if (position.value("sideSwitchActive").toBool()) { return position.value("sideSwitchText").toString(); } const int logicalSet = position.contains("set") ? position.value("set").toInt() : view.currentSet; const int logicalSetCount = position.contains("totalSets") ? position.value("totalSets").toInt() : view.totalSets; const QString setInfo = logicalSetCount > 1 ? QStringLiteral(" (подход %1/%2)").arg(logicalSet).arg(logicalSetCount) : QString(); switch (view.phase) { case SessionPhase::preparation: return QStringLiteral("Подготовка") + setInfo; case SessionPhase::repetitionExercise: return QStringLiteral("Повторения") + setInfo; case SessionPhase::timedExercise: return QStringLiteral("Рабочий таймер") + setInfo; case SessionPhase::rest: return QStringLiteral("Отдых") + setInfo; case SessionPhase::paused: return QStringLiteral("Пауза"); case SessionPhase::quickDecision: return QStringLiteral("Пять минут сделаны"); case SessionPhase::completed: return QStringLiteral("Тренировка завершена"); default: return QStringLiteral("Готов к старту"); } } QString SessionController::exerciseName() const { const SessionSnapshot view = snapshot(); if (!displayPlan() || 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 = displayPlan(); if (!plan || view.phase == SessionPhase::idle || view.phase == SessionPhase::completed) return {}; if (view.currentSet < view.totalSets) { const QVariantMap position = currentWorkoutPosition(); const QString side = position.value("sideText").toString(); return side.isEmpty() ? exerciseNameForStep(view.stepIndex) : QStringLiteral("%1 • %2 сторона").arg(exerciseNameForStep(view.stepIndex), side.toLower()); } 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 = displayPlan(); 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 = displayPlan(); 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 = displayPlan(); 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 { const WorkoutPlan *plan = displayPlan(); if (!plan) return QStringLiteral("0 / 0"); return QStringLiteral("%1 / %2") .arg(std::min(snapshot().stepIndex + 1, static_cast<int>(plan->steps.size()))) .arg(plan->steps.size()); } QString SessionController::completedStepText() const { const WorkoutPlan *plan = displayPlan(); if (!plan) return QStringLiteral("закрыто 0 / 0"); int expectedCompletedSteps = 0; for (const WorkoutStep &step : plan->steps) { const ExerciseDefinition *exercise = findExercise(exercises_, step.exerciseId); const int baseSets = std::max(1, step.sets); expectedCompletedSteps += (exercise && exercise->alternatingSides) ? baseSets * 2 : baseSets; } return QStringLiteral("закрыто %1 / %2") .arg(std::clamp(snapshot().completedStepCount, 0, expectedCompletedSteps)) .arg(expectedCompletedSteps); } 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) { 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) { 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(); const WorkoutPlan *plan = displayPlan(); if (!plan || view.phase == SessionPhase::idle || view.phase == SessionPhase::completed) { return 0; } return std::min(view.stepIndex + 1, static_cast<int>(plan->steps.size())); } QVariantMap SessionController::currentWorkoutPosition() const { const SessionSnapshot view = snapshot(); const WorkoutPlan *plan = displayPlan(); 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 bool alternatingSides = exercise && exercise->alternatingSides; const int logicalSet = alternatingSides ? (view.currentSet + 1) / 2 : view.currentSet; const int logicalSetCount = alternatingSides ? std::max(1, (view.totalSets + 1) / 2) : std::max(1, view.totalSets); const int planStepCount = static_cast<int>(plan->steps.size()); int warmupSteps = 0; while (warmupSteps < planStepCount && plan->steps.at(warmupSteps).isWarmup) { ++warmupSteps; } int cooldownSteps = 0; while (cooldownSteps < planStepCount - warmupSteps && plan->steps.at(planStepCount - 1 - cooldownSteps).isCooldown) { ++cooldownSteps; } QString section = QStringLiteral("work"); QString sectionText = QStringLiteral("Работа"); int round = 0; const int totalRounds = std::clamp(plan->roundCount, 1, 6); if (step.isWarmup) { section = QStringLiteral("warmup"); sectionText = QStringLiteral("Разминка"); } else if (step.isCooldown) { section = QStringLiteral("cooldown"); sectionText = QStringLiteral("Заминка"); } else { const int mainStepCount = std::max(0, planStepCount - warmupSteps - cooldownSteps); const int workIndex = std::max(0, view.stepIndex - warmupSteps); const bool upgradedQuickFlow = plan->variantId == QStringLiteral("quick-start") || plan->variantId == QStringLiteral("quick-upgraded"); if (upgradedQuickFlow && totalRounds > 1) { const int firstRoundSteps = std::clamp(plan->roundStepCount, 1, std::max(1, mainStepCount)); const int laterRoundSteps = std::max( 1, (mainStepCount - firstRoundSteps) / std::max(1, totalRounds - 1)); round = workIndex < firstRoundSteps ? 1 : std::clamp(2 + (workIndex - firstRoundSteps) / laterRoundSteps, 2, totalRounds); } else { const int stepsPerRound = totalRounds > 0 ? mainStepCount / totalRounds : mainStepCount; round = stepsPerRound > 0 ? std::clamp(workIndex / stepsPerRound + 1, 1, totalRounds) : 1; } } const SessionPhase effectivePhase = view.phase == SessionPhase::paused ? view.phaseBeforePause : view.phase; const bool sideSwitchActive = alternatingSides && effectivePhase == SessionPhase::rest && view.phaseDurationSeconds == 3 && view.currentSet % 2 == 0; const QString sideText = alternatingSides ? (view.currentSet % 2 == 1 ? QStringLiteral("Левая") : QStringLiteral("Правая")) : QString(); return { {"section", section}, {"sectionText", sectionText}, {"round", round}, {"totalRounds", totalRounds}, {"roundText", round > 0 ? QStringLiteral("Круг %1/%2").arg(round).arg(totalRounds) : QString()}, {"set", logicalSet}, {"totalSets", logicalSetCount}, {"setText", QStringLiteral("Подход %1/%2").arg(logicalSet).arg(logicalSetCount)}, {"side", alternatingSides ? (view.currentSet % 2 == 1 ? QStringLiteral("left") : QStringLiteral("right")) : QString()}, {"sideText", sideText}, {"alternatingSides", alternatingSides}, {"isRest", effectivePhase == SessionPhase::rest}, {"sideSwitchActive", sideSwitchActive}, {"sideSwitchText", sideSwitchActive ? QStringLiteral("Смена стороны • далее %1").arg(sideText.toLower()) : QString()} }; } QVariantList SessionController::currentExerciseFrameUrls() const { const SessionSnapshot view = snapshot(); const WorkoutPlan *plan = displayPlan(); 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); } int SessionController::currentExerciseFrameDurationMs() const { const SessionSnapshot view = snapshot(); const WorkoutPlan *plan = displayPlan(); if (!plan || view.phase == SessionPhase::idle || view.phase == SessionPhase::completed || view.stepIndex < 0 || view.stepIndex >= plan->steps.size()) { return 1100; } const ExerciseDefinition *exercise = findExercise(exercises_, plan->steps.at(view.stepIndex).exerciseId); return exercise ? exercise->frameDurationMs : 1100; } QString SessionController::currentExerciseFramePlayback() const { const SessionSnapshot view = snapshot(); const WorkoutPlan *plan = displayPlan(); if (!plan || view.phase == SessionPhase::idle || view.phase == SessionPhase::completed || view.stepIndex < 0 || view.stepIndex >= plan->steps.size()) { return QStringLiteral("loop"); } const ExerciseDefinition *exercise = findExercise(exercises_, plan->steps.at(view.stepIndex).exerciseId); return exercise ? exercise->framePlayback : QStringLiteral("loop"); } QVariantList SessionController::currentExerciseFrameDurationsMs() const { const SessionSnapshot view = snapshot(); const WorkoutPlan *plan = displayPlan(); 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); QVariantList result; if (!exercise) { return result; } result.reserve(exercise->frameDurationsMs.size()); for (const int durationMs : exercise->frameDurationsMs) { result.append(durationMs); } return result; } QString SessionController::currentLoadNote() const { return currentLoadNote_; } QVariantMap SessionController::currentSetLog() const { QStringList parts; if (currentExternalLoadKg_ > 0.0) parts << QStringLiteral("%1 кг").arg(formatDecimal(currentExternalLoadKg_)); if (currentEffortRating_ > 0) parts << QStringLiteral("RPE %1").arg(currentEffortRating_); if (currentDiscomfortRating_ > 0) { parts << QStringLiteral("дискомфорт %1/10").arg(currentDiscomfortRating_); } if (!currentLoadNote_.isEmpty()) parts << QStringLiteral("есть заметка"); return QVariantMap{ {"loadKg", currentExternalLoadKg_}, {"loadKgText", currentExternalLoadKg_ > 0.0 ? formatDecimal(currentExternalLoadKg_) : QString()}, {"effortRating", currentEffortRating_}, {"discomfortRating", currentDiscomfortRating_}, {"note", currentLoadNote_}, {"summaryText", parts.isEmpty() ? QStringLiteral("Записать нагрузку") : parts.join(QStringLiteral(" • "))} }; } QVariantMap SessionController::lastCompletedSetLog() const { std::optional<CompletedExercise> completed; if (pendingFinishedSession_ && !pendingFinishedSession_->steps.isEmpty()) { completed = pendingFinishedSession_->steps.constLast(); } else if (runner_) { completed = runner_->lastCompletedExercise(); } if (!completed) return QVariantMap{{"available", false}}; const CompletedExercise &step = *completed; QStringList parts; if (step.externalLoadKg > 0.0) parts << QStringLiteral("%1 кг").arg(formatDecimal(step.externalLoadKg)); if (step.effortRating > 0) parts << QStringLiteral("RPE %1").arg(step.effortRating); if (step.discomfortRating > 0) { parts << QStringLiteral("дискомфорт %1/10").arg(step.discomfortRating); } if (!step.loadNote.isEmpty()) parts << QStringLiteral("есть заметка"); return QVariantMap{ {"available", true}, {"exerciseName", step.exerciseName}, {"loadKg", step.externalLoadKg}, {"loadKgText", step.externalLoadKg > 0.0 ? formatDecimal(step.externalLoadKg) : QString()}, {"effortRating", step.effortRating}, {"discomfortRating", step.discomfortRating}, {"note", step.loadNote}, {"summaryText", parts.isEmpty() ? QStringLiteral("Оценить завершённый подход") : parts.join(QStringLiteral(" • "))} }; } 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; const QString profileId = selectedProfileId(); for (const WorkoutPlan &plan : state_.workoutPlans) { if (plan.profileId.compare(profileId, Qt::CaseInsensitive) != 0) continue; result.append(QVariantMap{ {"id", plan.id}, {"name", plan.name}, {"goal", plan.goal}, {"description", plan.description}, {"stepCount", plan.steps.size()}, {"dayOfWeek", plan.dayOfWeek}, {"roundCount", plan.roundCount}, {"selected", plan.id.compare(state_.selectedPlanId, Qt::CaseInsensitive) == 0} }); } return result; } QString SessionController::selectedPlanId() const { return state_.selectedPlanId; } QVariantMap SessionController::selectedPlanOptions() const { const WorkoutPlan *plan = displayPlan(); if (!plan) return {}; return QVariantMap{ {"warmupIncluded", plan->warmupIncluded}, {"warmupStepCount", plan->warmupStepCount}, {"cooldownIncluded", plan->cooldownIncluded}, {"cooldownStepCount", plan->cooldownStepCount}, {"roundCount", plan->roundCount}, {"recommendedRoundsText", plan->recommendedRoundsText} }; } QVariantList SessionController::selectedPlanSteps() const { return planStepsFor(selectedPlan()); } QVariantList SessionController::workoutQueueSteps() const { return planStepsFor(displayPlan()); } QVariantList SessionController::workoutReplacementExercises() const { QVariantList result; const SessionSnapshot view = snapshot(); const SessionPhase effectivePhase = view.phase == SessionPhase::paused ? view.phaseBeforePause : view.phase; const WorkoutPlan *plan = displayPlan(); if (!plan || view.stepIndex < 0 || view.stepIndex >= plan->steps.size() || (effectivePhase != SessionPhase::preparation && effectivePhase != SessionPhase::repetitionExercise && effectivePhase != SessionPhase::timedExercise)) { return result; } const QString currentId = plan->steps.at(view.stepIndex).exerciseId; const ExerciseDefinition *current = findExercise(exercises_, currentId); if (!current) return result; for (const ExerciseDefinition &exercise : exercises_) { if (exercise.id == currentId || exercise.metric != current->metric) continue; result.append(QVariantMap{ {"id", exercise.id}, {"name", exercise.name}, {"category", exercise.category}, {"equipment", exercise.equipment}, {"description", exercise.description}, {"metricText", metricText(exercise.metric)} }); } return result; } QVariantList SessionController::planStepsFor(const WorkoutPlan *plan) const { QVariantList result; 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)}, {"sets", step.sets}, {"isWarmup", step.isWarmup}, {"isCooldown", step.isCooldown} }); } return result; } int SessionController::totalSessions() const { return sessionsArray().size(); } int SessionController::currentSet() const { return runner_->snapshot().currentSet; } int SessionController::totalSets() const { return runner_->snapshot().totalSets; } int SessionController::selectedPlanDayOfWeek() const { const WorkoutPlan *plan = selectedPlan(); return plan ? plan->dayOfWeek : 0; } bool SessionController::hasScheduledPlanToday() const { return scheduledPlanForDate(QDate::currentDate()) != nullptr; } QString SessionController::scheduledPlanTodayName() const { const WorkoutPlan *plan = scheduledPlanForDate(QDate::currentDate()); return plan ? plan->name : QString(); } 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 fullSessions = 0; int completed = 0; for (const QJsonValue &value : sessions) { if (!value.isObject()) continue; const QJsonObject session = value.toObject(); if (isQuickSession(session)) continue; ++fullSessions; if (session.value("completedAll").toBool()) ++completed; } return fullSessions > 0 ? QStringLiteral("%1%").arg(qRound(completed * 100.0 / fullSessions)) : QStringLiteral("0%"); } 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()); const int heartRateLoad = sessionHeartRateLoad(session); const bool quickSession = isQuickSession(session); result.append(QVariantMap{ {"planName", session.value("planName").toString()}, {"sessionMode", quickSession ? QStringLiteral("quick") : QStringLiteral("full")}, {"modeText", quickSession ? QStringLiteral("5 минут") : QStringLiteral("полная")}, {"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()}, {"heartRateAvailable", session.value("heartRateSampleCount").toInt() > 0}, {"heartRateText", session.value("heartRateSampleCount").toInt() > 0 ? QStringLiteral("пульс %1 • %2–%3").arg(qRound(session.value("heartRateAvg").toDouble())) .arg(session.value("heartRateMin").toInt()).arg(session.value("heartRateMax").toInt()) : QString()}, {"heartRateLoadAvailable", heartRateLoad > 0}, {"heartRateLoad", heartRateLoad}, {"heartRateLoadText", heartRateLoad > 0 ? QStringLiteral("нагрузка HR %1").arg(heartRateLoad) : QString()} }); } 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()}, {"externalLoadKg", step.value("externalLoadKg").toDouble()}, {"effortRating", step.value("effortRating").toInt()}, {"discomfortRating", step.value("discomfortRating").toInt()} }); } return result; } QVariantList SessionController::loadJournalEntries() const { QVector<QJsonObject> sessions; for (const QJsonValue &value : sessionsArray()) { if (value.isObject()) sessions.append(value.toObject()); } std::sort(sessions.begin(), sessions.end(), [](const QJsonObject &left, const QJsonObject &right) { return QDateTime::fromString(left.value("endedAt").toString(), Qt::ISODate) > QDateTime::fromString(right.value("endedAt").toString(), Qt::ISODate); }); QVariantList result; for (const QJsonObject &session : sessions) { if (progressPlanFilter_ != "all" && session.value("planId").toString().compare(progressPlanFilter_, Qt::CaseInsensitive) != 0) continue; const QString dateText = dateTimeText(session.value("endedAt").toString()); const QString planName = session.value("planName").toString(); for (const QJsonValue &stepValue : session.value("steps").toArray()) { if (!stepValue.isObject()) continue; const QJsonObject step = stepValue.toObject(); const double loadKg = std::clamp(step.value("externalLoadKg").toDouble(), 0.0, 500.0); const int effort = std::clamp(step.value("effortRating").toInt(), 0, 10); const int discomfort = std::clamp(step.value("discomfortRating").toInt(), 0, 10); const QString note = step.value("loadNote").toString().trimmed(); if (loadKg <= 0.0 && effort <= 0 && discomfort <= 0 && note.isEmpty()) continue; QStringList details; if (loadKg > 0.0) details << QStringLiteral("%1 кг").arg(formatDecimal(loadKg)); if (effort > 0) details << QStringLiteral("RPE %1").arg(effort); if (discomfort > 0) details << QStringLiteral("дискомфорт %1/10").arg(discomfort); result.append(QVariantMap{ {"dateText", dateText}, {"planName", planName}, {"exerciseName", step.value("exerciseName").toString(step.value("exerciseId").toString())}, {"externalLoadKg", loadKg}, {"effortRating", effort}, {"discomfortRating", discomfort}, {"note", note}, {"detailsText", details.join(QStringLiteral(" • "))} }); if (result.size() >= 50) return result; } } 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; int bestActual = 0; QDateTime bestDoneAt; 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; } const int actualValue = std::max(0, step.value("actualValue").toInt()); found->actualTotal += actualValue; if (actualValue > found->bestActual) { found->bestActual = actualValue; found->bestDoneAt = endedAt; } 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}, {"bestActual", bucket.bestActual}, {"bestDateText", bucket.bestDoneAt.isValid() ? bucket.bestDoneAt.toString("dd.MM") : QStringLiteral("без даты")}, {"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(); } bool SessionController::updateCurrentSetLog( const QString &loadKgText, int effortRating, int discomfortRating, const QString ¬e) { const std::optional<double> loadKg = parseExternalLoadKg(loadKgText); if (!loadKg) { status_ = QStringLiteral("Вес нагрузки должен быть от 0 до 500 кг"); notify(); return false; } QString normalizedNote = note.trimmed(); if (normalizedNote.size() > 160) normalizedNote = normalizedNote.left(160); currentExternalLoadKg_ = *loadKg; currentEffortRating_ = std::clamp(effortRating, 0, 10); currentDiscomfortRating_ = std::clamp(discomfortRating, 0, 10); currentLoadNote_ = normalizedNote; if (runner_) { runner_->setCurrentSetLog( currentExternalLoadKg_, currentEffortRating_, currentDiscomfortRating_, currentLoadNote_); } if (active()) persistDraft(); status_ = QStringLiteral("Нагрузка подхода записана"); notify(); return true; } bool SessionController::updateLastCompletedSetLog( const QString &loadKgText, int effortRating, int discomfortRating, const QString ¬e) { const std::optional<double> loadKg = parseExternalLoadKg(loadKgText); if (!loadKg) { status_ = QStringLiteral("Вес нагрузки должен быть от 0 до 500 кг"); notify(); return false; } QString normalizedNote = note.trimmed(); if (normalizedNote.size() > 160) normalizedNote = normalizedNote.left(160); const int effort = std::clamp(effortRating, 0, 10); const int discomfort = std::clamp(discomfortRating, 0, 10); bool updated = false; if (pendingFinishedSession_ && !pendingFinishedSession_->steps.isEmpty()) { CompletedExercise &step = pendingFinishedSession_->steps.last(); step.externalLoadKg = *loadKg; step.effortRating = effort; step.discomfortRating = discomfort; step.loadNote = normalizedNote; if (!persistPendingResult()) { notify(); return false; } updated = true; } else if (runner_) { updated = runner_->updateLastCompletedSetLog(*loadKg, effort, discomfort, normalizedNote); if (updated && active()) persistDraft(); } status_ = updated ? QStringLiteral("Завершённый подход оценён") : QStringLiteral("Нет завершённого подхода для оценки"); notify(); return updated; } QVariantMap SessionController::nutritionSummary() const { const QString profileId = selectedProfileId(); const QJsonObject profileSettings = profileSettingsFor(root_, profileId); QString preset = normalizeNutritionPreset(profileSettings.value("nutritionPresetId").toString( root_.value("nutritionPresetId").toString("balanced"))); if (preset.isEmpty()) { preset = QStringLiteral("balanced"); } const QJsonArray weights = sortedObjectsByDateDesc( objectsForProfile(root_.value("bodyweightHistory").toArray(), profileId), QStringLiteral("loggedAt")); QJsonObject preferredWeight; const QJsonArray wearableDays = sortedObjectsByDateDesc( objectsForProfile(root_.value("wearableDailyHistory").toArray(), profileId), QStringLiteral("date")); for (const QJsonValue &value : wearableDays) { const QJsonObject day = value.toObject(); const double syncedWeight = day.value("weightKg").toDouble(); if (syncedWeight >= 30.0 && syncedWeight <= 250.0) { preferredWeight = day; break; } } for (const QJsonValue &value : weights) { if (!preferredWeight.isEmpty()) break; const QJsonObject item = value.toObject(); if (item.value("source").toString() == QStringLiteral("health-connect")) { preferredWeight = item; break; } } if (preferredWeight.isEmpty() && !weights.isEmpty()) preferredWeight = weights.first().toObject(); const double configuredInitialWeight = profileSettings.value("initialWeightKg").toDouble( profileId == "default" ? root_.value("currentBodyWeightKg").toDouble(75.0) : 75.0); const double weight = !preferredWeight.isEmpty() ? preferredWeight.value("weightKg").toDouble(75.0) : configuredInitialWeight; const bool fatLossGoal = profileSettings.value("weightGoalKg").toDouble() > 0.0 && profileSettings.value("weightGoalKg").toDouble() < weight; const int trainingCalories = fatLossGoal ? roundToStep(std::max(1500.0, weight * 30.0), 50) : roundToStep(weight * 40.0, 50); const int recoveryCalories = fatLossGoal ? std::max(1400, trainingCalories - 150) : std::max(2200, trainingCalories - 250); const int proteinTarget = fatLossGoal ? roundToStep(std::max(95.0, weight * 1.6), 5) : 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 (!preferredWeight.isEmpty()) { const QString recordedAt = preferredWeight.value("weightRecordedAt").toString( preferredWeight.value("loggedAt").toString(preferredWeight.value("date").toString())); weightLastLoggedText = QStringLiteral("Приоритетный вес: %1").arg(dateTimeText(recordedAt)); } 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(fatLossGoal ? trainingCalories - 100 : std::max(2600, trainingCalories - 100)).arg(trainingCalories + 100)}, {"recoveryCaloriesText", QStringLiteral("%1–%2").arg(fatLossGoal ? recoveryCalories - 100 : 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(profileSettingsFor(root_, selectedProfileId()).value("nutritionPresetId").toString( 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()}, {"externalLoadKg", step.value("externalLoadKg").toDouble()}, {"effortRating", step.value("effortRating").toInt()}, {"discomfortRating", step.value("discomfortRating").toInt()} }); } } } 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, int> quickSessionsPerDay; 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 (isQuickSession(session)) { ++quickSessionsPerDay[date]; } else 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)}, {"quickSessionCount", quickSessionsPerDay.value(date, 0)}, {"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 QDate previousWeekStart = today.addDays(-13); const QDate previousWeekEnd = today.addDays(-7); const QJsonArray sessions = sessionsArray(); struct WeekTotals { int sessions = 0; int fullSessions = 0; int quickSessions = 0; int workSeconds = 0; int completedSessions = 0; int totalSteps = 0; int completedSteps = 0; int heartRateLoad = 0; int heartRateSessions = 0; }; WeekTotals current; WeekTotals previous; QHash<QString, int> exerciseVolume; QSet<QString> currentCategories; 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() < previousWeekStart || endedAt.date() > today) continue; const bool inCurrentWeek = endedAt.date() >= weekStart; WeekTotals &totals = inCurrentWeek ? current : previous; ++totals.sessions; if (isQuickSession(session)) ++totals.quickSessions; else ++totals.fullSessions; totals.workSeconds += std::max(0, session.value("totalWorkSeconds").toInt()); const int hrLoad = sessionHeartRateLoad(session); if (hrLoad > 0) { totals.heartRateLoad += hrLoad; ++totals.heartRateSessions; } if (!isQuickSession(session) && session.value("completedAll").toBool()) ++totals.completedSessions; const QJsonArray steps = session.value("steps").toArray(); for (const QJsonValue &sv : steps) { if (!sv.isObject()) continue; const QJsonObject step = sv.toObject(); ++totals.totalSteps; if (step.value("completed").toBool()) ++totals.completedSteps; if (!inCurrentWeek) continue; const QString exId = step.value("exerciseId").toString(); const int actualValue = std::max(0, step.value("actualValue").toInt()); if (!exId.isEmpty()) exerciseVolume[exId] += actualValue; const ExerciseDefinition *def = findExercise(exercises_, exId); const QString category = def ? def->category.trimmed() : QString(); if (!category.isEmpty() && (step.value("completed").toBool() || actualValue > 0)) { currentCategories.insert(category); } } } QSet<QString> plannedCategories; if (const WorkoutPlan *plan = selectedPlan()) { for (const WorkoutStep &step : plan->steps) { if (step.isWarmup || step.isCooldown) continue; const ExerciseDefinition *def = findExercise(exercises_, step.exerciseId); const QString category = def ? def->category.trimmed() : QString(); if (!category.isEmpty()) plannedCategories.insert(category); } } QStringList undertrainedCategories; for (const QString &category : plannedCategories) { if (!currentCategories.contains(category)) undertrainedCategories.append(category); } undertrainedCategories.sort(Qt::CaseInsensitive); const QString undertrainedAreasText = undertrainedCategories.isEmpty() ? (current.sessions > 0 ? QStringLiteral("По категориям выбранного плана явных провалов нет") : QStringLiteral("Неделя пустая — зоны нагрузки пока не оценить")) : QStringLiteral("Просели зоны: %1").arg(undertrainedCategories.join(QStringLiteral(", "))); 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}}); } const int currentMinutes = current.workSeconds <= 0 ? 0 : std::max(1, current.workSeconds / 60); const int previousMinutes = previous.workSeconds <= 0 ? 0 : std::max(1, previous.workSeconds / 60); const int sessionsDelta = current.sessions - previous.sessions; const int minutesDelta = currentMinutes - previousMinutes; const int workChangePercent = previousMinutes > 0 ? qRound(minutesDelta * 100.0 / previousMinutes) : 0; const int heartRateLoadDelta = current.heartRateLoad - previous.heartRateLoad; const int heartRateLoadChangePercent = previous.heartRateLoad > 0 ? qRound(heartRateLoadDelta * 100.0 / previous.heartRateLoad) : 0; const QJsonObject goalSettings = profileSettingsFor(root_, selectedProfileId()); const int weeklyGoalMin = std::clamp(goalSettings.value("weeklyGoalMin").toInt(3), 1, 7); const int weeklyGoalMax = std::clamp( goalSettings.value("weeklyGoalMax").toInt(weeklyGoalMin), weeklyGoalMin, 7); const int rescueWeeklyGoalMin = std::clamp( goalSettings.value("rescueWeeklyGoalMin").toInt(std::max(1, weeklyGoalMin / 2)), 1, weeklyGoalMin); const bool rescueGoalActive = trainingContinuityForDate(today).value("returnMode").toBool(); const int effectiveGoalMin = rescueGoalActive ? rescueWeeklyGoalMin : weeklyGoalMin; const int remainingToGoal = std::max(0, effectiveGoalMin - current.fullSessions); const int weeklyGoalProgress = std::clamp( qRound(current.fullSessions * 100.0 / effectiveGoalMin), 0, 100); QString weeklyGoalStatusId = QStringLiteral("in-progress"); QString weeklyGoalStatusText; if (rescueGoalActive) { weeklyGoalStatusId = current.fullSessions >= effectiveGoalMin ? QStringLiteral("rescue-complete") : QStringLiteral("rescue"); weeklyGoalStatusText = current.fullSessions >= effectiveGoalMin ? QStringLiteral("Минимум недели возвращения закрыт. Дальше — только по самочувствию.") : QStringLiteral("Неделя возвращения: ещё %1 полн. до безопасного минимума.") .arg(remainingToGoal); } else if (current.fullSessions >= weeklyGoalMax) { weeklyGoalStatusId = QStringLiteral("maximum-complete"); weeklyGoalStatusText = QStringLiteral("Верхняя недельная цель закрыта."); } else if (current.fullSessions >= weeklyGoalMin) { weeklyGoalStatusId = QStringLiteral("minimum-complete"); weeklyGoalStatusText = weeklyGoalMin == weeklyGoalMax ? QStringLiteral("Недельная цель закрыта.") : QStringLiteral("Минимум закрыт. До верхней цели: %1.") .arg(weeklyGoalMax - current.fullSessions); } else { weeklyGoalStatusText = QStringLiteral("До недельного минимума: %1 полн. трен.") .arg(remainingToGoal); } QString loadStatusId = QStringLiteral("baseline"); QString loadStatusText = previous.sessions == 0 ? QStringLiteral("Сравнение появится после второй недели данных") : QStringLiteral("Нагрузка без резких изменений"); if (previous.heartRateLoad >= 20 && current.heartRateLoad >= previous.heartRateLoad + 20 && heartRateLoadChangePercent >= 50) { loadStatusId = QStringLiteral("sharp-hr-increase"); loadStatusText = QStringLiteral("Пульсовая нагрузка резко выросла — проверьте восстановление перед тяжёлой тренировкой"); } else if (previousMinutes >= 20 && currentMinutes >= previousMinutes + 30 && workChangePercent >= 50) { loadStatusId = QStringLiteral("sharp-increase"); loadStatusText = QStringLiteral("Объём резко вырос — проверьте восстановление перед тяжёлой тренировкой"); } else if (previous.sessions > 0 && current.sessions == 0) { loadStatusId = QStringLiteral("low-activity"); loadStatusText = QStringLiteral("За последние семь дней тренировок не записано"); } else if (previousMinutes > 0 && currentMinutes * 10 < previousMinutes * 6) { loadStatusId = QStringLiteral("reduced"); loadStatusText = QStringLiteral("Объём заметно ниже предыдущих семи дней"); } const auto signedText = [](int value) { return value > 0 ? QStringLiteral("+%1").arg(value) : QString::number(value); }; return {{"totalSessions", current.sessions}, {"fullSessions", current.fullSessions}, {"quickSessions", current.quickSessions}, {"weeklyGoalMin", weeklyGoalMin}, {"weeklyGoalMax", weeklyGoalMax}, {"rescueWeeklyGoalMin", rescueWeeklyGoalMin}, {"effectiveWeeklyGoalMin", effectiveGoalMin}, {"weeklyGoalProgress", weeklyGoalProgress}, {"weeklyGoalRemaining", remainingToGoal}, {"weeklyGoalAchieved", current.fullSessions >= effectiveGoalMin}, {"rescueGoalActive", rescueGoalActive}, {"weeklyGoalStatusId", weeklyGoalStatusId}, {"weeklyGoalStatusText", weeklyGoalStatusText}, {"weeklyGoalText", weeklyGoalMin == weeklyGoalMax ? QStringLiteral("%1 полных").arg(weeklyGoalMin) : QStringLiteral("%1–%2 полных").arg(weeklyGoalMin).arg(weeklyGoalMax)}, {"totalWorkMinutes", currentMinutes}, {"completedSessions", current.completedSessions}, {"completionRate", current.fullSessions > 0 ? qRound(current.completedSessions * 100.0 / current.fullSessions) : 0}, {"totalSteps", current.totalSteps}, {"completedSteps", current.completedSteps}, {"weekStart", weekStart.toString("dd.MM")}, {"weekEnd", today.toString("dd.MM")}, {"previousWeekStart", previousWeekStart.toString("dd.MM")}, {"previousWeekEnd", previousWeekEnd.toString("dd.MM")}, {"previousSessions", previous.sessions}, {"previousFullSessions", previous.fullSessions}, {"previousQuickSessions", previous.quickSessions}, {"previousWorkMinutes", previousMinutes}, {"previousCompletionRate", previous.fullSessions > 0 ? qRound(previous.completedSessions * 100.0 / previous.fullSessions) : 0}, {"comparisonAvailable", previous.sessions > 0}, {"sessionsDelta", sessionsDelta}, {"workMinutesDelta", minutesDelta}, {"sessionsDeltaText", signedText(sessionsDelta)}, {"workMinutesDeltaText", signedText(minutesDelta)}, {"workChangePercent", workChangePercent}, {"heartRateLoadAvailable", current.heartRateLoad > 0 || previous.heartRateLoad > 0}, {"heartRateLoad", current.heartRateLoad}, {"previousHeartRateLoad", previous.heartRateLoad}, {"heartRateLoadSessions", current.heartRateSessions}, {"previousHeartRateLoadSessions", previous.heartRateSessions}, {"heartRateLoadDelta", heartRateLoadDelta}, {"heartRateLoadDeltaText", signedText(heartRateLoadDelta)}, {"heartRateLoadChangePercent", heartRateLoadChangePercent}, {"undertrainedAreasText", undertrainedAreasText}, {"undertrainedAreasActive", !undertrainedCategories.isEmpty()}, {"loadStatusId", loadStatusId}, {"loadStatusText", loadStatusText}, {"topExercises", topExercises}}; } QVariantMap SessionController::wearableSummary() const { QJsonArray days = objectsForProfile( root_.value("wearableDailyHistory").toArray(), selectedProfileId()); days = sortedObjectsByDateDesc(days, QStringLiteral("date")); if (days.isEmpty()) { return { {"available", false}, {"headline", QStringLiteral("Нет данных часов")}, {"detail", QStringLiteral("Экспортируйте JSON в Android-компаньоне и импортируйте его здесь.")}, {"recommendation", QStringLiteral("Нагрузка определяется по плану и ручной оценке recovery.")} }; } const QJsonObject latest = days.first().toObject(); QJsonObject latestSleepDay; for (const QJsonValue &value : days) { const QJsonObject candidate = value.toObject(); if (candidate.value("sleepMinutes").toInt() > 0) { latestSleepDay = candidate; break; } } const int sleepMinutes = latestSleepDay.isEmpty() ? -1 : latestSleepDay.value("sleepMinutes").toInt(); QString recommendation = QStringLiteral("Данных недостаточно для коррекции нагрузки."); if (sleepMinutes >= 0 && sleepMinutes < 360) { recommendation = QStringLiteral("Сон менее 6 часов: предпочтительна лёгкая тренировка или восстановление."); } else if (sleepMinutes < 420 && sleepMinutes >= 0) { recommendation = QStringLiteral("Сон 6–7 часов: тренируйтесь по самочувствию, без форсирования объёма."); } else if (sleepMinutes >= 420) { recommendation = QStringLiteral("Сон не указывает на необходимость снижать плановую нагрузку."); } QStringList metrics; if (latest.value("steps").toInt() > 0) metrics << QStringLiteral("%1 шагов").arg(latest.value("steps").toInt()); if (sleepMinutes >= 0) { const QString sleepDate = latestSleepDay.value("date").toString(); metrics << QStringLiteral("сон %1 ч %2 мин (%3)") .arg(sleepMinutes / 60) .arg(sleepMinutes % 60) .arg(dateText(sleepDate)); } if (latest.value("heartRateAvg").isDouble()) metrics << QStringLiteral("пульс в среднем %1").arg(qRound(latest.value("heartRateAvg").toDouble())); if (latest.value("oxygenSaturationAvg").isDouble()) metrics << QStringLiteral("SpO₂ %1%").arg(QString::number(latest.value("oxygenSaturationAvg").toDouble(), 'f', 1)); return { {"available", true}, {"headline", QStringLiteral("Redmi Watch 5 • %1").arg(dateText(latest.value("date").toString()))}, {"detail", metrics.isEmpty() ? QStringLiteral("Запись импортирована") : metrics.join(QStringLiteral(" • "))}, {"recommendation", recommendation}, {"lastImportText", dateTimeText(latest.value("importedAt").toString())}, {"dayCount", days.size()} }; } QVariantMap SessionController::wearableStatistics() const { QJsonArray days = sortedObjectsByDateDesc( objectsForProfile(root_.value("wearableDailyHistory").toArray(), selectedProfileId()), QStringLiteral("date")); if (days.isEmpty()) return {{"available", false}, {"dayCount", 0}}; int stepDays = 0; qint64 stepTotal = 0; int sleepDays = 0; qint64 sleepTotal = 0; double caloriesTotal = 0.0; QJsonObject latestSteps; QJsonObject latestCalories; QJsonObject latestSleep; QJsonObject latestHeartRate; QJsonObject latestRestingHeartRate; QJsonObject latestOxygen; QJsonObject latestWeight; for (const QJsonValue &value : days) { const QJsonObject day = value.toObject(); const int steps = day.value("steps").toInt(); const int sleep = day.value("sleepMinutes").toInt(); const double calories = day.value("activeCaloriesKcal").toDouble(); if (steps > 0) { stepTotal += steps; ++stepDays; if (latestSteps.isEmpty()) latestSteps = day; } if (sleep > 0) { sleepTotal += sleep; ++sleepDays; if (latestSleep.isEmpty()) latestSleep = day; } if (calories > 0.0) { caloriesTotal += calories; if (latestCalories.isEmpty()) latestCalories = day; } if (latestHeartRate.isEmpty() && day.value("heartRateAvg").isDouble()) latestHeartRate = day; if (latestRestingHeartRate.isEmpty() && day.value("restingHeartRateAvg").isDouble()) latestRestingHeartRate = day; if (latestOxygen.isEmpty() && day.value("oxygenSaturationAvg").isDouble()) latestOxygen = day; if (latestWeight.isEmpty() && day.value("weightKg").toDouble() >= 30.0) latestWeight = day; } const auto datedMetric = [](const QJsonObject &day, const QString &value, const QString &suffix) { if (day.isEmpty()) return QStringLiteral("—"); return QStringLiteral("%1%2 • %3") .arg(value, suffix, dateText(day.value("date").toString())); }; const int averageSleep = sleepDays > 0 ? qRound(static_cast<double>(sleepTotal) / sleepDays) : 0; double recentRestingTotal = 0.0; double previousRestingTotal = 0.0; int recentRestingDays = 0; int previousRestingDays = 0; const QDate restingAnchor = QDate::fromString(latestRestingHeartRate.value("date").toString(), Qt::ISODate); if (restingAnchor.isValid()) { for (const QJsonValue &value : days) { const QJsonObject day = value.toObject(); if (!day.value("restingHeartRateAvg").isDouble()) continue; const QDate date = QDate::fromString(day.value("date").toString(), Qt::ISODate); const int age = date.daysTo(restingAnchor); if (age >= 0 && age <= 6) { recentRestingTotal += day.value("restingHeartRateAvg").toDouble(); ++recentRestingDays; } else if (age >= 7 && age <= 13) { previousRestingTotal += day.value("restingHeartRateAvg").toDouble(); ++previousRestingDays; } } } const int recentRestingAverage = recentRestingDays > 0 ? qRound(recentRestingTotal / recentRestingDays) : 0; const int previousRestingAverage = previousRestingDays > 0 ? qRound(previousRestingTotal / previousRestingDays) : 0; const int restingDelta = recentRestingAverage - previousRestingAverage; QString restingTrendId = QStringLiteral("unavailable"); QString restingTrendText = QStringLiteral("Для тренда нужны данные за две недели"); if (recentRestingDays > 0 && previousRestingDays > 0) { if (restingDelta >= 2) { restingTrendId = QStringLiteral("higher"); restingTrendText = QStringLiteral("На %1 уд/мин выше предыдущих 7 дней").arg(restingDelta); } else if (restingDelta <= -2) { restingTrendId = QStringLiteral("lower"); restingTrendText = QStringLiteral("На %1 уд/мин ниже предыдущих 7 дней").arg(std::abs(restingDelta)); } else { restingTrendId = QStringLiteral("stable"); restingTrendText = QStringLiteral("Без заметного изменения за две недели"); } } const QString weightOrigin = latestWeight.value("weightOrigin").toString(); return { {"available", true}, {"dayCount", days.size()}, {"dateRangeText", QStringLiteral("%1 — %2") .arg(dateText(days.last().toObject().value("date").toString()), dateText(days.first().toObject().value("date").toString()))}, {"stepsText", datedMetric(latestSteps, QString::number(latestSteps.value("steps").toInt()), QStringLiteral(" шагов"))}, {"averageStepsText", stepDays > 0 ? QStringLiteral("%1 в активный день").arg(stepTotal / stepDays) : QStringLiteral("нет данных")}, {"caloriesText", datedMetric(latestCalories, formatDecimal(latestCalories.value("activeCaloriesKcal").toDouble()), QStringLiteral(" ккал"))}, {"totalCaloriesText", QStringLiteral("%1 ккал за период").arg(qRound(caloriesTotal))}, {"sleepText", latestSleep.isEmpty() ? QStringLiteral("—") : QStringLiteral("%1 ч %2 мин • %3").arg(latestSleep.value("sleepMinutes").toInt() / 60) .arg(latestSleep.value("sleepMinutes").toInt() % 60) .arg(dateText(latestSleep.value("date").toString()))}, {"averageSleepText", sleepDays > 0 ? QStringLiteral("в среднем %1 ч %2 мин").arg(averageSleep / 60).arg(averageSleep % 60) : QStringLiteral("нет данных")}, {"heartRateText", datedMetric(latestHeartRate, QString::number(qRound(latestHeartRate.value("heartRateAvg").toDouble())), QStringLiteral(" уд/мин"))}, {"restingHeartRateText", datedMetric(latestRestingHeartRate, QString::number(qRound(latestRestingHeartRate.value("restingHeartRateAvg").toDouble())), QStringLiteral(" уд/мин"))}, {"restingHeartRateAverageText", recentRestingDays > 0 ? QStringLiteral("7 дней: %1 уд/мин").arg(recentRestingAverage) : QStringLiteral("нет данных")}, {"restingHeartRateTrendId", restingTrendId}, {"restingHeartRateTrendText", restingTrendText}, {"restingHeartRateDelta", restingDelta}, {"oxygenText", datedMetric(latestOxygen, QString::number(latestOxygen.value("oxygenSaturationAvg").toDouble(), 'f', 1), QStringLiteral("%"))}, {"weightText", datedMetric(latestWeight, latestWeight.isEmpty() ? QString() : formatDecimal(latestWeight.value("weightKg").toDouble()), QStringLiteral(" кг"))}, {"weightSourceText", latestWeight.isEmpty() ? QStringLiteral("Нет веса в Health Connect") : weightOrigin.isEmpty() ? QStringLiteral("Health Connect") : QStringLiteral("Health Connect • %1").arg(weightOrigin)} }; } QVariantList SessionController::wearableDays() const { QVariantList result; const QJsonArray days = sortedObjectsByDateDesc( objectsForProfile(root_.value("wearableDailyHistory").toArray(), selectedProfileId()), QStringLiteral("date")); const int count = qMin(days.size(), 30); for (int index = count - 1; index >= 0; --index) { const QJsonObject day = days.at(index).toObject(); result.append(QVariantMap{ {"date", day.value("date").toString()}, {"dateText", QDate::fromString(day.value("date").toString(), Qt::ISODate).toString("dd.MM")}, {"steps", day.value("steps").toInt()}, {"activeCaloriesKcal", day.value("activeCaloriesKcal").toDouble()}, {"sleepMinutes", day.value("sleepMinutes").toInt()}, {"heartRateAvg", day.value("heartRateAvg").toDouble()}, {"restingHeartRateAvg", day.value("restingHeartRateAvg").toDouble()}, {"oxygenSaturationAvg", day.value("oxygenSaturationAvg").toDouble()}, {"weightKg", day.value("weightKg").toDouble()} }); } return result; } QString SessionController::healthSyncStatusText() const { return healthSyncStatusText_; } QString SessionController::healthSyncLastSuccessText() const { const QDateTime syncedAt = QDateTime::fromString( root_.value("healthSyncLastSuccessAt").toString(), Qt::ISODate); return syncedAt.isValid() ? QStringLiteral("Последняя синхронизация: %1").arg(syncedAt.toLocalTime().toString("dd.MM • HH:mm")) : QStringLiteral("Успешной синхронизации ещё не было"); } bool SessionController::healthSyncInProgress() const { return healthSyncInProgress_; } QString SessionController::healthActivitySource() const { return root_.value("healthActivitySource").toString(QStringLiteral("com.xiaomi.wearable")); } QString SessionController::healthWeightSource() const { return root_.value("healthWeightSource").toString(QStringLiteral("com.google.android.apps.fitness")); } QString SessionController::resolvedHealthConnectOwnerForRoot(const QJsonObject &candidateRoot) const { const QJsonArray profiles = profilesArrayOrDefault(candidateRoot); #ifdef Q_OS_ANDROID QString localOwner = canonicalProfileId(profiles, healthConnectOwnerProfileId_); if (localOwner.isEmpty()) { const auto context = QNativeInterface::QAndroidApplication::context(); if (context.isValid()) { const QJniObject storedOwner = QJniObject::callStaticObjectMethod( "org/bodyweightbase/android/HealthConnectExporter", "ownerProfileId", "(Landroid/content/Context;)Ljava/lang/String;", context.object<jobject>()); localOwner = canonicalProfileId(profiles, storedOwner.toString()); } } if (!localOwner.isEmpty()) return localOwner; #endif return healthConnectOwnerProfileIdOrDefault(candidateRoot); } void SessionController::applyAndroidDevicePreferences() { #ifdef Q_OS_ANDROID const auto context = QNativeInterface::QAndroidApplication::context(); if (!context.isValid()) return; const QJniObject owner = QJniObject::fromString(healthConnectOwnerProfileId()); QJniObject::callStaticMethod<void>( "org/bodyweightbase/android/HealthConnectExporter", "setOwnerProfileId", "(Landroid/content/Context;Ljava/lang/String;)V", context.object<jobject>(), owner.object<jstring>()); const QJniObject activitySource = QJniObject::fromString(healthActivitySource()); const QJniObject weightSource = QJniObject::fromString(healthWeightSource()); QJniObject::callStaticMethod<void>( "org/bodyweightbase/android/HealthConnectExporter", "setSourcePreferences", "(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)V", context.object<jobject>(), activitySource.object<jstring>(), weightSource.object<jstring>()); const QTime reminderTime = QTime::fromString(workoutReminderTime(), QStringLiteral("HH:mm")); const QJniObject reminderProfile = QJniObject::fromString(selectedProfileId()); QJniObject::callStaticMethod<void>( "org/bodyweightbase/android/WorkoutReminderWorker", "setPreferences", "(Landroid/content/Context;Ljava/lang/String;ZII)V", context.object<jobject>(), reminderProfile.object<jstring>(), static_cast<jboolean>(workoutReminderEnabled()), static_cast<jint>(reminderTime.hour()), static_cast<jint>(reminderTime.minute())); #endif } QString SessionController::healthConnectOwnerProfileId() const { return healthConnectOwnerProfileId_.isEmpty() ? healthConnectOwnerProfileIdOrDefault(root_) : healthConnectOwnerProfileId_; } QString SessionController::healthConnectOwnerProfileName() const { const QString ownerId = healthConnectOwnerProfileId(); for (const QJsonValue &value : profilesArrayOrDefault(root_)) { if (!value.isObject()) continue; const QJsonObject profile = value.toObject(); if (profile.value("id").toString().compare(ownerId, Qt::CaseInsensitive) == 0) { return profile.value("name").toString(ownerId); } } return ownerId; } bool SessionController::workoutReminderEnabled() const { const QJsonObject settings = profileSettingsFor(root_, selectedProfileId()); return settings.value("workoutReminderEnabled").toBool(root_.value("workoutReminderEnabled").toBool(true)); } QString SessionController::workoutReminderTime() const { const QJsonObject settings = profileSettingsFor(root_, selectedProfileId()); const QString value = settings.value("workoutReminderTime").toString( root_.value("workoutReminderTime").toString(QStringLiteral("18:00"))); return QTime::fromString(value, QStringLiteral("HH:mm")).isValid() ? value : QStringLiteral("18:00"); } QVariantMap SessionController::trainingContinuity() const { return trainingContinuityForDate(QDate::currentDate()); } QVariantMap SessionController::trainingContinuityForDate(const QDate &today) const { if (!today.isValid()) return {{"active", false}}; const QDate yesterday = today.addDays(-1); QSet<QDate> sessionDates; QDate latestSessionDate; for (const QJsonValue &value : sessionsArray()) { if (!value.isObject()) continue; const QDate date = QDateTime::fromString( value.toObject().value("endedAt").toString(), Qt::ISODate).date(); if (!date.isValid() || date > today) continue; sessionDates.insert(date); if (!latestSessionDate.isValid() || date > latestSessionDate) latestSessionDate = date; } const QJsonObject cycle = trainingCycleForProfile(root_, selectedProfileId()); const QDate cycleStart = QDate::fromString(cycle.value("startDate").toString(), Qt::ISODate); const int durationWeeks = std::clamp(cycle.value("durationWeeks").toInt(26), 4, 26); const bool hasCycle = cycleStart.isValid(); const QDate originalEnd = hasCycle ? cycleStart.addDays(durationWeeks * 7 - 1) : QDate{}; const QDate analysisStart = hasCycle ? cycleStart : today.addDays(-27); const QDate missedRangeEnd = hasCycle && originalEnd < yesterday ? originalEnd : yesterday; QVector<QDate> missedDates; int scheduledCount = 0; if (analysisStart <= missedRangeEnd) { for (QDate date = analysisStart; date <= missedRangeEnd; date = date.addDays(1)) { if (!scheduledPlanForDate(date)) continue; ++scheduledCount; if (!sessionDates.contains(date)) missedDates.append(date); } } int recentMissedCount = 0; const QDate recentStart = latestSessionDate.isValid() && latestSessionDate >= analysisStart ? latestSessionDate.addDays(1) : analysisStart; if (recentStart <= yesterday) { for (QDate date = recentStart; date <= yesterday; date = date.addDays(1)) { if (scheduledPlanForDate(date) && !sessionDates.contains(date)) ++recentMissedCount; } } QDate adjustedEnd = originalEnd; if (hasCycle && !missedDates.isEmpty()) { int addedSlots = 0; QDate cursor = originalEnd.addDays(1); for (int checkedDays = 0; checkedDays < 1095 && addedSlots < missedDates.size(); ++checkedDays) { if (scheduledPlanForDate(cursor)) { ++addedSlots; adjustedEnd = cursor; } cursor = cursor.addDays(1); } } const int storedRampSessions = std::clamp( profileSettingsFor(root_, selectedProfileId()).value("returnRampSessionsRemaining").toInt(), 0, 2); int targetPercent = 100; int extraRestSeconds = 0; bool reduceSets = false; int recommendedMaxRounds = 6; QString title = QStringLiteral("По графику"); QString detail = QStringLiteral("Пропусков после последней тренировки нет."); if (recentMissedCount >= 3) { targetPercent = 70; extraRestSeconds = 15; reduceSets = true; recommendedMaxRounds = 1; title = QStringLiteral("Раскачка после паузы"); detail = QStringLiteral("Пропущено %1 тренировок подряд: цели −30%, один рабочий круг, подходов меньше и отдых длиннее.") .arg(recentMissedCount); } else if (recentMissedCount == 2) { targetPercent = 80; extraRestSeconds = 10; reduceSets = true; recommendedMaxRounds = 2; title = QStringLiteral("Мягкое возвращение"); detail = QStringLiteral("Пропущены две тренировки: цели −20%, подходов меньше и не больше двух кругов."); } else if (recentMissedCount == 1) { targetPercent = 90; extraRestSeconds = 5; title = QStringLiteral("Возвращение в ритм"); detail = QStringLiteral("Пропущена одна тренировка: цели −10%, отдых немного длиннее."); } else if (storedRampSessions >= 2) { targetPercent = 85; extraRestSeconds = 10; reduceSets = true; recommendedMaxRounds = 1; title = QStringLiteral("Раскачка продолжается"); detail = QStringLiteral("Вторая тренировка возврата: цели −15%, один спокойный круг."); } else if (storedRampSessions == 1) { targetPercent = 95; extraRestSeconds = 5; recommendedMaxRounds = 2; title = QStringLiteral("Закрепление ритма"); detail = QStringLiteral("Финальная облегчённая тренировка возврата: цели −5%, затем обычный план."); } QStringList recentMissedDates; const int firstShown = std::max(0, static_cast<int>(missedDates.size()) - 3); for (int index = firstShown; index < missedDates.size(); ++index) { recentMissedDates << missedDates.at(index).toString("dd.MM"); } return { {"active", scheduledCount > 0 || hasCycle}, {"missedCount", static_cast<int>(missedDates.size())}, {"recentMissedCount", recentMissedCount}, {"missedDatesText", recentMissedDates.join(QStringLiteral(", "))}, {"scheduledCount", scheduledCount}, {"rampSessionsRemaining", storedRampSessions}, {"returnMode", targetPercent < 100}, {"targetPercent", targetPercent}, {"extraRestSeconds", extraRestSeconds}, {"reduceSets", reduceSets}, {"recommendedMaxRounds", recommendedMaxRounds}, {"title", title}, {"detail", detail}, {"originalEndDate", originalEnd.isValid() ? originalEnd.toString("dd.MM.yyyy") : QString()}, {"adjustedEndDate", adjustedEnd.isValid() ? adjustedEnd.toString("dd.MM.yyyy") : QString()}, {"extensionDays", originalEnd.isValid() && adjustedEnd.isValid() ? originalEnd.daysTo(adjustedEnd) : 0} }; } QVariantMap SessionController::trainingCycle() const { const QJsonObject cycle = trainingCycleForProfile(root_, selectedProfileId()); if (cycle.isEmpty()) return {{"active", false}}; const QDate start = QDate::fromString(cycle.value("startDate").toString(), Qt::ISODate); const int durationWeeks = std::clamp(cycle.value("durationWeeks").toInt(26), 4, 26); if (!start.isValid()) return {{"active", false}}; const QDate today = QDate::currentDate(); const QDate originalEnd = start.addDays(durationWeeks * 7 - 1); const QVariantMap continuity = trainingContinuityForDate(QDate::currentDate()); const QDate extendedEnd = QDate::fromString( continuity.value("adjustedEndDate").toString(), QStringLiteral("dd.MM.yyyy")); const QDate end = extendedEnd.isValid() ? extendedEnd : originalEnd; const bool upcoming = today < start; const bool completed = today > end; const int rawWeek = upcoming ? 1 : start.daysTo(today) / 7 + 1; const int currentWeek = std::clamp(rawWeek, 1, durationWeeks); const CycleWeekSpec spec = cycleWeekSpec(currentWeek); return { {"active", true}, {"startDate", start.toString("dd.MM.yyyy")}, {"endDate", end.toString("dd.MM.yyyy")}, {"durationWeeks", durationWeeks}, {"originalEndDate", originalEnd.toString("dd.MM.yyyy")}, {"missedCount", continuity.value("missedCount")}, {"extensionDays", continuity.value("extensionDays")}, {"extended", continuity.value("extensionDays").toInt() > 0}, {"currentWeek", currentWeek}, {"phase", spec.phase}, {"focus", spec.focus}, {"adjustmentText", cycleAdjustmentText(spec)}, {"progress", completed ? 1.0 : upcoming ? 0.0 : std::clamp( static_cast<double>(start.daysTo(today)) / static_cast<double>(std::max<qint64>(1, start.daysTo(end))), 0.0, 1.0)}, {"upcoming", upcoming}, {"completed", completed} }; } QVariantMap SessionController::cycleReview() const { const QJsonObject cycle = trainingCycleForProfile(root_, selectedProfileId()); if (cycle.isEmpty()) return {{"active", false}, {"due", false}}; const QDate start = QDate::fromString(cycle.value("startDate").toString(), Qt::ISODate); if (!start.isValid()) return {{"active", false}, {"due", false}}; const QDate today = QDate::currentDate(); const int durationWeeks = std::clamp(cycle.value("durationWeeks").toInt(26), 4, 26); const int rawWeek = today < start ? 0 : start.daysTo(today) / 7 + 1; const int currentWeek = std::clamp(rawWeek, 0, durationWeeks); const int reviewWeek = currentWeek >= 4 ? (currentWeek / 4) * 4 : 0; const QString cycleId = cycle.value("id").toString(); QJsonObject completedReview; for (const QJsonValue &value : objectsForProfile( root_.value("trainingCycleReviews").toArray(), selectedProfileId())) { const QJsonObject review = value.toObject(); if (review.value("cycleId").toString() == cycleId && review.value("reviewWeek").toInt() == reviewWeek) { completedReview = review; break; } } const QDate periodStart = today.addDays(-27); int sessionCount = 0; int quickSessionCount = 0; int completedCount = 0; int workSeconds = 0; for (const QJsonValue &value : sessionsArray()) { const QJsonObject session = value.toObject(); const QDate date = QDateTime::fromString(session.value("endedAt").toString(), Qt::ISODate).date(); if (!date.isValid() || date < periodStart || date > today) continue; if (isQuickSession(session)) { ++quickSessionCount; workSeconds += std::max(0, session.value("totalWorkSeconds").toInt()); continue; } ++sessionCount; workSeconds += std::max(0, session.value("totalWorkSeconds").toInt()); if (session.value("completedAll").toBool()) ++completedCount; } const int completionRate = sessionCount > 0 ? qRound(completedCount * 100.0 / sessionCount) : 0; const QString recommendation = sessionCount < 8 ? QStringLiteral("Сначала закрепите регулярность; повышать нагрузку необязательно.") : completionRate < 70 ? QStringLiteral("Много частичных тренировок: разумнее оставить или облегчить нагрузку.") : QStringLiteral("Регулярность достаточная: можно оставить объём или мягко прогрессировать."); const int nextReviewWeek = reviewWeek == 0 ? 4 : reviewWeek + 4 <= durationWeeks ? reviewWeek + 4 : 0; return { {"active", true}, {"due", reviewWeek >= 4 && completedReview.isEmpty()}, {"reviewWeek", reviewWeek}, {"nextReviewWeek", nextReviewWeek}, {"sessionCount", sessionCount}, {"quickSessionCount", quickSessionCount}, {"workMinutes", workSeconds <= 0 ? 0 : std::max(1, workSeconds / 60)}, {"completionRate", completionRate}, {"recommendation", recommendation}, {"completed", !completedReview.isEmpty()}, {"lastDecision", completedReview.value("decision").toString()}, {"lastDecisionText", completedReview.value("decisionText").toString()}, {"completedAt", dateTimeText(completedReview.value("loggedAt").toString())} }; } QVariantList SessionController::trainingCycleWeeks() const { QVariantList result; const QJsonObject cycle = trainingCycleForProfile(root_, selectedProfileId()); if (cycle.isEmpty()) return result; const QDate start = QDate::fromString(cycle.value("startDate").toString(), Qt::ISODate); const int durationWeeks = std::clamp(cycle.value("durationWeeks").toInt(26), 4, 26); if (!start.isValid()) return result; const QDate today = QDate::currentDate(); const QJsonArray sessions = sessionsArray(); for (int week = 1; week <= durationWeeks; ++week) { const QDate weekStart = start.addDays((week - 1) * 7); const QDate weekEnd = weekStart.addDays(6); int completedSessions = 0; int quickSessions = 0; for (const QJsonValue &value : sessions) { if (!value.isObject()) continue; const QJsonObject session = value.toObject(); const QDate date = QDateTime::fromString(session.value("endedAt").toString(), Qt::ISODate).date(); if (!date.isValid() || date < weekStart || date > weekEnd) continue; if (isQuickSession(session)) ++quickSessions; else ++completedSessions; } const CycleWeekSpec spec = cycleWeekSpec(week); const bool current = today >= weekStart && today <= weekEnd; const QString status = weekEnd < today ? QStringLiteral("Пройдено") : current ? QStringLiteral("Текущая") : QStringLiteral("Впереди"); result.append(QVariantMap{ {"week", week}, {"dateRange", QStringLiteral("%1–%2").arg(weekStart.toString("dd.MM"), weekEnd.toString("dd.MM"))}, {"phase", spec.phase}, {"focus", spec.focus}, {"targetPercent", spec.targetPercent}, {"setDelta", spec.setDelta}, {"restDeltaSeconds", spec.restDeltaSeconds}, {"adjustmentText", cycleAdjustmentText(spec)}, {"status", status}, {"current", current}, {"completed", weekEnd < today}, {"completedSessions", completedSessions}, {"quickSessions", quickSessions} }); } return result; } 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 = displayPlan(); 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) { if (!soundsEnabled_) return; #ifdef Q_OS_WIN Beep(frequencyHz, durationMs); #elif defined(Q_OS_ANDROID) const auto context = QNativeInterface::QAndroidApplication::context(); if (!context.isValid()) return; QJniObject::callStaticMethod<void>( "org/bodyweightbase/android/WorkoutFeedback", "play", "(Landroid/content/Context;II)V", context.object<jobject>(), static_cast<jint>(frequencyHz), static_cast<jint>(durationMs)); #else Q_UNUSED(frequencyHz) Q_UNUSED(durationMs) QGuiApplication::beep(); #endif } void SessionController::playPhaseFeedback(SessionPhase before, SessionPhase after) { #ifndef Q_OS_ANDROID Q_UNUSED(before) Q_UNUSED(after) return; #else if (before == after || after == SessionPhase::paused || after == SessionPhase::idle) return; if (after == SessionPhase::completed) playBeep(1200, 260); else if (after == SessionPhase::quickDecision) playBeep(1040, 220); else if (after == SessionPhase::rest) playBeep(660, 160); else if (after == SessionPhase::preparation) playBeep(820, 110); else if (after == SessionPhase::repetitionExercise || after == SessionPhase::timedExercise) playBeep(940, 120); #endif } void SessionController::setKeepScreenOn(bool enabled) { #ifdef Q_OS_ANDROID const auto context = QNativeInterface::QAndroidApplication::context(); if (!context.isValid()) return; QJniObject::callStaticMethod<void>( "org/bodyweightbase/android/WorkoutFeedback", "setKeepScreenOn", "(Landroid/content/Context;Z)V", context.object<jobject>(), static_cast<jboolean>(enabled)); #else Q_UNUSED(enabled) #endif } bool SessionController::soundsEnabled() const { return soundsEnabled_; } void SessionController::setSoundsEnabled(bool enabled) { if (soundsEnabled_ == enabled) return; QJsonObject updated = root_; updated.insert("soundsEnabled", enabled); QString error; if (store_ && !store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось сохранить настройку звука: %1").arg(error); notify(); return; } root_ = updated; soundsEnabled_ = enabled; status_ = enabled ? QStringLiteral("Звуки тренировки включены") : QStringLiteral("Звуки тренировки выключены"); 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(); const QString profileId = savedPlan.value("profileId").toString(); if (profileId.compare(selectedProfileId(), Qt::CaseInsensitive) != 0) { status_ = QStringLiteral("Отмена относится к другому профилю"); notify(); return; } QJsonArray allPlans = root_.value("plans").toArray(); for (int i = 0; i < allPlans.size(); ++i) { if (!allPlans.at(i).isObject()) continue; if (planObjectMatchesProfile(allPlans.at(i).toObject(), planId, profileId)) { 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 << "Дата,План,Упражнение,Метрика,Цель,Факт,Выполнено,Отдых,Вес нагрузки кг,RPE,Дискомфорт,Заметка\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() << "," << formatDecimal(step.value("externalLoadKg").toDouble()) << "," << step.value("effortRating").toInt() << "," << step.value("discomfortRating").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() { const WorkoutPlan *plan = selectedPlan(); startWithRoundCount(plan ? plan->roundCount : 1); } void SessionController::startWithRoundCount(int roundCount) { if (!ready()) return; WorkoutPlan adjustedPlan = cycleAdjustedPlan(*selectedPlan()); const int requestedRounds = std::clamp( roundCount <= 0 ? adjustedPlan.roundCount : roundCount, 1, 6); const int returnRounds = trainingContinuityForDate(QDate::currentDate()) .value("recommendedMaxRounds", 6).toInt(); adjustedPlan.roundCount = std::min(requestedRounds, std::clamp(returnRounds, 1, 6)); startAdjustedPlan(std::move(adjustedPlan)); } void SessionController::startQuickWorkout(int roundCount) { if (!ready()) return; WorkoutPlan quickPlan = cycleAdjustedPlan(*selectedPlan()); QVector<WorkoutStep> candidates; for (const WorkoutStep &step : quickPlan.steps) { if (!step.isWarmup && !step.isCooldown) candidates.append(step); } if (candidates.isEmpty()) { status_ = QStringLiteral("В выбранном плане нет рабочих упражнений для пятиминутки"); notify(); return; } QVector<int> indexes; if (candidates.size() <= 3) { for (int index = 0; index < candidates.size(); ++index) indexes.append(index); } else { const int candidateCount = static_cast<int>(candidates.size()); indexes = {0, candidateCount / 2, candidateCount - 1}; } const int requestedRounds = std::clamp( roundCount <= 0 ? quickPlan.roundCount : roundCount, 1, 6); const int returnRounds = trainingContinuityForDate(QDate::currentDate()) .value("recommendedMaxRounds", 6).toInt(); const int fullRoundCount = std::min(requestedRounds, std::clamp(returnRounds, 1, 6)); QVector<WorkoutStep> quickSteps; QVector<int> quickTargets(candidates.size(), 0); for (const int index : indexes) { WorkoutStep step = candidates.at(index); const ExerciseDefinition *exercise = findExercise(exercises_, step.exerciseId); const int baseTarget = step.targetOverride.value_or(exercise ? exercise->defaultTarget : 1); const int scaledTarget = std::max(1, qRound(baseTarget * 0.6)); step.targetOverride = exercise && exercise->metric == ExerciseMetric::seconds ? std::min(45, scaledTarget) : std::min(10, scaledTarget); const int baseRest = step.restSecondsOverride.value_or( exercise ? exercise->defaultRestSeconds : 0); step.restSecondsOverride = std::min(10, std::max(0, baseRest)); step.sets = 1; step.isWarmup = false; step.isCooldown = false; quickTargets[index] = *step.targetOverride; quickSteps.append(step); } if (!quickSteps.isEmpty()) quickSteps.last().restSecondsOverride = 0; QVector<WorkoutStep> continuationSteps; for (int index = 0; index < candidates.size(); ++index) { const WorkoutStep original = candidates.at(index); if (quickTargets.at(index) <= 0) { continuationSteps.append(original); continue; } const ExerciseDefinition *exercise = findExercise(exercises_, original.exerciseId); const int fullTarget = original.targetOverride.value_or( exercise ? exercise->defaultTarget : quickTargets.at(index)); const int remainingTarget = std::max(0, fullTarget - quickTargets.at(index)); if (remainingTarget > 0) { WorkoutStep remainder = original; remainder.targetOverride = remainingTarget; remainder.sets = 1; continuationSteps.append(remainder); } if (original.sets > 1) { WorkoutStep remainingSets = original; remainingSets.sets = original.sets - 1; continuationSteps.append(remainingSets); } } const int firstRoundStepCount = quickSteps.size() + continuationSteps.size(); for (int completedRound = 1; completedRound < fullRoundCount; ++completedRound) { for (const WorkoutStep &step : candidates) continuationSteps.append(step); } quickPlan.variantId = QStringLiteral("quick-start"); quickPlan.roundCount = fullRoundCount; quickPlan.warmupIncluded = true; quickPlan.warmupStepCount = 2; quickPlan.cooldownIncluded = true; quickPlan.cooldownStepCount = std::max(1, quickPlan.cooldownStepCount); quickPlan.roundStepCount = firstRoundStepCount; quickPlan.recommendedRoundsText = QStringLiteral("Пятиминутка с возможностью продолжить полную тренировку"); quickPlan.steps = quickSteps; quickPlan.steps += continuationSteps; const SessionPhase phaseBeforeStart = snapshot().phase; QString error; if (!runner_->startWithQuickCheckpoint(quickPlan, quickSteps.size(), &error)) { status_ = error; } else { sessionStartedAtLocal_ = QDateTime::currentDateTime(); clearCurrentSetLog(); status_ = QStringLiteral("Пятиминутка началась"); persistDraft(); setKeepScreenOn(true); playPhaseFeedback(phaseBeforeStart, snapshot().phase); } notify(); } void SessionController::continueQuickWorkout() { if (!runner_ || !quickDecisionPending()) return; if (!runner_->continueAfterQuickCheckpoint()) return; status_ = QStringLiteral("Отлично. Продолжаем полную тренировку"); persistDraft(); setKeepScreenOn(true); playPhaseFeedback(SessionPhase::quickDecision, snapshot().phase); notify(); } void SessionController::finishQuickWorkout() { if (!runner_ || !quickDecisionPending()) return; if (!runner_->finishQuickAtCheckpoint()) return; status_ = QStringLiteral("Короткий старт засчитан • осталось сделать заминку"); persistDraft(); playPhaseFeedback(SessionPhase::quickDecision, snapshot().phase); captureFinishedSession(); notify(); } void SessionController::startAdjustedPlan(WorkoutPlan plan) { const SessionPhase phaseBeforeStart = snapshot().phase; QString error; if (!runner_->start(plan, &error)) { status_ = error; } else { sessionStartedAtLocal_ = QDateTime::currentDateTime(); currentLoadNote_.clear(); currentExternalLoadKg_ = 0.0; currentEffortRating_ = 0; currentDiscomfortRating_ = 0; status_ = plan.variantId == QStringLiteral("quick-start") ? QStringLiteral("Пятиминутка началась") : plan.roundCount > 1 ? QStringLiteral("Тренировка идёт: %1 кругов").arg(plan.roundCount) : QStringLiteral("Тренировка идёт"); persistDraft(); setKeepScreenOn(true); playPhaseFeedback(phaseBeforeStart, snapshot().phase); } notify(); } void SessionController::advanceOneSecond() { if (!runner_) return; const SessionSnapshot before = snapshot(); const SessionPhase phaseBeforeAdvance = before.phase; runner_->advance(1); const SessionPhase phaseAfterAdvance = snapshot().phase; clearCurrentSetLogAfterTransition(before); playPhaseFeedback(phaseBeforeAdvance, phaseAfterAdvance); captureFinishedSession(); if (active() && ++secondsSinceDraftSave_ >= 5) persistDraft(); notify(); } void SessionController::skipCountdown() { if (!runner_) return; const SessionPhase phaseBefore = snapshot().phase; runner_->skipCountdown(); playPhaseFeedback(phaseBefore, snapshot().phase); captureFinishedSession(); if (active()) persistDraft(); notify(); } void SessionController::completeCurrent() { if (!runner_) return; const SessionSnapshot before = snapshot(); const SessionPhase phase = before.phase; if (phase == SessionPhase::repetitionExercise) runner_->completeRepExercise(); else if (phase == SessionPhase::preparation || phase == SessionPhase::rest) runner_->skipCountdown(); clearCurrentSetLogAfterTransition(before); playPhaseFeedback(phase, snapshot().phase); captureFinishedSession(); if (active()) persistDraft(); notify(); } void SessionController::skipCurrentExercise() { if (!runner_) return; const SessionSnapshot before = snapshot(); const SessionPhase phaseBefore = before.phase; runner_->skipCurrentExercise(); clearCurrentSetLogAfterTransition(before); playPhaseFeedback(phaseBefore, snapshot().phase); captureFinishedSession(); if (active()) persistDraft(); notify(); } void SessionController::finishActiveWorkout() { if (!runner_ || !active()) return; const SessionPhase phaseBefore = snapshot().phase; runner_->finishEarly(); playPhaseFeedback(phaseBefore, snapshot().phase); captureFinishedSession(); notify(); } void SessionController::replaceCurrentExercise(const QString &exerciseId) { if (!runner_ || !active()) return; const SessionSnapshot before = snapshot(); const bool discardedProgress = before.currentValue > 0 || before.phaseElapsedSeconds > 0; QString error; if (!runner_->replaceCurrentExercise(exerciseId, &error)) { status_ = QStringLiteral("Не удалось заменить упражнение: %1").arg(error); notify(); return; } clearCurrentSetLog(); const QString replacementName = exerciseName(); status_ = discardedProgress ? QStringLiteral("Выбрано: %1. Незавершённый подход сброшен.").arg(replacementName) : QStringLiteral("Упражнение заменено: %1").arg(replacementName); 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; if (paused()) { runner_->resume(); workoutPausedForBackground_ = false; status_ = QStringLiteral("Тренировка продолжается"); setKeepScreenOn(true); } else { runner_->pause(); status_ = QStringLiteral("Тренировка на паузе"); setKeepScreenOn(false); } persistDraft(); notify(); } void SessionController::suspendWorkoutClock() { if (workoutLifecycleSuspended_) return; workoutLifecycleSuspended_ = true; workoutPausedForBackground_ = active() && !paused() && !quickDecisionPending(); if (workoutPausedForBackground_) { runner_->pause(); status_ = QStringLiteral("Тренировка приостановлена после сворачивания"); } if (active()) persistDraft(); setKeepScreenOn(false); notify(); } void SessionController::resumeWorkoutClock() { if (!workoutLifecycleSuspended_) return; workoutLifecycleSuspended_ = false; if (active() && workoutPausedForBackground_) { status_ = QStringLiteral("Таймер сохранён на паузе • нажмите «Продолжить»"); } else if (active() && !paused()) { setKeepScreenOn(true); } workoutPausedForBackground_ = false; notify(); } void SessionController::prepareForExit() { if (active()) persistDraft(); if (needsSave()) persistPendingResult(); setKeepScreenOn(false); } void SessionController::saveFinishedSession(const QString &feedbackLabel, bool applyAdaptation) { if (!pendingFinishedSession_ || !store_) return; const QString resultProfileId = pendingFinishedSessionProfileId_.isEmpty() ? selectedProfileId() : pendingFinishedSessionProfileId_; const bool quickSession = pendingFinishedSession_->sessionMode == QStringLiteral("quick"); const QVariantMap continuityBeforeSave = trainingContinuityForDate( sessionEndedAtLocal_.isValid() ? sessionEndedAtLocal_.date() : QDate::currentDate()); 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", resultProfileId); sessions.replace(sessions.size() - 1, savedSession); updated.insert("sessions", sessions); } QJsonObject returnSettings = profileSettingsFor(updated, resultProfileId); const int existingRamp = std::clamp( returnSettings.value("returnRampSessionsRemaining").toInt(), 0, 2); const int recentMisses = continuityBeforeSave.value("recentMissedCount").toInt(); const int rampAfterCurrentSession = quickSession ? existingRamp : existingRamp > 0 ? existingRamp - 1 : 0; const int rampFromNewGap = recentMisses >= 3 ? 2 : recentMisses == 2 ? 1 : 0; const int nextRamp = std::max(rampAfterCurrentSession, rampFromNewGap); returnSettings.insert("returnRampSessionsRemaining", nextRamp); returnSettings.insert("returnRampUpdatedAt", QDateTime::currentDateTime().toString(Qt::ISODateWithMs)); updated = withProfileSettings(updated, resultProfileId, returnSettings); const bool shouldAdapt = applyAdaptation && !quickSession; const bool adapted = shouldAdapt && applyAdaptationToPlan( updated, pendingFinishedSession_->planId, resultProfileId, exercises_, feedbackLabel, pendingFinishedSession_->completedAll); if (!shouldAdapt) { 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; } #ifdef Q_OS_ANDROID const auto context = QNativeInterface::QAndroidApplication::context(); if (context.isValid() && resultProfileId.compare(healthConnectOwnerProfileId(), Qt::CaseInsensitive) == 0) { const QJniObject profile = QJniObject::fromString(resultProfileId); const QJniObject title = QJniObject::fromString(pendingFinishedSession_->planName); const QJniObject notes = QJniObject::fromString( QStringLiteral("BodyweightBase • %1 упражнений").arg(pendingFinishedSession_->steps.size())); QJniObject::callStaticMethod<jboolean>( "org/bodyweightbase/android/HealthConnectExporter", "writeWorkoutIfGranted", "(Landroid/content/Context;Ljava/lang/String;JJLjava/lang/String;Ljava/lang/String;)Z", context.object<jobject>(), profile.object<jstring>(), static_cast<jlong>(sessionStartedAtLocal_.toMSecsSinceEpoch()), static_cast<jlong>(sessionEndedAtLocal_.toMSecsSinceEpoch()), title.object<jstring>(), notes.object<jstring>()); } #endif pendingFinishedSession_.reset(); pendingFinishedSessionProfileId_.clear(); status_ = quickSession ? QStringLiteral("Пятиминутка сохранена. Базовый план не изменён.") : adapted ? QStringLiteral("Тренировка сохранена. План адаптирован на следующий раз.") : QStringLiteral("Тренировка сохранена. Создана резервная копия."); notify(); } QVariantMap SessionController::pendingResultDetails() const { if (!pendingFinishedSession_) return {}; const bool quickSession = pendingFinishedSession_->sessionMode == QStringLiteral("quick"); const int workMinutes = pendingFinishedSession_->totalWorkSeconds <= 0 ? 0 : std::max(1, pendingFinishedSession_->totalWorkSeconds / 60); const int restMinutes = pendingFinishedSession_->totalRestSeconds <= 0 ? 0 : std::max(1, pendingFinishedSession_->totalRestSeconds / 60); int completedSteps = 0; int repetitionVolume = 0; int timedVolumeSeconds = 0; QVariantList exerciseList; for (const CompletedExercise &step : pendingFinishedSession_->steps) { if (step.completed) ++completedSteps; if (step.metric == ExerciseMetric::repetitions) { repetitionVolume += std::max(0, step.actualValue); } else { timedVolumeSeconds += std::max(0, step.actualValue); } exerciseList.append(QVariantMap{ {"name", step.exerciseName}, {"actual", step.actualValue}, {"target", step.targetValue}, {"completed", step.completed}, {"metric", step.metric == ExerciseMetric::repetitions ? QStringLiteral("повт") : QStringLiteral("сек")}, {"externalLoadKg", step.externalLoadKg}, {"effortRating", step.effortRating}, {"discomfortRating", step.discomfortRating}, {"loadNote", step.loadNote} }); } const int recordedStepCount = static_cast<int>(pendingFinishedSession_->steps.size()); const int stepCount = std::max(recordedStepCount, pendingFinishedSession_->expectedStepCount); const int skippedSteps = std::max(0, stepCount - completedSteps); const int completionRate = stepCount > 0 ? qRound(completedSteps * 100.0 / stepCount) : 0; const bool reportCompletedAll = pendingFinishedSession_->completedAll && stepCount > 0 && completedSteps == stepCount && completionRate == 100; QStringList volumeParts; if (repetitionVolume > 0) volumeParts << QStringLiteral("%1 повт").arg(repetitionVolume); if (timedVolumeSeconds > 0) volumeParts << QStringLiteral("%1 сек").arg(timedVolumeSeconds); const QString volumeText = volumeParts.isEmpty() ? QStringLiteral("объём не записан") : volumeParts.join(QStringLiteral(" • ")); const QString resultStatus = reportCompletedAll ? QStringLiteral("complete") : QStringLiteral("partial"); const QString resultTitle = quickSession ? reportCompletedAll ? QStringLiteral("Пятиминутка закрыта") : QStringLiteral("Пятиминутка прервана") : reportCompletedAll ? QStringLiteral("Тренировка закрыта") : QStringLiteral("Тренировка частично закрыта"); const QString recommendationTitle = quickSession ? QStringLiteral("Ритм важнее героизма") : reportCompletedAll ? QStringLiteral("Следующий шаг") : QStringLiteral("Осторожнее с нагрузкой"); const QString recommendationText = quickSession ? reportCompletedAll ? QStringLiteral("Короткий вход выполнен. Он сохранится отдельно и не заменит полную тренировку в статистике.") : QStringLiteral("Даже короткий старт можно завершить честно. Базовый план и его нагрузка останутся без изменений.") : reportCompletedAll ? QStringLiteral("Если самочувствие нормальное, сохраняй результат и оставляй план без ручного форсирования.") : QStringLiteral("Есть незакрытые шаги: сохрани честный результат и не повышай нагрузку до стабильного прохождения."); return QVariantMap{ {"planName", pendingFinishedSession_->planName}, {"sessionMode", pendingFinishedSession_->sessionMode}, {"quickSession", quickSession}, {"resultTitle", resultTitle}, {"resultStatus", resultStatus}, {"workMinutes", workMinutes}, {"restMinutes", restMinutes}, {"stepCount", stepCount}, {"completedStepCount", completedSteps}, {"skippedStepCount", skippedSteps}, {"completionRate", completionRate}, {"completedAll", reportCompletedAll}, {"totalMinutes", workMinutes + restMinutes}, {"workRestText", QStringLiteral("%1 / %2 мин").arg(workMinutes).arg(restMinutes)}, {"completedText", QStringLiteral("%1/%2 шагов").arg(completedSteps).arg(stepCount)}, {"volumeText", volumeText}, {"recommendationTitle", recommendationTitle}, {"recommendationText", recommendationText}, {"exercises", exerciseList} }; } QString SessionController::adaptationPreview(const QString &feedbackLabel) const { if (!pendingFinishedSession_) return {}; if (pendingFinishedSession_->sessionMode == QStringLiteral("quick")) { return QStringLiteral("Пятиминутка сохранится отдельно и не изменит базовый план."); } return adaptationText(feedbackLabel, pendingFinishedSession_->completedAll); } void SessionController::discardFinishedSession() { if (!pendingFinishedSession_ || !store_) return; QJsonObject updated = root_; StateStore::clearPendingDraft(updated); StateStore::clearPendingFinishedSession(updated); QString error; if (store_->save(updated, &error)) { root_ = updated; pendingFinishedSession_.reset(); pendingFinishedSessionProfileId_.clear(); status_ = QStringLiteral("Результат тренировки не сохранён"); } else { status_ = QStringLiteral("Не удалось очистить черновик: %1").arg(error); } notify(); } void SessionController::abortActiveWorkout() { if (!runner_ || !active() || !store_) return; runner_->abort(); setKeepScreenOn(false); currentLoadNote_.clear(); currentExternalLoadKg_ = 0.0; currentEffortRating_ = 0; currentDiscomfortRating_ = 0; 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; const QString draftProfileId = recoverableDraft_->draft.plan.profileId.trimmed(); if (draftProfileId.compare(selectedProfileId(), Qt::CaseInsensitive) != 0) { status_ = QStringLiteral("Черновик принадлежит другому профилю"); notify(); return; } QString error; if (!runner_->restore(recoverableDraft_->draft, &error)) { status_ = QStringLiteral("Черновик не восстановлен: %1").arg(error); notify(); return; } sessionStartedAtLocal_ = recoverableDraft_->sessionStartedAtLocal; currentLoadNote_ = recoverableDraft_->draft.currentLoadNote; currentExternalLoadKg_ = recoverableDraft_->draft.currentExternalLoadKg; currentEffortRating_ = recoverableDraft_->draft.currentEffortRating; currentDiscomfortRating_ = recoverableDraft_->draft.currentDiscomfortRating; recoverableDraft_.reset(); setKeepScreenOn(true); 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 QJsonArray profiles = profilesArrayOrDefault(root_); const QString normalizedId = canonicalProfileId(profiles, profileId); if (normalizedId.isEmpty()) { status_ = QStringLiteral("Профиль не найден"); notify(); return; } QJsonObject updated = root_; updated.insert("profiles", profiles); updated.insert("selectedProfileId", normalizedId); const QString selectedPlanId = selectedPlanIdForProfile(updated, normalizedId); setSelectedPlanIdForProfile(updated, normalizedId, selectedPlanId); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось выбрать профиль: %1").arg(error); notify(); return; } root_ = updated; state_.selectedPlanId = selectedPlanId; planUndoStack_ = QJsonArray{}; applyAndroidDevicePreferences(); 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); setSelectedPlanIdForProfile(updated, id, QString()); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось создать профиль: %1").arg(error); notify(); return; } root_ = updated; state_.selectedPlanId.clear(); planUndoStack_ = QJsonArray{}; applyAndroidDevicePreferences(); status_ = QStringLiteral("Создан профиль: %1").arg(normalizedName); notify(); } void SessionController::selectPlan(const QString &planId) { if (active() || needsSave() || hasRecoverableDraft() || !store_) return; const QString profileId = selectedProfileId(); auto found = std::find_if(state_.workoutPlans.cbegin(), state_.workoutPlans.cend(), [&planId, &profileId](const WorkoutPlan &plan) { return plan.id.compare(planId, Qt::CaseInsensitive) == 0 && plan.profileId.compare(profileId, Qt::CaseInsensitive) == 0; }); if (found == state_.workoutPlans.cend()) { status_ = QStringLiteral("План не найден"); notify(); return; } QJsonObject updated = root_; setSelectedPlanIdForProfile(updated, profileId, 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 (!planObjectMatchesProfile(object, plan->id, plan->profileId)) 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); setSelectedPlanIdForProfile(updated, selectedProfileId(), 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; const QString profileId = selectedProfileId(); const int profilePlanCount = static_cast<int>(std::count_if( state_.workoutPlans.cbegin(), state_.workoutPlans.cend(), [&profileId](const WorkoutPlan &plan) { return plan.profileId.compare(profileId, Qt::CaseInsensitive) == 0; })); if (profilePlanCount <= 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 (planObjectMatchesProfile(object, deletedId, profileId)) { removedPlan = true; continue; } updatedPlans.append(object); } if (!removedPlan || updatedPlans.isEmpty()) { status_ = QStringLiteral("План не найден в состоянии"); notify(); return; } QString nextSelectedId; for (const QJsonValue &value : updatedPlans) { if (!value.isObject()) continue; const QJsonObject object = value.toObject(); if (planObjectBelongsToProfile(object, profileId)) { nextSelectedId = object.value("id").toString(); break; } } if (nextSelectedId.isEmpty()) { status_ = QStringLiteral("Не найден следующий план текущего профиля"); notify(); return; } QJsonObject updated = root_; updated.insert("plans", updatedPlans); setSelectedPlanIdForProfile(updated, profileId, 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.at(index).profileId.compare(profileId, 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 (!planObjectMatchesProfile(object, plan->id, plan->profileId)) 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.profileId.compare(plan->profileId, 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 (!planObjectMatchesProfile(planObject, plan->id, plan->profileId)) 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 || item.profileId.compare(plan->profileId, 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::updateSelectedPlanStepSets(int stepIndex, int sets) { if (active() || needsSave() || hasRecoverableDraft() || !store_) return; const WorkoutPlan *plan = selectedPlan(); if (!plan || stepIndex < 0 || stepIndex >= plan->steps.size()) return; const int clampedSets = std::clamp(sets, 1, 10); 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 (!planObjectMatchesProfile(planObject, plan->id, plan->profileId)) continue; QJsonArray steps = planObject.value("steps").toArray(); if (stepIndex >= steps.size() || !steps.at(stepIndex).isObject()) break; QJsonObject stepObject = steps.at(stepIndex).toObject(); stepObject.insert("sets", clampedSets); steps.replace(stepIndex, stepObject); planObject.insert("steps", steps); plans.replace(planIndex, planObject); updatedStep = true; break; } if (!updatedStep) return; QJsonObject updated = root_; updated.insert("plans", plans); QString error; if (!store_->save(updated, &error)) return; root_ = updated; for (WorkoutPlan &item : state_.workoutPlans) { if (item.id.compare(plan->id, Qt::CaseInsensitive) != 0 || item.profileId.compare(plan->profileId, Qt::CaseInsensitive) != 0 || stepIndex >= item.steps.size()) continue; item.steps[stepIndex].sets = clampedSets; break; } notify(); } void SessionController::createNastyaStarterCourse() { if (active() || needsSave() || hasRecoverableDraft() || !store_) return; QJsonArray profiles = profilesArrayOrDefault(root_); QString nastyaId; for (const QJsonValue &value : profiles) { const QJsonObject profile = value.toObject(); if (profile.value("name").toString().compare(QStringLiteral("Настя"), Qt::CaseInsensitive) == 0) { nastyaId = profile.value("id").toString(); break; } } if (nastyaId.isEmpty()) { nastyaId = uniqueProfileId(QStringLiteral("настя"), profiles); profiles.append(QJsonObject{{"id", nastyaId}, {"name", QStringLiteral("Настя")}}); } const QString romanId = selectedProfileId(); QJsonArray plans = root_.value("plans").toArray(); for (int index = 0; index < plans.size(); ++index) { if (!plans.at(index).isObject()) continue; QJsonObject plan = plans.at(index).toObject(); if (plan.value("profileId").toString().isEmpty()) { plan.insert("profileId", romanId); plans.replace(index, plan); } } const auto hasPlan = [&plans](const QString &id) { return std::any_of(plans.cbegin(), plans.cend(), [&id](const QJsonValue &value) { return value.toObject().value("id").toString() == id; }); }; const auto step = [](const QString &exerciseId, int target, int rest, int sets, const QString ¬e) { return QJsonObject{{"exerciseId", exerciseId}, {"targetOverride", target}, {"restSecondsOverride", rest}, {"sets", sets}, {"coachNote", note}}; }; const auto addPlan = [&](const QString &id, const QString &name, const QString &goal, const QString &description, int day, int warmupCount, int cooldownCount, QJsonArray steps) { if (hasPlan(id)) return; plans.append(QJsonObject{{"id", id}, {"profileId", nastyaId}, {"basePlanId", ""}, {"name", name}, {"goal", goal}, {"description", description}, {"variantId", "nastya-start"}, {"roundCount", 1}, {"warmupIncluded", true}, {"warmupStepCount", warmupCount}, {"cooldownIncluded", true}, {"cooldownStepCount", cooldownCount}, {"dayOfWeek", day}, {"roundStepCount", 0}, {"recommendedRoundsText", QStringLiteral("30 минут, техника без отказа")}, {"steps", steps}}); }; addPlan(QStringLiteral("nastya-strength-posture"), QStringLiteral("Вт • Ягодицы, спина и кор"), QStringLiteral("Ягодицы, руки, спина, нижний пресс и осанка"), QStringLiteral("Силовая база с гантелями 4 кг и стабильным кором."), 2, 3, 3, QJsonArray{step("goblet_squat", 14, 35, 2, QStringLiteral("Гантель 4 кг у груди, колени веди по носкам.")), step("single_leg_glute_bridge", 12, 25, 2, QStringLiteral("Поочерёдно стороны, пауза вверху.")), step("one_arm_db_row", 12, 35, 2, QStringLiteral("По 4 кг, тяни локоть к тазу.")), step("pike_pushup", 5, 40, 2, QStringLiteral("Небольшая амплитуда, плечи не проваливай.")), step("dead_bug", 8, 25, 2, QStringLiteral("Поясница прижата, выдох на выпрямлении.")), step("supine_straight_leg_raise", 8, 30, 2, QStringLiteral("Медленно, без рывка поясницей.")), step("thoracic_rotation", 8, 20, 1, QStringLiteral("Плавно, обе стороны."))}); addPlan(QStringLiteral("nastya-cardio-mobility"), QStringLiteral("Чт • Кардио, осанка и мобильность"), QStringLiteral("Кардио для снижения веса, спина и суставы"), QStringLiteral("Низкоударное кардио с короткими прыжковыми вставками по самочувствию."), 4, 3, 3, QJsonArray{step("dance_cardio_warmup", 180, 20, 1, QStringLiteral("Включи любимую музыку, темп комфортный.")), step("high_knee_march", 40, 20, 2, QStringLiteral("Активные руки, мягкая стопа.")), step("mountain_climber", 25, 35, 2, QStringLiteral("Можно медленно; корпус ровный.")), step("wuqin_xi_monkey", 12, 30, 2, QStringLiteral("Лёгкие прыжки только при хорошем самочувствии.")), step("rear_delt_raise", 12, 35, 2, QStringLiteral("Гантели 4 кг либо без веса, лопатки вниз.")), step("plank_to_pike", 6, 30, 2, QStringLiteral("Укрепляй плечи и вытягивай заднюю линию.")), step("cossack_squat", 8, 25, 2, QStringLiteral("Контроль, без боли в коленях."))}); addPlan(QStringLiteral("nastya-pads-yoga"), QStringLiteral("Вс • Лапы, стойка и растяжка"), QStringLiteral("Парная техника, опора на руки и шпагаты"), QStringLiteral("Работа на лапах по очереди с партнёром, затем спокойная йога и растяжка."), 7, 3, 3, QJsonArray{step("pad_strike_block_round", 90, 45, 3, QStringLiteral("3 раунда, потом поменяться ролями.")), step("wall_handstand_hold", 6, 45, 2, QStringLiteral("Стена, короткое уверенное удержание.")), step("frog_stand_prep", 8, 35, 2, QStringLiteral("Сначала перенос веса, затем отрыв стопы.")), step("wrist_extension_lean", 20, 20, 2, QStringLiteral("Подготовь кисти после лап и опоры.")), step("front_split_prep", 30, 20, 2, QStringLiteral("Спокойно обе стороны.")), step("middle_split_prep", 30, 20, 2, QStringLiteral("Без пружин и боли.")), step("switch_90_90", 10, 20, 1, QStringLiteral("Закрой тренировку мобильностью таза."))}); QJsonObject updated = root_; QJsonObject profileSettings = updated.value("profileSettings").toObject(); if (!profileSettings.value(nastyaId).isObject()) { profileSettings.insert(nastyaId, QJsonObject{ {"initialWeightKg", 60.0}, {"weightGoalKg", 54.0}, {"heightCm", 163}, {"nutritionPresetId", "balanced"}, {"workoutReminderEnabled", true}, {"workoutReminderTime", "18:00"}, {"weeklyGoalMin", 3}, {"weeklyGoalMax", 3}, {"rescueWeeklyGoalMin", 1}, {"selectedPlanId", "nastya-strength-posture"} }); } else { QJsonObject nastyaSettings = profileSettings.value(nastyaId).toObject(); nastyaSettings.insert("selectedPlanId", QStringLiteral("nastya-strength-posture")); profileSettings.insert(nastyaId, nastyaSettings); } updated.insert("profiles", profiles); updated.insert("plans", plans); updated.insert("profileSettings", profileSettings); updated.insert("selectedProfileId", nastyaId); setSelectedPlanIdForProfile(updated, nastyaId, QStringLiteral("nastya-strength-posture")); ensureProfileWeeklyGoalSettings(updated); 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_ = imported.error; notify(); return; } root_ = updated; state_ = imported.state; planUndoStack_ = QJsonArray{}; applyAndroidDevicePreferences(); status_ = QStringLiteral("Профиль Настя и 3-дневный курс созданы"); notify(); } void SessionController::updateSelectedPlanFlowOptions( bool warmupIncluded, int warmupStepCount, bool cooldownIncluded, int cooldownStepCount) { if (active() || needsSave() || hasRecoverableDraft() || !store_) return; const WorkoutPlan *plan = selectedPlan(); if (!plan) { status_ = QStringLiteral("План не найден"); notify(); return; } const int normalizedWarmupCount = warmupIncluded ? std::clamp(warmupStepCount <= 0 ? 4 : warmupStepCount, 1, 8) : 0; const int normalizedCooldownCount = cooldownIncluded ? std::clamp(cooldownStepCount <= 0 ? 3 : cooldownStepCount, 1, 4) : 0; QJsonArray plans = root_.value("plans").toArray(); bool updatedPlan = false; for (int planIndex = 0; planIndex < plans.size(); ++planIndex) { if (!plans.at(planIndex).isObject()) continue; QJsonObject planObject = plans.at(planIndex).toObject(); if (!planObjectMatchesProfile(planObject, plan->id, plan->profileId)) continue; planUndoStack_.append(planObject); if (planUndoStack_.size() > maxPlanUndoSteps) planUndoStack_.removeFirst(); planObject.insert("warmupIncluded", warmupIncluded); planObject.insert("warmupStepCount", normalizedWarmupCount); planObject.insert("cooldownIncluded", cooldownIncluded); planObject.insert("cooldownStepCount", normalizedCooldownCount); plans.replace(planIndex, planObject); 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.profileId.compare(plan->profileId, Qt::CaseInsensitive) != 0) continue; item.warmupIncluded = warmupIncluded; item.warmupStepCount = normalizedWarmupCount; item.cooldownIncluded = cooldownIncluded; item.cooldownStepCount = normalizedCooldownCount; break; } status_ = QStringLiteral("Настройки разминки и заминки сохранены"); notify(); } void SessionController::setSelectedPlanDayOfWeek(int dayOfWeek) { if (active() || needsSave() || hasRecoverableDraft() || !store_) return; const WorkoutPlan *plan = selectedPlan(); if (!plan) return; const int normalized = std::clamp(dayOfWeek, 0, 7); QJsonArray plans = root_.value("plans").toArray(); bool updatedPlan = false; for (int planIndex = 0; planIndex < plans.size(); ++planIndex) { if (!plans.at(planIndex).isObject()) continue; QJsonObject planObject = plans.at(planIndex).toObject(); if (!planObjectMatchesProfile(planObject, plan->id, plan->profileId)) continue; planObject.insert("dayOfWeek", normalized); plans.replace(planIndex, planObject); updatedPlan = true; break; } if (!updatedPlan) return; QJsonObject updated = root_; updated.insert("plans", plans); QString error; if (!store_->save(updated, &error)) return; root_ = updated; for (WorkoutPlan &item : state_.workoutPlans) { if (item.id.compare(plan->id, Qt::CaseInsensitive) != 0 || item.profileId.compare(plan->profileId, Qt::CaseInsensitive) != 0) continue; item.dayOfWeek = normalized; break; } 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 (planObjectMatchesProfile(p, plan->id, plan->profileId)) { 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 (!planObjectMatchesProfile(planObject, plan->id, plan->profileId)) continue; QJsonArray steps = planObject.value("steps").toArray(); QJsonObject stepObject{ {"exerciseId", normalizedExerciseId}, {"targetOverride", exercise->defaultTarget}, {"restSecondsOverride", exercise->defaultRestSeconds} }; if (!normalizedNote.isEmpty()) { stepObject.insert("coachNote", normalizedNote); } stepObject.insert("sets", 1); 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 || item.profileId.compare(plan->profileId, Qt::CaseInsensitive) != 0) continue; WorkoutStep step; step.exerciseId = normalizedExerciseId; step.targetOverride = exercise->defaultTarget; step.restSecondsOverride = exercise->defaultRestSeconds; step.coachNote = normalizedNote; step.sets = 1; 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 (!planObjectMatchesProfile(planObject, planId, plan->profileId)) 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 || item.profileId.compare(plan->profileId, 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 (planObjectMatchesProfile(p, plan->id, plan->profileId)) { 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 (!planObjectMatchesProfile(planObject, plan->id, plan->profileId)) 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 || item.profileId.compare(plan->profileId, 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 (planObjectMatchesProfile(p, plan->id, plan->profileId)) { 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 (!planObjectMatchesProfile(planObject, plan->id, plan->profileId)) 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 || item.profileId.compare(plan->profileId, 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") { bool hasSyncedWeight = false; for (const QJsonValue &value : history) { const QJsonObject item = value.toObject(); if (sessionMatchesProfile(item, selectedProfileId()) && item.value("source").toString() == QStringLiteral("health-connect")) { hasSyncedWeight = true; break; } } if (!hasSyncedWeight) 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 = updated.value("currentBodyWeightKg").toDouble(*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; } const QString profileId = selectedProfileId(); QJsonObject profileSettings = profileSettingsFor(root_, profileId); QString current = normalizeNutritionPreset(profileSettings.value("nutritionPresetId").toString( root_.value("nutritionPresetId").toString("balanced"))); if (current.isEmpty()) { current = QStringLiteral("balanced"); } if (current == normalized) { status_ = QStringLiteral("Пресет питания уже выбран"); notify(); return; } profileSettings.insert("nutritionPresetId", normalized); QJsonObject updated = withProfileSettings(root_, profileId, profileSettings); // Root-level value is retained for legacy state and for old clients. 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::importHealthConnectData(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("Выберите JSON Health Connect"); notify(); return; } QFile file(normalizedPath); if (!file.open(QIODevice::ReadOnly)) { status_ = QStringLiteral("Не удалось прочитать Health Connect JSON: %1").arg(file.errorString()); notify(); return; } QJsonParseError parseError; const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); if (parseError.error != QJsonParseError::NoError || !document.isObject()) { status_ = QStringLiteral("Неверный JSON Health Connect"); notify(); return; } const QJsonObject exportRoot = document.object(); if (exportRoot.value("schemaVersion").toInt() != 1 || exportRoot.value("source").toString() != QStringLiteral("health-connect") || !exportRoot.value("days").isArray()) { status_ = QStringLiteral("Неподдерживаемый формат Health Connect"); notify(); return; } const QJsonArray profiles = profilesArrayOrDefault(root_); const QString ownerProfileId = canonicalProfileId(profiles, healthConnectOwnerProfileId()); const QString requestedProfileId = exportRoot.value("targetProfileId").toString().trimmed(); QString profileId = canonicalProfileId(profiles, requestedProfileId); if (!requestedProfileId.isEmpty() && profileId.isEmpty()) { status_ = QStringLiteral("Профиль из экспорта Health Connect не найден"); notify(); return; } if (!profileId.isEmpty() && profileId.compare(ownerProfileId, Qt::CaseInsensitive) != 0) { status_ = QStringLiteral("Экспорт Health Connect устарел: владелец данных изменён"); notify(); return; } if (profileId.isEmpty()) profileId = ownerProfileId; profileId = canonicalProfileId(profiles, profileId); if (profileId.isEmpty()) { status_ = QStringLiteral("Не выбран владелец данных Health Connect"); notify(); return; } QJsonArray history = root_.value("wearableDailyHistory").toArray(); QSet<QString> importedDates; QJsonArray validDays; const QString importedAt = QDateTime::currentDateTime().toString(Qt::ISODateWithMs); for (const QJsonValue &value : exportRoot.value("days").toArray()) { if (!value.isObject() || validDays.size() >= 180) continue; QJsonObject day = value.toObject(); const QString dateTextValue = day.value("date").toString(); if (!QDate::fromString(dateTextValue, Qt::ISODate).isValid()) continue; day.insert("profileId", profileId); day.insert("source", QStringLiteral("health-connect")); day.insert("deviceModel", exportRoot.value("deviceModel").toString(QStringLiteral("Redmi Watch 5"))); day.insert("importedAt", importedAt); importedDates.insert(dateTextValue); validDays.append(day); } if (validDays.isEmpty()) { status_ = QStringLiteral("В экспорте нет валидных дневных данных"); notify(); return; } QJsonArray merged; for (const QJsonValue &value : history) { const QJsonObject item = value.toObject(); const bool replaced = sessionMatchesProfile(item, profileId) && importedDates.contains(item.value("date").toString()); if (!replaced) merged.append(value); } for (const QJsonValue &value : validDays) merged.append(value); while (merged.size() > 720) merged.removeFirst(); QSet<QString> importedWeightDates; QJsonArray importedWeights; for (const QJsonValue &value : validDays) { const QJsonObject day = value.toObject(); const double weightKg = day.value("weightKg").toDouble(); if (weightKg < 30.0 || weightKg > 250.0) continue; const QString dateValue = day.value("date").toString(); QString loggedAt = day.value("weightRecordedAt").toString(); if (!QDateTime::fromString(loggedAt, Qt::ISODate).isValid()) { loggedAt = QDateTime( QDate::fromString(dateValue, Qt::ISODate), QTime(12, 0)) .toString(Qt::ISODateWithMs); } importedWeightDates.insert(dateValue); importedWeights.append(QJsonObject{ {"profileId", profileId}, {"loggedAt", loggedAt}, {"weightKg", std::round(weightKg * 10.0) / 10.0}, {"source", QStringLiteral("health-connect")}, {"dataOrigin", day.value("weightOrigin").toString()} }); } QJsonArray weights; for (const QJsonValue &value : root_.value("bodyweightHistory").toArray()) { const QJsonObject item = value.toObject(); const QString itemDate = QDateTime::fromString(item.value("loggedAt").toString(), Qt::ISODate) .date().toString(Qt::ISODate); const bool replaced = sessionMatchesProfile(item, profileId) && item.value("source").toString() == QStringLiteral("health-connect") && importedWeightDates.contains(itemDate); if (!replaced) weights.append(value); } for (const QJsonValue &value : importedWeights) weights.append(value); weights = sortedObjectsByDateDesc(weights, QStringLiteral("loggedAt")); while (weights.size() > 180) weights.removeLast(); QJsonObject updated = root_; updated.insert("wearableDailyHistory", merged); updated.insert("bodyweightHistory", weights); int enrichedSessions = 0; QVector<QPair<QDateTime, int>> heartRateSamples; const QJsonArray exportedSamples = exportRoot.value("heartRateSamples").toArray(); heartRateSamples.reserve(static_cast<qsizetype>(std::min<qsizetype>(exportedSamples.size(), 50000))); for (const QJsonValue &value : exportedSamples) { if (!value.isObject() || heartRateSamples.size() >= 50000) continue; const QJsonObject sample = value.toObject(); const QDateTime at = QDateTime::fromString(sample.value("at").toString(), Qt::ISODate); const int bpm = sample.value("bpm").toInt(); if (at.isValid() && bpm >= 20 && bpm <= 250) heartRateSamples.append({at, bpm}); } if (!heartRateSamples.isEmpty()) { QJsonArray sessions = updated.value("sessions").toArray(); for (int index = 0; index < sessions.size(); ++index) { if (!sessions.at(index).isObject()) continue; QJsonObject session = sessions.at(index).toObject(); if (!sessionMatchesProfile(session, profileId)) continue; const QDateTime startedAt = QDateTime::fromString(session.value("startedAt").toString(), Qt::ISODate); const QDateTime endedAt = QDateTime::fromString(session.value("endedAt").toString(), Qt::ISODate); if (!startedAt.isValid() || !endedAt.isValid() || endedAt < startedAt) continue; int sampleCount = 0; int bpmTotal = 0; int bpmMin = 999; int bpmMax = 0; for (const auto &[at, bpm] : heartRateSamples) { if (at < startedAt || at > endedAt) continue; ++sampleCount; bpmTotal += bpm; bpmMin = std::min(bpmMin, bpm); bpmMax = std::max(bpmMax, bpm); } if (sampleCount == 0) continue; session.insert("heartRateAvg", static_cast<double>(bpmTotal) / sampleCount); session.insert("heartRateMin", bpmMin); session.insert("heartRateMax", bpmMax); session.insert("heartRateSampleCount", sampleCount); session.insert("heartRateSource", QStringLiteral("health-connect")); sessions.replace(index, session); ++enrichedSessions; } updated.insert("sessions", sessions); } updated.insert("healthSyncLastSuccessAt", QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)); updated.insert("healthSyncSourceExportedAt", exportRoot.value("exportedAt")); if (profileId == QStringLiteral("default") && !importedWeights.isEmpty()) { const QJsonArray syncedWeights = sortedObjectsByDateDesc(importedWeights, QStringLiteral("loggedAt")); updated.insert("currentBodyWeightKg", syncedWeights.first().toObject().value("weightKg").toDouble()); } QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось сохранить данные часов: %1").arg(error); notify(); return; } root_ = updated; if (profileId == QStringLiteral("default") && updated.value("currentBodyWeightKg").isDouble()) state_.currentBodyWeightKg = updated.value("currentBodyWeightKg").toDouble(); status_ = QStringLiteral("Health Connect: импортировано %1 дней%2") .arg(validDays.size()) .arg(importedWeights.isEmpty() ? QString() : QStringLiteral(" • вес: %1").arg(importedWeights.size())) + (enrichedSessions > 0 ? QStringLiteral(" • пульс: %1 тренировок").arg(enrichedSessions) : QString()); healthSyncStatusText_ = QStringLiteral("Синхронизация завершена • %1 дней").arg(validDays.size()); healthSyncInProgress_ = false; notify(); } void SessionController::syncHealthConnect() { #ifdef Q_OS_ANDROID QFile::remove(healthSyncFilePath_); lastHealthSyncRequest_ = QDateTime::currentDateTime(); const auto context = QNativeInterface::QAndroidApplication::context(); if (!context.isValid()) { status_ = QStringLiteral("Android context недоступен"); notify(); return; } QJniObject::callStaticMethod<void>( "org/bodyweightbase/android/HealthConnectActivity", "launch", "(Landroid/content/Context;)V", context.object<jobject>()); healthSyncPollsRemaining_ = 400; healthSyncTimer_.start(); healthSyncInProgress_ = true; healthSyncStatusText_ = QStringLiteral("Ожидание Health Connect…"); status_ = QStringLiteral("Открыт Health Connect"); #else status_ = QStringLiteral("Прямая синхронизация доступна в Android-версии"); #endif notify(); } void SessionController::autoSyncHealthConnect() { #ifdef Q_OS_ANDROID if (QFileInfo::exists(healthSyncFilePath_)) { importHealthConnectData(healthSyncFilePath_); QFile::remove(healthSyncFilePath_); } const QDateTime now = QDateTime::currentDateTime(); if (lastHealthSyncRequest_.isValid() && lastHealthSyncRequest_.secsTo(now) < 600) return; const auto context = QNativeInterface::QAndroidApplication::context(); if (!context.isValid()) return; const jboolean started = QJniObject::callStaticMethod<jboolean>( "org/bodyweightbase/android/HealthConnectExporter", "syncIfGranted", "(Landroid/content/Context;)Z", context.object<jobject>()); if (!started) { healthSyncStatusText_ = QStringLiteral("Нужно разрешение Health Connect"); healthSyncInProgress_ = false; notify(); return; } lastHealthSyncRequest_ = now; healthSyncPollsRemaining_ = 80; healthSyncInProgress_ = true; healthSyncStatusText_ = QStringLiteral("Фоновая синхронизация…"); healthSyncTimer_.start(); notify(); #endif } void SessionController::refreshForCurrentDay() { const QDate today = QDate::currentDate(); if (scheduleDate_ == today || active() || needsSave() || hasRecoverableDraft()) return; scheduleDate_ = today; const WorkoutPlan *todayPlan = scheduledPlanForDate(today); if (!todayPlan) { status_ = QStringLiteral("Сегодня день отдыха — план не назначен"); notify(); return; } if (state_.selectedPlanId.compare(todayPlan->id, Qt::CaseInsensitive) != 0) { state_.selectedPlanId = todayPlan->id; setSelectedPlanIdForProfile(root_, selectedProfileId(), todayPlan->id); status_ = QStringLiteral("Выбран план на сегодня: %1").arg(todayPlan->name); } notify(); } void SessionController::setHealthSourcePreferences( const QString &activitySource, const QString &weightSource) { const QSet<QString> allowed{ QStringLiteral("auto"), QStringLiteral("com.xiaomi.wearable"), QStringLiteral("com.google.android.apps.fitness") }; const QString normalizedActivity = allowed.contains(activitySource) ? activitySource : QStringLiteral("com.xiaomi.wearable"); const QString normalizedWeight = allowed.contains(weightSource) ? weightSource : QStringLiteral("com.google.android.apps.fitness"); if (normalizedActivity == healthActivitySource() && normalizedWeight == healthWeightSource()) return; QJsonObject updated = root_; updated.insert("healthActivitySource", normalizedActivity); updated.insert("healthWeightSource", normalizedWeight); QString error; if (!store_ || !store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось сохранить источники Health Connect: %1").arg(error); notify(); return; } root_ = updated; applyAndroidDevicePreferences(); lastHealthSyncRequest_ = {}; healthSyncStatusText_ = QStringLiteral("Источники изменены • синхронизируйте данные"); notify(); } void SessionController::setHealthConnectOwnerProfile(const QString &profileId) { const QJsonArray profiles = profilesArrayOrDefault(root_); const QString normalizedId = canonicalProfileId(profiles, profileId); if (normalizedId.isEmpty()) { status_ = QStringLiteral("Профиль владельца Health Connect не найден"); notify(); return; } if (normalizedId.compare(healthConnectOwnerProfileId(), Qt::CaseInsensitive) == 0) return; QJsonObject updated = root_; updated.insert("healthConnectOwnerProfileId", normalizedId); QString error; if (!store_ || !store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось сохранить владельца Health Connect: %1").arg(error); notify(); return; } root_ = updated; healthConnectOwnerProfileId_ = normalizedId; QFile::remove(healthSyncFilePath_); QFile::remove(healthSyncFilePath_ + QStringLiteral(".tmp")); healthSyncTimer_.stop(); healthSyncInProgress_ = false; applyAndroidDevicePreferences(); lastHealthSyncRequest_ = {}; healthSyncStatusText_ = QStringLiteral("Данные этого телефона: %1").arg(healthConnectOwnerProfileName()); status_ = QStringLiteral("Health Connect привязан к профилю %1").arg(healthConnectOwnerProfileName()); notify(); } void SessionController::setWorkoutReminderSettings(bool enabled, const QString &timeText) { const QTime time = QTime::fromString(timeText.trimmed(), QStringLiteral("HH:mm")); if (!time.isValid()) { status_ = QStringLiteral("Время напоминания должно быть в формате ЧЧ:ММ"); notify(); return; } const QString normalizedTime = time.toString(QStringLiteral("HH:mm")); if (enabled == workoutReminderEnabled() && normalizedTime == workoutReminderTime()) return; const QString profileId = selectedProfileId(); QJsonObject settings = profileSettingsFor(root_, profileId); settings.insert("workoutReminderEnabled", enabled); settings.insert("workoutReminderTime", normalizedTime); QJsonObject updated = withProfileSettings(root_, profileId, settings); // Android owns one physical notification: mirror the selected profile there. updated.insert("workoutReminderEnabled", enabled); updated.insert("workoutReminderTime", normalizedTime); QString error; if (!store_ || !store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось сохранить напоминание: %1").arg(error); notify(); return; } root_ = updated; applyAndroidDevicePreferences(); status_ = enabled ? QStringLiteral("Напоминание сохранено: %1").arg(normalizedTime) : QStringLiteral("Напоминание о тренировке выключено"); notify(); } void SessionController::configureTrainingCycle(int durationWeeks) { if (!store_ || active() || needsSave() || hasRecoverableDraft()) return; const int normalizedDuration = durationWeeks <= 4 ? 4 : 26; const QDate today = QDate::currentDate(); const int daysUntilMonday = (8 - today.dayOfWeek()) % 7; const QDate start = today.addDays(daysUntilMonday); const QString profileId = selectedProfileId(); QJsonArray cycles = objectsWithoutProfile(root_.value("trainingCycles").toArray(), profileId); cycles.append(QJsonObject{ {"id", QStringLiteral("cycle-%1-%2").arg(profileId, start.toString("yyyyMMdd"))}, {"profileId", profileId}, {"startDate", start.toString(Qt::ISODate)}, {"durationWeeks", normalizedDuration}, {"goal", QStringLiteral("Мышцы • V-силуэт • Вин-Чун • выносливость • кардио")}, {"createdAt", QDateTime::currentDateTime().toString(Qt::ISODateWithMs)} }); QJsonObject updated = root_; updated.insert("trainingCycles", cycles); updated.insert("trainingCycleReviews", objectsWithoutProfile( root_.value("trainingCycleReviews").toArray(), profileId)); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось сохранить цикл: %1").arg(error); notify(); return; } root_ = updated; status_ = normalizedDuration == 4 ? QStringLiteral("Создан план на 4 недели") : QStringLiteral("Создан план на 26 недель"); notify(); } void SessionController::completeCycleReview(const QString &decision) { if (!store_ || active() || needsSave() || hasRecoverableDraft()) return; const QSet<QString> allowed{QStringLiteral("keep"), QStringLiteral("progress"), QStringLiteral("reduce")}; if (!allowed.contains(decision)) { status_ = QStringLiteral("Неизвестное решение пересмотра цикла"); notify(); return; } const QVariantMap reviewState = cycleReview(); if (!reviewState.value("due").toBool()) { status_ = QStringLiteral("Сейчас пересмотр цикла не требуется"); notify(); return; } const QJsonObject cycle = trainingCycleForProfile(root_, selectedProfileId()); QJsonObject updated = root_; QJsonArray plans = updated.value("plans").toArray(); const QString profileId = selectedProfileId(); if (decision != QStringLiteral("keep")) { for (int planIndex = 0; planIndex < plans.size(); ++planIndex) { QJsonObject plan = plans.at(planIndex).toObject(); if (!planObjectBelongsToProfile(plan, profileId)) continue; QJsonArray steps = plan.value("steps").toArray(); for (int stepIndex = 0; stepIndex < steps.size(); ++stepIndex) { QJsonObject step = steps.at(stepIndex).toObject(); if (step.value("isWarmup").toBool() || step.value("isCooldown").toBool()) continue; const ExerciseDefinition *exercise = findExercise(exercises_, step.value("exerciseId").toString()); const bool timed = exercise && exercise->metric == ExerciseMetric::seconds; const int target = step.value("targetOverride").isDouble() ? step.value("targetOverride").toInt() : exercise ? exercise->defaultTarget : 1; const int rest = step.value("restSecondsOverride").isDouble() ? step.value("restSecondsOverride").toInt() : exercise ? exercise->defaultRestSeconds : 0; if (decision == QStringLiteral("progress")) { step.insert("targetOverride", std::clamp(target + (timed ? 5 : 1), 1, 999)); } else { step.insert("targetOverride", std::clamp(target - (timed ? 5 : 1), 1, 999)); step.insert("restSecondsOverride", std::clamp(rest + 10, 0, 900)); } steps.replace(stepIndex, step); } plan.insert("steps", steps); plans.replace(planIndex, plan); } updated.insert("plans", plans); } const QString decisionText = decision == QStringLiteral("progress") ? QStringLiteral("Нагрузка увеличена") : decision == QStringLiteral("reduce") ? QStringLiteral("Нагрузка облегчена") : QStringLiteral("Нагрузка оставлена"); QJsonArray reviews = updated.value("trainingCycleReviews").toArray(); reviews.append(QJsonObject{ {"profileId", selectedProfileId()}, {"cycleId", cycle.value("id")}, {"reviewWeek", reviewState.value("reviewWeek").toInt()}, {"decision", decision}, {"decisionText", decisionText}, {"sessionCount", reviewState.value("sessionCount").toInt()}, {"workMinutes", reviewState.value("workMinutes").toInt()}, {"completionRate", reviewState.value("completionRate").toInt()}, {"loggedAt", QDateTime::currentDateTime().toString(Qt::ISODateWithMs)} }); updated.insert("trainingCycleReviews", reviews); QString error; 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; status_ = QStringLiteral("Пересмотр недели %1: %2") .arg(reviewState.value("reviewWeek").toInt()).arg(decisionText.toLower()); notify(); } void SessionController::clearTrainingCycle() { if (!store_ || active() || needsSave() || hasRecoverableDraft()) return; QJsonObject updated = root_; updated.insert("trainingCycles", objectsWithoutProfile( root_.value("trainingCycles").toArray(), selectedProfileId())); updated.insert("trainingCycleReviews", objectsWithoutProfile( root_.value("trainingCycleReviews").toArray(), selectedProfileId())); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось отключить цикл: %1").arg(error); notify(); return; } root_ = updated; status_ = QStringLiteral("Тренировочный цикл отключён"); 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 = planToJson(*plan); 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_ || active() || needsSave() || hasRecoverableDraft()) 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.profileId = selectedProfileId(); newPlan.name = planName; newPlan.goal = planJson.value("goal").toString(); newPlan.description = planJson.value("description").toString(); newPlan.variantId = planJson.value("variantId").toString("custom"); newPlan.roundCount = std::max(1, planJson.value("roundCount").toInt(1)); newPlan.warmupIncluded = planJson.value("warmupIncluded").toBool(); newPlan.warmupStepCount = std::clamp(planJson.value("warmupStepCount").toInt(), 0, 8); newPlan.cooldownIncluded = planJson.value("cooldownIncluded").toBool(); newPlan.cooldownStepCount = std::clamp(planJson.value("cooldownStepCount").toInt(), 0, 4); newPlan.roundStepCount = planJson.value("roundStepCount").toInt(); newPlan.recommendedRoundsText = planJson.value("recommendedRoundsText").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(); step.sets = std::max(1, stepJson.value("sets").toInt(1)); step.isWarmup = stepJson.value("isWarmup").toBool(); step.isCooldown = stepJson.value("isCooldown").toBool(); newPlan.steps.append(step); } if (newPlan.steps.isEmpty()) { status_ = QStringLiteral("Нет валидных шагов"); notify(); return; } QVector<WorkoutPlan> importedPlans = state_.workoutPlans; importedPlans.append(newPlan); QJsonObject updated = root_; QJsonArray plansArray; for (const WorkoutPlan &p : importedPlans) { plansArray.append(planToJson(p)); } updated.insert("plans", plansArray); QString error; if (!store_->save(updated, &error)) { status_ = QStringLiteral("Ошибка сохранения: %1").arg(error); notify(); return; } root_ = updated; state_.workoutPlans = std::move(importedPlans); status_ = QStringLiteral("План импортирован: %1 (%2 шагов)").arg(newPlan.name).arg(newPlan.steps.size()); notify(); } void SessionController::createLocalBackup() { if (!store_) return; const QString backupDirectory = QDir(storageDirectory_).filePath(QStringLiteral("backups")); QDir().mkpath(backupDirectory); const QString destination = QDir(backupDirectory).filePath( QStringLiteral("native-state-%1.json").arg(QDateTime::currentDateTime().toString("yyyyMMdd-HHmmss"))); if (!QFile::copy(store_->stateFilePath(), destination)) { status_ = QStringLiteral("Не удалось создать резервную копию"); notify(); return; } QDir directory(backupDirectory); const QFileInfoList backups = directory.entryInfoList( {QStringLiteral("native-state-*.json")}, QDir::Files, QDir::Time); for (int index = 10; index < backups.size(); ++index) QFile::remove(backups.at(index).absoluteFilePath()); status_ = QStringLiteral("Резервная копия создана"); notify(); } void SessionController::restoreLatestBackup() { if (!store_) return; if (active() || needsSave() || hasRecoverableDraft()) { status_ = QStringLiteral("Восстановление недоступно во время незавершённой тренировки"); notify(); return; } const QDir directory(QDir(storageDirectory_).filePath(QStringLiteral("backups"))); const QFileInfoList backups = directory.entryInfoList( {QStringLiteral("native-state-*.json")}, QDir::Files, QDir::Time); if (backups.isEmpty()) { status_ = QStringLiteral("Резервных копий пока нет"); notify(); return; } QFile file(backups.first().absoluteFilePath()); if (!file.open(QIODevice::ReadOnly)) { status_ = QStringLiteral("Не удалось открыть резервную копию"); notify(); return; } const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); QJsonObject restoredRoot = document.isObject() ? document.object() : QJsonObject{}; if (!restoredRoot.isEmpty()) { ensureProfileOwnershipState(restoredRoot); ensureProfileWeeklyGoalSettings(restoredRoot); } const QString restoredHealthOwner = restoredRoot.isEmpty() ? QString{} : resolvedHealthConnectOwnerForRoot(restoredRoot); if (!restoredHealthOwner.isEmpty()) { restoredRoot.insert("healthConnectOwnerProfileId", restoredHealthOwner); } const ImportResult imported = document.isObject() ? importNativeState(QJsonDocument(restoredRoot).toJson()) : ImportResult{}; if (!imported.ok) { status_ = QStringLiteral("Резервная копия повреждена"); notify(); return; } QString error; if (!store_->save(restoredRoot, &error)) { status_ = QStringLiteral("Не удалось восстановить данные: %1").arg(error); notify(); return; } root_ = restoredRoot; state_ = imported.state; healthConnectOwnerProfileId_ = restoredHealthOwner; soundsEnabled_ = root_.value("soundsEnabled").toBool(true); uiScale_ = root_.value("uiScale").toDouble(1.0); darkMode_ = root_.value("darkMode").toBool(true); planUndoStack_ = QJsonArray{}; recoverableDraft_ = StateStore::pendingDraft(root_); applyAndroidDevicePreferences(); status_ = QStringLiteral("Восстановлена копия: %1").arg(backups.first().fileName()); notify(); } void SessionController::openAndroidDataTransfer() { if (active() || needsSave() || hasRecoverableDraft()) { status_ = QStringLiteral("Перед импортом завершите текущую тренировку"); notify(); return; } #ifdef Q_OS_ANDROID QFile::remove(dataTransferImportPath_); const auto context = QNativeInterface::QAndroidApplication::context(); if (!context.isValid()) return; QJniObject::callStaticMethod<void>( "org/bodyweightbase/android/DataTransferActivity", "launch", "(Landroid/content/Context;)V", context.object<jobject>()); dataTransferPollsRemaining_ = 400; dataTransferTimer_.start(); #else status_ = QStringLiteral("На Windows используйте команды экспорта и импорта JSON"); notify(); #endif } void SessionController::clearPersonalData() { if (!store_) return; if (runner_ && active()) { runner_->abort(); setKeepScreenOn(false); } pendingFinishedSession_.reset(); recoverableDraft_.reset(); currentLoadNote_.clear(); currentExternalLoadKg_ = 0.0; currentEffortRating_ = 0; currentDiscomfortRating_ = 0; 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)); updated.insert("wearableDailyHistory", objectsWithoutProfile(root_.value("wearableDailyHistory").toArray(), profileId)); updated.insert("trainingCycleReviews", objectsWithoutProfile(root_.value("trainingCycleReviews").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(); } WorkoutPlan SessionController::cycleAdjustedPlan(const WorkoutPlan &plan) const { WorkoutPlan adjusted = plan; const QJsonObject cycle = trainingCycleForProfile(root_, selectedProfileId()); const QDate today = QDate::currentDate(); const QVariantMap continuity = trainingContinuityForDate(today); if (!cycle.isEmpty()) { const QDate start = QDate::fromString(cycle.value("startDate").toString(), Qt::ISODate); const int durationWeeks = std::clamp(cycle.value("durationWeeks").toInt(26), 4, 26); const QDate adjustedEnd = QDate::fromString( continuity.value("adjustedEndDate").toString(), QStringLiteral("dd.MM.yyyy")); const QDate activeEnd = adjustedEnd.isValid() ? adjustedEnd : start.addDays(durationWeeks * 7 - 1); if (start.isValid() && today >= start && today <= activeEnd) { const int week = std::clamp(static_cast<int>(start.daysTo(today) / 7) + 1, 1, durationWeeks); const CycleWeekSpec spec = cycleWeekSpec(week); adjusted.name = QStringLiteral("%1 • Неделя %2").arg(plan.name).arg(week); for (WorkoutStep &step : adjusted.steps) { const ExerciseDefinition *exercise = findExercise(exercises_, step.exerciseId); const int baseTarget = step.targetOverride.value_or(exercise ? exercise->defaultTarget : 1); const int baseRest = step.restSecondsOverride.value_or(exercise ? exercise->defaultRestSeconds : 0); step.targetOverride = std::clamp(qRound(baseTarget * spec.targetPercent / 100.0), 1, 999); step.restSecondsOverride = std::clamp(baseRest + spec.restDeltaSeconds, 0, 900); step.sets = std::max(1, step.sets + spec.setDelta); } } } const RecoverySignal recovery = currentRecoverySignal(root_, selectedProfileId(), today); const RecoverySignal load = recovery.targetPercent < 100 ? RecoverySignal{} : trainingLoadSignal(root_, selectedProfileId(), today); RecoverySignal activeSignal = recovery.targetPercent < 100 ? recovery : load; const int returnPercent = continuity.value("targetPercent", 100).toInt(); if (continuity.value("returnMode").toBool() && returnPercent <= activeSignal.targetPercent) { activeSignal = { returnPercent, continuity.value("extraRestSeconds").toInt(), continuity.value("reduceSets").toBool(), continuity.value("title").toString(), continuity.value("detail").toString() }; adjusted.name = QStringLiteral("%1 • Возвращение").arg(adjusted.name); } if (activeSignal.targetPercent < 100) { for (WorkoutStep &step : adjusted.steps) { if (step.isWarmup || step.isCooldown) continue; const ExerciseDefinition *exercise = findExercise(exercises_, step.exerciseId); const int target = step.targetOverride.value_or(exercise ? exercise->defaultTarget : 1); const int rest = step.restSecondsOverride.value_or(exercise ? exercise->defaultRestSeconds : 0); step.targetOverride = std::max(1, qRound(target * activeSignal.targetPercent / 100.0)); step.restSecondsOverride = std::clamp(rest + activeSignal.extraRestSeconds, 0, 900); if (activeSignal.reduceSets) step.sets = std::max(1, step.sets - 1); } } return adjusted; } const WorkoutPlan *SessionController::selectedPlan() const { const QString profileId = selectedProfileId(); auto found = std::find_if(state_.workoutPlans.cbegin(), state_.workoutPlans.cend(), [this, &profileId](const WorkoutPlan &plan) { return plan.id.compare(state_.selectedPlanId, Qt::CaseInsensitive) == 0 && plan.profileId.compare(profileId, Qt::CaseInsensitive) == 0; }); return found == state_.workoutPlans.cend() ? nullptr : &*found; } const WorkoutPlan *SessionController::scheduledPlanForDate(const QDate &date) const { if (!date.isValid()) return nullptr; const QString profileId = selectedProfileId(); const auto found = std::find_if(state_.workoutPlans.cbegin(), state_.workoutPlans.cend(), [&date, &profileId](const WorkoutPlan &plan) { return plan.dayOfWeek == date.dayOfWeek() && plan.profileId.compare(profileId, Qt::CaseInsensitive) == 0; }); return found == state_.workoutPlans.cend() ? nullptr : &*found; } const WorkoutPlan *SessionController::displayPlan() const { const SessionSnapshot view = snapshot(); if (runner_ && view.phase != SessionPhase::idle && view.phase != SessionPhase::completed) { if (const WorkoutPlan *plan = runner_->activePlan()) { return plan; } } return selectedPlan(); } QString SessionController::exerciseNameForStep(int index) const { const WorkoutPlan *plan = displayPlan(); if (!plan || index < 0 || index >= plan->steps.size()) return {}; const QString id = plan->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_); const QStringList extensions = { #ifdef Q_OS_ANDROID QStringLiteral("jpg"), #endif QStringLiteral("png") }; constexpr int maxSupportedFrames = 32; for (int index = 1; index <= maxSupportedFrames; ++index) { bool foundFrame = false; for (const QString &extension : extensions) { const QString path = directory.filePath( QStringLiteral("%1_%2.%3").arg(exerciseId, QString::number(index), extension)); if (!QFileInfo::exists(path)) continue; if (path.startsWith(":/")) urls.append(QStringLiteral("qrc%1").arg(path)); else urls.append(QUrl::fromLocalFile(QFileInfo(path).absoluteFilePath()).toString()); foundFrame = true; break; } if (!foundFrame) break; } 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::clearCurrentSetLog() { currentLoadNote_.clear(); currentExternalLoadKg_ = 0.0; currentEffortRating_ = 0; currentDiscomfortRating_ = 0; } void SessionController::clearCurrentSetLogAfterTransition(const SessionSnapshot &before) { const SessionSnapshot after = snapshot(); if (before.stepIndex != after.stepIndex || before.currentSet != after.currentSet) { clearCurrentSetLog(); } } void SessionController::captureFinishedSession() { if (!runner_ || pendingFinishedSession_) return; std::optional<FinishedSession> finished = runner_->takeFinishedSession(); if (!finished) return; pendingFinishedSession_ = std::move(finished); pendingFinishedSessionProfileId_ = selectedProfileId(); setKeepScreenOn(false); sessionEndedAtLocal_ = QDateTime::currentDateTime(); currentLoadNote_.clear(); currentExternalLoadKg_ = 0.0; currentEffortRating_ = 0; currentDiscomfortRating_ = 0; status_ = persistPendingResult() ? QStringLiteral("Подтвердите сохранение результата") : status_; } 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; } bool SessionController::persistPendingResult() { if (!pendingFinishedSession_ || !store_) return false; const QString resultProfileId = pendingFinishedSessionProfileId_.isEmpty() ? selectedProfileId() : pendingFinishedSessionProfileId_; QJsonObject updated = root_; QString error; if (!StateStore::setPendingFinishedSession( updated, *pendingFinishedSession_, sessionStartedAtLocal_, sessionEndedAtLocal_, resultProfileId, &error) || !store_->save(updated, &error)) { status_ = QStringLiteral("Не удалось сохранить ожидающий результат: %1").arg(error); return false; } root_ = updated; return true; } void SessionController::notify() { emit changed(); }