9316 lines
400 KiB
C
9316 lines
400 KiB
C
/**
|
||
* @file tui.c
|
||
* @brief Trainlog Notcurses terminal interface.
|
||
*/
|
||
|
||
#include "trainlog/tui.h"
|
||
|
||
#include <ctype.h>
|
||
#include <fcntl.h>
|
||
#include <locale.h>
|
||
#include <limits.h>
|
||
#include <math.h>
|
||
#include <stdbool.h>
|
||
#include <stdint.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <time.h>
|
||
#include <sys/stat.h>
|
||
#include <sys/wait.h>
|
||
#include <wchar.h>
|
||
#include <unistd.h>
|
||
|
||
#include <utf8proc.h>
|
||
#include <uuid/uuid.h>
|
||
#include <sqlite3.h>
|
||
|
||
#include "trainlog/terminal.h"
|
||
#include "trainlog/app_shell.h"
|
||
|
||
#include "trainlog/bodyviz.h"
|
||
#include "trainlog/body_analytics.h"
|
||
#include "trainlog/body_zone_catalog.h"
|
||
#include "trainlog/catalog.h"
|
||
#include "trainlog/duration.h"
|
||
#include "trainlog/equipment_catalog.h"
|
||
#include "trainlog/exercise_name_catalog.h"
|
||
#include "trainlog/id.h"
|
||
#include "trainlog/measured_max.h"
|
||
#include "trainlog/mtp.h"
|
||
#include "trainlog/sync.h"
|
||
#include "trainlog/sync_history.h"
|
||
#include "trainlog/sync_screen_action.h"
|
||
#include "trainlog/reps.h"
|
||
#include "trainlog/session_generation.h"
|
||
#include "trainlog/session_generation_policy_internal.h"
|
||
#include "trainlog/theme.h"
|
||
#include "trainlog/timeutil.h"
|
||
#include "trainlog/training_knowledge.h"
|
||
#include "trainlog/usb.h"
|
||
#include "timestamp.h"
|
||
#include "database_internal.h"
|
||
|
||
#define MAX_EXERCISES 128U
|
||
#define MAX_SESSION_EXERCISES 32U
|
||
#define MAX_SETS_PER_EXERCISE 64U
|
||
#define MAX_SESSIONS 128U
|
||
#define MAX_WEIGHT_POINTS 256U
|
||
#define MAX_BODY_ZONES 16U
|
||
#define MAX_BODY_OBSERVATIONS 512U
|
||
|
||
#define MAX_BODY_METRIC_POINTS 256U
|
||
#define EXERCISE_GRAPH_POINTS 12U
|
||
#define DASHBOARD_BUCKET_COUNT 12U
|
||
#define DASHBOARD_FACT_CAPACITY 4096U
|
||
#define DASHBOARD_ZONE_BUCKET_CAPACITY (MAX_BODY_ZONES + 1U)
|
||
|
||
/* TRAINLOG_TUI_V02_POLISH */
|
||
/* TRAINLOG_TUI_PROFILED_EXERCISE_CREATION */
|
||
/* TRAINLOG_VARIABLE_SET_REPS_V1 */
|
||
/* TRAINLOG_SYNC_RESPONSIVE_CACHE */
|
||
/* TRAINLOG_SYNC_LARGE_LAYOUT_S_FIX */
|
||
/* TRAINLOG_SYNC_HISTORY_BIDIRECTIONAL_V1 */
|
||
/* TRAINLOG_SYNC_PC_TO_ANDROID_DIAGNOSTICS */
|
||
/* TRAINLOG_SYNC_FULL_MTP_SILENCE */
|
||
|
||
static bool equipment_text_matches(const char *text, const char *query)
|
||
{
|
||
size_t index;
|
||
size_t query_index;
|
||
if (query == NULL || query[0] == '\0') return true;
|
||
if (text == NULL) return false;
|
||
for (index = 0U; text[index] != '\0'; ++index) {
|
||
for (query_index = 0U; query[query_index] != '\0'; ++query_index) {
|
||
unsigned char left = (unsigned char)text[index + query_index];
|
||
unsigned char right = (unsigned char)query[query_index];
|
||
if (left == '\0' || tolower(left) != tolower(right)) break;
|
||
}
|
||
if (query[query_index] == '\0') return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
static size_t equipment_collect(TrainlogDatabase *database, const char *query,
|
||
TrainlogResolvedEquipment *output, size_t capacity, bool *truncated,
|
||
bool *failed)
|
||
{
|
||
TrainlogCustomEquipment customs[64];
|
||
const TrainlogEquipment **supplied_matches = NULL;
|
||
size_t supplied_capacity = trainlog_equipment_catalog_count();
|
||
size_t supplied_count;
|
||
size_t custom_offset = 0U;
|
||
bool custom_more = true;
|
||
size_t count = 0U;
|
||
size_t index;
|
||
if (truncated != NULL) *truncated = false;
|
||
if (failed != NULL) *failed = false;
|
||
if (supplied_capacity > 0U && query != NULL && query[0] != '\0') {
|
||
supplied_matches = calloc(supplied_capacity, sizeof(*supplied_matches));
|
||
if (supplied_matches == NULL) {
|
||
if (failed != NULL) *failed = true;
|
||
return 0U;
|
||
}
|
||
}
|
||
supplied_count = query != NULL && query[0] != '\0'
|
||
? trainlog_equipment_catalog_search(query, supplied_matches,
|
||
supplied_capacity) : supplied_capacity;
|
||
for (index = 0U; index < supplied_count; ++index) {
|
||
const TrainlogEquipment *item = query != NULL && query[0] != '\0'
|
||
? supplied_matches[index] : trainlog_equipment_catalog_at(index);
|
||
if (item == NULL) continue;
|
||
if (count >= capacity) {
|
||
if (truncated != NULL) *truncated = true;
|
||
continue;
|
||
}
|
||
if (trainlog_database_resolve_equipment(database, item->equipment_id,
|
||
&output[count]) == TRAINLOG_STATUS_OK) ++count;
|
||
}
|
||
free(supplied_matches);
|
||
while (custom_more) {
|
||
size_t custom_count = 0U;
|
||
if (trainlog_database_list_custom_equipment_page(database,
|
||
custom_offset, customs, 64U, &custom_count,
|
||
&custom_more) != TRAINLOG_STATUS_OK) {
|
||
if (failed != NULL) *failed = true;
|
||
return count;
|
||
}
|
||
for (index = 0U; index < custom_count; ++index) {
|
||
if (!equipment_text_matches(customs[index].display_name, query) &&
|
||
!equipment_text_matches(customs[index].label_name, query) &&
|
||
!equipment_text_matches(customs[index].equipment_type, query))
|
||
continue;
|
||
if (count >= capacity) {
|
||
if (truncated != NULL) *truncated = true;
|
||
return count;
|
||
}
|
||
if (trainlog_database_resolve_equipment(database,
|
||
customs[index].equipment_id,
|
||
&output[count]) == TRAINLOG_STATUS_OK) ++count;
|
||
}
|
||
if (custom_count > SIZE_MAX - custom_offset ||
|
||
(custom_more && custom_count == 0U)) {
|
||
if (failed != NULL) *failed = true;
|
||
return count;
|
||
}
|
||
custom_offset += custom_count;
|
||
}
|
||
return count;
|
||
}
|
||
|
||
static const char *equipment_origin_label(TrainlogEquipmentOrigin origin)
|
||
{
|
||
if (origin == TRAINLOG_EQUIPMENT_SUPPLIED) return "fourni";
|
||
if (origin == TRAINLOG_EQUIPMENT_CUSTOM) return "personnalisé";
|
||
return "inconnu";
|
||
}
|
||
|
||
static void generate_custom_equipment_uuid(
|
||
char output[TRAINLOG_UUID_TEXT_LENGTH + 1U])
|
||
{
|
||
uuid_t value;
|
||
uuid_generate_random(value);
|
||
uuid_unparse_lower(value, output);
|
||
}
|
||
|
||
static const char *session_type_label(TrainlogSessionType type)
|
||
{
|
||
return type == TRAINLOG_SESSION_MAX_TEST ? "Test de max" : "Entraînement";
|
||
}
|
||
|
||
static const char *session_type_history_label(TrainlogSessionType type)
|
||
{
|
||
return type == TRAINLOG_SESSION_MAX_TEST ? "[MAX]" : "[ENTRAINEMENT]";
|
||
}
|
||
|
||
static bool parse_double_positive(const char *text, double *output)
|
||
{
|
||
char *end = NULL;
|
||
double value;
|
||
|
||
if (text == NULL || output == NULL || text[0] == '\0') {
|
||
return false;
|
||
}
|
||
|
||
value = strtod(text, &end);
|
||
if (end == text || *end != '\0' || !isfinite(value) || value <= 0.0) {
|
||
return false;
|
||
}
|
||
|
||
*output = value;
|
||
return true;
|
||
}
|
||
|
||
static const char *exercise_load_mode_label(
|
||
TrainlogLoadMode mode
|
||
)
|
||
{
|
||
switch (mode) {
|
||
case TRAINLOG_LOAD_EXTERNAL:
|
||
return "charge externe";
|
||
|
||
case TRAINLOG_LOAD_ASSISTANCE:
|
||
return "assistance";
|
||
|
||
case TRAINLOG_LOAD_NONE:
|
||
default:
|
||
return "sans charge";
|
||
}
|
||
}
|
||
|
||
static void exercise_short_date(
|
||
const char *timestamp,
|
||
char output[11]
|
||
)
|
||
{
|
||
if (timestamp == NULL ||
|
||
strlen(timestamp) < 10U) {
|
||
(void)snprintf(
|
||
output,
|
||
11U,
|
||
"%s",
|
||
"----------"
|
||
);
|
||
return;
|
||
}
|
||
|
||
(void)memcpy(
|
||
output,
|
||
timestamp,
|
||
10U
|
||
);
|
||
|
||
output[10] = '\0';
|
||
}
|
||
|
||
static void format_compact_max_weight(
|
||
double value,
|
||
char *output,
|
||
size_t output_size
|
||
)
|
||
{
|
||
size_t length;
|
||
if (output == NULL || output_size == 0U) return;
|
||
(void)snprintf(output, output_size, "%.2f", value);
|
||
length = strlen(output);
|
||
while (length > 0U && output[length - 1U] == '0')
|
||
output[--length] = '\0';
|
||
if (length > 0U && output[length - 1U] == '.')
|
||
output[--length] = '\0';
|
||
}
|
||
|
||
static void exercise_format_performance(
|
||
const TrainlogExercisePerformancePoint *point,
|
||
char *output,
|
||
size_t output_size
|
||
)
|
||
{
|
||
if (point == NULL ||
|
||
output == NULL ||
|
||
output_size == 0U) {
|
||
return;
|
||
}
|
||
|
||
if (point->has_performance == 0) {
|
||
(void)snprintf(
|
||
output,
|
||
output_size,
|
||
"%s",
|
||
"aucune série réussie"
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (point->has_explicit_max != 0) {
|
||
char weight[32];
|
||
format_compact_max_weight(point->weight_kg, weight, sizeof(weight));
|
||
(void)snprintf(output, output_size, "%s kg", weight);
|
||
return;
|
||
}
|
||
|
||
if (point->tracking_mode ==
|
||
TRAINLOG_TRACKING_DURATION) {
|
||
char duration[64];
|
||
|
||
if (trainlog_duration_format(
|
||
point->metric_value,
|
||
duration,
|
||
sizeof(duration)
|
||
) != TRAINLOG_STATUS_OK) {
|
||
(void)snprintf(
|
||
duration,
|
||
sizeof(duration),
|
||
"%d s",
|
||
point->metric_value
|
||
);
|
||
}
|
||
|
||
if (point->load_mode ==
|
||
TRAINLOG_LOAD_EXTERNAL) {
|
||
(void)snprintf(
|
||
output,
|
||
output_size,
|
||
"%.1f kg × %s",
|
||
point->weight_kg,
|
||
duration
|
||
);
|
||
} else if (
|
||
point->load_mode ==
|
||
TRAINLOG_LOAD_ASSISTANCE
|
||
) {
|
||
(void)snprintf(
|
||
output,
|
||
output_size,
|
||
"%.1f kg aide × %s",
|
||
point->weight_kg,
|
||
duration
|
||
);
|
||
} else {
|
||
(void)snprintf(
|
||
output,
|
||
output_size,
|
||
"%s",
|
||
duration
|
||
);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (point->load_mode ==
|
||
TRAINLOG_LOAD_EXTERNAL) {
|
||
(void)snprintf(
|
||
output,
|
||
output_size,
|
||
"%.1f kg × %d reps",
|
||
point->weight_kg,
|
||
point->metric_value
|
||
);
|
||
} else if (
|
||
point->load_mode ==
|
||
TRAINLOG_LOAD_ASSISTANCE
|
||
) {
|
||
(void)snprintf(
|
||
output,
|
||
output_size,
|
||
"%.1f kg aide × %d reps",
|
||
point->weight_kg,
|
||
point->metric_value
|
||
);
|
||
} else {
|
||
(void)snprintf(
|
||
output,
|
||
output_size,
|
||
"%d reps",
|
||
point->metric_value
|
||
);
|
||
}
|
||
}
|
||
|
||
static bool exercise_point_better(
|
||
const TrainlogExercisePerformancePoint *candidate,
|
||
const TrainlogExercisePerformancePoint *current
|
||
)
|
||
{
|
||
if (candidate == NULL ||
|
||
candidate->has_performance == 0) {
|
||
return false;
|
||
}
|
||
|
||
if (current == NULL ||
|
||
current->has_performance == 0) {
|
||
return true;
|
||
}
|
||
|
||
if (candidate->load_mode !=
|
||
current->load_mode) {
|
||
return false;
|
||
}
|
||
|
||
switch (candidate->load_mode) {
|
||
case TRAINLOG_LOAD_EXTERNAL:
|
||
if (candidate->weight_kg >
|
||
current->weight_kg) {
|
||
return true;
|
||
}
|
||
|
||
return
|
||
candidate->weight_kg ==
|
||
current->weight_kg &&
|
||
candidate->metric_value >
|
||
current->metric_value;
|
||
|
||
case TRAINLOG_LOAD_ASSISTANCE:
|
||
if (candidate->weight_kg <
|
||
current->weight_kg) {
|
||
return true;
|
||
}
|
||
|
||
return
|
||
candidate->weight_kg ==
|
||
current->weight_kg &&
|
||
candidate->metric_value >
|
||
current->metric_value;
|
||
|
||
case TRAINLOG_LOAD_NONE:
|
||
default:
|
||
return
|
||
candidate->metric_value >
|
||
current->metric_value;
|
||
}
|
||
}
|
||
|
||
static double exercise_graph_value(
|
||
const TrainlogExercisePerformancePoint *point
|
||
)
|
||
{
|
||
if (point->load_mode ==
|
||
TRAINLOG_LOAD_NONE) {
|
||
return (double)point->metric_value;
|
||
}
|
||
|
||
return point->weight_kg;
|
||
}
|
||
|
||
/* TRAINLOG_EXERCISE_FRAMES */
|
||
|
||
static bool secondary_zone_contains(
|
||
char secondary[][TRAINLOG_ZONE_ID_MAX + 1U],
|
||
size_t count,
|
||
const char *zone_id,
|
||
size_t *output_index
|
||
)
|
||
{
|
||
size_t index;
|
||
for (index = 0U; index < count; ++index) {
|
||
if (strcmp(secondary[index], zone_id) == 0) {
|
||
if (output_index != NULL) *output_index = index;
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* WHY: terminal users select translated catalogue rows; accepting raw IDs
|
||
* would leak wire identity into product behavior and permit unknown values. */
|
||
static bool load_exercise_body_zones(
|
||
TrainlogDatabase *database,
|
||
const char *exercise_id,
|
||
char primary[TRAINLOG_ZONE_ID_MAX + 1U],
|
||
char secondary[][TRAINLOG_ZONE_ID_MAX + 1U],
|
||
size_t *secondary_count
|
||
)
|
||
{
|
||
TrainlogExerciseBodyZone relations[MAX_BODY_ZONES];
|
||
size_t count = 0U;
|
||
size_t index;
|
||
primary[0] = '\0';
|
||
*secondary_count = 0U;
|
||
if (trainlog_database_list_exercise_body_zones(database, exercise_id,
|
||
relations, MAX_BODY_ZONES, &count) != TRAINLOG_STATUS_OK) return false;
|
||
for (index = 0U; index < count; ++index) {
|
||
if (relations[index].role == TRAINLOG_BODY_ZONE_PRIMARY) {
|
||
(void)snprintf(primary, TRAINLOG_ZONE_ID_MAX + 1U, "%s",
|
||
relations[index].zone_id);
|
||
} else if (*secondary_count < MAX_BODY_ZONES) {
|
||
(void)snprintf(secondary[*secondary_count], TRAINLOG_ZONE_ID_MAX + 1U,
|
||
"%s", relations[index].zone_id);
|
||
++*secondary_count;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/* CONTRACT: knowledge text is read-only catalogue data. The screen stores
|
||
* display lines locally so navigation never changes the scientific record. */
|
||
#define KNOWLEDGE_LINES_MAX 128U
|
||
#define KNOWLEDGE_LINE_MAX 256U
|
||
|
||
static void knowledge_add_wrapped(char lines[][KNOWLEDGE_LINE_MAX], size_t *count,
|
||
const char *text, int width)
|
||
{
|
||
const char *at = text == NULL ? "" : text;
|
||
size_t used = 0U;
|
||
int cells = 0;
|
||
|
||
/* WHY: terminal columns count display cells, while French labels are UTF-8;
|
||
* utf8proc prevents a wrap from splitting an accented character or from
|
||
* writing past the right border for a wide code point. */
|
||
while (*at != '\0' && *count < KNOWLEDGE_LINES_MAX) {
|
||
utf8proc_int32_t codepoint;
|
||
utf8proc_ssize_t bytes = utf8proc_iterate((const utf8proc_uint8_t *)at,
|
||
-1, &codepoint);
|
||
int codepoint_cells;
|
||
if (bytes <= 0) { codepoint = (unsigned char)*at; bytes = 1; }
|
||
codepoint_cells = utf8proc_charwidth(codepoint);
|
||
if (codepoint_cells < 0) codepoint_cells = 1;
|
||
if (cells > 0 && cells + codepoint_cells > width) {
|
||
lines[*count][used] = '\0';
|
||
++*count;
|
||
used = 0U;
|
||
cells = 0;
|
||
continue;
|
||
}
|
||
if (used + (size_t)bytes >= KNOWLEDGE_LINE_MAX - 1U) break;
|
||
(void)memcpy(lines[*count] + used, at, (size_t)bytes);
|
||
used += (size_t)bytes;
|
||
cells += codepoint_cells;
|
||
at += bytes;
|
||
}
|
||
/* INVARIANT: every entry is NUL-terminated before rendering, and the fixed
|
||
* line capacity bounds catalogue display independently of terminal size. */
|
||
if (*count < KNOWLEDGE_LINES_MAX) {
|
||
lines[*count][used] = '\0';
|
||
++*count;
|
||
}
|
||
}
|
||
|
||
static void knowledge_add_ids(char lines[][KNOWLEDGE_LINE_MAX], size_t *count,
|
||
const char *heading, const char *ids, bool muscles,
|
||
int width)
|
||
{
|
||
const char *at = ids;
|
||
knowledge_add_wrapped(lines, count, heading, width);
|
||
while (at != NULL && *at != '\0' && *count < KNOWLEDGE_LINES_MAX) {
|
||
const char *end = strchr(at, '\n');
|
||
size_t length = end == NULL ? strlen(at) : (size_t)(end - at);
|
||
char id[96];
|
||
const char *label = NULL;
|
||
if (length >= sizeof(id)) break;
|
||
(void)memcpy(id, at, length);
|
||
id[length] = '\0';
|
||
if (muscles) {
|
||
const TrainlogKnowledgeMuscle *item = trainlog_knowledge_muscle_lookup(id);
|
||
if (item != NULL) label = item->display_name_fr;
|
||
} else {
|
||
const TrainlogKnowledgeMovementPattern *item =
|
||
trainlog_knowledge_movement_pattern_lookup(id);
|
||
if (item != NULL) label = item->display_name_fr;
|
||
}
|
||
knowledge_add_wrapped(lines, count, label == NULL ? id : label, width);
|
||
if (end == NULL) break;
|
||
at = end + 1;
|
||
}
|
||
}
|
||
|
||
static void knowledge_add_plain_ids(char lines[][KNOWLEDGE_LINE_MAX], size_t *count,
|
||
const char *heading, const char *ids, int width)
|
||
{
|
||
const char *at = ids;
|
||
knowledge_add_wrapped(lines, count, heading, width);
|
||
while (at != NULL && *at != '\0' && *count < KNOWLEDGE_LINES_MAX) {
|
||
const char *end = strchr(at, '\n');
|
||
size_t length = end == NULL ? strlen(at) : (size_t)(end - at);
|
||
char id[96];
|
||
if (length >= sizeof(id)) break;
|
||
(void)memcpy(id, at, length);
|
||
id[length] = '\0';
|
||
knowledge_add_wrapped(lines, count, id, width);
|
||
if (end == NULL) break;
|
||
at = end + 1;
|
||
}
|
||
}
|
||
|
||
static void knowledge_add_zones(char lines[][KNOWLEDGE_LINE_MAX], size_t *count,
|
||
const TrainlogKnowledgeInterpretation *value, int width)
|
||
{
|
||
const TrainlogBodyZone *zone;
|
||
knowledge_add_wrapped(lines, count, "Zones scientifiques :", width);
|
||
zone = trainlog_body_zone_catalog_lookup(value->primary_zone_id);
|
||
knowledge_add_wrapped(lines, count,
|
||
zone == NULL ? value->primary_zone_id : zone->display_name, width);
|
||
knowledge_add_plain_ids(lines, count, "Zones secondaires :",
|
||
value->secondary_zone_ids, width);
|
||
}
|
||
|
||
/* TRAINLOG_DURATION_BODY_GRAPH_HELPERS */
|
||
|
||
typedef struct TrainlogBodyMetricView {
|
||
TrainlogBodyMetric metric;
|
||
const char *label;
|
||
const char *unit;
|
||
} TrainlogBodyMetricView;
|
||
|
||
static const TrainlogBodyMetricView BODY_METRICS[] = {
|
||
{TRAINLOG_BODY_METRIC_WEIGHT, "Poids", "kg"},
|
||
{TRAINLOG_BODY_METRIC_NECK, "Cou", "cm"},
|
||
{TRAINLOG_BODY_METRIC_SHOULDERS, "Épaules", "cm"},
|
||
{TRAINLOG_BODY_METRIC_CHEST, "Poitrine", "cm"},
|
||
{TRAINLOG_BODY_METRIC_WAIST, "Tour de taille", "cm"},
|
||
{TRAINLOG_BODY_METRIC_HIPS, "Hanches", "cm"},
|
||
{TRAINLOG_BODY_METRIC_LEFT_ARM, "Bras gauche", "cm"},
|
||
{TRAINLOG_BODY_METRIC_RIGHT_ARM, "Bras droit", "cm"},
|
||
{TRAINLOG_BODY_METRIC_LEFT_FOREARM, "Avant-bras gauche", "cm"},
|
||
{TRAINLOG_BODY_METRIC_RIGHT_FOREARM, "Avant-bras droit", "cm"},
|
||
{TRAINLOG_BODY_METRIC_LEFT_THIGH, "Cuisse gauche", "cm"},
|
||
{TRAINLOG_BODY_METRIC_RIGHT_THIGH, "Cuisse droite", "cm"},
|
||
{TRAINLOG_BODY_METRIC_LEFT_CALF, "Mollet gauche", "cm"},
|
||
{TRAINLOG_BODY_METRIC_RIGHT_CALF, "Mollet droit", "cm"}
|
||
};
|
||
|
||
/* TRAINLOG_GLOBAL_BODY_OVERLAY_HELPERS */
|
||
|
||
#define MAX_GLOBAL_BODY_DATES \
|
||
(MAX_BODY_METRIC_POINTS * 14U)
|
||
|
||
typedef struct TrainlogGlobalBodySeries {
|
||
TrainlogBodyMetricPoint points[MAX_BODY_METRIC_POINTS];
|
||
size_t count;
|
||
double baseline;
|
||
double latest_percent;
|
||
uint32_t symbol;
|
||
TrainlogColorRole role;
|
||
} TrainlogGlobalBodySeries;
|
||
|
||
static const uint32_t GLOBAL_BODY_SYMBOLS[] = {
|
||
0x25cfU, 0x25c6U, 0x25b2U, 0x25a0U, 0x25cbU, 0x25bcU, 0x25c7U,
|
||
0x25efU, 0x25c8U, 0x25b3U, 0x25a1U, 0x25bdU, 0x25ceU, 0x25c9U
|
||
};
|
||
|
||
static const TrainlogColorRole GLOBAL_BODY_ROLES[] = {
|
||
TRAINLOG_COLOR_ACCENT,
|
||
TRAINLOG_COLOR_SUCCESS,
|
||
TRAINLOG_COLOR_WARNING,
|
||
TRAINLOG_COLOR_ERROR,
|
||
TRAINLOG_COLOR_GRAPH,
|
||
TRAINLOG_COLOR_MUTED,
|
||
TRAINLOG_COLOR_ACCENT,
|
||
TRAINLOG_COLOR_SUCCESS,
|
||
TRAINLOG_COLOR_WARNING,
|
||
TRAINLOG_COLOR_ERROR,
|
||
TRAINLOG_COLOR_GRAPH,
|
||
TRAINLOG_COLOR_MUTED,
|
||
TRAINLOG_COLOR_ACCENT,
|
||
TRAINLOG_COLOR_SUCCESS
|
||
};
|
||
|
||
static int global_date_compare(
|
||
const void *left,
|
||
const void *right
|
||
)
|
||
{
|
||
return strcmp(
|
||
(const char *)left,
|
||
(const char *)right
|
||
);
|
||
}
|
||
|
||
static bool global_date_add(
|
||
char dates[MAX_GLOBAL_BODY_DATES][TRAINLOG_TIMESTAMP_MAX + 1U],
|
||
size_t *date_count,
|
||
const char *value
|
||
)
|
||
{
|
||
size_t index;
|
||
|
||
if (dates == NULL ||
|
||
date_count == NULL ||
|
||
value == NULL) {
|
||
return false;
|
||
}
|
||
|
||
for (index = 0U; index < *date_count; ++index) {
|
||
if (strcmp(dates[index], value) == 0) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
if (*date_count >= MAX_GLOBAL_BODY_DATES) {
|
||
return false;
|
||
}
|
||
|
||
(void)snprintf(
|
||
dates[*date_count],
|
||
sizeof(dates[*date_count]),
|
||
"%s",
|
||
value
|
||
);
|
||
|
||
++(*date_count);
|
||
return true;
|
||
}
|
||
|
||
static size_t global_date_index(
|
||
char dates[MAX_GLOBAL_BODY_DATES][TRAINLOG_TIMESTAMP_MAX + 1U],
|
||
size_t date_count,
|
||
const char *value
|
||
)
|
||
{
|
||
size_t low = 0U;
|
||
size_t high = date_count;
|
||
|
||
while (low < high) {
|
||
size_t middle =
|
||
low + ((high - low) / 2U);
|
||
|
||
int comparison =
|
||
strcmp(dates[middle], value);
|
||
|
||
if (comparison < 0) {
|
||
low = middle + 1U;
|
||
} else {
|
||
high = middle;
|
||
}
|
||
}
|
||
|
||
return low < date_count
|
||
? low
|
||
: date_count - 1U;
|
||
}
|
||
|
||
static int normalized_graph_row(
|
||
double value,
|
||
double minimum,
|
||
double maximum,
|
||
int graph_top,
|
||
int graph_height
|
||
)
|
||
{
|
||
double ratio;
|
||
int row;
|
||
|
||
if (maximum <= minimum) {
|
||
return graph_top +
|
||
(graph_height / 2);
|
||
}
|
||
|
||
ratio =
|
||
(value - minimum) /
|
||
(maximum - minimum);
|
||
|
||
row =
|
||
graph_top +
|
||
graph_height -
|
||
1 -
|
||
(int)(
|
||
ratio *
|
||
(double)(graph_height - 1)
|
||
);
|
||
|
||
if (row < graph_top) {
|
||
row = graph_top;
|
||
}
|
||
|
||
if (row >
|
||
graph_top + graph_height - 1) {
|
||
row =
|
||
graph_top +
|
||
graph_height -
|
||
1;
|
||
}
|
||
|
||
return row;
|
||
}
|
||
|
||
/* TRAINLOG_DASHBOARD_GRAPH_ONLY */
|
||
|
||
/* TRAINLOG_DASHBOARD_12_MONTHS */
|
||
|
||
#define DASHBOARD_MONTH_COUNT 12U
|
||
|
||
typedef struct TrainlogDashboardMonth {
|
||
int year;
|
||
int month;
|
||
} TrainlogDashboardMonth;
|
||
|
||
typedef struct TrainlogDashboardMonthValue {
|
||
bool present;
|
||
double value;
|
||
} TrainlogDashboardMonthValue;
|
||
|
||
static bool dashboard_parse_year_month(
|
||
const char *timestamp,
|
||
int *year,
|
||
int *month
|
||
)
|
||
{
|
||
int parsed_year;
|
||
int parsed_month;
|
||
|
||
if (timestamp == NULL ||
|
||
year == NULL ||
|
||
month == NULL ||
|
||
strlen(timestamp) < 7U ||
|
||
timestamp[4] != '-' ||
|
||
timestamp[0] < '0' || timestamp[0] > '9' ||
|
||
timestamp[1] < '0' || timestamp[1] > '9' ||
|
||
timestamp[2] < '0' || timestamp[2] > '9' ||
|
||
timestamp[3] < '0' || timestamp[3] > '9' ||
|
||
timestamp[5] < '0' || timestamp[5] > '9' ||
|
||
timestamp[6] < '0' || timestamp[6] > '9') {
|
||
return false;
|
||
}
|
||
|
||
parsed_year =
|
||
((timestamp[0] - '0') * 1000) +
|
||
((timestamp[1] - '0') * 100) +
|
||
((timestamp[2] - '0') * 10) +
|
||
(timestamp[3] - '0');
|
||
|
||
parsed_month =
|
||
((timestamp[5] - '0') * 10) +
|
||
(timestamp[6] - '0');
|
||
|
||
if (parsed_year < 1 ||
|
||
parsed_month < 1 ||
|
||
parsed_month > 12) {
|
||
return false;
|
||
}
|
||
|
||
*year = parsed_year;
|
||
*month = parsed_month;
|
||
return true;
|
||
}
|
||
|
||
static long dashboard_month_key(
|
||
int year,
|
||
int month
|
||
)
|
||
{
|
||
return ((long)year * 12L) +
|
||
(long)(month - 1);
|
||
}
|
||
|
||
static void dashboard_month_from_key(
|
||
long key,
|
||
TrainlogDashboardMonth *output
|
||
)
|
||
{
|
||
long year;
|
||
long month_zero;
|
||
|
||
year = key / 12L;
|
||
month_zero = key % 12L;
|
||
|
||
if (month_zero < 0L) {
|
||
month_zero += 12L;
|
||
--year;
|
||
}
|
||
|
||
output->year = (int)year;
|
||
output->month = (int)month_zero + 1;
|
||
}
|
||
|
||
static bool dashboard_current_month_key(
|
||
long *output_key
|
||
)
|
||
{
|
||
time_t now;
|
||
struct tm local_time;
|
||
|
||
if (output_key == NULL) {
|
||
return false;
|
||
}
|
||
|
||
now = time(NULL);
|
||
if (now == (time_t)-1) {
|
||
return false;
|
||
}
|
||
|
||
if (localtime_r(&now, &local_time) == NULL) {
|
||
return false;
|
||
}
|
||
|
||
*output_key =
|
||
dashboard_month_key(
|
||
local_time.tm_year + 1900,
|
||
local_time.tm_mon + 1
|
||
);
|
||
|
||
return true;
|
||
}
|
||
|
||
typedef struct TrainlogSessionDraftExercise {
|
||
TrainlogSessionExerciseInput input;
|
||
TrainlogSetInput sets[MAX_SETS_PER_EXERCISE];
|
||
char name[TRAINLOG_NAME_MAX + 1U];
|
||
TrainlogTrackingMode tracking_mode;
|
||
char notes[TRAINLOG_NOTE_MAX + 1U];
|
||
/* Transient provenance only; the persisted plan stores the resulting kg. */
|
||
bool target_from_percent_max;
|
||
} TrainlogSessionDraftExercise;
|
||
|
||
static void draft_bind_input(
|
||
TrainlogSessionDraftExercise *draft
|
||
)
|
||
{
|
||
if (draft == NULL) {
|
||
return;
|
||
}
|
||
|
||
draft->input.sets =
|
||
draft->sets;
|
||
|
||
draft->input.notes =
|
||
draft->notes[0] != '\0'
|
||
? draft->notes
|
||
: NULL;
|
||
}
|
||
|
||
static void draft_format_set_metric(
|
||
const TrainlogSessionDraftExercise *draft,
|
||
const TrainlogSetInput *set,
|
||
char *output,
|
||
size_t output_size
|
||
)
|
||
{
|
||
if (draft->tracking_mode == TRAINLOG_TRACKING_DURATION) {
|
||
if (trainlog_duration_format(
|
||
set->duration_seconds,
|
||
output,
|
||
output_size
|
||
) != TRAINLOG_STATUS_OK) {
|
||
(void)snprintf(output, output_size, "%d s",
|
||
set->duration_seconds);
|
||
}
|
||
} else {
|
||
(void)snprintf(output, output_size, "%d", set->reps);
|
||
}
|
||
}
|
||
|
||
static void draft_delete_exercise(
|
||
TrainlogSessionDraftExercise *drafts,
|
||
size_t *count,
|
||
size_t selected
|
||
)
|
||
{
|
||
size_t index;
|
||
|
||
if (drafts == NULL ||
|
||
count == NULL ||
|
||
selected >= *count) {
|
||
return;
|
||
}
|
||
|
||
for (index = selected;
|
||
index + 1U < *count;
|
||
++index) {
|
||
drafts[index] =
|
||
drafts[index + 1U];
|
||
|
||
draft_bind_input(
|
||
&drafts[index]
|
||
);
|
||
}
|
||
|
||
--(*count);
|
||
|
||
if (*count < MAX_SESSION_EXERCISES) {
|
||
(void)memset(
|
||
&drafts[*count],
|
||
0,
|
||
sizeof(drafts[*count])
|
||
);
|
||
}
|
||
}
|
||
|
||
static void draft_set_summary(
|
||
const TrainlogSessionDraftExercise *draft,
|
||
char *output,
|
||
size_t output_size
|
||
)
|
||
{
|
||
size_t index;
|
||
size_t used = 0U;
|
||
|
||
if (draft == NULL ||
|
||
output == NULL ||
|
||
output_size == 0U) {
|
||
return;
|
||
}
|
||
|
||
if (draft->input.has_max_weight) {
|
||
char weight[32];
|
||
|
||
format_compact_max_weight(
|
||
draft->input.max_weight_kg,
|
||
weight,
|
||
sizeof(weight)
|
||
);
|
||
(void)snprintf(
|
||
output,
|
||
output_size,
|
||
"Max %s kg",
|
||
weight
|
||
);
|
||
} else if (draft->input.recording_mode ==
|
||
TRAINLOG_RECORDING_CONTINUOUS) {
|
||
char duration[64];
|
||
(void)trainlog_duration_format(
|
||
draft->input.continuous_duration_seconds,
|
||
duration,
|
||
sizeof(duration)
|
||
);
|
||
(void)snprintf(output, output_size, "Continu · %s", duration);
|
||
} else if (draft->input.set_count == 0U &&
|
||
draft->input.target_sets > 0 && draft->input.target_reps > 0) {
|
||
if (draft->input.target_has_weight) {
|
||
(void)snprintf(output, output_size,
|
||
"Plan %d×%d · %.2f kg · repos %d s · 0 réalisée",
|
||
draft->input.target_sets, draft->input.target_reps,
|
||
draft->input.target_weight_kg, draft->input.rest_seconds);
|
||
} else {
|
||
(void)snprintf(output, output_size,
|
||
"Plan %d×%d · charge absente · repos %d s · 0 réalisée",
|
||
draft->input.target_sets, draft->input.target_reps,
|
||
draft->input.rest_seconds);
|
||
}
|
||
} else {
|
||
int written = snprintf(output, output_size, "%zu séries · ",
|
||
draft->input.set_count);
|
||
if (written < 0) {
|
||
output[0] = '\0';
|
||
return;
|
||
}
|
||
used = (size_t)written < output_size
|
||
? (size_t)written : output_size - 1U;
|
||
|
||
/* CONTRACT: the in-progress summary reflects each actual row. It
|
||
* must not collapse heterogeneous loads back to the target weight. */
|
||
for (index = 0U; index < draft->input.set_count && used + 1U < output_size;
|
||
++index) {
|
||
char metric[64];
|
||
char fragment[96];
|
||
draft_format_set_metric(draft, &draft->sets[index], metric,
|
||
sizeof(metric));
|
||
if (draft->input.load_mode == TRAINLOG_LOAD_NONE) {
|
||
written = snprintf(fragment, sizeof(fragment), "%s%s",
|
||
index > 0U ? " / " : "", metric);
|
||
} else {
|
||
written = snprintf(fragment, sizeof(fragment), "%s%s×%s%s",
|
||
index > 0U ? " / " : "", metric,
|
||
draft->sets[index].has_weight ? "" : "—",
|
||
draft->sets[index].has_weight ? "kg" : "");
|
||
if (draft->sets[index].has_weight) {
|
||
written = snprintf(fragment, sizeof(fragment),
|
||
"%s%s×%.2fkg%s",
|
||
index > 0U ? " / " : "", metric,
|
||
draft->sets[index].weight_kg,
|
||
draft->input.load_mode == TRAINLOG_LOAD_ASSISTANCE
|
||
? " aide" : "");
|
||
}
|
||
}
|
||
if (written < 0) {
|
||
break;
|
||
}
|
||
(void)snprintf(output + used, output_size - used, "%s", fragment);
|
||
used = strlen(output);
|
||
}
|
||
}
|
||
}
|
||
|
||
static bool load_persisted_draft(
|
||
TrainlogDatabase *database,
|
||
const char *session_id,
|
||
TrainlogSessionSummary *session,
|
||
TrainlogSessionDraftExercise drafts[MAX_SESSION_EXERCISES],
|
||
size_t *draft_count
|
||
)
|
||
{
|
||
TrainlogEditableExerciseRecord
|
||
records[MAX_SESSION_EXERCISES];
|
||
|
||
TrainlogSetInput
|
||
sets[MAX_SESSION_EXERCISES *
|
||
MAX_SETS_PER_EXERCISE];
|
||
|
||
size_t record_count = 0U;
|
||
size_t set_count = 0U;
|
||
size_t index;
|
||
|
||
if (database == NULL ||
|
||
session_id == NULL ||
|
||
session == NULL ||
|
||
drafts == NULL ||
|
||
draft_count == NULL) {
|
||
return false;
|
||
}
|
||
|
||
if (trainlog_database_load_session_editable(
|
||
database,
|
||
session_id,
|
||
session,
|
||
records,
|
||
MAX_SESSION_EXERCISES,
|
||
&record_count,
|
||
sets,
|
||
MAX_SESSION_EXERCISES *
|
||
MAX_SETS_PER_EXERCISE,
|
||
&set_count
|
||
) != TRAINLOG_STATUS_OK) {
|
||
return false;
|
||
}
|
||
|
||
(void)set_count;
|
||
|
||
(void)memset(
|
||
drafts,
|
||
0,
|
||
sizeof(*drafts) *
|
||
MAX_SESSION_EXERCISES
|
||
);
|
||
|
||
for (index = 0U;
|
||
index < record_count;
|
||
++index) {
|
||
TrainlogSessionDraftExercise *draft =
|
||
&drafts[index];
|
||
|
||
const TrainlogEditableExerciseRecord *record =
|
||
&records[index];
|
||
|
||
if (record->set_count >
|
||
MAX_SETS_PER_EXERCISE) {
|
||
return false;
|
||
}
|
||
|
||
(void)snprintf(
|
||
draft->input.exercise_id,
|
||
sizeof(draft->input.exercise_id),
|
||
"%s",
|
||
record->exercise_id
|
||
);
|
||
|
||
(void)snprintf(
|
||
draft->name,
|
||
sizeof(draft->name),
|
||
"%s",
|
||
record->name
|
||
);
|
||
|
||
draft->tracking_mode =
|
||
record->tracking_mode;
|
||
|
||
draft->input.recording_mode =
|
||
record->recording_mode;
|
||
draft->input.data_fields =
|
||
record->data_fields;
|
||
|
||
draft->input.load_mode =
|
||
record->load_mode;
|
||
|
||
draft->input.rest_seconds =
|
||
record->rest_seconds;
|
||
|
||
draft->input.target_sets =
|
||
record->target_sets;
|
||
|
||
draft->input.target_reps =
|
||
record->target_reps;
|
||
|
||
draft->input.target_duration_seconds =
|
||
record->target_duration_seconds;
|
||
|
||
draft->input.target_has_weight =
|
||
record->has_target_weight != 0;
|
||
|
||
draft->input.target_weight_kg =
|
||
record->target_weight_kg;
|
||
|
||
draft->input.has_max_weight =
|
||
record->has_max_weight != 0;
|
||
draft->input.max_weight_kg =
|
||
record->max_weight_kg;
|
||
|
||
draft->input.continuous_duration_seconds =
|
||
record->continuous_duration_seconds;
|
||
draft->input.continuous_has_speed =
|
||
record->has_continuous_speed != 0;
|
||
draft->input.continuous_speed_kmh =
|
||
record->continuous_speed_kmh;
|
||
draft->input.continuous_has_distance =
|
||
record->has_continuous_distance != 0;
|
||
draft->input.continuous_distance_km =
|
||
record->continuous_distance_km;
|
||
|
||
(void)snprintf(
|
||
draft->input.entry_id,
|
||
sizeof(draft->input.entry_id),
|
||
"%s",
|
||
record->entry_id
|
||
);
|
||
(void)snprintf(
|
||
draft->input.equipment_id,
|
||
sizeof(draft->input.equipment_id),
|
||
"%s",
|
||
record->equipment_id
|
||
);
|
||
|
||
(void)snprintf(
|
||
draft->notes,
|
||
sizeof(draft->notes),
|
||
"%s",
|
||
record->notes
|
||
);
|
||
|
||
if (record->set_count > 0U) {
|
||
(void)memcpy(
|
||
draft->sets,
|
||
&sets[record->set_offset],
|
||
record->set_count *
|
||
sizeof(draft->sets[0])
|
||
);
|
||
}
|
||
|
||
draft->input.set_count =
|
||
record->set_count;
|
||
|
||
draft_bind_input(draft);
|
||
}
|
||
|
||
*draft_count =
|
||
record_count;
|
||
|
||
return true;
|
||
}
|
||
|
||
static bool persist_draft_replacement(
|
||
TrainlogDatabase *database,
|
||
const char *session_id,
|
||
TrainlogSessionDraftExercise drafts[MAX_SESSION_EXERCISES],
|
||
size_t count
|
||
)
|
||
{
|
||
TrainlogSessionExerciseInput
|
||
inputs[MAX_SESSION_EXERCISES];
|
||
|
||
size_t index;
|
||
|
||
for (index = 0U;
|
||
index < count;
|
||
++index) {
|
||
draft_bind_input(
|
||
&drafts[index]
|
||
);
|
||
|
||
inputs[index] =
|
||
drafts[index].input;
|
||
}
|
||
|
||
return trainlog_database_replace_session_exercises(
|
||
database,
|
||
session_id,
|
||
inputs,
|
||
count
|
||
) == TRAINLOG_STATUS_OK;
|
||
}
|
||
|
||
typedef struct TrainlogGeneratorPreviewItem {
|
||
char exercise_name[TRAINLOG_NAME_MAX + 1U];
|
||
char equipment_name[TRAINLOG_NAME_MAX + 1U];
|
||
char zone_name[TRAINLOG_NAME_MAX + 1U];
|
||
char movement_name[TRAINLOG_NAME_MAX + 1U];
|
||
} TrainlogGeneratorPreviewItem;
|
||
|
||
static const char *generator_goal_label(const char *goal_id)
|
||
{
|
||
if (strcmp(goal_id, "general") == 0) return "Général";
|
||
if (strcmp(goal_id, "strength") == 0) return "Force";
|
||
if (strcmp(goal_id, "hypertrophy") == 0) return "Hypertrophie";
|
||
if (strcmp(goal_id, "endurance") == 0) return "Endurance";
|
||
return goal_id;
|
||
}
|
||
|
||
static bool generator_prepare_preview(TrainlogDatabase *database,
|
||
const TrainlogGeneratedSession *session, TrainlogGeneratorPreviewItem *items)
|
||
{
|
||
size_t index;
|
||
for (index = 0U; index < session->exercise_count; ++index) {
|
||
TrainlogExercise exercise;
|
||
TrainlogResolvedEquipment equipment;
|
||
const TrainlogBodyZone *zone;
|
||
const TrainlogKnowledgeMovementPattern *pattern = NULL;
|
||
if (trainlog_database_get_exercise_profile(database,
|
||
session->exercises[index].exercise_id, &exercise) != TRAINLOG_STATUS_OK ||
|
||
trainlog_database_resolve_equipment(database,
|
||
session->exercises[index].equipment_id, &equipment) != TRAINLOG_STATUS_OK) return false;
|
||
zone = trainlog_body_zone_catalog_lookup(session->exercises[index].primary_zone_id);
|
||
if (session->exercises[index].pattern_count > 0U)
|
||
pattern = trainlog_knowledge_movement_pattern_lookup(session->exercises[index].pattern_ids[0]);
|
||
(void)snprintf(items[index].exercise_name, sizeof(items[index].exercise_name), "%s", exercise.name);
|
||
(void)snprintf(items[index].equipment_name, sizeof(items[index].equipment_name), "%s", equipment.display_name);
|
||
(void)snprintf(items[index].zone_name, sizeof(items[index].zone_name), "%s",
|
||
zone != NULL ? zone->display_name : session->exercises[index].primary_zone_id);
|
||
(void)snprintf(items[index].movement_name, sizeof(items[index].movement_name), "%s",
|
||
pattern != NULL ? pattern->display_name_fr : "mouvement non classé");
|
||
}
|
||
return true;
|
||
}
|
||
|
||
static bool generator_build_drafts(TrainlogDatabase *database,
|
||
const TrainlogGeneratedSession *session, TrainlogSessionDraftExercise *drafts)
|
||
{
|
||
size_t index;
|
||
for (index = 0U; index < session->exercise_count; ++index) {
|
||
const TrainlogGeneratedExercise *generated = &session->exercises[index];
|
||
TrainlogExercise exercise;
|
||
if (trainlog_database_get_exercise_profile(database, generated->exercise_id,
|
||
&exercise) != TRAINLOG_STATUS_OK) return false;
|
||
(void)memset(&drafts[index], 0, sizeof(drafts[index]));
|
||
(void)snprintf(drafts[index].input.exercise_id,
|
||
sizeof(drafts[index].input.exercise_id), "%s", generated->exercise_id);
|
||
(void)snprintf(drafts[index].input.equipment_id,
|
||
sizeof(drafts[index].input.equipment_id), "%s", generated->equipment_id);
|
||
(void)snprintf(drafts[index].name, sizeof(drafts[index].name), "%s", exercise.name);
|
||
drafts[index].tracking_mode = TRAINLOG_TRACKING_REPS;
|
||
drafts[index].input.recording_mode = TRAINLOG_RECORDING_SETS;
|
||
drafts[index].input.load_mode = generated->planned_load_mode;
|
||
drafts[index].input.target_sets = generated->target_sets;
|
||
drafts[index].input.target_reps = generated->target_repetitions;
|
||
drafts[index].input.rest_seconds = generated->rest_seconds;
|
||
drafts[index].input.target_has_weight = generated->has_target_weight;
|
||
drafts[index].input.target_weight_kg = generated->target_weight_kg;
|
||
/* INVARIANT: generated dose is a plan. No performed row exists until
|
||
* the normal editor records a real metric supplied by the user. */
|
||
drafts[index].input.set_count = 0U;
|
||
draft_bind_input(&drafts[index]);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
static TrainlogStatus persist_new_session_drafts(TrainlogDatabase *database,
|
||
TrainlogSessionDraftExercise *drafts, size_t exercise_count,
|
||
const char *started_at, TrainlogSessionType session_type)
|
||
{
|
||
TrainlogSessionExerciseInput *inputs;
|
||
TrainlogSessionInput session;
|
||
char session_id[TRAINLOG_GENERATED_ID_CAPACITY];
|
||
char ended_at[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||
TrainlogStatus status;
|
||
size_t index;
|
||
if (database == NULL || drafts == NULL || started_at == NULL ||
|
||
exercise_count == 0U ||
|
||
exercise_count > MAX_SESSION_EXERCISES) return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||
inputs = calloc(exercise_count, sizeof(*inputs));
|
||
if (inputs == NULL) return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||
if (trainlog_id_generate("se", session_id, sizeof(session_id)) != TRAINLOG_STATUS_OK ||
|
||
trainlog_time_now_rfc3339(ended_at, sizeof(ended_at)) != TRAINLOG_STATUS_OK) {
|
||
free(inputs);
|
||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||
}
|
||
for (index = 0U; index < exercise_count; ++index) {
|
||
draft_bind_input(&drafts[index]);
|
||
inputs[index] = drafts[index].input;
|
||
}
|
||
(void)memset(&session, 0, sizeof(session));
|
||
session.session_type = session_type;
|
||
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id);
|
||
(void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at);
|
||
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", ended_at);
|
||
session.exercises = inputs;
|
||
session.exercise_count = exercise_count;
|
||
status = trainlog_database_insert_session(database, &session);
|
||
free(inputs);
|
||
return status;
|
||
}
|
||
|
||
static void body_short_date(
|
||
const char *timestamp,
|
||
char output[9]
|
||
)
|
||
{
|
||
if (timestamp == NULL ||
|
||
strlen(timestamp) < 10U) {
|
||
(void)snprintf(
|
||
output,
|
||
9U,
|
||
"%s",
|
||
"--/--/--"
|
||
);
|
||
|
||
return;
|
||
}
|
||
|
||
(void)snprintf(
|
||
output,
|
||
9U,
|
||
"%c%c/%c%c/%c%c",
|
||
timestamp[8],
|
||
timestamp[9],
|
||
timestamp[5],
|
||
timestamp[6],
|
||
timestamp[2],
|
||
timestamp[3]
|
||
);
|
||
}
|
||
|
||
static size_t body_record_metric_count(
|
||
const TrainlogBodyObservationRecord *record
|
||
)
|
||
{
|
||
size_t count = 0U;
|
||
|
||
if (record == NULL) {
|
||
return 0U;
|
||
}
|
||
|
||
#define COUNT_BODY_VALUE(flag_) \
|
||
do { \
|
||
if ((flag_)) { \
|
||
++count; \
|
||
} \
|
||
} while (0)
|
||
|
||
COUNT_BODY_VALUE(record->has_body_weight);
|
||
COUNT_BODY_VALUE(record->has_neck);
|
||
COUNT_BODY_VALUE(record->has_shoulders);
|
||
COUNT_BODY_VALUE(record->has_chest);
|
||
COUNT_BODY_VALUE(record->has_waist);
|
||
COUNT_BODY_VALUE(record->has_hips);
|
||
COUNT_BODY_VALUE(record->has_left_arm);
|
||
COUNT_BODY_VALUE(record->has_right_arm);
|
||
COUNT_BODY_VALUE(record->has_left_forearm);
|
||
COUNT_BODY_VALUE(record->has_right_forearm);
|
||
COUNT_BODY_VALUE(record->has_left_thigh);
|
||
COUNT_BODY_VALUE(record->has_right_thigh);
|
||
COUNT_BODY_VALUE(record->has_left_calf);
|
||
COUNT_BODY_VALUE(record->has_right_calf);
|
||
|
||
#undef COUNT_BODY_VALUE
|
||
|
||
return count;
|
||
}
|
||
|
||
static void body_summary_text(
|
||
const TrainlogBodyObservationRecord *record,
|
||
char *output,
|
||
size_t output_size
|
||
)
|
||
{
|
||
char weight[24];
|
||
char waist[24];
|
||
char chest[24];
|
||
size_t metric_count;
|
||
|
||
if (record == NULL ||
|
||
output == NULL ||
|
||
output_size == 0U) {
|
||
return;
|
||
}
|
||
|
||
if (record->has_body_weight) {
|
||
(void)snprintf(
|
||
weight,
|
||
sizeof(weight),
|
||
"%.1f kg",
|
||
record->body_weight_kg
|
||
);
|
||
} else {
|
||
(void)snprintf(
|
||
weight,
|
||
sizeof(weight),
|
||
"%s",
|
||
"— kg"
|
||
);
|
||
}
|
||
|
||
if (record->has_waist) {
|
||
(void)snprintf(
|
||
waist,
|
||
sizeof(waist),
|
||
"taille %.1f",
|
||
record->waist_cm
|
||
);
|
||
} else {
|
||
(void)snprintf(
|
||
waist,
|
||
sizeof(waist),
|
||
"%s",
|
||
"taille —"
|
||
);
|
||
}
|
||
|
||
if (record->has_chest) {
|
||
(void)snprintf(
|
||
chest,
|
||
sizeof(chest),
|
||
"poitrine %.1f",
|
||
record->chest_cm
|
||
);
|
||
} else {
|
||
(void)snprintf(
|
||
chest,
|
||
sizeof(chest),
|
||
"%s",
|
||
"poitrine —"
|
||
);
|
||
}
|
||
|
||
metric_count =
|
||
body_record_metric_count(record);
|
||
|
||
(void)snprintf(
|
||
output,
|
||
output_size,
|
||
"%-9s %-9s %-14s %2zu valeur(s)",
|
||
weight,
|
||
waist,
|
||
chest,
|
||
metric_count
|
||
);
|
||
}
|
||
|
||
static void body_input_from_record(
|
||
const TrainlogBodyObservationRecord *record,
|
||
TrainlogBodyObservationInput *input
|
||
)
|
||
{
|
||
if (record == NULL ||
|
||
input == NULL) {
|
||
return;
|
||
}
|
||
|
||
(void)memset(
|
||
input,
|
||
0,
|
||
sizeof(*input)
|
||
);
|
||
|
||
(void)snprintf(
|
||
input->observation_id,
|
||
sizeof(input->observation_id),
|
||
"%s",
|
||
record->observation_id
|
||
);
|
||
|
||
(void)snprintf(
|
||
input->observed_at,
|
||
sizeof(input->observed_at),
|
||
"%s",
|
||
record->observed_at
|
||
);
|
||
|
||
input->has_body_weight =
|
||
record->has_body_weight;
|
||
|
||
input->body_weight_kg =
|
||
record->body_weight_kg;
|
||
|
||
input->has_neck =
|
||
record->has_neck;
|
||
|
||
input->neck_cm =
|
||
record->neck_cm;
|
||
|
||
input->has_shoulders =
|
||
record->has_shoulders;
|
||
|
||
input->shoulders_cm =
|
||
record->shoulders_cm;
|
||
|
||
input->has_chest =
|
||
record->has_chest;
|
||
|
||
input->chest_cm =
|
||
record->chest_cm;
|
||
|
||
input->has_waist =
|
||
record->has_waist;
|
||
|
||
input->waist_cm =
|
||
record->waist_cm;
|
||
|
||
input->has_hips =
|
||
record->has_hips;
|
||
|
||
input->hips_cm =
|
||
record->hips_cm;
|
||
|
||
input->has_left_arm =
|
||
record->has_left_arm;
|
||
|
||
input->left_arm_cm =
|
||
record->left_arm_cm;
|
||
|
||
input->has_right_arm =
|
||
record->has_right_arm;
|
||
|
||
input->right_arm_cm =
|
||
record->right_arm_cm;
|
||
|
||
input->has_left_forearm =
|
||
record->has_left_forearm;
|
||
|
||
input->left_forearm_cm =
|
||
record->left_forearm_cm;
|
||
|
||
input->has_right_forearm =
|
||
record->has_right_forearm;
|
||
|
||
input->right_forearm_cm =
|
||
record->right_forearm_cm;
|
||
|
||
input->has_left_thigh =
|
||
record->has_left_thigh;
|
||
|
||
input->left_thigh_cm =
|
||
record->left_thigh_cm;
|
||
|
||
input->has_right_thigh =
|
||
record->has_right_thigh;
|
||
|
||
input->right_thigh_cm =
|
||
record->right_thigh_cm;
|
||
|
||
input->has_left_calf =
|
||
record->has_left_calf;
|
||
|
||
input->left_calf_cm =
|
||
record->left_calf_cm;
|
||
|
||
input->has_right_calf =
|
||
record->has_right_calf;
|
||
|
||
input->right_calf_cm =
|
||
record->right_calf_cm;
|
||
|
||
input->notes =
|
||
record->notes[0] != '\0'
|
||
? record->notes
|
||
: NULL;
|
||
}
|
||
|
||
/* TRAINLOG_BODY_ANALYTICS_TUI_V1 */
|
||
|
||
static bool body_analytics_profile_path(
|
||
char *output,
|
||
size_t output_size,
|
||
bool ensure_directory
|
||
)
|
||
{
|
||
const char *config_home =
|
||
getenv(
|
||
"XDG_CONFIG_HOME"
|
||
);
|
||
|
||
const char *home =
|
||
getenv(
|
||
"HOME"
|
||
);
|
||
|
||
char base[
|
||
PATH_MAX + 1U
|
||
];
|
||
|
||
char directory[
|
||
PATH_MAX + 1U
|
||
];
|
||
|
||
int written;
|
||
|
||
if (
|
||
output == NULL ||
|
||
output_size == 0U
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
if (
|
||
config_home != NULL &&
|
||
config_home[0] != '\0'
|
||
) {
|
||
written =
|
||
snprintf(
|
||
base,
|
||
sizeof(base),
|
||
"%s",
|
||
config_home
|
||
);
|
||
} else if (
|
||
home != NULL &&
|
||
home[0] != '\0'
|
||
) {
|
||
written =
|
||
snprintf(
|
||
base,
|
||
sizeof(base),
|
||
"%s/.config",
|
||
home
|
||
);
|
||
|
||
if (
|
||
written >= 0 &&
|
||
(size_t)written <
|
||
sizeof(base) &&
|
||
ensure_directory
|
||
) {
|
||
(void)mkdir(
|
||
base,
|
||
0700
|
||
);
|
||
}
|
||
} else {
|
||
return false;
|
||
}
|
||
|
||
if (
|
||
written < 0 ||
|
||
(size_t)written >=
|
||
sizeof(base)
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
written =
|
||
snprintf(
|
||
directory,
|
||
sizeof(directory),
|
||
"%s/trainlog",
|
||
base
|
||
);
|
||
|
||
if (
|
||
written < 0 ||
|
||
(size_t)written >=
|
||
sizeof(directory)
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
if (ensure_directory) {
|
||
(void)mkdir(
|
||
directory,
|
||
0700
|
||
);
|
||
}
|
||
|
||
written =
|
||
snprintf(
|
||
output,
|
||
output_size,
|
||
"%s/body_analytics.conf",
|
||
directory
|
||
);
|
||
|
||
return
|
||
written >= 0 &&
|
||
(size_t)written <
|
||
output_size;
|
||
}
|
||
|
||
static bool body_analytics_profile_load(
|
||
TrainlogBodyAnalyticsProfile *output
|
||
)
|
||
{
|
||
char path[
|
||
PATH_MAX + 1U
|
||
];
|
||
|
||
char line[128];
|
||
int version = 0;
|
||
int formula = -1;
|
||
double height_cm = 0.0;
|
||
FILE *file;
|
||
|
||
if (
|
||
output == NULL ||
|
||
!body_analytics_profile_path(
|
||
path,
|
||
sizeof(path),
|
||
false
|
||
)
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
file =
|
||
fopen(
|
||
path,
|
||
"rb"
|
||
);
|
||
|
||
if (file == NULL) {
|
||
return false;
|
||
}
|
||
|
||
while (
|
||
fgets(
|
||
line,
|
||
sizeof(line),
|
||
file
|
||
) != NULL
|
||
) {
|
||
if (
|
||
sscanf(
|
||
line,
|
||
"version=%d",
|
||
&version
|
||
) == 1
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
if (
|
||
strcmp(
|
||
line,
|
||
"formula=male\n"
|
||
) == 0 ||
|
||
strcmp(
|
||
line,
|
||
"formula=male"
|
||
) == 0
|
||
) {
|
||
formula =
|
||
(int)
|
||
TRAINLOG_BODY_ANALYTICS_FORMULA_MALE;
|
||
|
||
continue;
|
||
}
|
||
|
||
if (
|
||
strcmp(
|
||
line,
|
||
"formula=female\n"
|
||
) == 0 ||
|
||
strcmp(
|
||
line,
|
||
"formula=female"
|
||
) == 0
|
||
) {
|
||
formula =
|
||
(int)
|
||
TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE;
|
||
|
||
continue;
|
||
}
|
||
|
||
(void)sscanf(
|
||
line,
|
||
"height_cm=%lf",
|
||
&height_cm
|
||
);
|
||
}
|
||
|
||
(void)fclose(
|
||
file
|
||
);
|
||
|
||
if (
|
||
version != 1 ||
|
||
formula < 0
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
output->formula =
|
||
(TrainlogBodyAnalyticsFormula)
|
||
formula;
|
||
|
||
output->height_cm =
|
||
height_cm;
|
||
|
||
return
|
||
trainlog_body_analytics_profile_valid(
|
||
output
|
||
);
|
||
}
|
||
|
||
static bool body_analytics_profile_save(
|
||
const TrainlogBodyAnalyticsProfile *profile
|
||
)
|
||
{
|
||
char path[
|
||
PATH_MAX + 1U
|
||
];
|
||
|
||
FILE *file;
|
||
|
||
if (
|
||
!trainlog_body_analytics_profile_valid(
|
||
profile
|
||
) ||
|
||
!body_analytics_profile_path(
|
||
path,
|
||
sizeof(path),
|
||
true
|
||
)
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
file =
|
||
fopen(
|
||
path,
|
||
"wb"
|
||
);
|
||
|
||
if (file == NULL) {
|
||
return false;
|
||
}
|
||
|
||
if (
|
||
fprintf(
|
||
file,
|
||
"version=1\n"
|
||
"formula=%s\n"
|
||
"height_cm=%.2f\n",
|
||
profile->formula ==
|
||
TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE
|
||
? "female"
|
||
: "male",
|
||
profile->height_cm
|
||
) < 0
|
||
) {
|
||
(void)fclose(
|
||
file
|
||
);
|
||
|
||
return false;
|
||
}
|
||
|
||
return
|
||
fclose(
|
||
file
|
||
) == 0;
|
||
}
|
||
|
||
static const TrainlogBodyObservationRecord *
|
||
body_analytics_oldest_weight(
|
||
const TrainlogBodyObservationRecord *records,
|
||
size_t count
|
||
)
|
||
{
|
||
size_t index;
|
||
|
||
if (
|
||
records == NULL ||
|
||
count == 0U
|
||
) {
|
||
return NULL;
|
||
}
|
||
|
||
for (
|
||
index = count;
|
||
index > 0U;
|
||
--index
|
||
) {
|
||
if (
|
||
records[index - 1U]
|
||
.has_body_weight
|
||
) {
|
||
return
|
||
&records[index - 1U];
|
||
}
|
||
}
|
||
|
||
return NULL;
|
||
}
|
||
|
||
static const TrainlogBodyObservationRecord *
|
||
body_analytics_oldest_waist(
|
||
const TrainlogBodyObservationRecord *records,
|
||
size_t count
|
||
)
|
||
{
|
||
size_t index;
|
||
|
||
if (
|
||
records == NULL ||
|
||
count == 0U
|
||
) {
|
||
return NULL;
|
||
}
|
||
|
||
for (
|
||
index = count;
|
||
index > 0U;
|
||
--index
|
||
) {
|
||
if (
|
||
records[index - 1U]
|
||
.has_waist
|
||
) {
|
||
return
|
||
&records[index - 1U];
|
||
}
|
||
}
|
||
|
||
return NULL;
|
||
}
|
||
|
||
static bool body_analytics_oldest_estimate(
|
||
const TrainlogBodyAnalyticsProfile *profile,
|
||
const TrainlogBodyObservationRecord *records,
|
||
size_t count,
|
||
TrainlogBodyAnalyticsResult *output
|
||
)
|
||
{
|
||
size_t index;
|
||
|
||
if (
|
||
profile == NULL ||
|
||
records == NULL ||
|
||
output == NULL
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
for (
|
||
index = count;
|
||
index > 0U;
|
||
--index
|
||
) {
|
||
TrainlogBodyAnalyticsResult
|
||
candidate;
|
||
|
||
if (
|
||
trainlog_body_analytics_calculate(
|
||
profile,
|
||
&records[index - 1U],
|
||
&candidate
|
||
) ==
|
||
TRAINLOG_STATUS_OK &&
|
||
candidate
|
||
.has_body_fat_estimate
|
||
) {
|
||
*output =
|
||
candidate;
|
||
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/* TRAINLOG_SYNC_TUI */
|
||
/* TRAINLOG_SHARED_SYNC_ENGINE_V1 */
|
||
|
||
#define SYNC_HISTORY_CAPACITY 64U
|
||
#define SYNC_DETAIL_LINE_CAPACITY 160U
|
||
#define SYNC_DETAIL_TEXT_CAPACITY 16384U
|
||
|
||
static void session_history_datetime(
|
||
const char *timestamp,
|
||
char output[17]
|
||
)
|
||
{
|
||
if (
|
||
timestamp == NULL ||
|
||
strlen(timestamp) < 16U
|
||
) {
|
||
(void)snprintf(
|
||
output,
|
||
17U,
|
||
"%s",
|
||
"--/--/---- --:--"
|
||
);
|
||
|
||
return;
|
||
}
|
||
|
||
(void)snprintf(
|
||
output,
|
||
17U,
|
||
"%c%c/%c%c/%c%c%c%c %c%c:%c%c",
|
||
timestamp[8],
|
||
timestamp[9],
|
||
timestamp[5],
|
||
timestamp[6],
|
||
timestamp[0],
|
||
timestamp[1],
|
||
timestamp[2],
|
||
timestamp[3],
|
||
timestamp[11],
|
||
timestamp[12],
|
||
timestamp[14],
|
||
timestamp[15]
|
||
);
|
||
}
|
||
|
||
static bool sync_history_path(
|
||
char *output,
|
||
size_t output_size
|
||
)
|
||
{
|
||
const char *data_home =
|
||
getenv(
|
||
"XDG_DATA_HOME"
|
||
);
|
||
|
||
const char *home =
|
||
getenv(
|
||
"HOME"
|
||
);
|
||
|
||
int written;
|
||
|
||
if (
|
||
output == NULL ||
|
||
output_size == 0U
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
if (
|
||
data_home != NULL &&
|
||
data_home[0] != '\0'
|
||
) {
|
||
written =
|
||
snprintf(
|
||
output,
|
||
output_size,
|
||
"%s/trainlog/sync_history.log",
|
||
data_home
|
||
);
|
||
} else if (
|
||
home != NULL &&
|
||
home[0] != '\0'
|
||
) {
|
||
written =
|
||
snprintf(
|
||
output,
|
||
output_size,
|
||
"%s/.local/share/trainlog/sync_history.log",
|
||
home
|
||
);
|
||
} else {
|
||
return false;
|
||
}
|
||
|
||
return
|
||
written >= 0 &&
|
||
(size_t)written <
|
||
output_size;
|
||
}
|
||
|
||
static bool sync_runs_path(
|
||
const char *sync_id,
|
||
char *output,
|
||
size_t output_size
|
||
)
|
||
{
|
||
const char *data_home =
|
||
getenv(
|
||
"XDG_DATA_HOME"
|
||
);
|
||
|
||
const char *home =
|
||
getenv(
|
||
"HOME"
|
||
);
|
||
|
||
int written;
|
||
|
||
if (
|
||
sync_id == NULL ||
|
||
sync_id[0] == '\0' ||
|
||
output == NULL ||
|
||
output_size == 0U
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
if (
|
||
data_home != NULL &&
|
||
data_home[0] != '\0'
|
||
) {
|
||
written =
|
||
snprintf(
|
||
output,
|
||
output_size,
|
||
"%s/trainlog/sync_runs/%s.txt",
|
||
data_home,
|
||
sync_id
|
||
);
|
||
} else if (
|
||
home != NULL &&
|
||
home[0] != '\0'
|
||
) {
|
||
written =
|
||
snprintf(
|
||
output,
|
||
output_size,
|
||
"%s/.local/share/trainlog/sync_runs/%s.txt",
|
||
home,
|
||
sync_id
|
||
);
|
||
} else {
|
||
return false;
|
||
}
|
||
|
||
return
|
||
written >= 0 &&
|
||
(size_t)written <
|
||
output_size;
|
||
}
|
||
|
||
static void sync_history_load(
|
||
TrainlogSyncHistoryEntry *output,
|
||
size_t capacity,
|
||
size_t *output_count
|
||
)
|
||
{
|
||
char path[
|
||
PATH_MAX + 1U
|
||
];
|
||
|
||
TrainlogSyncHistoryEntry
|
||
ring[SYNC_HISTORY_CAPACITY];
|
||
|
||
size_t count = 0U;
|
||
size_t next = 0U;
|
||
size_t copied;
|
||
FILE *file;
|
||
char line[768];
|
||
|
||
if (
|
||
output_count == NULL ||
|
||
(
|
||
capacity > 0U &&
|
||
output == NULL
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
*output_count = 0U;
|
||
|
||
if (
|
||
!sync_history_path(
|
||
path,
|
||
sizeof(path)
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
file =
|
||
fopen(
|
||
path,
|
||
"rb"
|
||
);
|
||
|
||
if (file == NULL) {
|
||
return;
|
||
}
|
||
|
||
(void)memset(
|
||
ring,
|
||
0,
|
||
sizeof(ring)
|
||
);
|
||
|
||
while (
|
||
fgets(
|
||
line,
|
||
sizeof(line),
|
||
file
|
||
) != NULL
|
||
) {
|
||
TrainlogSyncHistoryEntry *entry;
|
||
|
||
if (
|
||
strchr(line, '\n') == NULL &&
|
||
!feof(file)
|
||
) {
|
||
int discarded;
|
||
|
||
do {
|
||
discarded = fgetc(file);
|
||
} while (discarded != '\n' && discarded != EOF);
|
||
|
||
continue;
|
||
}
|
||
|
||
entry =
|
||
&ring[next];
|
||
|
||
if (
|
||
!trainlog_sync_history_parse_line(
|
||
line,
|
||
entry
|
||
)
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
next =
|
||
(
|
||
next + 1U
|
||
) %
|
||
SYNC_HISTORY_CAPACITY;
|
||
|
||
if (
|
||
count <
|
||
SYNC_HISTORY_CAPACITY
|
||
) {
|
||
++count;
|
||
}
|
||
}
|
||
|
||
(void)fclose(
|
||
file
|
||
);
|
||
|
||
copied =
|
||
count < capacity
|
||
? count
|
||
: capacity;
|
||
|
||
for (
|
||
size_t index = 0U;
|
||
index < copied;
|
||
++index
|
||
) {
|
||
size_t source =
|
||
(
|
||
next +
|
||
SYNC_HISTORY_CAPACITY -
|
||
1U -
|
||
index
|
||
) %
|
||
SYNC_HISTORY_CAPACITY;
|
||
|
||
output[index] =
|
||
ring[source];
|
||
}
|
||
|
||
*output_count =
|
||
copied;
|
||
}
|
||
|
||
static double sync_bytes_to_gib(
|
||
uint64_t bytes
|
||
)
|
||
{
|
||
return
|
||
(double)bytes /
|
||
(
|
||
1024.0 *
|
||
1024.0 *
|
||
1024.0
|
||
);
|
||
}
|
||
|
||
typedef enum TrainlogSessionPhase {
|
||
TRAINLOG_SESSION_IDLE = 0,
|
||
TRAINLOG_SESSION_CHOOSE_TYPE,
|
||
TRAINLOG_SESSION_EXERCISE_PICKER,
|
||
TRAINLOG_SESSION_EQUIPMENT_PICKER,
|
||
TRAINLOG_SESSION_EQUIPMENT_CREATE,
|
||
TRAINLOG_SESSION_PLANNING,
|
||
TRAINLOG_SESSION_DRAFT,
|
||
TRAINLOG_SESSION_ACTUALS,
|
||
TRAINLOG_SESSION_GENERATOR_CONFIG,
|
||
TRAINLOG_SESSION_GENERATOR_WARNING,
|
||
TRAINLOG_SESSION_GENERATOR_PREVIEW,
|
||
TRAINLOG_SESSION_CONFIRM_REMOVE,
|
||
TRAINLOG_SESSION_CONFIRM_SET_REMOVE,
|
||
TRAINLOG_SESSION_CONFIRM_ABANDON,
|
||
TRAINLOG_SESSION_CONFIRM_LEAVE,
|
||
TRAINLOG_SESSION_MESSAGE
|
||
} TrainlogSessionPhase;
|
||
|
||
typedef enum TrainlogSessionFormPurpose {
|
||
TRAINLOG_SESSION_FORM_NONE = 0,
|
||
TRAINLOG_SESSION_FORM_SET_METRIC,
|
||
TRAINLOG_SESSION_FORM_SET_WEIGHT,
|
||
TRAINLOG_SESSION_FORM_CONTINUOUS_DURATION,
|
||
TRAINLOG_SESSION_FORM_CONTINUOUS_SPEED,
|
||
TRAINLOG_SESSION_FORM_CONTINUOUS_DISTANCE,
|
||
TRAINLOG_SESSION_FORM_TARGET_SETS,
|
||
TRAINLOG_SESSION_FORM_TARGET_REPS,
|
||
TRAINLOG_SESSION_FORM_TARGET_DURATION,
|
||
TRAINLOG_SESSION_FORM_REST,
|
||
TRAINLOG_SESSION_FORM_TARGET_WEIGHT,
|
||
TRAINLOG_SESSION_FORM_TARGET_PERCENT_MAX,
|
||
TRAINLOG_SESSION_FORM_EQUIPMENT_NAME,
|
||
TRAINLOG_SESSION_FORM_EQUIPMENT_LABEL,
|
||
TRAINLOG_SESSION_FORM_EQUIPMENT_TYPE,
|
||
TRAINLOG_SESSION_FORM_EQUIPMENT_LOAD,
|
||
TRAINLOG_SESSION_FORM_GENERATED_SETS,
|
||
TRAINLOG_SESSION_FORM_GENERATED_REPS,
|
||
TRAINLOG_SESSION_FORM_GENERATED_PERCENT_MAX,
|
||
TRAINLOG_SESSION_FORM_MAX_WEIGHT
|
||
} TrainlogSessionFormPurpose;
|
||
|
||
typedef struct TrainlogSessionController {
|
||
/* CONTRACT: desktop draft durability is exactly one process run. Route
|
||
* changes retain this controller; only explicit discard clears it. */
|
||
bool has_draft;
|
||
bool dirty;
|
||
bool generated_preview;
|
||
bool generation_warning_acknowledged;
|
||
bool correcting;
|
||
bool replacing_occurrence;
|
||
bool destructive_confirm_selected;
|
||
TrainlogSessionDraftExercise drafts[MAX_SESSION_EXERCISES];
|
||
size_t draft_count;
|
||
size_t selected;
|
||
size_t set_selected;
|
||
size_t set_field;
|
||
size_t planning_field;
|
||
TrainlogSessionType session_type;
|
||
TrainlogSessionPhase phase;
|
||
TrainlogSessionPhase return_phase;
|
||
TrainlogSessionFormPurpose form_purpose;
|
||
TrainlogFormField form;
|
||
TrainlogSetInput pending_set;
|
||
int pending_generated_sets;
|
||
TrainlogExercise picker[MAX_EXERCISES];
|
||
size_t picker_count;
|
||
size_t picker_selected;
|
||
size_t equipment_count;
|
||
size_t equipment_selected;
|
||
TrainlogCustomEquipment pending_equipment;
|
||
char started_at[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||
char generation_reference_time[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||
char persisted_session_id[TRAINLOG_ID_MAX + 1U];
|
||
char message[192];
|
||
TrainlogAppRoute pending_route;
|
||
TrainlogGeneratedSession generated;
|
||
TrainlogGeneratorPreviewItem generated_items[TRAINLOG_GENERATOR_MAX_SELECTED];
|
||
const TrainlogBodyZone *generation_zone;
|
||
const TrainlogSessionGenerationGoalPolicy *generation_goal;
|
||
size_t generation_zone_index;
|
||
size_t generation_goal_index;
|
||
size_t generation_duration_index;
|
||
int generation_duration_minutes;
|
||
TrainlogDurabilityState durability;
|
||
} TrainlogSessionController;
|
||
|
||
/* WHY: compiler-visible source buffers are wider than the shell route field.
|
||
* CONTRACT: stable identities are copied whole or rejected, never truncated.
|
||
* INVARIANT: a failed copy leaves an empty, terminated destination. */
|
||
static bool copy_ui_stable_id(char *output, size_t capacity, const char *value)
|
||
{
|
||
size_t length;
|
||
if (output == NULL || capacity == 0U || value == NULL) return false;
|
||
length = strlen(value);
|
||
if (length >= capacity) { output[0] = '\0'; return false; }
|
||
(void)memcpy(output, value, length + 1U);
|
||
return true;
|
||
}
|
||
|
||
typedef enum TrainlogEquipmentPhase {
|
||
TRAINLOG_EQUIPMENT_IDLE = 0,
|
||
TRAINLOG_EQUIPMENT_NAME,
|
||
TRAINLOG_EQUIPMENT_LABEL,
|
||
TRAINLOG_EQUIPMENT_TYPE,
|
||
TRAINLOG_EQUIPMENT_LOAD,
|
||
TRAINLOG_EQUIPMENT_CONFIRM_DISCARD,
|
||
TRAINLOG_EQUIPMENT_MESSAGE
|
||
} TrainlogEquipmentPhase;
|
||
|
||
typedef struct TrainlogEquipmentController {
|
||
/* INVARIANT: this controller is the sole transient owner of a catalogue
|
||
* equipment definition. Invalid submissions retain every raw field. */
|
||
TrainlogEquipmentPhase phase;
|
||
TrainlogEquipmentPhase return_phase;
|
||
TrainlogFormField form;
|
||
TrainlogCustomEquipment pending;
|
||
int load_choice;
|
||
bool dirty;
|
||
bool leaving;
|
||
TrainlogAppRoute pending_route;
|
||
char message[192];
|
||
} TrainlogEquipmentController;
|
||
|
||
typedef enum TrainlogExercisePhase {
|
||
TRAINLOG_EXERCISE_IDLE = 0,
|
||
TRAINLOG_EXERCISE_NAME,
|
||
TRAINLOG_EXERCISE_TRACKING,
|
||
TRAINLOG_EXERCISE_RECORDING,
|
||
TRAINLOG_EXERCISE_SPEED,
|
||
TRAINLOG_EXERCISE_DISTANCE,
|
||
TRAINLOG_EXERCISE_ZONES,
|
||
TRAINLOG_EXERCISE_MERGE_PICKER,
|
||
TRAINLOG_EXERCISE_MERGE_CONFIRM,
|
||
TRAINLOG_EXERCISE_MERGE_RESULT,
|
||
TRAINLOG_EXERCISE_CONFIRM_DISCARD,
|
||
TRAINLOG_EXERCISE_MESSAGE
|
||
} TrainlogExercisePhase;
|
||
|
||
typedef struct TrainlogExerciseController {
|
||
/* CONTRACT: creation/editing owns one transient value set. An edit keeps
|
||
* exercise_id and passes the stored profile back unchanged; referenced
|
||
* profile restrictions remain enforced by the database transaction. */
|
||
TrainlogExercisePhase phase;
|
||
TrainlogExercisePhase return_phase;
|
||
TrainlogFormField form;
|
||
TrainlogExercise pending;
|
||
char primary_zone[TRAINLOG_ZONE_ID_MAX + 1U];
|
||
char secondary_zones[MAX_BODY_ZONES][TRAINLOG_ZONE_ID_MAX + 1U];
|
||
size_t secondary_count;
|
||
size_t zone_selected;
|
||
bool editing;
|
||
bool inline_session;
|
||
bool dirty;
|
||
bool leaving;
|
||
TrainlogAppRoute pending_route;
|
||
char message[192];
|
||
TrainlogExercise merge_candidates[MAX_EXERCISES];
|
||
size_t merge_count;
|
||
size_t merge_selected;
|
||
TrainlogExercise merge_source;
|
||
TrainlogExercise merge_target;
|
||
TrainlogExerciseMergePreview merge_preview;
|
||
} TrainlogExerciseController;
|
||
|
||
typedef enum TrainlogBodyPhase {
|
||
TRAINLOG_BODY_IDLE = 0,
|
||
TRAINLOG_BODY_FORM,
|
||
TRAINLOG_BODY_CONFIRM_DISCARD,
|
||
TRAINLOG_BODY_MESSAGE
|
||
} TrainlogBodyPhase;
|
||
|
||
typedef struct TrainlogBodyController {
|
||
/* CONTRACT: one controller owns all fourteen raw metric fields until one
|
||
* insert/update succeeds. Editing copies the immutable record identity,
|
||
* timestamp and session link and never permits those fields to drift. */
|
||
TrainlogBodyPhase phase;
|
||
TrainlogFormField form;
|
||
TrainlogBodyObservationInput pending;
|
||
char session_id[TRAINLOG_ID_MAX + 1U];
|
||
char notes[TRAINLOG_NOTE_MAX + 1U];
|
||
size_t field;
|
||
bool editing;
|
||
bool dirty;
|
||
bool leaving;
|
||
TrainlogAppRoute pending_route;
|
||
char message[192];
|
||
} TrainlogBodyController;
|
||
|
||
typedef enum TrainlogProfilePhase {
|
||
TRAINLOG_PROFILE_IDLE = 0,
|
||
TRAINLOG_PROFILE_FORMULA,
|
||
TRAINLOG_PROFILE_HEIGHT,
|
||
TRAINLOG_PROFILE_CONFIRM_DISCARD,
|
||
TRAINLOG_PROFILE_MESSAGE
|
||
} TrainlogProfilePhase;
|
||
|
||
typedef struct TrainlogProfileController {
|
||
TrainlogProfilePhase phase;
|
||
TrainlogProfilePhase return_phase;
|
||
TrainlogFormField form;
|
||
TrainlogBodyAnalyticsProfile pending;
|
||
TrainlogAppRoute caller;
|
||
bool dirty;
|
||
bool leaving;
|
||
TrainlogAppRoute pending_route;
|
||
char message[192];
|
||
} TrainlogProfileController;
|
||
|
||
typedef TrainlogStatus (*TrainlogAppSyncProbe)(TrainlogSyncDeviceInfo *output);
|
||
typedef TrainlogStatus (*TrainlogAppSyncRun)(TrainlogSyncTrigger trigger,
|
||
bool require_request, TrainlogSyncDirection direction,
|
||
TrainlogSyncReport *output);
|
||
|
||
typedef struct TrainlogSyncController {
|
||
/* Navigation only reads cached local journal state. Device discovery and
|
||
* the synchronous engine are invoked solely by explicit controller keys. */
|
||
TrainlogSyncScreenState action;
|
||
TrainlogSyncDeviceInfo device;
|
||
TrainlogStatus probe_status;
|
||
bool probe_known;
|
||
TrainlogSyncReport report;
|
||
bool has_report;
|
||
bool running;
|
||
TrainlogSyncHistoryEntry history[SYNC_HISTORY_CAPACITY];
|
||
size_t history_count;
|
||
size_t selected;
|
||
char detail_text[SYNC_DETAIL_TEXT_CAPACITY];
|
||
char *detail_lines[SYNC_DETAIL_LINE_CAPACITY];
|
||
size_t detail_line_count;
|
||
size_t detail_scroll;
|
||
bool showing_detail;
|
||
TrainlogAppSyncProbe probe;
|
||
TrainlogAppSyncRun run;
|
||
} TrainlogSyncController;
|
||
|
||
typedef struct TrainlogDashboardWeek {
|
||
int64_t start_day;
|
||
int64_t span_days;
|
||
char label[12];
|
||
size_t sessions;
|
||
size_t maxima;
|
||
size_t working_improvements;
|
||
size_t max_improvements;
|
||
} TrainlogDashboardWeek;
|
||
|
||
typedef struct TrainlogDashboardZoneBucket {
|
||
char label[TRAINLOG_NAME_MAX + 1U];
|
||
size_t count;
|
||
} TrainlogDashboardZoneBucket;
|
||
|
||
typedef enum TrainlogStatisticsPeriod {
|
||
TRAINLOG_STATS_30_DAYS = 0,
|
||
TRAINLOG_STATS_7_DAYS,
|
||
TRAINLOG_STATS_90_DAYS,
|
||
TRAINLOG_STATS_YEAR,
|
||
TRAINLOG_STATS_ALL
|
||
} TrainlogStatisticsPeriod;
|
||
|
||
typedef struct TrainlogDashboardSnapshot {
|
||
bool error;
|
||
bool partial;
|
||
bool invalid_data;
|
||
bool has_performance;
|
||
TrainlogExercise exercise;
|
||
TrainlogLoadMode performance_load_mode;
|
||
TrainlogTrackingMode performance_tracking_mode;
|
||
int performance_dose;
|
||
char performance_equipment_id[TRAINLOG_ID_MAX + 1U];
|
||
char performance_equipment_label[TRAINLOG_NAME_MAX + 1U];
|
||
TrainlogExercisePerformancePoint performance[MAX_SESSIONS];
|
||
size_t performance_count;
|
||
bool has_explicit_max;
|
||
TrainlogExercise max_exercise;
|
||
double max_weight_kg;
|
||
char max_timestamp[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||
bool has_body;
|
||
size_t body_metric;
|
||
TrainlogBodyMetricPoint body[MAX_BODY_METRIC_POINTS];
|
||
size_t body_count;
|
||
TrainlogDashboardWeek weeks[DASHBOARD_BUCKET_COUNT];
|
||
size_t week_count;
|
||
size_t completed_session_count;
|
||
size_t performed_set_count;
|
||
size_t distinct_exercise_count;
|
||
size_t explicit_max_count;
|
||
/* WHY: this is a catalogue dashboard, not a history-derived muscle
|
||
* claim. CONTRACT: every active canonical exercise contributes once to
|
||
* its persisted primary zone, or to the explicit unclassified bucket.
|
||
* INVARIANT: aliases and secondary zones never affect these counts. */
|
||
TrainlogDashboardZoneBucket zones[DASHBOARD_ZONE_BUCKET_CAPACITY];
|
||
size_t zone_count;
|
||
TrainlogStatisticsPeriod period;
|
||
} TrainlogDashboardSnapshot;
|
||
|
||
typedef enum TrainlogDashboardFactKind {
|
||
TRAINLOG_DASHBOARD_WORK,
|
||
TRAINLOG_DASHBOARD_MAX,
|
||
TRAINLOG_DASHBOARD_CONTINUOUS
|
||
} TrainlogDashboardFactKind;
|
||
|
||
typedef struct TrainlogDashboardFact {
|
||
TrainlogDashboardFactKind kind;
|
||
TrainlogTimestampKey time;
|
||
char session_id[TRAINLOG_ID_MAX + 1U];
|
||
char entry_id[TRAINLOG_ID_MAX + 1U];
|
||
char exercise_id[TRAINLOG_ID_MAX + 1U];
|
||
char started_at[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||
char equipment_id[TRAINLOG_ID_MAX + 1U];
|
||
TrainlogTrackingMode tracking_mode;
|
||
TrainlogLoadMode load_mode;
|
||
size_t set_position;
|
||
int dose;
|
||
bool has_weight;
|
||
double weight_kg;
|
||
} TrainlogDashboardFact;
|
||
|
||
typedef struct TrainlogAppContext {
|
||
TrainlogDatabase *database;
|
||
TrainlogTerminal *terminal;
|
||
TrainlogNavigationState navigation;
|
||
TrainlogSessionController session;
|
||
TrainlogEquipmentController equipment_controller;
|
||
TrainlogExerciseController exercise_controller;
|
||
TrainlogBodyController body_controller;
|
||
TrainlogProfileController profile_controller;
|
||
TrainlogSyncController sync_controller;
|
||
TrainlogDashboardSnapshot dashboard;
|
||
TrainlogFocusTarget focus;
|
||
TrainlogActionModel actions;
|
||
TrainlogOverlayStack overlays;
|
||
TrainlogShellLayout layout;
|
||
TrainlogSurface *header;
|
||
TrainlogSurface *sidebar;
|
||
TrainlogSurface *content;
|
||
TrainlogSurface *footer;
|
||
size_t navigation_selected;
|
||
TrainlogFocusTarget navigation_restore_focus;
|
||
size_t content_selected;
|
||
size_t content_scroll;
|
||
TrainlogListState list;
|
||
TrainlogSearchState search;
|
||
TrainlogListState saved_lists[TRAINLOG_ROUTE_COUNT];
|
||
TrainlogSearchState saved_searches[TRAINLOG_ROUTE_COUNT];
|
||
TrainlogExercise exercises[MAX_EXERCISES];
|
||
TrainlogResolvedEquipment equipment[256];
|
||
TrainlogSessionSummary sessions[MAX_SESSIONS];
|
||
const char *stable_ids[MAX_BODY_OBSERVATIONS];
|
||
size_t loaded_count;
|
||
int exercise_zone_filter;
|
||
bool list_error;
|
||
TrainlogExercise exercise_detail;
|
||
TrainlogExerciseBodyZone exercise_zones[MAX_BODY_ZONES];
|
||
size_t exercise_zone_count;
|
||
TrainlogResolvedEquipment exercise_explicit_equipment[64];
|
||
size_t exercise_explicit_equipment_count;
|
||
TrainlogResolvedEquipment exercise_historic_equipment[128];
|
||
size_t exercise_historic_equipment_count;
|
||
size_t exercise_equipment_selected;
|
||
bool exercise_detail_metadata_error;
|
||
TrainlogResolvedEquipment equipment_detail;
|
||
TrainlogExercisePerformancePoint performance_points[MAX_SESSIONS];
|
||
size_t performance_count;
|
||
TrainlogMeasuredMaxSummary max_summary;
|
||
size_t max_rounding_index;
|
||
bool performance_error;
|
||
TrainlogSessionSummary session_detail;
|
||
TrainlogPersistedExerciseDetail session_entries[MAX_SESSION_EXERCISES];
|
||
size_t session_entry_count;
|
||
size_t session_entry_selected;
|
||
size_t session_set_scroll;
|
||
bool session_detail_error;
|
||
TrainlogFeedbackView *exercise_feedback;
|
||
TrainlogFeedbackView *session_followups;
|
||
size_t exercise_feedback_count;
|
||
size_t session_followup_count;
|
||
TrainlogBodyObservationRecord body_records[MAX_BODY_OBSERVATIONS];
|
||
TrainlogBodyObservationRecord body_detail;
|
||
TrainlogBodyMetricPoint body_metric_points[MAX_BODY_METRIC_POINTS];
|
||
size_t body_metric_count;
|
||
bool body_metric_partial;
|
||
size_t body_metric_selected;
|
||
TrainlogGlobalBodySeries body_global_series[14];
|
||
bool body_global_enabled[14];
|
||
bool body_global_initialized;
|
||
size_t body_global_selected;
|
||
char body_global_dates[MAX_GLOBAL_BODY_DATES][TRAINLOG_TIMESTAMP_MAX + 1U];
|
||
size_t body_global_date_count;
|
||
bool body_global_partial;
|
||
bool body_snapshot_error;
|
||
TrainlogBodyAnalyticsProfile body_profile;
|
||
bool body_has_profile;
|
||
bool quit_confirmation;
|
||
bool running;
|
||
} TrainlogAppContext;
|
||
|
||
static void app_shell_open_route(TrainlogAppContext *app,
|
||
TrainlogAppRoute route);
|
||
static void app_shell_load_exercise_detail_metadata(TrainlogAppContext *app);
|
||
static void app_shell_refresh_list(TrainlogAppContext *app);
|
||
static void app_shell_load_body_global(TrainlogAppContext *app);
|
||
static void app_shell_select_available_body_metric(TrainlogAppContext *app);
|
||
|
||
static void body_metric_slot(TrainlogBodyObservationInput *input, size_t field,
|
||
bool **present, double **value)
|
||
{
|
||
*present = NULL; *value = NULL;
|
||
#define BODY_SLOT(index_, has_, value_) case index_: *present = &input->has_; \
|
||
*value = &input->value_; break
|
||
switch (field) {
|
||
BODY_SLOT(0U, has_body_weight, body_weight_kg);
|
||
BODY_SLOT(1U, has_neck, neck_cm);
|
||
BODY_SLOT(2U, has_shoulders, shoulders_cm);
|
||
BODY_SLOT(3U, has_chest, chest_cm);
|
||
BODY_SLOT(4U, has_waist, waist_cm);
|
||
BODY_SLOT(5U, has_hips, hips_cm);
|
||
BODY_SLOT(6U, has_left_arm, left_arm_cm);
|
||
BODY_SLOT(7U, has_right_arm, right_arm_cm);
|
||
BODY_SLOT(8U, has_left_forearm, left_forearm_cm);
|
||
BODY_SLOT(9U, has_right_forearm, right_forearm_cm);
|
||
BODY_SLOT(10U, has_left_thigh, left_thigh_cm);
|
||
BODY_SLOT(11U, has_right_thigh, right_thigh_cm);
|
||
BODY_SLOT(12U, has_left_calf, left_calf_cm);
|
||
BODY_SLOT(13U, has_right_calf, right_calf_cm);
|
||
default: break;
|
||
}
|
||
#undef BODY_SLOT
|
||
}
|
||
|
||
static void body_controller_open_field(TrainlogBodyController *controller)
|
||
{
|
||
bool *present;
|
||
double *value;
|
||
char text[64] = "";
|
||
body_metric_slot(&controller->pending, controller->field, &present, &value);
|
||
if (controller->editing && present != NULL && *present)
|
||
(void)snprintf(text, sizeof(text), "%.10g", *value);
|
||
trainlog_form_init(&controller->form, text);
|
||
controller->phase = TRAINLOG_BODY_FORM;
|
||
}
|
||
|
||
static void body_controller_start_add(TrainlogAppContext *app)
|
||
{
|
||
TrainlogBodyController *controller = &app->body_controller;
|
||
(void)memset(controller, 0, sizeof(*controller));
|
||
if (trainlog_id_generate("bo", controller->pending.observation_id,
|
||
sizeof(controller->pending.observation_id)) != TRAINLOG_STATUS_OK ||
|
||
trainlog_time_now_rfc3339(controller->pending.observed_at,
|
||
sizeof(controller->pending.observed_at)) != TRAINLOG_STATUS_OK) {
|
||
controller->phase = TRAINLOG_BODY_MESSAGE;
|
||
(void)snprintf(controller->message, sizeof(controller->message),
|
||
"Impossible de préparer un identifiant ou une date de relevé.");
|
||
return;
|
||
}
|
||
body_controller_open_field(controller);
|
||
app->focus = TRAINLOG_FOCUS_EDITOR;
|
||
}
|
||
|
||
static bool body_controller_start_edit(TrainlogAppContext *app,
|
||
const char *observation_id)
|
||
{
|
||
TrainlogBodyController *controller = &app->body_controller;
|
||
TrainlogBodyObservationRecord record;
|
||
(void)memset(controller, 0, sizeof(*controller));
|
||
if (trainlog_database_get_body_observation(app->database, observation_id,
|
||
&record) != TRAINLOG_STATUS_OK) return false;
|
||
body_input_from_record(&record, &controller->pending);
|
||
(void)snprintf(controller->session_id, sizeof(controller->session_id),
|
||
"%s", record.session_id);
|
||
(void)snprintf(controller->notes, sizeof(controller->notes), "%s",
|
||
record.notes);
|
||
controller->pending.session_id = controller->session_id[0] != '\0'
|
||
? controller->session_id : NULL;
|
||
controller->pending.notes = controller->notes[0] != '\0'
|
||
? controller->notes : NULL;
|
||
controller->editing = true;
|
||
body_controller_open_field(controller);
|
||
app->focus = TRAINLOG_FOCUS_EDITOR;
|
||
return true;
|
||
}
|
||
|
||
static bool body_controller_persist(TrainlogAppContext *app)
|
||
{
|
||
TrainlogBodyController *controller = &app->body_controller;
|
||
TrainlogStatus status = controller->editing
|
||
? trainlog_database_update_body_observation(app->database,
|
||
&controller->pending)
|
||
: trainlog_database_insert_body_observation(app->database,
|
||
&controller->pending);
|
||
if (status != TRAINLOG_STATUS_OK) {
|
||
(void)snprintf(controller->message, sizeof(controller->message),
|
||
"Enregistrement impossible : au moins une mesure positive est requise.");
|
||
return false;
|
||
}
|
||
controller->dirty = false;
|
||
controller->form.active = false;
|
||
controller->phase = TRAINLOG_BODY_MESSAGE;
|
||
(void)snprintf(controller->message, sizeof(controller->message), "%s",
|
||
controller->editing ? "Relevé corrigé; identité, date et séance conservées."
|
||
: "Relevé corporel enregistré.");
|
||
if (app->navigation.current.route == TRAINLOG_ROUTE_BODY) {
|
||
app_shell_refresh_list(app);
|
||
/* INVARIANT: a successful edit refreshes both halves of the body
|
||
* dashboard from the same committed database state. */
|
||
app_shell_load_body_global(app);
|
||
app_shell_select_available_body_metric(app);
|
||
}
|
||
if (controller->editing &&
|
||
app->navigation.current.route == TRAINLOG_ROUTE_BODY_DETAIL)
|
||
(void)trainlog_database_get_body_observation(app->database,
|
||
controller->pending.observation_id, &app->body_detail);
|
||
return true;
|
||
}
|
||
|
||
static bool app_shell_dispatch_body_controller(TrainlogAppContext *app, int key)
|
||
{
|
||
TrainlogBodyController *controller = &app->body_controller;
|
||
TrainlogFormResult result;
|
||
if (controller->phase == TRAINLOG_BODY_IDLE) return false;
|
||
if (controller->phase == TRAINLOG_BODY_MESSAGE) {
|
||
if (key == TRAINLOG_KEY_ENTER || key == '\n' ||
|
||
key == TRAINLOG_KEY_ESCAPE) {
|
||
(void)memset(controller, 0, sizeof(*controller));
|
||
app->focus = TRAINLOG_FOCUS_CONTENT;
|
||
}
|
||
return true;
|
||
}
|
||
if (controller->phase == TRAINLOG_BODY_CONFIRM_DISCARD) {
|
||
if (key == '1') {
|
||
bool leaving = controller->leaving;
|
||
TrainlogAppRoute route = controller->pending_route;
|
||
(void)memset(controller, 0, sizeof(*controller));
|
||
app->focus = TRAINLOG_FOCUS_CONTENT;
|
||
if (leaving) app_shell_open_route(app, route);
|
||
} else if (key == '0' || key == TRAINLOG_KEY_ESCAPE) {
|
||
controller->phase = TRAINLOG_BODY_FORM;
|
||
controller->leaving = false;
|
||
}
|
||
return true;
|
||
}
|
||
result = trainlog_form_handle(&controller->form, key);
|
||
if (result == TRAINLOG_FORM_EDITED) {
|
||
controller->dirty = true;
|
||
controller->message[0] = '\0';
|
||
} else if (result == TRAINLOG_FORM_SUBMIT) {
|
||
bool *present;
|
||
double *value;
|
||
double parsed = 0.0;
|
||
body_metric_slot(&controller->pending, controller->field,
|
||
&present, &value);
|
||
if (present == NULL || value == NULL) return true;
|
||
if (controller->form.text[0] == '\0') {
|
||
if (!controller->editing) { *present = false; *value = 0.0; }
|
||
} else if (controller->editing &&
|
||
strcmp(controller->form.text, "-") == 0) {
|
||
*present = false; *value = 0.0;
|
||
} else if (!parse_double_positive(controller->form.text, &parsed)) {
|
||
(void)snprintf(controller->message, sizeof(controller->message),
|
||
"Nombre positif invalide; le texte saisi est conservé.");
|
||
return true;
|
||
} else { *present = true; *value = parsed; }
|
||
controller->dirty = true;
|
||
if (controller->field + 1U < 14U) {
|
||
++controller->field;
|
||
body_controller_open_field(controller);
|
||
} else (void)body_controller_persist(app);
|
||
} else if (result == TRAINLOG_FORM_CANCEL) {
|
||
if (controller->dirty) controller->phase = TRAINLOG_BODY_CONFIRM_DISCARD;
|
||
else { (void)memset(controller, 0, sizeof(*controller));
|
||
app->focus = TRAINLOG_FOCUS_CONTENT; }
|
||
} else if (result == TRAINLOG_FORM_OPEN_NAVIGATION)
|
||
(void)trainlog_overlays_push(&app->overlays,
|
||
TRAINLOG_OVERLAY_NAVIGATION, app->focus,
|
||
app->navigation.current.stable_id);
|
||
else if (result == TRAINLOG_FORM_OPEN_ACTIONS)
|
||
(void)trainlog_overlays_push(&app->overlays,
|
||
TRAINLOG_OVERLAY_ACTIONS, app->focus,
|
||
app->navigation.current.stable_id);
|
||
return true;
|
||
}
|
||
|
||
static void profile_controller_start(TrainlogAppContext *app,
|
||
TrainlogAppRoute caller)
|
||
{
|
||
TrainlogProfileController *controller = &app->profile_controller;
|
||
char text[8];
|
||
(void)memset(controller, 0, sizeof(*controller));
|
||
controller->caller = caller;
|
||
if (!body_analytics_profile_load(&controller->pending)) {
|
||
controller->pending.formula = TRAINLOG_BODY_ANALYTICS_FORMULA_MALE;
|
||
controller->pending.height_cm = 170.0;
|
||
}
|
||
controller->phase = TRAINLOG_PROFILE_FORMULA;
|
||
controller->return_phase = TRAINLOG_PROFILE_FORMULA;
|
||
(void)snprintf(text, sizeof(text), "%d",
|
||
controller->pending.formula == TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE
|
||
? 2 : 1);
|
||
trainlog_form_init(&controller->form, text);
|
||
app->focus = TRAINLOG_FOCUS_EDITOR;
|
||
}
|
||
|
||
static bool app_shell_dispatch_profile_controller(TrainlogAppContext *app,
|
||
int key)
|
||
{
|
||
TrainlogProfileController *controller = &app->profile_controller;
|
||
TrainlogFormResult result;
|
||
if (controller->phase == TRAINLOG_PROFILE_IDLE) return false;
|
||
if (controller->phase == TRAINLOG_PROFILE_MESSAGE) {
|
||
if (key == TRAINLOG_KEY_ENTER || key == '\n' || key == TRAINLOG_KEY_ESCAPE) {
|
||
TrainlogAppRoute caller = controller->caller;
|
||
(void)memset(controller, 0, sizeof(*controller));
|
||
app->focus = TRAINLOG_FOCUS_CONTENT;
|
||
if (app->navigation.current.route == caller)
|
||
app->body_has_profile = body_analytics_profile_load(
|
||
&app->body_profile);
|
||
}
|
||
return true;
|
||
}
|
||
if (controller->phase == TRAINLOG_PROFILE_CONFIRM_DISCARD) {
|
||
if (key == '1') {
|
||
bool leaving = controller->leaving;
|
||
TrainlogAppRoute route = controller->pending_route;
|
||
(void)memset(controller, 0, sizeof(*controller));
|
||
app->focus = TRAINLOG_FOCUS_CONTENT;
|
||
if (leaving) app_shell_open_route(app, route);
|
||
} else if (key == '0' || key == TRAINLOG_KEY_ESCAPE)
|
||
controller->phase = controller->return_phase;
|
||
return true;
|
||
}
|
||
result = trainlog_form_handle(&controller->form, key);
|
||
if (result == TRAINLOG_FORM_EDITED) controller->dirty = true;
|
||
else if (result == TRAINLOG_FORM_SUBMIT &&
|
||
controller->phase == TRAINLOG_PROFILE_FORMULA) {
|
||
if (strcmp(controller->form.text, "1") != 0 &&
|
||
strcmp(controller->form.text, "2") != 0) {
|
||
(void)snprintf(controller->message, sizeof(controller->message),
|
||
"Formule attendue : 1 homme ou 2 femme.");
|
||
return true;
|
||
}
|
||
controller->pending.formula = strcmp(controller->form.text, "2") == 0
|
||
? TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE
|
||
: TRAINLOG_BODY_ANALYTICS_FORMULA_MALE;
|
||
controller->phase = TRAINLOG_PROFILE_HEIGHT;
|
||
controller->return_phase = TRAINLOG_PROFILE_HEIGHT;
|
||
{ char text[64]; (void)snprintf(text, sizeof(text), "%.1f",
|
||
controller->pending.height_cm); trainlog_form_init(&controller->form, text); }
|
||
} else if (result == TRAINLOG_FORM_SUBMIT) {
|
||
double height;
|
||
if (!parse_double_positive(controller->form.text, &height) ||
|
||
height < 100.0 || height > 250.0) {
|
||
(void)snprintf(controller->message, sizeof(controller->message),
|
||
"Taille attendue entre 100 et 250 cm; texte conservé.");
|
||
return true;
|
||
}
|
||
controller->pending.height_cm = height;
|
||
if (!body_analytics_profile_save(&controller->pending)) {
|
||
(void)snprintf(controller->message, sizeof(controller->message),
|
||
"Impossible d’enregistrer le profil local; champs conservés.");
|
||
return true;
|
||
}
|
||
controller->dirty = false;
|
||
controller->phase = TRAINLOG_PROFILE_MESSAGE;
|
||
(void)snprintf(controller->message, sizeof(controller->message),
|
||
"Profil d’estimation corporelle enregistré localement.");
|
||
} else if (result == TRAINLOG_FORM_CANCEL) {
|
||
if (controller->dirty) {
|
||
controller->return_phase = controller->phase;
|
||
controller->phase = TRAINLOG_PROFILE_CONFIRM_DISCARD;
|
||
}
|
||
else { (void)memset(controller, 0, sizeof(*controller));
|
||
app->focus = TRAINLOG_FOCUS_CONTENT; }
|
||
} else if (result == TRAINLOG_FORM_OPEN_NAVIGATION)
|
||
(void)trainlog_overlays_push(&app->overlays,
|
||
TRAINLOG_OVERLAY_NAVIGATION, app->focus,
|
||
app->navigation.current.stable_id);
|
||
else if (result == TRAINLOG_FORM_OPEN_ACTIONS)
|
||
(void)trainlog_overlays_push(&app->overlays,
|
||
TRAINLOG_OVERLAY_ACTIONS, app->focus,
|
||
app->navigation.current.stable_id);
|
||
return true;
|
||
}
|
||
|
||
static void session_controller_sync_durability(TrainlogSessionController *session)
|
||
{
|
||
session->durability.session_draft = session->has_draft;
|
||
session->durability.session_dirty = session->dirty;
|
||
session->durability.generator_configuration_dirty =
|
||
session->phase == TRAINLOG_SESSION_GENERATOR_CONFIG &&
|
||
(session->generation_zone != NULL || session->generation_goal != NULL);
|
||
session->durability.generator_preview = session->generated_preview;
|
||
session->durability.generator_preview_dirty = session->generated_preview;
|
||
session->durability.transient_form_dirty = session->form.active &&
|
||
session->form.bytes > 0U;
|
||
}
|
||
|
||
static void session_controller_clear_draft(TrainlogSessionController *session)
|
||
{
|
||
/* CONTRACT: this is called only by explicit abandon or a successful save.
|
||
* Route changes retain the complete run-memory draft. */
|
||
(void)memset(session->drafts, 0, sizeof(session->drafts));
|
||
session->has_draft = false;
|
||
session->dirty = false;
|
||
session->correcting = false;
|
||
session->draft_count = 0U;
|
||
session->selected = 0U;
|
||
session->persisted_session_id[0] = '\0';
|
||
session->started_at[0] = '\0';
|
||
session_controller_sync_durability(session);
|
||
}
|
||
|
||
static void session_controller_discard_generator(TrainlogSessionController *session)
|
||
{
|
||
/* INVARIANT: no Back/navigation path calls this helper. Preview edits,
|
||
* raw configuration and warning acknowledgement survive keep-and-leave. */
|
||
(void)memset(&session->generated, 0, sizeof(session->generated));
|
||
(void)memset(session->generated_items, 0, sizeof(session->generated_items));
|
||
session->generated_preview = false;
|
||
session->generation_warning_acknowledged = false;
|
||
session->generation_zone = NULL;
|
||
session->generation_goal = NULL;
|
||
session->generation_duration_minutes = 0;
|
||
session->generation_reference_time[0] = '\0';
|
||
session_controller_sync_durability(session);
|
||
}
|
||
|
||
static bool session_controller_load_picker(TrainlogAppContext *app)
|
||
{
|
||
TrainlogSessionController *session = &app->session;
|
||
session->picker_count = 0U;
|
||
session->picker_selected = 0U;
|
||
if (trainlog_database_list_exercises(app->database, session->picker,
|
||
MAX_EXERCISES, &session->picker_count) != TRAINLOG_STATUS_OK) {
|
||
(void)snprintf(session->message, sizeof(session->message),
|
||
"Impossible de lire le catalogue d’exercices.");
|
||
session->phase = TRAINLOG_SESSION_MESSAGE;
|
||
return false;
|
||
}
|
||
if (session->picker_count == 0U) {
|
||
(void)snprintf(session->message, sizeof(session->message),
|
||
"Ajoutez d’abord au moins un exercice au catalogue.");
|
||
session->phase = TRAINLOG_SESSION_MESSAGE;
|
||
return false;
|
||
}
|
||
session->phase = TRAINLOG_SESSION_EXERCISE_PICKER;
|
||
return true;
|
||
}
|
||
|
||
static void session_controller_load_equipment(TrainlogAppContext *app)
|
||
{
|
||
TrainlogSessionController *session = &app->session;
|
||
size_t index;
|
||
session->equipment_count = equipment_collect(app->database, "",
|
||
app->equipment, sizeof(app->equipment) / sizeof(app->equipment[0]),
|
||
NULL, NULL);
|
||
session->equipment_selected = 0U; /* zero is the explicit none choice */
|
||
if (session->selected < session->draft_count) {
|
||
const char *current = session->drafts[session->selected].input.equipment_id;
|
||
for (index = 0U; current[0] != '\0' && index < session->equipment_count; ++index)
|
||
if (strcmp(current, app->equipment[index].equipment_id) == 0) {
|
||
session->equipment_selected = index + 1U;
|
||
break;
|
||
}
|
||
}
|
||
session->phase = TRAINLOG_SESSION_EQUIPMENT_PICKER;
|
||
}
|
||
|
||
static bool machine_exercise_compatibility(const char *exercise_id,
|
||
const char **equipment_id, TrainlogLoadMode *load_mode)
|
||
{
|
||
static const struct { const char *exercise; const char *equipment; bool assistance; } rows[] = {
|
||
{"ex_0e26c06f-a458-40a4-be20-4ed219ede30d", "plate_loaded_leg_press", false},
|
||
{"ex_1872246a-39ae-44dc-b58d-f87e90ca49ab", "leg_extension", false},
|
||
{"ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0", "rotary_torso", false},
|
||
{"ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de", "diverging_seated_row", false},
|
||
{"ex_33f79331-871c-4eed-babe-346e53a99070", "seated_row", false},
|
||
{"ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf", "assisted_dip_chin_machine", true},
|
||
{"ex_474ec393-3efa-4aaa-8e08-1a0245ed7835", "back_extension", false},
|
||
{"ex_4cd2433e-80b1-478a-b8df-73fc6ef80962", "rear_delt_pec_fly", false},
|
||
{"ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee", "rear_delt_pec_fly", false},
|
||
{"ex_58b8dfbc-92b2-4783-a449-9947a42480b8", "assisted_dip_chin_machine", true},
|
||
{"ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b", "converging_shoulder_press", false},
|
||
{"ex_7e7cf906-2214-4066-bcb7-c16382d83b3b", "hip_abduction", false},
|
||
{"ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163", "seated_leg_curl", false},
|
||
{"ex_a72fa713-4b0e-431d-95e2-42d95beb77b1", "lat_pull", false},
|
||
{"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", "leg_press", false},
|
||
{"ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6", "diverging_lat_pulldown", false},
|
||
{"ex_d7398d9f-d928-4d2e-94e9-74e201da55c5", "prone_leg_curl", false},
|
||
{"ex_ec619fc2-4685-4044-873c-86764bd4a0fe", "arm_curl", false}
|
||
};
|
||
size_t index;
|
||
if (strcmp(exercise_id, "ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2") == 0) {
|
||
*equipment_id = "treadmill";
|
||
*load_mode = TRAINLOG_LOAD_NONE;
|
||
return true;
|
||
}
|
||
for (index = 0U; index < sizeof(rows) / sizeof(rows[0]); ++index) {
|
||
if (strcmp(rows[index].exercise, exercise_id) == 0) {
|
||
*equipment_id = rows[index].equipment;
|
||
*load_mode = rows[index].assistance ? TRAINLOG_LOAD_ASSISTANCE :
|
||
TRAINLOG_LOAD_EXTERNAL;
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
static bool session_controller_choose_exercise(TrainlogSessionController *session)
|
||
{
|
||
TrainlogSessionDraftExercise replacement;
|
||
TrainlogSessionDraftExercise *target;
|
||
const TrainlogExercise *exercise;
|
||
char preserved_id[TRAINLOG_ID_MAX + 1U] = "";
|
||
char preserved_notes[TRAINLOG_NOTE_MAX + 1U] = "";
|
||
if (session->picker_count == 0U ||
|
||
session->picker_selected >= session->picker_count) return false;
|
||
if (!session->replacing_occurrence &&
|
||
session->draft_count >= MAX_SESSION_EXERCISES) return false;
|
||
exercise = &session->picker[session->picker_selected];
|
||
(void)memset(&replacement, 0, sizeof(replacement));
|
||
if (session->replacing_occurrence && session->selected < session->draft_count) {
|
||
target = &session->drafts[session->selected];
|
||
(void)snprintf(preserved_id, sizeof(preserved_id), "%s",
|
||
target->input.entry_id);
|
||
(void)snprintf(preserved_notes, sizeof(preserved_notes), "%s", target->notes);
|
||
}
|
||
(void)snprintf(replacement.input.entry_id,
|
||
sizeof(replacement.input.entry_id), "%s", preserved_id);
|
||
(void)snprintf(replacement.input.exercise_id,
|
||
sizeof(replacement.input.exercise_id), "%s", exercise->exercise_id);
|
||
(void)snprintf(replacement.name, sizeof(replacement.name), "%s", exercise->name);
|
||
(void)snprintf(replacement.notes, sizeof(replacement.notes), "%s", preserved_notes);
|
||
replacement.tracking_mode = exercise->tracking_mode;
|
||
replacement.input.recording_mode = exercise->recording_mode;
|
||
replacement.input.data_fields = exercise->data_fields;
|
||
replacement.input.load_mode = TRAINLOG_LOAD_NONE;
|
||
{
|
||
const char *compatibility_equipment = NULL;
|
||
TrainlogLoadMode compatibility_load = TRAINLOG_LOAD_NONE;
|
||
/* WHY: fixed-machine identity already determines the Phase-1 legacy
|
||
* provenance. CONTRACT: custom/unresolved IDs never use name
|
||
* inference and retain the explicit compatibility editor. */
|
||
if (machine_exercise_compatibility(exercise->exercise_id,
|
||
&compatibility_equipment, &compatibility_load)) {
|
||
(void)snprintf(replacement.input.equipment_id,
|
||
sizeof(replacement.input.equipment_id), "%s", compatibility_equipment);
|
||
replacement.input.load_mode = compatibility_load;
|
||
}
|
||
}
|
||
draft_bind_input(&replacement);
|
||
if (session->replacing_occurrence) session->drafts[session->selected] = replacement;
|
||
else {
|
||
session->selected = session->draft_count;
|
||
session->drafts[session->draft_count++] = replacement;
|
||
}
|
||
draft_bind_input(&session->drafts[session->selected]);
|
||
session->has_draft = true;
|
||
session->dirty = true;
|
||
session->replacing_occurrence = false;
|
||
session->phase = TRAINLOG_SESSION_DRAFT;
|
||
session_controller_sync_durability(session);
|
||
return true;
|
||
}
|
||
|
||
static bool session_controller_actuals_valid(TrainlogSessionController *session)
|
||
{
|
||
size_t index;
|
||
if (session->draft_count == 0U) {
|
||
(void)snprintf(session->message, sizeof(session->message),
|
||
"Ajoutez au moins un exercice avant d’enregistrer.");
|
||
return false;
|
||
}
|
||
for (index = 0U; index < session->draft_count; ++index) {
|
||
TrainlogSessionExerciseInput *input = &session->drafts[index].input;
|
||
bool valid = input->has_max_weight ||
|
||
(input->recording_mode == TRAINLOG_RECORDING_CONTINUOUS
|
||
? input->continuous_duration_seconds > 0 : input->set_count > 0U);
|
||
if (!valid) {
|
||
session->selected = index;
|
||
(void)snprintf(session->message, sizeof(session->message),
|
||
"Saisissez un résultat réel pour %.120s.", session->drafts[index].name);
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
static TrainlogStatus session_controller_save(TrainlogAppContext *app)
|
||
{
|
||
TrainlogSessionController *session = &app->session;
|
||
TrainlogStatus status;
|
||
if (!session_controller_actuals_valid(session)) return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||
if (session->correcting) status = persist_draft_replacement(app->database,
|
||
session->persisted_session_id, session->drafts, session->draft_count)
|
||
? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
||
else status = persist_new_session_drafts(app->database, session->drafts,
|
||
session->draft_count, session->started_at, session->session_type);
|
||
if (status == TRAINLOG_STATUS_OK) session_controller_clear_draft(session);
|
||
return status;
|
||
}
|
||
|
||
static bool session_controller_generate(TrainlogAppContext *app)
|
||
{
|
||
TrainlogSessionController *session = &app->session;
|
||
TrainlogGenerationDatabaseRequest request;
|
||
char reference_time[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||
TrainlogStatus status;
|
||
if (session->generation_zone == NULL || session->generation_goal == NULL ||
|
||
session->generation_duration_minutes <= 0) return false;
|
||
if (session->generation_reference_time[0] == '\0' &&
|
||
trainlog_time_now_rfc3339(session->generation_reference_time,
|
||
sizeof(session->generation_reference_time)) != TRAINLOG_STATUS_OK) return false;
|
||
(void)snprintf(reference_time, sizeof(reference_time), "%s",
|
||
session->generation_reference_time);
|
||
(void)memset(&request, 0, sizeof(request));
|
||
request.zone_id = session->generation_zone->zone_id;
|
||
request.goal_id = session->generation_goal->id;
|
||
request.duration_minutes = session->generation_duration_minutes;
|
||
request.reference_time = reference_time;
|
||
status = trainlog_session_generate_from_database(app->database, &request,
|
||
&session->generated);
|
||
if (status != TRAINLOG_STATUS_OK) {
|
||
(void)snprintf(session->message, sizeof(session->message),
|
||
"Génération refusée (%d).", (int)status);
|
||
session->return_phase = TRAINLOG_SESSION_GENERATOR_CONFIG;
|
||
session->phase = TRAINLOG_SESSION_MESSAGE;
|
||
return false;
|
||
}
|
||
if (session->generated.exercise_count == 0U) {
|
||
(void)snprintf(session->message, sizeof(session->message),
|
||
"Pas assez d’exercices résolus et compatibles; rien n’a été inventé.");
|
||
session->return_phase = TRAINLOG_SESSION_GENERATOR_CONFIG;
|
||
session->phase = TRAINLOG_SESSION_MESSAGE;
|
||
return false;
|
||
}
|
||
if (!generator_prepare_preview(app->database, &session->generated,
|
||
session->generated_items)) return false;
|
||
session->generated_preview = true;
|
||
session->selected = 0U;
|
||
session->phase = session->generated.exposure.warning_level ==
|
||
TRAINLOG_GENERATION_WARNING_NONE || session->generation_warning_acknowledged
|
||
? TRAINLOG_SESSION_GENERATOR_PREVIEW : TRAINLOG_SESSION_GENERATOR_WARNING;
|
||
session_controller_sync_durability(session);
|
||
return true;
|
||
}
|
||
|
||
static TrainlogStatus session_requalify_history(void *context,
|
||
const TrainlogGenerationHistoryRow *row)
|
||
{
|
||
return trainlog_session_generation_analyzer_accept(
|
||
(TrainlogSessionGenerationAnalyzer *)context, row);
|
||
}
|
||
|
||
static bool session_controller_requalify_generated(TrainlogAppContext *app,
|
||
size_t selected,
|
||
int sets, int repetitions)
|
||
{
|
||
TrainlogSessionController *session = &app->session;
|
||
TrainlogGeneratedExercise *current;
|
||
TrainlogGenerationCandidate candidate;
|
||
TrainlogGenerationRequest request;
|
||
TrainlogSessionGenerationAnalyzer *analyzer = NULL;
|
||
TrainlogGeneratedSession result;
|
||
const char *secondary[TRAINLOG_GENERATOR_MAX_ZONES];
|
||
const char *patterns[TRAINLOG_GENERATOR_MAX_PATTERNS];
|
||
const char *sources[TRAINLOG_GENERATOR_MAX_SOURCE_REFS];
|
||
size_t index;
|
||
TrainlogStatus status;
|
||
if (selected >= session->generated.exercise_count ||
|
||
session->generation_zone == NULL || session->generation_goal == NULL ||
|
||
session->generation_reference_time[0] == '\0') return false;
|
||
current = &session->generated.exercises[selected];
|
||
for (index = 0U; index < current->secondary_zone_count; ++index)
|
||
secondary[index] = current->secondary_zone_ids[index];
|
||
for (index = 0U; index < current->pattern_count; ++index)
|
||
patterns[index] = current->pattern_ids[index];
|
||
for (index = 0U; index < current->source_ref_count; ++index)
|
||
sources[index] = current->source_ref_ids[index];
|
||
(void)memset(&candidate, 0, sizeof(candidate));
|
||
candidate.exercise_id = current->exercise_id;
|
||
candidate.equipment_id = current->equipment_id;
|
||
candidate.primary_zone_id = current->primary_zone_id;
|
||
candidate.secondary_zone_ids = secondary;
|
||
candidate.secondary_zone_count = current->secondary_zone_count;
|
||
candidate.pattern_ids = patterns;
|
||
candidate.pattern_count = current->pattern_count;
|
||
candidate.source_ref_ids = sources;
|
||
candidate.source_ref_count = current->source_ref_count;
|
||
candidate.confidence = current->confidence;
|
||
candidate.equipment_load_semantics = current->equipment_load_semantics;
|
||
(void)memset(&request, 0, sizeof(request));
|
||
request.zone_id = session->generation_zone->zone_id;
|
||
request.goal_id = session->generation_goal->id;
|
||
request.duration_minutes = session->generation_duration_minutes;
|
||
request.reference_time = session->generation_reference_time;
|
||
request.candidates = &candidate;
|
||
request.candidate_count = 1U;
|
||
status = trainlog_session_generation_requalify_dose(&request, 0U, sets,
|
||
repetitions, &analyzer);
|
||
if (status == TRAINLOG_STATUS_OK)
|
||
status = trainlog_database_scan_generation_history(app->database,
|
||
session_requalify_history, analyzer);
|
||
if (status == TRAINLOG_STATUS_OK)
|
||
status = trainlog_session_generation_analyzer_finish(analyzer, &result);
|
||
trainlog_session_generation_analyzer_destroy(analyzer);
|
||
if (status != TRAINLOG_STATUS_OK || result.exercise_count != 1U) return false;
|
||
session->generated.estimated_duration_seconds +=
|
||
result.exercises[0].estimated_seconds - current->estimated_seconds;
|
||
*current = result.exercises[0];
|
||
return true;
|
||
}
|
||
|
||
static bool session_controller_accept_generator(TrainlogAppContext *app)
|
||
{
|
||
TrainlogSessionController *session = &app->session;
|
||
if (session->has_draft) {
|
||
(void)snprintf(session->message, sizeof(session->message),
|
||
"Une séance est déjà en cours. Reprenez-la ou gardez cet aperçu.");
|
||
session->return_phase = TRAINLOG_SESSION_GENERATOR_PREVIEW;
|
||
session->phase = TRAINLOG_SESSION_MESSAGE;
|
||
return false;
|
||
}
|
||
if (!generator_build_drafts(app->database, &session->generated, session->drafts))
|
||
return false;
|
||
session->draft_count = session->generated.exercise_count;
|
||
session->has_draft = true;
|
||
session->dirty = true;
|
||
session->session_type = TRAINLOG_SESSION_TRAINING;
|
||
session->correcting = false;
|
||
if (trainlog_time_now_rfc3339(session->started_at,
|
||
sizeof(session->started_at)) != TRAINLOG_STATUS_OK) return false;
|
||
/* CONTRACT: acceptance transfers planning metadata into the ordinary
|
||
* draft and creates zero performed sets. */
|
||
session_controller_discard_generator(session);
|
||
session->phase = TRAINLOG_SESSION_DRAFT;
|
||
session->selected = 0U;
|
||
session_controller_sync_durability(session);
|
||
return true;
|
||
}
|
||
|
||
static const TrainlogAppRoute shell_sections[] = {
|
||
TRAINLOG_ROUTE_HOME, TRAINLOG_ROUTE_SESSIONS, TRAINLOG_ROUTE_EXERCISES,
|
||
TRAINLOG_ROUTE_STATS, TRAINLOG_ROUTE_SYNC, TRAINLOG_ROUTE_SETTINGS
|
||
};
|
||
static const char *const shell_section_labels[] = {
|
||
"Accueil", "Séances", "Exercices", "Statistiques", "Synchronisation",
|
||
"Paramètres"
|
||
};
|
||
|
||
static void app_shell_destroy_surfaces(TrainlogAppContext *app)
|
||
{
|
||
/* INVARIANT: each plane has exactly one owner and destruction proceeds
|
||
* from transient/inner content toward persistent outer chrome. */
|
||
trainlog_surface_destroy(app->content); app->content = NULL;
|
||
trainlog_surface_destroy(app->sidebar); app->sidebar = NULL;
|
||
trainlog_surface_destroy(app->footer); app->footer = NULL;
|
||
trainlog_surface_destroy(app->header); app->header = NULL;
|
||
}
|
||
|
||
static bool app_shell_layout(TrainlogAppContext *app)
|
||
{
|
||
trainlog_shell_layout_compute(trainlog_terminal_columns(app->terminal),
|
||
trainlog_terminal_rows(app->terminal), &app->layout);
|
||
if (!app->layout.usable) { app_shell_destroy_surfaces(app); return true; }
|
||
if (app->header == NULL)
|
||
app->header = trainlog_surface_create(app->terminal, "trainlog.header",
|
||
app->layout.header.y, app->layout.header.x,
|
||
app->layout.header.height, app->layout.header.width);
|
||
else if (!trainlog_surface_set_rect(app->header, app->layout.header.y,
|
||
app->layout.header.x, app->layout.header.height, app->layout.header.width))
|
||
return false;
|
||
if (app->footer == NULL)
|
||
app->footer = trainlog_surface_create(app->terminal, "trainlog.footer",
|
||
app->layout.footer.y, app->layout.footer.x,
|
||
app->layout.footer.height, app->layout.footer.width);
|
||
else if (!trainlog_surface_set_rect(app->footer, app->layout.footer.y,
|
||
app->layout.footer.x, app->layout.footer.height, app->layout.footer.width))
|
||
return false;
|
||
if (app->content == NULL)
|
||
app->content = trainlog_surface_create(app->terminal, "trainlog.content",
|
||
app->layout.content.y, app->layout.content.x,
|
||
app->layout.content.height, app->layout.content.width);
|
||
else if (!trainlog_surface_set_rect(app->content, app->layout.content.y,
|
||
app->layout.content.x, app->layout.content.height, app->layout.content.width))
|
||
return false;
|
||
if (app->layout.sidebar_visible && app->sidebar == NULL)
|
||
app->sidebar = trainlog_surface_create(app->terminal, "trainlog.sidebar",
|
||
app->layout.sidebar.y, app->layout.sidebar.x,
|
||
app->layout.sidebar.height, app->layout.sidebar.width);
|
||
else if (app->layout.sidebar_visible && !trainlog_surface_set_rect(app->sidebar,
|
||
app->layout.sidebar.y, app->layout.sidebar.x,
|
||
app->layout.sidebar.height, app->layout.sidebar.width)) return false;
|
||
else if (!app->layout.sidebar_visible && app->sidebar != NULL) {
|
||
trainlog_surface_destroy(app->sidebar); app->sidebar = NULL;
|
||
/* CONTRACT: compact Navigation replaces the disappearing sidebar;
|
||
* retain both its selected section and its owned focus. */
|
||
}
|
||
return app->header != NULL && app->footer != NULL && app->content != NULL &&
|
||
(!app->layout.sidebar_visible || app->sidebar != NULL);
|
||
}
|
||
|
||
static void app_shell_add_action(TrainlogAppContext *app, const char *identifier,
|
||
int key, const char *label, unsigned priority,
|
||
TrainlogIntent intent, TrainlogAppRoute route)
|
||
{
|
||
TrainlogAction action = {identifier, key, label, true, priority, intent, route};
|
||
(void)trainlog_actions_add(&app->actions, &action);
|
||
}
|
||
|
||
static bool app_shell_is_list_route(TrainlogAppRoute route)
|
||
{
|
||
return route == TRAINLOG_ROUTE_EXERCISES || route == TRAINLOG_ROUTE_EQUIPMENT ||
|
||
route == TRAINLOG_ROUTE_SESSIONS_COMPLETED ||
|
||
route == TRAINLOG_ROUTE_STATS_EXERCISE || route == TRAINLOG_ROUTE_MAX ||
|
||
route == TRAINLOG_ROUTE_BODY;
|
||
}
|
||
|
||
static void app_shell_refresh_list(TrainlogAppContext *app)
|
||
{
|
||
TrainlogAppRoute route = app->navigation.current.route;
|
||
size_t index;
|
||
size_t count = 0U;
|
||
size_t capacity = 0U;
|
||
bool source_capped = false;
|
||
app->list_error = false;
|
||
if (route == TRAINLOG_ROUTE_EXERCISES || route == TRAINLOG_ROUTE_STATS_EXERCISE ||
|
||
route == TRAINLOG_ROUTE_MAX) {
|
||
char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U] = "";
|
||
const char *zone_id = NULL;
|
||
size_t zone_count = trainlog_body_zone_catalog_count();
|
||
bool unclassified = app->exercise_zone_filter == (int)zone_count;
|
||
if (app->search.bytes > 0U && trainlog_catalog_normalize_name(app->search.text,
|
||
normalized, sizeof(normalized)) != TRAINLOG_STATUS_OK) normalized[0] = '\0';
|
||
if (app->exercise_zone_filter >= 0 &&
|
||
app->exercise_zone_filter < (int)zone_count) {
|
||
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(
|
||
(size_t)app->exercise_zone_filter);
|
||
if (zone != NULL) zone_id = zone->zone_id;
|
||
}
|
||
if (trainlog_database_list_exercises_filtered(app->database, normalized,
|
||
zone_id, true, false, unclassified, app->exercises,
|
||
MAX_EXERCISES, &count) != TRAINLOG_STATUS_OK) app->list_error = true;
|
||
capacity = MAX_EXERCISES;
|
||
source_capped = count == capacity;
|
||
for (index = 0U; index < count; ++index)
|
||
app->stable_ids[index] = app->exercises[index].exercise_id;
|
||
} else if (route == TRAINLOG_ROUTE_EQUIPMENT) {
|
||
bool truncated = false;
|
||
bool failed = false;
|
||
count = equipment_collect(app->database, app->search.text,
|
||
app->equipment, sizeof(app->equipment) / sizeof(app->equipment[0]),
|
||
&truncated, &failed);
|
||
capacity = sizeof(app->equipment) / sizeof(app->equipment[0]);
|
||
source_capped = truncated;
|
||
app->list_error = failed;
|
||
for (index = 0U; index < count; ++index)
|
||
app->stable_ids[index] = app->equipment[index].equipment_id;
|
||
} else if (route == TRAINLOG_ROUTE_SESSIONS_COMPLETED) {
|
||
TrainlogSessionSummary all[MAX_SESSIONS];
|
||
size_t all_count = 0U;
|
||
if (trainlog_database_list_sessions(app->database, all, MAX_SESSIONS,
|
||
&all_count) != TRAINLOG_STATUS_OK) app->list_error = true;
|
||
capacity = MAX_SESSIONS;
|
||
source_capped = all_count == capacity;
|
||
for (index = 0U; index < all_count && count < MAX_SESSIONS; ++index) {
|
||
char date[17];
|
||
const char *type = session_type_history_label(all[index].session_type);
|
||
session_history_datetime(all[index].started_at, date);
|
||
if (app->search.bytes == 0U || equipment_text_matches(date, app->search.text) ||
|
||
equipment_text_matches(type, app->search.text)) {
|
||
app->sessions[count] = all[index];
|
||
app->stable_ids[count] = app->sessions[count].session_id;
|
||
++count;
|
||
}
|
||
}
|
||
} else if (route == TRAINLOG_ROUTE_BODY) {
|
||
if (trainlog_database_list_body_observations(app->database,
|
||
app->body_records, MAX_BODY_OBSERVATIONS, &count) != TRAINLOG_STATUS_OK)
|
||
app->list_error = true;
|
||
capacity = MAX_BODY_OBSERVATIONS;
|
||
source_capped = count == capacity;
|
||
for (index = 0U; index < count; ++index)
|
||
app->stable_ids[index] = app->body_records[index].observation_id;
|
||
}
|
||
app->loaded_count = count;
|
||
trainlog_list_set_items(&app->list, app->stable_ids, count,
|
||
app->layout.content.height > 8 ? (size_t)(app->layout.content.height - 8) : 1U,
|
||
!source_capped, source_capped);
|
||
}
|
||
|
||
static void app_shell_load_body_metric(TrainlogAppContext *app)
|
||
{
|
||
app->body_metric_count = 0U;
|
||
app->body_snapshot_error =
|
||
trainlog_database_list_body_metric_points(app->database,
|
||
BODY_METRICS[app->body_metric_selected].metric,
|
||
app->body_metric_points, MAX_BODY_METRIC_POINTS,
|
||
&app->body_metric_count) != TRAINLOG_STATUS_OK;
|
||
app->body_metric_partial = app->body_metric_count == MAX_BODY_METRIC_POINTS;
|
||
}
|
||
|
||
static void app_shell_load_body_global(TrainlogAppContext *app)
|
||
{
|
||
size_t metric;
|
||
app->body_global_date_count = 0U;
|
||
app->body_snapshot_error = false;
|
||
app->body_global_partial = false;
|
||
if (!app->body_global_initialized) {
|
||
for (metric = 0U; metric < 14U; ++metric)
|
||
app->body_global_enabled[metric] = true;
|
||
app->body_global_initialized = true;
|
||
}
|
||
(void)memset(app->body_global_series, 0,
|
||
sizeof(app->body_global_series));
|
||
(void)memset(app->body_global_dates, 0, sizeof(app->body_global_dates));
|
||
for (metric = 0U; metric < 14U; ++metric) {
|
||
TrainlogGlobalBodySeries *series = &app->body_global_series[metric];
|
||
size_t point;
|
||
series->symbol = GLOBAL_BODY_SYMBOLS[metric];
|
||
series->role = GLOBAL_BODY_ROLES[metric];
|
||
if (trainlog_database_list_body_metric_points(app->database,
|
||
BODY_METRICS[metric].metric, series->points,
|
||
MAX_BODY_METRIC_POINTS, &series->count) != TRAINLOG_STATUS_OK) {
|
||
app->body_snapshot_error = true;
|
||
series->count = 0U;
|
||
continue;
|
||
}
|
||
if (series->count == MAX_BODY_METRIC_POINTS)
|
||
app->body_global_partial = true;
|
||
if (series->count == 0U) continue;
|
||
series->baseline = series->points[0].value;
|
||
(void)trainlog_body_percent_change(series->baseline,
|
||
series->points[series->count - 1U].value,
|
||
&series->latest_percent);
|
||
for (point = 0U; point < series->count; ++point)
|
||
if (!global_date_add(app->body_global_dates,
|
||
&app->body_global_date_count,
|
||
series->points[point].observed_at))
|
||
app->body_snapshot_error = true;
|
||
}
|
||
if (app->body_global_date_count > 0U)
|
||
qsort(app->body_global_dates, app->body_global_date_count,
|
||
sizeof(app->body_global_dates[0]), global_date_compare);
|
||
}
|
||
|
||
static void app_shell_select_available_body_metric(TrainlogAppContext *app)
|
||
{
|
||
size_t metric;
|
||
/* CONTRACT: the profile dashboard offers only measurements backed by at
|
||
* least one real observation. Weight is the deterministic first choice;
|
||
* otherwise BODY_METRICS catalogue order is the stable fallback. */
|
||
if (app->body_metric_selected < 14U &&
|
||
app->body_global_series[app->body_metric_selected].count > 0U) return;
|
||
app->body_metric_selected = 0U;
|
||
for (metric = 0U; metric < 14U; ++metric) {
|
||
if (app->body_global_series[metric].count > 0U) {
|
||
app->body_metric_selected = metric;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
static void app_shell_move_available_body_metric(TrainlogAppContext *app,
|
||
int direction)
|
||
{
|
||
size_t step;
|
||
for (step = 0U; step < 14U; ++step) {
|
||
app->body_metric_selected = direction < 0
|
||
? (app->body_metric_selected + 13U) % 14U
|
||
: (app->body_metric_selected + 1U) % 14U;
|
||
if (app->body_global_series[app->body_metric_selected].count > 0U)
|
||
return;
|
||
}
|
||
}
|
||
|
||
static void app_shell_load_body_analytics(TrainlogAppContext *app)
|
||
{
|
||
size_t count = 0U;
|
||
app->body_snapshot_error =
|
||
trainlog_database_list_body_observations(app->database,
|
||
app->body_records, MAX_BODY_OBSERVATIONS,
|
||
&count) != TRAINLOG_STATUS_OK;
|
||
app->loaded_count = count;
|
||
app->body_has_profile = body_analytics_profile_load(&app->body_profile);
|
||
}
|
||
|
||
static bool dashboard_copy_sql_text(sqlite3_stmt *statement, int column,
|
||
char *output, size_t capacity)
|
||
{
|
||
const unsigned char *value;
|
||
int bytes;
|
||
if (sqlite3_column_type(statement, column) != SQLITE_TEXT) return false;
|
||
value = sqlite3_column_text(statement, column);
|
||
bytes = sqlite3_column_bytes(statement, column);
|
||
if (value == NULL || bytes < 0 || (size_t)bytes >= capacity) return false;
|
||
(void)memcpy(output, value, (size_t)bytes);
|
||
output[bytes] = '\0';
|
||
return true;
|
||
}
|
||
|
||
/* WHY: the global dashboard compares occurrence-owned facts, while the public
|
||
* performance reader deliberately collapses each session to one representative.
|
||
* CONTRACT: this private projection emits every performed set, every explicit
|
||
* MAX exactly once, and every continuous occurrence exactly once. INVARIANT:
|
||
* malformed legacy timestamps are omitted before sorting and reported through
|
||
* invalid_data; they can never reach a comparator with uninitialized keys. */
|
||
static TrainlogStatus dashboard_database_list_facts(TrainlogDatabase *database,
|
||
TrainlogDashboardFact *output, size_t capacity, size_t *output_count,
|
||
bool *truncated, bool *invalid_data)
|
||
{
|
||
static const char *const SQL =
|
||
"SELECT 0,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
|
||
"COALESCE(se.equipment_id,''),e.tracking_mode,se.load_mode,ps.position,"
|
||
"CASE WHEN e.tracking_mode='reps' THEN ps.reps ELSE ps.duration_seconds END,"
|
||
"ps.weight_kg FROM performed_sets ps "
|
||
"JOIN session_exercises se ON se.id=ps.session_exercise_row_id "
|
||
"JOIN sessions s ON s.id=se.session_row_id "
|
||
"JOIN exercises e ON e.id=se.exercise_row_id "
|
||
"UNION ALL "
|
||
"SELECT 1,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
|
||
"COALESCE(se.equipment_id,''),e.tracking_mode,se.load_mode,0,0,"
|
||
"mr.max_weight_kg FROM max_results mr "
|
||
"JOIN session_exercises se ON se.id=mr.session_exercise_row_id "
|
||
"JOIN sessions s ON s.id=se.session_row_id "
|
||
"JOIN exercises e ON e.id=se.exercise_row_id "
|
||
"UNION ALL "
|
||
"SELECT 2,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
|
||
"COALESCE(se.equipment_id,''),e.tracking_mode,se.load_mode,0,"
|
||
"ca.duration_seconds,NULL FROM continuous_activity ca "
|
||
"JOIN session_exercises se ON se.id=ca.session_exercise_row_id "
|
||
"JOIN sessions s ON s.id=se.session_row_id "
|
||
"JOIN exercises e ON e.id=se.exercise_row_id;";
|
||
sqlite3_stmt *statement = NULL;
|
||
size_t count = 0U;
|
||
int rc;
|
||
if (output_count != NULL) *output_count = 0U;
|
||
if (truncated != NULL) *truncated = false;
|
||
if (invalid_data != NULL) *invalid_data = false;
|
||
if (database == NULL || database->connection == NULL || output_count == NULL ||
|
||
truncated == NULL || invalid_data == NULL ||
|
||
(capacity > 0U && output == NULL)) return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||
rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
|
||
if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR;
|
||
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
||
TrainlogDashboardFact fact;
|
||
const unsigned char *tracking;
|
||
const unsigned char *load;
|
||
int kind;
|
||
(void)memset(&fact, 0, sizeof(fact));
|
||
if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER ||
|
||
!dashboard_copy_sql_text(statement, 1, fact.started_at,
|
||
sizeof(fact.started_at)) ||
|
||
!dashboard_copy_sql_text(statement, 2, fact.session_id,
|
||
sizeof(fact.session_id)) ||
|
||
!dashboard_copy_sql_text(statement, 3, fact.entry_id,
|
||
sizeof(fact.entry_id)) ||
|
||
!dashboard_copy_sql_text(statement, 4, fact.exercise_id,
|
||
sizeof(fact.exercise_id)) ||
|
||
!dashboard_copy_sql_text(statement, 5, fact.equipment_id,
|
||
sizeof(fact.equipment_id)) ||
|
||
sqlite3_column_type(statement, 6) != SQLITE_TEXT ||
|
||
sqlite3_column_type(statement, 7) != SQLITE_TEXT ||
|
||
!trainlog_timestamp_parse(fact.started_at, strlen(fact.started_at),
|
||
&fact.time)) {
|
||
*invalid_data = true;
|
||
continue;
|
||
}
|
||
kind = sqlite3_column_int(statement, 0);
|
||
if (kind < 0 || kind > 2) {
|
||
*invalid_data = true;
|
||
continue;
|
||
}
|
||
fact.kind = (TrainlogDashboardFactKind)kind;
|
||
tracking = sqlite3_column_text(statement, 6);
|
||
load = sqlite3_column_text(statement, 7);
|
||
if (strcmp((const char *)tracking, "reps") == 0)
|
||
fact.tracking_mode = TRAINLOG_TRACKING_REPS;
|
||
else if (strcmp((const char *)tracking, "duration") == 0)
|
||
fact.tracking_mode = TRAINLOG_TRACKING_DURATION;
|
||
else { *invalid_data = true; continue; }
|
||
if (strcmp((const char *)load, "none") == 0)
|
||
fact.load_mode = TRAINLOG_LOAD_NONE;
|
||
else if (strcmp((const char *)load, "external") == 0)
|
||
fact.load_mode = TRAINLOG_LOAD_EXTERNAL;
|
||
else if (strcmp((const char *)load, "assistance") == 0)
|
||
fact.load_mode = TRAINLOG_LOAD_ASSISTANCE;
|
||
else { *invalid_data = true; continue; }
|
||
if (fact.kind == TRAINLOG_DASHBOARD_WORK) {
|
||
sqlite3_int64 position;
|
||
if (sqlite3_column_type(statement, 8) != SQLITE_INTEGER ||
|
||
sqlite3_column_type(statement, 9) != SQLITE_INTEGER) {
|
||
*invalid_data = true; continue;
|
||
}
|
||
position = sqlite3_column_int64(statement, 8);
|
||
if (position < 0 || (uint64_t)position > (uint64_t)SIZE_MAX) {
|
||
*invalid_data = true; continue;
|
||
}
|
||
fact.set_position = (size_t)position;
|
||
fact.dose = sqlite3_column_int(statement, 9);
|
||
} else if (fact.kind == TRAINLOG_DASHBOARD_CONTINUOUS) {
|
||
if (sqlite3_column_type(statement, 9) != SQLITE_INTEGER) {
|
||
*invalid_data = true; continue;
|
||
}
|
||
fact.dose = sqlite3_column_int(statement, 9);
|
||
}
|
||
if (fact.kind != TRAINLOG_DASHBOARD_CONTINUOUS) {
|
||
int type = sqlite3_column_type(statement, 10);
|
||
if (type == SQLITE_NULL && fact.kind == TRAINLOG_DASHBOARD_WORK) {
|
||
fact.has_weight = false;
|
||
} else if ((type != SQLITE_FLOAT && type != SQLITE_INTEGER) ||
|
||
!isfinite(sqlite3_column_double(statement, 10))) {
|
||
*invalid_data = true; continue;
|
||
} else {
|
||
fact.has_weight = true;
|
||
fact.weight_kg = sqlite3_column_double(statement, 10);
|
||
}
|
||
if (fact.kind == TRAINLOG_DASHBOARD_MAX && fact.weight_kg <= 0.0) {
|
||
*invalid_data = true; continue;
|
||
}
|
||
}
|
||
if (count == capacity) { *truncated = true; continue; }
|
||
output[count++] = fact;
|
||
}
|
||
if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK)
|
||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||
*output_count = count;
|
||
return TRAINLOG_STATUS_OK;
|
||
}
|
||
|
||
static int dashboard_fact_time_compare(const void *left, const void *right)
|
||
{
|
||
const TrainlogDashboardFact *a = left;
|
||
const TrainlogDashboardFact *b = right;
|
||
int result = trainlog_timestamp_compare(&a->time, &b->time);
|
||
if (result != 0) return result;
|
||
result = strcmp(a->session_id, b->session_id);
|
||
if (result != 0) return result;
|
||
result = strcmp(a->entry_id, b->entry_id);
|
||
if (result != 0) return result;
|
||
if (a->kind != b->kind) return a->kind < b->kind ? -1 : 1;
|
||
return a->set_position == b->set_position ? 0 :
|
||
(a->set_position < b->set_position ? -1 : 1);
|
||
}
|
||
|
||
static int dashboard_performance_time_compare(const void *left, const void *right)
|
||
{
|
||
const TrainlogExercisePerformancePoint *a = left;
|
||
const TrainlogExercisePerformancePoint *b = right;
|
||
TrainlogTimestampKey a_key;
|
||
TrainlogTimestampKey b_key;
|
||
(void)trainlog_timestamp_parse(a->started_at, strlen(a->started_at), &a_key);
|
||
(void)trainlog_timestamp_parse(b->started_at, strlen(b->started_at), &b_key);
|
||
{
|
||
int result = trainlog_timestamp_compare(&a_key, &b_key);
|
||
return result != 0 ? result : strcmp(a->session_id, b->session_id);
|
||
}
|
||
}
|
||
|
||
static int dashboard_body_time_compare(const void *left, const void *right)
|
||
{
|
||
const TrainlogBodyMetricPoint *a = left;
|
||
const TrainlogBodyMetricPoint *b = right;
|
||
TrainlogTimestampKey a_key;
|
||
TrainlogTimestampKey b_key;
|
||
(void)trainlog_timestamp_parse(a->observed_at, strlen(a->observed_at), &a_key);
|
||
(void)trainlog_timestamp_parse(b->observed_at, strlen(b->observed_at), &b_key);
|
||
return trainlog_timestamp_compare(&a_key, &b_key);
|
||
}
|
||
|
||
static int64_t dashboard_floor_div(int64_t value, int64_t divisor)
|
||
{
|
||
return value >= 0 ? value / divisor : -((-value + divisor - 1) / divisor);
|
||
}
|
||
|
||
static int64_t dashboard_period_days(TrainlogStatisticsPeriod period)
|
||
{
|
||
switch (period) {
|
||
case TRAINLOG_STATS_7_DAYS: return 7;
|
||
case TRAINLOG_STATS_30_DAYS: return 30;
|
||
case TRAINLOG_STATS_90_DAYS: return 90;
|
||
case TRAINLOG_STATS_YEAR: return 365;
|
||
case TRAINLOG_STATS_ALL: return 0;
|
||
}
|
||
return 30;
|
||
}
|
||
|
||
static bool dashboard_in_period(const TrainlogTimestampKey *key,
|
||
TrainlogStatisticsPeriod period)
|
||
{
|
||
const int64_t days = dashboard_period_days(period);
|
||
/* Unix time and Trainlog's proleptic day zero differ by 719162 days. */
|
||
const int64_t now = (int64_t)time(NULL) + INT64_C(719162) * INT64_C(86400);
|
||
return key->utc_second <= now &&
|
||
(days == 0 || key->utc_second >= now - days * INT64_C(86400));
|
||
}
|
||
|
||
/* WHY: the selected period must change both dashboard charts, while the
|
||
* canonical rolling membership test above remains the sole inclusion rule.
|
||
* CONTRACT: 7/30/90/365 days use respectively daily, five-day, fifteen-day
|
||
* and roughly monthly (31-day) presentation buckets; Tout uses at most twelve
|
||
* equal day spans covering represented history. INVARIANT: every included
|
||
* local date is clamped into exactly one leading-to-current bucket. */
|
||
static void dashboard_prepare_period_buckets(TrainlogDashboardSnapshot *dashboard,
|
||
int64_t current_day, int64_t earliest_day)
|
||
{
|
||
size_t count;
|
||
int64_t span;
|
||
size_t index;
|
||
switch (dashboard->period) {
|
||
case TRAINLOG_STATS_7_DAYS: count = 7U; span = 1; break;
|
||
case TRAINLOG_STATS_30_DAYS: count = 6U; span = 5; break;
|
||
case TRAINLOG_STATS_90_DAYS: count = 6U; span = 15; break;
|
||
case TRAINLOG_STATS_YEAR: count = 12U; span = 31; break;
|
||
case TRAINLOG_STATS_ALL: {
|
||
int64_t represented = current_day >= earliest_day
|
||
? current_day - earliest_day + 1 : 1;
|
||
count = represented < (int64_t)DASHBOARD_BUCKET_COUNT
|
||
? (size_t)represented : DASHBOARD_BUCKET_COUNT;
|
||
if (count == 0U) count = 1U;
|
||
span = (represented + (int64_t)count - 1) / (int64_t)count;
|
||
break;
|
||
}
|
||
}
|
||
dashboard->week_count = count;
|
||
for (index = 0U; index < count; ++index) {
|
||
int64_t distance = (int64_t)(count - 1U - index) * span;
|
||
dashboard->weeks[index].start_day = current_day - distance - span + 1;
|
||
dashboard->weeks[index].span_days = span;
|
||
if (index + 1U == count)
|
||
(void)snprintf(dashboard->weeks[index].label,
|
||
sizeof(dashboard->weeks[index].label), "%s", "actuel");
|
||
else if (dashboard->period == TRAINLOG_STATS_7_DAYS)
|
||
(void)snprintf(dashboard->weeks[index].label,
|
||
sizeof(dashboard->weeks[index].label), "J−%zu", count - 1U - index);
|
||
else if (dashboard->period == TRAINLOG_STATS_YEAR)
|
||
(void)snprintf(dashboard->weeks[index].label,
|
||
sizeof(dashboard->weeks[index].label), "M−%zu", count - 1U - index);
|
||
else
|
||
(void)snprintf(dashboard->weeks[index].label,
|
||
sizeof(dashboard->weeks[index].label), "P−%zu", count - 1U - index);
|
||
}
|
||
}
|
||
|
||
static size_t dashboard_period_bucket(const TrainlogDashboardSnapshot *dashboard,
|
||
int64_t local_day)
|
||
{
|
||
int64_t first;
|
||
int64_t span;
|
||
size_t slot;
|
||
if (dashboard->week_count == 0U) return 0U;
|
||
first = dashboard->weeks[0].start_day;
|
||
span = dashboard->weeks[0].span_days;
|
||
if (local_day <= first) return 0U;
|
||
slot = (size_t)((local_day - first) / span);
|
||
return slot < dashboard->week_count ? slot : dashboard->week_count - 1U;
|
||
}
|
||
|
||
static const char *dashboard_period_bucket_summary(TrainlogStatisticsPeriod period)
|
||
{
|
||
switch (period) {
|
||
case TRAINLOG_STATS_7_DAYS: return "7 jours · quotidien";
|
||
case TRAINLOG_STATS_30_DAYS: return "30 jours · pas de 5 j";
|
||
case TRAINLOG_STATS_90_DAYS: return "90 jours · pas de 15 j";
|
||
case TRAINLOG_STATS_YEAR: return "1 an · pas de 31 j";
|
||
case TRAINLOG_STATS_ALL: return "Tout · 12 périodes max.";
|
||
}
|
||
return "Période";
|
||
}
|
||
|
||
static bool dashboard_fact_external_context_qualifies(TrainlogAppContext *app,
|
||
const TrainlogDashboardFact *fact)
|
||
{
|
||
TrainlogResolvedEquipment equipment;
|
||
if (fact->equipment_id[0] == '\0' ||
|
||
trainlog_database_resolve_equipment(app->database, fact->equipment_id,
|
||
&equipment) != TRAINLOG_STATUS_OK) return false;
|
||
return strcmp(equipment.load_semantics, "external") == 0;
|
||
}
|
||
|
||
static TrainlogExercise *dashboard_find_exercise(TrainlogExercise *exercises,
|
||
size_t count, const char *exercise_id)
|
||
{
|
||
size_t index;
|
||
for (index = 0U; index < count; ++index)
|
||
if (strcmp(exercises[index].exercise_id, exercise_id) == 0)
|
||
return &exercises[index];
|
||
return NULL;
|
||
}
|
||
|
||
static int dashboard_zone_bucket_compare(const void *left, const void *right)
|
||
{
|
||
const TrainlogDashboardZoneBucket *a = left;
|
||
const TrainlogDashboardZoneBucket *b = right;
|
||
if (a->count != b->count) return a->count > b->count ? -1 : 1;
|
||
return strcmp(a->label, b->label);
|
||
}
|
||
|
||
/* WHY: a body-zone dashboard must describe the current catalogue rather than
|
||
* infer classifications from history or commercial names. CONTRACT: one
|
||
* canonical exercise is counted once, using only its persisted primary body
|
||
* zone. INVARIANT: missing primary relations remain visible as "Non classés";
|
||
* secondary relations and aliases can neither create nor duplicate a bucket. */
|
||
static bool dashboard_load_zone_distribution(TrainlogAppContext *app,
|
||
const TrainlogExercise *exercises, size_t exercise_count)
|
||
{
|
||
TrainlogDashboardSnapshot *dashboard = &app->dashboard;
|
||
size_t exercise_index;
|
||
for (exercise_index = 0U; exercise_index < exercise_count; ++exercise_index) {
|
||
TrainlogExerciseBodyZone relations[MAX_BODY_ZONES];
|
||
const char *label = "Non classés";
|
||
size_t relation_count = 0U;
|
||
size_t relation_index;
|
||
size_t bucket;
|
||
if (trainlog_database_list_exercise_body_zones(app->database,
|
||
exercises[exercise_index].exercise_id, relations, MAX_BODY_ZONES,
|
||
&relation_count) != TRAINLOG_STATUS_OK) return false;
|
||
for (relation_index = 0U; relation_index < relation_count; ++relation_index) {
|
||
if (relations[relation_index].role == TRAINLOG_BODY_ZONE_PRIMARY) {
|
||
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(
|
||
relations[relation_index].zone_id);
|
||
if (zone != NULL && !zone->is_group) label = zone->display_name;
|
||
break;
|
||
}
|
||
}
|
||
for (bucket = 0U; bucket < dashboard->zone_count; ++bucket)
|
||
if (strcmp(dashboard->zones[bucket].label, label) == 0) break;
|
||
if (bucket == dashboard->zone_count) {
|
||
if (bucket == DASHBOARD_ZONE_BUCKET_CAPACITY) return false;
|
||
(void)snprintf(dashboard->zones[bucket].label,
|
||
sizeof(dashboard->zones[bucket].label), "%s", label);
|
||
++dashboard->zone_count;
|
||
}
|
||
++dashboard->zones[bucket].count;
|
||
}
|
||
qsort(dashboard->zones, dashboard->zone_count, sizeof(dashboard->zones[0]),
|
||
dashboard_zone_bucket_compare);
|
||
return true;
|
||
}
|
||
|
||
/* WHY: the landing page must remain useful without rendering-time SQL.
|
||
* CONTRACT: this bounded controller snapshot contains only actual persisted
|
||
* facts. One working-performance series has an exact exercise/equipment/load
|
||
* identity; explicit MAX is separate, and assistance/planned targets never
|
||
* enter it. All chronology is decided by Trainlog's timestamp parser, not
|
||
* SQLite text ordering. */
|
||
static void app_shell_load_dashboard(TrainlogAppContext *app)
|
||
{
|
||
TrainlogDashboardSnapshot *dashboard = &app->dashboard;
|
||
TrainlogStatisticsPeriod period = dashboard->period;
|
||
TrainlogExercise *exercises = NULL;
|
||
TrainlogDashboardFact *facts = NULL;
|
||
TrainlogBodyMetricPoint body[MAX_BODY_METRIC_POINTS];
|
||
TrainlogSessionSummary sessions[MAX_SESSIONS];
|
||
TrainlogTimestampKey newest_performance;
|
||
char newest_body[TRAINLOG_TIMESTAMP_MAX + 1U] = "";
|
||
size_t exercise_count = 0U;
|
||
size_t fact_count = 0U;
|
||
size_t session_count = 0U;
|
||
size_t exercise_index;
|
||
int64_t current_day;
|
||
int64_t earliest_day = INT64_MAX;
|
||
time_t now;
|
||
bool has_newest_performance = false;
|
||
bool facts_truncated = false;
|
||
bool facts_invalid = false;
|
||
|
||
(void)memset(dashboard, 0, sizeof(*dashboard));
|
||
dashboard->period = period;
|
||
facts = calloc(DASHBOARD_FACT_CAPACITY, sizeof(*facts));
|
||
if (facts == NULL) {
|
||
dashboard->error = true;
|
||
return;
|
||
}
|
||
if (trainlog_database_read_snapshot_begin(app->database) != TRAINLOG_STATUS_OK) {
|
||
dashboard->error = true;
|
||
free(facts);
|
||
return;
|
||
}
|
||
if (trainlog_database_list_sessions(app->database, sessions, MAX_SESSIONS,
|
||
&session_count) != TRAINLOG_STATUS_OK) {
|
||
dashboard->error = true;
|
||
goto finish;
|
||
}
|
||
dashboard->partial = session_count == MAX_SESSIONS;
|
||
/* CONTRACT: frequency is one count per actual session; a MAX marker is an
|
||
* attribute of that count and never a second session. Rolling membership
|
||
* uses canonical instants, while buckets use represented local dates.
|
||
* Presentation always ends at the canonical current day, so the bucket
|
||
* containing that day remains explicit even when it is zero. */
|
||
now = time(NULL);
|
||
if (now == (time_t)-1) {
|
||
dashboard->error = true;
|
||
goto finish;
|
||
}
|
||
current_day = dashboard_floor_div((int64_t)now +
|
||
INT64_C(719162) * INT64_C(86400), INT64_C(86400));
|
||
for (exercise_index = 0U; exercise_index < session_count; ++exercise_index) {
|
||
TrainlogTimestampKey key;
|
||
if (!trainlog_timestamp_parse(sessions[exercise_index].started_at,
|
||
strlen(sessions[exercise_index].started_at), &key)) {
|
||
dashboard->invalid_data = true;
|
||
continue;
|
||
}
|
||
if (!dashboard_in_period(&key, dashboard->period)) continue;
|
||
++dashboard->completed_session_count;
|
||
if (key.local_day < earliest_day) earliest_day = key.local_day;
|
||
}
|
||
dashboard_prepare_period_buckets(dashboard, current_day,
|
||
earliest_day == INT64_MAX ? current_day : earliest_day);
|
||
for (exercise_index = 0U; exercise_index < session_count; ++exercise_index) {
|
||
TrainlogTimestampKey key;
|
||
size_t slot;
|
||
if (!trainlog_timestamp_parse(sessions[exercise_index].started_at,
|
||
strlen(sessions[exercise_index].started_at), &key) ||
|
||
!dashboard_in_period(&key, dashboard->period)) continue;
|
||
slot = dashboard_period_bucket(dashboard, key.local_day);
|
||
++dashboard->weeks[slot].sessions;
|
||
if (sessions[exercise_index].session_type == TRAINLOG_SESSION_MAX_TEST)
|
||
++dashboard->weeks[slot].maxima;
|
||
}
|
||
if (trainlog_database_list_exercises(app->database, NULL, 0U,
|
||
&exercise_count) != TRAINLOG_STATUS_OK ||
|
||
exercise_count > SIZE_MAX / sizeof(*exercises)) {
|
||
dashboard->error = true;
|
||
goto finish;
|
||
}
|
||
if (exercise_count > 0U) {
|
||
exercises = calloc(exercise_count, sizeof(*exercises));
|
||
if (exercises == NULL || trainlog_database_list_exercises(app->database,
|
||
exercises, exercise_count, &exercise_count) != TRAINLOG_STATUS_OK) {
|
||
dashboard->error = true;
|
||
goto finish;
|
||
}
|
||
}
|
||
if (!dashboard_load_zone_distribution(app, exercises, exercise_count)) {
|
||
dashboard->error = true;
|
||
goto finish;
|
||
}
|
||
if (dashboard_database_list_facts(app->database, facts,
|
||
DASHBOARD_FACT_CAPACITY, &fact_count, &facts_truncated,
|
||
&facts_invalid) != TRAINLOG_STATUS_OK) {
|
||
dashboard->error = true;
|
||
goto finish;
|
||
}
|
||
dashboard->partial = dashboard->partial || facts_truncated;
|
||
dashboard->invalid_data = dashboard->invalid_data || facts_invalid;
|
||
qsort(facts, fact_count, sizeof(facts[0]), dashboard_fact_time_compare);
|
||
for (exercise_index = 0U; exercise_index < fact_count; ++exercise_index) {
|
||
TrainlogDashboardFact *fact = &facts[exercise_index];
|
||
TrainlogExercise *exercise;
|
||
bool external;
|
||
bool comparable_work;
|
||
bool comparable_max;
|
||
size_t prior;
|
||
bool has_prior = false;
|
||
double prior_best = 0.0;
|
||
exercise = dashboard_find_exercise(exercises, exercise_count,
|
||
fact->exercise_id);
|
||
if (!dashboard_in_period(&fact->time, dashboard->period)) continue;
|
||
if (fact->kind == TRAINLOG_DASHBOARD_WORK)
|
||
++dashboard->performed_set_count;
|
||
else if (fact->kind == TRAINLOG_DASHBOARD_MAX)
|
||
++dashboard->explicit_max_count;
|
||
{
|
||
size_t seen;
|
||
for (seen = 0U; seen < exercise_index; ++seen)
|
||
if (dashboard_in_period(&facts[seen].time, dashboard->period) &&
|
||
strcmp(facts[seen].exercise_id, fact->exercise_id) == 0)
|
||
break;
|
||
if (seen == exercise_index) ++dashboard->distinct_exercise_count;
|
||
}
|
||
external = dashboard_fact_external_context_qualifies(app, fact);
|
||
comparable_work = fact->kind == TRAINLOG_DASHBOARD_WORK && external &&
|
||
fact->load_mode == TRAINLOG_LOAD_EXTERNAL && fact->dose > 0 &&
|
||
fact->has_weight && isfinite(fact->weight_kg) &&
|
||
fact->weight_kg >= 0.0;
|
||
comparable_max = fact->kind == TRAINLOG_DASHBOARD_MAX && external &&
|
||
fact->load_mode != TRAINLOG_LOAD_ASSISTANCE && fact->weight_kg > 0.0;
|
||
|
||
/* CONTRACT: Android and TUI compare the same actual observations:
|
||
* exact exercise + nonempty resolved external equipment + external
|
||
* load semantics + exact performed dose. Only facts at a strictly
|
||
* earlier canonical instant establish the prior best; bytewise IDs
|
||
* order equal-instant presentation but never manufacture an event. */
|
||
if (comparable_work || comparable_max) {
|
||
for (prior = 0U; prior < exercise_index; ++prior) {
|
||
TrainlogDashboardFact *old = &facts[prior];
|
||
bool same = old->kind == fact->kind &&
|
||
strcmp(old->exercise_id, fact->exercise_id) == 0 &&
|
||
strcmp(old->equipment_id, fact->equipment_id) == 0 &&
|
||
old->tracking_mode == fact->tracking_mode &&
|
||
old->load_mode == fact->load_mode &&
|
||
(fact->kind != TRAINLOG_DASHBOARD_WORK ||
|
||
old->dose == fact->dose);
|
||
if (!same || trainlog_timestamp_compare(&old->time,
|
||
&fact->time) >= 0) continue;
|
||
if (!has_prior || old->weight_kg > prior_best)
|
||
prior_best = old->weight_kg;
|
||
has_prior = true;
|
||
}
|
||
if (has_prior && fact->weight_kg > prior_best &&
|
||
dashboard->week_count > 0U) {
|
||
size_t slot = dashboard_period_bucket(dashboard,
|
||
fact->time.local_day);
|
||
if (comparable_work)
|
||
++dashboard->weeks[slot].working_improvements;
|
||
else ++dashboard->weeks[slot].max_improvements;
|
||
}
|
||
}
|
||
if (comparable_max && exercise != NULL) {
|
||
TrainlogTimestampKey current_max;
|
||
int order = 1;
|
||
if (dashboard->has_explicit_max &&
|
||
trainlog_timestamp_parse(dashboard->max_timestamp,
|
||
strlen(dashboard->max_timestamp), ¤t_max))
|
||
order = trainlog_timestamp_compare(&fact->time, ¤t_max);
|
||
if (!dashboard->has_explicit_max || order > 0 ||
|
||
(order == 0 && strcmp(fact->exercise_id,
|
||
dashboard->max_exercise.exercise_id) < 0)) {
|
||
dashboard->has_explicit_max = true;
|
||
dashboard->max_exercise = *exercise;
|
||
dashboard->max_weight_kg = fact->weight_kg;
|
||
(void)snprintf(dashboard->max_timestamp,
|
||
sizeof(dashboard->max_timestamp), "%s", fact->started_at);
|
||
}
|
||
}
|
||
if (comparable_work && exercise != NULL) {
|
||
int order = has_newest_performance ?
|
||
trainlog_timestamp_compare(&fact->time, &newest_performance) : 1;
|
||
if (!has_newest_performance || order > 0 ||
|
||
(order == 0 && strcmp(fact->exercise_id,
|
||
dashboard->exercise.exercise_id) < 0)) {
|
||
dashboard->has_performance = true;
|
||
dashboard->exercise = *exercise;
|
||
dashboard->performance_load_mode = fact->load_mode;
|
||
dashboard->performance_tracking_mode = fact->tracking_mode;
|
||
dashboard->performance_dose = fact->dose;
|
||
(void)snprintf(dashboard->performance_equipment_id,
|
||
sizeof(dashboard->performance_equipment_id), "%s",
|
||
fact->equipment_id);
|
||
newest_performance = fact->time;
|
||
has_newest_performance = true;
|
||
}
|
||
}
|
||
}
|
||
if (dashboard->has_performance) {
|
||
TrainlogResolvedEquipment equipment;
|
||
size_t index;
|
||
if (trainlog_database_resolve_equipment(app->database,
|
||
dashboard->performance_equipment_id, &equipment) ==
|
||
TRAINLOG_STATUS_OK)
|
||
(void)snprintf(dashboard->performance_equipment_label,
|
||
sizeof(dashboard->performance_equipment_label), "%s",
|
||
equipment.display_name);
|
||
for (index = 0U; index < fact_count &&
|
||
dashboard->performance_count < MAX_SESSIONS; ++index) {
|
||
TrainlogDashboardFact *fact = &facts[index];
|
||
TrainlogExercisePerformancePoint *point;
|
||
if (fact->kind != TRAINLOG_DASHBOARD_WORK || fact->dose <= 0 ||
|
||
fact->load_mode != dashboard->performance_load_mode ||
|
||
fact->tracking_mode != dashboard->performance_tracking_mode ||
|
||
fact->dose != dashboard->performance_dose ||
|
||
strcmp(fact->exercise_id, dashboard->exercise.exercise_id) != 0 ||
|
||
strcmp(fact->equipment_id,
|
||
dashboard->performance_equipment_id) != 0 ||
|
||
!dashboard_in_period(&fact->time, dashboard->period)) continue;
|
||
point = &dashboard->performance[dashboard->performance_count++];
|
||
(void)memset(point, 0, sizeof(*point));
|
||
(void)snprintf(point->session_id, sizeof(point->session_id), "%s",
|
||
fact->session_id);
|
||
(void)snprintf(point->started_at, sizeof(point->started_at), "%s",
|
||
fact->started_at);
|
||
point->tracking_mode = fact->tracking_mode;
|
||
point->load_mode = fact->load_mode;
|
||
point->actual_set_count = 1U;
|
||
point->has_performance = 1;
|
||
point->metric_value = fact->dose;
|
||
point->has_weight = 1;
|
||
point->weight_kg = fact->weight_kg;
|
||
(void)snprintf(point->equipment_id, sizeof(point->equipment_id), "%s",
|
||
fact->equipment_id);
|
||
}
|
||
if (dashboard->performance_count == MAX_SESSIONS)
|
||
dashboard->partial = true;
|
||
qsort(dashboard->performance, dashboard->performance_count,
|
||
sizeof(dashboard->performance[0]), dashboard_performance_time_compare);
|
||
}
|
||
|
||
for (exercise_index = 0U; exercise_index < 14U; ++exercise_index) {
|
||
size_t count = 0U;
|
||
size_t point_index;
|
||
if (trainlog_database_list_body_metric_points(app->database,
|
||
BODY_METRICS[exercise_index].metric, body,
|
||
MAX_BODY_METRIC_POINTS, &count) != TRAINLOG_STATUS_OK) {
|
||
dashboard->error = true;
|
||
continue;
|
||
}
|
||
if (count == MAX_BODY_METRIC_POINTS) dashboard->partial = true;
|
||
for (point_index = 0U; point_index < count; ++point_index) {
|
||
TrainlogTimestampKey candidate;
|
||
TrainlogTimestampKey current;
|
||
int order = 1;
|
||
if (!trainlog_timestamp_parse(body[point_index].observed_at,
|
||
strlen(body[point_index].observed_at), &candidate)) {
|
||
dashboard->invalid_data = true;
|
||
continue;
|
||
}
|
||
if (!dashboard_in_period(&candidate, dashboard->period)) continue;
|
||
if (newest_body[0] != '\0') {
|
||
(void)trainlog_timestamp_parse(newest_body, strlen(newest_body), ¤t);
|
||
order = trainlog_timestamp_compare(&candidate, ¤t);
|
||
}
|
||
if (newest_body[0] == '\0' || order > 0 ||
|
||
(order == 0 && exercise_index < dashboard->body_metric)) {
|
||
dashboard->has_body = true;
|
||
dashboard->body_metric = exercise_index;
|
||
(void)snprintf(newest_body, sizeof(newest_body), "%s",
|
||
body[point_index].observed_at);
|
||
}
|
||
}
|
||
}
|
||
if (dashboard->has_body) {
|
||
size_t count = 0U;
|
||
size_t point_index;
|
||
if (trainlog_database_list_body_metric_points(app->database,
|
||
BODY_METRICS[dashboard->body_metric].metric, body,
|
||
MAX_BODY_METRIC_POINTS, &count) != TRAINLOG_STATUS_OK) {
|
||
dashboard->error = true;
|
||
goto finish;
|
||
}
|
||
if (count == MAX_BODY_METRIC_POINTS)
|
||
dashboard->partial = true;
|
||
for (point_index = 0U; point_index < count; ++point_index) {
|
||
TrainlogTimestampKey key;
|
||
if (!trainlog_timestamp_parse(body[point_index].observed_at,
|
||
strlen(body[point_index].observed_at), &key)) {
|
||
dashboard->invalid_data = true;
|
||
continue;
|
||
}
|
||
if (dashboard_in_period(&key, dashboard->period))
|
||
dashboard->body[dashboard->body_count++] = body[point_index];
|
||
}
|
||
qsort(dashboard->body, dashboard->body_count, sizeof(dashboard->body[0]),
|
||
dashboard_body_time_compare);
|
||
}
|
||
|
||
finish:
|
||
if (trainlog_database_read_snapshot_end(app->database, !dashboard->error) !=
|
||
TRAINLOG_STATUS_OK) dashboard->error = true;
|
||
free(exercises);
|
||
free(facts);
|
||
}
|
||
|
||
static void sync_controller_load_history(TrainlogSyncController *sync)
|
||
{
|
||
char selected_id[TRAINLOG_ID_MAX + 1U] = "";
|
||
size_t index;
|
||
if (sync->selected < sync->history_count)
|
||
(void)snprintf(selected_id, sizeof(selected_id), "%s",
|
||
sync->history[sync->selected].sync_id);
|
||
sync_history_load(sync->history, SYNC_HISTORY_CAPACITY,
|
||
&sync->history_count);
|
||
if (sync->history_count == 0U) sync->selected = 0U;
|
||
else if (selected_id[0] != '\0') {
|
||
for (index = 0U; index < sync->history_count; ++index)
|
||
if (strcmp(sync->history[index].sync_id, selected_id) == 0) {
|
||
sync->selected = index;
|
||
break;
|
||
}
|
||
if (index == sync->history_count && sync->selected >= sync->history_count)
|
||
sync->selected = sync->history_count - 1U;
|
||
} else if (sync->selected >= sync->history_count)
|
||
sync->selected = sync->history_count - 1U;
|
||
}
|
||
|
||
static void sync_controller_open_detail(TrainlogSyncController *sync)
|
||
{
|
||
const TrainlogSyncHistoryEntry *entry;
|
||
char path[PATH_MAX + 1U];
|
||
FILE *file;
|
||
size_t used;
|
||
char *cursor;
|
||
sync->detail_line_count = 0U;
|
||
sync->detail_scroll = 0U;
|
||
sync->showing_detail = false;
|
||
if (sync->history_count == 0U || sync->selected >= sync->history_count)
|
||
return;
|
||
entry = &sync->history[sync->selected];
|
||
if (entry->sync_id[0] == '\0') return;
|
||
/* CONTRACT: known legacy one-way records remain readable internal history,
|
||
* but their stored diagnostic prose predates the unified PC↔Android TUI and
|
||
* may expose prohibited directional modes. Present only parsed neutral
|
||
* metadata; never rewrite or reinterpret the retained artifact. */
|
||
if (entry->direction_known &&
|
||
entry->direction != TRAINLOG_SYNC_BIDIRECTIONAL) {
|
||
(void)snprintf(sync->detail_text, sizeof(sync->detail_text),
|
||
"Synchronisation historique PC↔Android\n"
|
||
"Horodatage : %s\n"
|
||
"État : %s\n"
|
||
"Détail hérité masqué (entrée historique)",
|
||
entry->timestamp, entry->success ? "réussie" : "échouée");
|
||
sync->detail_lines[sync->detail_line_count++] = sync->detail_text;
|
||
cursor = sync->detail_text;
|
||
while (*cursor != '\0' &&
|
||
sync->detail_line_count < SYNC_DETAIL_LINE_CAPACITY) {
|
||
if (*cursor == '\n') {
|
||
*cursor = '\0';
|
||
if (cursor[1] != '\0')
|
||
sync->detail_lines[sync->detail_line_count++] = cursor + 1;
|
||
}
|
||
++cursor;
|
||
}
|
||
sync->showing_detail = true;
|
||
return;
|
||
}
|
||
if (!sync_runs_path(entry->sync_id,
|
||
path, sizeof(path))) return;
|
||
file = fopen(path, "rb");
|
||
if (file == NULL) return;
|
||
used = fread(sync->detail_text, 1U,
|
||
sizeof(sync->detail_text) - 1U, file);
|
||
sync->detail_text[used] = '\0';
|
||
(void)fclose(file);
|
||
if (used == 0U) return;
|
||
cursor = sync->detail_text;
|
||
sync->detail_lines[sync->detail_line_count++] = cursor;
|
||
while (*cursor != '\0' &&
|
||
sync->detail_line_count < SYNC_DETAIL_LINE_CAPACITY) {
|
||
if (*cursor == '\n') {
|
||
*cursor = '\0';
|
||
if (cursor[1] != '\0')
|
||
sync->detail_lines[sync->detail_line_count++] = cursor + 1;
|
||
}
|
||
++cursor;
|
||
}
|
||
sync->showing_detail = true;
|
||
}
|
||
|
||
static const char *sync_controller_status_error(TrainlogStatus status)
|
||
{
|
||
switch (status) {
|
||
case TRAINLOG_STATUS_CONFLICT:
|
||
return "Synchronisation refusée : une autre exécution est active.";
|
||
case TRAINLOG_STATUS_NOT_FOUND:
|
||
return "Synchronisation impossible : aucun appareil MTP Trainlog détecté.";
|
||
case TRAINLOG_STATUS_INVALID_ARGUMENT:
|
||
return "Synchronisation refusée : données ou demande invalides.";
|
||
case TRAINLOG_STATUS_DATABASE_ERROR:
|
||
return "Synchronisation interrompue : erreur de base de données locale.";
|
||
case TRAINLOG_STATUS_SCHEMA_UNSUPPORTED:
|
||
return "Synchronisation refusée : version de données non prise en charge.";
|
||
case TRAINLOG_STATUS_SYSTEM_ERROR:
|
||
return "Synchronisation interrompue : erreur d’accès appareil ou fichier.";
|
||
case TRAINLOG_STATUS_OK:
|
||
default:
|
||
return "Synchronisation interrompue sans rapport de réussite.";
|
||
}
|
||
}
|
||
|
||
static void app_shell_open_exercise_analytics(TrainlogAppContext *app,
|
||
TrainlogAppRoute route)
|
||
{
|
||
app->performance_count = 0U;
|
||
app->performance_error = trainlog_database_list_exercise_performance(
|
||
app->database, app->exercise_detail.exercise_id,
|
||
app->performance_points, MAX_SESSIONS, &app->performance_count) !=
|
||
TRAINLOG_STATUS_OK;
|
||
if (!app->performance_error && route == TRAINLOG_ROUTE_EXERCISE_MAX)
|
||
app->performance_error = trainlog_measured_max_summarize(
|
||
app->performance_points, app->performance_count,
|
||
&app->max_summary) != TRAINLOG_STATUS_OK;
|
||
(void)trainlog_navigation_open(&app->navigation, route,
|
||
app->exercise_detail.exercise_id);
|
||
app->content_scroll = 0U;
|
||
}
|
||
|
||
static void equipment_controller_start(TrainlogAppContext *app)
|
||
{
|
||
TrainlogEquipmentController *controller = &app->equipment_controller;
|
||
(void)memset(controller, 0, sizeof(*controller));
|
||
controller->phase = TRAINLOG_EQUIPMENT_NAME;
|
||
controller->return_phase = TRAINLOG_EQUIPMENT_NAME;
|
||
controller->load_choice = 2;
|
||
trainlog_form_init(&controller->form, "");
|
||
app->focus = TRAINLOG_FOCUS_EDITOR;
|
||
}
|
||
|
||
static void equipment_controller_open_field(TrainlogEquipmentController *controller,
|
||
TrainlogEquipmentPhase phase,
|
||
const char *value)
|
||
{
|
||
controller->phase = phase;
|
||
controller->return_phase = phase;
|
||
controller->message[0] = '\0';
|
||
trainlog_form_init(&controller->form, value);
|
||
}
|
||
|
||
static bool equipment_controller_save(TrainlogAppContext *app)
|
||
{
|
||
TrainlogEquipmentController *controller = &app->equipment_controller;
|
||
TrainlogStatus status;
|
||
generate_custom_equipment_uuid(controller->pending.equipment_id);
|
||
(void)snprintf(controller->pending.load_semantics,
|
||
sizeof(controller->pending.load_semantics), "%s",
|
||
controller->load_choice == 1 ? "none" :
|
||
controller->load_choice == 2 ? "external" : "assistance");
|
||
status = trainlog_database_create_custom_equipment(app->database,
|
||
&controller->pending);
|
||
if (status != TRAINLOG_STATUS_OK) {
|
||
(void)snprintf(controller->message, sizeof(controller->message), "%s",
|
||
status == TRAINLOG_STATUS_CONFLICT
|
||
? "Conflit de définition; les champs sont conservés."
|
||
: "Équipement non créé : définition invalide ou conflit.");
|
||
controller->phase = TRAINLOG_EQUIPMENT_LOAD;
|
||
return false;
|
||
}
|
||
controller->dirty = false;
|
||
controller->form.active = false;
|
||
controller->phase = TRAINLOG_EQUIPMENT_MESSAGE;
|
||
(void)snprintf(controller->message, sizeof(controller->message),
|
||
"Équipement personnel créé : %.120s", controller->pending.display_name);
|
||
app_shell_refresh_list(app);
|
||
if (!copy_ui_stable_id(app->list.selected_id, sizeof(app->list.selected_id),
|
||
controller->pending.equipment_id)) return false;
|
||
trainlog_list_set_items(&app->list, app->stable_ids, app->loaded_count,
|
||
app->list.visible_rows, app->list.total_known, app->list.more_available);
|
||
return true;
|
||
}
|
||
|
||
static bool app_shell_dispatch_equipment_controller(TrainlogAppContext *app,
|
||
int key)
|
||
{
|
||
TrainlogEquipmentController *controller = &app->equipment_controller;
|
||
TrainlogFormResult result;
|
||
if (controller->phase == TRAINLOG_EQUIPMENT_IDLE) return false;
|
||
if (controller->phase == TRAINLOG_EQUIPMENT_MESSAGE) {
|
||
if (key == TRAINLOG_KEY_ENTER || key == '\n' ||
|
||
key == TRAINLOG_KEY_ESCAPE) {
|
||
controller->phase = TRAINLOG_EQUIPMENT_IDLE;
|
||
controller->message[0] = '\0';
|
||
app->focus = TRAINLOG_FOCUS_CONTENT;
|
||
}
|
||
return true;
|
||
}
|
||
if (controller->phase == TRAINLOG_EQUIPMENT_CONFIRM_DISCARD) {
|
||
if (key == '1') {
|
||
bool leaving = controller->leaving;
|
||
TrainlogAppRoute pending_route = controller->pending_route;
|
||
(void)memset(controller, 0, sizeof(*controller));
|
||
app->focus = TRAINLOG_FOCUS_CONTENT;
|
||
if (leaving) app_shell_open_route(app, pending_route);
|
||
} else if (key == '0' || key == TRAINLOG_KEY_ESCAPE)
|
||
controller->phase = controller->return_phase;
|
||
return true;
|
||
}
|
||
if (controller->phase == TRAINLOG_EQUIPMENT_LOAD) {
|
||
if (key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_LEFT)
|
||
controller->load_choice = controller->load_choice > 1
|
||
? controller->load_choice - 1 : 3;
|
||
else if (key == TRAINLOG_KEY_DOWN || key == TRAINLOG_KEY_RIGHT)
|
||
controller->load_choice = controller->load_choice < 3
|
||
? controller->load_choice + 1 : 1;
|
||
else if (key >= '1' && key <= '3')
|
||
controller->load_choice = key - '0';
|
||
else if (key == TRAINLOG_KEY_ENTER || key == '\n')
|
||
(void)equipment_controller_save(app);
|
||
else if (key == TRAINLOG_KEY_ESCAPE) {
|
||
controller->leaving = false;
|
||
controller->return_phase = TRAINLOG_EQUIPMENT_LOAD;
|
||
controller->phase = TRAINLOG_EQUIPMENT_CONFIRM_DISCARD;
|
||
} else if (key == TRAINLOG_KEY_F6)
|
||
(void)trainlog_overlays_push(&app->overlays,
|
||
TRAINLOG_OVERLAY_NAVIGATION, app->focus,
|
||
app->navigation.current.stable_id);
|
||
else if (key == TRAINLOG_KEY_F7)
|
||
(void)trainlog_overlays_push(&app->overlays,
|
||
TRAINLOG_OVERLAY_ACTIONS, app->focus,
|
||
app->navigation.current.stable_id);
|
||
return true;
|
||
}
|
||
result = trainlog_form_handle(&controller->form, key);
|
||
if (result == TRAINLOG_FORM_OPEN_NAVIGATION) {
|
||
(void)trainlog_overlays_push(&app->overlays,
|
||
TRAINLOG_OVERLAY_NAVIGATION, app->focus,
|
||
app->navigation.current.stable_id);
|
||
return true;
|
||
}
|
||
if (result == TRAINLOG_FORM_OPEN_ACTIONS) {
|
||
(void)trainlog_overlays_push(&app->overlays,
|
||
TRAINLOG_OVERLAY_ACTIONS, app->focus,
|
||
app->navigation.current.stable_id);
|
||
return true;
|
||
}
|
||
if (result == TRAINLOG_FORM_CANCEL) {
|
||
controller->leaving = false;
|
||
controller->return_phase = controller->phase;
|
||
controller->phase = TRAINLOG_EQUIPMENT_CONFIRM_DISCARD;
|
||
return true;
|
||
}
|
||
if (result == TRAINLOG_FORM_EDITED) {
|
||
controller->dirty = true;
|
||
controller->message[0] = '\0';
|
||
return true;
|
||
}
|
||
if (result != TRAINLOG_FORM_SUBMIT && result != TRAINLOG_FORM_NEXT)
|
||
return true;
|
||
if ((controller->phase == TRAINLOG_EQUIPMENT_NAME ||
|
||
controller->phase == TRAINLOG_EQUIPMENT_TYPE) &&
|
||
controller->form.text[0] == '\0') {
|
||
(void)snprintf(controller->message, sizeof(controller->message),
|
||
"Ce champ est obligatoire; la saisie est conservée.");
|
||
return true;
|
||
}
|
||
if (controller->phase == TRAINLOG_EQUIPMENT_NAME) {
|
||
(void)snprintf(controller->pending.display_name,
|
||
sizeof(controller->pending.display_name), "%s", controller->form.text);
|
||
equipment_controller_open_field(controller, TRAINLOG_EQUIPMENT_LABEL,
|
||
controller->pending.label_name);
|
||
} else if (controller->phase == TRAINLOG_EQUIPMENT_LABEL) {
|
||
(void)snprintf(controller->pending.label_name,
|
||
sizeof(controller->pending.label_name), "%s", controller->form.text);
|
||
equipment_controller_open_field(controller, TRAINLOG_EQUIPMENT_TYPE,
|
||
controller->pending.equipment_type);
|
||
} else {
|
||
(void)snprintf(controller->pending.equipment_type,
|
||
sizeof(controller->pending.equipment_type), "%s", controller->form.text);
|
||
controller->form.active = false;
|
||
controller->phase = TRAINLOG_EQUIPMENT_LOAD;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
static void exercise_controller_start_create(TrainlogAppContext *app,
|
||
bool inline_session)
|
||
{
|
||
TrainlogExerciseController *controller = &app->exercise_controller;
|
||
(void)memset(controller, 0, sizeof(*controller));
|
||
controller->phase = TRAINLOG_EXERCISE_NAME;
|
||
controller->return_phase = TRAINLOG_EXERCISE_NAME;
|
||
controller->pending.tracking_mode = TRAINLOG_TRACKING_REPS;
|
||
controller->pending.recording_mode = TRAINLOG_RECORDING_SETS;
|
||
controller->inline_session = inline_session;
|
||
trainlog_form_init(&controller->form, "");
|
||
app->focus = TRAINLOG_FOCUS_EDITOR;
|
||
}
|
||
|
||
static void exercise_controller_start_edit(TrainlogAppContext *app)
|
||
{
|
||
TrainlogExerciseController *controller = &app->exercise_controller;
|
||
(void)memset(controller, 0, sizeof(*controller));
|
||
controller->pending = app->exercise_detail;
|
||
controller->editing = true;
|
||
controller->phase = TRAINLOG_EXERCISE_NAME;
|
||
controller->return_phase = TRAINLOG_EXERCISE_NAME;
|
||
if (!load_exercise_body_zones(app->database,
|
||
controller->pending.exercise_id, controller->primary_zone,
|
||
controller->secondary_zones, &controller->secondary_count)) {
|
||
controller->phase = TRAINLOG_EXERCISE_MESSAGE;
|
||
(void)snprintf(controller->message, sizeof(controller->message),
|
||
"Impossible de lire les zones actuelles.");
|
||
} else trainlog_form_init(&controller->form, controller->pending.name);
|
||
app->focus = TRAINLOG_FOCUS_EDITOR;
|
||
}
|
||
|
||
static bool exercise_merge_matches(const TrainlogExercise *exercise,
|
||
const char *query)
|
||
{
|
||
char normalized_name[(TRAINLOG_NAME_MAX * 4U) + 1U];
|
||
char normalized_query[(TRAINLOG_NAME_MAX * 4U) + 1U];
|
||
if (query == NULL || query[0] == '\0') return true;
|
||
if (trainlog_catalog_normalize_name(exercise->name, normalized_name,
|
||
sizeof(normalized_name)) != TRAINLOG_STATUS_OK ||
|
||
trainlog_catalog_normalize_name(query, normalized_query,
|
||
sizeof(normalized_query)) != TRAINLOG_STATUS_OK) return false;
|
||
return strstr(normalized_name, normalized_query) != NULL;
|
||
}
|
||
|
||
static size_t exercise_merge_match_count(const TrainlogExerciseController *controller)
|
||
{
|
||
size_t index, count = 0U;
|
||
for (index = 0U; index < controller->merge_count; ++index)
|
||
if (exercise_merge_matches(&controller->merge_candidates[index],
|
||
controller->form.text)) ++count;
|
||
return count;
|
||
}
|
||
|
||
static TrainlogExercise *exercise_merge_match_at(TrainlogExerciseController *controller,
|
||
size_t selected)
|
||
{
|
||
size_t index, match = 0U;
|
||
for (index = 0U; index < controller->merge_count; ++index) {
|
||
if (!exercise_merge_matches(&controller->merge_candidates[index],
|
||
controller->form.text)) continue;
|
||
if (match == selected) return &controller->merge_candidates[index];
|
||
++match;
|
||
}
|
||
return NULL;
|
||
}
|
||
|
||
static bool exercise_merge_candidate_is_compatible(
|
||
TrainlogDatabase *database,
|
||
const TrainlogExercise *source,
|
||
const TrainlogExercise *target
|
||
)
|
||
{
|
||
TrainlogExerciseBodyZone source_zones[MAX_BODY_ZONES];
|
||
TrainlogExerciseBodyZone target_zones[MAX_BODY_ZONES];
|
||
size_t source_count = 0U, target_count = 0U, index;
|
||
const char *source_primary = NULL, *target_primary = NULL;
|
||
if (source->tracking_mode != target->tracking_mode ||
|
||
source->recording_mode != target->recording_mode ||
|
||
source->data_fields != target->data_fields) return false;
|
||
if (trainlog_database_list_exercise_body_zones(database,
|
||
source->exercise_id, source_zones, MAX_BODY_ZONES,
|
||
&source_count) != TRAINLOG_STATUS_OK ||
|
||
trainlog_database_list_exercise_body_zones(database,
|
||
target->exercise_id, target_zones, MAX_BODY_ZONES,
|
||
&target_count) != TRAINLOG_STATUS_OK) return false;
|
||
for (index = 0U; index < source_count; ++index)
|
||
if (source_zones[index].role == TRAINLOG_BODY_ZONE_PRIMARY)
|
||
source_primary = source_zones[index].zone_id;
|
||
for (index = 0U; index < target_count; ++index)
|
||
if (target_zones[index].role == TRAINLOG_BODY_ZONE_PRIMARY)
|
||
target_primary = target_zones[index].zone_id;
|
||
return source_primary == NULL || target_primary == NULL ||
|
||
strcmp(source_primary, target_primary) == 0;
|
||
}
|
||
|
||
static void exercise_controller_start_merge(TrainlogAppContext *app)
|
||
{
|
||
TrainlogExerciseController *controller = &app->exercise_controller;
|
||
TrainlogExercise all[MAX_EXERCISES];
|
||
size_t index, count = 0U;
|
||
TrainlogStatus status = trainlog_database_list_exercises(app->database, all,
|
||
MAX_EXERCISES, &count);
|
||
(void)memset(controller, 0, sizeof(*controller));
|
||
controller->merge_source = app->exercise_detail;
|
||
if (status != TRAINLOG_STATUS_OK) {
|
||
controller->phase = TRAINLOG_EXERCISE_MERGE_RESULT;
|
||
(void)snprintf(controller->message, sizeof(controller->message),
|
||
"Impossible de charger les cibles de fusion.");
|
||
} else {
|
||
for (index = 0U; index < count; ++index)
|
||
if (strcmp(all[index].exercise_id,
|
||
controller->merge_source.exercise_id) != 0 &&
|
||
exercise_merge_candidate_is_compatible(app->database,
|
||
&controller->merge_source, &all[index]))
|
||
controller->merge_candidates[controller->merge_count++] = all[index];
|
||
controller->phase = TRAINLOG_EXERCISE_MERGE_PICKER;
|
||
trainlog_form_init(&controller->form, "");
|
||
}
|
||
(void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_EXERCISE_MERGE,
|
||
TRAINLOG_FOCUS_CONTENT, controller->merge_source.exercise_id);
|
||
app->focus = TRAINLOG_FOCUS_OVERLAY;
|
||
}
|
||
|
||
static bool exercise_controller_save(TrainlogAppContext *app)
|
||
{
|
||
TrainlogExerciseController *controller = &app->exercise_controller;
|
||
const char *secondary_ids[MAX_BODY_ZONES];
|
||
TrainlogExercise created;
|
||
TrainlogStatus status;
|
||
char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U];
|
||
size_t index;
|
||
if (!controller->editing &&
|
||
controller->pending.recording_mode == TRAINLOG_RECORDING_SETS &&
|
||
controller->primary_zone[0] == '\0') {
|
||
(void)snprintf(controller->message, sizeof(controller->message),
|
||
"Une zone principale est requise pour un exercice par séries.");
|
||
return false;
|
||
}
|
||
for (index = 0U; index < controller->secondary_count; ++index)
|
||
secondary_ids[index] = controller->secondary_zones[index];
|
||
if (controller->editing) {
|
||
status = trainlog_catalog_normalize_name(controller->pending.name,
|
||
normalized, sizeof(normalized));
|
||
if (status == TRAINLOG_STATUS_OK)
|
||
status = trainlog_database_update_exercise_profiled(app->database,
|
||
controller->pending.exercise_id, controller->pending.name,
|
||
normalized, controller->pending.tracking_mode,
|
||
controller->pending.recording_mode,
|
||
controller->pending.data_fields,
|
||
controller->primary_zone[0] != '\0'
|
||
? controller->primary_zone : NULL,
|
||
secondary_ids, controller->secondary_count);
|
||
created = controller->pending;
|
||
} else status = trainlog_catalog_create_exercise_profiled_with_zones(
|
||
app->database, controller->pending.name,
|
||
controller->pending.tracking_mode,
|
||
controller->pending.recording_mode,
|
||
controller->pending.data_fields,
|
||
controller->primary_zone[0] != '\0' ? controller->primary_zone : NULL,
|
||
secondary_ids, controller->secondary_count, &created);
|
||
if (status != TRAINLOG_STATUS_OK) {
|
||
(void)snprintf(controller->message, sizeof(controller->message), "%s",
|
||
status == TRAINLOG_STATUS_CONFLICT
|
||
? "Conflit de nom ou de profil; tous les champs sont conservés."
|
||
: "Exercice non enregistré; tous les champs sont conservés.");
|
||
return false;
|
||
}
|
||
controller->dirty = false;
|
||
controller->form.active = false;
|
||
controller->pending = created;
|
||
if (controller->inline_session) {
|
||
TrainlogSessionController *session = &app->session;
|
||
if (!session_controller_load_picker(app)) {
|
||
controller->phase = TRAINLOG_EXERCISE_MESSAGE;
|
||
(void)snprintf(controller->message, sizeof(controller->message),
|
||
"Exercice créé, mais le sélecteur n’a pas pu être rechargé.");
|
||
return true;
|
||
}
|
||
for (index = 0U; index < session->picker_count; ++index)
|
||
if (strcmp(session->picker[index].exercise_id,
|
||
created.exercise_id) == 0) {
|
||
session->picker_selected = index;
|
||
break;
|
||
}
|
||
(void)memset(controller, 0, sizeof(*controller));
|
||
app->focus = TRAINLOG_FOCUS_CONTENT;
|
||
return true;
|
||
}
|
||
if (controller->editing) {
|
||
app->exercise_detail = created;
|
||
app_shell_load_exercise_detail_metadata(app);
|
||
}
|
||
else {
|
||
app_shell_refresh_list(app);
|
||
if (!copy_ui_stable_id(app->list.selected_id,
|
||
sizeof(app->list.selected_id), created.exercise_id)) return false;
|
||
trainlog_list_set_items(&app->list, app->stable_ids, app->loaded_count,
|
||
app->list.visible_rows, app->list.total_known,
|
||
app->list.more_available);
|
||
}
|
||
controller->phase = TRAINLOG_EXERCISE_MESSAGE;
|
||
(void)snprintf(controller->message, sizeof(controller->message),
|
||
"Exercice %s : %.120s", controller->editing ? "modifié" : "créé",
|
||
created.name);
|
||
return true;
|
||
}
|
||
|
||
static bool app_shell_dispatch_exercise_controller(TrainlogAppContext *app,
|
||
int key)
|
||
{
|
||
TrainlogExerciseController *controller = &app->exercise_controller;
|
||
TrainlogFormResult result;
|
||
size_t zone_count = trainlog_body_zone_catalog_count();
|
||
if (controller->phase == TRAINLOG_EXERCISE_IDLE) return false;
|
||
if (controller->phase == TRAINLOG_EXERCISE_MESSAGE) {
|
||
if (key == TRAINLOG_KEY_ENTER || key == '\n' ||
|
||
key == TRAINLOG_KEY_ESCAPE) {
|
||
(void)memset(controller, 0, sizeof(*controller));
|
||
app->focus = TRAINLOG_FOCUS_CONTENT;
|
||
}
|
||
return true;
|
||
}
|
||
if (controller->phase == TRAINLOG_EXERCISE_CONFIRM_DISCARD) {
|
||
if (key == '1') {
|
||
TrainlogSessionController *session = &app->session;
|
||
bool inline_session = controller->inline_session;
|
||
bool leaving = controller->leaving;
|
||
TrainlogAppRoute pending_route = controller->pending_route;
|
||
(void)memset(controller, 0, sizeof(*controller));
|
||
if (inline_session && !leaving)
|
||
session->phase = TRAINLOG_SESSION_EXERCISE_PICKER;
|
||
app->focus = TRAINLOG_FOCUS_CONTENT;
|
||
if (leaving) app_shell_open_route(app, pending_route);
|
||
} else if (key == '0' || key == TRAINLOG_KEY_ESCAPE)
|
||
controller->phase = controller->return_phase;
|
||
return true;
|
||
}
|
||
if (controller->phase == TRAINLOG_EXERCISE_ZONES) {
|
||
const TrainlogBodyZone *zone = zone_count > 0U
|
||
? trainlog_body_zone_catalog_at(controller->zone_selected) : NULL;
|
||
size_t secondary_index;
|
||
if (key == TRAINLOG_KEY_UP && controller->zone_selected > 0U)
|
||
--controller->zone_selected;
|
||
else if (key == TRAINLOG_KEY_DOWN &&
|
||
controller->zone_selected + 1U < zone_count)
|
||
++controller->zone_selected;
|
||
else if ((key == 'n' || key == 'N')) {
|
||
controller->primary_zone[0] = '\0';
|
||
controller->secondary_count = 0U;
|
||
controller->dirty = true;
|
||
} else if ((key == 'p' || key == 'P') && zone != NULL && !zone->is_group) {
|
||
(void)snprintf(controller->primary_zone,
|
||
sizeof(controller->primary_zone), "%s", zone->zone_id);
|
||
if (secondary_zone_contains(controller->secondary_zones,
|
||
controller->secondary_count, zone->zone_id,
|
||
&secondary_index)) {
|
||
for (size_t move = secondary_index;
|
||
move + 1U < controller->secondary_count; ++move)
|
||
(void)memcpy(controller->secondary_zones[move],
|
||
controller->secondary_zones[move + 1U],
|
||
sizeof(controller->secondary_zones[move]));
|
||
--controller->secondary_count;
|
||
}
|
||
controller->dirty = true;
|
||
} else if (key == ' ' && zone != NULL && !zone->is_group &&
|
||
controller->primary_zone[0] != '\0' &&
|
||
strcmp(controller->primary_zone, zone->zone_id) != 0) {
|
||
if (secondary_zone_contains(controller->secondary_zones,
|
||
controller->secondary_count, zone->zone_id,
|
||
&secondary_index)) {
|
||
for (size_t move = secondary_index;
|
||
move + 1U < controller->secondary_count; ++move)
|
||
(void)memcpy(controller->secondary_zones[move],
|
||
controller->secondary_zones[move + 1U],
|
||
sizeof(controller->secondary_zones[move]));
|
||
--controller->secondary_count;
|
||
} else if (controller->secondary_count < MAX_BODY_ZONES) {
|
||
(void)snprintf(controller->secondary_zones[
|
||
controller->secondary_count], TRAINLOG_ZONE_ID_MAX + 1U,
|
||
"%s", zone->zone_id);
|
||
++controller->secondary_count;
|
||
}
|
||
controller->dirty = true;
|
||
} else if (key == TRAINLOG_KEY_ENTER || key == '\n')
|
||
(void)exercise_controller_save(app);
|
||
else if (key == TRAINLOG_KEY_ESCAPE) {
|
||
controller->leaving = false;
|
||
controller->return_phase = TRAINLOG_EXERCISE_ZONES;
|
||
controller->phase = TRAINLOG_EXERCISE_CONFIRM_DISCARD;
|
||
} else if (key == TRAINLOG_KEY_F6)
|
||
(void)trainlog_overlays_push(&app->overlays,
|
||
TRAINLOG_OVERLAY_NAVIGATION, app->focus,
|
||
app->navigation.current.stable_id);
|
||
else if (key == TRAINLOG_KEY_F7)
|
||
(void)trainlog_overlays_push(&app->overlays,
|
||
TRAINLOG_OVERLAY_ACTIONS, app->focus,
|
||
app->navigation.current.stable_id);
|
||
return true;
|
||
}
|
||
if (controller->phase == TRAINLOG_EXERCISE_TRACKING ||
|
||
controller->phase == TRAINLOG_EXERCISE_RECORDING ||
|
||
controller->phase == TRAINLOG_EXERCISE_SPEED ||
|
||
controller->phase == TRAINLOG_EXERCISE_DISTANCE) {
|
||
bool directional = key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN ||
|
||
key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT;
|
||
if (directional || key == '0' || key == '1' || key == '2') {
|
||
if (controller->phase == TRAINLOG_EXERCISE_TRACKING) {
|
||
if (key == '1') controller->pending.tracking_mode = TRAINLOG_TRACKING_REPS;
|
||
else if (key == '2') controller->pending.tracking_mode = TRAINLOG_TRACKING_DURATION;
|
||
else controller->pending.tracking_mode =
|
||
controller->pending.tracking_mode == TRAINLOG_TRACKING_REPS
|
||
? TRAINLOG_TRACKING_DURATION : TRAINLOG_TRACKING_REPS;
|
||
} else if (controller->phase == TRAINLOG_EXERCISE_RECORDING) {
|
||
if (key == '1') controller->pending.recording_mode = TRAINLOG_RECORDING_SETS;
|
||
else if (key == '2') controller->pending.recording_mode = TRAINLOG_RECORDING_CONTINUOUS;
|
||
else controller->pending.recording_mode =
|
||
controller->pending.recording_mode == TRAINLOG_RECORDING_SETS
|
||
? TRAINLOG_RECORDING_CONTINUOUS : TRAINLOG_RECORDING_SETS;
|
||
} else if (controller->phase == TRAINLOG_EXERCISE_SPEED) {
|
||
if (key == '0') controller->pending.data_fields &=
|
||
~TRAINLOG_EXERCISE_DATA_SPEED_KMH;
|
||
else if (key == '1') controller->pending.data_fields |=
|
||
TRAINLOG_EXERCISE_DATA_SPEED_KMH;
|
||
else controller->pending.data_fields ^=
|
||
TRAINLOG_EXERCISE_DATA_SPEED_KMH;
|
||
} else {
|
||
if (key == '0') controller->pending.data_fields &=
|
||
~TRAINLOG_EXERCISE_DATA_DISTANCE_KM;
|
||
else if (key == '1') controller->pending.data_fields |=
|
||
TRAINLOG_EXERCISE_DATA_DISTANCE_KM;
|
||
else controller->pending.data_fields ^=
|
||
TRAINLOG_EXERCISE_DATA_DISTANCE_KM;
|
||
}
|
||
controller->dirty = true;
|
||
} else if (key == TRAINLOG_KEY_ENTER || key == '\n') {
|
||
if (controller->phase == TRAINLOG_EXERCISE_TRACKING)
|
||
controller->phase = TRAINLOG_EXERCISE_RECORDING;
|
||
else if (controller->phase == TRAINLOG_EXERCISE_RECORDING) {
|
||
if (controller->pending.recording_mode == TRAINLOG_RECORDING_CONTINUOUS) {
|
||
controller->pending.tracking_mode = TRAINLOG_TRACKING_DURATION;
|
||
controller->phase = TRAINLOG_EXERCISE_SPEED;
|
||
} else {
|
||
controller->pending.data_fields = 0U;
|
||
controller->phase = TRAINLOG_EXERCISE_ZONES;
|
||
}
|
||
} else if (controller->phase == TRAINLOG_EXERCISE_SPEED)
|
||
controller->phase = TRAINLOG_EXERCISE_DISTANCE;
|
||
else controller->phase = TRAINLOG_EXERCISE_ZONES;
|
||
} else if (key == TRAINLOG_KEY_ESCAPE) {
|
||
controller->return_phase = controller->phase;
|
||
controller->phase = TRAINLOG_EXERCISE_CONFIRM_DISCARD;
|
||
} else if (key == TRAINLOG_KEY_F6)
|
||
(void)trainlog_overlays_push(&app->overlays,
|
||
TRAINLOG_OVERLAY_NAVIGATION, app->focus,
|
||
app->navigation.current.stable_id);
|
||
else if (key == TRAINLOG_KEY_F7)
|
||
(void)trainlog_overlays_push(&app->overlays,
|
||
TRAINLOG_OVERLAY_ACTIONS, app->focus,
|
||
app->navigation.current.stable_id);
|
||
return true;
|
||
}
|
||
result = trainlog_form_handle(&controller->form, key);
|
||
if (result == TRAINLOG_FORM_OPEN_NAVIGATION ||
|
||
result == TRAINLOG_FORM_OPEN_ACTIONS) {
|
||
(void)trainlog_overlays_push(&app->overlays,
|
||
result == TRAINLOG_FORM_OPEN_NAVIGATION
|
||
? TRAINLOG_OVERLAY_NAVIGATION : TRAINLOG_OVERLAY_ACTIONS,
|
||
app->focus, app->navigation.current.stable_id);
|
||
} else if (result == TRAINLOG_FORM_CANCEL) {
|
||
controller->leaving = false;
|
||
controller->return_phase = TRAINLOG_EXERCISE_NAME;
|
||
controller->phase = TRAINLOG_EXERCISE_CONFIRM_DISCARD;
|
||
} else if (result == TRAINLOG_FORM_EDITED) {
|
||
controller->dirty = true;
|
||
controller->message[0] = '\0';
|
||
} else if (result == TRAINLOG_FORM_SUBMIT || result == TRAINLOG_FORM_NEXT) {
|
||
if (controller->form.text[0] == '\0')
|
||
(void)snprintf(controller->message, sizeof(controller->message),
|
||
"Le nom est obligatoire; la saisie est conservée.");
|
||
else {
|
||
(void)snprintf(controller->pending.name,
|
||
sizeof(controller->pending.name), "%s", controller->form.text);
|
||
controller->form.active = false;
|
||
controller->phase = controller->editing
|
||
? TRAINLOG_EXERCISE_ZONES : TRAINLOG_EXERCISE_TRACKING;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
static void app_shell_release_session_detail(TrainlogAppContext *app)
|
||
{
|
||
trainlog_database_free_session_details(app->session_entries,
|
||
app->session_entry_count);
|
||
(void)memset(app->session_entries, 0, sizeof(app->session_entries));
|
||
app->session_entry_count = 0U;
|
||
free(app->exercise_feedback);
|
||
free(app->session_followups);
|
||
app->exercise_feedback = NULL;
|
||
app->session_followups = NULL;
|
||
app->exercise_feedback_count = 0U;
|
||
app->session_followup_count = 0U;
|
||
}
|
||
|
||
static void app_shell_open_session_detail(TrainlogAppContext *app,
|
||
const char *session_id)
|
||
{
|
||
app_shell_release_session_detail(app);
|
||
app->session_detail_error = trainlog_database_get_session_details(app->database,
|
||
session_id, &app->session_detail, app->session_entries,
|
||
MAX_SESSION_EXERCISES, &app->session_entry_count) != TRAINLOG_STATUS_OK;
|
||
if (!app->session_detail_error) {
|
||
/* Resource bound matches the strict companion cardinality. The cache
|
||
* is detail-owned and released on every navigation replacement. */
|
||
app->exercise_feedback = calloc(TRAINLOG_FEEDBACK_VIEW_MAX,
|
||
sizeof(*app->exercise_feedback));
|
||
app->session_followups = calloc(TRAINLOG_FEEDBACK_VIEW_MAX,
|
||
sizeof(*app->session_followups));
|
||
if (app->exercise_feedback == NULL || app->session_followups == NULL)
|
||
app->session_detail_error = true;
|
||
}
|
||
if (!app->session_detail_error) {
|
||
app->session_detail_error = trainlog_database_list_training_feedback(
|
||
app->database, session_id, app->exercise_feedback,
|
||
TRAINLOG_FEEDBACK_VIEW_MAX, &app->exercise_feedback_count,
|
||
app->session_followups, TRAINLOG_FEEDBACK_VIEW_MAX,
|
||
&app->session_followup_count) != TRAINLOG_STATUS_OK;
|
||
}
|
||
app->session_entry_selected = 0U;
|
||
app->session_set_scroll = 0U;
|
||
(void)trainlog_navigation_open(&app->navigation, TRAINLOG_ROUTE_SESSION_DETAIL,
|
||
session_id);
|
||
}
|
||
|
||
static void app_shell_actions(TrainlogAppContext *app)
|
||
{
|
||
TrainlogAppRoute route = app->navigation.current.route;
|
||
TrainlogSessionController *session = &app->session;
|
||
trainlog_actions_clear(&app->actions);
|
||
if (app->body_controller.phase != TRAINLOG_BODY_IDLE) {
|
||
TrainlogBodyPhase phase = app->body_controller.phase;
|
||
if (phase == TRAINLOG_BODY_CONFIRM_DISCARD) {
|
||
app_shell_add_action(app, "body.form.discard", '1',
|
||
"1 Abandonner", 1U, TRAINLOG_INTENT_DISCARD, route);
|
||
app_shell_add_action(app, "body.form.resume", '0',
|
||
"0 Reprendre", 2U, TRAINLOG_INTENT_BACK, route);
|
||
} else if (phase == TRAINLOG_BODY_MESSAGE)
|
||
app_shell_add_action(app, "body.form.close", TRAINLOG_KEY_ENTER,
|
||
"Entrée Retour", 1U, TRAINLOG_INTENT_BACK, route);
|
||
else {
|
||
app_shell_add_action(app, "body.form.next", TRAINLOG_KEY_ENTER,
|
||
app->body_controller.field == 13U ? "Entrée Enregistrer"
|
||
: "Entrée Champ suivant",
|
||
1U, TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "body.form.cancel", TRAINLOG_KEY_ESCAPE,
|
||
"Échap Abandonner…", 2U, TRAINLOG_INTENT_DISCARD, route);
|
||
}
|
||
app_shell_add_action(app, "navigation", TRAINLOG_KEY_F6,
|
||
"F6 Navigation", 80U, TRAINLOG_INTENT_OPEN_NAVIGATION, route);
|
||
app_shell_add_action(app, "actions", TRAINLOG_KEY_F7,
|
||
"F7 Actions", 81U, TRAINLOG_INTENT_OPEN_ACTIONS, route);
|
||
app_shell_add_action(app, "help", '?', "? Aide", 82U,
|
||
TRAINLOG_INTENT_OPEN_HELP, route);
|
||
return;
|
||
}
|
||
if (app->profile_controller.phase != TRAINLOG_PROFILE_IDLE) {
|
||
TrainlogProfilePhase phase = app->profile_controller.phase;
|
||
if (phase == TRAINLOG_PROFILE_CONFIRM_DISCARD) {
|
||
app_shell_add_action(app, "profile.discard", '1', "1 Abandonner",
|
||
1U, TRAINLOG_INTENT_DISCARD, route);
|
||
app_shell_add_action(app, "profile.resume", '0', "0 Reprendre",
|
||
2U, TRAINLOG_INTENT_BACK, route);
|
||
} else if (phase == TRAINLOG_PROFILE_MESSAGE)
|
||
app_shell_add_action(app, "profile.close", TRAINLOG_KEY_ENTER,
|
||
"Entrée Retour", 1U, TRAINLOG_INTENT_BACK, route);
|
||
else {
|
||
app_shell_add_action(app, "profile.next", TRAINLOG_KEY_ENTER,
|
||
phase == TRAINLOG_PROFILE_HEIGHT ? "Entrée Enregistrer"
|
||
: "Entrée Continuer",
|
||
1U, TRAINLOG_INTENT_SAVE, route);
|
||
app_shell_add_action(app, "profile.cancel", TRAINLOG_KEY_ESCAPE,
|
||
"Échap Abandonner…", 2U, TRAINLOG_INTENT_DISCARD, route);
|
||
}
|
||
app_shell_add_action(app, "navigation", TRAINLOG_KEY_F6,
|
||
"F6 Navigation", 80U, TRAINLOG_INTENT_OPEN_NAVIGATION, route);
|
||
app_shell_add_action(app, "actions", TRAINLOG_KEY_F7,
|
||
"F7 Actions", 81U, TRAINLOG_INTENT_OPEN_ACTIONS, route);
|
||
app_shell_add_action(app, "help", '?', "? Aide", 82U,
|
||
TRAINLOG_INTENT_OPEN_HELP, route);
|
||
return;
|
||
}
|
||
if (app->exercise_controller.phase != TRAINLOG_EXERCISE_IDLE) {
|
||
TrainlogExercisePhase phase = app->exercise_controller.phase;
|
||
if (phase == TRAINLOG_EXERCISE_CONFIRM_DISCARD) {
|
||
app_shell_add_action(app, "exercise.form.discard", '1',
|
||
"1 Abandonner", 1U, TRAINLOG_INTENT_DISCARD, route);
|
||
app_shell_add_action(app, "exercise.form.resume", '0',
|
||
"0 Reprendre", 2U, TRAINLOG_INTENT_BACK, route);
|
||
} else if (phase == TRAINLOG_EXERCISE_MESSAGE)
|
||
app_shell_add_action(app, "exercise.form.close", TRAINLOG_KEY_ENTER,
|
||
"Entrée Retour", 1U, TRAINLOG_INTENT_BACK, route);
|
||
else {
|
||
app_shell_add_action(app, "exercise.form.next", TRAINLOG_KEY_ENTER,
|
||
phase == TRAINLOG_EXERCISE_ZONES ? "Entrée Enregistrer"
|
||
: "Entrée Continuer",
|
||
1U, phase == TRAINLOG_EXERCISE_ZONES ? TRAINLOG_INTENT_SAVE
|
||
: TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "exercise.form.cancel", TRAINLOG_KEY_ESCAPE,
|
||
"Échap Abandonner…", 2U, TRAINLOG_INTENT_DISCARD, route);
|
||
}
|
||
app_shell_add_action(app, "navigation", TRAINLOG_KEY_F6,
|
||
"F6 Navigation", 80U, TRAINLOG_INTENT_OPEN_NAVIGATION, route);
|
||
app_shell_add_action(app, "actions", TRAINLOG_KEY_F7,
|
||
"F7 Actions", 81U, TRAINLOG_INTENT_OPEN_ACTIONS, route);
|
||
app_shell_add_action(app, "help", '?', "? Aide", 82U,
|
||
TRAINLOG_INTENT_OPEN_HELP, route);
|
||
return;
|
||
}
|
||
if (app->equipment_controller.phase != TRAINLOG_EQUIPMENT_IDLE) {
|
||
TrainlogEquipmentPhase phase = app->equipment_controller.phase;
|
||
if (phase == TRAINLOG_EQUIPMENT_CONFIRM_DISCARD) {
|
||
app_shell_add_action(app, "equipment.create.discard", '1',
|
||
"1 Abandonner", 1U, TRAINLOG_INTENT_DISCARD, route);
|
||
app_shell_add_action(app, "equipment.create.resume", '0',
|
||
"0 Reprendre", 2U, TRAINLOG_INTENT_BACK, route);
|
||
} else if (phase == TRAINLOG_EQUIPMENT_MESSAGE)
|
||
app_shell_add_action(app, "equipment.create.close", TRAINLOG_KEY_ENTER,
|
||
"Entrée Catalogue", 1U, TRAINLOG_INTENT_BACK, route);
|
||
else {
|
||
app_shell_add_action(app, "equipment.create.next", TRAINLOG_KEY_ENTER,
|
||
phase == TRAINLOG_EQUIPMENT_LOAD ? "Entrée Enregistrer"
|
||
: "Entrée Champ suivant",
|
||
1U, phase == TRAINLOG_EQUIPMENT_LOAD ? TRAINLOG_INTENT_SAVE
|
||
: TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "equipment.create.cancel", TRAINLOG_KEY_ESCAPE,
|
||
"Échap Abandonner…", 2U, TRAINLOG_INTENT_DISCARD, route);
|
||
}
|
||
app_shell_add_action(app, "navigation", TRAINLOG_KEY_F6,
|
||
"F6 Navigation", 80U, TRAINLOG_INTENT_OPEN_NAVIGATION, route);
|
||
app_shell_add_action(app, "actions", TRAINLOG_KEY_F7,
|
||
"F7 Actions", 81U, TRAINLOG_INTENT_OPEN_ACTIONS, route);
|
||
app_shell_add_action(app, "help", '?', "? Aide", 82U,
|
||
TRAINLOG_INTENT_OPEN_HELP, route);
|
||
return;
|
||
}
|
||
if (route == TRAINLOG_ROUTE_HOME)
|
||
app_shell_add_action(app, "session", '1', "1 Séance", 1U,
|
||
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSION_MANUAL);
|
||
if (route == TRAINLOG_ROUTE_BODY ||
|
||
route == TRAINLOG_ROUTE_SYNC || route == TRAINLOG_ROUTE_SETTINGS)
|
||
app_shell_add_action(app, "open", TRAINLOG_KEY_ENTER, "Entrée Ouvrir", 1U,
|
||
TRAINLOG_INTENT_PRIMARY, route);
|
||
if ((route == TRAINLOG_ROUTE_SESSION_MANUAL ||
|
||
route == TRAINLOG_ROUTE_SESSION_CURRENT) &&
|
||
session->phase == TRAINLOG_SESSION_DRAFT) {
|
||
app_shell_add_action(app, "session.occurrence.add", 'a', "a Ajouter", 1U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "session.occurrence.edit", 'e', "e Séries/résultat", 2U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "session.occurrence.replace", 'r', "r Remplacer", 3U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "session.occurrence.equipment", 'i', "i Équipement", 4U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "session.occurrence.plan", 'p', "p Objectif prévu", 5U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "session.occurrence.remove", 'd', "d Retirer", 6U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "session.finish", 'f', "f Enregistrer", 7U,
|
||
TRAINLOG_INTENT_SAVE, route);
|
||
app_shell_add_action(app, "session.abandon", 'q', "q Abandonner", 8U,
|
||
TRAINLOG_INTENT_DISCARD, route);
|
||
}
|
||
if ((route == TRAINLOG_ROUTE_SESSION_MANUAL ||
|
||
route == TRAINLOG_ROUTE_SESSION_CURRENT) &&
|
||
session->phase == TRAINLOG_SESSION_EXERCISE_PICKER)
|
||
app_shell_add_action(app, "session.exercise.create", 'n',
|
||
"n Créer un exercice", 1U, TRAINLOG_INTENT_NONE, route);
|
||
if ((route == TRAINLOG_ROUTE_SESSION_MANUAL ||
|
||
route == TRAINLOG_ROUTE_SESSION_CURRENT) &&
|
||
session->phase == TRAINLOG_SESSION_ACTUALS) {
|
||
TrainlogSessionDraftExercise *draft = session->selected < session->draft_count
|
||
? &session->drafts[session->selected] : NULL;
|
||
app_shell_add_action(app, "session.actual.edit", 'e', "e Modifier", 1U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
if (draft != NULL && draft->input.recording_mode == TRAINLOG_RECORDING_SETS &&
|
||
!draft->input.has_max_weight) {
|
||
app_shell_add_action(app, "session.set.add", 'a', "a Ajouter série", 2U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "session.set.remove", 'd', "d Supprimer série", 3U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
}
|
||
if (draft != NULL && draft->input.recording_mode == TRAINLOG_RECORDING_CONTINUOUS &&
|
||
(draft->input.data_fields & TRAINLOG_EXERCISE_DATA_SPEED_KMH) != 0U)
|
||
app_shell_add_action(app, "session.continuous.speed", 's', "s Vitesse", 2U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
if (draft != NULL && draft->input.recording_mode == TRAINLOG_RECORDING_CONTINUOUS &&
|
||
(draft->input.data_fields & TRAINLOG_EXERCISE_DATA_DISTANCE_KM) != 0U)
|
||
app_shell_add_action(app, "session.continuous.distance", 'k', "k Distance", 3U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "session.actual.done", 'f', "f Terminer édition", 4U,
|
||
TRAINLOG_INTENT_BACK, route);
|
||
}
|
||
if (route == TRAINLOG_ROUTE_SESSION_GENERATOR) {
|
||
if (session->phase == TRAINLOG_SESSION_GENERATOR_CONFIG)
|
||
app_shell_add_action(app, "generator.generate", 'g', "g Générer", 1U,
|
||
TRAINLOG_INTENT_PRIMARY, route);
|
||
if (session->phase == TRAINLOG_SESSION_GENERATOR_WARNING) {
|
||
app_shell_add_action(app, "generator.warning.continue", 'c',
|
||
"c Continuer", 1U, TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "generator.warning.zone", 'z',
|
||
"z Autre zone", 2U, TRAINLOG_INTENT_NONE, route);
|
||
}
|
||
if (session->phase == TRAINLOG_SESSION_GENERATOR_PREVIEW) {
|
||
app_shell_add_action(app, "generator.accept", 'a', "a Accepter", 1U,
|
||
TRAINLOG_INTENT_PRIMARY, route);
|
||
app_shell_add_action(app, "generator.remove", 'd', "d Retirer", 2U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "generator.edit.dose", 'e', "e Modifier dose", 3U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "generator.load.percent", '%', "% %MAX", 4U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "generator.load.auto", 'u', "u Charge auto", 5U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "generator.load.none", 'x', "x Sans charge", 6U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "generator.regenerate", 'g', "g Régénérer", 4U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "generator.move.up", '<', "< Monter", 5U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "generator.move.down", '>', "> Descendre", 6U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
}
|
||
if (session->generated_preview)
|
||
app_shell_add_action(app, "generator.discard", 'q',
|
||
"q Abandonner la proposition", 6U, TRAINLOG_INTENT_DISCARD, route);
|
||
}
|
||
if (app_shell_is_list_route(route)) {
|
||
app_shell_add_action(app, "list.open", TRAINLOG_KEY_ENTER, "Entrée Ouvrir", 1U,
|
||
TRAINLOG_INTENT_PRIMARY, route);
|
||
if (route != TRAINLOG_ROUTE_BODY)
|
||
app_shell_add_action(app, "list.search", '/', "/ Rechercher", 2U,
|
||
TRAINLOG_INTENT_OPEN_SEARCH, route);
|
||
}
|
||
if (route == TRAINLOG_ROUTE_BODY) {
|
||
app_shell_add_action(app, "body.add", 'a', "a Ajouter", 1U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "body.edit", 'e', "e Modifier", 2U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "body.analytics", 'v', "v Analyse", 3U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "body.metric", 'm', "m Historique mesure", 4U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "body.graph", 'g', "g Vue 12 mois", 5U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "body.global", 'o', "o Vue globale", 6U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "body.profile.metric.previous", TRAINLOG_KEY_LEFT,
|
||
"← Mesure précédente", 7U, TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "body.profile.metric.next", TRAINLOG_KEY_RIGHT,
|
||
"→ Mesure suivante", 8U, TRAINLOG_INTENT_NONE, route);
|
||
}
|
||
if (route == TRAINLOG_ROUTE_BODY_DETAIL)
|
||
app_shell_add_action(app, "body.detail.edit", 'e', "e Modifier", 1U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
if (route == TRAINLOG_ROUTE_BODY_METRIC) {
|
||
app_shell_add_action(app, "body.metric.previous", TRAINLOG_KEY_LEFT,
|
||
"← Mesure précédente", 1U, TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "body.metric.next", TRAINLOG_KEY_RIGHT,
|
||
"→ Mesure suivante", 2U, TRAINLOG_INTENT_NONE, route);
|
||
}
|
||
if (route == TRAINLOG_ROUTE_BODY_GLOBAL)
|
||
app_shell_add_action(app, "body.global.toggle", ' ',
|
||
"Espace Afficher/masquer", 1U, TRAINLOG_INTENT_NONE, route);
|
||
if (route == TRAINLOG_ROUTE_BODY_ANALYTICS)
|
||
app_shell_add_action(app, "body.profile", 'p', "p Profil estimation",
|
||
1U, TRAINLOG_INTENT_NONE, route);
|
||
if (route == TRAINLOG_ROUTE_SYNC) {
|
||
/* WHY: one registry entry is the source of truth for both the footer
|
||
* and F7 palette, preventing directional or duplicate launch affordances. */
|
||
app_shell_add_action(app, "sync.now", 's',
|
||
"s Synchroniser maintenant · PC↔Android", 1U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "sync.refresh", 'r', "r Actualiser appareil",
|
||
2U, TRAINLOG_INTENT_REFRESH, route);
|
||
}
|
||
if (route == TRAINLOG_ROUTE_EQUIPMENT)
|
||
app_shell_add_action(app, "equipment.create", 'n', "n Créer", 1U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
if (route == TRAINLOG_ROUTE_EXERCISE_DETAIL) {
|
||
app_shell_add_action(app, "exercise.performance", 'p', "p Performances", 1U,
|
||
TRAINLOG_INTENT_PRIMARY, route);
|
||
app_shell_add_action(app, "exercise.max", 'm', "m MAX", 2U,
|
||
TRAINLOG_INTENT_PRIMARY, route);
|
||
app_shell_add_action(app, "exercise.knowledge", 'k', "k Connaissances", 3U,
|
||
TRAINLOG_INTENT_PRIMARY, route);
|
||
app_shell_add_action(app, "exercise.edit", 'e', "e Modifier", 4U,
|
||
TRAINLOG_INTENT_PRIMARY, route);
|
||
app_shell_add_action(app, "exercise.merge", 'u', "u Fusionner avec…", 5U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
}
|
||
if (route == TRAINLOG_ROUTE_EXERCISES)
|
||
app_shell_add_action(app, "exercise.create", 'a', "a Créer", 1U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
if (route == TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE)
|
||
app_shell_add_action(app, "knowledge.close", 'k', "k Fermer", 1U,
|
||
TRAINLOG_INTENT_BACK, TRAINLOG_ROUTE_EXERCISE_DETAIL);
|
||
if (route == TRAINLOG_ROUTE_EXERCISE_MAX)
|
||
app_shell_add_action(app, "max.rounding", 'r', "r Arrondi", 1U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
if (route == TRAINLOG_ROUTE_SESSION_DETAIL)
|
||
app_shell_add_action(app, "session.edit", 'e', "e Modifier", 1U,
|
||
TRAINLOG_INTENT_NONE, route);
|
||
/* CONTRACT: destination aliases live in the same model as contextual
|
||
* actions. Dispatcher, footer selection and F7 therefore cannot drift. */
|
||
app_shell_add_action(app, "home", '0', "0 Accueil", 100U,
|
||
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_HOME);
|
||
app_shell_add_action(app, "session.alias", '1', "1 Séance", 101U,
|
||
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSION_MANUAL);
|
||
app_shell_add_action(app, "completed.alias", '2', "2 Effectuées", 102U,
|
||
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSIONS_COMPLETED);
|
||
app_shell_add_action(app, "exercises.alias", '3', "3 Exercices", 103U,
|
||
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_EXERCISES);
|
||
app_shell_add_action(app, "body.alias", '5', "5 Mensurations", 105U,
|
||
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_BODY);
|
||
app_shell_add_action(app, "sync.alias", '6', "6 Synchronisation", 106U,
|
||
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SYNC);
|
||
app_shell_add_action(app, "navigation", TRAINLOG_KEY_F6, "F6 Navigation", 80U,
|
||
TRAINLOG_INTENT_OPEN_NAVIGATION, route);
|
||
app_shell_add_action(app, "actions", TRAINLOG_KEY_F7, "F7 Actions", 81U,
|
||
TRAINLOG_INTENT_OPEN_ACTIONS, route);
|
||
app_shell_add_action(app, "help", '?', "? Aide", 82U,
|
||
TRAINLOG_INTENT_OPEN_HELP, route);
|
||
app_shell_add_action(app, "back", TRAINLOG_KEY_ESCAPE,
|
||
route == TRAINLOG_ROUTE_HOME ? "q Quitter" : "Échap Retour", 83U,
|
||
route == TRAINLOG_ROUTE_HOME ? TRAINLOG_INTENT_QUIT : TRAINLOG_INTENT_BACK,
|
||
route);
|
||
}
|
||
|
||
static void app_shell_render_footer(TrainlogAppContext *app)
|
||
{
|
||
const TrainlogOverlay *overlay = trainlog_overlays_top(&app->overlays);
|
||
const TrainlogAction *contextual[2] = {NULL, NULL};
|
||
size_t position;
|
||
size_t contextual_count = 0U;
|
||
trainlog_surface_erase(app->footer);
|
||
trainlog_surface_set_role(app->footer, TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_MANTLE, TRAINLOG_TEXT_NORMAL);
|
||
if (overlay != NULL) {
|
||
if (overlay->type == TRAINLOG_OVERLAY_CONFIRMATION && app->quit_confirmation) {
|
||
trainlog_surface_printf(app->footer, 0, 2,
|
||
"↑↓ choisir Entrée valider");
|
||
trainlog_surface_printf(app->footer, 1, 2,
|
||
"Annuler conserve l’état de cette exécution");
|
||
return;
|
||
}
|
||
trainlog_surface_printf(app->footer, 0, 2, "↑↓ choisir Entrée valider");
|
||
trainlog_surface_printf(app->footer, 1, 2, "Échap fermer F7 Actions ? Aide");
|
||
return;
|
||
}
|
||
/* INVARIANT: stable action identity is deduplicated after every screen,
|
||
* focus and shell-wide contributor has been aggregated, before layout.
|
||
* Reserved global actions remain in F7 but are rendered once in row two. */
|
||
for (position = 0U; position < app->actions.count && contextual_count < 2U;
|
||
++position) {
|
||
const TrainlogAction *candidate = trainlog_actions_at_priority(
|
||
&app->actions, position);
|
||
if (candidate == NULL ||
|
||
strcmp(candidate->identifier, "navigation") == 0 ||
|
||
strcmp(candidate->identifier, "actions") == 0 ||
|
||
strcmp(candidate->identifier, "help") == 0 ||
|
||
strcmp(candidate->identifier, "back") == 0) continue;
|
||
contextual[contextual_count++] = candidate;
|
||
}
|
||
if (contextual[0] != NULL)
|
||
trainlog_surface_printf(app->footer, 0, 2, "%s", contextual[0]->label);
|
||
if (contextual[1] != NULL)
|
||
trainlog_surface_printf(app->footer, 0, 24, "%s", contextual[1]->label);
|
||
trainlog_surface_printf(app->footer, 1, 2,
|
||
"F6 Navigation F7 Actions ? Aide %s",
|
||
app->navigation.current.route == TRAINLOG_ROUTE_HOME ? "q Quitter" : "Échap Retour");
|
||
}
|
||
|
||
static void app_shell_render_sidebar(TrainlogAppContext *app)
|
||
{
|
||
size_t index;
|
||
if (app->sidebar == NULL) return;
|
||
trainlog_surface_erase(app->sidebar);
|
||
trainlog_surface_set_role(app->sidebar, TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_MANTLE, TRAINLOG_TEXT_NORMAL);
|
||
for (index = 0U; index < sizeof(shell_sections) / sizeof(shell_sections[0]); ++index) {
|
||
bool active = trainlog_route_section(app->navigation.current.route) == shell_sections[index];
|
||
bool focused = app->focus == TRAINLOG_FOCUS_NAVIGATION &&
|
||
app->navigation_selected == index;
|
||
trainlog_surface_set_role(app->sidebar,
|
||
focused || active ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT,
|
||
focused ? TRAINLOG_RGB_SURFACE0 : TRAINLOG_RGB_MANTLE,
|
||
focused ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL);
|
||
trainlog_surface_printf(app->sidebar, 1 + (int)index * 2, 1,
|
||
"%c%c %-17s", focused ? '>' : ' ', active ? '[' : ' ',
|
||
shell_section_labels[index]);
|
||
if (active && app->layout.sidebar_expanded) {
|
||
const char *subroute = NULL;
|
||
switch (app->navigation.current.route) {
|
||
case TRAINLOG_ROUTE_SESSION_CURRENT: subroute = "· Séance en cours"; break;
|
||
case TRAINLOG_ROUTE_SESSION_GENERATOR: subroute = "· Programmer"; break;
|
||
case TRAINLOG_ROUTE_SESSION_MANUAL: subroute = "· Nouvelle séance"; break;
|
||
case TRAINLOG_ROUTE_SESSIONS_COMPLETED: subroute = "· Effectuées"; break;
|
||
case TRAINLOG_ROUTE_SESSION_DETAIL: subroute = "· Détail séance"; break;
|
||
case TRAINLOG_ROUTE_EXERCISE_DETAIL: subroute = "· Fiche exercice"; break;
|
||
case TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE: subroute = "· Connaissances"; break;
|
||
case TRAINLOG_ROUTE_EXERCISE_PERFORMANCE: subroute = "· Performances"; break;
|
||
case TRAINLOG_ROUTE_EXERCISE_MAX: subroute = "· MAX exercice"; break;
|
||
case TRAINLOG_ROUTE_EQUIPMENT_DETAIL: subroute = "· Fiche équipement"; break;
|
||
case TRAINLOG_ROUTE_STATS_EXERCISE: subroute = "· Par exercice"; break;
|
||
case TRAINLOG_ROUTE_BODY: subroute = "· Mensurations"; break;
|
||
case TRAINLOG_ROUTE_BODY_DETAIL: subroute = "· Relevé corporel"; break;
|
||
case TRAINLOG_ROUTE_BODY_METRIC: subroute = "· Historique mesure"; break;
|
||
case TRAINLOG_ROUTE_BODY_TRENDS: subroute = "· Évolution 12 mois"; break;
|
||
case TRAINLOG_ROUTE_BODY_GLOBAL: subroute = "· Vue globale"; break;
|
||
case TRAINLOG_ROUTE_BODY_ANALYTICS: subroute = "· Analyse corporelle"; break;
|
||
case TRAINLOG_ROUTE_MAX: subroute = "· Capacités / MAX"; break;
|
||
default: break;
|
||
}
|
||
if (subroute != NULL)
|
||
trainlog_surface_printf(app->sidebar,
|
||
2 + (int)index * 2, 3, "%s", subroute);
|
||
}
|
||
}
|
||
}
|
||
|
||
static void app_shell_render_session_controller(TrainlogAppContext *app)
|
||
{
|
||
TrainlogSessionController *session = &app->session;
|
||
int row = 3;
|
||
size_t index;
|
||
if (session->phase == TRAINLOG_SESSION_CHOOSE_TYPE) {
|
||
trainlog_surface_printf(app->content, row++, 2, "Choisissez le type de séance :");
|
||
trainlog_surface_printf(app->content, row++, 4, "%c Entraînement",
|
||
session->session_type == TRAINLOG_SESSION_TRAINING ? '>' : ' ');
|
||
trainlog_surface_printf(app->content, row, 4, "%c Test max explicite",
|
||
session->session_type == TRAINLOG_SESSION_MAX_TEST ? '>' : ' ');
|
||
} else if (session->phase == TRAINLOG_SESSION_EXERCISE_PICKER) {
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"%s — choisir un exercice", session->replacing_occurrence
|
||
? "Remplacer l’occurrence" : "Ajouter une occurrence");
|
||
for (index = 0U; index < session->picker_count &&
|
||
row < app->layout.content.height; ++index)
|
||
trainlog_surface_printf(app->content, row++, 4, "%c %.52s",
|
||
index == session->picker_selected ? '>' : ' ',
|
||
session->picker[index].name);
|
||
} else if (session->phase == TRAINLOG_SESSION_EQUIPMENT_PICKER) {
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Équipement de l’occurrence — ID stable de l’occurrence inchangé");
|
||
trainlog_surface_printf(app->content, row++, 4, "%c Aucun équipement",
|
||
session->equipment_selected == 0U ? '>' : ' ');
|
||
for (index = 0U; index < session->equipment_count &&
|
||
row < app->layout.content.height; ++index)
|
||
trainlog_surface_printf(app->content, row++, 4, "%c %.46s %s",
|
||
session->equipment_selected == index + 1U ? '>' : ' ',
|
||
app->equipment[index].display_name,
|
||
equipment_origin_label(app->equipment[index].origin));
|
||
trainlog_surface_printf(app->content, app->layout.content.height - 2, 2,
|
||
"Entrée choisir · x aucun · n créer puis reprendre (phase catalogue)");
|
||
} else if (session->phase == TRAINLOG_SESSION_EQUIPMENT_CREATE) {
|
||
const char *label = session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_NAME
|
||
? "Nom convivial" : session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_LABEL
|
||
? "Nom étiquette (optionnel)" :
|
||
session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_TYPE
|
||
? "Type" : "Charge : 1 aucune · 2 externe · 3 assistance";
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Créer un équipement personnel — retour à l’occurrence");
|
||
trainlog_surface_printf(app->content, row++, 4, "%s", label);
|
||
trainlog_surface_printf(app->content, row++, 4, "> %s", session->form.text);
|
||
if (session->message[0] != '\0')
|
||
trainlog_surface_printf(app->content, row + 1, 4, "%s", session->message);
|
||
} else if (session->phase == TRAINLOG_SESSION_PLANNING &&
|
||
session->selected < session->draft_count) {
|
||
TrainlogSessionDraftExercise *draft = &session->drafts[session->selected];
|
||
static const char *const labels[] = {"Séries cibles", "Répétitions cibles",
|
||
"Durée cible (s)", "Repos (s)", "Charge/assistance cible (kg)"};
|
||
char values[5][48];
|
||
(void)snprintf(values[0], sizeof(values[0]), "%d", draft->input.target_sets);
|
||
(void)snprintf(values[1], sizeof(values[1]), "%d", draft->input.target_reps);
|
||
(void)snprintf(values[2], sizeof(values[2]), "%d",
|
||
draft->input.target_duration_seconds);
|
||
(void)snprintf(values[3], sizeof(values[3]), "%d", draft->input.rest_seconds);
|
||
if (draft->input.target_has_weight)
|
||
(void)snprintf(values[4], sizeof(values[4]), "%.2f",
|
||
draft->input.target_weight_kg);
|
||
else (void)snprintf(values[4], sizeof(values[4]), "—");
|
||
if (draft->input.recording_mode != TRAINLOG_RECORDING_SETS) {
|
||
(void)snprintf(values[0], sizeof(values[0]), "non applicable");
|
||
(void)snprintf(values[3], sizeof(values[3]), "non applicable");
|
||
}
|
||
if (draft->tracking_mode != TRAINLOG_TRACKING_REPS)
|
||
(void)snprintf(values[1], sizeof(values[1]), "non applicable");
|
||
if (draft->tracking_mode != TRAINLOG_TRACKING_DURATION)
|
||
(void)snprintf(values[2], sizeof(values[2]), "non applicable");
|
||
if (draft->input.load_mode == TRAINLOG_LOAD_NONE)
|
||
(void)snprintf(values[4], sizeof(values[4]), "non applicable");
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Objectif prévu — séparé des valeurs réalisées");
|
||
for (index = 0U; index < 5U; ++index)
|
||
trainlog_surface_printf(app->content, row++, 4, "%c %-32s %s",
|
||
index == session->planning_field ? '>' : ' ', labels[index], values[index]);
|
||
if (row < app->layout.content.height - 2)
|
||
trainlog_surface_printf(app->content, row++, 4,
|
||
"%% sur la charge : calcul utilisateur 1..100%% du dernier MAX compatible");
|
||
if (session->form.active)
|
||
trainlog_surface_printf(app->content, app->layout.content.height - 3, 2,
|
||
"> %s", session->form.text);
|
||
} else if (session->phase == TRAINLOG_SESSION_ACTUALS &&
|
||
session->selected < session->draft_count) {
|
||
TrainlogSessionDraftExercise *draft = &session->drafts[session->selected];
|
||
trainlog_surface_printf(app->content, row++, 2, "%s", draft->name);
|
||
if (draft->input.has_max_weight || session->session_type == TRAINLOG_SESSION_MAX_TEST)
|
||
trainlog_surface_printf(app->content, row++, 4,
|
||
"MAX explicite : %s%.2f kg", draft->input.has_max_weight ? "" : "à saisir · ",
|
||
draft->input.has_max_weight ? draft->input.max_weight_kg : 0.0);
|
||
else if (draft->input.recording_mode == TRAINLOG_RECORDING_CONTINUOUS)
|
||
{
|
||
trainlog_surface_printf(app->content, row++, 4,
|
||
"Activité continue : %d s · aucune série fictive",
|
||
draft->input.continuous_duration_seconds);
|
||
if ((draft->input.data_fields & TRAINLOG_EXERCISE_DATA_SPEED_KMH) != 0U)
|
||
trainlog_surface_printf(app->content, row++, 4, "Vitesse : %s%.2f km/h",
|
||
draft->input.continuous_has_speed ? "" : "— ",
|
||
draft->input.continuous_has_speed
|
||
? draft->input.continuous_speed_kmh : 0.0);
|
||
if ((draft->input.data_fields & TRAINLOG_EXERCISE_DATA_DISTANCE_KM) != 0U)
|
||
trainlog_surface_printf(app->content, row++, 4, "Distance : %s%.2f km",
|
||
draft->input.continuous_has_distance ? "" : "— ",
|
||
draft->input.continuous_has_distance
|
||
? draft->input.continuous_distance_km : 0.0);
|
||
trainlog_surface_printf(app->content, row++, 4,
|
||
"e durée · s vitesse · k distance");
|
||
}
|
||
else {
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Séries réalisées — cibles séparées : %d×%d, repos %d s",
|
||
draft->input.target_sets, draft->input.target_reps,
|
||
draft->input.rest_seconds);
|
||
for (index = 0U; index < draft->input.set_count &&
|
||
row < app->layout.content.height; ++index) {
|
||
char metric[64];
|
||
draft_format_set_metric(draft, &draft->sets[index], metric,
|
||
sizeof(metric));
|
||
trainlog_surface_printf(app->content, row++, 4,
|
||
"%c %zu. %-16s %s%.2f kg", index == session->set_selected ? '>' : ' ',
|
||
index + 1U, metric, draft->sets[index].has_weight ? "" : "— ",
|
||
draft->sets[index].has_weight ? draft->sets[index].weight_kg : 0.0);
|
||
}
|
||
if (draft->input.set_count == 0U)
|
||
trainlog_surface_printf(app->content, row++, 4,
|
||
"Aucune série réelle. a pour saisir la première.");
|
||
}
|
||
if (session->form.active) {
|
||
trainlog_surface_printf(app->content, app->layout.content.height - 3, 2,
|
||
"> %s%s", session->form.text,
|
||
session->form.capacity_error ? " [200 octets maximum]" : "");
|
||
}
|
||
} else if (session->phase == TRAINLOG_SESSION_GENERATOR_CONFIG) {
|
||
trainlog_surface_printf(app->content, row++, 2, "Paramètres de la proposition");
|
||
trainlog_surface_printf(app->content, row++, 4, "Zone : %s",
|
||
session->generation_zone != NULL ? session->generation_zone->display_name : "—");
|
||
trainlog_surface_printf(app->content, row++, 4, "Objectif : %s",
|
||
session->generation_goal != NULL ?
|
||
generator_goal_label(session->generation_goal->id) : "—");
|
||
trainlog_surface_printf(app->content, row++, 4, "Durée : %d min",
|
||
session->generation_duration_minutes);
|
||
trainlog_surface_printf(app->content, row + 1, 2,
|
||
"z zone · o objectif · t durée · g générer");
|
||
} else if (session->phase == TRAINLOG_SESSION_GENERATOR_WARNING) {
|
||
TrainlogBodyZoneRecentExposure *exposure = &session->generated.exposure;
|
||
trainlog_surface_printf(app->content, row++, 2, "Zone travaillée récemment");
|
||
trainlog_surface_printf(app->content, row++, 4,
|
||
"24 h : %zu séries principales, %zu secondaires (%zu séances)",
|
||
exposure->within_24h.primary_set_count,
|
||
exposure->within_24h.secondary_set_count,
|
||
exposure->within_24h.session_count);
|
||
trainlog_surface_printf(app->content, row++, 4,
|
||
"72 h : %zu séries principales, %zu secondaires (%zu séances)",
|
||
exposure->within_72h.primary_set_count,
|
||
exposure->within_72h.secondary_set_count,
|
||
exposure->within_72h.session_count);
|
||
trainlog_surface_printf(app->content, row + 1, 2,
|
||
"c/Entrée continuer · z changer de zone · Échap conserver et revenir");
|
||
} else if (session->phase == TRAINLOG_SESSION_GENERATOR_PREVIEW) {
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"%zu exercice(s) · cible %d min · estimation %d min",
|
||
session->generated.exercise_count,
|
||
session->generation_duration_minutes,
|
||
session->generated.estimated_duration_seconds / 60);
|
||
if (session->generation_duration_minutes * 60 -
|
||
session->generated.estimated_duration_seconds >= 300)
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Proposition plus courte : aucun remplissage artificiel.");
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Échauffement et retour au calme absents en V1.");
|
||
for (index = 0U; index < session->generated.exercise_count &&
|
||
row + 1 < app->layout.content.height; ++index) {
|
||
TrainlogGeneratedExercise *item = &session->generated.exercises[index];
|
||
trainlog_surface_printf(app->content, row++, 4, "%c %zu. %.38s",
|
||
index == session->selected ? '>' : ' ', index + 1U,
|
||
session->generated_items[index].exercise_name);
|
||
trainlog_surface_printf(app->content, row++, 7,
|
||
"%d×%d · repos %d s · %.30s", item->target_sets,
|
||
item->target_repetitions, item->rest_seconds,
|
||
session->generated_items[index].equipment_name);
|
||
if (index == session->selected && row < app->layout.content.height) {
|
||
if (item->has_target_weight)
|
||
trainlog_surface_printf(app->content, row++, 7,
|
||
"%.2f kg · %s", item->target_weight_kg,
|
||
item->rationale_count > 0U && strcmp(item->rationale_codes[0],
|
||
"user_selected_max_percentage") == 0
|
||
? "pourcentage MAX choisi" : "charge automatique observée");
|
||
else trainlog_surface_printf(app->content, row++, 7,
|
||
item->rationale_count > 0U && strcmp(item->rationale_codes[0],
|
||
"compatible_max_unavailable") == 0
|
||
? "MAX compatible indisponible"
|
||
: "Aucune charge numérique qualifiée");
|
||
}
|
||
}
|
||
} else if (session->phase == TRAINLOG_SESSION_CONFIRM_REMOVE) {
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Retirer cette occurrence de la séance ?");
|
||
trainlog_surface_printf(app->content, row, 4, "%s Non %s Oui, supprimer",
|
||
session->destructive_confirm_selected ? "[ ]" : "[>]",
|
||
session->destructive_confirm_selected ? "[>]" : "[ ]");
|
||
} else if (session->phase == TRAINLOG_SESSION_CONFIRM_SET_REMOVE) {
|
||
trainlog_surface_printf(app->content, row++, 2, "Supprimer cette série ?");
|
||
trainlog_surface_printf(app->content, row, 4, "%s Non %s Oui, supprimer",
|
||
session->destructive_confirm_selected ? "[ ]" : "[>]",
|
||
session->destructive_confirm_selected ? "[>]" : "[ ]");
|
||
} else if (session->phase == TRAINLOG_SESSION_CONFIRM_ABANDON) {
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Abandonner la séance en cours ?");
|
||
trainlog_surface_printf(app->content, row, 4, "%s Non %s Oui, abandonner",
|
||
session->destructive_confirm_selected ? "[ ]" : "[>]",
|
||
session->destructive_confirm_selected ? "[>]" : "[ ]");
|
||
} else if (session->phase == TRAINLOG_SESSION_CONFIRM_LEAVE) {
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Proposition non acceptée conservée en mémoire.");
|
||
trainlog_surface_printf(app->content, row, 4,
|
||
"Entrée conserver et revenir · d Abandonner la proposition");
|
||
} else if (session->phase == TRAINLOG_SESSION_MESSAGE) {
|
||
trainlog_surface_printf(app->content, row++, 2, "%s", session->message);
|
||
trainlog_surface_printf(app->content, row, 2, "Entrée pour continuer");
|
||
} else {
|
||
trainlog_surface_printf(app->content, row++, 2, "Type : %s",
|
||
session_type_label(session->session_type));
|
||
if (session->draft_count == 0U)
|
||
trainlog_surface_printf(app->content, row, 2,
|
||
"Aucune occurrence. a pour choisir un exercice.");
|
||
for (index = 0U; index < session->draft_count &&
|
||
row < app->layout.content.height; ++index) {
|
||
char summary[128];
|
||
draft_set_summary(&session->drafts[index], summary, sizeof(summary));
|
||
trainlog_surface_printf(app->content, row++, 2, "%c %zu. %-28.28s %.38s",
|
||
index == session->selected ? '>' : ' ', index + 1U,
|
||
session->drafts[index].name, summary);
|
||
}
|
||
if (session->message[0] != '\0')
|
||
trainlog_surface_printf(app->content, app->layout.content.height - 2,
|
||
2, "%s", session->message);
|
||
}
|
||
}
|
||
|
||
static void app_shell_render_equipment_controller(TrainlogAppContext *app)
|
||
{
|
||
TrainlogEquipmentController *controller = &app->equipment_controller;
|
||
const char *label;
|
||
int row = 4;
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Créer un équipement personnel");
|
||
if (controller->phase == TRAINLOG_EQUIPMENT_CONFIRM_DISCARD) {
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Abandonner cette définition non enregistrée ?");
|
||
trainlog_surface_printf(app->content, row, 4,
|
||
"1 abandonner · 0/Échap reprendre");
|
||
return;
|
||
}
|
||
if (controller->phase == TRAINLOG_EQUIPMENT_MESSAGE) {
|
||
trainlog_surface_printf(app->content, row++, 2, "%s", controller->message);
|
||
trainlog_surface_printf(app->content, row, 2, "Entrée pour revenir au catalogue");
|
||
return;
|
||
}
|
||
trainlog_surface_printf(app->content, row++, 2, "Nom : %s",
|
||
controller->pending.display_name[0] != '\0'
|
||
? controller->pending.display_name : "—");
|
||
trainlog_surface_printf(app->content, row++, 2, "Étiquette : %s",
|
||
controller->pending.label_name[0] != '\0'
|
||
? controller->pending.label_name : "—");
|
||
trainlog_surface_printf(app->content, row++, 2, "Type : %s",
|
||
controller->pending.equipment_type[0] != '\0'
|
||
? controller->pending.equipment_type : "—");
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Charge : %s", controller->load_choice == 1 ? "aucune" :
|
||
controller->load_choice == 2 ? "externe" : "assistance");
|
||
row += 1;
|
||
if (controller->phase == TRAINLOG_EQUIPMENT_LOAD) {
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"↑↓ ou 1–3 choisir · Entrée enregistrer");
|
||
} else {
|
||
label = controller->phase == TRAINLOG_EQUIPMENT_NAME ? "Nom convivial" :
|
||
controller->phase == TRAINLOG_EQUIPMENT_LABEL
|
||
? "Nom d’étiquette (optionnel)" : "Type";
|
||
trainlog_surface_printf(app->content, row++, 2, "%s", label);
|
||
trainlog_surface_printf(app->content, row++, 4, "> %s%s",
|
||
controller->form.text,
|
||
controller->form.capacity_error ? " [200 octets maximum]" : "");
|
||
}
|
||
if (controller->message[0] != '\0')
|
||
trainlog_surface_printf(app->content, row + 1, 2, "%s",
|
||
controller->message);
|
||
}
|
||
|
||
static void app_shell_render_exercise_controller(TrainlogAppContext *app)
|
||
{
|
||
TrainlogExerciseController *controller = &app->exercise_controller;
|
||
int row = 4;
|
||
trainlog_surface_printf(app->content, row++, 2, "%s un exercice",
|
||
controller->editing ? "Modifier" : "Créer");
|
||
if (controller->phase == TRAINLOG_EXERCISE_CONFIRM_DISCARD) {
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Abandonner les modifications non enregistrées ?");
|
||
trainlog_surface_printf(app->content, row, 4,
|
||
"1 abandonner · 0/Échap reprendre");
|
||
return;
|
||
}
|
||
if (controller->phase == TRAINLOG_EXERCISE_MESSAGE) {
|
||
trainlog_surface_printf(app->content, row++, 2, "%s", controller->message);
|
||
trainlog_surface_printf(app->content, row, 2, "Entrée pour revenir");
|
||
return;
|
||
}
|
||
trainlog_surface_printf(app->content, row++, 2, "Nom : %s",
|
||
controller->pending.name[0] != '\0' ? controller->pending.name : "—");
|
||
trainlog_surface_printf(app->content, row++, 2, "Suivi : %s",
|
||
controller->pending.tracking_mode == TRAINLOG_TRACKING_REPS
|
||
? "répétitions" : "durée");
|
||
trainlog_surface_printf(app->content, row++, 2, "Organisation : %s",
|
||
controller->pending.recording_mode == TRAINLOG_RECORDING_CONTINUOUS
|
||
? "continue" : "séries");
|
||
if (controller->editing)
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Identifiant stable conservé : %s",
|
||
controller->pending.exercise_id);
|
||
row += 1;
|
||
if (controller->phase == TRAINLOG_EXERCISE_NAME) {
|
||
trainlog_surface_printf(app->content, row++, 2, "Nom d’affichage");
|
||
trainlog_surface_printf(app->content, row++, 4, "> %s%s",
|
||
controller->form.text,
|
||
controller->form.capacity_error ? " [200 octets maximum]" : "");
|
||
} else if (controller->phase == TRAINLOG_EXERCISE_TRACKING)
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Suivi : 1 répétitions · 2 durée · ↑↓ choisir · Entrée continuer");
|
||
else if (controller->phase == TRAINLOG_EXERCISE_RECORDING)
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Organisation : 1 séries · 2 continu · ↑↓ choisir · Entrée continuer");
|
||
else if (controller->phase == TRAINLOG_EXERCISE_SPEED)
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Mesurer la vitesse : %s · 0 non · 1 oui · ↑↓ choisir",
|
||
(controller->pending.data_fields & TRAINLOG_EXERCISE_DATA_SPEED_KMH)
|
||
!= 0U ? "oui" : "non");
|
||
else if (controller->phase == TRAINLOG_EXERCISE_DISTANCE)
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Mesurer la distance : %s · 0 non · 1 oui · ↑↓ choisir",
|
||
(controller->pending.data_fields & TRAINLOG_EXERCISE_DATA_DISTANCE_KM)
|
||
!= 0U ? "oui" : "non");
|
||
else if (controller->phase == TRAINLOG_EXERCISE_ZONES) {
|
||
size_t index;
|
||
const TrainlogBodyZone *primary = trainlog_body_zone_catalog_lookup(
|
||
controller->primary_zone);
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Zone principale : %s · secondaires : %zu",
|
||
primary != NULL ? primary->display_name : "Non renseignée",
|
||
controller->secondary_count);
|
||
for (index = controller->zone_selected;
|
||
index < trainlog_body_zone_catalog_count() &&
|
||
row < app->layout.content.height - 2; ++index) {
|
||
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(index);
|
||
trainlog_surface_printf(app->content, row++, 4, "%c %s%s",
|
||
index == controller->zone_selected ? '>' : ' ',
|
||
zone != NULL ? zone->display_name : "?",
|
||
zone != NULL && zone->is_group ? " [groupe]" : "");
|
||
}
|
||
trainlog_surface_printf(app->content,
|
||
app->layout.content.height - 2, 2,
|
||
"p principale · Espace secondaire · n effacer · Entrée enregistrer");
|
||
}
|
||
if (controller->message[0] != '\0')
|
||
trainlog_surface_printf(app->content,
|
||
app->layout.content.height - 3, 2, "%s", controller->message);
|
||
}
|
||
|
||
static int app_shell_graph_height(const TrainlogAppContext *app, int top)
|
||
{
|
||
int available = app->layout.content.height - top - 2;
|
||
if (available > 7) return 7;
|
||
return available;
|
||
}
|
||
|
||
/* WHY: a statistical line must show the relationship between two observations,
|
||
* rather than fill horizontal space with decorative ASCII. CONTRACT: callers
|
||
* pass distinct x coordinates inside their plot; this helper writes only
|
||
* Unicode line glyphs between them. INVARIANT: each interpolated cell stays in
|
||
* the caller-owned chart rectangle. */
|
||
static void app_shell_draw_chart_segment(TrainlogSurface *surface, int x1, int y1,
|
||
int x2, int y2)
|
||
{
|
||
int x;
|
||
if (surface == NULL || x2 <= x1) return;
|
||
for (x = x1 + 1; x < x2; ++x) {
|
||
int y = y1 + ((y2 - y1) * (x - x1)) / (x2 - x1);
|
||
int delta = y2 - y1;
|
||
uint32_t glyph = delta == 0 ? 0x2500U :
|
||
(delta < 0 ? 0x2571U : 0x2572U);
|
||
trainlog_surface_draw(surface, y, x, glyph);
|
||
}
|
||
}
|
||
|
||
static void app_shell_draw_performance_graph(
|
||
TrainlogAppContext *app,
|
||
const TrainlogExercisePerformancePoint *context,
|
||
TrainlogLoadMode mode,
|
||
TrainlogTrackingMode tracking_mode,
|
||
int top,
|
||
int height,
|
||
bool measured_max)
|
||
{
|
||
size_t indices[EXERCISE_GRAPH_POINTS];
|
||
size_t selected_count = 0U;
|
||
size_t index;
|
||
double minimum;
|
||
double maximum;
|
||
const int left = 11;
|
||
int width = app->layout.content.width - left - 2;
|
||
|
||
/* CONTRACT: graph coordinates are relative to the bounded content plane.
|
||
* Header/footer/sidebar ownership is therefore preserved at every valid
|
||
* resize, including the minimum 72x20 viewport. */
|
||
if (height < 3 || width < 12 || top < 1 ||
|
||
top + height >= app->layout.content.height) return;
|
||
for (index = 0U; index < app->performance_count &&
|
||
selected_count < EXERCISE_GRAPH_POINTS; ++index) {
|
||
const TrainlogExercisePerformancePoint *point =
|
||
&app->performance_points[index];
|
||
if (point->has_performance != 0 && point->load_mode == mode &&
|
||
(context == NULL || strcmp(point->equipment_id,
|
||
context->equipment_id) == 0) &&
|
||
(!measured_max || point->session_type == TRAINLOG_SESSION_MAX_TEST))
|
||
indices[selected_count++] = index;
|
||
}
|
||
if (selected_count == 0U) {
|
||
trainlog_surface_printf(app->content, top, 2,
|
||
"Aucun point exploitable pour ce contexte.");
|
||
return;
|
||
}
|
||
if (selected_count == 1U) {
|
||
char value[128];
|
||
exercise_format_performance(&app->performance_points[indices[0]], value,
|
||
sizeof(value));
|
||
trainlog_surface_printf(app->content, top, 2,
|
||
"%s · 1 point · tendance indisponible", value);
|
||
return;
|
||
}
|
||
minimum = exercise_graph_value(&app->performance_points[indices[0]]);
|
||
maximum = minimum;
|
||
for (index = 1U; index < selected_count; ++index) {
|
||
double value = exercise_graph_value(
|
||
&app->performance_points[indices[index]]);
|
||
if (value < minimum) minimum = value;
|
||
if (value > maximum) maximum = value;
|
||
}
|
||
trainlog_surface_set_role(app->content,
|
||
mode == TRAINLOG_LOAD_ASSISTANCE ? TRAINLOG_COLOR_WARNING
|
||
: TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
trainlog_surface_printf(app->content, top - 1, left, "%s",
|
||
mode == TRAINLOG_LOAD_ASSISTANCE
|
||
? (measured_max ? "Assistance mesurée (kg) — moins = mieux"
|
||
: "Assistance (kg) — moins = mieux")
|
||
: mode == TRAINLOG_LOAD_EXTERNAL
|
||
? (measured_max ? "MAX mesuré (kg)" : "Charge du meilleur set (kg)")
|
||
: tracking_mode == TRAINLOG_TRACKING_DURATION
|
||
? (measured_max ? "Durée MAX mesurée" : "Meilleure durée")
|
||
: (measured_max ? "Répétitions MAX mesurées" : "Meilleures répétitions"));
|
||
trainlog_surface_printf(app->content, top, 2, "haut %.1f", maximum);
|
||
trainlog_surface_printf(app->content, top + height - 1, 2, "bas %.1f", minimum);
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
{
|
||
int previous_x = -1;
|
||
int previous_y = -1;
|
||
for (int axis_row = top; axis_row < top + height; ++axis_row)
|
||
trainlog_surface_draw(app->content, axis_row, left - 2, 0x2502U);
|
||
size_t order;
|
||
for (order = selected_count; order > 0U; --order) {
|
||
size_t chronological = selected_count - order;
|
||
const TrainlogExercisePerformancePoint *point =
|
||
&app->performance_points[indices[order - 1U]];
|
||
int x = left +
|
||
(int)((chronological * (size_t)(width - 1)) /
|
||
(selected_count - 1U));
|
||
int y = normalized_graph_row(exercise_graph_value(point), minimum,
|
||
maximum, top, height);
|
||
if (previous_x >= 0 && x > previous_x) {
|
||
app_shell_draw_chart_segment(app->content, previous_x, previous_y,
|
||
x, y);
|
||
}
|
||
trainlog_surface_draw(app->content, y, x,
|
||
chronological + 1U == selected_count ? 0x25cfU : 0x25cbU);
|
||
previous_x = x;
|
||
previous_y = y;
|
||
}
|
||
}
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
{
|
||
char oldest[11];
|
||
char newest[11];
|
||
exercise_short_date(app->performance_points[
|
||
indices[selected_count - 1U]].started_at, oldest);
|
||
exercise_short_date(app->performance_points[indices[0]].started_at, newest);
|
||
trainlog_surface_printf(app->content, top + height, left, "%s", oldest);
|
||
trainlog_surface_printf(app->content, top + height,
|
||
left + width - 10, "%s", newest);
|
||
}
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
}
|
||
|
||
static void app_shell_draw_body_points(TrainlogAppContext *app,
|
||
const TrainlogBodyMetricPoint *points, size_t count, int top, int height,
|
||
const char *unit)
|
||
{
|
||
size_t start;
|
||
size_t index;
|
||
double minimum;
|
||
double maximum;
|
||
int left = 11;
|
||
int width = app->layout.content.width - left - 2;
|
||
if (count == 0U) {
|
||
trainlog_surface_printf(app->content, top, 2,
|
||
"Aucune donnée pour cette mesure.");
|
||
return;
|
||
}
|
||
if (count == 1U) {
|
||
trainlog_surface_printf(app->content, top, 2,
|
||
"%.2f %s · 1 relevé · tendance indisponible", points[0].value, unit);
|
||
return;
|
||
}
|
||
if (height < 3 || width < 10 || top + height >= app->layout.content.height)
|
||
return;
|
||
start = count > (size_t)width ? count - (size_t)width : 0U;
|
||
minimum = points[start].value;
|
||
maximum = minimum;
|
||
for (index = start + 1U; index < count; ++index) {
|
||
if (points[index].value < minimum) minimum = points[index].value;
|
||
if (points[index].value > maximum) maximum = points[index].value;
|
||
}
|
||
trainlog_surface_printf(app->content, top, 2, "haut %.1f", maximum);
|
||
trainlog_surface_printf(app->content, top + height - 1, 2, "bas %.1f", minimum);
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
for (int axis_row = top; axis_row < top + height; ++axis_row)
|
||
trainlog_surface_draw(app->content, axis_row, left - 2, 0x2502U);
|
||
{
|
||
int previous_x = -1;
|
||
int previous_y = -1;
|
||
size_t visible = count - start;
|
||
for (index = start; index < count; ++index) {
|
||
int x = left + (int)(((index - start) * (size_t)(width - 1)) /
|
||
(visible - 1U));
|
||
int y = normalized_graph_row(points[index].value, minimum, maximum,
|
||
top, height);
|
||
if (previous_x >= 0)
|
||
app_shell_draw_chart_segment(app->content, previous_x, previous_y,
|
||
x, y);
|
||
trainlog_surface_draw(app->content, y, x,
|
||
index + 1U == count ? 0x25cfU : 0x25cbU);
|
||
previous_x = x;
|
||
previous_y = y;
|
||
}
|
||
}
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
{
|
||
char oldest[11];
|
||
char newest[11];
|
||
exercise_short_date(points[start].observed_at, oldest);
|
||
exercise_short_date(points[count - 1U].observed_at, newest);
|
||
trainlog_surface_printf(app->content, top + height, left,
|
||
"%s → %s · %zu relevés · %s", oldest, newest, count, unit);
|
||
}
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
}
|
||
|
||
static void app_shell_render_body_profile(TrainlogAppContext *app)
|
||
{
|
||
const TrainlogBodyObservationRecord *record;
|
||
const bool *present[13];
|
||
const double *values[13];
|
||
double maximum = 0.0;
|
||
size_t circumference_count = 0U;
|
||
size_t shown = 0U;
|
||
size_t index;
|
||
int evolution_top;
|
||
int profile_row = 6;
|
||
int bar_left = 23;
|
||
int bar_capacity = app->layout.content.width - bar_left - 10;
|
||
TrainlogGlobalBodySeries *series;
|
||
|
||
if (app->body_snapshot_error) {
|
||
trainlog_surface_printf(app->content, 3, 2,
|
||
"Impossible de lire les mensurations.");
|
||
return;
|
||
}
|
||
if (app->loaded_count == 0U) {
|
||
trainlog_surface_printf(app->content, 3, 2,
|
||
"Aucun relevé corporel. a pour ajouter un relevé.");
|
||
trainlog_surface_printf(app->content, 5, 2,
|
||
"ÉVOLUTION DANS LE TEMPS · aucune métrique disponible");
|
||
return;
|
||
}
|
||
if (app->list.selected_index >= app->loaded_count)
|
||
app->list.selected_index = 0U;
|
||
record = &app->body_records[app->list.selected_index];
|
||
{
|
||
const bool *const flags[13] = {&record->has_neck,
|
||
&record->has_shoulders, &record->has_chest, &record->has_waist,
|
||
&record->has_hips, &record->has_left_arm, &record->has_right_arm,
|
||
&record->has_left_forearm, &record->has_right_forearm,
|
||
&record->has_left_thigh, &record->has_right_thigh,
|
||
&record->has_left_calf, &record->has_right_calf};
|
||
const double *const measurements[13] = {&record->neck_cm,
|
||
&record->shoulders_cm, &record->chest_cm, &record->waist_cm,
|
||
&record->hips_cm, &record->left_arm_cm, &record->right_arm_cm,
|
||
&record->left_forearm_cm, &record->right_forearm_cm,
|
||
&record->left_thigh_cm, &record->right_thigh_cm,
|
||
&record->left_calf_cm, &record->right_calf_cm};
|
||
(void)memcpy(present, flags, sizeof(present));
|
||
(void)memcpy(values, measurements, sizeof(values));
|
||
}
|
||
for (index = 0U; index < 13U; ++index) {
|
||
if (!*present[index]) continue;
|
||
++circumference_count;
|
||
if (*values[index] > maximum) maximum = *values[index];
|
||
}
|
||
|
||
/* WHY: weight and circumference have unrelated units and must never share
|
||
* a visual scale. INVARIANT: absent circumference fields produce no row;
|
||
* every rendered bar encodes the selected observation's actual centimetres. */
|
||
trainlog_surface_printf(app->content, 3, 2,
|
||
"PROFIL ACTUEL · relevé %zu/%zu · %s", app->list.selected_index + 1U,
|
||
app->loaded_count, record->observed_at);
|
||
if (record->has_body_weight)
|
||
trainlog_surface_printf(app->content, 4, 2, "Poids : %.2f kg",
|
||
record->body_weight_kg);
|
||
else trainlog_surface_printf(app->content, 4, 2,
|
||
"Poids : non renseigné");
|
||
trainlog_surface_printf(app->content, 5, 2,
|
||
"PROFIL CIRCONFÉRENCES · échelle au plus grand cm présent");
|
||
|
||
evolution_top = app->layout.content.height >= 26
|
||
? 20 : app->layout.content.height - 6;
|
||
if (evolution_top < 10) evolution_top = 10;
|
||
if (bar_capacity < 1) bar_capacity = 1;
|
||
for (index = 0U; index < 13U && profile_row < evolution_top - 1; ++index) {
|
||
int length;
|
||
int column;
|
||
if (!*present[index]) continue;
|
||
length = maximum > 0.0
|
||
? (int)((*values[index] / maximum) * (double)bar_capacity) : 1;
|
||
if (length < 1) length = 1;
|
||
if (length > bar_capacity) length = bar_capacity;
|
||
trainlog_surface_printf(app->content, profile_row, 2, "%-19s",
|
||
BODY_METRICS[index + 1U].label);
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
for (column = 0; column < length; ++column)
|
||
trainlog_surface_draw(app->content, profile_row,
|
||
bar_left + column, 0x2586U);
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
trainlog_surface_printf(app->content, profile_row,
|
||
bar_left + length + 1, "%.1f cm", *values[index]);
|
||
++profile_row;
|
||
++shown;
|
||
}
|
||
if (circumference_count == 0U)
|
||
trainlog_surface_printf(app->content, profile_row, 2,
|
||
"Aucune circonférence dans ce relevé.");
|
||
else if (shown < circumference_count)
|
||
trainlog_surface_printf(app->content, evolution_top - 1, 2,
|
||
"… %zu autre(s) circonférence(s) · ↑↓ change le relevé",
|
||
circumference_count - shown);
|
||
|
||
series = &app->body_global_series[app->body_metric_selected];
|
||
if (series->count == 0U) {
|
||
trainlog_surface_printf(app->content, evolution_top, 2,
|
||
"ÉVOLUTION DANS LE TEMPS · aucune métrique disponible");
|
||
return;
|
||
}
|
||
trainlog_surface_printf(app->content, evolution_top, 2,
|
||
"ÉVOLUTION DANS LE TEMPS · ←/→ %s",
|
||
BODY_METRICS[app->body_metric_selected].label);
|
||
app_shell_draw_body_points(app, series->points, series->count,
|
||
evolution_top + 1,
|
||
app->layout.content.height - evolution_top - 2,
|
||
BODY_METRICS[app->body_metric_selected].unit);
|
||
}
|
||
|
||
static void app_shell_render_body_controller(TrainlogAppContext *app)
|
||
{
|
||
TrainlogBodyController *controller = &app->body_controller;
|
||
const char *label = controller->field < 14U
|
||
? BODY_METRICS[controller->field].label : "Mesure";
|
||
trainlog_surface_printf(app->content, 4, 2, "%s — champ %zu/14",
|
||
controller->editing ? "Modifier le relevé" : "Nouveau relevé",
|
||
controller->field + 1U);
|
||
if (controller->phase == TRAINLOG_BODY_CONFIRM_DISCARD) {
|
||
trainlog_surface_printf(app->content, 7, 2,
|
||
"Abandonner toutes les modifications ?");
|
||
trainlog_surface_printf(app->content, 9, 2,
|
||
"1 abandonner · 0/Échap reprendre");
|
||
} else if (controller->phase == TRAINLOG_BODY_MESSAGE) {
|
||
trainlog_surface_printf(app->content, 7, 2, "%s", controller->message);
|
||
trainlog_surface_printf(app->content, 9, 2, "Entrée pour revenir");
|
||
} else {
|
||
trainlog_surface_printf(app->content, 7, 2, "%s (%s)", label,
|
||
BODY_METRICS[controller->field].unit);
|
||
trainlog_surface_printf(app->content, 9, 2, "> %s",
|
||
controller->form.text);
|
||
trainlog_surface_printf(app->content, 11, 2, "%s",
|
||
controller->editing
|
||
? "vide conserve · - efface · valeur positive remplace"
|
||
: "vide signifie mesure non faite · valeur positive ajoute");
|
||
if (controller->message[0] != '\0')
|
||
trainlog_surface_printf(app->content, 13, 2, "%s",
|
||
controller->message);
|
||
}
|
||
}
|
||
|
||
static void app_shell_render_profile_controller(TrainlogAppContext *app)
|
||
{
|
||
TrainlogProfileController *controller = &app->profile_controller;
|
||
trainlog_surface_printf(app->content, 4, 2,
|
||
"Profil d’estimation corporelle");
|
||
if (controller->phase == TRAINLOG_PROFILE_CONFIRM_DISCARD) {
|
||
trainlog_surface_printf(app->content, 7, 2,
|
||
"Abandonner les modifications du profil ?");
|
||
trainlog_surface_printf(app->content, 9, 2,
|
||
"1 abandonner · 0/Échap reprendre");
|
||
} else if (controller->phase == TRAINLOG_PROFILE_MESSAGE) {
|
||
trainlog_surface_printf(app->content, 7, 2, "%s", controller->message);
|
||
} else {
|
||
trainlog_surface_printf(app->content, 7, 2, "%s",
|
||
controller->phase == TRAINLOG_PROFILE_FORMULA
|
||
? "Formule : 1 homme · 2 femme"
|
||
: "Taille en cm : 100 à 250");
|
||
trainlog_surface_printf(app->content, 9, 2, "> %s",
|
||
controller->form.text);
|
||
if (controller->message[0] != '\0')
|
||
trainlog_surface_printf(app->content, 11, 2, "%s",
|
||
controller->message);
|
||
}
|
||
}
|
||
|
||
static void app_shell_render_body_detail(TrainlogAppContext *app)
|
||
{
|
||
const TrainlogBodyObservationRecord *record = &app->body_detail;
|
||
const bool present[14] = {record->has_body_weight, record->has_neck,
|
||
record->has_shoulders, record->has_chest, record->has_waist,
|
||
record->has_hips, record->has_left_arm, record->has_right_arm,
|
||
record->has_left_forearm, record->has_right_forearm,
|
||
record->has_left_thigh, record->has_right_thigh,
|
||
record->has_left_calf, record->has_right_calf};
|
||
const double values[14] = {record->body_weight_kg, record->neck_cm,
|
||
record->shoulders_cm, record->chest_cm, record->waist_cm,
|
||
record->hips_cm, record->left_arm_cm, record->right_arm_cm,
|
||
record->left_forearm_cm, record->right_forearm_cm,
|
||
record->left_thigh_cm, record->right_thigh_cm,
|
||
record->left_calf_cm, record->right_calf_cm};
|
||
size_t index;
|
||
int row = 4;
|
||
trainlog_surface_printf(app->content, row++, 2, "Date : %s",
|
||
record->observed_at);
|
||
trainlog_surface_printf(app->content, row++, 2, "Séance liée : %s",
|
||
record->session_id[0] != '\0' ? record->session_id : "aucune");
|
||
for (index = app->content_scroll; index < 14U &&
|
||
row < app->layout.content.height - 1; ++index) {
|
||
if (present[index])
|
||
trainlog_surface_printf(app->content, row++, 2, "%-22s %.2f %s",
|
||
BODY_METRICS[index].label, values[index], BODY_METRICS[index].unit);
|
||
else trainlog_surface_printf(app->content, row++, 2, "%-22s —",
|
||
BODY_METRICS[index].label);
|
||
}
|
||
}
|
||
|
||
static void app_shell_surface_segment(TrainlogSurface *surface, int x1, int y1,
|
||
int x2, int y2, uint32_t symbol)
|
||
{
|
||
int x;
|
||
if (surface == NULL || x2 < x1) return;
|
||
if (x1 == x2) { trainlog_surface_draw(surface, y2, x2, symbol); return; }
|
||
for (x = x1; x <= x2; ++x) {
|
||
int y = y1 + ((y2 - y1) * (x - x1)) / (x2 - x1);
|
||
trainlog_surface_draw(surface, y, x, symbol);
|
||
}
|
||
}
|
||
|
||
static void app_shell_dashboard_sparkline(TrainlogAppContext *app,
|
||
const double *values, size_t count, int left, int top, int width, int height)
|
||
{
|
||
double minimum;
|
||
double maximum;
|
||
size_t start;
|
||
size_t index;
|
||
int previous_x = -1;
|
||
int previous_y = -1;
|
||
/* INVARIANT: a line encodes change between actual observations. A single
|
||
* point has no trend and must never acquire a synthetic axis or baseline. */
|
||
if (count < 2U || width < 4 || height < 2) return;
|
||
start = count > (size_t)width ? count - (size_t)width : 0U;
|
||
minimum = values[start];
|
||
maximum = values[start];
|
||
for (index = start + 1U; index < count; ++index) {
|
||
if (values[index] < minimum) minimum = values[index];
|
||
if (values[index] > maximum) maximum = values[index];
|
||
}
|
||
if (minimum == maximum) { minimum -= 1.0; maximum += 1.0; }
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
for (index = start; index < count; ++index) {
|
||
size_t relative = index - start;
|
||
size_t visible = count - start;
|
||
int x = visible == 1U ? left + width / 2 : left +
|
||
(int)((relative * (size_t)(width - 1)) / (visible - 1U));
|
||
int y = top + height - 1 - (int)(((values[index] - minimum) /
|
||
(maximum - minimum)) * (double)(height - 1));
|
||
if (previous_x >= 0)
|
||
app_shell_surface_segment(app->content, previous_x, previous_y,
|
||
x, y, 0x2584U);
|
||
trainlog_surface_draw(app->content, y, x,
|
||
index + 1U == count ? 0x2588U : 0x2584U);
|
||
previous_x = x;
|
||
previous_y = y;
|
||
}
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
}
|
||
|
||
static void app_shell_dashboard_performance_events(TrainlogAppContext *app,
|
||
const TrainlogDashboardSnapshot *dashboard, int row, int column)
|
||
{
|
||
size_t index;
|
||
size_t working = 0U;
|
||
size_t maxima = 0U;
|
||
for (index = 0U; index < dashboard->week_count; ++index) {
|
||
working += dashboard->weeks[index].working_improvements;
|
||
maxima += dashboard->weeks[index].max_improvements;
|
||
}
|
||
/* Text totals are the accessible equivalent of the Unicode plot. Empty
|
||
* buckets remain dots: a low block would falsely suggest an event. */
|
||
trainlog_surface_printf(app->content, row, column,
|
||
"%s · travail %zu · MAX %zu%s",
|
||
dashboard_period_bucket_summary(dashboard->period), working, maxima,
|
||
working + maxima == 0U ? " · données insuffisantes" : "");
|
||
for (index = 0U; index < dashboard->week_count &&
|
||
column + 2 + (int)index * 3 < app->layout.content.width; ++index) {
|
||
size_t level = dashboard->weeks[index].working_improvements +
|
||
dashboard->weeks[index].max_improvements;
|
||
trainlog_surface_set_role(app->content,
|
||
dashboard->weeks[index].max_improvements > 0U ?
|
||
TRAINLOG_COLOR_WARNING : TRAINLOG_COLOR_GRAPH,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
trainlog_surface_draw(app->content, row + 1, column + 2 + (int)index * 3,
|
||
level > 0U ? 0x2588U : 0x00b7U);
|
||
}
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
}
|
||
|
||
static void app_shell_dashboard_wide_performance_events(TrainlogAppContext *app,
|
||
const TrainlogDashboardSnapshot *dashboard, int row, int column)
|
||
{
|
||
size_t index;
|
||
size_t working = 0U;
|
||
size_t maxima = 0U;
|
||
for (index = 0U; index < dashboard->week_count; ++index) {
|
||
working += dashboard->weeks[index].working_improvements;
|
||
maxima += dashboard->weeks[index].max_improvements;
|
||
}
|
||
trainlog_surface_printf(app->content, row, column,
|
||
"%s · travail %zu · MAX %zu",
|
||
dashboard_period_bucket_summary(dashboard->period), working, maxima);
|
||
for (index = 0U; index < dashboard->week_count; ++index) {
|
||
size_t level = dashboard->weeks[index].working_improvements +
|
||
dashboard->weeks[index].max_improvements;
|
||
trainlog_surface_set_role(app->content,
|
||
dashboard->weeks[index].max_improvements > 0U ?
|
||
TRAINLOG_COLOR_WARNING : TRAINLOG_COLOR_GRAPH,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
trainlog_surface_draw(app->content, row + 1,
|
||
column + (int)index * 3, level > 0U ? 0x2588U : 0x00b7U);
|
||
}
|
||
if (working + maxima == 0U)
|
||
trainlog_surface_printf(app->content, row + 2, column,
|
||
"Données insuffisantes.");
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
}
|
||
|
||
static void app_shell_dashboard_decimal_comma(double value, char *output,
|
||
size_t output_size)
|
||
{
|
||
char *separator;
|
||
if (output == NULL || output_size == 0U) return;
|
||
(void)snprintf(output, output_size, "%.2f", value);
|
||
separator = strchr(output, '.');
|
||
if (separator != NULL) *separator = ',';
|
||
}
|
||
|
||
/* WHY: the landing page needs a genuinely global catalogue view, while the
|
||
* exercise list remains the destination for inspection. CONTRACT: bars encode
|
||
* active canonical-exercise counts prepared in the dashboard snapshot only.
|
||
* INVARIANT: zero counts never receive invented bars, and a compact viewport
|
||
* deterministically retains the count/label-sorted leading buckets. */
|
||
static void app_shell_dashboard_zone_chart(TrainlogAppContext *app,
|
||
const TrainlogDashboardSnapshot *dashboard, int top, int left, int width,
|
||
int rows)
|
||
{
|
||
size_t index;
|
||
size_t bucket_rows;
|
||
size_t maximum = 0U;
|
||
bool separated_rows;
|
||
int row_stride;
|
||
int label_width;
|
||
int bar_width;
|
||
if (width < 20 || rows < 1) return;
|
||
if (dashboard->zone_count == 0U) {
|
||
trainlog_surface_printf(app->content, top, left,
|
||
"Aucun exercice actif à répartir.");
|
||
return;
|
||
}
|
||
for (index = 0U; index < dashboard->zone_count; ++index)
|
||
if (dashboard->zones[index].count > maximum) maximum = dashboard->zones[index].count;
|
||
label_width = width >= 64 ? 20 : width >= 48 ? 16 : 12;
|
||
bar_width = width - label_width - 6;
|
||
if (bar_width < 4) return;
|
||
/* Spacing is a wide-layout enhancement, never a reason to hide catalogue
|
||
* buckets which already fit. Thin bars remain independently legible on
|
||
* adjacent rows when the available chart height cannot hold every gap. */
|
||
separated_rows = app->layout.columns >= 100 &&
|
||
dashboard->zone_count <= (size_t)((rows + 1) / 2);
|
||
row_stride = separated_rows ? 2 : 1;
|
||
bucket_rows = separated_rows ? (size_t)((rows + 1) / 2) : (size_t)rows;
|
||
if (dashboard->zone_count < bucket_rows) bucket_rows = dashboard->zone_count;
|
||
if (dashboard->zone_count > bucket_rows) {
|
||
size_t summary_row = separated_rows
|
||
? bucket_rows * (size_t)row_stride - 1U : bucket_rows;
|
||
if (summary_row >= (size_t)rows && bucket_rows > 1U) --bucket_rows;
|
||
}
|
||
for (index = 0U; index < bucket_rows; ++index) {
|
||
int row = top + (int)index * row_stride;
|
||
int blocks = maximum == 0U ? 0 : (int)((dashboard->zones[index].count *
|
||
(size_t)bar_width + maximum - 1U) / maximum);
|
||
int block;
|
||
/* CONTRACT: label, bar and right-edge count own separate columns;
|
||
* independently scaled partial-height rows can never form the former
|
||
* solid staircase. Wide layouts leave a blank row between buckets. */
|
||
trainlog_surface_printf(app->content, row, left,
|
||
"%-*.*s", label_width, label_width, dashboard->zones[index].label);
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
for (block = 0; block < blocks; ++block)
|
||
trainlog_surface_draw(app->content, row,
|
||
left + label_width + 1 + block, 0x2586U);
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
trainlog_surface_printf(app->content, row,
|
||
left + label_width + 2 + bar_width, "%zu",
|
||
dashboard->zones[index].count);
|
||
}
|
||
if (dashboard->zone_count > bucket_rows) {
|
||
size_t summary_row = separated_rows
|
||
? bucket_rows * (size_t)row_stride - 1U : bucket_rows;
|
||
if (summary_row >= (size_t)rows) return;
|
||
trainlog_surface_printf(app->content,
|
||
top + (int)summary_row, left,
|
||
"+ %zu zones dans le catalogue", dashboard->zone_count - bucket_rows);
|
||
}
|
||
}
|
||
|
||
static void app_shell_dashboard_frequency_chart(TrainlogAppContext *app,
|
||
const TrainlogDashboardSnapshot *dashboard, int top, int left, int width,
|
||
int height)
|
||
{
|
||
size_t index;
|
||
size_t maximum = 0U;
|
||
int baseline;
|
||
if (width < 12 || height < 1) return;
|
||
if (dashboard->week_count == 0U) {
|
||
trainlog_surface_printf(app->content, top, left,
|
||
"Aucune séance dans cette période.");
|
||
return;
|
||
}
|
||
for (index = 0U; index < dashboard->week_count; ++index)
|
||
if (dashboard->weeks[index].sessions > maximum)
|
||
maximum = dashboard->weeks[index].sessions;
|
||
trainlog_surface_printf(app->content, top, left,
|
||
"%s · actuel %zu séance%s · MAX %zu",
|
||
dashboard_period_bucket_summary(dashboard->period),
|
||
dashboard->weeks[dashboard->week_count - 1U].sessions,
|
||
dashboard->weeks[dashboard->week_count - 1U].sessions == 1U ? "" : "s",
|
||
dashboard->weeks[dashboard->week_count - 1U].maxima);
|
||
baseline = top + height;
|
||
for (index = 0U; index < dashboard->week_count; ++index) {
|
||
const char *label = dashboard->weeks[index].label;
|
||
int label_margin = 3;
|
||
int x = dashboard->week_count > 1U
|
||
? left + label_margin + (int)((index * (size_t)(width - 2 * label_margin - 1)) /
|
||
(dashboard->week_count - 1U))
|
||
: left + width / 2;
|
||
int level = maximum == 0U ? 0 : (int)((dashboard->weeks[index].sessions *
|
||
(size_t)height + maximum - 1U) / maximum);
|
||
int bar_row;
|
||
trainlog_surface_set_role(app->content,
|
||
dashboard->weeks[index].maxima > 0U ? TRAINLOG_COLOR_WARNING :
|
||
(index + 1U == dashboard->week_count ? TRAINLOG_COLOR_ACCENT :
|
||
TRAINLOG_COLOR_GRAPH), TRAINLOG_RGB_BASE,
|
||
index + 1U == dashboard->week_count ? TRAINLOG_TEXT_BOLD :
|
||
TRAINLOG_TEXT_NORMAL);
|
||
if (level == 0) trainlog_surface_draw(app->content, baseline, x, 0x00b7U);
|
||
else for (bar_row = 0; bar_row < level; ++bar_row)
|
||
trainlog_surface_draw(app->content, baseline - bar_row, x, 0x2588U);
|
||
/* Each period bucket owns one x coordinate: the count, optional MAX
|
||
* marker, bar and label all remain vertically aligned at that point. */
|
||
trainlog_surface_printf(app->content, baseline - (level > 0 ? level - 1 : 0),
|
||
x, "%zu", dashboard->weeks[index].sessions);
|
||
if (dashboard->weeks[index].maxima > 0U) {
|
||
/* WHY: the minimum dashboard has one bar row; lifting its marker
|
||
* would overwrite the frequency summary. CONTRACT: compact MAX
|
||
* markers sit beside the count, taller charts keep them above it. */
|
||
int marker_row = height == 1 ? baseline :
|
||
baseline - (level > 0 ? level : 0);
|
||
int marker_column = height == 1 ? x - 2 : x;
|
||
trainlog_surface_draw(app->content, marker_row, marker_column,
|
||
0x25c6U);
|
||
}
|
||
trainlog_surface_printf(app->content, baseline + 1,
|
||
x - (index + 1U == dashboard->week_count ? 3 : 1), "%s", label);
|
||
}
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
}
|
||
|
||
static void app_shell_render_dashboard(TrainlogAppContext *app)
|
||
{
|
||
const TrainlogDashboardSnapshot *dashboard = &app->dashboard;
|
||
double body_values[MAX_BODY_METRIC_POINTS];
|
||
size_t index;
|
||
for (index = 0U; index < dashboard->body_count; ++index)
|
||
body_values[index] = dashboard->body[index].value;
|
||
trainlog_surface_printf(app->content, 2, 2,
|
||
"Période %s %s %s %s %s",
|
||
dashboard->period == TRAINLOG_STATS_7_DAYS ? "[7j]" : "7j",
|
||
dashboard->period == TRAINLOG_STATS_30_DAYS ? "[30j]" : "30j",
|
||
dashboard->period == TRAINLOG_STATS_90_DAYS ? "[90j]" : "90j",
|
||
dashboard->period == TRAINLOG_STATS_YEAR ? "[1an]" : "1an",
|
||
dashboard->period == TRAINLOG_STATS_ALL ? "[Tout]" : "Tout");
|
||
trainlog_surface_printf(app->content, 3, 2,
|
||
"Séances %zu Séries %zu Exercices pratiqués %zu MAX %zu",
|
||
dashboard->completed_session_count, dashboard->performed_set_count,
|
||
dashboard->distinct_exercise_count, dashboard->explicit_max_count);
|
||
if (dashboard->error)
|
||
trainlog_surface_printf(app->content, 4, 2,
|
||
"Certaines données n’ont pas pu être lues.");
|
||
else if (dashboard->invalid_data)
|
||
trainlog_surface_printf(app->content, 4, 2,
|
||
"Données historiques invalides ignorées.");
|
||
else if (dashboard->partial)
|
||
trainlog_surface_printf(app->content, 4, 2,
|
||
"Vue bornée : historique récent partiel.");
|
||
if (app->layout.columns >= 100 && app->layout.content.width >= 70) {
|
||
int right = app->layout.content.width / 2 + 2;
|
||
int body_width = app->layout.content.width - right - 2;
|
||
int frequency_top = 10;
|
||
int chart_height = app->layout.content.height - frequency_top - 11;
|
||
int zones_top;
|
||
int zone_rows;
|
||
if (chart_height > 5) chart_height = 5;
|
||
if (chart_height < 2) chart_height = 2;
|
||
trainlog_surface_printf(app->content, 5, 2, "%s Progression globale",
|
||
app->content_selected == 0U ? "▶" : " ");
|
||
app_shell_dashboard_wide_performance_events(app, dashboard, 6, 4);
|
||
trainlog_surface_printf(app->content, 5, right, "%s Mensurations",
|
||
app->content_selected == 1U ? "▶" : " ");
|
||
if (dashboard->has_body && dashboard->body_count > 0U) {
|
||
double latest = dashboard->body[dashboard->body_count - 1U].value;
|
||
char latest_text[32];
|
||
app_shell_dashboard_decimal_comma(latest, latest_text,
|
||
sizeof(latest_text));
|
||
if (dashboard->body_count == 1U)
|
||
trainlog_surface_printf(app->content, 6, right,
|
||
"%s · %s %s · 1 relevé",
|
||
BODY_METRICS[dashboard->body_metric].label, latest_text,
|
||
BODY_METRICS[dashboard->body_metric].unit);
|
||
else {
|
||
double delta = latest - dashboard->body[
|
||
dashboard->body_count - 2U].value;
|
||
char delta_text[32];
|
||
app_shell_dashboard_decimal_comma(fabs(delta), delta_text,
|
||
sizeof(delta_text));
|
||
trainlog_surface_printf(app->content, 6, right,
|
||
"%s · %s %s · Δ %c%s",
|
||
BODY_METRICS[dashboard->body_metric].label, latest_text,
|
||
BODY_METRICS[dashboard->body_metric].unit,
|
||
delta >= 0.0 ? '+' : '-', delta_text);
|
||
app_shell_dashboard_sparkline(app, body_values,
|
||
dashboard->body_count, right, 7, body_width, 2);
|
||
}
|
||
} else trainlog_surface_printf(app->content, 6, right,
|
||
"Aucune mensuration.");
|
||
trainlog_surface_printf(app->content, 10, 2, "%s Fréquence",
|
||
app->content_selected == 2U ? "▶" : " ");
|
||
app_shell_dashboard_frequency_chart(app, dashboard, 11, 4,
|
||
app->layout.content.width - 8, chart_height);
|
||
/* INVARIANT: frequency owns summary, bars, MAX markers and labels;
|
||
* distribution begins strictly after its final label row. */
|
||
zones_top = 14 + chart_height;
|
||
zone_rows = app->layout.content.height - zones_top - 2;
|
||
trainlog_surface_printf(app->content, zones_top - 1, 2,
|
||
"%s Répartition du catalogue",
|
||
app->content_selected == 3U ? "▶" : " ");
|
||
app_shell_dashboard_zone_chart(app, dashboard, zones_top, 4,
|
||
app->layout.content.width - 8, zone_rows);
|
||
if (app->layout.content.height > zones_top + 4)
|
||
trainlog_surface_printf(app->content,
|
||
app->layout.content.height - 2, 2,
|
||
"↑↓ région · Entrée détail · chiffres période");
|
||
return;
|
||
}
|
||
/* Compact planes retain each dashboard region. The smaller form omits a
|
||
* decorative body sparkline, not data: a real two-point chart belongs in
|
||
* the detail view, while the zone bars and frequency bars stay visible. */
|
||
trainlog_surface_printf(app->content, 5, 2, "%s Progression globale",
|
||
app->content_selected == 0U ? "▶" : " ");
|
||
app_shell_dashboard_performance_events(app, dashboard, 5, 25);
|
||
trainlog_surface_printf(app->content, 7, 2, "%s Mensurations",
|
||
app->content_selected == 1U ? "▶" : " ");
|
||
if (dashboard->has_body && dashboard->body_count > 0U) {
|
||
double latest = dashboard->body[dashboard->body_count - 1U].value;
|
||
char latest_text[32];
|
||
app_shell_dashboard_decimal_comma(latest, latest_text, sizeof(latest_text));
|
||
if (dashboard->body_count == 1U)
|
||
trainlog_surface_printf(app->content, 8, 4,
|
||
"%s · %s %s · 1 relevé, tendance indisponible",
|
||
BODY_METRICS[dashboard->body_metric].label, latest_text,
|
||
BODY_METRICS[dashboard->body_metric].unit);
|
||
else {
|
||
double delta = latest - dashboard->body[dashboard->body_count - 2U].value;
|
||
char delta_text[32];
|
||
app_shell_dashboard_decimal_comma(fabs(delta), delta_text,
|
||
sizeof(delta_text));
|
||
trainlog_surface_printf(app->content, 8, 4,
|
||
"%s · %s %s · Δ %c%s",
|
||
BODY_METRICS[dashboard->body_metric].label, latest_text,
|
||
BODY_METRICS[dashboard->body_metric].unit,
|
||
delta >= 0.0 ? '+' : '-', delta_text);
|
||
}
|
||
} else trainlog_surface_printf(app->content, 8, 4,
|
||
"Aucune mensuration saisie.");
|
||
trainlog_surface_printf(app->content, 10, 2, "%s Fréquence",
|
||
app->content_selected == 2U ? "▶" : " ");
|
||
app_shell_dashboard_frequency_chart(app, dashboard, 11, 4,
|
||
app->layout.content.width - 8,
|
||
1);
|
||
trainlog_surface_printf(app->content, 14, 2, "%s Répartition du catalogue",
|
||
app->content_selected == 3U ? "▶" : " ");
|
||
if (app->layout.content.height >= 16)
|
||
app_shell_dashboard_zone_chart(app, dashboard, 15, 4,
|
||
app->layout.content.width - 8,
|
||
app->layout.content.height - 15);
|
||
}
|
||
|
||
static void app_shell_render_body_global(TrainlogAppContext *app)
|
||
{
|
||
size_t metric;
|
||
double minimum = 100.0;
|
||
double maximum = 100.0;
|
||
int graph_top = 4;
|
||
int graph_height = app->layout.content.height >= 20 ? 7 : 5;
|
||
int left = 8;
|
||
int width = app->layout.content.width - left - 3;
|
||
for (metric = 0U; metric < 14U; ++metric) {
|
||
TrainlogGlobalBodySeries *series = &app->body_global_series[metric];
|
||
size_t point;
|
||
if (!app->body_global_enabled[metric]) continue;
|
||
for (point = 0U; point < series->count; ++point) {
|
||
double value;
|
||
if (trainlog_body_index100(series->baseline,
|
||
series->points[point].value, &value) != TRAINLOG_STATUS_OK)
|
||
continue;
|
||
if (value < minimum) minimum = value;
|
||
if (value > maximum) maximum = value;
|
||
}
|
||
}
|
||
if (maximum == minimum) { minimum = 99.0; maximum = 101.0; }
|
||
if (width >= 10 && app->body_global_date_count > 0U) {
|
||
trainlog_surface_printf(app->content, graph_top, 2, "%.1f", maximum);
|
||
trainlog_surface_printf(app->content, graph_top + graph_height - 1, 2,
|
||
"%.1f", minimum);
|
||
for (metric = 0U; metric < 14U; ++metric) {
|
||
TrainlogGlobalBodySeries *series = &app->body_global_series[metric];
|
||
size_t point;
|
||
int previous_x = -1;
|
||
int previous_y = -1;
|
||
if (!app->body_global_enabled[metric]) continue;
|
||
trainlog_surface_set_role(app->content, series->role,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
for (point = 0U; point < series->count; ++point) {
|
||
double value;
|
||
size_t date;
|
||
int x;
|
||
int y;
|
||
if (trainlog_body_index100(series->baseline,
|
||
series->points[point].value,
|
||
&value) != TRAINLOG_STATUS_OK) continue;
|
||
date = global_date_index(app->body_global_dates,
|
||
app->body_global_date_count,
|
||
series->points[point].observed_at);
|
||
x = app->body_global_date_count == 1U ? left + width / 2
|
||
: left + (int)((date * (size_t)(width - 1)) /
|
||
(app->body_global_date_count - 1U));
|
||
y = normalized_graph_row(value, minimum, maximum,
|
||
graph_top, graph_height);
|
||
if (previous_x >= 0)
|
||
app_shell_draw_chart_segment(app->content, previous_x,
|
||
previous_y, x, y);
|
||
trainlog_surface_draw(app->content, y, x, series->symbol);
|
||
previous_x = x;
|
||
previous_y = y;
|
||
}
|
||
}
|
||
} else trainlog_surface_printf(app->content, graph_top, 2,
|
||
"Aucune série corporelle disponible.");
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
for (metric = 0U; metric < 14U &&
|
||
graph_top + graph_height + 2 + (int)(metric % 7U) <
|
||
app->layout.content.height; ++metric) {
|
||
int row = graph_top + graph_height + 2 + (int)(metric % 7U);
|
||
int column = metric < 7U ? 2 : app->layout.content.width / 2;
|
||
trainlog_surface_printf(app->content, row, column, "%c [%c] %-15s %+.1f%%",
|
||
app->body_global_selected == metric ? '>' : ' ',
|
||
app->body_global_enabled[metric] ? 'x' : ' ',
|
||
BODY_METRICS[metric].label,
|
||
app->body_global_series[metric].latest_percent);
|
||
trainlog_surface_draw(app->content, row, column + 2,
|
||
app->body_global_series[metric].symbol);
|
||
}
|
||
if (app->body_global_partial)
|
||
trainlog_surface_printf(app->content, app->layout.content.height - 1, 2,
|
||
"Limite locale atteinte : certaines séries peuvent être partielles.");
|
||
}
|
||
|
||
static void app_shell_render_body_trends(TrainlogAppContext *app)
|
||
{
|
||
TrainlogDashboardMonthValue monthly[14][DASHBOARD_MONTH_COUNT];
|
||
TrainlogDashboardMonth months[DASHBOARD_MONTH_COUNT];
|
||
double baseline[14] = {0.0};
|
||
double minimum = 0.0;
|
||
double maximum = 0.0;
|
||
long current_key;
|
||
long first_key;
|
||
size_t metric;
|
||
size_t plotted = 0U;
|
||
int top = 5;
|
||
int height = app_shell_graph_height(app, top);
|
||
int left = 8;
|
||
int width = app->layout.content.width - left - 3;
|
||
(void)memset(monthly, 0, sizeof(monthly));
|
||
trainlog_surface_printf(app->content, 3, 2,
|
||
"Évolution corporelle normalisée — 12 mois");
|
||
if (!dashboard_current_month_key(¤t_key)) {
|
||
trainlog_surface_printf(app->content, top, 2,
|
||
"Impossible de déterminer le mois courant.");
|
||
return;
|
||
}
|
||
first_key = current_key - (long)(DASHBOARD_MONTH_COUNT - 1U);
|
||
for (metric = 0U; metric < DASHBOARD_MONTH_COUNT; ++metric)
|
||
dashboard_month_from_key(first_key + (long)metric, &months[metric]);
|
||
for (metric = 0U; metric < 14U; ++metric) {
|
||
TrainlogGlobalBodySeries *series = &app->body_global_series[metric];
|
||
size_t point;
|
||
size_t first = 0U;
|
||
size_t visible = 0U;
|
||
bool have_first = false;
|
||
for (point = 0U; point < series->count; ++point) {
|
||
int year;
|
||
int month;
|
||
long offset;
|
||
if (!dashboard_parse_year_month(series->points[point].observed_at,
|
||
&year, &month)) continue;
|
||
offset = dashboard_month_key(year, month) - first_key;
|
||
if (offset < 0L || offset >= (long)DASHBOARD_MONTH_COUNT) continue;
|
||
monthly[metric][(size_t)offset].present = true;
|
||
monthly[metric][(size_t)offset].value = series->points[point].value;
|
||
}
|
||
for (point = 0U; point < DASHBOARD_MONTH_COUNT; ++point)
|
||
if (monthly[metric][point].present) {
|
||
if (!have_first) { first = point; have_first = true; }
|
||
++visible;
|
||
}
|
||
if (visible < 2U) continue;
|
||
baseline[metric] = monthly[metric][first].value;
|
||
++plotted;
|
||
for (point = 0U; point < DASHBOARD_MONTH_COUNT; ++point) {
|
||
double percent;
|
||
if (monthly[metric][point].present &&
|
||
trainlog_body_percent_change(baseline[metric],
|
||
monthly[metric][point].value,
|
||
&percent) == TRAINLOG_STATUS_OK) {
|
||
if (percent < minimum) minimum = percent;
|
||
if (percent > maximum) maximum = percent;
|
||
}
|
||
}
|
||
}
|
||
if (plotted == 0U || height < 3 || width < 12) {
|
||
trainlog_surface_printf(app->content, top, 2,
|
||
"Premières courbes après 2 mois relevés pour une même mesure.");
|
||
return;
|
||
}
|
||
if (minimum == maximum) { minimum = -1.0; maximum = 1.0; }
|
||
trainlog_surface_printf(app->content, top, 2, "%+.1f%%", maximum);
|
||
trainlog_surface_printf(app->content, top + height - 1, 2, "%+.1f%%", minimum);
|
||
for (metric = 0U; metric < 14U; ++metric) {
|
||
size_t month;
|
||
int previous_x = -1;
|
||
int previous_y = -1;
|
||
long previous_month = -2L;
|
||
if (baseline[metric] <= 0.0) continue;
|
||
trainlog_surface_set_role(app->content, GLOBAL_BODY_ROLES[metric],
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
for (month = 0U; month < DASHBOARD_MONTH_COUNT; ++month) {
|
||
double percent;
|
||
int x;
|
||
int y;
|
||
if (!monthly[metric][month].present) {
|
||
previous_x = -1; previous_y = -1; previous_month = -2L;
|
||
continue;
|
||
}
|
||
if (trainlog_body_percent_change(baseline[metric],
|
||
monthly[metric][month].value,
|
||
&percent) != TRAINLOG_STATUS_OK) continue;
|
||
x = left + (int)((month * (size_t)(width - 1)) /
|
||
(DASHBOARD_MONTH_COUNT - 1U));
|
||
y = normalized_graph_row(percent, minimum, maximum, top, height);
|
||
if (previous_x >= 0 && previous_month + 1L == (long)month)
|
||
app_shell_draw_chart_segment(app->content, previous_x, previous_y,
|
||
x, y);
|
||
trainlog_surface_draw(app->content, y, x, GLOBAL_BODY_SYMBOLS[metric]);
|
||
previous_x = x; previous_y = y; previous_month = (long)month;
|
||
}
|
||
}
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
if (top + height < app->layout.content.height)
|
||
trainlog_surface_printf(app->content, top + height, left,
|
||
"%02d/%02d … %02d/%02d", months[0].month,
|
||
months[0].year % 100, months[11].month, months[11].year % 100);
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
if (app->body_global_partial)
|
||
trainlog_surface_printf(app->content, app->layout.content.height - 1, 2,
|
||
"Limite locale atteinte : vue 12 mois potentiellement partielle.");
|
||
}
|
||
|
||
static void app_shell_render_body_analytics(TrainlogAppContext *app)
|
||
{
|
||
const TrainlogBodyObservationRecord *latest = app->loaded_count > 0U
|
||
? &app->body_records[0] : NULL;
|
||
TrainlogBodyAnalyticsResult current;
|
||
TrainlogBodyAnalyticsResult oldest_estimate;
|
||
const TrainlogBodyObservationRecord *oldest_weight =
|
||
body_analytics_oldest_weight(app->body_records, app->loaded_count);
|
||
const TrainlogBodyObservationRecord *oldest_waist =
|
||
body_analytics_oldest_waist(app->body_records, app->loaded_count);
|
||
bool has_current = latest != NULL && trainlog_body_analytics_calculate(
|
||
app->body_has_profile ? &app->body_profile : NULL, latest,
|
||
¤t) == TRAINLOG_STATUS_OK;
|
||
bool has_oldest_estimate = app->body_has_profile &&
|
||
body_analytics_oldest_estimate(&app->body_profile, app->body_records,
|
||
app->loaded_count, &oldest_estimate);
|
||
int row = 4;
|
||
if (latest == NULL) {
|
||
trainlog_surface_printf(app->content, row, 2, "Aucun relevé corporel.");
|
||
return;
|
||
}
|
||
trainlog_surface_printf(app->content, row++, 2, "Page %zu/2 · relevé %s",
|
||
app->content_selected + 1U, latest->observed_at);
|
||
trainlog_surface_printf(app->content, row++, 2, "Profil : %s",
|
||
app->body_has_profile
|
||
? (app->body_profile.formula == TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE
|
||
? "formule femme" : "formule homme")
|
||
: "non configuré");
|
||
if (app->body_has_profile)
|
||
trainlog_surface_printf(app->content, row++, 2, "Taille : %.1f cm",
|
||
app->body_profile.height_cm);
|
||
#define BODY_ANALYTIC(label_, has_, value_, suffix_) \
|
||
trainlog_surface_printf(app->content, row++, 2, "%-27s %s%.2f %s", \
|
||
label_, (has_) ? "" : "— ", (has_) ? (value_) : 0.0, suffix_)
|
||
row += 1;
|
||
if (app->content_selected == 0U) {
|
||
BODY_ANALYTIC("Graisse estimée", has_current && current.has_body_fat_estimate,
|
||
current.body_fat_percent, "%");
|
||
BODY_ANALYTIC("Masse grasse estimée", has_current && current.has_fat_mass_estimate,
|
||
current.fat_mass_kg, "kg");
|
||
BODY_ANALYTIC("Masse maigre estimée", has_current && current.has_lean_mass_estimate,
|
||
current.lean_mass_kg, "kg");
|
||
if (latest->has_body_weight)
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Poids %.1f kg · variation %+.1f kg", latest->body_weight_kg,
|
||
oldest_weight != NULL ? latest->body_weight_kg -
|
||
oldest_weight->body_weight_kg : 0.0);
|
||
if (latest->has_waist)
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Tour de taille %.1f cm · variation %+.1f cm", latest->waist_cm,
|
||
oldest_waist != NULL ? latest->waist_cm -
|
||
oldest_waist->waist_cm : 0.0);
|
||
if (has_current && current.has_body_fat_estimate && has_oldest_estimate)
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Variation graisse estimée %+.2f point(s)",
|
||
current.body_fat_percent - oldest_estimate.body_fat_percent);
|
||
trainlog_surface_printf(app->content, row + 1, 2,
|
||
"Estimation anthropométrique : tendance, pas mesure directe.");
|
||
} else {
|
||
BODY_ANALYTIC("Taille / hanches", has_current && current.has_waist_hip_ratio,
|
||
current.waist_hip_ratio, "");
|
||
BODY_ANALYTIC("Épaules / taille", has_current && current.has_shoulder_waist_ratio,
|
||
current.shoulder_waist_ratio, "");
|
||
BODY_ANALYTIC("Poitrine / taille", has_current && current.has_chest_waist_ratio,
|
||
current.chest_waist_ratio, "");
|
||
BODY_ANALYTIC("Asymétrie bras", has_current && current.has_arm_asymmetry,
|
||
current.arm_asymmetry_percent, "%");
|
||
BODY_ANALYTIC("Asymétrie avant-bras", has_current && current.has_forearm_asymmetry,
|
||
current.forearm_asymmetry_percent, "%");
|
||
BODY_ANALYTIC("Asymétrie cuisses", has_current && current.has_thigh_asymmetry,
|
||
current.thigh_asymmetry_percent, "%");
|
||
BODY_ANALYTIC("Asymétrie mollets", has_current && current.has_calf_asymmetry,
|
||
current.calf_asymmetry_percent, "%");
|
||
}
|
||
#undef BODY_ANALYTIC
|
||
}
|
||
|
||
static void app_shell_render_sync(TrainlogAppContext *app)
|
||
{
|
||
TrainlogSyncController *sync = &app->sync_controller;
|
||
size_t index;
|
||
int row = 4;
|
||
if (sync->showing_detail) {
|
||
trainlog_surface_printf(app->content, row++, 2, "Détail %s",
|
||
sync->history[sync->selected].sync_id);
|
||
for (index = sync->detail_scroll; index < sync->detail_line_count &&
|
||
row < app->layout.content.height; ++index)
|
||
trainlog_surface_printf(app->content, row++, 2, "%.*s",
|
||
app->layout.content.width - 4, sync->detail_lines[index]);
|
||
return;
|
||
}
|
||
if (!sync->probe_known)
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"État appareil non actualisé · r pour rechercher.");
|
||
else if (sync->probe_status == TRAINLOG_STATUS_OK && sync->device.connected) {
|
||
trainlog_surface_printf(app->content, row++, 2, "Appareil : %s %s",
|
||
sync->device.device.vendor, sync->device.device.model);
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Stockage : %.2f GiB libres / %.2f GiB",
|
||
sync_bytes_to_gib(sync->device.storage.free_space_bytes),
|
||
sync_bytes_to_gib(sync->device.storage.max_capacity_bytes));
|
||
} else trainlog_surface_printf(app->content, row++, 2,
|
||
sync->probe_status == TRAINLOG_STATUS_CONFLICT
|
||
? "Service de synchronisation occupé."
|
||
: "Aucun appareil MTP Trainlog détecté.");
|
||
if (sync->running) {
|
||
trainlog_surface_printf(app->content, row + 1, 2,
|
||
"Synchronisation en cours… PC↔Android");
|
||
trainlog_surface_printf(app->content, row + 3, 2,
|
||
"Une seule exécution peut être active.");
|
||
return;
|
||
}
|
||
if (sync->action.confirming) {
|
||
trainlog_surface_printf(app->content, row + 1, 2, "%s",
|
||
trainlog_sync_screen_confirmation(sync->action.direction));
|
||
trainlog_surface_printf(app->content, row + 3, 2,
|
||
"Entrée confirme une exécution · Échap annule");
|
||
return;
|
||
}
|
||
if (sync->has_report) {
|
||
trainlog_surface_printf(app->content, row++, 2, "%s",
|
||
sync->report.success ? sync->report.summary :
|
||
sync->report.error[0] != '\0' ? sync->report.error
|
||
: "Synchronisation échouée.");
|
||
}
|
||
row += 1;
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Historique des synchronisations");
|
||
if (sync->history_count == 0U)
|
||
trainlog_surface_printf(app->content, row, 2,
|
||
"Aucune synchronisation enregistrée.");
|
||
for (index = 0U; index < sync->history_count &&
|
||
row < app->layout.content.height; ++index) {
|
||
const TrainlogSyncHistoryEntry *entry = &sync->history[index];
|
||
/* CONTRACT: legacy journal directions remain parseable internal
|
||
* metadata, but the TUI exposes one PC↔Android sync operation. Old
|
||
* directional summaries used the same forbidden mode labels. */
|
||
const char *summary = entry->direction_known &&
|
||
entry->direction != TRAINLOG_SYNC_BIDIRECTIONAL
|
||
? "entrée historique" : entry->summary;
|
||
trainlog_surface_printf(app->content, row++, 2, "%c %-16s %c %-17.17s %.*s",
|
||
index == sync->selected ? '>' : ' ', entry->timestamp,
|
||
entry->success ? '+' : '!',
|
||
"PC↔Android",
|
||
app->layout.content.width > 45 ? app->layout.content.width - 45 : 8,
|
||
summary);
|
||
}
|
||
}
|
||
|
||
static void app_shell_render_content(TrainlogAppContext *app)
|
||
{
|
||
TrainlogAppRoute route = app->navigation.current.route;
|
||
int width = app->layout.content.width;
|
||
/* CONTRACT: renderers consume controller-owned immutable snapshots and
|
||
* write coordinates relative to this persistent content plane. SQL,
|
||
* device discovery and sync execution stay in explicit controller actions. */
|
||
trainlog_surface_erase(app->content);
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_ACCENT,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD);
|
||
trainlog_surface_printf(app->content, 1, 2, "%s", trainlog_route_title(route));
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
if (app->body_controller.phase != TRAINLOG_BODY_IDLE) {
|
||
app_shell_render_body_controller(app);
|
||
} else if (app->profile_controller.phase != TRAINLOG_PROFILE_IDLE) {
|
||
app_shell_render_profile_controller(app);
|
||
} else if (app->exercise_controller.phase != TRAINLOG_EXERCISE_IDLE) {
|
||
app_shell_render_exercise_controller(app);
|
||
} else if (app->equipment_controller.phase != TRAINLOG_EQUIPMENT_IDLE) {
|
||
app_shell_render_equipment_controller(app);
|
||
} else if (route == TRAINLOG_ROUTE_SESSION_MANUAL ||
|
||
route == TRAINLOG_ROUTE_SESSION_CURRENT ||
|
||
route == TRAINLOG_ROUTE_SESSION_GENERATOR) {
|
||
app_shell_render_session_controller(app);
|
||
} else if (route == TRAINLOG_ROUTE_HOME) {
|
||
trainlog_surface_printf(app->content, 4, 2, "Votre entraînement, au même endroit.");
|
||
trainlog_surface_printf(app->content, 7, 2, "1 Reprendre ou commencer une séance");
|
||
trainlog_surface_printf(app->content, 9, 2, "5 Ajouter ou consulter des mensurations");
|
||
} else if (route == TRAINLOG_ROUTE_SESSIONS) {
|
||
static const char *const rows[] = {"Séance en cours", "Nouvelle séance manuelle",
|
||
"Séances effectuées"};
|
||
size_t index;
|
||
for (index = 0U; index < 3U; ++index) {
|
||
trainlog_surface_set_role(app->content,
|
||
app->content_selected == index ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE,
|
||
app->content_selected == index ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL);
|
||
trainlog_surface_printf(app->content, 4 + (int)index * 2, 2,
|
||
"%s %s", app->content_selected == index ? "▶ [SÉLECTION]" : " ", rows[index]);
|
||
}
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
} else if (route == TRAINLOG_ROUTE_STATS) {
|
||
app_shell_render_dashboard(app);
|
||
} else if (route == TRAINLOG_ROUTE_SESSION_DETAIL) {
|
||
int row = 3;
|
||
if (app->session_detail_error) trainlog_surface_printf(app->content, row, 2,
|
||
"Impossible de charger la séance.");
|
||
else {
|
||
TrainlogPersistedExerciseDetail *entry = app->session_entry_count > 0U
|
||
? &app->session_entries[app->session_entry_selected] : NULL;
|
||
trainlog_surface_printf(app->content, row++, 2, "Début : %s",
|
||
app->session_detail.started_at);
|
||
trainlog_surface_printf(app->content, row++, 2, "Fin : %s",
|
||
app->session_detail.ended_at[0] != '\0'
|
||
? app->session_detail.ended_at : "séance ouverte");
|
||
trainlog_surface_printf(app->content, row++, 2, "Type : %s",
|
||
session_type_label(app->session_detail.session_type));
|
||
row += 1;
|
||
if (entry == NULL) trainlog_surface_printf(app->content, row, 2,
|
||
"Aucun exercice dans cette séance.");
|
||
else {
|
||
char duration[64] = "";
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Exercice %zu/%zu — %s", app->session_entry_selected + 1U,
|
||
app->session_entry_count, entry->name);
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Occurrence : %s", entry->entry_id);
|
||
{
|
||
size_t feedback_index;
|
||
bool heading = false;
|
||
for (feedback_index = 0U;
|
||
feedback_index < app->exercise_feedback_count &&
|
||
row < app->layout.content.height; ++feedback_index) {
|
||
TrainlogFeedbackView *feedback = &app->exercise_feedback[feedback_index];
|
||
char label[32];
|
||
if (strcmp(feedback->entry_id, entry->entry_id) != 0) continue;
|
||
if (!heading) {
|
||
trainlog_surface_printf(app->content, row++, 2, "RESSENTIS EXERCICES");
|
||
heading = true;
|
||
}
|
||
(void)trainlog_feedback_relative_label(app->session_detail.ended_at,
|
||
feedback->observed_at, true, label, sizeof(label));
|
||
trainlog_surface_printf(app->content, row++, 4, "%s %s", label,
|
||
feedback->raw_text);
|
||
}
|
||
}
|
||
if (entry->equipment_id[0] != '\0')
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Équipement : %s (i pour la fiche)", entry->equipment_id);
|
||
if (entry->has_max_weight != 0)
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"MAX réellement mesuré : %.2f kg", entry->max_weight_kg);
|
||
else if (entry->recording_mode == TRAINLOG_RECORDING_CONTINUOUS) {
|
||
(void)trainlog_duration_format(entry->continuous_duration_seconds,
|
||
duration, sizeof(duration));
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Réalisé : activité continue · %s", duration);
|
||
if (entry->has_continuous_speed != 0)
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Vitesse : %.1f km/h", entry->continuous_speed_kmh);
|
||
if (entry->has_continuous_distance != 0)
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Distance : %.2f km", entry->continuous_distance_km);
|
||
} else {
|
||
size_t index;
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Plan : %d série(s) · repos %d s%s", entry->target_sets,
|
||
entry->rest_seconds, entry->has_target_weight != 0
|
||
? " · charge/assistance cible définie" : "");
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Réalisé : %zu série(s)", entry->actual_set_count);
|
||
for (index = app->session_set_scroll;
|
||
index < entry->actual_set_count && row < app->layout.content.height;
|
||
++index) {
|
||
char metric[64]; char weight[48];
|
||
if (entry->tracking_mode == TRAINLOG_TRACKING_DURATION)
|
||
(void)trainlog_duration_format(entry->actual_sets[index].duration_seconds,
|
||
metric, sizeof(metric));
|
||
else (void)snprintf(metric, sizeof(metric), "%d reps",
|
||
entry->actual_sets[index].reps);
|
||
if (entry->actual_sets[index].has_weight)
|
||
(void)snprintf(weight, sizeof(weight), "%.2f kg",
|
||
entry->actual_sets[index].weight_kg);
|
||
else (void)snprintf(weight, sizeof(weight), "—");
|
||
trainlog_surface_printf(app->content, row++, 4,
|
||
"%zu. %-18s %s", index + 1U, metric, weight);
|
||
}
|
||
}
|
||
}
|
||
if (app->session_followup_count > 0U && row < app->layout.content.height) {
|
||
size_t followup_index;
|
||
trainlog_surface_printf(app->content, row++, 2, "SUIVI APRÈS SÉANCE");
|
||
for (followup_index = 0U; followup_index < app->session_followup_count &&
|
||
row < app->layout.content.height; ++followup_index) {
|
||
char label[32];
|
||
(void)trainlog_feedback_relative_label(app->session_detail.ended_at,
|
||
app->session_followups[followup_index].observed_at,
|
||
false, label, sizeof(label));
|
||
trainlog_surface_printf(app->content, row++, 4, "%s %s", label,
|
||
app->session_followups[followup_index].raw_text);
|
||
}
|
||
}
|
||
}
|
||
} else if (route == TRAINLOG_ROUTE_EQUIPMENT_DETAIL) {
|
||
const TrainlogResolvedEquipment *item = &app->equipment_detail;
|
||
size_t relation_index;
|
||
int relation_row = 12;
|
||
trainlog_surface_printf(app->content, 4, 2, "%s", item->display_name);
|
||
trainlog_surface_printf(app->content, 6, 2, "Identifiant : %s", item->equipment_id);
|
||
trainlog_surface_printf(app->content, 7, 2, "Étiquette : %s",
|
||
item->label_name[0] != '\0' ? item->label_name : "—");
|
||
trainlog_surface_printf(app->content, 8, 2, "Type : %s",
|
||
item->equipment_type[0] != '\0' ? item->equipment_type : "—");
|
||
trainlog_surface_printf(app->content, 9, 2, "Charge : %s",
|
||
item->load_semantics[0] != '\0' ? item->load_semantics : "—");
|
||
trainlog_surface_printf(app->content, 10, 2, "Origine : %s",
|
||
equipment_origin_label(item->origin));
|
||
trainlog_surface_printf(app->content, relation_row++, 2, "Exercices possibles");
|
||
for (relation_index = 0U;
|
||
relation_index < trainlog_equipment_exercise_relation_count() &&
|
||
relation_row < app->layout.content.height; ++relation_index) {
|
||
const TrainlogEquipmentExerciseRelation *relation =
|
||
trainlog_equipment_exercise_relation_at(relation_index);
|
||
const TrainlogExerciseKnowledge *exercise;
|
||
const char *confidence;
|
||
if (relation == NULL || strcmp(relation->equipment_id,
|
||
item->equipment_id) != 0) continue;
|
||
exercise = trainlog_exercise_knowledge_lookup(relation->exercise_id);
|
||
if (exercise == NULL) continue;
|
||
confidence = strcmp(relation->confidence, "high") == 0 ? "élevée" :
|
||
strcmp(relation->confidence, "moderate") == 0 ? "modérée" : "incertaine";
|
||
trainlog_surface_printf(app->content, relation_row++, 4,
|
||
"> %.38s · confiance %s · sources vérifiées",
|
||
exercise->exercise_name, confidence);
|
||
}
|
||
if (relation_row == 13)
|
||
trainlog_surface_printf(app->content, relation_row, 4,
|
||
"Aucune association vérifiée; aucune anatomie déduite du nom.");
|
||
} else if (route == TRAINLOG_ROUTE_EXERCISE_DETAIL) {
|
||
const TrainlogExercise *item = &app->exercise_detail;
|
||
const TrainlogBodyZone *primary = NULL;
|
||
const TrainlogBodyZone *group = NULL;
|
||
char secondary[256] = "";
|
||
size_t index;
|
||
int row = 4;
|
||
for (index = 0U; index < app->exercise_zone_count; ++index) {
|
||
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(
|
||
app->exercise_zones[index].zone_id);
|
||
if (app->exercise_zones[index].role == TRAINLOG_BODY_ZONE_PRIMARY)
|
||
primary = zone;
|
||
else if (zone != NULL) {
|
||
size_t used = strlen(secondary);
|
||
if (used < sizeof(secondary) - 1U)
|
||
(void)snprintf(secondary + used, sizeof(secondary) - used,
|
||
"%s%s", used > 0U ? ", " : "", zone->display_name);
|
||
}
|
||
}
|
||
if (primary != NULL && primary->parent_zone_id != NULL)
|
||
group = trainlog_body_zone_catalog_lookup(primary->parent_zone_id);
|
||
trainlog_surface_printf(app->content, row++, 2, "%s", item->name);
|
||
trainlog_surface_printf(app->content, row++, 2, "Identifiant : %s", item->exercise_id);
|
||
trainlog_surface_printf(app->content, row++, 2, "Suivi : %s · organisation : %s",
|
||
item->tracking_mode == TRAINLOG_TRACKING_REPS ? "répétitions" : "durée",
|
||
item->recording_mode == TRAINLOG_RECORDING_CONTINUOUS ? "continue" : "séries");
|
||
if (app->exercise_detail_metadata_error)
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Métadonnées de zones/usage partiellement indisponibles.");
|
||
else {
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Zone principale : %s", primary != NULL
|
||
? primary->display_name : "Non renseignée");
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Secondaires : %s", secondary[0] != '\0' ? secondary : "Aucune");
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Groupe : %s", group != NULL ? group->display_name : "Aucun");
|
||
}
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Compatibles (manifeste) : %zu · utilisés historiquement : %zu",
|
||
app->exercise_explicit_equipment_count,
|
||
app->exercise_historic_equipment_count);
|
||
for (index = 0U; index < app->exercise_explicit_equipment_count &&
|
||
row < app->layout.content.height - 2; ++index)
|
||
trainlog_surface_printf(app->content, row++, 4, "%c %.48s [compatible]",
|
||
app->exercise_equipment_selected == index ? '>' : ' ',
|
||
app->exercise_explicit_equipment[index].display_name);
|
||
for (index = 0U; index < app->exercise_historic_equipment_count &&
|
||
row < app->layout.content.height - 2; ++index) {
|
||
size_t absolute = app->exercise_explicit_equipment_count + index;
|
||
trainlog_surface_printf(app->content, row++, 4, "%c %.48s [usage]",
|
||
app->exercise_equipment_selected == absolute ? '>' : ' ',
|
||
app->exercise_historic_equipment[index].display_name);
|
||
}
|
||
trainlog_surface_printf(app->content, app->layout.content.height - 2, 2,
|
||
"Entrée fiche équipement · p Performances · m MAX · k Connaissances · e Modifier");
|
||
} else if (route == TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE) {
|
||
const TrainlogExerciseKnowledge *record = trainlog_exercise_knowledge_lookup(
|
||
app->exercise_detail.exercise_id);
|
||
const TrainlogKnowledgeInterpretation *value = record == NULL ? NULL
|
||
: record->interpretation != NULL ? record->interpretation
|
||
: record->conditional_interpretation;
|
||
char lines[KNOWLEDGE_LINES_MAX][KNOWLEDGE_LINE_MAX];
|
||
size_t count = 0U;
|
||
size_t index;
|
||
int wrap = app->layout.content.width - 6;
|
||
knowledge_add_wrapped(lines, &count, app->exercise_detail.name, wrap);
|
||
if (record == NULL || value == NULL)
|
||
knowledge_add_wrapped(lines, &count, record == NULL
|
||
? "Aucune fiche scientifique pour cet identifiant."
|
||
: "Interprétation scientifique non résolue.", wrap);
|
||
else {
|
||
knowledge_add_wrapped(lines, &count,
|
||
record->interpretation != NULL ? "Connaissances validées"
|
||
: "Interprétation conditionnelle — à confirmer", wrap);
|
||
knowledge_add_ids(lines, &count, "Mouvement :", value->pattern_ids, false, wrap);
|
||
knowledge_add_wrapped(lines, &count, "Confiance :", wrap);
|
||
knowledge_add_wrapped(lines, &count, value->confidence, wrap);
|
||
knowledge_add_ids(lines, &count, "Muscles principaux :",
|
||
value->primary_muscle_ids, true, wrap);
|
||
knowledge_add_ids(lines, &count, "Secondaires :",
|
||
value->secondary_muscle_ids, true, wrap);
|
||
knowledge_add_ids(lines, &count, "Stabilisateurs :",
|
||
value->stabilizer_muscle_ids, true, wrap);
|
||
knowledge_add_zones(lines, &count, value, wrap);
|
||
knowledge_add_plain_ids(lines, &count, "Sources :", value->source_refs, wrap);
|
||
}
|
||
if (app->content_scroll >= count)
|
||
app->content_scroll = count > 0U ? count - 1U : 0U;
|
||
for (index = app->content_scroll; index < count &&
|
||
3 + (int)(index - app->content_scroll) < app->layout.content.height; ++index)
|
||
trainlog_surface_printf(app->content,
|
||
3 + (int)(index - app->content_scroll), 2, "%s", lines[index]);
|
||
} else if (route == TRAINLOG_ROUTE_EXERCISE_PERFORMANCE) {
|
||
const TrainlogExercisePerformancePoint *latest = NULL;
|
||
const TrainlogExercisePerformancePoint *best = NULL;
|
||
TrainlogLoadMode mode = TRAINLOG_LOAD_NONE;
|
||
TrainlogResolvedEquipment equipment;
|
||
size_t index;
|
||
size_t context_points = 0U;
|
||
int row = 4;
|
||
char latest_text[128];
|
||
char best_text[128];
|
||
if (app->performance_error) {
|
||
trainlog_surface_printf(app->content, row, 2,
|
||
"Impossible de lire l’historique.");
|
||
} else {
|
||
for (index = 0U; index < app->performance_count; ++index)
|
||
if (app->performance_points[index].has_performance != 0) {
|
||
latest = &app->performance_points[index]; mode = latest->load_mode; break;
|
||
}
|
||
for (index = 0U; latest != NULL && index < app->performance_count; ++index)
|
||
if (app->performance_points[index].has_performance != 0 &&
|
||
app->performance_points[index].load_mode == mode &&
|
||
strcmp(app->performance_points[index].equipment_id,
|
||
latest->equipment_id) == 0 &&
|
||
exercise_point_better(&app->performance_points[index], best))
|
||
best = &app->performance_points[index];
|
||
exercise_format_performance(latest, latest_text, sizeof(latest_text));
|
||
exercise_format_performance(best, best_text, sizeof(best_text));
|
||
trainlog_surface_printf(app->content, row++, 2, "Performance — %s",
|
||
app->exercise_detail.name);
|
||
if (latest != NULL) {
|
||
const char *equipment_label = "sans équipement";
|
||
if (latest->equipment_id[0] != '\0' &&
|
||
trainlog_database_resolve_equipment(app->database,
|
||
latest->equipment_id, &equipment) == TRAINLOG_STATUS_OK)
|
||
equipment_label = equipment.display_name;
|
||
for (index = 0U; index < app->performance_count; ++index)
|
||
if (app->performance_points[index].has_performance != 0 &&
|
||
app->performance_points[index].load_mode == mode &&
|
||
strcmp(app->performance_points[index].equipment_id,
|
||
latest->equipment_id) == 0)
|
||
++context_points;
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Contexte : %s · %s · %zu point%s",
|
||
exercise_load_mode_label(mode), equipment_label, context_points,
|
||
context_points == 1U ? "" : "s");
|
||
}
|
||
trainlog_surface_printf(app->content, row++, 2, "Séances enregistrées : %zu",
|
||
app->performance_count);
|
||
trainlog_surface_printf(app->content, row++, 2, "Dernier meilleur set : %s",
|
||
latest != NULL ? latest_text : "aucun");
|
||
trainlog_surface_printf(app->content, row++, 2, "Meilleur set enregistré : %s",
|
||
best != NULL ? best_text : "aucun");
|
||
if (latest != NULL) {
|
||
int graph_top = row + 1;
|
||
int graph_height = app_shell_graph_height(app, graph_top);
|
||
app_shell_draw_performance_graph(app, latest, mode,
|
||
app->exercise_detail.tracking_mode, graph_top,
|
||
graph_height, false);
|
||
if (graph_height >= 3) row = graph_top + graph_height + 2;
|
||
else row += 2;
|
||
} else row += 2;
|
||
if (row < app->layout.content.height)
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Points réels du contexte :");
|
||
for (index = app->content_scroll; index < app->performance_count &&
|
||
row < app->layout.content.height; ++index) {
|
||
char date[11]; char summary[128];
|
||
if (app->performance_points[index].has_performance == 0 ||
|
||
app->performance_points[index].load_mode != mode || latest == NULL ||
|
||
strcmp(app->performance_points[index].equipment_id,
|
||
latest->equipment_id) != 0) continue;
|
||
exercise_short_date(app->performance_points[index].started_at, date);
|
||
exercise_format_performance(&app->performance_points[index], summary,
|
||
sizeof(summary));
|
||
trainlog_surface_printf(app->content, row++, 2, "%s %s", date, summary);
|
||
}
|
||
}
|
||
} else if (route == TRAINLOG_ROUTE_EXERCISE_MAX) {
|
||
static const double increments[] = {0.5, 1.0, 2.5, 5.0};
|
||
const TrainlogMeasuredMaxSummary *summary = &app->max_summary;
|
||
int row = 4;
|
||
trainlog_surface_printf(app->content, row++, 2, "%s", app->exercise_detail.name);
|
||
if (app->performance_error) trainlog_surface_printf(app->content, row, 2,
|
||
"Impossible de lire les tests de MAX.");
|
||
else if (!summary->found) {
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Aucun MAX mesuré réussi.");
|
||
trainlog_surface_printf(app->content, row, 2,
|
||
"Seules les séances explicitement « Test de max » comptent.");
|
||
} else {
|
||
char current[128]; char record[128];
|
||
exercise_format_performance(&summary->current, current, sizeof(current));
|
||
exercise_format_performance(&summary->record, record, sizeof(record));
|
||
trainlog_surface_printf(app->content, row++, 2, "Tests MAX : %zu · réussis : %zu",
|
||
summary->test_count, summary->successful_test_count);
|
||
trainlog_surface_printf(app->content, row++, 2, "Actuel : %s", current);
|
||
trainlog_surface_printf(app->content, row++, 2, "Record même mode : %s", record);
|
||
if (summary->current.load_mode == TRAINLOG_LOAD_EXTERNAL) {
|
||
static const double percentages[] = {60.0, 70.0, 80.0, 90.0};
|
||
double working[4]; size_t index; bool valid = true;
|
||
for (index = 0U; index < 4U; ++index)
|
||
if (trainlog_measured_max_working_load(&summary->current,
|
||
percentages[index], increments[app->max_rounding_index],
|
||
&working[index]) != TRAINLOG_STATUS_OK) valid = false;
|
||
if (valid) trainlog_surface_printf(app->content, row++, 2,
|
||
"Travail : 60%% %.1f · 70%% %.1f · 80%% %.1f · 90%% %.1f kg",
|
||
working[0], working[1], working[2], working[3]);
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Arrondi %.1f kg · aucun 1RM estimé",
|
||
increments[app->max_rounding_index]);
|
||
} else trainlog_surface_printf(app->content, row++, 2,
|
||
summary->current.load_mode == TRAINLOG_LOAD_ASSISTANCE
|
||
? "Assistance : moins de kg = mieux · pourcentages non applicables"
|
||
: "Sans charge externe · pourcentages non applicables");
|
||
{
|
||
int graph_top = row + 1;
|
||
int graph_height = app_shell_graph_height(app, graph_top);
|
||
app_shell_draw_performance_graph(app, &summary->current,
|
||
summary->current.load_mode,
|
||
app->exercise_detail.tracking_mode, graph_top,
|
||
graph_height, true);
|
||
}
|
||
}
|
||
} else if (route == TRAINLOG_ROUTE_BODY) {
|
||
app_shell_render_body_profile(app);
|
||
} else if (route == TRAINLOG_ROUTE_BODY_DETAIL) {
|
||
app_shell_render_body_detail(app);
|
||
} else if (route == TRAINLOG_ROUTE_BODY_METRIC) {
|
||
int graph_height = app_shell_graph_height(app, 6);
|
||
trainlog_surface_printf(app->content, 4, 2, "%s — ←/→ change la mesure",
|
||
BODY_METRICS[app->body_metric_selected].label);
|
||
if (app->body_snapshot_error)
|
||
trainlog_surface_printf(app->content, 6, 2,
|
||
"Impossible de lire cet historique.");
|
||
else app_shell_draw_body_points(app, app->body_metric_points,
|
||
app->body_metric_count, 6, graph_height,
|
||
BODY_METRICS[app->body_metric_selected].unit);
|
||
if (app->body_metric_partial)
|
||
trainlog_surface_printf(app->content,
|
||
app->layout.content.height - 1, 2,
|
||
"Limite locale atteinte : historique partiel.");
|
||
} else if (route == TRAINLOG_ROUTE_BODY_TRENDS) {
|
||
app_shell_render_body_trends(app);
|
||
} else if (route == TRAINLOG_ROUTE_BODY_GLOBAL) {
|
||
app_shell_render_body_global(app);
|
||
} else if (route == TRAINLOG_ROUTE_BODY_ANALYTICS) {
|
||
app_shell_render_body_analytics(app);
|
||
} else if (route == TRAINLOG_ROUTE_SYNC) {
|
||
app_shell_render_sync(app);
|
||
} else if (app_shell_is_list_route(route)) {
|
||
size_t index;
|
||
int row = 5;
|
||
trainlog_surface_printf(app->content, 3, 2, "Recherche : %s%s",
|
||
app->search.bytes > 0U ? app->search.text : "—",
|
||
app->search.focused ? " [saisie]" : "");
|
||
if (app->list_error) trainlog_surface_printf(app->content, row++, 2,
|
||
"Impossible de charger cette liste.");
|
||
else if (app->loaded_count == 0U) trainlog_surface_printf(app->content, row++, 2,
|
||
"Aucun résultat.");
|
||
for (index = app->list.viewport_start; index < app->loaded_count &&
|
||
index < app->list.viewport_start + app->list.visible_rows; ++index) {
|
||
char secondary[48] = "";
|
||
char session_line[80] = "";
|
||
char body_line[160] = "";
|
||
char clipped[TRAINLOG_NAME_MAX + 8U];
|
||
const char *name;
|
||
int label_cells = app->layout.content.width - 28;
|
||
if (route == TRAINLOG_ROUTE_EQUIPMENT) {
|
||
name = app->equipment[index].display_name;
|
||
(void)snprintf(secondary, sizeof(secondary), "%s",
|
||
equipment_origin_label(app->equipment[index].origin));
|
||
} else if (route == TRAINLOG_ROUTE_SESSIONS_COMPLETED) {
|
||
char date[17];
|
||
session_history_datetime(app->sessions[index].started_at, date);
|
||
(void)snprintf(session_line, sizeof(session_line),
|
||
"%s %s", date,
|
||
session_type_history_label(app->sessions[index].session_type));
|
||
name = session_line;
|
||
(void)snprintf(secondary, sizeof(secondary), "%zu exercice(s)",
|
||
app->sessions[index].exercise_count);
|
||
} else if (route == TRAINLOG_ROUTE_BODY) {
|
||
char date[9];
|
||
body_short_date(app->body_records[index].observed_at, date);
|
||
body_summary_text(&app->body_records[index], body_line,
|
||
sizeof(body_line));
|
||
name = body_line;
|
||
(void)snprintf(secondary, sizeof(secondary), "%s", date);
|
||
} else {
|
||
name = app->exercises[index].name;
|
||
(void)snprintf(secondary, sizeof(secondary), "%s",
|
||
app->exercises[index].tracking_mode == TRAINLOG_TRACKING_REPS
|
||
? "reps" : "durée");
|
||
}
|
||
if (label_cells < 12) label_cells = 12;
|
||
if (label_cells > 48) label_cells = 48;
|
||
trainlog_shell_format_list_label(name, label_cells, clipped,
|
||
sizeof(clipped));
|
||
trainlog_surface_set_role(app->content,
|
||
index == app->list.selected_index ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE,
|
||
index == app->list.selected_index ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL);
|
||
trainlog_surface_printf(app->content, row++, 2, "%s %-*s %s",
|
||
index == app->list.selected_index ? "> [SÉLECTION]" : " ",
|
||
label_cells, clipped, secondary);
|
||
}
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
if (app->loaded_count > 0U) {
|
||
if (app->list.total_known)
|
||
trainlog_surface_printf(app->content,
|
||
app->layout.content.height - 2, 2, "Position %zu/%zu",
|
||
app->list.selected_index + 1U, app->loaded_count);
|
||
else trainlog_surface_printf(app->content,
|
||
app->layout.content.height - 2, 2,
|
||
"Position %zu/%zu+ · limite locale atteinte",
|
||
app->list.selected_index + 1U, app->loaded_count);
|
||
}
|
||
} else if (route == TRAINLOG_ROUTE_SETTINGS) {
|
||
trainlog_surface_printf(app->content, 4, 2, "Profil d’estimation corporelle");
|
||
if (app->body_has_profile)
|
||
trainlog_surface_printf(app->content, 6, 2, "%s · %.1f cm",
|
||
app->body_profile.formula == TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE
|
||
? "Formule femme" : "Formule homme",
|
||
app->body_profile.height_cm);
|
||
else trainlog_surface_printf(app->content, 6, 2,
|
||
"Profil non configuré.");
|
||
trainlog_surface_printf(app->content, 8, 2,
|
||
"Entrée pour consulter ou modifier le profil.");
|
||
} else {
|
||
trainlog_surface_printf(app->content, 4, 2,
|
||
"Entrée ouvre la vue complète existante dans ce contexte.");
|
||
if (width < 80) trainlog_surface_printf(app->content, 6, 2,
|
||
"Mode compact · F6 ouvre Navigation");
|
||
}
|
||
}
|
||
|
||
static void app_shell_render_overlay(TrainlogAppContext *app)
|
||
{
|
||
const TrainlogOverlay *overlay = trainlog_overlays_top(&app->overlays);
|
||
TrainlogRect rect;
|
||
TrainlogSurface *surface;
|
||
size_t index;
|
||
if (overlay == NULL) return;
|
||
rect = trainlog_shell_overlay_rect(&app->layout,
|
||
overlay->type == TRAINLOG_OVERLAY_NAVIGATION ? 36 : 72,
|
||
overlay->type == TRAINLOG_OVERLAY_HELP ? 14 :
|
||
overlay->type == TRAINLOG_OVERLAY_EXERCISE_MERGE ? 16 : 12);
|
||
surface = trainlog_surface_create(app->terminal, "trainlog.overlay",
|
||
rect.y, rect.x, rect.height, rect.width);
|
||
if (surface == NULL) return;
|
||
trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_SURFACE0, TRAINLOG_TEXT_NORMAL);
|
||
if (overlay->type == TRAINLOG_OVERLAY_NAVIGATION) {
|
||
trainlog_surface_printf(surface, 1, 2, "Navigation");
|
||
for (index = 0U; index < sizeof(shell_sections) / sizeof(shell_sections[0]) &&
|
||
3 + (int)index < rect.height; ++index)
|
||
trainlog_surface_printf(surface, 3 + (int)index, 2, "%c %s",
|
||
overlay->selected == index ? '>' : ' ',
|
||
shell_section_labels[index]);
|
||
} else if (overlay->type == TRAINLOG_OVERLAY_ACTIONS) {
|
||
size_t visible = rect.height > 4 ? (size_t)(rect.height - 4) : 1U;
|
||
size_t start = overlay->selected >= visible
|
||
? overlay->selected - visible + 1U : 0U;
|
||
trainlog_surface_printf(surface, 1, 2, "Actions disponibles");
|
||
for (index = start; index < app->actions.count &&
|
||
index < start + visible; ++index)
|
||
trainlog_surface_printf(surface, 3 + (int)(index - start), 2, "%c %s",
|
||
overlay->selected == index ? '>' : ' ', app->actions.items[index].label);
|
||
if (app->actions.count > visible)
|
||
trainlog_surface_printf(surface, rect.height - 1, 2,
|
||
"Action %zu/%zu%s%s", overlay->selected + 1U,
|
||
app->actions.count, start > 0U ? " · ↑" : "",
|
||
start + visible < app->actions.count ? " · ↓" : "");
|
||
} else if (overlay->type == TRAINLOG_OVERLAY_EXERCISE_MERGE) {
|
||
TrainlogExerciseController *controller = &app->exercise_controller;
|
||
if (controller->phase == TRAINLOG_EXERCISE_MERGE_PICKER) {
|
||
size_t matches = exercise_merge_match_count(controller);
|
||
size_t visible = rect.height > 7 ? (size_t)(rect.height - 7) : 1U;
|
||
size_t start = controller->merge_selected >= visible
|
||
? controller->merge_selected - visible + 1U : 0U;
|
||
trainlog_surface_printf(surface, 1, 2, "Fusionner la source : %.48s",
|
||
controller->merge_source.name);
|
||
trainlog_surface_printf(surface, 3, 2, "Recherche cible : %s_",
|
||
controller->form.text);
|
||
for (index = start; index < matches && index < start + visible; ++index) {
|
||
TrainlogExercise *candidate = exercise_merge_match_at(controller, index);
|
||
if (candidate != NULL)
|
||
trainlog_surface_printf(surface, 5 + (int)(index - start), 2,
|
||
"%c %.58s", controller->merge_selected == index ? '>' : ' ',
|
||
candidate->name);
|
||
}
|
||
if (matches == 0U) trainlog_surface_printf(surface, 5, 2,
|
||
"Aucune cible correspondante.");
|
||
trainlog_surface_printf(surface, rect.height - 1, 2,
|
||
"Entrée prévisualiser · Échap annuler");
|
||
} else if (controller->phase == TRAINLOG_EXERCISE_MERGE_CONFIRM) {
|
||
trainlog_surface_printf(surface, 1, 2, "Confirmer la fusion");
|
||
trainlog_surface_printf(surface, 3, 2, "SOURCE : %.54s",
|
||
controller->merge_source.name);
|
||
trainlog_surface_printf(surface, 4, 2, " vers CANONIQUE : %.45s",
|
||
controller->merge_target.name);
|
||
trainlog_surface_printf(surface, 6, 2,
|
||
"Impact source : %zu occurrence(s), %zu série(s), %zu continu, %zu MAX.",
|
||
controller->merge_preview.occurrences,
|
||
controller->merge_preview.performed_sets,
|
||
controller->merge_preview.continuous_activities,
|
||
controller->merge_preview.max_results);
|
||
trainlog_surface_printf(surface, 7, 2,
|
||
"Associations : %zu équipement(s), %zu zone(s).",
|
||
controller->merge_preview.associated_equipment,
|
||
controller->merge_preview.body_zones);
|
||
trainlog_surface_printf(surface, 9, 2, "%c Annuler",
|
||
overlay->selected == 0U ? '>' : ' ');
|
||
trainlog_surface_printf(surface, 10, 2, "%c Fusionner source vers canonique",
|
||
overlay->selected == 1U ? '>' : ' ');
|
||
} else {
|
||
trainlog_surface_printf(surface, 1, 2, "Résultat de la fusion");
|
||
trainlog_surface_printf(surface, 3, 2, "%.64s", controller->message);
|
||
trainlog_surface_printf(surface, 6, 2, "Entrée fermer");
|
||
}
|
||
} else if (overlay->type == TRAINLOG_OVERLAY_CONFIRMATION &&
|
||
app->quit_confirmation) {
|
||
trainlog_surface_printf(surface, 1, 2, "Quitter TRAINLOG ?");
|
||
trainlog_surface_printf(surface, 3, 2,
|
||
"Le brouillon et les propositions gardés seulement pour ce run seront perdus.");
|
||
trainlog_surface_printf(surface, 5, 2, "%c Annuler",
|
||
overlay->selected == 0U ? '>' : ' ');
|
||
trainlog_surface_printf(surface, 6, 2, "%c Quitter et perdre cet état",
|
||
overlay->selected == 1U ? '>' : ' ');
|
||
} else {
|
||
trainlog_surface_printf(surface, 1, 2, "Aide contextuelle");
|
||
trainlog_surface_printf(surface, 3, 2, "0/Home Accueil · 1/F1 Séance · 2/F2 Effectuées");
|
||
trainlog_surface_printf(surface, 5, 2, "3/F3 Exercices · 4/F4 Équipements · 5/F5 Mensurations");
|
||
trainlog_surface_printf(surface, 7, 2, "6 Synchronisation · F6 Navigation · F7 Actions");
|
||
trainlog_surface_printf(surface, 9, 2, "Tab change le focus · Échap revient");
|
||
}
|
||
trainlog_surface_move_top(surface);
|
||
app_shell_render_footer(app);
|
||
trainlog_terminal_render(app->terminal);
|
||
trainlog_surface_destroy(surface);
|
||
}
|
||
|
||
static void app_shell_render(TrainlogAppContext *app)
|
||
{
|
||
if (!app_shell_layout(app)) { app->running = false; return; }
|
||
if (!app->layout.usable) {
|
||
trainlog_terminal_erase(app->terminal);
|
||
trainlog_terminal_printf(app->terminal, 1, 2,
|
||
"Terminal trop petit — %dx%d, minimum 72x20.",
|
||
app->layout.columns, app->layout.rows);
|
||
trainlog_terminal_printf(app->terminal, 3, 2, "q pour quitter");
|
||
trainlog_terminal_render(app->terminal); return;
|
||
}
|
||
trainlog_surface_erase(app->header);
|
||
trainlog_surface_set_role(app->header, TRAINLOG_COLOR_ACCENT,
|
||
TRAINLOG_RGB_MANTLE, TRAINLOG_TEXT_BOLD);
|
||
trainlog_surface_printf(app->header, 0, 2, "TRAINLOG");
|
||
trainlog_surface_set_role(app->header, TRAINLOG_COLOR_MUTED,
|
||
TRAINLOG_RGB_MANTLE, TRAINLOG_TEXT_NORMAL);
|
||
trainlog_surface_printf(app->header, 1, 2, "%s", trainlog_route_title(app->navigation.current.route));
|
||
if (!app->layout.sidebar_visible) {
|
||
int controls_x = app->layout.columns > 35 ? app->layout.columns - 34 : 2;
|
||
trainlog_surface_printf(app->header, 0, controls_x, "%c F6 Navigation",
|
||
app->focus == TRAINLOG_FOCUS_NAVIGATION ? '>' : ' ');
|
||
trainlog_surface_printf(app->header, 1, controls_x, "%c F7 Actions",
|
||
app->focus == TRAINLOG_FOCUS_ACTIONS ? '>' : ' ');
|
||
}
|
||
app_shell_render_sidebar(app); app_shell_render_content(app);
|
||
app_shell_render_footer(app);
|
||
trainlog_surface_move_top(app->footer);
|
||
trainlog_terminal_render(app->terminal);
|
||
if (trainlog_overlays_top(&app->overlays) != NULL) app_shell_render_overlay(app);
|
||
}
|
||
|
||
static void app_shell_open_route(TrainlogAppContext *app, TrainlogAppRoute route)
|
||
{
|
||
TrainlogAppRoute previous = app->navigation.current.route;
|
||
TrainlogSessionController *session = &app->session;
|
||
if (route != previous &&
|
||
app->exercise_controller.phase != TRAINLOG_EXERCISE_IDLE &&
|
||
app->exercise_controller.phase != TRAINLOG_EXERCISE_MESSAGE &&
|
||
app->exercise_controller.phase != TRAINLOG_EXERCISE_CONFIRM_DISCARD) {
|
||
/* CONTRACT: selecting a destination never silently replaces the one
|
||
* transient exercise editor. Raw fields remain owned until discard. */
|
||
app->exercise_controller.return_phase = app->exercise_controller.phase;
|
||
app->exercise_controller.pending_route = route;
|
||
app->exercise_controller.leaving = true;
|
||
app->exercise_controller.phase = TRAINLOG_EXERCISE_CONFIRM_DISCARD;
|
||
return;
|
||
}
|
||
if (route != previous &&
|
||
app->equipment_controller.phase != TRAINLOG_EQUIPMENT_IDLE &&
|
||
app->equipment_controller.phase != TRAINLOG_EQUIPMENT_MESSAGE &&
|
||
app->equipment_controller.phase != TRAINLOG_EQUIPMENT_CONFIRM_DISCARD) {
|
||
app->equipment_controller.return_phase = app->equipment_controller.phase;
|
||
app->equipment_controller.pending_route = route;
|
||
app->equipment_controller.leaving = true;
|
||
app->equipment_controller.phase = TRAINLOG_EQUIPMENT_CONFIRM_DISCARD;
|
||
return;
|
||
}
|
||
if (route != previous && app->body_controller.phase == TRAINLOG_BODY_FORM) {
|
||
app->body_controller.pending_route = route;
|
||
app->body_controller.leaving = true;
|
||
app->body_controller.phase = TRAINLOG_BODY_CONFIRM_DISCARD;
|
||
return;
|
||
}
|
||
if (route != previous &&
|
||
(app->profile_controller.phase == TRAINLOG_PROFILE_FORMULA ||
|
||
app->profile_controller.phase == TRAINLOG_PROFILE_HEIGHT)) {
|
||
app->profile_controller.pending_route = route;
|
||
app->profile_controller.leaving = true;
|
||
app->profile_controller.return_phase = app->profile_controller.phase;
|
||
app->profile_controller.phase = TRAINLOG_PROFILE_CONFIRM_DISCARD;
|
||
return;
|
||
}
|
||
if (session->form.active && route != previous) {
|
||
(void)snprintf(session->message, sizeof(session->message),
|
||
"Terminez ou annulez le champ actif avant de changer de rubrique.");
|
||
return;
|
||
}
|
||
if (previous == TRAINLOG_ROUTE_SESSION_GENERATOR &&
|
||
route != TRAINLOG_ROUTE_SESSION_GENERATOR &&
|
||
(session->generated_preview || session->generation_zone != NULL) &&
|
||
session->phase != TRAINLOG_SESSION_CONFIRM_LEAVE) {
|
||
session->return_phase = TRAINLOG_SESSION_GENERATOR_PREVIEW;
|
||
session->pending_route = route;
|
||
session->phase = TRAINLOG_SESSION_CONFIRM_LEAVE;
|
||
return;
|
||
}
|
||
if (app_shell_is_list_route(previous)) {
|
||
app->saved_lists[previous] = app->list;
|
||
app->saved_searches[previous] = app->search;
|
||
app->saved_searches[previous].focused = false;
|
||
}
|
||
(void)trainlog_navigation_open(&app->navigation, route, NULL);
|
||
app->focus = TRAINLOG_FOCUS_CONTENT;
|
||
app->content_selected = 0U;
|
||
app->content_scroll = 0U;
|
||
if (route == TRAINLOG_ROUTE_SESSION_MANUAL ||
|
||
route == TRAINLOG_ROUTE_SESSION_CURRENT) {
|
||
session->message[0] = '\0';
|
||
session->phase = session->has_draft ? TRAINLOG_SESSION_DRAFT
|
||
: TRAINLOG_SESSION_CHOOSE_TYPE;
|
||
session->session_type = session->has_draft ? session->session_type
|
||
: TRAINLOG_SESSION_TRAINING;
|
||
} else if (route == TRAINLOG_ROUTE_SESSION_GENERATOR) {
|
||
if (session->generated_preview)
|
||
session->phase = TRAINLOG_SESSION_GENERATOR_PREVIEW;
|
||
else {
|
||
size_t zone_count = trainlog_body_zone_catalog_count();
|
||
session->phase = TRAINLOG_SESSION_GENERATOR_CONFIG;
|
||
if (session->generation_zone == NULL && zone_count > 0U)
|
||
session->generation_zone = trainlog_body_zone_catalog_at(0U);
|
||
if (session->generation_goal == NULL &&
|
||
trainlog_session_generation_policy_v1.goal_count > 0U)
|
||
session->generation_goal = &trainlog_session_generation_policy_v1.goals[0];
|
||
if (session->generation_duration_minutes <= 0 &&
|
||
trainlog_session_generation_policy_v1.duration_preset_count > 0U)
|
||
session->generation_duration_minutes =
|
||
trainlog_session_generation_policy_v1.duration_presets_minutes[0];
|
||
}
|
||
session_controller_sync_durability(session);
|
||
}
|
||
if (route == TRAINLOG_ROUTE_SESSION_GENERATOR &&
|
||
!session->generated_preview && session->generation_zone == NULL) {
|
||
session->generation_zone_index = 0U;
|
||
session->generation_goal_index = 0U;
|
||
session->generation_duration_index = 0U;
|
||
session->generation_zone = trainlog_body_zone_catalog_at(0U);
|
||
if (trainlog_session_generation_policy_v1.goal_count > 0U)
|
||
session->generation_goal = &trainlog_session_generation_policy_v1.goals[0];
|
||
if (trainlog_session_generation_policy_v1.duration_preset_count > 0U)
|
||
session->generation_duration_minutes =
|
||
trainlog_session_generation_policy_v1.duration_presets_minutes[0];
|
||
session->phase = TRAINLOG_SESSION_GENERATOR_CONFIG;
|
||
} else if (route == TRAINLOG_ROUTE_SESSION_GENERATOR && session->generated_preview &&
|
||
session->phase != TRAINLOG_SESSION_GENERATOR_WARNING)
|
||
session->phase = TRAINLOG_SESSION_GENERATOR_PREVIEW;
|
||
if (route == TRAINLOG_ROUTE_STATS)
|
||
app_shell_load_dashboard(app);
|
||
else if (route == TRAINLOG_ROUTE_BODY_METRIC)
|
||
app_shell_load_body_metric(app);
|
||
else if (route == TRAINLOG_ROUTE_BODY ||
|
||
route == TRAINLOG_ROUTE_BODY_TRENDS ||
|
||
route == TRAINLOG_ROUTE_BODY_GLOBAL)
|
||
app_shell_load_body_global(app);
|
||
else if (route == TRAINLOG_ROUTE_BODY_ANALYTICS)
|
||
app_shell_load_body_analytics(app);
|
||
else if (route == TRAINLOG_ROUTE_SYNC) {
|
||
sync_controller_load_history(&app->sync_controller);
|
||
app->sync_controller.showing_detail = false;
|
||
} else if (route == TRAINLOG_ROUTE_SETTINGS)
|
||
app->body_has_profile = body_analytics_profile_load(&app->body_profile);
|
||
if (app_shell_is_list_route(route)) {
|
||
app->list = app->saved_lists[route];
|
||
app->search = app->saved_searches[route];
|
||
app_shell_refresh_list(app);
|
||
}
|
||
if (route == TRAINLOG_ROUTE_BODY)
|
||
app_shell_select_available_body_metric(app);
|
||
}
|
||
|
||
static bool app_shell_back(TrainlogAppContext *app)
|
||
{
|
||
TrainlogAppRoute previous = app->navigation.current.route;
|
||
if (app_shell_is_list_route(previous)) {
|
||
app->saved_lists[previous] = app->list;
|
||
app->saved_searches[previous] = app->search;
|
||
app->saved_searches[previous].focused = false;
|
||
}
|
||
if (!trainlog_navigation_back(&app->navigation)) return false;
|
||
if (app_shell_is_list_route(app->navigation.current.route)) {
|
||
app->list = app->saved_lists[app->navigation.current.route];
|
||
app->search = app->saved_searches[app->navigation.current.route];
|
||
app_shell_refresh_list(app);
|
||
}
|
||
if (app->navigation.current.route == TRAINLOG_ROUTE_STATS)
|
||
app_shell_load_dashboard(app);
|
||
else if (app->navigation.current.route == TRAINLOG_ROUTE_BODY) {
|
||
app_shell_load_body_global(app);
|
||
app_shell_select_available_body_metric(app);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
static bool app_shell_dispatch_session(TrainlogAppContext *app, int key);
|
||
static bool app_shell_dispatch_sync(TrainlogAppContext *app, int key);
|
||
|
||
static void app_shell_load_exercise_detail_metadata(TrainlogAppContext *app)
|
||
{
|
||
size_t index;
|
||
app->exercise_zone_count = 0U;
|
||
app->exercise_explicit_equipment_count = 0U;
|
||
app->exercise_historic_equipment_count = 0U;
|
||
app->exercise_equipment_selected = 0U;
|
||
app->exercise_detail_metadata_error =
|
||
trainlog_database_list_exercise_body_zones(app->database,
|
||
app->exercise_detail.exercise_id, app->exercise_zones,
|
||
MAX_BODY_ZONES, &app->exercise_zone_count) != TRAINLOG_STATUS_OK;
|
||
for (index = 0U; index < trainlog_equipment_catalog_relation_count() &&
|
||
app->exercise_explicit_equipment_count < 64U; ++index) {
|
||
const TrainlogExerciseEquipmentRelation *relation =
|
||
trainlog_equipment_catalog_relation_at(index);
|
||
if (relation != NULL && strcmp(relation->exercise_id,
|
||
app->exercise_detail.exercise_id) == 0 &&
|
||
trainlog_database_resolve_equipment(app->database,
|
||
relation->equipment_id,
|
||
&app->exercise_explicit_equipment[
|
||
app->exercise_explicit_equipment_count]) == TRAINLOG_STATUS_OK)
|
||
++app->exercise_explicit_equipment_count;
|
||
}
|
||
if (trainlog_database_list_exercise_equipment(app->database,
|
||
app->exercise_detail.exercise_id,
|
||
app->exercise_historic_equipment, 128U,
|
||
&app->exercise_historic_equipment_count) != TRAINLOG_STATUS_OK) {
|
||
app->exercise_historic_equipment_count = 0U;
|
||
app->exercise_detail_metadata_error = true;
|
||
}
|
||
}
|
||
|
||
static void app_shell_primary(TrainlogAppContext *app)
|
||
{
|
||
TrainlogAppRoute route = app->navigation.current.route;
|
||
if (route == TRAINLOG_ROUTE_SESSIONS) {
|
||
static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_SESSION_CURRENT,
|
||
TRAINLOG_ROUTE_SESSION_MANUAL, TRAINLOG_ROUTE_SESSIONS_COMPLETED};
|
||
app_shell_open_route(app, targets[app->content_selected % 3U]);
|
||
} else if (route == TRAINLOG_ROUTE_STATS) {
|
||
static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_STATS_EXERCISE,
|
||
TRAINLOG_ROUTE_BODY, TRAINLOG_ROUTE_SESSIONS_COMPLETED,
|
||
TRAINLOG_ROUTE_EXERCISES};
|
||
app_shell_open_route(app, targets[app->content_selected % 4U]);
|
||
} else if (route == TRAINLOG_ROUTE_SESSION_MANUAL ||
|
||
route == TRAINLOG_ROUTE_SESSION_CURRENT ||
|
||
route == TRAINLOG_ROUTE_SESSION_GENERATOR) {
|
||
(void)app_shell_dispatch_session(app, TRAINLOG_KEY_ENTER);
|
||
} else if (app_shell_is_list_route(route)) {
|
||
if (app->loaded_count == 0U) return;
|
||
/* INVARIANT: detail routes borrow list selection; they do not replace
|
||
* its stable-ID/query/viewport state. Back restores this exact snapshot
|
||
* after any intervening resize or catalogue refresh. */
|
||
app->saved_lists[route] = app->list;
|
||
app->saved_searches[route] = app->search;
|
||
app->saved_searches[route].focused = false;
|
||
if (route == TRAINLOG_ROUTE_EQUIPMENT) {
|
||
app->equipment_detail = app->equipment[app->list.selected_index];
|
||
(void)trainlog_navigation_open(&app->navigation,
|
||
TRAINLOG_ROUTE_EQUIPMENT_DETAIL,
|
||
app->equipment_detail.equipment_id);
|
||
}
|
||
else if (route == TRAINLOG_ROUTE_SESSIONS_COMPLETED)
|
||
app_shell_open_session_detail(app,
|
||
app->sessions[app->list.selected_index].session_id);
|
||
else if (route == TRAINLOG_ROUTE_BODY) {
|
||
app->body_detail = app->body_records[app->list.selected_index];
|
||
(void)trainlog_navigation_open(&app->navigation,
|
||
TRAINLOG_ROUTE_BODY_DETAIL,
|
||
app->body_detail.observation_id);
|
||
}
|
||
else if (route == TRAINLOG_ROUTE_STATS_EXERCISE) {
|
||
app->exercise_detail = app->exercises[app->list.selected_index];
|
||
app_shell_open_exercise_analytics(app,
|
||
TRAINLOG_ROUTE_EXERCISE_PERFORMANCE);
|
||
} else if (route == TRAINLOG_ROUTE_MAX) {
|
||
app->exercise_detail = app->exercises[app->list.selected_index];
|
||
app_shell_open_exercise_analytics(app, TRAINLOG_ROUTE_EXERCISE_MAX);
|
||
}
|
||
else {
|
||
app->exercise_detail = app->exercises[app->list.selected_index];
|
||
app_shell_load_exercise_detail_metadata(app);
|
||
(void)trainlog_navigation_open(&app->navigation,
|
||
TRAINLOG_ROUTE_EXERCISE_DETAIL,
|
||
app->exercise_detail.exercise_id);
|
||
}
|
||
if (route != TRAINLOG_ROUTE_EQUIPMENT && route != TRAINLOG_ROUTE_EXERCISES &&
|
||
route != TRAINLOG_ROUTE_STATS_EXERCISE && route != TRAINLOG_ROUTE_MAX &&
|
||
route != TRAINLOG_ROUTE_SESSIONS_COMPLETED && route != TRAINLOG_ROUTE_BODY) {
|
||
trainlog_terminal_erase(app->terminal);
|
||
app_shell_refresh_list(app);
|
||
}
|
||
} else if (route == TRAINLOG_ROUTE_SYNC) {
|
||
sync_controller_open_detail(&app->sync_controller);
|
||
} else if (route == TRAINLOG_ROUTE_SETTINGS) {
|
||
profile_controller_start(app, TRAINLOG_ROUTE_SETTINGS);
|
||
}
|
||
}
|
||
|
||
static void app_shell_dispatch_overlay(TrainlogAppContext *app, int key)
|
||
{
|
||
TrainlogOverlay *overlay = &app->overlays.items[app->overlays.count - 1U];
|
||
if (overlay->type == TRAINLOG_OVERLAY_EXERCISE_MERGE) {
|
||
TrainlogExerciseController *controller = &app->exercise_controller;
|
||
if (controller->phase == TRAINLOG_EXERCISE_MERGE_PICKER) {
|
||
size_t matches = exercise_merge_match_count(controller);
|
||
if (key == TRAINLOG_KEY_ESCAPE) {
|
||
if (controller->form.text[0] != '\0') {
|
||
trainlog_form_init(&controller->form, "");
|
||
controller->merge_selected = 0U;
|
||
} else {
|
||
char id[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
|
||
(void)trainlog_overlays_pop(&app->overlays, &app->focus, id);
|
||
(void)memset(controller, 0, sizeof(*controller));
|
||
}
|
||
} else if (key == TRAINLOG_KEY_UP && controller->merge_selected > 0U)
|
||
--controller->merge_selected;
|
||
else if (key == TRAINLOG_KEY_DOWN && controller->merge_selected + 1U < matches)
|
||
++controller->merge_selected;
|
||
else if ((key == TRAINLOG_KEY_ENTER || key == '\n') && matches > 0U) {
|
||
TrainlogExercise *target = exercise_merge_match_at(controller,
|
||
controller->merge_selected);
|
||
if (target != NULL) {
|
||
controller->merge_target = *target;
|
||
if (trainlog_database_preview_exercise_merge(app->database,
|
||
controller->merge_source.exercise_id,
|
||
&controller->merge_preview) == TRAINLOG_STATUS_OK)
|
||
controller->phase = TRAINLOG_EXERCISE_MERGE_CONFIRM;
|
||
else {
|
||
controller->phase = TRAINLOG_EXERCISE_MERGE_RESULT;
|
||
(void)snprintf(controller->message,
|
||
sizeof(controller->message),
|
||
"Prévisualisation impossible; aucune fusion effectuée.");
|
||
}
|
||
overlay->selected = 0U;
|
||
}
|
||
} else if (!(key == '/' && controller->form.text[0] == '\0')) {
|
||
/* '/' activates the standard search affordance; this overlay
|
||
* already owns the shared UTF-8 form editor. */
|
||
TrainlogFormResult result = trainlog_form_handle(&controller->form, key);
|
||
if (result == TRAINLOG_FORM_EDITED) controller->merge_selected = 0U;
|
||
}
|
||
} else if (controller->phase == TRAINLOG_EXERCISE_MERGE_CONFIRM) {
|
||
if (key == TRAINLOG_KEY_ESCAPE) {
|
||
controller->phase = TRAINLOG_EXERCISE_MERGE_PICKER;
|
||
overlay->selected = controller->merge_selected;
|
||
} else if (key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN)
|
||
overlay->selected = overlay->selected == 0U ? 1U : 0U;
|
||
else if (key == TRAINLOG_KEY_ENTER || key == '\n') {
|
||
if (overlay->selected == 0U) {
|
||
controller->phase = TRAINLOG_EXERCISE_MERGE_PICKER;
|
||
overlay->selected = controller->merge_selected;
|
||
} else {
|
||
TrainlogStatus status = trainlog_database_merge_exercises(app->database,
|
||
controller->merge_source.exercise_id,
|
||
controller->merge_target.exercise_id);
|
||
if (status == TRAINLOG_STATUS_OK) {
|
||
app->exercise_detail = controller->merge_target;
|
||
if (!copy_ui_stable_id(app->navigation.current.stable_id,
|
||
sizeof(app->navigation.current.stable_id),
|
||
controller->merge_target.exercise_id)) {
|
||
controller->phase = TRAINLOG_EXERCISE_MERGE_RESULT;
|
||
(void)snprintf(controller->message,
|
||
sizeof(controller->message),
|
||
"Fusion terminée; identifiant UI hors borne.");
|
||
return;
|
||
}
|
||
app_shell_load_exercise_detail_metadata(app);
|
||
(void)snprintf(controller->message, sizeof(controller->message),
|
||
"Fusion terminée. Cible sélectionnée : %.112s",
|
||
controller->merge_target.name);
|
||
} else (void)snprintf(controller->message,
|
||
sizeof(controller->message), "%s",
|
||
status == TRAINLOG_STATUS_CONFLICT
|
||
? "Fusion refusée : profil ou zone principale incompatible. Aucun changement."
|
||
: "Fusion impossible. Aucun changement confirmé.");
|
||
controller->phase = TRAINLOG_EXERCISE_MERGE_RESULT;
|
||
}
|
||
}
|
||
} else if (key == TRAINLOG_KEY_ENTER || key == '\n' ||
|
||
key == TRAINLOG_KEY_ESCAPE) {
|
||
char id[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
|
||
(void)trainlog_overlays_pop(&app->overlays, &app->focus, id);
|
||
(void)memset(controller, 0, sizeof(*controller));
|
||
}
|
||
return;
|
||
}
|
||
size_t count = overlay->type == TRAINLOG_OVERLAY_NAVIGATION
|
||
? sizeof(shell_sections) / sizeof(shell_sections[0])
|
||
: overlay->type == TRAINLOG_OVERLAY_CONFIRMATION ? 2U : app->actions.count;
|
||
if (key == TRAINLOG_KEY_ESCAPE ||
|
||
(key == TRAINLOG_KEY_F6 &&
|
||
overlay->type == TRAINLOG_OVERLAY_NAVIGATION)) {
|
||
char id[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
|
||
(void)trainlog_overlays_pop(&app->overlays, &app->focus, id);
|
||
app->search.focused = app->focus == TRAINLOG_FOCUS_SEARCH;
|
||
} else if (key == TRAINLOG_KEY_UP && overlay->selected > 0U) --overlay->selected;
|
||
else if (key == TRAINLOG_KEY_DOWN && overlay->selected + 1U < count) ++overlay->selected;
|
||
else if ((key == TRAINLOG_KEY_ENTER || key == '\n') && count > 0U) {
|
||
if (overlay->type == TRAINLOG_OVERLAY_CONFIRMATION && app->quit_confirmation) {
|
||
bool quit = overlay->selected == 1U;
|
||
char id[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
|
||
(void)trainlog_overlays_pop(&app->overlays, &app->focus, id);
|
||
app->search.focused = app->focus == TRAINLOG_FOCUS_SEARCH;
|
||
app->quit_confirmation = false;
|
||
if (quit) app->running = false;
|
||
} else if (overlay->type == TRAINLOG_OVERLAY_NAVIGATION) {
|
||
TrainlogAppRoute target = shell_sections[overlay->selected];
|
||
char id[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
|
||
(void)trainlog_overlays_pop(&app->overlays, &app->focus, id);
|
||
app->search.focused = app->focus == TRAINLOG_FOCUS_SEARCH;
|
||
app_shell_open_route(app, target);
|
||
} else if (overlay->type == TRAINLOG_OVERLAY_ACTIONS) {
|
||
TrainlogAction action = app->actions.items[overlay->selected];
|
||
char id[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
|
||
(void)trainlog_overlays_pop(&app->overlays, &app->focus, id);
|
||
if (app->exercise_controller.phase != TRAINLOG_EXERCISE_IDLE) {
|
||
(void)app_shell_dispatch_exercise_controller(app, action.key);
|
||
} else if (app->equipment_controller.phase != TRAINLOG_EQUIPMENT_IDLE) {
|
||
(void)app_shell_dispatch_equipment_controller(app, action.key);
|
||
} else if (app->session.form.active) {
|
||
(void)snprintf(app->session.message, sizeof(app->session.message),
|
||
"Terminez ou annulez le champ actif avant une autre action.");
|
||
} else if (strcmp(action.identifier, "generator.discard") == 0) {
|
||
session_controller_discard_generator(&app->session);
|
||
app_shell_open_route(app, TRAINLOG_ROUTE_SESSIONS);
|
||
} else if (strcmp(action.identifier, "exercise.merge") == 0) {
|
||
exercise_controller_start_merge(app);
|
||
} else if (app->navigation.current.route == TRAINLOG_ROUTE_SYNC) {
|
||
/* CONTRACT: F7 executes the exact registered key through the
|
||
* production sync controller; it is not a palette-only stub. */
|
||
(void)app_shell_dispatch_sync(app, action.key);
|
||
} else if (app->navigation.current.route == TRAINLOG_ROUTE_SESSION_MANUAL ||
|
||
app->navigation.current.route == TRAINLOG_ROUTE_SESSION_CURRENT ||
|
||
app->navigation.current.route == TRAINLOG_ROUTE_SESSION_GENERATOR)
|
||
(void)app_shell_dispatch_session(app, action.key);
|
||
else if (action.intent == TRAINLOG_INTENT_PRIMARY) app_shell_primary(app);
|
||
else if (action.intent == TRAINLOG_INTENT_OPEN_SEARCH &&
|
||
app_shell_is_list_route(app->navigation.current.route)) {
|
||
/* Keep F7 action execution aligned with the registered '/'
|
||
* action: it opens the filter and transfers editor focus. */
|
||
app->search.open = true;
|
||
app->search.focused = true;
|
||
app->focus = TRAINLOG_FOCUS_SEARCH;
|
||
} else if (action.intent == TRAINLOG_INTENT_OPEN_ROUTE)
|
||
app_shell_open_route(app, action.route);
|
||
else if (action.intent == TRAINLOG_INTENT_BACK)
|
||
(void)app_shell_back(app);
|
||
}
|
||
}
|
||
}
|
||
|
||
static bool session_form_int(const TrainlogFormField *form, int *output)
|
||
{
|
||
char *end = NULL;
|
||
long value;
|
||
if (form == NULL || output == NULL || form->bytes == 0U) return false;
|
||
value = strtol(form->text, &end, 10);
|
||
if (end == form->text || *end != '\0' || value <= 0L || value > INT_MAX)
|
||
return false;
|
||
*output = (int)value;
|
||
return true;
|
||
}
|
||
|
||
static bool session_form_nonnegative_int(const TrainlogFormField *form, int *output)
|
||
{
|
||
char *end = NULL;
|
||
long value;
|
||
if (form == NULL || output == NULL || form->bytes == 0U) return false;
|
||
value = strtol(form->text, &end, 10);
|
||
if (end == form->text || *end != '\0' || value < 0L || value > INT_MAX)
|
||
return false;
|
||
*output = (int)value;
|
||
return true;
|
||
}
|
||
|
||
static bool session_form_double(const TrainlogFormField *form, double *output)
|
||
{
|
||
char *end = NULL;
|
||
double value;
|
||
if (form == NULL || output == NULL || form->bytes == 0U) return false;
|
||
value = strtod(form->text, &end);
|
||
if (end == form->text || *end != '\0' || !isfinite(value) || value < 0.0)
|
||
return false;
|
||
*output = value;
|
||
return true;
|
||
}
|
||
|
||
static void session_controller_start_form(TrainlogSessionController *session,
|
||
TrainlogSessionFormPurpose purpose,
|
||
const char *initial)
|
||
{
|
||
session->form_purpose = purpose;
|
||
trainlog_form_init(&session->form, initial);
|
||
}
|
||
|
||
static bool app_shell_dispatch_session_form(TrainlogAppContext *app, int key)
|
||
{
|
||
TrainlogSessionController *session = &app->session;
|
||
TrainlogSessionDraftExercise *draft = &session->drafts[session->selected];
|
||
TrainlogFormResult result = trainlog_form_handle(&session->form, key);
|
||
int integer_value;
|
||
double double_value;
|
||
if (result == TRAINLOG_FORM_OPEN_NAVIGATION) {
|
||
(void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_NAVIGATION,
|
||
TRAINLOG_FOCUS_EDITOR, app->navigation.current.stable_id); return true;
|
||
}
|
||
if (result == TRAINLOG_FORM_OPEN_ACTIONS) {
|
||
(void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_ACTIONS,
|
||
TRAINLOG_FOCUS_EDITOR, app->navigation.current.stable_id); return true;
|
||
}
|
||
if (result == TRAINLOG_FORM_CANCEL) {
|
||
session->form.active = false;
|
||
session->form_purpose = TRAINLOG_SESSION_FORM_NONE;
|
||
if (session->phase == TRAINLOG_SESSION_EQUIPMENT_CREATE)
|
||
session->phase = TRAINLOG_SESSION_EQUIPMENT_PICKER;
|
||
return true;
|
||
}
|
||
if (result != TRAINLOG_FORM_SUBMIT && result != TRAINLOG_FORM_NEXT)
|
||
return true;
|
||
if (session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_NAME) {
|
||
if (session->form.bytes == 0U || session->form.bytes >=
|
||
sizeof(session->pending_equipment.display_name)) {
|
||
(void)snprintf(session->message, sizeof(session->message),
|
||
"Le nom convivial est requis et doit tenir dans le champ.");
|
||
return true;
|
||
}
|
||
(void)snprintf(session->pending_equipment.display_name,
|
||
sizeof(session->pending_equipment.display_name), "%s", session->form.text);
|
||
session_controller_start_form(session,
|
||
TRAINLOG_SESSION_FORM_EQUIPMENT_LABEL, "");
|
||
return true;
|
||
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_LABEL) {
|
||
if (session->form.bytes >= sizeof(session->pending_equipment.label_name)) {
|
||
(void)snprintf(session->message, sizeof(session->message),
|
||
"Le nom d’étiquette est trop long.");
|
||
return true;
|
||
}
|
||
(void)snprintf(session->pending_equipment.label_name,
|
||
sizeof(session->pending_equipment.label_name), "%s", session->form.text);
|
||
session_controller_start_form(session,
|
||
TRAINLOG_SESSION_FORM_EQUIPMENT_TYPE, "");
|
||
return true;
|
||
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_TYPE) {
|
||
if (session->form.bytes == 0U || session->form.bytes >=
|
||
sizeof(session->pending_equipment.equipment_type)) {
|
||
(void)snprintf(session->message, sizeof(session->message),
|
||
"Le type d’équipement est requis.");
|
||
return true;
|
||
}
|
||
(void)snprintf(session->pending_equipment.equipment_type,
|
||
sizeof(session->pending_equipment.equipment_type), "%s", session->form.text);
|
||
session_controller_start_form(session,
|
||
TRAINLOG_SESSION_FORM_EQUIPMENT_LOAD, "2");
|
||
return true;
|
||
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_LOAD &&
|
||
session_form_int(&session->form, &integer_value) &&
|
||
integer_value <= 3) {
|
||
char generated[TRAINLOG_UUID_TEXT_LENGTH + 1U];
|
||
size_t index;
|
||
(void)snprintf(session->pending_equipment.load_semantics,
|
||
sizeof(session->pending_equipment.load_semantics), "%s",
|
||
integer_value == 1 ? "none" : integer_value == 2 ? "external" : "assistance");
|
||
generate_custom_equipment_uuid(generated);
|
||
(void)snprintf(session->pending_equipment.equipment_id,
|
||
sizeof(session->pending_equipment.equipment_id), "%s", generated);
|
||
if (trainlog_database_create_custom_equipment(app->database,
|
||
&session->pending_equipment) != TRAINLOG_STATUS_OK) {
|
||
(void)snprintf(session->message, sizeof(session->message),
|
||
"Équipement non créé : nom ou identifiant invalide/conflit.");
|
||
return true;
|
||
}
|
||
session->form.active = false;
|
||
session->form_purpose = TRAINLOG_SESSION_FORM_NONE;
|
||
session_controller_load_equipment(app);
|
||
for (index = 0U; index < session->equipment_count; ++index)
|
||
if (strcmp(app->equipment[index].equipment_id, generated) == 0) {
|
||
session->equipment_selected = index + 1U;
|
||
break;
|
||
}
|
||
session->message[0] = '\0';
|
||
return true;
|
||
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_GENERATED_SETS &&
|
||
session_form_int(&session->form, &integer_value) && integer_value <= 64 &&
|
||
session->selected < session->generated.exercise_count) {
|
||
char value[48];
|
||
session->pending_generated_sets = integer_value;
|
||
(void)snprintf(value, sizeof(value), "%d",
|
||
session->generated.exercises[session->selected].target_repetitions);
|
||
session_controller_start_form(session,
|
||
TRAINLOG_SESSION_FORM_GENERATED_REPS, value);
|
||
return true;
|
||
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_GENERATED_REPS &&
|
||
session_form_int(&session->form, &integer_value) && integer_value <= 10000 &&
|
||
session->selected < session->generated.exercise_count) {
|
||
if (!session_controller_requalify_generated(app, session->selected,
|
||
session->pending_generated_sets, integer_value)) {
|
||
(void)snprintf(session->message, sizeof(session->message),
|
||
"Dose refusée; la proposition précédente est conservée.");
|
||
return true;
|
||
}
|
||
session->form.active = false;
|
||
session->form_purpose = TRAINLOG_SESSION_FORM_NONE;
|
||
session_controller_sync_durability(session);
|
||
return true;
|
||
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_GENERATED_PERCENT_MAX &&
|
||
session_form_int(&session->form, &integer_value) && integer_value <= 100 &&
|
||
session->selected < session->generated.exercise_count) {
|
||
TrainlogGeneratedExercise *generated =
|
||
&session->generated.exercises[session->selected];
|
||
TrainlogLatestExplicitMax latest = {0};
|
||
double target = 0.0;
|
||
TrainlogStatus status = trainlog_database_latest_explicit_max_equipment_context(
|
||
app->database, generated->exercise_id, generated->equipment_id, &latest);
|
||
generated->has_target_weight = status == TRAINLOG_STATUS_OK &&
|
||
trainlog_measured_max_target_load(&latest, generated->equipment_id,
|
||
strcmp(generated->equipment_load_semantics, "external") == 0
|
||
? TRAINLOG_LOAD_EXTERNAL : TRAINLOG_LOAD_NONE,
|
||
integer_value, &target) == TRAINLOG_STATUS_OK;
|
||
generated->target_weight_kg = generated->has_target_weight ? target : 0.0;
|
||
generated->planned_load_mode = generated->has_target_weight
|
||
? TRAINLOG_LOAD_EXTERNAL : TRAINLOG_LOAD_NONE;
|
||
generated->rationale_count = 1U;
|
||
(void)snprintf(generated->rationale_codes[0],
|
||
sizeof(generated->rationale_codes[0]), "%s",
|
||
generated->has_target_weight ? "user_selected_max_percentage"
|
||
: "compatible_max_unavailable");
|
||
generated->load_source_session_id[0] = '\0';
|
||
generated->load_source_occurrence_id[0] = '\0';
|
||
generated->load_source_started_at[0] = '\0';
|
||
session->form.active = false;
|
||
session->form_purpose = TRAINLOG_SESSION_FORM_NONE;
|
||
session->dirty = true;
|
||
session_controller_sync_durability(session);
|
||
return true;
|
||
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_SET_METRIC &&
|
||
session_form_int(&session->form, &integer_value)) {
|
||
if (draft->tracking_mode == TRAINLOG_TRACKING_DURATION)
|
||
session->pending_set.duration_seconds = integer_value;
|
||
else session->pending_set.reps = integer_value;
|
||
if (draft->input.load_mode != TRAINLOG_LOAD_NONE) {
|
||
session_controller_start_form(session,
|
||
TRAINLOG_SESSION_FORM_SET_WEIGHT, "");
|
||
return true;
|
||
}
|
||
if (session->set_selected < draft->input.set_count)
|
||
draft->sets[session->set_selected] = session->pending_set;
|
||
else if (draft->input.set_count < MAX_SETS_PER_EXERCISE)
|
||
draft->sets[draft->input.set_count++] = session->pending_set;
|
||
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_SET_WEIGHT &&
|
||
(session->form.bytes == 0U ||
|
||
session_form_double(&session->form, &double_value))) {
|
||
session->pending_set.has_weight = session->form.bytes > 0U;
|
||
session->pending_set.weight_kg = session->form.bytes > 0U ? double_value : 0.0;
|
||
if (session->set_selected < draft->input.set_count)
|
||
draft->sets[session->set_selected] = session->pending_set;
|
||
else if (draft->input.set_count < MAX_SETS_PER_EXERCISE)
|
||
draft->sets[draft->input.set_count++] = session->pending_set;
|
||
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_CONTINUOUS_DURATION &&
|
||
session_form_int(&session->form, &integer_value)) {
|
||
draft->input.continuous_duration_seconds = integer_value;
|
||
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_CONTINUOUS_SPEED &&
|
||
(session->form.bytes == 0U || session_form_double(&session->form,
|
||
&double_value))) {
|
||
draft->input.continuous_has_speed = session->form.bytes > 0U;
|
||
draft->input.continuous_speed_kmh = session->form.bytes > 0U ? double_value : 0.0;
|
||
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_CONTINUOUS_DISTANCE &&
|
||
(session->form.bytes == 0U || session_form_double(&session->form,
|
||
&double_value))) {
|
||
draft->input.continuous_has_distance = session->form.bytes > 0U;
|
||
draft->input.continuous_distance_km = session->form.bytes > 0U ? double_value : 0.0;
|
||
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_TARGET_SETS &&
|
||
session_form_nonnegative_int(&session->form, &integer_value)) {
|
||
draft->input.target_sets = integer_value;
|
||
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_TARGET_REPS &&
|
||
session_form_nonnegative_int(&session->form, &integer_value)) {
|
||
draft->input.target_reps = integer_value;
|
||
if (integer_value > 0) draft->input.target_duration_seconds = 0;
|
||
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_TARGET_DURATION &&
|
||
session_form_nonnegative_int(&session->form, &integer_value)) {
|
||
draft->input.target_duration_seconds = integer_value;
|
||
if (integer_value > 0) draft->input.target_reps = 0;
|
||
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_REST &&
|
||
session_form_nonnegative_int(&session->form, &integer_value)) {
|
||
draft->input.rest_seconds = integer_value;
|
||
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_TARGET_WEIGHT &&
|
||
(session->form.bytes == 0U || session_form_double(&session->form,
|
||
&double_value))) {
|
||
draft->input.target_has_weight = session->form.bytes > 0U;
|
||
draft->input.target_weight_kg = session->form.bytes > 0U ? double_value : 0.0;
|
||
draft->target_from_percent_max = false;
|
||
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_TARGET_PERCENT_MAX &&
|
||
session_form_int(&session->form, &integer_value) &&
|
||
integer_value <= 100) {
|
||
TrainlogLatestExplicitMax latest = {0};
|
||
double target = 0.0;
|
||
TrainlogStatus max_status = trainlog_database_latest_explicit_max_equipment_context(
|
||
app->database, draft->input.exercise_id, draft->input.equipment_id, &latest);
|
||
if (max_status != TRAINLOG_STATUS_OK ||
|
||
trainlog_measured_max_target_load(&latest,
|
||
draft->input.equipment_id, draft->input.load_mode,
|
||
integer_value, &target) !=
|
||
TRAINLOG_STATUS_OK) {
|
||
draft->input.target_has_weight = false;
|
||
draft->input.target_weight_kg = 0.0;
|
||
draft->target_from_percent_max = false;
|
||
(void)snprintf(session->message, sizeof(session->message),
|
||
"MAX compatible indisponible : exercice et équipement externe exacts requis.");
|
||
session->form.active = false;
|
||
session->form_purpose = TRAINLOG_SESSION_FORM_NONE;
|
||
draft_bind_input(draft);
|
||
session_controller_sync_durability(session);
|
||
return true;
|
||
}
|
||
draft->input.target_has_weight = true;
|
||
draft->input.target_weight_kg = target;
|
||
draft->target_from_percent_max = true;
|
||
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_MAX_WEIGHT &&
|
||
session_form_double(&session->form, &double_value)) {
|
||
draft->input.has_max_weight = true;
|
||
draft->input.max_weight_kg = double_value;
|
||
draft->input.set_count = 0U;
|
||
draft->input.continuous_duration_seconds = 0;
|
||
} else {
|
||
(void)snprintf(session->message, sizeof(session->message),
|
||
"Valeur invalide; saisissez un nombre positif.");
|
||
return true;
|
||
}
|
||
draft_bind_input(draft);
|
||
session->dirty = true;
|
||
session->message[0] = '\0';
|
||
session->form.active = false;
|
||
session->form_purpose = TRAINLOG_SESSION_FORM_NONE;
|
||
session_controller_sync_durability(session);
|
||
return true;
|
||
}
|
||
|
||
static bool app_shell_dispatch_session(TrainlogAppContext *app, int key)
|
||
{
|
||
TrainlogSessionController *session = &app->session;
|
||
TrainlogAppRoute route = app->navigation.current.route;
|
||
if (route != TRAINLOG_ROUTE_SESSION_MANUAL &&
|
||
route != TRAINLOG_ROUTE_SESSION_CURRENT &&
|
||
route != TRAINLOG_ROUTE_SESSION_GENERATOR) return false;
|
||
if (session->form.active) return app_shell_dispatch_session_form(app, key);
|
||
if (key == TRAINLOG_KEY_F6 || key == TRAINLOG_KEY_F7 || key == '?') return false;
|
||
if (session->phase == TRAINLOG_SESSION_CHOOSE_TYPE) {
|
||
if (key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN ||
|
||
key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT)
|
||
session->session_type = session->session_type == TRAINLOG_SESSION_TRAINING
|
||
? TRAINLOG_SESSION_MAX_TEST : TRAINLOG_SESSION_TRAINING;
|
||
else if (key == TRAINLOG_KEY_ENTER || key == '\n') {
|
||
if (trainlog_time_now_rfc3339(session->started_at,
|
||
sizeof(session->started_at)) == TRAINLOG_STATUS_OK) {
|
||
session->has_draft = true;
|
||
session_controller_load_picker(app);
|
||
}
|
||
} else if (key == TRAINLOG_KEY_ESCAPE || key == 27)
|
||
app_shell_open_route(app, TRAINLOG_ROUTE_SESSIONS);
|
||
return true;
|
||
}
|
||
if (session->phase == TRAINLOG_SESSION_EXERCISE_PICKER) {
|
||
if (key == TRAINLOG_KEY_UP && session->picker_selected > 0U)
|
||
--session->picker_selected;
|
||
else if (key == TRAINLOG_KEY_DOWN &&
|
||
session->picker_selected + 1U < session->picker_count)
|
||
++session->picker_selected;
|
||
else if (key == 'n' || key == 'N')
|
||
exercise_controller_start_create(app, true);
|
||
else if (key == TRAINLOG_KEY_ENTER || key == '\n')
|
||
(void)session_controller_choose_exercise(session);
|
||
else if (key == TRAINLOG_KEY_ESCAPE || key == 27) {
|
||
session->replacing_occurrence = false;
|
||
session->phase = TRAINLOG_SESSION_DRAFT;
|
||
}
|
||
return true;
|
||
}
|
||
if (session->phase == TRAINLOG_SESSION_EQUIPMENT_PICKER) {
|
||
if (key == TRAINLOG_KEY_UP && session->equipment_selected > 0U)
|
||
--session->equipment_selected;
|
||
else if (key == TRAINLOG_KEY_DOWN &&
|
||
session->equipment_selected < session->equipment_count)
|
||
++session->equipment_selected;
|
||
else if ((key == 'x' || key == 'X') && session->selected < session->draft_count) {
|
||
TrainlogSessionDraftExercise *draft = &session->drafts[session->selected];
|
||
draft->input.equipment_id[0] = '\0';
|
||
draft->input.load_mode = TRAINLOG_LOAD_NONE;
|
||
draft->input.target_has_weight = false;
|
||
draft->input.target_weight_kg = 0.0;
|
||
draft->target_from_percent_max = false;
|
||
for (size_t index = 0U; index < draft->input.set_count; ++index) {
|
||
draft->sets[index].has_weight = false;
|
||
draft->sets[index].weight_kg = 0.0;
|
||
}
|
||
draft_bind_input(draft);
|
||
session->dirty = true;
|
||
session->phase = TRAINLOG_SESSION_DRAFT;
|
||
} else if ((key == TRAINLOG_KEY_ENTER || key == '\n') &&
|
||
session->selected < session->draft_count) {
|
||
TrainlogSessionDraftExercise *draft = &session->drafts[session->selected];
|
||
const char *id = session->equipment_selected == 0U ? ""
|
||
: app->equipment[session->equipment_selected - 1U].equipment_id;
|
||
bool context_changed = strcmp(draft->input.equipment_id, id) != 0;
|
||
(void)snprintf(session->drafts[session->selected].input.equipment_id,
|
||
sizeof(session->drafts[session->selected].input.equipment_id), "%s", id);
|
||
if (session->equipment_selected == 0U)
|
||
session->drafts[session->selected].input.load_mode = TRAINLOG_LOAD_NONE;
|
||
else if (strcmp(app->equipment[session->equipment_selected - 1U].load_semantics,
|
||
"assistance") == 0)
|
||
session->drafts[session->selected].input.load_mode = TRAINLOG_LOAD_ASSISTANCE;
|
||
else if (strcmp(app->equipment[session->equipment_selected - 1U].load_semantics,
|
||
"external") == 0)
|
||
session->drafts[session->selected].input.load_mode = TRAINLOG_LOAD_EXTERNAL;
|
||
else session->drafts[session->selected].input.load_mode = TRAINLOG_LOAD_NONE;
|
||
/* INVARIANT: a calculated target cannot survive an equipment
|
||
* context change. Direct manual kg remains user-owned. */
|
||
if (context_changed && draft->target_from_percent_max) {
|
||
draft->input.target_has_weight = false;
|
||
draft->input.target_weight_kg = 0.0;
|
||
draft->target_from_percent_max = false;
|
||
}
|
||
if (session->drafts[session->selected].input.load_mode == TRAINLOG_LOAD_NONE) {
|
||
session->drafts[session->selected].input.target_has_weight = false;
|
||
session->drafts[session->selected].input.target_weight_kg = 0.0;
|
||
for (size_t index = 0U; index < draft->input.set_count; ++index) {
|
||
draft->sets[index].has_weight = false;
|
||
draft->sets[index].weight_kg = 0.0;
|
||
}
|
||
draft_bind_input(draft);
|
||
}
|
||
session->dirty = true;
|
||
session->phase = TRAINLOG_SESSION_DRAFT;
|
||
} else if (key == 'n' || key == 'N') {
|
||
(void)memset(&session->pending_equipment, 0,
|
||
sizeof(session->pending_equipment));
|
||
session->message[0] = '\0';
|
||
session->phase = TRAINLOG_SESSION_EQUIPMENT_CREATE;
|
||
session_controller_start_form(session,
|
||
TRAINLOG_SESSION_FORM_EQUIPMENT_NAME, "");
|
||
} else if (key == TRAINLOG_KEY_ESCAPE || key == 27)
|
||
session->phase = TRAINLOG_SESSION_DRAFT;
|
||
session_controller_sync_durability(session);
|
||
return true;
|
||
}
|
||
if (session->phase == TRAINLOG_SESSION_PLANNING &&
|
||
session->selected < session->draft_count) {
|
||
TrainlogSessionDraftExercise *draft = &session->drafts[session->selected];
|
||
if (key == TRAINLOG_KEY_UP && session->planning_field > 0U)
|
||
--session->planning_field;
|
||
else if (key == TRAINLOG_KEY_DOWN && session->planning_field < 4U)
|
||
++session->planning_field;
|
||
else if (key == TRAINLOG_KEY_TAB)
|
||
session->planning_field = (session->planning_field + 1U) % 5U;
|
||
else if (key == TRAINLOG_KEY_SHIFT_TAB)
|
||
session->planning_field = session->planning_field > 0U
|
||
? session->planning_field - 1U : 4U;
|
||
else if (key == '%' && session->planning_field == 4U) {
|
||
if (draft->input.load_mode != TRAINLOG_LOAD_EXTERNAL ||
|
||
draft->input.equipment_id[0] == '\0') {
|
||
draft->input.target_has_weight = false;
|
||
draft->input.target_weight_kg = 0.0;
|
||
draft->target_from_percent_max = false;
|
||
(void)snprintf(session->message, sizeof(session->message),
|
||
"%%MAX indisponible : choisissez une résistance externe compatible.");
|
||
} else session_controller_start_form(session,
|
||
TRAINLOG_SESSION_FORM_TARGET_PERCENT_MAX, "");
|
||
} else if (key == TRAINLOG_KEY_ENTER || key == '\n' || key == 'e' || key == 'E') {
|
||
char value[48] = "";
|
||
TrainlogSessionFormPurpose purposes[] = {
|
||
TRAINLOG_SESSION_FORM_TARGET_SETS,
|
||
TRAINLOG_SESSION_FORM_TARGET_REPS,
|
||
TRAINLOG_SESSION_FORM_TARGET_DURATION,
|
||
TRAINLOG_SESSION_FORM_REST,
|
||
TRAINLOG_SESSION_FORM_TARGET_WEIGHT
|
||
};
|
||
bool applicable = session->planning_field == 0U
|
||
? draft->input.recording_mode == TRAINLOG_RECORDING_SETS
|
||
: session->planning_field == 1U
|
||
? draft->input.recording_mode == TRAINLOG_RECORDING_SETS &&
|
||
draft->tracking_mode == TRAINLOG_TRACKING_REPS
|
||
: session->planning_field == 2U
|
||
? draft->tracking_mode == TRAINLOG_TRACKING_DURATION
|
||
: session->planning_field == 3U
|
||
? draft->input.recording_mode == TRAINLOG_RECORDING_SETS
|
||
: draft->input.load_mode != TRAINLOG_LOAD_NONE;
|
||
if (!applicable) {
|
||
(void)snprintf(session->message, sizeof(session->message),
|
||
"Ce champ ne s’applique pas au profil de cet exercice.");
|
||
return true;
|
||
}
|
||
if (session->planning_field == 0U)
|
||
(void)snprintf(value, sizeof(value), "%d", draft->input.target_sets);
|
||
else if (session->planning_field == 1U)
|
||
(void)snprintf(value, sizeof(value), "%d", draft->input.target_reps);
|
||
else if (session->planning_field == 2U)
|
||
(void)snprintf(value, sizeof(value), "%d", draft->input.target_duration_seconds);
|
||
else if (session->planning_field == 3U)
|
||
(void)snprintf(value, sizeof(value), "%d", draft->input.rest_seconds);
|
||
else if (draft->input.target_has_weight)
|
||
(void)snprintf(value, sizeof(value), "%.2f", draft->input.target_weight_kg);
|
||
session_controller_start_form(session, purposes[session->planning_field], value);
|
||
} else if (key == TRAINLOG_KEY_ESCAPE || key == 27 || key == 'b' || key == 'B')
|
||
session->phase = TRAINLOG_SESSION_DRAFT;
|
||
return true;
|
||
}
|
||
if (session->phase == TRAINLOG_SESSION_CONFIRM_REMOVE) {
|
||
if (key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT || key == TRAINLOG_KEY_TAB)
|
||
session->destructive_confirm_selected = !session->destructive_confirm_selected;
|
||
else if ((key == TRAINLOG_KEY_ENTER || key == '\n') && session->destructive_confirm_selected && session->selected < session->draft_count) {
|
||
draft_delete_exercise(session->drafts, &session->draft_count,
|
||
session->selected);
|
||
if (session->selected >= session->draft_count && session->selected > 0U)
|
||
--session->selected;
|
||
session->dirty = true;
|
||
session->phase = TRAINLOG_SESSION_DRAFT;
|
||
} else if ((key == TRAINLOG_KEY_ENTER || key == '\n') || key == 'q' || key == 'Q' || key == TRAINLOG_KEY_ESCAPE || key == 27)
|
||
session->phase = TRAINLOG_SESSION_DRAFT;
|
||
return true;
|
||
}
|
||
if (session->phase == TRAINLOG_SESSION_CONFIRM_SET_REMOVE) {
|
||
TrainlogSessionDraftExercise *draft = &session->drafts[session->selected];
|
||
if (key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT || key == TRAINLOG_KEY_TAB)
|
||
session->destructive_confirm_selected = !session->destructive_confirm_selected;
|
||
else if ((key == TRAINLOG_KEY_ENTER || key == '\n') && session->destructive_confirm_selected && draft->input.set_count > 0U) {
|
||
for (size_t index = session->set_selected; index + 1U < draft->input.set_count; ++index)
|
||
draft->sets[index] = draft->sets[index + 1U];
|
||
--draft->input.set_count;
|
||
if (session->set_selected >= draft->input.set_count && session->set_selected > 0U) --session->set_selected;
|
||
draft_bind_input(draft); session->dirty = true; session->phase = TRAINLOG_SESSION_ACTUALS;
|
||
} else if ((key == TRAINLOG_KEY_ENTER || key == '\n') || key == 'q' || key == 'Q' || key == TRAINLOG_KEY_ESCAPE || key == 27)
|
||
session->phase = TRAINLOG_SESSION_ACTUALS;
|
||
session_controller_sync_durability(session);
|
||
return true;
|
||
}
|
||
if (session->phase == TRAINLOG_SESSION_CONFIRM_ABANDON) {
|
||
if (key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT || key == TRAINLOG_KEY_TAB)
|
||
session->destructive_confirm_selected = !session->destructive_confirm_selected;
|
||
else if ((key == TRAINLOG_KEY_ENTER || key == '\n') && session->destructive_confirm_selected) {
|
||
session_controller_clear_draft(session);
|
||
app_shell_open_route(app, TRAINLOG_ROUTE_SESSIONS);
|
||
} else if ((key == TRAINLOG_KEY_ENTER || key == '\n') || key == 'q' || key == 'Q' || key == TRAINLOG_KEY_ESCAPE || key == 27)
|
||
session->phase = TRAINLOG_SESSION_DRAFT;
|
||
return true;
|
||
}
|
||
if (session->phase == TRAINLOG_SESSION_CONFIRM_LEAVE) {
|
||
if (key == TRAINLOG_KEY_ENTER || key == '\n') {
|
||
TrainlogAppRoute pending = session->pending_route;
|
||
app_shell_open_route(app, pending);
|
||
} else if (key == 'd' || key == 'D') {
|
||
TrainlogAppRoute pending = session->pending_route;
|
||
session_controller_discard_generator(session);
|
||
app_shell_open_route(app, pending);
|
||
} else if (key == TRAINLOG_KEY_ESCAPE || key == 27)
|
||
session->phase = session->return_phase;
|
||
return true;
|
||
}
|
||
if (session->phase == TRAINLOG_SESSION_MESSAGE) {
|
||
if (key == TRAINLOG_KEY_ENTER || key == '\n' ||
|
||
key == TRAINLOG_KEY_ESCAPE || key == 27) {
|
||
session->message[0] = '\0';
|
||
session->phase = session->return_phase != TRAINLOG_SESSION_IDLE
|
||
? session->return_phase : TRAINLOG_SESSION_DRAFT;
|
||
}
|
||
return true;
|
||
}
|
||
if (session->phase == TRAINLOG_SESSION_GENERATOR_CONFIG) {
|
||
size_t zone_count = trainlog_body_zone_catalog_count();
|
||
if ((key == 'z' || key == 'Z') && zone_count > 0U) {
|
||
session->generation_zone_index =
|
||
(session->generation_zone_index + 1U) % zone_count;
|
||
session->generation_zone = trainlog_body_zone_catalog_at(
|
||
session->generation_zone_index);
|
||
} else if ((key == 'o' || key == 'O') &&
|
||
trainlog_session_generation_policy_v1.goal_count > 0U) {
|
||
session->generation_goal_index = (session->generation_goal_index + 1U) %
|
||
trainlog_session_generation_policy_v1.goal_count;
|
||
session->generation_goal = &trainlog_session_generation_policy_v1.goals[
|
||
session->generation_goal_index];
|
||
} else if ((key == 't' || key == 'T') &&
|
||
trainlog_session_generation_policy_v1.duration_preset_count > 0U) {
|
||
session->generation_duration_index =
|
||
(session->generation_duration_index + 1U) %
|
||
trainlog_session_generation_policy_v1.duration_preset_count;
|
||
session->generation_duration_minutes =
|
||
trainlog_session_generation_policy_v1.duration_presets_minutes[
|
||
session->generation_duration_index];
|
||
} else if (key == 'g' || key == 'G' || key == TRAINLOG_KEY_ENTER || key == '\n')
|
||
(void)session_controller_generate(app);
|
||
else if (key == TRAINLOG_KEY_ESCAPE || key == 27) {
|
||
session->pending_route = TRAINLOG_ROUTE_SESSIONS;
|
||
session->return_phase = TRAINLOG_SESSION_GENERATOR_CONFIG;
|
||
session->phase = TRAINLOG_SESSION_CONFIRM_LEAVE;
|
||
}
|
||
session_controller_sync_durability(session);
|
||
return true;
|
||
}
|
||
if (session->phase == TRAINLOG_SESSION_GENERATOR_WARNING) {
|
||
if (key == 'c' || key == 'C' || key == TRAINLOG_KEY_ENTER || key == '\n') {
|
||
session->generation_warning_acknowledged = true;
|
||
session->phase = TRAINLOG_SESSION_GENERATOR_PREVIEW;
|
||
} else if (key == 'z' || key == 'Z') {
|
||
session->generated_preview = false;
|
||
session->phase = TRAINLOG_SESSION_GENERATOR_CONFIG;
|
||
} else if (key == TRAINLOG_KEY_ESCAPE || key == 27) {
|
||
session->pending_route = TRAINLOG_ROUTE_SESSIONS;
|
||
session->return_phase = TRAINLOG_SESSION_GENERATOR_WARNING;
|
||
session->phase = TRAINLOG_SESSION_CONFIRM_LEAVE;
|
||
}
|
||
session_controller_sync_durability(session);
|
||
return true;
|
||
}
|
||
if (session->phase == TRAINLOG_SESSION_GENERATOR_PREVIEW) {
|
||
size_t count = session->generated.exercise_count;
|
||
if (key == TRAINLOG_KEY_UP && session->selected > 0U) --session->selected;
|
||
else if (key == TRAINLOG_KEY_DOWN && session->selected + 1U < count)
|
||
++session->selected;
|
||
else if ((key == '<' || key == ',') && session->selected > 0U) {
|
||
TrainlogGeneratedExercise item = session->generated.exercises[session->selected];
|
||
TrainlogGeneratorPreviewItem label = session->generated_items[session->selected];
|
||
session->generated.exercises[session->selected] =
|
||
session->generated.exercises[session->selected - 1U];
|
||
session->generated_items[session->selected] =
|
||
session->generated_items[session->selected - 1U];
|
||
session->generated.exercises[--session->selected] = item;
|
||
session->generated_items[session->selected] = label;
|
||
} else if ((key == '>' || key == '.') && session->selected + 1U < count) {
|
||
TrainlogGeneratedExercise item = session->generated.exercises[session->selected];
|
||
TrainlogGeneratorPreviewItem label = session->generated_items[session->selected];
|
||
session->generated.exercises[session->selected] =
|
||
session->generated.exercises[session->selected + 1U];
|
||
session->generated_items[session->selected] =
|
||
session->generated_items[session->selected + 1U];
|
||
session->generated.exercises[++session->selected] = item;
|
||
session->generated_items[session->selected] = label;
|
||
} else if ((key == 'd' || key == TRAINLOG_KEY_DELETE) && count > 0U) {
|
||
for (size_t index = session->selected; index + 1U < count; ++index) {
|
||
session->generated.exercises[index] = session->generated.exercises[index + 1U];
|
||
session->generated_items[index] = session->generated_items[index + 1U];
|
||
}
|
||
--session->generated.exercise_count;
|
||
if (session->selected >= session->generated.exercise_count && session->selected > 0U)
|
||
--session->selected;
|
||
} else if ((key == 'e' || key == 'E') && count > 0U) {
|
||
char value[48];
|
||
(void)snprintf(value, sizeof(value), "%d",
|
||
session->generated.exercises[session->selected].target_sets);
|
||
session_controller_start_form(session,
|
||
TRAINLOG_SESSION_FORM_GENERATED_SETS, value);
|
||
} else if (key == '%' && count > 0U) {
|
||
session_controller_start_form(session,
|
||
TRAINLOG_SESSION_FORM_GENERATED_PERCENT_MAX, "");
|
||
} else if ((key == 'x' || key == 'X') && count > 0U) {
|
||
TrainlogGeneratedExercise *generated =
|
||
&session->generated.exercises[session->selected];
|
||
generated->has_target_weight = false;
|
||
generated->target_weight_kg = 0.0;
|
||
generated->planned_load_mode = TRAINLOG_LOAD_NONE;
|
||
generated->rationale_count = 1U;
|
||
(void)snprintf(generated->rationale_codes[0],
|
||
sizeof(generated->rationale_codes[0]), "%s", "numeric_load_absent");
|
||
generated->load_source_session_id[0] = '\0';
|
||
generated->load_source_occurrence_id[0] = '\0';
|
||
generated->load_source_started_at[0] = '\0';
|
||
} else if ((key == 'u' || key == 'U') && count > 0U) {
|
||
TrainlogGeneratedExercise *generated =
|
||
&session->generated.exercises[session->selected];
|
||
(void)session_controller_requalify_generated(app, session->selected,
|
||
generated->target_sets, generated->target_repetitions);
|
||
} else if (key == 'g' || key == 'G') {
|
||
(void)session_controller_generate(app);
|
||
} else if (key == 'a' || key == 'A' || key == TRAINLOG_KEY_ENTER || key == '\n') {
|
||
if (session_controller_accept_generator(app))
|
||
app_shell_open_route(app, TRAINLOG_ROUTE_SESSION_CURRENT);
|
||
} else if (key == 'q' || key == 'Q' || key == TRAINLOG_KEY_ESCAPE || key == 27) {
|
||
session->pending_route = TRAINLOG_ROUTE_SESSIONS;
|
||
session->return_phase = TRAINLOG_SESSION_GENERATOR_PREVIEW;
|
||
session->phase = TRAINLOG_SESSION_CONFIRM_LEAVE;
|
||
}
|
||
session_controller_sync_durability(session);
|
||
return true;
|
||
}
|
||
if (session->phase == TRAINLOG_SESSION_ACTUALS && session->draft_count > 0U) {
|
||
TrainlogSessionDraftExercise *draft = &session->drafts[session->selected];
|
||
if (key == 'f' || key == 'F' || key == 'b' || key == 'B' ||
|
||
key == TRAINLOG_KEY_ESCAPE || key == 27) session->phase = TRAINLOG_SESSION_DRAFT;
|
||
else if (session->session_type == TRAINLOG_SESSION_MAX_TEST ||
|
||
draft->input.has_max_weight) {
|
||
if (key == TRAINLOG_KEY_ENTER || key == '\n' || key == 'e' || key == 'E') {
|
||
char value[48] = "";
|
||
if (draft->input.has_max_weight)
|
||
(void)snprintf(value, sizeof(value), "%.2f", draft->input.max_weight_kg);
|
||
session_controller_start_form(session, TRAINLOG_SESSION_FORM_MAX_WEIGHT, value);
|
||
}
|
||
} else if (draft->input.recording_mode == TRAINLOG_RECORDING_CONTINUOUS) {
|
||
if (key == TRAINLOG_KEY_ENTER || key == '\n' || key == 'e' || key == 'E') {
|
||
char value[48] = "";
|
||
if (draft->input.continuous_duration_seconds > 0)
|
||
(void)snprintf(value, sizeof(value), "%d",
|
||
draft->input.continuous_duration_seconds);
|
||
session_controller_start_form(session,
|
||
TRAINLOG_SESSION_FORM_CONTINUOUS_DURATION, value);
|
||
} else if ((key == 's' || key == 'S') &&
|
||
(draft->input.data_fields & TRAINLOG_EXERCISE_DATA_SPEED_KMH) != 0U) {
|
||
char value[48] = "";
|
||
if (draft->input.continuous_has_speed)
|
||
(void)snprintf(value, sizeof(value), "%.2f",
|
||
draft->input.continuous_speed_kmh);
|
||
session_controller_start_form(session,
|
||
TRAINLOG_SESSION_FORM_CONTINUOUS_SPEED, value);
|
||
} else if ((key == 'k' || key == 'K') &&
|
||
(draft->input.data_fields & TRAINLOG_EXERCISE_DATA_DISTANCE_KM) != 0U) {
|
||
char value[48] = "";
|
||
if (draft->input.continuous_has_distance)
|
||
(void)snprintf(value, sizeof(value), "%.2f",
|
||
draft->input.continuous_distance_km);
|
||
session_controller_start_form(session,
|
||
TRAINLOG_SESSION_FORM_CONTINUOUS_DISTANCE, value);
|
||
}
|
||
} else if (key == TRAINLOG_KEY_UP && session->set_selected > 0U)
|
||
--session->set_selected;
|
||
else if (key == TRAINLOG_KEY_DOWN &&
|
||
session->set_selected + 1U < draft->input.set_count)
|
||
++session->set_selected;
|
||
else if (key == TRAINLOG_KEY_TAB || key == TRAINLOG_KEY_SHIFT_TAB ||
|
||
key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT)
|
||
session->set_field = session->set_field == 0U ? 1U : 0U;
|
||
else if ((key == 'd' || key == 'D' || key == TRAINLOG_KEY_DELETE) &&
|
||
draft->input.set_count > 0U) {
|
||
session->destructive_confirm_selected = false;
|
||
session->phase = TRAINLOG_SESSION_CONFIRM_SET_REMOVE;
|
||
} else if ((key == 'a' || key == 'A') &&
|
||
draft->input.set_count < MAX_SETS_PER_EXERCISE) {
|
||
(void)memset(&session->pending_set, 0, sizeof(session->pending_set));
|
||
session->set_selected = draft->input.set_count;
|
||
session_controller_start_form(session, TRAINLOG_SESSION_FORM_SET_METRIC, "");
|
||
} else if ((key == TRAINLOG_KEY_ENTER || key == '\n' || key == 'e' || key == 'E') &&
|
||
session->set_selected < draft->input.set_count) {
|
||
char value[48];
|
||
session->pending_set = draft->sets[session->set_selected];
|
||
if (session->set_field == 1U && draft->input.load_mode != TRAINLOG_LOAD_NONE) {
|
||
(void)snprintf(value, sizeof(value), "%.2f", session->pending_set.weight_kg);
|
||
session_controller_start_form(session, TRAINLOG_SESSION_FORM_SET_WEIGHT, value);
|
||
} else {
|
||
(void)snprintf(value, sizeof(value), "%d",
|
||
draft->tracking_mode == TRAINLOG_TRACKING_DURATION
|
||
? session->pending_set.duration_seconds : session->pending_set.reps);
|
||
session_controller_start_form(session, TRAINLOG_SESSION_FORM_SET_METRIC, value);
|
||
}
|
||
}
|
||
session_controller_sync_durability(session);
|
||
return true;
|
||
}
|
||
if (session->phase == TRAINLOG_SESSION_DRAFT) {
|
||
session->message[0] = '\0';
|
||
if (key == TRAINLOG_KEY_UP && session->selected > 0U) --session->selected;
|
||
else if (key == TRAINLOG_KEY_DOWN && session->selected + 1U < session->draft_count)
|
||
++session->selected;
|
||
else if (key == 'a' || key == 'A') {
|
||
session->replacing_occurrence = false;
|
||
(void)session_controller_load_picker(app);
|
||
} else if ((key == 'r' || key == 'R') && session->draft_count > 0U) {
|
||
session->replacing_occurrence = true;
|
||
(void)session_controller_load_picker(app);
|
||
} else if ((key == 'i' || key == 'I') && session->draft_count > 0U)
|
||
session_controller_load_equipment(app);
|
||
else if ((key == 'p' || key == 'P') && session->draft_count > 0U) {
|
||
session->planning_field = 0U;
|
||
session->phase = TRAINLOG_SESSION_PLANNING;
|
||
} else if ((key == 'd' || key == 'D' || key == TRAINLOG_KEY_DELETE) &&
|
||
session->draft_count > 0U) {
|
||
session->destructive_confirm_selected = false;
|
||
session->phase = TRAINLOG_SESSION_CONFIRM_REMOVE;
|
||
}
|
||
else if ((key == TRAINLOG_KEY_ENTER || key == '\n' || key == 'e' || key == 'E') &&
|
||
session->draft_count > 0U) {
|
||
session->set_selected = 0U;
|
||
session->set_field = 0U;
|
||
session->phase = TRAINLOG_SESSION_ACTUALS;
|
||
} else if (key == 'f' || key == 'F') {
|
||
TrainlogStatus status = session_controller_save(app);
|
||
if (status == TRAINLOG_STATUS_OK) {
|
||
app_shell_open_route(app, TRAINLOG_ROUTE_SESSIONS_COMPLETED);
|
||
return true;
|
||
}
|
||
if (status != TRAINLOG_STATUS_INVALID_ARGUMENT)
|
||
(void)snprintf(session->message, sizeof(session->message),
|
||
"Échec de l’enregistrement; le brouillon est conservé.");
|
||
} else if (key == 'q' || key == 'Q') {
|
||
session->destructive_confirm_selected = false;
|
||
session->phase = TRAINLOG_SESSION_CONFIRM_ABANDON;
|
||
}
|
||
else if (key == TRAINLOG_KEY_ESCAPE || key == 27)
|
||
app_shell_open_route(app, TRAINLOG_ROUTE_SESSIONS);
|
||
session_controller_sync_durability(session);
|
||
return true;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
static bool app_shell_dispatch_sync(TrainlogAppContext *app, int key)
|
||
{
|
||
TrainlogSyncController *sync = &app->sync_controller;
|
||
TrainlogSyncScreenAction action;
|
||
bool was_confirming;
|
||
if (app->navigation.current.route != TRAINLOG_ROUTE_SYNC) return false;
|
||
/* INVARIANT: synchronous execution may be re-entered by terminal or test
|
||
* callbacks, but no input can launch/probe/open history during that run. */
|
||
if (sync->running) return true;
|
||
if (sync->showing_detail) {
|
||
size_t visible = app->layout.content.height > 5
|
||
? (size_t)(app->layout.content.height - 5) : 1U;
|
||
if (key == TRAINLOG_KEY_ESCAPE || key == 'b' || key == 'B' ||
|
||
key == TRAINLOG_KEY_ENTER || key == '\n') {
|
||
sync->showing_detail = false;
|
||
return true;
|
||
}
|
||
if (key == TRAINLOG_KEY_UP && sync->detail_scroll > 0U)
|
||
--sync->detail_scroll;
|
||
else if (key == TRAINLOG_KEY_DOWN &&
|
||
sync->detail_scroll + visible < sync->detail_line_count)
|
||
++sync->detail_scroll;
|
||
else if (key == TRAINLOG_KEY_PAGE_UP)
|
||
sync->detail_scroll = sync->detail_scroll > visible
|
||
? sync->detail_scroll - visible : 0U;
|
||
else if (key == TRAINLOG_KEY_PAGE_DOWN &&
|
||
sync->detail_line_count > visible) {
|
||
size_t maximum = sync->detail_line_count - visible;
|
||
sync->detail_scroll = sync->detail_scroll + visible < maximum
|
||
? sync->detail_scroll + visible : maximum;
|
||
}
|
||
return true;
|
||
}
|
||
was_confirming = sync->action.confirming;
|
||
action = trainlog_sync_screen_dispatch(&sync->action,
|
||
key == TRAINLOG_KEY_ESCAPE ? 27 : key);
|
||
if (action.effect == TRAINLOG_SYNC_SCREEN_REFRESH) {
|
||
(void)memset(&sync->device, 0, sizeof(sync->device));
|
||
sync->probe_status = (sync->probe != NULL ? sync->probe
|
||
: trainlog_sync_probe)(
|
||
&sync->device);
|
||
sync->probe_known = true;
|
||
return true;
|
||
}
|
||
if (action.effect == TRAINLOG_SYNC_SCREEN_CONFIRM ||
|
||
action.effect == TRAINLOG_SYNC_SCREEN_CANCEL) return true;
|
||
if (action.effect == TRAINLOG_SYNC_SCREEN_RUN) {
|
||
TrainlogAppSyncRun run = sync->run != NULL ? sync->run : trainlog_sync_run;
|
||
TrainlogStatus run_status;
|
||
(void)memset(&sync->report, 0, sizeof(sync->report));
|
||
sync->has_report = false;
|
||
sync->running = true;
|
||
/* Render before entering the synchronous shared engine. This provides
|
||
* immediate progress without a second scheduler or history system. */
|
||
if (app->terminal != NULL) app_shell_render(app);
|
||
/* INVARIANT: this is the sole engine call site in the controller and
|
||
* every TUI launch has the complete bidirectional intent. */
|
||
run_status = run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
||
TRAINLOG_SYNC_BIDIRECTIONAL, &sync->report);
|
||
sync->running = false;
|
||
/* WHY: retain the engine's precise diagnostic, but never leave a
|
||
* completed failed action at the unhelpful `error=unknown` boundary. */
|
||
if ((!sync->report.success || run_status != TRAINLOG_STATUS_OK) &&
|
||
sync->report.error[0] == '\0')
|
||
(void)snprintf(sync->report.error, sizeof(sync->report.error), "%s",
|
||
sync_controller_status_error(run_status));
|
||
sync->has_report = true;
|
||
sync_controller_load_history(sync);
|
||
(void)memset(&sync->device, 0, sizeof(sync->device));
|
||
sync->probe_status = (sync->probe != NULL ? sync->probe
|
||
: trainlog_sync_probe)(
|
||
&sync->device);
|
||
sync->probe_known = true;
|
||
return true;
|
||
}
|
||
if (was_confirming) return true;
|
||
if (key == TRAINLOG_KEY_UP && sync->history_count > 0U)
|
||
sync->selected = sync->selected > 0U ? sync->selected - 1U
|
||
: sync->history_count - 1U;
|
||
else if (key == TRAINLOG_KEY_DOWN && sync->history_count > 0U)
|
||
sync->selected = sync->selected + 1U < sync->history_count
|
||
? sync->selected + 1U : 0U;
|
||
else if ((key == TRAINLOG_KEY_ENTER || key == '\n') &&
|
||
sync->history_count > 0U)
|
||
sync_controller_open_detail(sync);
|
||
else return false;
|
||
return true;
|
||
}
|
||
|
||
static void app_shell_dispatch(TrainlogAppContext *app, int key)
|
||
{
|
||
const TrainlogAction *action;
|
||
if (!app->layout.usable) { if (key == 'q' || key == 'Q') app->running = false; return; }
|
||
if (trainlog_overlays_top(&app->overlays) != NULL) {
|
||
app_shell_dispatch_overlay(app, key); return;
|
||
}
|
||
/* Local forms own their editor keys before shell aliases or focus controls. */
|
||
if (app->focus == TRAINLOG_FOCUS_EDITOR) {
|
||
if (app_shell_dispatch_exercise_controller(app, key)) return;
|
||
if (app_shell_dispatch_equipment_controller(app, key)) return;
|
||
if (app_shell_dispatch_body_controller(app, key)) return;
|
||
if (app_shell_dispatch_profile_controller(app, key)) return;
|
||
if (app_shell_dispatch_session(app, key)) return;
|
||
}
|
||
/* Local editor input precedes every route or shell alias. Home/End and
|
||
* printable digits therefore remain text editing while search is focused. */
|
||
if (app->search.focused && app_shell_is_list_route(app->navigation.current.route)) {
|
||
bool changed = false;
|
||
if (key == TRAINLOG_KEY_TAB || key == TRAINLOG_KEY_SHIFT_TAB) {
|
||
app->search.focused = false;
|
||
app->focus = key == TRAINLOG_KEY_SHIFT_TAB
|
||
? TRAINLOG_FOCUS_NAVIGATION : TRAINLOG_FOCUS_CONTENT;
|
||
return;
|
||
}
|
||
if (key == TRAINLOG_KEY_F6 || key == TRAINLOG_KEY_F7) {
|
||
/* Let a focused search invoke shell controls; the overlay restores
|
||
* TRAINLOG_FOCUS_SEARCH and re-enables this editor on close. */
|
||
app->search.focused = false;
|
||
} else {
|
||
if (key == TRAINLOG_KEY_ENTER || key == '\n') {
|
||
app->search.focused = false; app->focus = TRAINLOG_FOCUS_CONTENT; return;
|
||
}
|
||
if (key == TRAINLOG_KEY_ESCAPE || key == 27) {
|
||
bool closed = trainlog_search_escape(&app->search);
|
||
if (closed) app->focus = TRAINLOG_FOCUS_CONTENT;
|
||
app_shell_refresh_list(app); return;
|
||
}
|
||
if (key == TRAINLOG_KEY_HOME) trainlog_search_home(&app->search);
|
||
else if (key == TRAINLOG_KEY_END) trainlog_search_end(&app->search);
|
||
else if (key == TRAINLOG_KEY_LEFT) trainlog_search_left(&app->search);
|
||
else if (key == TRAINLOG_KEY_RIGHT) trainlog_search_right(&app->search);
|
||
else if (key == TRAINLOG_KEY_BACKSPACE || key == TRAINLOG_KEY_DELETE)
|
||
changed = trainlog_search_backspace(&app->search);
|
||
else if (key >= 0x20 && key <= 0x10ffff) {
|
||
char encoded[5] = "";
|
||
utf8proc_ssize_t bytes = utf8proc_encode_char((utf8proc_int32_t)key,
|
||
(utf8proc_uint8_t *)encoded);
|
||
if (bytes > 0) changed = trainlog_search_insert(&app->search,
|
||
encoded, (size_t)bytes);
|
||
}
|
||
if (changed) app_shell_refresh_list(app);
|
||
return;
|
||
}
|
||
}
|
||
if (key == TRAINLOG_KEY_F6) {
|
||
if (app->layout.sidebar_visible) {
|
||
if (app->focus == TRAINLOG_FOCUS_NAVIGATION)
|
||
app->focus = app->navigation_restore_focus == TRAINLOG_FOCUS_NAVIGATION
|
||
? TRAINLOG_FOCUS_CONTENT : app->navigation_restore_focus;
|
||
else {
|
||
app->navigation_restore_focus = app->focus;
|
||
app->focus = TRAINLOG_FOCUS_NAVIGATION;
|
||
}
|
||
}
|
||
else (void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_NAVIGATION,
|
||
app->focus, app->navigation.current.stable_id);
|
||
return;
|
||
}
|
||
if (key == TRAINLOG_KEY_F7) {
|
||
(void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_ACTIONS,
|
||
app->focus, app->navigation.current.stable_id); return;
|
||
}
|
||
if (key == '?') {
|
||
(void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_HELP,
|
||
app->focus, app->navigation.current.stable_id); return;
|
||
}
|
||
if (key == TRAINLOG_KEY_TAB || key == TRAINLOG_KEY_SHIFT_TAB) {
|
||
if (app->layout.sidebar_visible) {
|
||
if (key == TRAINLOG_KEY_SHIFT_TAB)
|
||
app->focus = app->focus == TRAINLOG_FOCUS_NAVIGATION
|
||
? TRAINLOG_FOCUS_ACTIONS : app->focus == TRAINLOG_FOCUS_ACTIONS
|
||
? TRAINLOG_FOCUS_CONTENT : app->focus == TRAINLOG_FOCUS_CONTENT
|
||
? TRAINLOG_FOCUS_SEARCH : TRAINLOG_FOCUS_NAVIGATION;
|
||
else app->focus = app->focus == TRAINLOG_FOCUS_NAVIGATION
|
||
? TRAINLOG_FOCUS_SEARCH : app->focus == TRAINLOG_FOCUS_SEARCH
|
||
? TRAINLOG_FOCUS_CONTENT : app->focus == TRAINLOG_FOCUS_CONTENT
|
||
? TRAINLOG_FOCUS_ACTIONS : TRAINLOG_FOCUS_NAVIGATION;
|
||
} else if (key == TRAINLOG_KEY_SHIFT_TAB)
|
||
app->focus = app->focus == TRAINLOG_FOCUS_NAVIGATION
|
||
? TRAINLOG_FOCUS_ACTIONS : app->focus == TRAINLOG_FOCUS_ACTIONS
|
||
? TRAINLOG_FOCUS_CONTENT : app->focus == TRAINLOG_FOCUS_CONTENT
|
||
? TRAINLOG_FOCUS_SEARCH : TRAINLOG_FOCUS_NAVIGATION;
|
||
else app->focus = app->focus == TRAINLOG_FOCUS_NAVIGATION
|
||
? TRAINLOG_FOCUS_SEARCH : app->focus == TRAINLOG_FOCUS_SEARCH
|
||
? TRAINLOG_FOCUS_CONTENT : app->focus == TRAINLOG_FOCUS_CONTENT
|
||
? TRAINLOG_FOCUS_ACTIONS : TRAINLOG_FOCUS_NAVIGATION;
|
||
app->search.focused = app->focus == TRAINLOG_FOCUS_SEARCH;
|
||
if (app->search.focused) app->search.open = true;
|
||
return;
|
||
}
|
||
if (app->focus == TRAINLOG_FOCUS_NAVIGATION) {
|
||
if (!app->layout.sidebar_visible) {
|
||
if (key == TRAINLOG_KEY_ENTER || key == '\n')
|
||
(void)trainlog_overlays_push(&app->overlays,
|
||
TRAINLOG_OVERLAY_NAVIGATION, TRAINLOG_FOCUS_NAVIGATION,
|
||
app->navigation.current.stable_id);
|
||
else if (key == TRAINLOG_KEY_ESCAPE)
|
||
app->focus = app->navigation_restore_focus == TRAINLOG_FOCUS_NAVIGATION
|
||
? TRAINLOG_FOCUS_CONTENT : app->navigation_restore_focus;
|
||
/* INVARIANT: compact Navigation owns its input and cannot fall
|
||
* through to the content route controller. */
|
||
return;
|
||
}
|
||
size_t count = sizeof(shell_sections) / sizeof(shell_sections[0]);
|
||
if (key == TRAINLOG_KEY_UP && app->navigation_selected > 0U) --app->navigation_selected;
|
||
else if (key == TRAINLOG_KEY_DOWN && app->navigation_selected + 1U < count)
|
||
++app->navigation_selected;
|
||
else if (key == TRAINLOG_KEY_ENTER || key == '\n') {
|
||
app_shell_open_route(app, shell_sections[app->navigation_selected]);
|
||
app->focus = TRAINLOG_FOCUS_CONTENT;
|
||
} else if (key == TRAINLOG_KEY_ESCAPE)
|
||
app->focus = app->navigation_restore_focus == TRAINLOG_FOCUS_NAVIGATION
|
||
? TRAINLOG_FOCUS_CONTENT : app->navigation_restore_focus;
|
||
app->search.focused = app->focus == TRAINLOG_FOCUS_SEARCH;
|
||
/* CONTRACT: a key delivered to persistent navigation is consumed
|
||
* exactly once. Escape closes focus without also navigating Back. */
|
||
return;
|
||
}
|
||
if (app->focus == TRAINLOG_FOCUS_ACTIONS) {
|
||
if (key == TRAINLOG_KEY_ENTER || key == '\n')
|
||
(void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_ACTIONS,
|
||
TRAINLOG_FOCUS_ACTIONS, app->navigation.current.stable_id);
|
||
else if (key == TRAINLOG_KEY_ESCAPE)
|
||
app->focus = TRAINLOG_FOCUS_CONTENT;
|
||
/* INVARIANT: the rendered Actions control owns its input. */
|
||
return;
|
||
}
|
||
/* WHY: route controllers may legitimately own printable keys. Search is a
|
||
* shell action, so resolve its registered '/' before those controllers.
|
||
* CONTRACT: physical '/' and F7 select the same list.search action. */
|
||
action = trainlog_actions_find_key(&app->actions, key);
|
||
if (action != NULL && action->intent == TRAINLOG_INTENT_OPEN_SEARCH &&
|
||
app_shell_is_list_route(app->navigation.current.route)) {
|
||
app->search.open = true;
|
||
app->search.focused = true;
|
||
app->focus = TRAINLOG_FOCUS_SEARCH;
|
||
return;
|
||
}
|
||
if (app->navigation.current.route == TRAINLOG_ROUTE_STATS &&
|
||
key >= '1' && key <= '5') {
|
||
static const TrainlogStatisticsPeriod periods[] = {
|
||
TRAINLOG_STATS_7_DAYS, TRAINLOG_STATS_30_DAYS,
|
||
TRAINLOG_STATS_90_DAYS, TRAINLOG_STATS_YEAR, TRAINLOG_STATS_ALL
|
||
};
|
||
/* Compact keyboard contract: changing period reloads only the
|
||
* controller-owned read snapshot; rendering remains SQL-free. */
|
||
app->dashboard.period = periods[(size_t)(key - '1')];
|
||
app_shell_load_dashboard(app);
|
||
return;
|
||
}
|
||
if (app_shell_dispatch_exercise_controller(app, key)) return;
|
||
if (app_shell_dispatch_equipment_controller(app, key)) return;
|
||
if (app_shell_dispatch_body_controller(app, key)) return;
|
||
if (app_shell_dispatch_profile_controller(app, key)) return;
|
||
if (app_shell_dispatch_session(app, key)) return;
|
||
if (app_shell_dispatch_sync(app, key)) return;
|
||
if ((app->navigation.current.route == TRAINLOG_ROUTE_SESSIONS ||
|
||
app->navigation.current.route == TRAINLOG_ROUTE_STATS) &&
|
||
(key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN)) {
|
||
size_t count = app->navigation.current.route == TRAINLOG_ROUTE_STATS ? 4U : 3U;
|
||
if (key == TRAINLOG_KEY_UP && app->content_selected > 0U) --app->content_selected;
|
||
if (key == TRAINLOG_KEY_DOWN && app->content_selected + 1U < count) ++app->content_selected;
|
||
return;
|
||
}
|
||
if (app_shell_is_list_route(app->navigation.current.route)) {
|
||
if ((key == 'a' || key == 'A') &&
|
||
app->navigation.current.route == TRAINLOG_ROUTE_EXERCISES) {
|
||
exercise_controller_start_create(app, false);
|
||
return;
|
||
}
|
||
if ((key == 'n' || key == 'N') &&
|
||
app->navigation.current.route == TRAINLOG_ROUTE_EQUIPMENT) {
|
||
equipment_controller_start(app);
|
||
return;
|
||
}
|
||
if (key == TRAINLOG_KEY_UP) {
|
||
trainlog_list_move(&app->list, app->stable_ids, -1); return;
|
||
}
|
||
if (key == TRAINLOG_KEY_DOWN) {
|
||
trainlog_list_move(&app->list, app->stable_ids, 1); return;
|
||
}
|
||
if (key == TRAINLOG_KEY_PAGE_UP) {
|
||
trainlog_list_move(&app->list, app->stable_ids,
|
||
-(int)app->list.visible_rows); return;
|
||
}
|
||
if (key == TRAINLOG_KEY_PAGE_DOWN) {
|
||
trainlog_list_move(&app->list, app->stable_ids,
|
||
(int)app->list.visible_rows); return;
|
||
}
|
||
if ((key == 'z' || key == 'Z') &&
|
||
app->navigation.current.route == TRAINLOG_ROUTE_EXERCISES) {
|
||
size_t zone_count = trainlog_body_zone_catalog_count();
|
||
++app->exercise_zone_filter;
|
||
if (app->exercise_zone_filter > (int)zone_count)
|
||
app->exercise_zone_filter = -1;
|
||
app_shell_refresh_list(app); return;
|
||
}
|
||
if ((key == 'x' || key == 'X') &&
|
||
app->navigation.current.route == TRAINLOG_ROUTE_EXERCISES) {
|
||
trainlog_search_init(&app->search); app->exercise_zone_filter = -1;
|
||
app_shell_refresh_list(app); return;
|
||
}
|
||
}
|
||
if (app->navigation.current.route == TRAINLOG_ROUTE_BODY) {
|
||
if (key == 'a' || key == 'A') {
|
||
body_controller_start_add(app); return;
|
||
}
|
||
if ((key == 'e' || key == 'E') && app->loaded_count > 0U) {
|
||
(void)body_controller_start_edit(app,
|
||
app->body_records[app->list.selected_index].observation_id);
|
||
return;
|
||
}
|
||
if (key == 'v' || key == 'V') {
|
||
app_shell_open_route(app, TRAINLOG_ROUTE_BODY_ANALYTICS); return;
|
||
}
|
||
if (key == 'm' || key == 'M') {
|
||
app_shell_open_route(app, TRAINLOG_ROUTE_BODY_METRIC); return;
|
||
}
|
||
if (key == 'g' || key == 'G') {
|
||
app->body_metric_selected = 0U;
|
||
app_shell_open_route(app, TRAINLOG_ROUTE_BODY_TRENDS); return;
|
||
}
|
||
if (key == 'o' || key == 'O') {
|
||
app_shell_open_route(app, TRAINLOG_ROUTE_BODY_GLOBAL); return;
|
||
}
|
||
if (key == TRAINLOG_KEY_LEFT) {
|
||
app_shell_move_available_body_metric(app, -1); return;
|
||
}
|
||
if (key == TRAINLOG_KEY_RIGHT) {
|
||
app_shell_move_available_body_metric(app, 1); return;
|
||
}
|
||
}
|
||
if (app->navigation.current.route == TRAINLOG_ROUTE_BODY_DETAIL) {
|
||
if (key == 'e' || key == 'E') {
|
||
(void)body_controller_start_edit(app,
|
||
app->body_detail.observation_id); return;
|
||
}
|
||
if (key == TRAINLOG_KEY_UP && app->content_scroll > 0U) {
|
||
--app->content_scroll; return;
|
||
}
|
||
if (key == TRAINLOG_KEY_DOWN && app->content_scroll + 1U < 14U) {
|
||
++app->content_scroll; return;
|
||
}
|
||
}
|
||
if (app->navigation.current.route == TRAINLOG_ROUTE_BODY_METRIC) {
|
||
if (key == TRAINLOG_KEY_LEFT) {
|
||
app->body_metric_selected = app->body_metric_selected > 0U
|
||
? app->body_metric_selected - 1U : 13U;
|
||
app_shell_load_body_metric(app); return;
|
||
}
|
||
if (key == TRAINLOG_KEY_RIGHT) {
|
||
app->body_metric_selected = (app->body_metric_selected + 1U) % 14U;
|
||
app_shell_load_body_metric(app); return;
|
||
}
|
||
}
|
||
if (app->navigation.current.route == TRAINLOG_ROUTE_BODY_GLOBAL) {
|
||
if (key == TRAINLOG_KEY_UP)
|
||
app->body_global_selected = app->body_global_selected > 0U
|
||
? app->body_global_selected - 1U : 13U;
|
||
else if (key == TRAINLOG_KEY_DOWN)
|
||
app->body_global_selected = (app->body_global_selected + 1U) % 14U;
|
||
else if (key == ' ')
|
||
app->body_global_enabled[app->body_global_selected] =
|
||
!app->body_global_enabled[app->body_global_selected];
|
||
else goto body_global_unhandled;
|
||
return;
|
||
body_global_unhandled:;
|
||
}
|
||
if (app->navigation.current.route == TRAINLOG_ROUTE_BODY_ANALYTICS) {
|
||
if (key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT) {
|
||
app->content_selected = app->content_selected == 0U ? 1U : 0U;
|
||
return;
|
||
}
|
||
if (key == 'p' || key == 'P') {
|
||
profile_controller_start(app, TRAINLOG_ROUTE_BODY_ANALYTICS); return;
|
||
}
|
||
}
|
||
if (app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_DETAIL) {
|
||
size_t equipment_count = app->exercise_explicit_equipment_count +
|
||
app->exercise_historic_equipment_count;
|
||
if (key == TRAINLOG_KEY_UP && app->exercise_equipment_selected > 0U) {
|
||
--app->exercise_equipment_selected;
|
||
return;
|
||
}
|
||
if (key == TRAINLOG_KEY_DOWN &&
|
||
app->exercise_equipment_selected + 1U < equipment_count) {
|
||
++app->exercise_equipment_selected;
|
||
return;
|
||
}
|
||
if ((key == TRAINLOG_KEY_ENTER || key == '\n') && equipment_count > 0U) {
|
||
size_t selected = app->exercise_equipment_selected;
|
||
app->equipment_detail = selected < app->exercise_explicit_equipment_count
|
||
? app->exercise_explicit_equipment[selected]
|
||
: app->exercise_historic_equipment[
|
||
selected - app->exercise_explicit_equipment_count];
|
||
(void)trainlog_navigation_open(&app->navigation,
|
||
TRAINLOG_ROUTE_EQUIPMENT_DETAIL,
|
||
app->equipment_detail.equipment_id);
|
||
return;
|
||
}
|
||
if (key == 'k' || key == 'K') {
|
||
(void)trainlog_navigation_open(&app->navigation,
|
||
TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE,
|
||
app->exercise_detail.exercise_id); return;
|
||
}
|
||
if (key == 'p' || key == 'P') {
|
||
app_shell_open_exercise_analytics(app,
|
||
TRAINLOG_ROUTE_EXERCISE_PERFORMANCE); return;
|
||
}
|
||
if (key == 'm' || key == 'M') {
|
||
app_shell_open_exercise_analytics(app, TRAINLOG_ROUTE_EXERCISE_MAX); return;
|
||
}
|
||
if (key == 'e' || key == 'E') {
|
||
exercise_controller_start_edit(app);
|
||
return;
|
||
}
|
||
if (key == 'u' || key == 'U') {
|
||
exercise_controller_start_merge(app);
|
||
return;
|
||
}
|
||
}
|
||
if (app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE) {
|
||
size_t page = app->layout.content.height > 4
|
||
? (size_t)(app->layout.content.height - 4) : 1U;
|
||
if (key == TRAINLOG_KEY_UP && app->content_scroll > 0U) --app->content_scroll;
|
||
else if (key == TRAINLOG_KEY_DOWN) ++app->content_scroll;
|
||
else if (key == TRAINLOG_KEY_PAGE_UP)
|
||
app->content_scroll = app->content_scroll > page ? app->content_scroll - page : 0U;
|
||
else if (key == TRAINLOG_KEY_PAGE_DOWN) app->content_scroll += page;
|
||
else if (key == 'k' || key == 'K') (void)app_shell_back(app);
|
||
else goto shell_nonknowledge_input;
|
||
return;
|
||
}
|
||
shell_nonknowledge_input:
|
||
if (app->navigation.current.route == TRAINLOG_ROUTE_EQUIPMENT_DETAIL &&
|
||
(key == TRAINLOG_KEY_ENTER || key == '\n')) {
|
||
size_t relation_index;
|
||
for (relation_index = 0U;
|
||
relation_index < trainlog_equipment_exercise_relation_count();
|
||
++relation_index) {
|
||
const TrainlogEquipmentExerciseRelation *relation =
|
||
trainlog_equipment_exercise_relation_at(relation_index);
|
||
if (relation == NULL || strcmp(relation->equipment_id,
|
||
app->equipment_detail.equipment_id) != 0) continue;
|
||
if (trainlog_database_get_exercise_profile(app->database,
|
||
relation->exercise_id, &app->exercise_detail) == TRAINLOG_STATUS_OK) {
|
||
app_shell_load_exercise_detail_metadata(app);
|
||
(void)trainlog_navigation_open(&app->navigation,
|
||
TRAINLOG_ROUTE_EXERCISE_DETAIL, app->exercise_detail.exercise_id);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
if (app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_PERFORMANCE ||
|
||
app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_MAX) {
|
||
size_t page = app->layout.content.height > 10
|
||
? (size_t)(app->layout.content.height - 10) : 1U;
|
||
if (key == TRAINLOG_KEY_UP && app->content_scroll > 0U) --app->content_scroll;
|
||
else if (key == TRAINLOG_KEY_DOWN &&
|
||
app->content_scroll + 1U < app->performance_count) ++app->content_scroll;
|
||
else if (key == TRAINLOG_KEY_PAGE_UP)
|
||
app->content_scroll = app->content_scroll > page ? app->content_scroll - page : 0U;
|
||
else if (key == TRAINLOG_KEY_PAGE_DOWN && app->performance_count > 0U)
|
||
app->content_scroll = app->content_scroll + page < app->performance_count
|
||
? app->content_scroll + page : app->performance_count - 1U;
|
||
else if ((key == 'r' || key == 'R') &&
|
||
app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_MAX)
|
||
app->max_rounding_index = (app->max_rounding_index + 1U) % 4U;
|
||
else goto shell_nonanalytics_input;
|
||
return;
|
||
}
|
||
shell_nonanalytics_input:
|
||
if (app->navigation.current.route == TRAINLOG_ROUTE_SESSION_DETAIL &&
|
||
!app->session_detail_error) {
|
||
TrainlogPersistedExerciseDetail *entry = app->session_entry_count > 0U
|
||
? &app->session_entries[app->session_entry_selected] : NULL;
|
||
if ((key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_UP) &&
|
||
app->session_entry_count > 0U) {
|
||
app->session_entry_selected = app->session_entry_selected > 0U
|
||
? app->session_entry_selected - 1U : app->session_entry_count - 1U;
|
||
app->session_set_scroll = 0U; return;
|
||
}
|
||
if ((key == TRAINLOG_KEY_RIGHT || key == TRAINLOG_KEY_DOWN) &&
|
||
app->session_entry_count > 0U) {
|
||
app->session_entry_selected = (app->session_entry_selected + 1U) %
|
||
app->session_entry_count;
|
||
app->session_set_scroll = 0U; return;
|
||
}
|
||
if (key == TRAINLOG_KEY_PAGE_UP && app->session_set_scroll > 0U) {
|
||
--app->session_set_scroll; return;
|
||
}
|
||
if (key == TRAINLOG_KEY_PAGE_DOWN && entry != NULL &&
|
||
app->session_set_scroll + 1U < entry->actual_set_count) {
|
||
++app->session_set_scroll; return;
|
||
}
|
||
if ((key == 'i' || key == 'I') && entry != NULL &&
|
||
entry->equipment_id[0] != '\0') {
|
||
if (trainlog_database_resolve_equipment(app->database,
|
||
entry->equipment_id, &app->equipment_detail) == TRAINLOG_STATUS_OK)
|
||
(void)trainlog_navigation_open(&app->navigation,
|
||
TRAINLOG_ROUTE_EQUIPMENT_DETAIL,
|
||
app->equipment_detail.equipment_id);
|
||
return;
|
||
}
|
||
if (key == 'e' || key == 'E') {
|
||
char session_id[TRAINLOG_ID_MAX + 1U];
|
||
(void)snprintf(session_id, sizeof(session_id), "%s",
|
||
app->session_detail.session_id);
|
||
if (app->session.has_draft) {
|
||
app_shell_open_route(app, TRAINLOG_ROUTE_SESSION_CURRENT);
|
||
(void)snprintf(app->session.message, sizeof(app->session.message),
|
||
"Une séance est déjà en cours; elle n’a pas été remplacée.");
|
||
} else {
|
||
TrainlogSessionSummary persisted;
|
||
if (load_persisted_draft(app->database, session_id, &persisted,
|
||
app->session.drafts, &app->session.draft_count)) {
|
||
app->session.has_draft = true;
|
||
app->session.dirty = false;
|
||
app->session.correcting = true;
|
||
app->session.session_type = persisted.session_type;
|
||
(void)snprintf(app->session.persisted_session_id,
|
||
sizeof(app->session.persisted_session_id), "%s", session_id);
|
||
app->session.phase = TRAINLOG_SESSION_DRAFT;
|
||
session_controller_sync_durability(&app->session);
|
||
app_shell_open_route(app, TRAINLOG_ROUTE_SESSION_CURRENT);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
if (key == TRAINLOG_KEY_ENTER || key == '\n') { app_shell_primary(app); return; }
|
||
if (key == TRAINLOG_KEY_HOME) key = '0';
|
||
else if (key == TRAINLOG_KEY_F1) key = '1';
|
||
else if (key == TRAINLOG_KEY_F2) key = '2';
|
||
else if (key == TRAINLOG_KEY_F3) key = '3';
|
||
else if (key == TRAINLOG_KEY_F4) key = '4';
|
||
else if (key == TRAINLOG_KEY_F5) key = '5';
|
||
action = trainlog_actions_find_key(&app->actions, key);
|
||
if (action != NULL && action->intent == TRAINLOG_INTENT_OPEN_ROUTE) {
|
||
app_shell_open_route(app, action->route); return;
|
||
}
|
||
else if (key == TRAINLOG_KEY_ESCAPE || key == 'b' || key == 'B') {
|
||
if (!app_shell_back(app)) app_shell_open_route(app, TRAINLOG_ROUTE_HOME);
|
||
} else if ((key == 'q' || key == 'Q') && app->navigation.current.route == TRAINLOG_ROUTE_HOME) {
|
||
session_controller_sync_durability(&app->session);
|
||
if (app->session.has_draft || app->session.generated_preview ||
|
||
app->session.generation_zone != NULL ||
|
||
app->session.durability.transient_form_dirty) {
|
||
app->quit_confirmation = true;
|
||
(void)trainlog_overlays_push(&app->overlays,
|
||
TRAINLOG_OVERLAY_CONFIRMATION, app->focus,
|
||
app->navigation.current.stable_id);
|
||
} else app->running = false;
|
||
}
|
||
}
|
||
|
||
int trainlog_tui_run(TrainlogDatabase *database)
|
||
{
|
||
TrainlogAppContext app;
|
||
if (database == NULL) {
|
||
return 1;
|
||
}
|
||
|
||
(void)setlocale(LC_ALL, "");
|
||
|
||
(void)memset(&app, 0, sizeof(app));
|
||
app.database = database;
|
||
app.terminal = trainlog_terminal_create();
|
||
if (app.terminal == NULL) {
|
||
return 1;
|
||
}
|
||
|
||
trainlog_terminal_cursor_visible(app.terminal, false);
|
||
trainlog_navigation_init(&app.navigation);
|
||
trainlog_overlays_init(&app.overlays);
|
||
trainlog_list_init(&app.list);
|
||
trainlog_search_init(&app.search);
|
||
trainlog_sync_screen_state_init(&app.sync_controller.action);
|
||
app.sync_controller.probe_status = TRAINLOG_STATUS_NOT_FOUND;
|
||
app.sync_controller.probe = trainlog_sync_probe;
|
||
app.sync_controller.run = trainlog_sync_run;
|
||
app.exercise_zone_filter = -1;
|
||
app.focus = TRAINLOG_FOCUS_CONTENT;
|
||
app.running = true;
|
||
/* CONTRACT: this is the single shell event pump. Controllers return to it
|
||
* after an explicit action; navigation and resize never write domain data. */
|
||
while (app.running) {
|
||
int key;
|
||
app_shell_actions(&app);
|
||
app_shell_render(&app);
|
||
if (!app.running) break;
|
||
key = trainlog_terminal_get_key(app.terminal);
|
||
if (key == TRAINLOG_KEY_NONE) continue;
|
||
if (key == TRAINLOG_KEY_RESIZE) {
|
||
if (!trainlog_terminal_refresh_geometry(app.terminal)) {
|
||
app.running = false;
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
app_shell_dispatch(&app, key);
|
||
}
|
||
app_shell_destroy_surfaces(&app);
|
||
app_shell_release_session_detail(&app);
|
||
trainlog_terminal_destroy(app.terminal);
|
||
return 0;
|
||
}
|