9241 lines
404 KiB
C
9241 lines
404 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 <stdarg.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 "trainlog/terminal.h"
|
||
#include "trainlog/app_shell.h"
|
||
#include "trainlog/presentation.h"
|
||
|
||
#include "trainlog/bodyviz.h"
|
||
#include "trainlog/body_analytics.h"
|
||
#include "trainlog/chart.h"
|
||
#include "trainlog/body_zone_catalog.h"
|
||
#include "trainlog/catalog.h"
|
||
#include "trainlog/duration.h"
|
||
#include "trainlog/dashboard.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/statistics.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"
|
||
|
||
static void presentation_surface_printf(TrainlogSurface *surface, int row,
|
||
int column, const char *format, ...)
|
||
{
|
||
va_list arguments;
|
||
va_list copy;
|
||
char *value;
|
||
int count;
|
||
va_start(arguments, format);
|
||
va_copy(copy, arguments);
|
||
count = trainlog_presentation_vformat(NULL, 0U, format, copy);
|
||
va_end(copy);
|
||
if (count >= 0) {
|
||
value = malloc((size_t)count + 1U);
|
||
if (value != NULL) {
|
||
(void)trainlog_presentation_vformat(value, (size_t)count + 1U,
|
||
format, arguments);
|
||
trainlog_surface_printf(surface, row, column, "%s", value);
|
||
free(value);
|
||
}
|
||
}
|
||
va_end(arguments);
|
||
}
|
||
|
||
static void presentation_terminal_printf(TrainlogTerminal *terminal, int row,
|
||
int column, const char *format, ...)
|
||
{
|
||
va_list arguments;
|
||
char value[512];
|
||
va_start(arguments, format);
|
||
(void)trainlog_presentation_vformat(value, sizeof(value), format, arguments);
|
||
va_end(arguments);
|
||
trainlog_terminal_printf(terminal, row, column, "%s", value);
|
||
}
|
||
|
||
/* All legacy renderer format literals cross the centralized catalog. Unknown
|
||
* strings pass through, preserving persisted names and technical literals. */
|
||
#define trainlog_surface_printf presentation_surface_printf
|
||
#define trainlog_terminal_printf presentation_terminal_printf
|
||
|
||
#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
|
||
|
||
/* 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 trainlog_presentation_source("fourni");
|
||
if (origin == TRAINLOG_EQUIPMENT_CUSTOM)
|
||
return trainlog_presentation_source("personnalisé");
|
||
return trainlog_presentation_source("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 trainlog_presentation_source(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]" :
|
||
trainlog_presentation_text("label.training.upper");
|
||
}
|
||
|
||
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 trainlog_presentation_source("charge externe");
|
||
|
||
case TRAINLOG_LOAD_ASSISTANCE:
|
||
return trainlog_presentation_source("assistance");
|
||
|
||
case TRAINLOG_LOAD_NONE:
|
||
default:
|
||
return trainlog_presentation_source("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)trainlog_presentation_format(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)trainlog_presentation_format_key(output, output_size,
|
||
"summary.performance.none");
|
||
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)trainlog_presentation_format(
|
||
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)trainlog_presentation_format_key(output, output_size,
|
||
"summary.assisted.duration", point->weight_kg, duration);
|
||
} else {
|
||
(void)snprintf(
|
||
output,
|
||
output_size,
|
||
"%s",
|
||
duration
|
||
);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (point->load_mode ==
|
||
TRAINLOG_LOAD_EXTERNAL) {
|
||
(void)trainlog_presentation_format(
|
||
output,
|
||
output_size,
|
||
"%.1f kg × %d reps",
|
||
point->weight_kg,
|
||
point->metric_value
|
||
);
|
||
} else if (
|
||
point->load_mode ==
|
||
TRAINLOG_LOAD_ASSISTANCE
|
||
) {
|
||
(void)trainlog_presentation_format_key(output, output_size,
|
||
"summary.assisted.reps", point->weight_kg, point->metric_value);
|
||
} else {
|
||
(void)trainlog_presentation_format(
|
||
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"}
|
||
};
|
||
|
||
static const char *body_metric_label(size_t index)
|
||
{
|
||
return index < sizeof(BODY_METRICS) / sizeof(BODY_METRICS[0])
|
||
? trainlog_presentation_source(BODY_METRICS[index].label)
|
||
: trainlog_presentation_source("Mesure");
|
||
}
|
||
|
||
/* 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)trainlog_presentation_format_key(output, output_size,
|
||
"summary.continuous", 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)trainlog_presentation_format_key(output, output_size,
|
||
"summary.plan.weight",
|
||
draft->input.target_sets, draft->input.target_reps,
|
||
draft->input.target_weight_kg, draft->input.rest_seconds);
|
||
} else {
|
||
(void)trainlog_presentation_format_key(output, output_size,
|
||
"summary.plan.no_load",
|
||
draft->input.target_sets, draft->input.target_reps,
|
||
draft->input.rest_seconds);
|
||
}
|
||
} else {
|
||
int written = trainlog_presentation_format_key(output, output_size,
|
||
"summary.sets.prefix", 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 = trainlog_presentation_format(fragment, sizeof(fragment),
|
||
"%s%s×%.2fkg%s",
|
||
index > 0U ? " / " : "", metric,
|
||
draft->sets[index].weight_kg,
|
||
draft->input.load_mode == TRAINLOG_LOAD_ASSISTANCE
|
||
? trainlog_presentation_text("summary.assistance.suffix") : "");
|
||
}
|
||
}
|
||
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 trainlog_presentation_text("goal.general");
|
||
if (strcmp(goal_id, "strength") == 0) return trainlog_presentation_text("goal.strength");
|
||
if (strcmp(goal_id, "hypertrophy") == 0) return trainlog_presentation_text("goal.hypertrophy");
|
||
if (strcmp(goal_id, "endurance") == 0) return trainlog_presentation_text("goal.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;
|
||
}
|
||
|
||
if (!trainlog_presentation_compact_date(timestamp, true, output, 9U))
|
||
(void)snprintf(output, 9U, "%s", "--/--/--");
|
||
}
|
||
|
||
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)trainlog_presentation_format_key(waist, sizeof(waist),
|
||
"summary.waist.value", record->waist_cm);
|
||
} else {
|
||
(void)trainlog_presentation_format_key(waist, sizeof(waist),
|
||
"summary.waist.none");
|
||
}
|
||
|
||
if (record->has_chest) {
|
||
(void)trainlog_presentation_format_key(chest, sizeof(chest),
|
||
"summary.chest.value", record->chest_cm);
|
||
} else {
|
||
(void)trainlog_presentation_format_key(chest, sizeof(chest),
|
||
"summary.chest.none");
|
||
}
|
||
|
||
metric_count =
|
||
body_record_metric_count(record);
|
||
|
||
(void)trainlog_presentation_format_key(output, output_size,
|
||
"summary.body", 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;
|
||
}
|
||
|
||
{
|
||
char date[9];
|
||
if (!trainlog_presentation_compact_date(timestamp, true, date,
|
||
sizeof(date))) {
|
||
(void)snprintf(output, 17U, "%s", "--/--/---- --:--");
|
||
return;
|
||
}
|
||
(void)snprintf(output, 17U, "%s %c%c:%c%c", date,
|
||
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 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_PROFILE,
|
||
TRAINLOG_EXERCISE_CONFIRM_DISCARD,
|
||
TRAINLOG_EXERCISE_MESSAGE
|
||
} TrainlogExercisePhase;
|
||
|
||
typedef struct TrainlogExerciseController {
|
||
/* CONTRACT: creation/editing owns one transient value set. An edit keeps
|
||
* exercise_id; incompatible future-profile changes require confirmation
|
||
* and never mutate occurrence-owned historical snapshots. */
|
||
TrainlogExercisePhase phase;
|
||
TrainlogExercisePhase return_phase;
|
||
TrainlogFormField form;
|
||
TrainlogExercise pending;
|
||
TrainlogExercise original;
|
||
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 profile_confirmed;
|
||
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;
|
||
/* WHY: report summary/error are durable diagnostic payloads, not UI copy.
|
||
* CONTRACT: the latest engine status is retained separately so the
|
||
* presenter can classify failures without parsing or rewriting payloads.
|
||
* INVARIANT: current-result rendering depends only on this status and the
|
||
* report's typed fields/counters. */
|
||
TrainlogStatus latest_status;
|
||
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 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;
|
||
TrainlogStatisticsSummary statistics;
|
||
TrainlogZoneStatistics statistics_zones[TRAINLOG_STATISTICS_ZONE_MAX];
|
||
size_t statistics_zone_count;
|
||
TrainlogExerciseStatistics exercise_statistics;
|
||
TrainlogMaxListItem max_list_items[MAX_EXERCISES];
|
||
size_t max_list_item_count;
|
||
bool max_list_partial;
|
||
bool max_show_all;
|
||
TrainlogExerciseListStatistic exercise_list_statistics[MAX_EXERCISES];
|
||
size_t exercise_list_statistics_count;
|
||
bool exercise_list_statistics_partial;
|
||
TrainlogStatisticsWindow statistics_window;
|
||
int64_t statistics_now;
|
||
size_t statistics_graph;
|
||
size_t statistics_zone_selected;
|
||
TrainlogExerciseSeriesKind exercise_statistics_graph;
|
||
bool statistics_error;
|
||
bool statistics_zones_partial;
|
||
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;
|
||
bool settings_language_save_failed;
|
||
bool settings_language_durability_uncertain;
|
||
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;
|
||
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 app_shell_load_statistics(TrainlogAppContext *app)
|
||
{
|
||
time_t now = time(NULL);
|
||
app->statistics_now = now == (time_t)-1 ? 0 : (int64_t)now;
|
||
app->statistics_error = now == (time_t)-1 ||
|
||
trainlog_statistics_load_summary(app->database, app->statistics_window,
|
||
(int64_t)now, &app->statistics) != TRAINLOG_STATUS_OK;
|
||
}
|
||
|
||
static void app_shell_load_statistics_zones(TrainlogAppContext *app)
|
||
{
|
||
time_t now = time(NULL);
|
||
app->statistics_zone_count = 0U;
|
||
app->statistics_zones_partial = false;
|
||
app->statistics_error = now == (time_t)-1 ||
|
||
trainlog_statistics_load_zones(app->database, (int64_t)now,
|
||
app->statistics_zones, TRAINLOG_STATISTICS_ZONE_MAX,
|
||
&app->statistics_zone_count, &app->statistics_zones_partial) !=
|
||
TRAINLOG_STATUS_OK;
|
||
if (app->statistics_zone_selected >= app->statistics_zone_count)
|
||
app->statistics_zone_selected = app->statistics_zone_count > 0U
|
||
? app->statistics_zone_count - 1U : 0U;
|
||
}
|
||
|
||
static void app_shell_load_exercise_statistics(TrainlogAppContext *app)
|
||
{
|
||
time_t now = time(NULL);
|
||
app->statistics_error = now == (time_t)-1 ||
|
||
trainlog_statistics_load_exercise(app->database,
|
||
app->exercise_detail.exercise_id, (int64_t)now,
|
||
&app->exercise_statistics) != TRAINLOG_STATUS_OK;
|
||
}
|
||
|
||
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)trainlog_presentation_format_input(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)trainlog_presentation_format_key(controller->message,
|
||
sizeof(controller->message), "msg.body.prepare");
|
||
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)trainlog_presentation_format_key(controller->message,
|
||
sizeof(controller->message), "msg.body.positive");
|
||
return false;
|
||
}
|
||
controller->dirty = false;
|
||
controller->form.active = false;
|
||
controller->phase = TRAINLOG_BODY_MESSAGE;
|
||
(void)trainlog_presentation_format_key(controller->message,
|
||
sizeof(controller->message), controller->editing
|
||
? "msg.body.corrected" : "msg.body.saved");
|
||
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)trainlog_presentation_format_key(controller->message,
|
||
sizeof(controller->message), "msg.positive.invalid");
|
||
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)trainlog_presentation_format_key(controller->message,
|
||
sizeof(controller->message), "msg.profile.formula");
|
||
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)trainlog_presentation_format_input(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)trainlog_presentation_format_key(controller->message,
|
||
sizeof(controller->message), "msg.profile.height");
|
||
return true;
|
||
}
|
||
controller->pending.height_cm = height;
|
||
if (!body_analytics_profile_save(&controller->pending)) {
|
||
(void)trainlog_presentation_format_key(controller->message,
|
||
sizeof(controller->message), "msg.profile.save.fail");
|
||
return true;
|
||
}
|
||
controller->dirty = false;
|
||
controller->phase = TRAINLOG_PROFILE_MESSAGE;
|
||
(void)trainlog_presentation_format_key(controller->message,
|
||
sizeof(controller->message), "msg.profile.saved");
|
||
} 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)trainlog_presentation_format_key(session->message,
|
||
sizeof(session->message), "msg.catalog.read");
|
||
session->phase = TRAINLOG_SESSION_MESSAGE;
|
||
return false;
|
||
}
|
||
if (session->picker_count == 0U) {
|
||
(void)trainlog_presentation_format_key(session->message,
|
||
sizeof(session->message), "msg.catalog.empty");
|
||
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)trainlog_presentation_format_key(session->message,
|
||
sizeof(session->message), "msg.session.empty");
|
||
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)trainlog_presentation_format_key(session->message,
|
||
sizeof(session->message), "msg.actual.required",
|
||
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)trainlog_presentation_format_key(session->message,
|
||
sizeof(session->message), "msg.generation.rejected", (int)status);
|
||
session->return_phase = TRAINLOG_SESSION_GENERATOR_CONFIG;
|
||
session->phase = TRAINLOG_SESSION_MESSAGE;
|
||
return false;
|
||
}
|
||
if (session->generated.exercise_count == 0U) {
|
||
(void)trainlog_presentation_format_key(session->message,
|
||
sizeof(session->message), "msg.generation.empty");
|
||
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)trainlog_presentation_format_key(session->message,
|
||
sizeof(session->message), "msg.session.exists.preview");
|
||
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[] = {
|
||
"nav.home", "nav.sessions", "nav.exercises", "nav.statistics", "nav.sync",
|
||
"nav.settings"
|
||
};
|
||
|
||
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,
|
||
trainlog_presentation_source(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;
|
||
if (route == TRAINLOG_ROUTE_STATS_EXERCISE) {
|
||
app->exercise_list_statistics_count = 0U;
|
||
app->exercise_list_statistics_partial = false;
|
||
if (trainlog_statistics_list_exercises(app->database,
|
||
app->exercise_list_statistics, MAX_EXERCISES,
|
||
&app->exercise_list_statistics_count,
|
||
&app->exercise_list_statistics_partial) != TRAINLOG_STATUS_OK)
|
||
app->list_error = true;
|
||
source_capped = source_capped || app->exercise_list_statistics_partial;
|
||
}
|
||
if (route == TRAINLOG_ROUTE_MAX) {
|
||
size_t write = 0U;
|
||
app->max_list_item_count = 0U;
|
||
app->max_list_partial = false;
|
||
if (trainlog_statistics_list_maxima(app->database,
|
||
app->max_list_items, MAX_EXERCISES,
|
||
&app->max_list_item_count, &app->max_list_partial) !=
|
||
TRAINLOG_STATUS_OK) app->list_error = true;
|
||
for (index = 0U; index < count; ++index) {
|
||
bool measured = false;
|
||
for (size_t maximum = 0U; maximum < app->max_list_item_count;
|
||
++maximum)
|
||
if (strcmp(app->exercises[index].exercise_id,
|
||
app->max_list_items[maximum].exercise_id) == 0) {
|
||
measured = true; break;
|
||
}
|
||
if ((app->max_show_all && app->exercises[index].recording_mode ==
|
||
TRAINLOG_RECORDING_SETS) || measured)
|
||
app->exercises[write++] = app->exercises[index];
|
||
}
|
||
count = write;
|
||
source_capped = source_capped || app->max_list_partial;
|
||
}
|
||
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 const char *dashboard_period_bucket_summary(TrainlogDashboardPeriod period)
|
||
{
|
||
switch (period) {
|
||
case TRAINLOG_DASHBOARD_7_DAYS: return "7 jours · quotidien";
|
||
case TRAINLOG_DASHBOARD_30_DAYS: return "30 jours · pas de 5 j";
|
||
case TRAINLOG_DASHBOARD_90_DAYS: return "90 jours · pas de 15 j";
|
||
case TRAINLOG_DASHBOARD_YEAR: return "1 an · pas de 31 j";
|
||
case TRAINLOG_DASHBOARD_ALL: return "Tout · 12 périodes max.";
|
||
}
|
||
return "Période";
|
||
}
|
||
|
||
static void dashboard_period_bucket_label(TrainlogDashboardPeriod period,
|
||
size_t index, size_t count, char output[12])
|
||
{
|
||
if (index + 1U == count) {
|
||
(void)snprintf(output, 12U, "%s",
|
||
trainlog_presentation_text("dashboard.current"));
|
||
} else if (period == TRAINLOG_DASHBOARD_7_DAYS) {
|
||
(void)snprintf(output, 12U, "J−%zu", count - 1U - index);
|
||
} else if (period == TRAINLOG_DASHBOARD_YEAR) {
|
||
(void)snprintf(output, 12U, "M−%zu", count - 1U - index);
|
||
} else {
|
||
(void)snprintf(output, 12U, "P−%zu", count - 1U - index);
|
||
}
|
||
}
|
||
|
||
/* The TUI owns only the reference clock and selected presentation period. */
|
||
static void __attribute__((unused)) app_shell_load_dashboard(TrainlogAppContext *app)
|
||
{
|
||
TrainlogDashboardQuery query;
|
||
TrainlogDashboardPeriod period = app->dashboard.period;
|
||
time_t now = time(NULL);
|
||
query.period = period;
|
||
if (now == (time_t)-1) {
|
||
(void)memset(&app->dashboard, 0, sizeof(app->dashboard));
|
||
app->dashboard.period = period;
|
||
app->dashboard.error = true;
|
||
return;
|
||
}
|
||
query.reference_unix_second = (int64_t)now;
|
||
(void)trainlog_dashboard_load(app->database, &query, &app->dashboard);
|
||
}
|
||
|
||
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 *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;
|
||
/* WHY: history artifacts intentionally retain byte-exact diagnostics for
|
||
* logs and support, but arbitrary stored prose is not localized UI copy.
|
||
* CONTRACT: normal history presents only parsed metadata and labels old
|
||
* directional records neutrally; the files themselves remain untouched.
|
||
* INVARIANT: no history summary or run-detail byte reaches this surface. */
|
||
(void)trainlog_presentation_format_key(sync->detail_text,
|
||
sizeof(sync->detail_text),
|
||
entry->direction_known &&
|
||
entry->direction != TRAINLOG_SYNC_BIDIRECTIONAL
|
||
? "sync.history.legacy" : "sync.history.entry",
|
||
entry->timestamp,
|
||
trainlog_presentation_text(entry->success
|
||
? "sync.state.success" : "sync.state.failure"));
|
||
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_key(TrainlogStatus status)
|
||
{
|
||
switch (status) {
|
||
case TRAINLOG_STATUS_CONFLICT:
|
||
return "sync.result.failure.conflict";
|
||
case TRAINLOG_STATUS_NOT_FOUND:
|
||
return "sync.result.failure.not_found";
|
||
case TRAINLOG_STATUS_INVALID_ARGUMENT:
|
||
return "sync.result.failure.invalid";
|
||
case TRAINLOG_STATUS_DATABASE_ERROR:
|
||
return "sync.result.failure.database";
|
||
case TRAINLOG_STATUS_SCHEMA_UNSUPPORTED:
|
||
return "sync.result.failure.version";
|
||
case TRAINLOG_STATUS_SYSTEM_ERROR:
|
||
return "sync.result.failure.system";
|
||
case TRAINLOG_STATUS_OK:
|
||
default:
|
||
return "sync.result.failure.no_report";
|
||
}
|
||
}
|
||
|
||
static void sync_controller_present_result(const TrainlogSyncController *sync,
|
||
char *output, size_t capacity)
|
||
{
|
||
const TrainlogSyncReport *report;
|
||
if (sync == NULL || output == NULL || capacity == 0U) return;
|
||
report = &sync->report;
|
||
/* CONTRACT: success requires agreement between the typed engine status and
|
||
* report flag. No diagnostic string is inspected, translated, or copied. */
|
||
if (sync->latest_status != TRAINLOG_STATUS_OK || !report->success) {
|
||
(void)snprintf(output, capacity, "%s",
|
||
trainlog_presentation_text(
|
||
sync_controller_status_key(sync->latest_status)));
|
||
return;
|
||
}
|
||
(void)trainlog_presentation_format_key(output, capacity,
|
||
"sync.result.success.counts",
|
||
trainlog_sync_screen_direction_label(report->direction),
|
||
report->exercises_imported, report->exercises_reconciled,
|
||
report->exercises_skipped, report->sessions_imported,
|
||
report->sessions_skipped, report->body_imported,
|
||
report->body_skipped, report->equipment_definitions_imported,
|
||
report->equipment_definitions_skipped, report->catalog_published);
|
||
}
|
||
|
||
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;
|
||
if (route == TRAINLOG_ROUTE_EXERCISE_MAX)
|
||
app_shell_load_exercise_statistics(app);
|
||
(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)trainlog_presentation_format_key(controller->message,
|
||
sizeof(controller->message), status == TRAINLOG_STATUS_CONFLICT
|
||
? "msg.equipment.conflict" : "msg.equipment.invalid");
|
||
controller->phase = TRAINLOG_EQUIPMENT_LOAD;
|
||
return false;
|
||
}
|
||
controller->dirty = false;
|
||
controller->form.active = false;
|
||
controller->phase = TRAINLOG_EQUIPMENT_MESSAGE;
|
||
(void)trainlog_presentation_format_key(controller->message,
|
||
sizeof(controller->message), "msg.equipment.created",
|
||
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)trainlog_presentation_format_key(controller->message,
|
||
sizeof(controller->message), "msg.field.required");
|
||
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->original = 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)trainlog_presentation_format_key(controller->message,
|
||
sizeof(controller->message), "msg.zones.read");
|
||
} 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)trainlog_presentation_format_key(controller->message,
|
||
sizeof(controller->message), "msg.merge.targets");
|
||
} 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->profile_confirmed &&
|
||
(controller->pending.tracking_mode != controller->original.tracking_mode ||
|
||
controller->pending.recording_mode != controller->original.recording_mode ||
|
||
controller->pending.data_fields != controller->original.data_fields)) {
|
||
controller->phase = TRAINLOG_EXERCISE_CONFIRM_PROFILE;
|
||
return false;
|
||
}
|
||
if (!controller->editing &&
|
||
controller->pending.recording_mode == TRAINLOG_RECORDING_SETS &&
|
||
controller->primary_zone[0] == '\0') {
|
||
(void)trainlog_presentation_format_key(controller->message,
|
||
sizeof(controller->message), "msg.zone.required");
|
||
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)trainlog_presentation_format_key(controller->message,
|
||
sizeof(controller->message), status == TRAINLOG_STATUS_CONFLICT
|
||
? "msg.exercise.conflict" : "msg.exercise.save.fail");
|
||
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)trainlog_presentation_format_key(controller->message,
|
||
sizeof(controller->message), "msg.exercise.reload");
|
||
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)trainlog_presentation_format_key(controller->message,
|
||
sizeof(controller->message), "msg.exercise.result",
|
||
trainlog_presentation_text(controller->editing
|
||
? "msg.exercise.edited" : "msg.exercise.created"), 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_CONFIRM_PROFILE) {
|
||
if (key == 'y' || key == 'Y' || key == '1') {
|
||
controller->profile_confirmed = true;
|
||
controller->phase = TRAINLOG_EXERCISE_ZONES;
|
||
(void)exercise_controller_save(app);
|
||
} else if (key == 'n' || key == 'N' || key == '0' ||
|
||
key == TRAINLOG_KEY_ESCAPE || key == 'q' || key == 'Q') {
|
||
controller->phase = TRAINLOG_EXERCISE_ZONES;
|
||
}
|
||
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)trainlog_presentation_format_key(controller->message,
|
||
sizeof(controller->message), "msg.name.required");
|
||
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_PROFILE) {
|
||
app_shell_add_action(app, "exercise.profile.no", '0',
|
||
"0 Non", 1U, TRAINLOG_INTENT_BACK, route);
|
||
app_shell_add_action(app, "exercise.profile.yes", '1',
|
||
"1 Oui, modifier", 2U, TRAINLOG_INTENT_SAVE, route);
|
||
} else 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_STATS)
|
||
app_shell_add_action(app, "statistics.open", TRAINLOG_KEY_ENTER,
|
||
"Entrée Ouvrir", 1U, TRAINLOG_INTENT_PRIMARY, route);
|
||
if (route == TRAINLOG_ROUTE_STATS_TRAINING) {
|
||
app_shell_add_action(app, "statistics.graph.previous", TRAINLOG_KEY_LEFT,
|
||
"← Graphe précédent", 1U, TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "statistics.graph.next", TRAINLOG_KEY_RIGHT,
|
||
"→ Graphe suivant", 2U, TRAINLOG_INTENT_NONE, route);
|
||
}
|
||
if (route == TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL) {
|
||
app_shell_add_action(app, "statistics.exercise.previous", TRAINLOG_KEY_LEFT,
|
||
"← Métrique précédente", 1U, TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "statistics.exercise.next", TRAINLOG_KEY_RIGHT,
|
||
"→ Métrique suivante", 2U, TRAINLOG_INTENT_NONE, route);
|
||
}
|
||
if (route == TRAINLOG_ROUTE_STATS_ZONES) {
|
||
app_shell_add_action(app, "statistics.zone.previous", TRAINLOG_KEY_UP,
|
||
"↑ Zone précédente", 1U, TRAINLOG_INTENT_NONE, route);
|
||
app_shell_add_action(app, "statistics.zone.next", TRAINLOG_KEY_DOWN,
|
||
"↓ Zone suivante", 2U, TRAINLOG_INTENT_NONE, route);
|
||
}
|
||
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_MAX)
|
||
app_shell_add_action(app, "max.filter", 'a', "a MAX mesurés/tous", 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_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",
|
||
trainlog_presentation_source(
|
||
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 ? '[' : ' ',
|
||
trainlog_presentation_text(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_STATS_TRAINING: subroute = "· Vue globale"; break;
|
||
case TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL: subroute = "· Exercice"; break;
|
||
case TRAINLOG_ROUTE_STATS_ZONES: subroute = "· Zones"; 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",
|
||
trainlog_presentation_source(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",
|
||
trainlog_presentation_source(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",
|
||
trainlog_presentation_source(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)trainlog_presentation_format(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]), "%s",
|
||
trainlog_presentation_text("value.not_applicable"));
|
||
(void)snprintf(values[3], sizeof(values[3]), "%s",
|
||
trainlog_presentation_text("value.not_applicable"));
|
||
}
|
||
if (draft->tracking_mode != TRAINLOG_TRACKING_REPS)
|
||
(void)snprintf(values[1], sizeof(values[1]), "%s",
|
||
trainlog_presentation_text("value.not_applicable"));
|
||
if (draft->tracking_mode != TRAINLOG_TRACKING_DURATION)
|
||
(void)snprintf(values[2], sizeof(values[2]), "%s",
|
||
trainlog_presentation_text("value.not_applicable"));
|
||
if (draft->input.load_mode == TRAINLOG_LOAD_NONE)
|
||
(void)snprintf(values[4], sizeof(values[4]), "%s",
|
||
trainlog_presentation_text("value.not_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 ? '>' : ' ',
|
||
trainlog_presentation_source(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
|
||
? trainlog_presentation_text("ui.max.compatible.none")
|
||
: "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",
|
||
trainlog_presentation_source(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", trainlog_presentation_source(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",
|
||
trainlog_presentation_source(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", trainlog_presentation_source(
|
||
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",
|
||
trainlog_presentation_source(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",
|
||
trainlog_presentation_source(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_CONFIRM_PROFILE) {
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Modifier le mode de suivi ?");
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Les anciennes séances resteront inchangées.");
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Les prochaines utilisations suivront le nouveau profil.");
|
||
trainlog_surface_printf(app->content, row, 4,
|
||
"0/Échap/q Non · 1 Oui, modifier");
|
||
return;
|
||
}
|
||
if (controller->phase == TRAINLOG_EXERCISE_MESSAGE) {
|
||
trainlog_surface_printf(app->content, row++, 2, "%s",
|
||
trainlog_presentation_source(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
|
||
? trainlog_presentation_source("continue")
|
||
: trainlog_presentation_source("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",
|
||
trainlog_presentation_source(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, const char *title)
|
||
{
|
||
TrainlogChartPoint chart_points[MAX_BODY_METRIC_POINTS];
|
||
TrainlogChartRect rect;
|
||
size_t index;
|
||
if (count > MAX_BODY_METRIC_POINTS) count = MAX_BODY_METRIC_POINTS;
|
||
for (index = 0U; index < count; ++index) {
|
||
TrainlogTimestampKey timestamp;
|
||
if (!trainlog_timestamp_parse(points[index].observed_at,
|
||
strlen(points[index].observed_at), ×tamp)) {
|
||
count = index;
|
||
break;
|
||
}
|
||
chart_points[index].timestamp = timestamp.utc_second;
|
||
chart_points[index].value = points[index].value;
|
||
chart_points[index].timestamp_label = points[index].observed_at;
|
||
}
|
||
rect.top = top;
|
||
rect.left = 2;
|
||
rect.height = height;
|
||
rect.width = app->layout.content.width - 4;
|
||
trainlog_chart_render(app->content, rect, chart_points, count, unit, title);
|
||
}
|
||
|
||
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");
|
||
|
||
/* WHY: sparse observations should donate their unused profile rows to the
|
||
* chart. The current content-plane height is the only geometry source, so
|
||
* resize redraws cannot retain stale coordinates. */
|
||
evolution_top = profile_row + (int)circumference_count + 1;
|
||
if (evolution_top < 8) evolution_top = 8;
|
||
if (evolution_top > app->layout.content.height - 7)
|
||
evolution_top = app->layout.content.height - 7;
|
||
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_metric_label(index + 1U));
|
||
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;
|
||
}
|
||
{
|
||
char title[128];
|
||
(void)trainlog_presentation_format_key(title, sizeof(title),
|
||
"chart.body.metric", trainlog_presentation_source(
|
||
body_metric_label(app->body_metric_selected)));
|
||
app_shell_draw_body_points(app, series->points, series->count,
|
||
evolution_top, app->layout.content.height - evolution_top - 1,
|
||
BODY_METRICS[app->body_metric_selected].unit, title);
|
||
}
|
||
}
|
||
|
||
static void app_shell_render_body_controller(TrainlogAppContext *app)
|
||
{
|
||
TrainlogBodyController *controller = &app->body_controller;
|
||
const char *label = controller->field < 14U
|
||
? body_metric_label(controller->field)
|
||
: trainlog_presentation_source("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",
|
||
trainlog_presentation_source(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",
|
||
trainlog_presentation_source(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",
|
||
trainlog_presentation_source(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",
|
||
trainlog_presentation_source(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
|
||
: trainlog_presentation_source("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_metric_label(index), values[index], BODY_METRICS[index].unit);
|
||
else trainlog_surface_printf(app->content, row++, 2, "%-22s —",
|
||
body_metric_label(index));
|
||
}
|
||
}
|
||
|
||
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)
|
||
{
|
||
if (output == NULL || output_size == 0U) return;
|
||
(void)trainlog_presentation_format(output, output_size, "%.2f", value);
|
||
}
|
||
|
||
/* 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) {
|
||
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(
|
||
dashboard->zones[index].zone_id);
|
||
const char *label = dashboard->zones[index].zone_id[0] == '\0'
|
||
? trainlog_presentation_text("ui.unclassified")
|
||
: (zone == NULL ? dashboard->zones[index].zone_id : zone->display_name);
|
||
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, 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) {
|
||
char label[12];
|
||
dashboard_period_bucket_label(dashboard->period, index,
|
||
dashboard->week_count, 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 __attribute__((unused)) 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_DASHBOARD_7_DAYS ? "[7j]" : "7j",
|
||
dashboard->period == TRAINLOG_DASHBOARD_30_DAYS ? "[30j]" : "30j",
|
||
dashboard->period == TRAINLOG_DASHBOARD_90_DAYS ? "[90j]" : "90j",
|
||
dashboard->period == TRAINLOG_DASHBOARD_YEAR ? "[1an]" : "1an",
|
||
trainlog_presentation_source(
|
||
dashboard->period == TRAINLOG_DASHBOARD_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_metric_label(dashboard->body_metric), 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_metric_label(dashboard->body_metric), 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_metric_label(dashboard->body_metric), 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_metric_label(dashboard->body_metric), 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_metric_label(metric),
|
||
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;
|
||
char result[768];
|
||
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) {
|
||
sync_controller_present_result(sync, result, sizeof(result));
|
||
trainlog_surface_printf(app->content, row++, 2, "%s", result);
|
||
}
|
||
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: journal summary bytes stay available to persistence/log
|
||
* consumers, but the normal UI exposes only localized typed state. */
|
||
const char *summary = trainlog_presentation_text(
|
||
entry->direction_known &&
|
||
entry->direction != TRAINLOG_SYNC_BIDIRECTIONAL
|
||
? "ui.historical.entry"
|
||
: entry->success ? "sync.state.success"
|
||
: "sync.state.failure");
|
||
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 const char *statistics_window_label(TrainlogStatisticsWindow window)
|
||
{
|
||
switch (window) {
|
||
case TRAINLOG_STATISTICS_7_DAYS:
|
||
return trainlog_presentation_source("semaine courante");
|
||
case TRAINLOG_STATISTICS_30_DAYS:
|
||
return trainlog_presentation_source("mois courant");
|
||
case TRAINLOG_STATISTICS_ALL:
|
||
return trainlog_presentation_source("Tout l’historique");
|
||
}
|
||
return trainlog_presentation_source("Période");
|
||
}
|
||
|
||
static void statistics_duration(char *output, size_t capacity, uint64_t seconds)
|
||
{
|
||
uint64_t hours = seconds / UINT64_C(3600);
|
||
uint64_t minutes = (seconds % UINT64_C(3600)) / UINT64_C(60);
|
||
(void)snprintf(output, capacity, "%lluh%02llu",
|
||
(unsigned long long)hours, (unsigned long long)minutes);
|
||
}
|
||
|
||
static void app_shell_render_statistics_hub(TrainlogAppContext *app)
|
||
{
|
||
static const char *const labels[] = {
|
||
"Corps · poids et mensurations",
|
||
"Entraînement · vue globale et régularité",
|
||
"Exercices · faits et courbes par identité",
|
||
"Zones · travail principal et secondaire",
|
||
"MAX · historique des tests enregistrés"
|
||
};
|
||
size_t index;
|
||
trainlog_surface_printf(app->content, 3, 2,
|
||
"Centre d’analyse · données enregistrées uniquement");
|
||
for (index = 0U; index < 5U; ++index) {
|
||
bool selected = app->content_selected == index;
|
||
trainlog_surface_set_role(app->content,
|
||
selected ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE,
|
||
selected ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL);
|
||
trainlog_surface_printf(app->content, 5 + (int)index * 2, 2,
|
||
"%s %s", selected ? "▶" : " ",
|
||
trainlog_presentation_source(labels[index]));
|
||
}
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
if (app->layout.content.height > 15)
|
||
trainlog_surface_printf(app->content, 15, 2,
|
||
"Entrée ouvrir · aucune recommandation ni score global");
|
||
}
|
||
|
||
static void app_shell_render_statistics_chart(TrainlogAppContext *app,
|
||
int top, int height)
|
||
{
|
||
TrainlogChartPoint points[TRAINLOG_STATISTICS_BUCKET_MAX];
|
||
size_t count = app->statistics.bucket_count;
|
||
size_t index;
|
||
const char *unit;
|
||
const char *metric;
|
||
char title[128];
|
||
TrainlogChartOptions options = {0};
|
||
if (count > TRAINLOG_STATISTICS_BUCKET_MAX)
|
||
count = TRAINLOG_STATISTICS_BUCKET_MAX;
|
||
for (index = 0U; index < count; ++index) {
|
||
const TrainlogStatisticsBucket *bucket = &app->statistics.buckets[index];
|
||
points[index].timestamp = bucket->timestamp;
|
||
points[index].timestamp_label = bucket->label;
|
||
if (app->statistics_graph == 0U)
|
||
points[index].value = bucket->loaded_volume_kg;
|
||
else if (app->statistics_graph == 1U)
|
||
points[index].value = (double)bucket->sessions;
|
||
else if (app->statistics_graph == 2U)
|
||
points[index].value = (double)bucket->sets;
|
||
else if (app->statistics_graph == 3U)
|
||
points[index].value = (double)bucket->repetitions;
|
||
else points[index].value = (double)bucket->immediate_feedback_count;
|
||
}
|
||
if (app->statistics_graph == 0U) { metric = "Volume chargé"; unit = "kg"; }
|
||
else if (app->statistics_graph == 1U) { metric = "Séances"; unit = "séances"; }
|
||
else if (app->statistics_graph == 2U) { metric = "Séries"; unit = "séries"; }
|
||
else if (app->statistics_graph == 3U) { metric = "Répétitions"; unit = "reps"; }
|
||
else { metric = "Ressentis immédiats"; unit = "retours"; }
|
||
metric = trainlog_presentation_source(metric);
|
||
unit = trainlog_presentation_source(unit);
|
||
(void)trainlog_presentation_format_key(title, sizeof(title),
|
||
"chart.statistics", metric,
|
||
app->statistics_window == TRAINLOG_STATISTICS_7_DAYS
|
||
? trainlog_presentation_source("semaines calendaires")
|
||
: app->statistics_window == TRAINLOG_STATISTICS_30_DAYS
|
||
? trainlog_presentation_source("mois calendaires")
|
||
: app->statistics.bucket_kind ==
|
||
TRAINLOG_STATISTICS_BUCKET_CALENDAR_MONTH
|
||
? trainlog_presentation_source("mois · historique complet")
|
||
: trainlog_presentation_source("semaines · historique complet"),
|
||
app->statistics_graph + 1U);
|
||
options.non_negative = true;
|
||
options.zero_baseline = true;
|
||
if (count > 0U) {
|
||
options.has_time_domain = true;
|
||
options.minimum_timestamp = points[0].timestamp;
|
||
options.maximum_timestamp =
|
||
app->statistics.buckets[count - 1U].end_timestamp;
|
||
}
|
||
trainlog_chart_render_bars(app->content,
|
||
(TrainlogChartRect){top, 2, height, app->layout.content.width - 4},
|
||
points, count, unit, title, &options);
|
||
}
|
||
|
||
static void app_shell_render_statistics_training(TrainlogAppContext *app)
|
||
{
|
||
const TrainlogStatisticsSummary *value = &app->statistics;
|
||
char activity[32];
|
||
char session_duration[32];
|
||
int chart_top = 10;
|
||
if (app->statistics_error) {
|
||
trainlog_surface_printf(app->content, 4, 2,
|
||
"Impossible de calculer les statistiques enregistrées.");
|
||
return;
|
||
}
|
||
statistics_duration(activity, sizeof(activity), value->activity_duration_seconds);
|
||
statistics_duration(session_duration, sizeof(session_duration),
|
||
value->session_duration_seconds);
|
||
if (app->layout.content.width >= 86 && app->layout.content.height >= 24) {
|
||
int right = app->layout.content.width / 2 + 1;
|
||
trainlog_surface_printf(app->content, 3, 2,
|
||
"Période : %s · 1 7j · 2 30j · 3 Tout",
|
||
statistics_window_label(app->statistics_window));
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_ACCENT,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD);
|
||
trainlog_surface_printf(app->content, 5, 2, "ACTIVITÉ");
|
||
trainlog_surface_printf(app->content, 5, right, "TRAVAIL");
|
||
trainlog_surface_printf(app->content, 9, 2, "RESSENTIS");
|
||
trainlog_surface_printf(app->content, 9, right, "PRÉVU / RÉALISÉ");
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
trainlog_surface_printf(app->content, 6, 2,
|
||
"Séances %zu · jours %zu · semaines %zu", value->sessions,
|
||
value->active_days, value->active_weeks);
|
||
if (value->sessions > 0U)
|
||
trainlog_surface_printf(app->content, 7, 2,
|
||
"Exercices %zu · occurrences %zu · dernière J-%zu",
|
||
value->distinct_exercises, value->occurrences,
|
||
value->days_since_last_session);
|
||
else trainlog_surface_printf(app->content, 7, 2,
|
||
"Exercices 0 · occurrences 0 · aucune séance");
|
||
trainlog_surface_printf(app->content, 6, right,
|
||
"Séries %zu · répétitions %llu", value->sets,
|
||
(unsigned long long)value->repetitions);
|
||
trainlog_surface_printf(app->content, 7, right,
|
||
"Volume %.1f kg · activité %s", value->loaded_volume_kg, activity);
|
||
if (value->sessions_with_duration > 0U)
|
||
trainlog_surface_printf(app->content, 8, right,
|
||
"Séances horodatées %s · moyenne %.0f min",
|
||
session_duration,
|
||
(double)value->session_duration_seconds / 60.0 /
|
||
(double)value->sessions_with_duration);
|
||
else trainlog_surface_printf(app->content, 8, right,
|
||
"Durée moyenne indisponible");
|
||
trainlog_surface_printf(app->content, 10, 2,
|
||
"Immédiats %zu · J+1 séance %zu", value->immediate_feedback_count,
|
||
value->followup_count);
|
||
if (value->top_feedback_count > 0U)
|
||
trainlog_surface_printf(app->content, 11, 2, "%s · %zu retour%s",
|
||
value->top_feedback_exercise, value->top_feedback_count,
|
||
value->top_feedback_count == 1U ? "" : "s");
|
||
else trainlog_surface_printf(app->content, 11, 2,
|
||
"Texte libre non analysé");
|
||
trainlog_surface_printf(app->content, 10, right,
|
||
"Occurrences liées %zu · séries %llu/%llu",
|
||
value->planned_actual_occurrences,
|
||
(unsigned long long)value->planned_sets,
|
||
(unsigned long long)value->actual_sets_for_plans);
|
||
trainlog_surface_printf(app->content, 11, right,
|
||
"Répétitions %llu/%llu",
|
||
(unsigned long long)value->planned_repetitions,
|
||
(unsigned long long)value->actual_repetitions_for_plans);
|
||
app_shell_render_statistics_chart(app, 13,
|
||
app->layout.content.height - 14);
|
||
return;
|
||
}
|
||
trainlog_surface_printf(app->content, 3, 2,
|
||
"Période : %s · 1 7j · 2 30j · 3 Tout",
|
||
statistics_window_label(app->statistics_window));
|
||
trainlog_surface_printf(app->content, 4, 2,
|
||
"Séances %zu · exercices %zu · occurrences %zu · jours %zu · semaines %zu",
|
||
value->sessions, value->distinct_exercises, value->occurrences,
|
||
value->active_days, value->active_weeks);
|
||
if (value->sessions > 0U && app->layout.content.width > 82)
|
||
trainlog_surface_printf(app->content, 4, 73,
|
||
"Dernière : J-%zu", value->days_since_last_session);
|
||
trainlog_surface_printf(app->content, 5, 2,
|
||
"Séries %zu · répétitions %llu · durée d’activité %s",
|
||
value->sets, (unsigned long long)value->repetitions, activity);
|
||
if (value->sessions_with_duration > 0U)
|
||
trainlog_surface_printf(app->content, 6, 2,
|
||
"Durée séances horodatées %s · moyenne %.0f min (%zu séance%s)",
|
||
session_duration,
|
||
(double)value->session_duration_seconds / 60.0 /
|
||
(double)value->sessions_with_duration,
|
||
value->sessions_with_duration,
|
||
value->sessions_with_duration == 1U ? "" : "s");
|
||
else trainlog_surface_printf(app->content, 6, 2,
|
||
"Durée moyenne : indisponible (fin de séance absente)");
|
||
trainlog_surface_printf(app->content, 7, 2,
|
||
"Volume chargé valide : %.1f kg", value->loaded_volume_kg);
|
||
trainlog_surface_printf(app->content, 8, 2,
|
||
"Prévu/réalisé mêmes occurrences : %zu · séries %llu/%llu · reps %llu/%llu",
|
||
value->planned_actual_occurrences,
|
||
(unsigned long long)value->planned_sets,
|
||
(unsigned long long)value->actual_sets_for_plans,
|
||
(unsigned long long)value->planned_repetitions,
|
||
(unsigned long long)value->actual_repetitions_for_plans);
|
||
if (value->top_feedback_count > 0U && app->layout.content.width > 85)
|
||
trainlog_surface_printf(app->content, 9, 2,
|
||
"Ressentis immédiats %zu · plus fréquent %s (%zu) · J+1 séance %zu · texte libre non analysé",
|
||
value->immediate_feedback_count, value->top_feedback_exercise,
|
||
value->top_feedback_count, value->followup_count);
|
||
else trainlog_surface_printf(app->content, 9, 2,
|
||
"Ressentis : immédiats %zu · J+1 séance %zu · texte libre non analysé",
|
||
value->immediate_feedback_count, value->followup_count);
|
||
if (app->layout.content.height - chart_top >= 3)
|
||
app_shell_render_statistics_chart(app, chart_top,
|
||
app->layout.content.height - chart_top - 1);
|
||
}
|
||
|
||
static const char *exercise_series_label(TrainlogExerciseSeriesKind kind)
|
||
{
|
||
switch (kind) {
|
||
case TRAINLOG_EXERCISE_SERIES_LOAD:
|
||
return trainlog_presentation_source("Charge observée");
|
||
case TRAINLOG_EXERCISE_SERIES_VOLUME:
|
||
return trainlog_presentation_source("Volume chargé");
|
||
case TRAINLOG_EXERCISE_SERIES_REPETITIONS:
|
||
return trainlog_presentation_source("Répétitions");
|
||
case TRAINLOG_EXERCISE_SERIES_SETS:
|
||
return trainlog_presentation_source("Séries");
|
||
case TRAINLOG_EXERCISE_SERIES_MAX:
|
||
return trainlog_presentation_source("MAX enregistrés");
|
||
case TRAINLOG_EXERCISE_SERIES_COUNT: break;
|
||
}
|
||
return trainlog_presentation_source("Mesure");
|
||
}
|
||
|
||
static const char *exercise_series_unit(TrainlogExerciseSeriesKind kind)
|
||
{
|
||
if (kind == TRAINLOG_EXERCISE_SERIES_LOAD ||
|
||
kind == TRAINLOG_EXERCISE_SERIES_VOLUME ||
|
||
kind == TRAINLOG_EXERCISE_SERIES_MAX) return "kg";
|
||
return kind == TRAINLOG_EXERCISE_SERIES_REPETITIONS ? "reps" : "séries";
|
||
}
|
||
|
||
static void app_shell_render_exercise_statistics(TrainlogAppContext *app)
|
||
{
|
||
const TrainlogExerciseStatistics *value = &app->exercise_statistics;
|
||
TrainlogChartPoint points[TRAINLOG_STATISTICS_SERIES_MAX];
|
||
TrainlogExerciseSeriesKind kind = app->exercise_statistics_graph;
|
||
size_t count = value->series_count[kind];
|
||
char title[128];
|
||
size_t index;
|
||
TrainlogChartOptions options = {0};
|
||
if (app->statistics_error) {
|
||
trainlog_surface_printf(app->content, 4, 2,
|
||
"Impossible de calculer les statistiques de cet exercice.");
|
||
return;
|
||
}
|
||
trainlog_surface_printf(app->content, 3, 2, "%s · %s",
|
||
value->name, value->exercise_id);
|
||
trainlog_surface_printf(app->content, 4, 2,
|
||
"Occurrences %zu · séances %zu · séries %zu · répétitions %llu",
|
||
value->occurrences, value->sessions, value->sets,
|
||
(unsigned long long)value->repetitions);
|
||
trainlog_surface_printf(app->content, 5, 2,
|
||
"Première %s · dernière %s · fréquence 7j %zu · 30j %zu",
|
||
value->first_at[0] != '\0' ? value->first_at : "—",
|
||
value->last_at[0] != '\0' ? value->last_at : "—",
|
||
value->frequency_7, value->frequency_30);
|
||
if (strcmp(value->load_semantics, "external") == 0)
|
||
trainlog_surface_printf(app->content, 6, 2,
|
||
"Charge max %.1f kg · volume total %.1f kg · moyen/occ. %.1f kg · /séance %.1f kg",
|
||
value->has_highest_load ? value->highest_load_kg : 0.0,
|
||
value->loaded_volume_kg,
|
||
value->occurrences > 0U ? value->loaded_volume_kg /
|
||
(double)value->occurrences : 0.0,
|
||
value->sessions > 0U ? value->loaded_volume_kg /
|
||
(double)value->sessions : 0.0);
|
||
else if (value->duration_seconds > 0U)
|
||
trainlog_surface_printf(app->content, 6, 2,
|
||
"Durée enregistrée : %llu s · volume chargé non applicable",
|
||
(unsigned long long)value->duration_seconds);
|
||
else trainlog_surface_printf(app->content, 6, 2,
|
||
"Volume chargé non applicable à ce profil.");
|
||
trainlog_surface_printf(app->content, 7, 2,
|
||
"MAX %zu · ressentis immédiats %zu · prévu/réalisé séries %llu/%llu",
|
||
value->max_count, value->immediate_feedback_count,
|
||
(unsigned long long)value->planned_sets,
|
||
(unsigned long long)value->actual_sets_for_plans);
|
||
if (value->has_latest_planned_actual_weight && app->layout.content.width > 86)
|
||
trainlog_surface_printf(app->content, 7, 66, "charge %.1f/%.1f kg",
|
||
value->latest_planned_weight_kg, value->latest_actual_weight_kg);
|
||
for (index = 0U; index < count; ++index) {
|
||
points[index].timestamp = value->series[kind][index].timestamp;
|
||
points[index].value = value->series[kind][index].value;
|
||
points[index].timestamp_label = value->series[kind][index].timestamp_label;
|
||
}
|
||
(void)trainlog_presentation_format_key(title, sizeof(title),
|
||
"chart.exercise",
|
||
exercise_series_label(kind), (int)kind + 1);
|
||
options.non_negative = true;
|
||
trainlog_chart_render_line(app->content,
|
||
(TrainlogChartRect){9, 2, app->layout.content.height - 10,
|
||
app->layout.content.width - 4},
|
||
points, count, exercise_series_unit(kind), title, &options);
|
||
}
|
||
|
||
static void app_shell_render_statistics_zones(TrainlogAppContext *app)
|
||
{
|
||
size_t index;
|
||
size_t maximum = 1U;
|
||
int row = 9;
|
||
if (app->statistics_error) {
|
||
trainlog_surface_printf(app->content, 4, 2,
|
||
"Impossible de calculer les statistiques par zone.");
|
||
return;
|
||
}
|
||
if (app->statistics_zone_count == 0U) {
|
||
trainlog_surface_printf(app->content, 4, 2,
|
||
"Aucune zone associée à un entraînement enregistré.");
|
||
return;
|
||
}
|
||
for (index = 0U; index < app->statistics_zone_count; ++index) {
|
||
TrainlogZoneStatistics *zone = &app->statistics_zones[index];
|
||
const TrainlogBodyZone *catalog = trainlog_body_zone_catalog_lookup(zone->zone_id);
|
||
if (catalog != NULL)
|
||
(void)snprintf(zone->label, sizeof(zone->label), "%s", catalog->display_name);
|
||
if (zone->primary_30 > maximum) maximum = zone->primary_30;
|
||
if (zone->secondary_30 > maximum) maximum = zone->secondary_30;
|
||
}
|
||
{
|
||
const TrainlogZoneStatistics *selected =
|
||
&app->statistics_zones[app->statistics_zone_selected];
|
||
trainlog_surface_printf(app->content, 3, 2,
|
||
"%s · séances %zu · exercices distincts %zu",
|
||
selected->label, selected->sessions_any,
|
||
selected->distinct_exercises);
|
||
trainlog_surface_printf(app->content, 4, 2,
|
||
"7j principal %zu · secondaire %zu · 30j principal %zu · secondaire %zu",
|
||
selected->primary_7, selected->secondary_7,
|
||
selected->primary_30, selected->secondary_30);
|
||
trainlog_surface_printf(app->content, 5, 2,
|
||
"Dernier principal : %s",
|
||
selected->latest_primary[0] != '\0' ? selected->latest_primary : "—");
|
||
trainlog_surface_printf(app->content, 6, 2,
|
||
"Dernier secondaire : %s",
|
||
selected->latest_secondary[0] != '\0' ? selected->latest_secondary : "—");
|
||
trainlog_surface_printf(app->content, 7, 2,
|
||
"↑/↓ zone · barres = séances distinctes sur 30 jours");
|
||
}
|
||
for (index = 0U; index < app->statistics_zone_count &&
|
||
row + 1 < app->layout.content.height; ++index) {
|
||
const TrainlogZoneStatistics *zone = &app->statistics_zones[index];
|
||
int capacity = app->layout.content.width - 34;
|
||
int primary;
|
||
int secondary;
|
||
if (capacity < 4) capacity = 4;
|
||
primary = (int)((zone->primary_30 * (size_t)capacity) / maximum);
|
||
secondary = (int)((zone->secondary_30 * (size_t)capacity) / maximum);
|
||
trainlog_surface_printf(app->content, row, 2, "%c %-15.15s P",
|
||
index == app->statistics_zone_selected ? '>' : ' ', zone->label);
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
for (int column = 0; column < primary; ++column)
|
||
trainlog_surface_draw(app->content, row, 22 + column, 0x2588U);
|
||
trainlog_surface_printf(app->content, row, 24 + capacity, "%zu",
|
||
zone->primary_30);
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
trainlog_surface_printf(app->content, row + 1, 19, "S");
|
||
for (int column = 0; column < secondary; ++column)
|
||
trainlog_surface_draw(app->content, row + 1, 22 + column, 0x2584U);
|
||
trainlog_surface_printf(app->content, row + 1, 24 + capacity, "%zu",
|
||
zone->secondary_30);
|
||
row += 2;
|
||
}
|
||
if (app->statistics_zones_partial)
|
||
trainlog_surface_printf(app->content, app->layout.content.height - 1, 2,
|
||
"Vue partielle : capacité locale de présentation atteinte.");
|
||
}
|
||
|
||
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_statistics_hub(app);
|
||
} else if (route == TRAINLOG_ROUTE_STATS_TRAINING) {
|
||
app_shell_render_statistics_training(app);
|
||
} else if (route == TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL) {
|
||
app_shell_render_exercise_statistics(app);
|
||
} else if (route == TRAINLOG_ROUTE_STATS_ZONES) {
|
||
app_shell_render_statistics_zones(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,
|
||
app->session_detail.started_at, feedback->observed_at,
|
||
true, label, sizeof(label));
|
||
trainlog_surface_printf(app->content, row++, 4, "%s %s", trainlog_presentation_source(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)trainlog_presentation_format(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_detail.started_at,
|
||
app->session_followups[followup_index].observed_at,
|
||
false, label, sizeof(label));
|
||
trainlog_surface_printf(app->content, row++, 4, "%s %s", trainlog_presentation_source(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
|
||
? trainlog_presentation_text("label.confidence.high")
|
||
: strcmp(relation->confidence, "moderate") == 0
|
||
? trainlog_presentation_text("label.confidence.moderate")
|
||
: trainlog_presentation_text("label.confidence.uncertain");
|
||
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
|
||
? trainlog_presentation_source("continue")
|
||
: trainlog_presentation_source("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) {
|
||
const TrainlogExerciseStatistics *stats = &app->exercise_statistics;
|
||
TrainlogChartPoint points[TRAINLOG_STATISTICS_SERIES_MAX];
|
||
size_t count = stats->series_count[TRAINLOG_EXERCISE_SERIES_MAX];
|
||
size_t index;
|
||
int row = 4;
|
||
trainlog_surface_printf(app->content, row++, 2, "%s", app->exercise_detail.name);
|
||
if (app->performance_error || app->statistics_error)
|
||
trainlog_surface_printf(app->content, row, 2,
|
||
"Impossible de lire les tests de MAX.");
|
||
else if (!stats->has_latest_max) {
|
||
trainlog_surface_printf(app->content, row, 2, "Aucun MAX enregistré.");
|
||
} else {
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Tests enregistrés : %zu · dernier %.1f kg · meilleur %.1f kg",
|
||
stats->max_count, stats->latest_max_kg, stats->best_max_kg);
|
||
trainlog_surface_printf(app->content, row++, 2, "Date du dernier : %s",
|
||
stats->latest_max_at);
|
||
if (stats->has_previous_max) {
|
||
double difference = stats->latest_max_kg - stats->previous_max_kg;
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Précédent %.1f kg · différence %+.1f kg%s",
|
||
stats->previous_max_kg, difference,
|
||
stats->previous_max_kg != 0.0 ? " · pourcentage calculable" : "");
|
||
if (stats->previous_max_kg != 0.0)
|
||
trainlog_surface_printf(app->content, row++, 2,
|
||
"Différence relative : %+.1f%%",
|
||
difference * 100.0 / stats->previous_max_kg);
|
||
}
|
||
for (index = 0U; index < count; ++index) {
|
||
points[index].timestamp = stats->series[
|
||
TRAINLOG_EXERCISE_SERIES_MAX][index].timestamp;
|
||
points[index].value = stats->series[
|
||
TRAINLOG_EXERCISE_SERIES_MAX][index].value;
|
||
points[index].timestamp_label = stats->series[
|
||
TRAINLOG_EXERCISE_SERIES_MAX][index].timestamp_label;
|
||
}
|
||
if (app->layout.content.height - row >= 3)
|
||
{
|
||
TrainlogChartOptions options = {0};
|
||
options.non_negative = true;
|
||
trainlog_chart_render_line(app->content,
|
||
(TrainlogChartRect){row, 2,
|
||
app->layout.content.height - row - 1,
|
||
app->layout.content.width - 4},
|
||
points, count, "kg", "Historique des MAX", &options);
|
||
}
|
||
}
|
||
} 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_metric_label(app->body_metric_selected));
|
||
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,
|
||
"ÉVOLUTION DANS LE TEMPS");
|
||
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;
|
||
if (route == TRAINLOG_ROUTE_MAX)
|
||
trainlog_surface_printf(app->content, 2, 2,
|
||
"Vue : %s · a afficher %s",
|
||
trainlog_presentation_source(app->max_show_all
|
||
? "tous les exercices compatibles" : "MAX enregistrés"),
|
||
trainlog_presentation_source(app->max_show_all
|
||
? "seulement les MAX" : "tous les compatibles"));
|
||
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)trainlog_presentation_format_key(secondary,
|
||
sizeof(secondary), "list.exercise.count",
|
||
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;
|
||
if (route == TRAINLOG_ROUTE_MAX) {
|
||
bool measured = false;
|
||
for (size_t maximum = 0U; maximum < app->max_list_item_count;
|
||
++maximum)
|
||
if (strcmp(app->exercises[index].exercise_id,
|
||
app->max_list_items[maximum].exercise_id) == 0) {
|
||
char date[9];
|
||
body_short_date(app->max_list_items[maximum].latest_at, date);
|
||
(void)trainlog_presentation_format(secondary, sizeof(secondary),
|
||
"%.1f kg %s",
|
||
app->max_list_items[maximum].latest_max_kg, date);
|
||
measured = true; break;
|
||
}
|
||
if (!measured) (void)snprintf(secondary,
|
||
sizeof(secondary), "—");
|
||
} else if (route == TRAINLOG_ROUTE_STATS_EXERCISE) {
|
||
bool found = false;
|
||
for (size_t fact = 0U;
|
||
fact < app->exercise_list_statistics_count; ++fact)
|
||
if (strcmp(app->exercises[index].exercise_id,
|
||
app->exercise_list_statistics[fact].exercise_id) == 0) {
|
||
char date[9];
|
||
body_short_date(app->exercise_list_statistics[fact].latest_at,
|
||
date);
|
||
(void)trainlog_presentation_format_key(secondary,
|
||
sizeof(secondary), "list.occurrences",
|
||
app->exercise_list_statistics[fact].occurrences,
|
||
date);
|
||
found = true; break;
|
||
}
|
||
if (!found) (void)snprintf(secondary, sizeof(secondary),
|
||
"%s", trainlog_presentation_source("aucune donnée"));
|
||
} else (void)snprintf(secondary, sizeof(secondary), "%s",
|
||
app->exercises[index].tracking_mode == TRAINLOG_TRACKING_REPS
|
||
? "reps" : trainlog_presentation_source("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_set_role(app->content,
|
||
app->content_selected == 0U ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE,
|
||
app->content_selected == 0U ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL);
|
||
trainlog_surface_printf(app->content, 4, 2, "%s %s : %s",
|
||
app->content_selected == 0U ? ">" : " ",
|
||
trainlog_presentation_text("settings.language"),
|
||
trainlog_presentation_language_name(trainlog_presentation_language()));
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
trainlog_surface_printf(app->content, 5, 4, "%s",
|
||
trainlog_presentation_text("settings.language.help"));
|
||
if (app->settings_language_save_failed)
|
||
trainlog_surface_printf(app->content, 6, 4, "%s",
|
||
trainlog_presentation_text("settings.language.save_failed"));
|
||
else if (app->settings_language_durability_uncertain)
|
||
trainlog_surface_printf(app->content, 6, 4, "%s",
|
||
trainlog_presentation_text(
|
||
"settings.language.durability_uncertain"));
|
||
trainlog_surface_set_role(app->content,
|
||
app->content_selected == 1U ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE,
|
||
app->content_selected == 1U ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL);
|
||
trainlog_surface_printf(app->content, 8, 2, "%s %s",
|
||
app->content_selected == 1U ? ">" : " ",
|
||
trainlog_presentation_text("settings.body_profile"));
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
if (app->body_has_profile)
|
||
trainlog_surface_printf(app->content, 10, 4, "%s · %.1f cm",
|
||
app->body_profile.formula == TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE
|
||
? trainlog_presentation_source("Formule femme")
|
||
: trainlog_presentation_source("Formule homme"),
|
||
app->body_profile.height_cm);
|
||
else trainlog_surface_printf(app->content, 10, 4,
|
||
"Profil non configuré.");
|
||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED,
|
||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||
trainlog_surface_printf(app->content, 11, 4, "%s",
|
||
trainlog_presentation_text("settings.body_profile.help"));
|
||
} 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 ? '>' : ' ',
|
||
trainlog_presentation_text(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",
|
||
trainlog_presentation_source(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)trainlog_presentation_format_key(session->message,
|
||
sizeof(session->message), "msg.route.active.field");
|
||
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_TRAINING)
|
||
app_shell_load_statistics(app);
|
||
else if (route == TRAINLOG_ROUTE_STATS_ZONES)
|
||
app_shell_load_statistics_zones(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_TRAINING)
|
||
app_shell_load_statistics(app);
|
||
else if (app->navigation.current.route == TRAINLOG_ROUTE_STATS_ZONES)
|
||
app_shell_load_statistics_zones(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_BODY,
|
||
TRAINLOG_ROUTE_STATS_TRAINING, TRAINLOG_ROUTE_STATS_EXERCISE,
|
||
TRAINLOG_ROUTE_STATS_ZONES, TRAINLOG_ROUTE_MAX};
|
||
app_shell_open_route(app, targets[app->content_selected % 5U]);
|
||
} 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_load_exercise_statistics(app);
|
||
(void)trainlog_navigation_open(&app->navigation,
|
||
TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL,
|
||
app->exercise_detail.exercise_id);
|
||
} 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) {
|
||
if (app->content_selected == 0U) {
|
||
TrainlogPresentationLanguage previous =
|
||
trainlog_presentation_language();
|
||
TrainlogPresentationLanguage language =
|
||
previous == TRAINLOG_PRESENTATION_FRENCH
|
||
? TRAINLOG_PRESENTATION_ENGLISH
|
||
: TRAINLOG_PRESENTATION_FRENCH;
|
||
TrainlogPresentationSaveResult save_result;
|
||
/* WHY: a visual toggle that was not persisted is misleading.
|
||
* CONTRACT: switching language mutates only the local preference;
|
||
* failure rolls back and produces a localized visible diagnostic.
|
||
* INVARIANT: no database object is reachable from this operation. */
|
||
(void)trainlog_presentation_set_language(language);
|
||
save_result = trainlog_presentation_save();
|
||
app->settings_language_save_failed = save_result ==
|
||
TRAINLOG_PRESENTATION_SAVE_NOT_COMMITTED;
|
||
app->settings_language_durability_uncertain = save_result ==
|
||
TRAINLOG_PRESENTATION_SAVE_COMMITTED_DURABILITY_UNCERTAIN;
|
||
trainlog_presentation_resolve_save(previous, save_result);
|
||
} else 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)trainlog_presentation_format_key(controller->message,
|
||
sizeof(controller->message), "msg.merge.preview.fail");
|
||
}
|
||
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)trainlog_presentation_format_key(
|
||
controller->message, sizeof(controller->message),
|
||
"msg.merge.id.bound");
|
||
return;
|
||
}
|
||
app_shell_load_exercise_detail_metadata(app);
|
||
(void)trainlog_presentation_format_key(
|
||
controller->message, sizeof(controller->message),
|
||
"msg.merge.completed", controller->merge_target.name);
|
||
} else (void)trainlog_presentation_format_key(
|
||
controller->message, sizeof(controller->message),
|
||
status == TRAINLOG_STATUS_CONFLICT
|
||
? "msg.merge.conflict" : "msg.merge.fail");
|
||
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)trainlog_presentation_format_key(app->session.message,
|
||
sizeof(app->session.message), "msg.action.active.field");
|
||
} 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)trainlog_presentation_format_key(session->message,
|
||
sizeof(session->message), "msg.equipment.display.required");
|
||
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)trainlog_presentation_format_key(session->message,
|
||
sizeof(session->message), "msg.equipment.label.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)trainlog_presentation_format_key(session->message,
|
||
sizeof(session->message), "msg.equipment.type.required");
|
||
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)trainlog_presentation_format_key(session->message,
|
||
sizeof(session->message), "msg.equipment.create.fail");
|
||
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)trainlog_presentation_format_key(session->message,
|
||
sizeof(session->message), "msg.dose.rejected");
|
||
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)trainlog_presentation_format_key(session->message,
|
||
sizeof(session->message), "msg.max.unavailable");
|
||
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)trainlog_presentation_format_key(session->message,
|
||
sizeof(session->message), "msg.value.invalid");
|
||
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)trainlog_presentation_format_key(session->message,
|
||
sizeof(session->message), "msg.percent.unavailable");
|
||
} 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)trainlog_presentation_format_key(session->message,
|
||
sizeof(session->message), "msg.field.not.applicable");
|
||
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)trainlog_presentation_format_input(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)trainlog_presentation_format_input(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)trainlog_presentation_format_input(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)trainlog_presentation_format_input(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)trainlog_presentation_format_input(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)trainlog_presentation_format_key(session->message,
|
||
sizeof(session->message), "msg.draft.save.fail");
|
||
} 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;
|
||
sync->latest_status = 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_TRAINING &&
|
||
key >= '1' && key <= '3') {
|
||
static const TrainlogStatisticsWindow periods[] = {
|
||
TRAINLOG_STATISTICS_7_DAYS, TRAINLOG_STATISTICS_30_DAYS,
|
||
TRAINLOG_STATISTICS_ALL
|
||
};
|
||
/* CONTRACT: a period switch refreshes one controller-owned snapshot;
|
||
* rendering stays SQL-free and all graph coordinates are recomputed. */
|
||
app->statistics_window = periods[(size_t)(key - '1')];
|
||
app_shell_load_statistics(app);
|
||
return;
|
||
}
|
||
if (app->navigation.current.route == TRAINLOG_ROUTE_SETTINGS &&
|
||
(key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN)) {
|
||
app->content_selected = app->content_selected == 0U ? 1U : 0U;
|
||
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 ? 5U : 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->navigation.current.route == TRAINLOG_ROUTE_STATS_TRAINING &&
|
||
(key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT)) {
|
||
app->statistics_graph = key == TRAINLOG_KEY_LEFT
|
||
? (app->statistics_graph + 4U) % 5U
|
||
: (app->statistics_graph + 1U) % 5U;
|
||
return;
|
||
}
|
||
if (app->navigation.current.route == TRAINLOG_ROUTE_STATS_ZONES &&
|
||
app->statistics_zone_count > 0U &&
|
||
(key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN)) {
|
||
if (key == TRAINLOG_KEY_UP)
|
||
app->statistics_zone_selected = app->statistics_zone_selected > 0U
|
||
? app->statistics_zone_selected - 1U
|
||
: app->statistics_zone_count - 1U;
|
||
else
|
||
app->statistics_zone_selected =
|
||
(app->statistics_zone_selected + 1U) % app->statistics_zone_count;
|
||
return;
|
||
}
|
||
if (app->navigation.current.route == TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL &&
|
||
(key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT)) {
|
||
size_t step;
|
||
for (step = 0U; step < TRAINLOG_EXERCISE_SERIES_COUNT; ++step) {
|
||
size_t next = key == TRAINLOG_KEY_LEFT
|
||
? ((size_t)app->exercise_statistics_graph +
|
||
TRAINLOG_EXERCISE_SERIES_COUNT - 1U) %
|
||
TRAINLOG_EXERCISE_SERIES_COUNT
|
||
: ((size_t)app->exercise_statistics_graph + 1U) %
|
||
TRAINLOG_EXERCISE_SERIES_COUNT;
|
||
app->exercise_statistics_graph = (TrainlogExerciseSeriesKind)next;
|
||
if (app->exercise_statistics.series_count[next] > 0U) break;
|
||
}
|
||
return;
|
||
}
|
||
if (app_shell_is_list_route(app->navigation.current.route)) {
|
||
if ((key == 'a' || key == 'A') &&
|
||
app->navigation.current.route == TRAINLOG_ROUTE_MAX) {
|
||
app->max_show_all = !app->max_show_all;
|
||
app_shell_refresh_list(app);
|
||
return;
|
||
}
|
||
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) {
|
||
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 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)trainlog_presentation_format_key(app->session.message,
|
||
sizeof(app->session.message), "msg.session.exists");
|
||
} 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, "");
|
||
/* WHY: presentation preference is loaded before the first surface is
|
||
* created so the initial frame cannot flash the default language.
|
||
* Invalid/missing local config deterministically remains French. */
|
||
trainlog_presentation_init();
|
||
(void)trainlog_presentation_load();
|
||
|
||
(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;
|
||
}
|
||
|
||
/* tui.c is included directly by the focused workflow harness; do not rewrite
|
||
* the harness's terminal mocks after the production implementation ends. */
|
||
#undef trainlog_surface_printf
|
||
#undef trainlog_terminal_printf
|