trainlog/tui/src/tui.c

10350 lines
223 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @file tui.c
* @brief First usable Trainlog ncurses interface.
*/
#include "trainlog/tui.h"
#include <ctype.h>
#include <fcntl.h>
#include <locale.h>
#include <limits.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <sys/wait.h>
#include <wchar.h>
#include <unistd.h>
#include <curses.h>
#include "trainlog/bodyviz.h"
#include "trainlog/catalog.h"
#include "trainlog/duration.h"
#include "trainlog/id.h"
#include "trainlog/measured_max.h"
#include "trainlog/mtp.h"
#include "trainlog/sync.h"
#include "trainlog/reps.h"
#include "trainlog/theme.h"
#include "trainlog/timeutil.h"
#include "trainlog/usb.h"
#define MAX_EXERCISES 128U
#define MAX_SESSION_EXERCISES 32U
#define MAX_SETS_PER_EXERCISE 64U
#define MAX_SESSIONS 128U
#define MAX_WEIGHT_POINTS 256U
#define MAX_BODY_METRIC_POINTS 256U
/* 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 */
typedef enum DashboardAction {
DASHBOARD_NEW_SESSION = 0,
DASHBOARD_HISTORY,
DASHBOARD_EXERCISES,
DASHBOARD_BODY,
DASHBOARD_SYNC,
DASHBOARD_QUIT
} DashboardAction;
/* TRAINLOG_DASHBOARD_FORWARD_DECLARATIONS */
static DashboardAction screen_dashboard(
TrainlogDatabase *database
);
static void screen_exercises(
TrainlogDatabase *database
);
static void screen_sync(
TrainlogDatabase *database
);
static void dashboard_panel(
int top,
int left,
int bottom,
int right,
const char *label
);
static void dashboard_ascii_header(void);
static void draw_dashboard_body_graph(
TrainlogDatabase *database
);
static void focused_panel(
int top,
int left,
int bottom,
int right,
const char *label,
bool active
);
static void primary_top_navbar(
int active_page,
int selected_page,
bool focused
);
static bool primary_top_nav_forward(int key);
static bool primary_top_nav_activate(int selected_page);
static void section_ascii_header(const char *subtitle);
static void session_history_datetime(
const char *timestamp,
char output[17]
);
static void draw_shell(const char *heading, const char *footer)
{
erase();
box(stdscr, 0, 0);
attron(A_BOLD | trainlog_theme_attribute(TRAINLOG_COLOR_ACCENT));
mvprintw(1, 2, " %s ", heading);
attroff(A_BOLD | trainlog_theme_attribute(TRAINLOG_COLOR_ACCENT));
attron(trainlog_theme_attribute(TRAINLOG_COLOR_MUTED));
mvprintw(LINES - 2, 2, "%-*s", COLS - 4, footer);
attroff(trainlog_theme_attribute(TRAINLOG_COLOR_MUTED));
}
static void wait_key(void)
{
attron(trainlog_theme_attribute(TRAINLOG_COLOR_MUTED));
mvprintw(LINES - 2, 2, "Appuyez sur une touche pour continuer...");
attroff(trainlog_theme_attribute(TRAINLOG_COLOR_MUTED));
refresh();
(void)getch();
}
static void status_line(const char *text, TrainlogColorRole role)
{
attron(trainlog_theme_attribute(role));
mvprintw(LINES - 3, 2, "%-*s", COLS - 4, text);
attroff(trainlog_theme_attribute(role));
}
static bool prompt_text(
int row,
const char *label,
char *output,
size_t output_size,
bool allow_empty
)
{
size_t used = 0U;
int input_column;
int cursor_row;
bool accepted = false;
if (label == NULL ||
output == NULL ||
output_size < 2U) {
return false;
}
output[0] = '\0';
mvprintw(
row,
2,
"%s",
label
);
getyx(
stdscr,
cursor_row,
input_column
);
(void)cursor_row;
noecho();
(void)curs_set(1);
for (;;) {
wint_t value;
int rc;
move(
row,
input_column
);
clrtoeol();
if (used > 0U) {
addnstr(
output,
(int)used
);
}
refresh();
rc = get_wch(&value);
if (rc == ERR) {
break;
}
if (rc == KEY_CODE_YES) {
int key =
(int)value;
if (key == KEY_ENTER) {
if (allow_empty ||
used > 0U) {
accepted = true;
break;
}
continue;
}
if (key == KEY_BACKSPACE ||
key == KEY_DC) {
if (used > 0U) {
do {
--used;
} while (
used > 0U &&
(((unsigned char)output[used] &
0xc0U) == 0x80U)
);
output[used] = '\0';
}
continue;
}
continue;
}
if (value == (wint_t)27) {
accepted = false;
break;
}
if (value == (wint_t)'\n' ||
value == (wint_t)'\r') {
if (allow_empty ||
used > 0U) {
accepted = true;
break;
}
continue;
}
if (value == (wint_t)8 ||
value == (wint_t)127) {
if (used > 0U) {
do {
--used;
} while (
used > 0U &&
(((unsigned char)output[used] &
0xc0U) == 0x80U)
);
output[used] = '\0';
}
continue;
}
if (value >= (wint_t)32) {
char encoded[MB_LEN_MAX];
mbstate_t state;
size_t encoded_size;
(void)memset(
&state,
0,
sizeof(state)
);
encoded_size =
wcrtomb(
encoded,
(wchar_t)value,
&state
);
if (encoded_size ==
(size_t)-1 ||
encoded_size == 0U ||
encoded_size >
output_size - used - 1U) {
continue;
}
(void)memcpy(
output + used,
encoded,
encoded_size
);
used += encoded_size;
output[used] = '\0';
}
}
noecho();
(void)curs_set(0);
return accepted;
}
static bool parse_int(const char *text, int minimum, int maximum, int *output)
{
char *end = NULL;
long value;
if (text == NULL || output == NULL || text[0] == '\0') {
return false;
}
value = strtol(text, &end, 10);
if (end == text || *end != '\0' ||
value < (long)minimum || value > (long)maximum) {
return false;
}
*output = (int)value;
return true;
}
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' || value <= 0.0) {
return false;
}
*output = value;
return true;
}
static bool prompt_int_value(
int row,
const char *label,
int minimum,
int maximum,
int default_value,
int *output
)
{
char buffer[64];
for (;;) {
char decorated[128];
(void)snprintf(
decorated,
sizeof(decorated),
"%s [%d]: ",
label,
default_value
);
if (!prompt_text(row, decorated, buffer, sizeof(buffer), true)) {
return false;
}
if (buffer[0] == '\0') {
*output = default_value;
return true;
}
if (parse_int(buffer, minimum, maximum, output)) {
return true;
}
status_line("Valeur entière invalide.", TRAINLOG_COLOR_ERROR);
refresh();
}
}
static bool prompt_optional_double(
int row,
const char *label,
bool *present,
double *output
)
{
char buffer[64];
for (;;) {
if (!prompt_text(row, label, buffer, sizeof(buffer), true)) {
return false;
}
if (buffer[0] == '\0') {
*present = false;
*output = 0.0;
return true;
}
if (parse_double_positive(buffer, output)) {
*present = true;
return true;
}
status_line("Nombre positif invalide.", TRAINLOG_COLOR_ERROR);
refresh();
}
}
/* TRAINLOG_EXERCISE_PERFORMANCE_TUI */
#define EXERCISE_GRAPH_POINTS 12U
static const char *exercise_load_mode_label(
TrainlogLoadMode mode
)
{
switch (mode) {
case TRAINLOG_LOAD_EXTERNAL:
return "charge externe";
case TRAINLOG_LOAD_ASSISTANCE:
return "assistance";
case TRAINLOG_LOAD_NONE:
default:
return "sans charge";
}
}
static void exercise_short_date(
const char *timestamp,
char output[11]
)
{
if (timestamp == NULL ||
strlen(timestamp) < 10U) {
(void)snprintf(
output,
11U,
"%s",
"----------"
);
return;
}
(void)memcpy(
output,
timestamp,
10U
);
output[10] = '\0';
}
static void exercise_format_performance(
const TrainlogExercisePerformancePoint *point,
char *output,
size_t output_size
)
{
if (point == NULL ||
output == NULL ||
output_size == 0U) {
return;
}
if (point->has_performance == 0) {
(void)snprintf(
output,
output_size,
"%s",
"aucune série réussie"
);
return;
}
if (point->tracking_mode ==
TRAINLOG_TRACKING_DURATION) {
char duration[64];
if (trainlog_duration_format(
point->metric_value,
duration,
sizeof(duration)
) != TRAINLOG_STATUS_OK) {
(void)snprintf(
duration,
sizeof(duration),
"%d s",
point->metric_value
);
}
if (point->load_mode ==
TRAINLOG_LOAD_EXTERNAL) {
(void)snprintf(
output,
output_size,
"%.1f kg × %s",
point->weight_kg,
duration
);
} else if (
point->load_mode ==
TRAINLOG_LOAD_ASSISTANCE
) {
(void)snprintf(
output,
output_size,
"%.1f kg aide × %s",
point->weight_kg,
duration
);
} else {
(void)snprintf(
output,
output_size,
"%s",
duration
);
}
return;
}
if (point->load_mode ==
TRAINLOG_LOAD_EXTERNAL) {
(void)snprintf(
output,
output_size,
"%.1f kg × %d reps",
point->weight_kg,
point->metric_value
);
} else if (
point->load_mode ==
TRAINLOG_LOAD_ASSISTANCE
) {
(void)snprintf(
output,
output_size,
"%.1f kg aide × %d reps",
point->weight_kg,
point->metric_value
);
} else {
(void)snprintf(
output,
output_size,
"%d reps",
point->metric_value
);
}
}
static bool exercise_point_better(
const TrainlogExercisePerformancePoint *candidate,
const TrainlogExercisePerformancePoint *current
)
{
if (candidate == NULL ||
candidate->has_performance == 0) {
return false;
}
if (current == NULL ||
current->has_performance == 0) {
return true;
}
if (candidate->load_mode !=
current->load_mode) {
return false;
}
switch (candidate->load_mode) {
case TRAINLOG_LOAD_EXTERNAL:
if (candidate->weight_kg >
current->weight_kg) {
return true;
}
return
candidate->weight_kg ==
current->weight_kg &&
candidate->metric_value >
current->metric_value;
case TRAINLOG_LOAD_ASSISTANCE:
if (candidate->weight_kg <
current->weight_kg) {
return true;
}
return
candidate->weight_kg ==
current->weight_kg &&
candidate->metric_value >
current->metric_value;
case TRAINLOG_LOAD_NONE:
default:
return
candidate->metric_value >
current->metric_value;
}
}
static double exercise_graph_value(
const TrainlogExercisePerformancePoint *point
)
{
if (point->load_mode ==
TRAINLOG_LOAD_NONE) {
return (double)point->metric_value;
}
return point->weight_kg;
}
static void draw_exercise_performance_graph(
const TrainlogExercisePerformancePoint *points,
size_t count,
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 = 0.0;
double maximum = 0.0;
const int left = 11;
int width =
COLS - left - 4;
if (points == NULL ||
count == 0U ||
height < 3 ||
width < 12) {
return;
}
for (index = 0U;
index < count &&
selected_count < EXERCISE_GRAPH_POINTS;
++index) {
if (points[index].has_performance != 0 &&
points[index].load_mode == mode) {
indices[selected_count] = index;
++selected_count;
}
}
if (selected_count == 0U) {
mvprintw(
top + 1,
4,
"Aucune performance réussie pour ce mode."
);
return;
}
minimum =
exercise_graph_value(
&points[indices[0]]
);
maximum = minimum;
for (index = 1U;
index < selected_count;
++index) {
double value =
exercise_graph_value(
&points[indices[index]]
);
if (value < minimum) {
minimum = value;
}
if (value > maximum) {
maximum = value;
}
}
if (maximum == minimum) {
minimum -= 1.0;
maximum += 1.0;
}
if (mode == TRAINLOG_LOAD_ASSISTANCE) {
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_WARNING
)
);
mvprintw(
top,
left,
measured_max
? "Assistance mesurée (kg) — moins = mieux"
: "Assistance (kg) — moins = mieux"
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_WARNING
)
);
} else if (mode ==
TRAINLOG_LOAD_EXTERNAL) {
mvprintw(
top,
left,
measured_max
? "Max mesuré (kg)"
: "Charge du meilleur set (kg)"
);
} else if (
tracking_mode ==
TRAINLOG_TRACKING_DURATION
) {
mvprintw(
top,
left,
measured_max
? "Durée max mesurée"
: "Meilleure durée"
);
} else {
mvprintw(
top,
left,
measured_max
? "Répétitions max mesurées"
: "Meilleures répétitions"
);
}
mvprintw(
top + 1,
2,
"%.1f",
maximum
);
mvprintw(
top + height - 1,
2,
"%.1f",
minimum
);
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_GRAPH
)
);
{
int previous_x = -1;
int previous_y = -1;
size_t order;
for (order = selected_count;
order > 0U;
--order) {
size_t chronological =
selected_count - order;
const TrainlogExercisePerformancePoint *point =
&points[indices[order - 1U]];
double value =
exercise_graph_value(point);
double ratio =
(value - minimum) /
(maximum - minimum);
int x =
selected_count == 1U
? left + (width / 2)
: left +
(int)(
(chronological *
(size_t)(width - 1)) /
(selected_count - 1U)
);
int y =
top +
height -
1 -
(int)(
ratio *
(double)(height - 1)
);
if (previous_x >= 0 &&
x > previous_x) {
int line_x;
for (line_x = previous_x + 1;
line_x < x;
++line_x) {
int line_y =
previous_y +
(((y - previous_y) *
(line_x - previous_x)) /
(x - previous_x));
mvaddch(
line_y,
line_x,
(chtype)'.'
);
}
}
mvaddch(
y,
x,
chronological + 1U ==
selected_count
? (chtype)'O'
: (chtype)'*'
);
previous_x = x;
previous_y = y;
}
}
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_GRAPH
)
);
{
char oldest[11];
char newest[11];
exercise_short_date(
points[indices[selected_count - 1U]]
.started_at,
oldest
);
exercise_short_date(
points[indices[0]].started_at,
newest
);
mvprintw(
top + height,
left,
"%s",
oldest
);
mvprintw(
top + height,
left + width - 10,
"%s",
newest
);
}
}
/* TRAINLOG_EXERCISE_FRAMES */
static void exercise_panel(
int top,
int left,
int bottom,
int right,
const char *label
)
{
WINDOW *panel;
int height;
int width;
if (top < 0 ||
left < 0 ||
bottom <= top ||
right <= left ||
bottom >= LINES ||
right >= COLS) {
return;
}
height = bottom - top + 1;
width = right - left + 1;
panel = derwin(
stdscr,
height,
width,
top,
left
);
if (panel == NULL) {
return;
}
box(panel, 0, 0);
if (label != NULL &&
label[0] != '\0' &&
width > 8) {
wattron(
panel,
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvwprintw(
panel,
0,
2,
" %.*s ",
width - 6,
label
);
wattroff(
panel,
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
syncok(panel, TRUE);
wsyncup(panel);
delwin(panel);
}
static void screen_exercise_performance(
TrainlogDatabase *database,
const TrainlogExercise *exercise
)
{
TrainlogExercisePerformancePoint
points[MAX_SESSIONS];
size_t count = 0U;
size_t index;
size_t history_limit;
TrainlogLoadMode graph_mode =
TRAINLOG_LOAD_NONE;
const TrainlogExercisePerformancePoint *latest =
NULL;
const TrainlogExercisePerformancePoint *best =
NULL;
char latest_text[128];
char best_text[128];
bool decorated =
COLS >= 100 &&
LINES >= 36;
int summary_top =
decorated ? 8 : 3;
int summary_bottom =
decorated ? 14 : 9;
int graph_top =
decorated ? 15 : 11;
int graph_bottom =
decorated ? 25 : 20;
int history_top =
decorated ? 26 : 22;
int history_bottom =
LINES - 4;
if (database == NULL ||
exercise == NULL) {
return;
}
if (trainlog_database_list_exercise_performance(
database,
exercise->exercise_id,
points,
MAX_SESSIONS,
&count
) != TRAINLOG_STATUS_OK) {
draw_shell(
"Performance exercice",
"Une touche pour revenir"
);
status_line(
"Impossible de lire l'historique.",
TRAINLOG_COLOR_ERROR
);
wait_key();
return;
}
for (index = 0U;
index < count;
++index) {
if (points[index].has_performance != 0) {
latest = &points[index];
graph_mode =
points[index].load_mode;
break;
}
}
if (latest != NULL) {
for (index = 0U;
index < count;
++index) {
if (points[index].has_performance != 0 &&
points[index].load_mode ==
graph_mode &&
exercise_point_better(
&points[index],
best
)) {
best = &points[index];
}
}
}
exercise_format_performance(
latest,
latest_text,
sizeof(latest_text)
);
exercise_format_performance(
best,
best_text,
sizeof(best_text)
);
if (decorated) {
erase();
box(stdscr, 0, 0);
section_ascii_header(
":: P E R F O R M A N C E E X E R C I C E ::"
);
dashboard_panel(
summary_top,
2,
summary_bottom,
COLS - 3,
"PERFORMANCE"
);
dashboard_panel(
graph_top,
2,
graph_bottom,
COLS - 3,
"EVOLUTION"
);
if (history_bottom >
history_top + 2) {
focused_panel(
history_top,
2,
history_bottom,
COLS - 3,
"HISTORIQUE",
true
);
}
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
mvprintw(
LINES - 2,
2,
"%.*s",
COLS - 4,
"b/Échap retour"
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
} else {
draw_shell(
"TRAINLOG — Performance exercice",
"b/Échap retour"
);
exercise_panel(
summary_top,
2,
summary_bottom,
COLS - 3,
"PERFORMANCE"
);
exercise_panel(
graph_top,
2,
graph_bottom,
COLS - 3,
"EVOLUTION"
);
if (history_bottom >
history_top + 2) {
exercise_panel(
history_top,
2,
history_bottom,
COLS - 3,
"HISTORIQUE"
);
}
}
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvprintw(
summary_top + 1,
5,
"%s",
exercise->name
);
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvprintw(
summary_top + 2,
5,
"Séances enregistrées : %zu",
count
);
if (latest == NULL) {
mvprintw(
summary_top + 3,
5,
"Aucune série réussie enregistrée."
);
} else {
mvprintw(
summary_top + 3,
5,
"Mode suivi : %s",
exercise_load_mode_label(
graph_mode
)
);
mvprintw(
summary_top + 4,
5,
"Dernier meilleur set : %.*s",
COLS - 30,
latest_text
);
mvprintw(
summary_top + 5,
5,
"Meilleur set enregistré : %.*s",
COLS - 33,
best_text
);
}
if (latest != NULL) {
int graph_content_top =
graph_top + 1;
int graph_height =
graph_bottom -
graph_top -
2;
draw_exercise_performance_graph(
points,
count,
graph_mode,
exercise->tracking_mode,
graph_content_top,
graph_height,
false
);
}
if (history_bottom >
history_top + 2) {
int history_first_row =
history_top + 1;
history_limit =
history_bottom >
history_first_row
? (size_t)(
history_bottom -
history_first_row
)
: 0U;
for (index = 0U;
index < count &&
index < history_limit;
++index) {
char date[11];
char summary[128];
exercise_short_date(
points[index].started_at,
date
);
exercise_format_performance(
&points[index],
summary,
sizeof(summary)
);
mvprintw(
history_first_row +
(int)index,
5,
"%s %-13s %.*s",
date,
exercise_load_mode_label(
points[index].load_mode
),
COLS - 40,
summary
);
}
}
refresh();
for (;;) {
int key = getch();
if (key == 'b' ||
key == 'B' ||
key == 27 ||
key == '\n' ||
key == KEY_ENTER) {
return;
}
}
}
/* TRAINLOG_MEASURED_MAX_TUI_V1 */
static void screen_exercise_measured_max(
TrainlogDatabase *database,
const TrainlogExercise *exercise
)
{
static const double increments[] = {
0.5,
1.0,
2.5,
5.0
};
static const double percentages[] = {
60.0,
70.0,
80.0,
90.0
};
size_t increment_index = 2U;
if (
database == NULL ||
exercise == NULL
) {
return;
}
for (;;) {
TrainlogExercisePerformancePoint
points[MAX_SESSIONS];
TrainlogExercisePerformancePoint
max_points[MAX_SESSIONS];
TrainlogMeasuredMaxSummary summary;
size_t count = 0U;
size_t max_count = 0U;
size_t index;
size_t history_limit;
bool decorated =
COLS >= 100 &&
LINES >= 30;
int summary_top =
decorated ? 8 : 3;
int summary_bottom =
decorated ? 15 : 9;
int graph_top =
decorated ? 16 : 10;
int graph_bottom =
decorated ? 23 : 17;
int history_top =
decorated ? 24 : 18;
int history_bottom =
LINES - 4;
int key;
if (
trainlog_database_list_exercise_performance(
database,
exercise->exercise_id,
points,
MAX_SESSIONS,
&count
) != TRAINLOG_STATUS_OK ||
trainlog_measured_max_summarize(
points,
count,
&summary
) != TRAINLOG_STATUS_OK
) {
draw_shell(
"TRAINLOG — Max mesuré",
"Une touche pour revenir"
);
status_line(
"Impossible de lire les tests de max.",
TRAINLOG_COLOR_ERROR
);
wait_key();
return;
}
for (
index = 0U;
index < count &&
max_count < MAX_SESSIONS;
++index
) {
if (
points[index].session_type ==
TRAINLOG_SESSION_MAX_TEST
) {
max_points[max_count] =
points[index];
++max_count;
}
}
erase();
box(
stdscr,
0,
0
);
if (decorated) {
section_ascii_header(
":: M A X M E S U R E ::"
);
dashboard_panel(
summary_top,
2,
summary_bottom,
COLS - 3,
"MAX MESURE"
);
dashboard_panel(
graph_top,
2,
graph_bottom,
COLS - 3,
"EVOLUTION DES TESTS MAX"
);
if (
history_bottom >
history_top + 1
) {
exercise_panel(
history_top,
2,
history_bottom,
COLS - 3,
"HISTORIQUE TESTS MAX"
);
}
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
mvprintw(
LINES - 2,
2,
"%.*s",
COLS - 4,
"r arrondi charge b/Échap retour"
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
} else {
draw_shell(
"TRAINLOG — Max mesuré",
"r arrondi charge b/Échap retour"
);
exercise_panel(
summary_top,
2,
summary_bottom,
COLS - 3,
"MAX MESURE"
);
exercise_panel(
graph_top,
2,
graph_bottom,
COLS - 3,
"EVOLUTION"
);
if (
history_bottom >
history_top + 1
) {
exercise_panel(
history_top,
2,
history_bottom,
COLS - 3,
"HISTORIQUE"
);
}
}
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvprintw(
summary_top + 1,
5,
"%s",
exercise->name
);
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvprintw(
summary_top + 2,
5,
"Tests max : %zu · réussis : %zu",
summary.test_count,
summary.successful_test_count
);
if (!summary.found) {
mvprintw(
summary_top + 3,
5,
"Aucun max mesuré réussi."
);
mvprintw(
summary_top + 4,
5,
"Seules les séances explicitement « Test de max » comptent."
);
} else {
char current_text[128];
char record_text[128];
char current_date[11];
char record_date[11];
exercise_format_performance(
&summary.current,
current_text,
sizeof(current_text)
);
exercise_format_performance(
&summary.record,
record_text,
sizeof(record_text)
);
exercise_short_date(
summary.current.started_at,
current_date
);
exercise_short_date(
summary.record.started_at,
record_date
);
mvprintw(
summary_top + 3,
5,
"Actuel : %s · %.*s",
current_date,
COLS - 32,
current_text
);
mvprintw(
summary_top + 4,
5,
"Record même mode : %s · %.*s",
record_date,
COLS - 42,
record_text
);
if (
summary.current.load_mode ==
TRAINLOG_LOAD_EXTERNAL
) {
double working[4];
bool valid = true;
for (
index = 0U;
index < 4U;
++index
) {
if (
trainlog_measured_max_working_load(
&summary.current,
percentages[index],
increments[increment_index],
&working[index]
) != TRAINLOG_STATUS_OK
) {
valid = false;
break;
}
}
if (valid) {
mvprintw(
summary_top + 5,
5,
"Travail : 60%% %.1f · 70%% %.1f · 80%% %.1f · 90%% %.1f kg",
working[0],
working[1],
working[2],
working[3]
);
mvprintw(
summary_top + 6,
5,
"Arrondi : %.1f kg (r pour changer) · aucun 1RM estimé",
increments[increment_index]
);
}
} else if (
summary.current.load_mode ==
TRAINLOG_LOAD_ASSISTANCE
) {
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_WARNING
)
);
mvprintw(
summary_top + 5,
5,
"Assistance : moins de kg = mieux."
);
mvprintw(
summary_top + 6,
5,
"Pourcentages de charge non applicables à l'assistance."
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_WARNING
)
);
} else {
mvprintw(
summary_top + 5,
5,
"Sans charge externe : pourcentages non applicables."
);
mvprintw(
summary_top + 6,
5,
"Le max reste une valeur réellement réalisée, jamais estimée."
);
}
}
if (
summary.found &&
max_count > 0U
) {
int graph_content_top =
graph_top + 1;
int graph_height =
graph_bottom -
graph_top -
2;
draw_exercise_performance_graph(
max_points,
max_count,
summary.current.load_mode,
exercise->tracking_mode,
graph_content_top,
graph_height,
true
);
} else {
mvprintw(
graph_top + 2,
5,
"Aucun point de max mesuré à tracer."
);
}
if (
history_bottom >
history_top + 1
) {
int first_row =
history_top + 1;
history_limit =
history_bottom >
first_row
? (size_t)(
history_bottom -
first_row
)
: 0U;
for (
index = 0U;
index < max_count &&
index < history_limit;
++index
) {
char date[11];
char text[128];
exercise_short_date(
max_points[index].started_at,
date
);
exercise_format_performance(
&max_points[index],
text,
sizeof(text)
);
mvprintw(
first_row +
(int)index,
5,
"%s %-13s %.*s",
date,
exercise_load_mode_label(
max_points[index]
.load_mode
),
COLS - 40,
text
);
}
}
refresh();
key = getch();
if (
key == 'b' ||
key == 'B' ||
key == 27 ||
key == '\n' ||
key == KEY_ENTER
) {
return;
}
if (
key == 'r' ||
key == 'R'
) {
increment_index =
(
increment_index + 1U
) %
(
sizeof(increments) /
sizeof(increments[0])
);
}
}
}
/* TRAINLOG_SECTION_ASCII_HEADER */
static void section_ascii_header(
const char *subtitle
)
{
static const char *const logo[] = {
"TTTTT RRRR AAA IIIII N N L OOO GGG ",
" T R R A A I NN N L O O G ",
" T RRRR AAAAA I N N N L O O G GG",
" T R R A A I N NN L O O G G",
" T R R A A IIIII N N LLLLL OOO GGG "
};
const size_t line_count =
sizeof(logo) /
sizeof(logo[0]);
size_t index;
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
for (index = 0U;
index < line_count;
++index) {
int width =
(int)strlen(logo[index]);
int column =
(COLS - width) / 2;
if (column < 2) {
column = 2;
}
mvprintw(
1 + (int)index,
column,
"%.*s",
COLS - column - 2,
logo[index]
);
}
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
if (subtitle != NULL &&
subtitle[0] != '\0') {
int width =
(int)strlen(subtitle);
int column =
(COLS - width) / 2;
if (column < 2) {
column = 2;
}
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
mvprintw(
6,
column,
"%s",
subtitle
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
}
}
static void section_scrollbar(
int top,
int bottom,
int column,
size_t selected,
size_t count,
size_t visible
)
{
int track_height;
int row;
int thumb;
if (bottom <= top ||
count <= visible ||
count <= 1U) {
return;
}
track_height =
bottom - top + 1;
if (track_height < 2) {
return;
}
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
for (row = top;
row <= bottom;
++row) {
mvaddch(
row,
column,
ACS_VLINE
);
}
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
thumb =
top +
(int)(
(selected *
(size_t)(track_height - 1)) /
(count - 1U)
);
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvaddch(
thumb,
column,
ACS_CKBOARD
);
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
static void screen_exercises(
TrainlogDatabase *database
)
{
TrainlogExercise exercises[MAX_EXERCISES];
size_t selected = 0U;
int nav_selected = 3;
int focus = 1;
for (;;) {
size_t count = 0U;
size_t top = 0U;
size_t index;
bool large_layout =
COLS >= 100 &&
LINES >= 30;
bool framed =
COLS >= 90 &&
LINES >= 24;
int list_top =
large_layout
? 11
: 3;
int list_bottom =
LINES - 4;
int first_row =
list_top + 1;
int visible_rows =
framed
? list_bottom -
first_row
: LINES - 7;
int key;
if (visible_rows < 1) {
return;
}
if (trainlog_database_list_exercises(
database,
exercises,
MAX_EXERCISES,
&count
) != TRAINLOG_STATUS_OK) {
return;
}
if (count > 0U &&
selected >= count) {
selected =
count - 1U;
}
if (count > 0U &&
selected >=
(size_t)visible_rows) {
top =
selected -
(size_t)visible_rows +
1U;
}
if (large_layout) {
erase();
box(stdscr, 0, 0);
section_ascii_header(
":: E X E R C I C E S ::"
);
primary_top_navbar(
3,
nav_selected,
focus == 0
);
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
mvprintw(
LINES - 2,
2,
"%.*s",
COLS - 4,
"Tab zone ↑↓/PgUp/PgDn catalogue ←→ menu Entrée ouvrir m max mesuré a ajouter 0/Home accueil F1-F4 direct b/Échap retour"
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
} else {
draw_shell(
"TRAINLOG — Exercices",
"↑↓ naviguer Entrée performance m max mesuré a ajouter b/Échap retour"
);
}
if (framed) {
if (large_layout) {
focused_panel(
list_top,
2,
list_bottom,
COLS - 3,
"CATALOGUE",
focus == 1
);
} else {
exercise_panel(
list_top,
2,
list_bottom,
COLS - 3,
"CATALOGUE"
);
}
}
if (count == 0U) {
mvprintw(
first_row + 1,
framed ? 5 : 4,
"Aucun exercice."
);
}
for (index = 0U;
index < (size_t)visible_rows &&
top + index < count;
++index) {
size_t absolute =
top + index;
int item_row =
first_row +
(int)index;
int item_col =
framed ? 5 : 4;
if (focus == 1 &&
absolute == selected) {
attron(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
mvprintw(
item_row,
item_col,
" %-42s [%s] ",
exercises[absolute].name,
exercises[absolute].tracking_mode ==
TRAINLOG_TRACKING_REPS
? "reps"
: "durée"
);
if (focus == 1 &&
absolute == selected) {
attroff(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
}
if (large_layout) {
section_scrollbar(
first_row,
list_bottom - 1,
COLS - 5,
selected,
count,
(size_t)visible_rows
);
}
refresh();
key = getch();
if (large_layout &&
(key == '\t' ||
key == KEY_BTAB)) {
focus =
focus == 0
? 1
: 0;
continue;
}
if (large_layout &&
primary_top_nav_forward(key)) {
return;
}
if (key == 'b' ||
key == 'B' ||
key == 27) {
return;
}
if (large_layout &&
focus == 0) {
if (key == KEY_LEFT) {
nav_selected =
nav_selected > 0
? nav_selected - 1
: 5;
} else if (key == KEY_RIGHT) {
nav_selected =
nav_selected < 5
? nav_selected + 1
: 0;
} else if (
key == '\n' ||
key == KEY_ENTER
) {
if (primary_top_nav_activate(
nav_selected
)) {
return;
}
}
continue;
}
if (count > 0U &&
key == KEY_UP) {
selected =
selected > 0U
? selected - 1U
: 0U;
continue;
}
if (count > 0U &&
key == KEY_DOWN) {
selected =
selected + 1U < count
? selected + 1U
: count - 1U;
continue;
}
if (count > 0U &&
key == KEY_PPAGE) {
size_t jump =
(size_t)visible_rows;
selected =
selected > jump
? selected - jump
: 0U;
continue;
}
if (count > 0U &&
key == KEY_NPAGE) {
size_t jump =
(size_t)visible_rows;
selected =
selected + jump < count
? selected + jump
: count - 1U;
continue;
}
if (count > 0U &&
(key == 'm' ||
key == 'M')) {
screen_exercise_measured_max(
database,
&exercises[selected]
);
continue;
}
if (count > 0U &&
(key == '\n' ||
key == KEY_ENTER)) {
screen_exercise_performance(
database,
&exercises[selected]
);
continue;
}
if (key == 'a' ||
key == 'A') {
char name[TRAINLOG_NAME_MAX + 1U];
int mode = 1;
int organization = 1;
int wants_speed = 0;
int wants_distance = 0;
TrainlogExerciseDataFields data_fields = 0U;
TrainlogExercise created;
TrainlogStatus status;
draw_shell(
"Nouvel exercice",
"Entrée valide chaque champ · Échap annule"
);
if (!prompt_text(
4,
"Nom : ",
name,
sizeof(name),
false
)) {
continue;
}
if (!prompt_int_value(
5,
"Mode 1=reps 2=durée",
1,
2,
1,
&mode
)) {
continue;
}
if (!prompt_int_value(
8,
"Organisation (1 séries, 2 continu)",
1,
2,
1,
&organization
)) {
continue;
}
if (organization == 2) {
mode = 2;
if (!prompt_int_value(
10,
"Mesurer la vitesse km/h ? (0 non, 1 oui)",
0,
1,
1,
&wants_speed
)) {
continue;
}
if (!prompt_int_value(
12,
"Mesurer la distance km ? (0 non, 1 oui)",
0,
1,
0,
&wants_distance
)) {
continue;
}
if (wants_speed != 0) {
data_fields |=
TRAINLOG_EXERCISE_DATA_SPEED_KMH;
}
if (wants_distance != 0) {
data_fields |=
TRAINLOG_EXERCISE_DATA_DISTANCE_KM;
}
}
status =
trainlog_catalog_create_exercise_profiled(
database,
name,
mode == 1
? TRAINLOG_TRACKING_REPS
: TRAINLOG_TRACKING_DURATION,
organization == 2
? TRAINLOG_RECORDING_CONTINUOUS
: TRAINLOG_RECORDING_SETS,
data_fields,
&created
);
if (status ==
TRAINLOG_STATUS_OK) {
status_line(
"✓ Exercice ajouté.",
TRAINLOG_COLOR_SUCCESS
);
} else if (
status ==
TRAINLOG_STATUS_CONFLICT
) {
status_line(
"Doublon détecté.",
TRAINLOG_COLOR_WARNING
);
} else {
status_line(
"Impossible d'ajouter l'exercice.",
TRAINLOG_COLOR_ERROR
);
}
wait_key();
}
}
}
static bool create_exercise_inline(
TrainlogDatabase *database
)
{
char name[TRAINLOG_NAME_MAX + 1U];
int mode = 1;
int organization = 1;
int wants_speed = 0;
int wants_distance = 0;
TrainlogExerciseDataFields data_fields = 0U;
TrainlogExercise created;
TrainlogStatus status;
draw_shell(
"Nouvel exercice",
"Entrée valide chaque champ · Échap annule"
);
if (!prompt_text(
4,
"Nom : ",
name,
sizeof(name),
false
)) {
return false;
}
if (!prompt_int_value(
5,
"Mode 1=reps 2=durée",
1,
2,
1,
&mode
)) {
return false;
}
if (!prompt_int_value(
8,
"Organisation (1 séries, 2 continu)",
1,
2,
1,
&organization
)) {
return false;
}
if (organization == 2) {
mode = 2;
if (!prompt_int_value(
10,
"Mesurer la vitesse km/h ? (0 non, 1 oui)",
0,
1,
1,
&wants_speed
)) {
return false;
}
if (!prompt_int_value(
12,
"Mesurer la distance km ? (0 non, 1 oui)",
0,
1,
0,
&wants_distance
)) {
return false;
}
if (wants_speed != 0) {
data_fields |=
TRAINLOG_EXERCISE_DATA_SPEED_KMH;
}
if (wants_distance != 0) {
data_fields |=
TRAINLOG_EXERCISE_DATA_DISTANCE_KM;
}
}
status =
trainlog_catalog_create_exercise_profiled(
database,
name,
mode == 1
? TRAINLOG_TRACKING_REPS
: TRAINLOG_TRACKING_DURATION,
organization == 2
? TRAINLOG_RECORDING_CONTINUOUS
: TRAINLOG_RECORDING_SETS,
data_fields,
&created
);
if (status == TRAINLOG_STATUS_OK) {
status_line(
"✓ Exercice ajouté au catalogue.",
TRAINLOG_COLOR_SUCCESS
);
} else if (
status == TRAINLOG_STATUS_CONFLICT
) {
status_line(
"Doublon détecté.",
TRAINLOG_COLOR_WARNING
);
} else {
status_line(
"Impossible d'ajouter l'exercice.",
TRAINLOG_COLOR_ERROR
);
}
wait_key();
return status == TRAINLOG_STATUS_OK;
}
static bool choose_exercise(
TrainlogDatabase *database,
TrainlogExercise *output
)
{
TrainlogExercise exercises[MAX_EXERCISES];
size_t selected = 0U;
for (;;) {
size_t count = 0U;
size_t top = 0U;
size_t index;
bool large_layout =
COLS >= 100 &&
LINES >= 30;
int list_top =
large_layout ? 8 : 3;
int list_bottom =
LINES - 4;
int first_row =
list_top + 1;
int visible_rows =
list_bottom - first_row;
int key;
if (visible_rows < 1) {
return false;
}
if (trainlog_database_list_exercises(
database,
exercises,
MAX_EXERCISES,
&count
) != TRAINLOG_STATUS_OK) {
return false;
}
if (count == 0U) {
draw_shell(
"Choisir un exercice",
"a ajouter un exercice · Échap annuler"
);
mvprintw(
4,
4,
"Aucun exercice disponible."
);
refresh();
key = getch();
if (key == 'a' ||
key == 'A') {
(void)create_exercise_inline(
database
);
continue;
}
if (key == 27) {
return false;
}
continue;
}
if (selected >= count) {
selected =
count - 1U;
}
if (selected >=
(size_t)visible_rows) {
top =
selected -
(size_t)visible_rows +
1U;
}
if (large_layout) {
erase();
box(stdscr, 0, 0);
section_ascii_header(
":: C H O I S I R E X E R C I C E ::"
);
focused_panel(
list_top,
2,
list_bottom,
COLS - 3,
"CATALOGUE",
true
);
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
mvprintw(
LINES - 2,
2,
"%.*s",
COLS - 4,
"↑↓ choisir Entrée sélectionner a créer un exercice Échap annuler"
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
} else {
draw_shell(
"Choisir un exercice",
"↑↓ naviguer Entrée choisir a ajouter Échap annuler"
);
}
for (index = 0U;
index < (size_t)visible_rows &&
top + index < count;
++index) {
size_t absolute =
top + index;
int item_row =
first_row +
(int)index;
if (absolute == selected) {
attron(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
mvprintw(
item_row,
large_layout ? 5 : 4,
" %-42s [%s] ",
exercises[absolute].name,
exercises[absolute].tracking_mode ==
TRAINLOG_TRACKING_REPS
? "reps"
: "durée"
);
if (absolute == selected) {
attroff(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
}
if (large_layout) {
section_scrollbar(
first_row,
list_bottom - 1,
COLS - 5,
selected,
count,
(size_t)visible_rows
);
}
refresh();
key = getch();
if (key == KEY_UP) {
selected =
selected > 0U
? selected - 1U
: count - 1U;
} else if (key == KEY_DOWN) {
selected =
selected + 1U < count
? selected + 1U
: 0U;
} else if (
key == '\n' ||
key == KEY_ENTER
) {
*output =
exercises[selected];
return true;
} else if (
key == 'a' ||
key == 'A'
) {
(void)create_exercise_inline(
database
);
} else if (key == 27) {
return false;
}
}
}
/* 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 bool prompt_duration_value(
int row,
const char *label,
int minimum_seconds,
int maximum_seconds,
int default_seconds,
int *output_seconds
)
{
char buffer[64];
char default_text[64];
char decorated[192];
if (trainlog_duration_format(
default_seconds,
default_text,
sizeof(default_text)
) != TRAINLOG_STATUS_OK) {
return false;
}
for (;;) {
int parsed;
(void)snprintf(
decorated,
sizeof(decorated),
"%s [%s]: ",
label,
default_text
);
if (!prompt_text(
row,
decorated,
buffer,
sizeof(buffer),
true
)) {
return false;
}
if (buffer[0] == '\0') {
*output_seconds = default_seconds;
return true;
}
if (trainlog_duration_parse(buffer, &parsed) ==
TRAINLOG_STATUS_OK &&
parsed >= minimum_seconds &&
parsed <= maximum_seconds) {
*output_seconds = parsed;
return true;
}
status_line(
"Durée invalide : 90, 90s, 1:30, 1m30, 2m.",
TRAINLOG_COLOR_ERROR
);
refresh();
}
}
static void draw_body_metric_graph(
int row,
int height,
const TrainlogBodyMetricPoint *points,
size_t count,
const char *unit
)
{
double minimum;
double maximum;
size_t start;
size_t index;
int width;
if (count == 0U) {
mvprintw(row, 4, "Aucune donnée pour cette mesure.");
return;
}
width = COLS - 14;
if (width < 10 || height < 3) {
return;
}
start = count > (size_t)width
? count - (size_t)width
: 0U;
minimum = points[start].value;
maximum = points[start].value;
for (index = start + 1U; index < count; ++index) {
if (points[index].value < minimum) {
minimum = points[index].value;
}
if (points[index].value > maximum) {
maximum = points[index].value;
}
}
if (maximum == minimum) {
int graph_row = row + (height / 2);
mvprintw(graph_row, 2, "%.1f %s", minimum, unit);
attron(trainlog_theme_attribute(TRAINLOG_COLOR_GRAPH));
for (index = start; index < count; ++index) {
int x = 12 + (int)(index - start);
if (x < COLS - 2) {
mvaddch(graph_row, x, (chtype)'*');
}
}
attroff(trainlog_theme_attribute(TRAINLOG_COLOR_GRAPH));
return;
}
mvprintw(row, 2, "%.1f", maximum);
mvprintw(row + height - 1, 2, "%.1f", minimum);
attron(trainlog_theme_attribute(TRAINLOG_COLOR_GRAPH));
for (index = start; index < count; ++index) {
double ratio =
(points[index].value - minimum) /
(maximum - minimum);
int y = row + height - 1 -
(int)(ratio * (double)(height - 1));
int x = 10 + (int)(index - start);
if (y < row) {
y = row;
}
if (y > row + height - 1) {
y = row + height - 1;
}
if (x < COLS - 2) {
mvaddch(y, x, (chtype)'*');
}
}
attroff(trainlog_theme_attribute(TRAINLOG_COLOR_GRAPH));
}
static void add_body_observation(TrainlogDatabase *database)
{
TrainlogBodyObservationInput observation;
char id[TRAINLOG_GENERATED_ID_CAPACITY];
char timestamp[TRAINLOG_TIMESTAMP_MAX + 1U];
int row;
(void)memset(&observation, 0, sizeof(observation));
if (trainlog_id_generate("bo", id, sizeof(id)) != TRAINLOG_STATUS_OK ||
trainlog_time_now_rfc3339(
timestamp,
sizeof(timestamp)
) != TRAINLOG_STATUS_OK) {
return;
}
(void)snprintf(
observation.observation_id,
sizeof(observation.observation_id),
"%s",
id
);
(void)snprintf(
observation.observed_at,
sizeof(observation.observed_at),
"%s",
timestamp
);
draw_shell(
"Nouvelle mesure — 1/2",
"Entrée valider · Échap annuler · vide = mesure non faite"
);
row = 4;
#define BODY_PROMPT(label_, flag_, value_) \
do { \
if (!prompt_optional_double( \
row++, \
(label_), \
&(flag_), \
&(value_) \
)) { \
status_line( \
"Saisie annulée : aucune mesure enregistrée.", \
TRAINLOG_COLOR_MUTED \
); \
wait_key(); \
return; \
} \
} while (0)
BODY_PROMPT(
"Poids kg : ",
observation.has_body_weight,
observation.body_weight_kg
);
BODY_PROMPT(
"Cou cm : ",
observation.has_neck,
observation.neck_cm
);
BODY_PROMPT(
"Épaules cm : ",
observation.has_shoulders,
observation.shoulders_cm
);
BODY_PROMPT(
"Poitrine cm : ",
observation.has_chest,
observation.chest_cm
);
BODY_PROMPT(
"Tour de taille cm : ",
observation.has_waist,
observation.waist_cm
);
BODY_PROMPT(
"Hanches cm : ",
observation.has_hips,
observation.hips_cm
);
draw_shell(
"Nouvelle mesure — 2/2",
"Entrée valider · Échap annuler · vide = mesure non faite"
);
row = 4;
BODY_PROMPT(
"Bras gauche cm : ",
observation.has_left_arm,
observation.left_arm_cm
);
BODY_PROMPT(
"Bras droit cm : ",
observation.has_right_arm,
observation.right_arm_cm
);
BODY_PROMPT(
"Avant-bras gauche cm : ",
observation.has_left_forearm,
observation.left_forearm_cm
);
BODY_PROMPT(
"Avant-bras droit cm : ",
observation.has_right_forearm,
observation.right_forearm_cm
);
BODY_PROMPT(
"Cuisse gauche cm : ",
observation.has_left_thigh,
observation.left_thigh_cm
);
BODY_PROMPT(
"Cuisse droite cm : ",
observation.has_right_thigh,
observation.right_thigh_cm
);
BODY_PROMPT(
"Mollet gauche cm : ",
observation.has_left_calf,
observation.left_calf_cm
);
BODY_PROMPT(
"Mollet droit cm : ",
observation.has_right_calf,
observation.right_calf_cm
);
#undef BODY_PROMPT
if (trainlog_database_insert_body_observation(
database,
&observation
) == TRAINLOG_STATUS_OK) {
status_line(
"✓ Mesures enregistrées.",
TRAINLOG_COLOR_SUCCESS
);
} else {
status_line(
"Aucune mesure valide enregistrée.",
TRAINLOG_COLOR_WARNING
);
}
wait_key();
}
/* 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;
char symbol;
TrainlogColorRole role;
} TrainlogGlobalBodySeries;
static const char GLOBAL_BODY_SYMBOLS[] = {
'P', 'N', 'E', 'C', 'T', 'H', 'A',
'B', 'F', 'G', 'Q', 'R', 'M', 'D'
};
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 void global_plot_point(
int row,
int column,
char symbol,
TrainlogColorRole role
)
{
chtype current;
chtype character;
if (row < 0 ||
row >= LINES ||
column < 0 ||
column >= COLS) {
return;
}
current =
mvinch(row, column) &
A_CHARTEXT;
character =
(chtype)(unsigned char)symbol;
if (current != (chtype)' ' &&
current != (chtype)'.' &&
current != character) {
character = (chtype)'#';
}
attron(trainlog_theme_attribute(role));
mvaddch(row, column, character);
attroff(trainlog_theme_attribute(role));
}
static void global_plot_segment(
int x1,
int y1,
int x2,
int y2,
char symbol,
TrainlogColorRole role
)
{
int x;
if (x2 < x1) {
return;
}
if (x1 == x2) {
global_plot_point(
y2,
x2,
symbol,
role
);
return;
}
for (x = x1; x <= x2; ++x) {
int y =
y1 +
(((y2 - y1) * (x - x1)) /
(x2 - x1));
global_plot_point(
y,
x,
symbol,
role
);
}
}
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;
}
static void draw_global_body_overlay(
TrainlogDatabase *database
)
{
static TrainlogGlobalBodySeries series[14];
static char dates[MAX_GLOBAL_BODY_DATES]
[TRAINLOG_TIMESTAMP_MAX + 1U];
const size_t metric_count =
sizeof(BODY_METRICS) /
sizeof(BODY_METRICS[0]);
size_t date_count = 0U;
size_t metric_index;
double minimum = 100.0;
double maximum = 100.0;
const int graph_top = 5;
const int graph_height = 5;
const int graph_left = 8;
int graph_width =
COLS - graph_left - 3;
(void)memset(
series,
0,
sizeof(series)
);
(void)memset(
dates,
0,
sizeof(dates)
);
for (metric_index = 0U;
metric_index < metric_count;
++metric_index) {
TrainlogGlobalBodySeries *item =
&series[metric_index];
size_t point_index;
item->symbol =
GLOBAL_BODY_SYMBOLS[metric_index];
item->role =
GLOBAL_BODY_ROLES[metric_index];
if (trainlog_database_list_body_metric_points(
database,
BODY_METRICS[metric_index].metric,
item->points,
MAX_BODY_METRIC_POINTS,
&item->count
) != TRAINLOG_STATUS_OK ||
item->count == 0U) {
continue;
}
item->baseline =
item->points[0].value;
(void)trainlog_body_percent_change(
item->baseline,
item->points[item->count - 1U].value,
&item->latest_percent
);
for (point_index = 0U;
point_index < item->count;
++point_index) {
double normalized = 100.0;
(void)global_date_add(
dates,
&date_count,
item->points[point_index].observed_at
);
if (trainlog_body_index100(
item->baseline,
item->points[point_index].value,
&normalized
) == TRAINLOG_STATUS_OK) {
if (normalized < minimum) {
minimum = normalized;
}
if (normalized > maximum) {
maximum = normalized;
}
}
}
}
if (date_count == 0U) {
mvprintw(
4,
4,
"Aucune mensuration disponible pour la vue globale."
);
return;
}
if (graph_width < 10) {
return;
}
qsort(
dates,
date_count,
sizeof(dates[0]),
global_date_compare
);
mvprintw(
3,
4,
"Vue globale — première mesure de chaque série = 100"
);
if (minimum == maximum) {
minimum = 99.0;
maximum = 101.0;
}
mvprintw(
graph_top,
2,
"%.1f",
maximum
);
mvprintw(
graph_top + graph_height - 1,
2,
"%.1f",
minimum
);
if (100.0 >= minimum &&
100.0 <= maximum) {
int baseline_row =
normalized_graph_row(
100.0,
minimum,
maximum,
graph_top,
graph_height
);
int column;
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
for (column = graph_left;
column <
graph_left + graph_width;
++column) {
mvaddch(
baseline_row,
column,
(chtype)'.'
);
}
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
}
for (metric_index = 0U;
metric_index < metric_count;
++metric_index) {
TrainlogGlobalBodySeries *item =
&series[metric_index];
size_t point_index;
int previous_x = -1;
int previous_y = -1;
if (item->count == 0U) {
continue;
}
for (point_index = 0U;
point_index < item->count;
++point_index) {
size_t date_index =
global_date_index(
dates,
date_count,
item->points[point_index].observed_at
);
double normalized = 100.0;
int x;
int y;
if (trainlog_body_index100(
item->baseline,
item->points[point_index].value,
&normalized
) != TRAINLOG_STATUS_OK) {
continue;
}
if (date_count == 1U) {
x =
graph_left +
(graph_width / 2);
} else {
x =
graph_left +
(int)(
(date_index *
(size_t)(graph_width - 1)) /
(date_count - 1U)
);
}
y =
normalized_graph_row(
normalized,
minimum,
maximum,
graph_top,
graph_height
);
if (previous_x >= 0 &&
previous_y >= 0) {
global_plot_segment(
previous_x,
previous_y,
x,
y,
item->symbol,
item->role
);
} else {
global_plot_point(
y,
x,
item->symbol,
item->role
);
}
previous_x = x;
previous_y = y;
}
}
{
size_t row_index = 0U;
for (metric_index = 0U;
metric_index < metric_count;
++metric_index) {
TrainlogGlobalBodySeries *item =
&series[metric_index];
int column;
int row;
if (item->count == 0U) {
continue;
}
row =
11 +
(int)(row_index % 7U);
column =
row_index < 7U
? 4
: (COLS / 2);
attron(
trainlog_theme_attribute(
item->role
)
);
mvprintw(
row,
column,
"%c %-16s %+.1f%%",
item->symbol,
BODY_METRICS[metric_index].label,
item->latest_percent
);
attroff(
trainlog_theme_attribute(
item->role
)
);
++row_index;
}
}
}
/* 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;
}
static int dashboard_month_x(
size_t month_index,
int graph_left,
int graph_width
)
{
if (DASHBOARD_MONTH_COUNT <= 1U) {
return graph_left;
}
return graph_left +
(int)(
(month_index *
(size_t)(graph_width - 1)) /
(DASHBOARD_MONTH_COUNT - 1U)
);
}
static void dashboard_draw_month_axis(
const TrainlogDashboardMonth months[DASHBOARD_MONTH_COUNT],
int row,
int graph_left,
int graph_width
)
{
size_t index;
int spacing =
graph_width /
(int)(DASHBOARD_MONTH_COUNT - 1U);
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
for (index = 0U;
index < DASHBOARD_MONTH_COUNT;
++index) {
int x =
dashboard_month_x(
index,
graph_left,
graph_width
);
if (spacing >= 6) {
char label[8];
(void)snprintf(
label,
sizeof(label),
"%02d/%02d",
months[index].month,
months[index].year % 100
);
/*
* Center the month label on its slot, then clamp it inside
* the terminal so the first and last visible months are not
* silently lost at the borders.
*/
x -= 2;
if (x < 2) {
x = 2;
}
if (x > COLS - 8) {
x = COLS - 8;
}
mvprintw(
row,
x,
"%s",
label
);
} else {
if (x + 2 < COLS - 1) {
mvprintw(
row,
x,
"%02d",
months[index].month
);
}
}
}
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
}
/* TRAINLOG_DASHBOARD_ASCII_PANELS */
static void dashboard_panel(
int top,
int left,
int bottom,
int right,
const char *label
)
{
WINDOW *panel;
int height;
int width;
if (top < 0 ||
left < 0 ||
bottom <= top ||
right <= left ||
bottom >= LINES ||
right >= COLS) {
return;
}
height = bottom - top + 1;
width = right - left + 1;
panel = derwin(
stdscr,
height,
width,
top,
left
);
if (panel == NULL) {
return;
}
box(panel, 0, 0);
if (label != NULL &&
label[0] != '\0' &&
width > 8) {
wattron(
panel,
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvwprintw(
panel,
0,
2,
" %.*s ",
width - 6,
label
);
wattroff(
panel,
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
/*
* derwin() shares the parent screen storage. syncok()+wsyncup() makes
* the panel border part of stdscr, so later dashboard content and one
* final refresh() compose cleanly.
*/
syncok(panel, TRUE);
wsyncup(panel);
delwin(panel);
}
/* TRAINLOG_PRIMARY_TOP_NAVIGATION */
/* TRAINLOG_FOCUSED_PANELS */
static void focused_panel(
int top,
int left,
int bottom,
int right,
const char *label,
bool active
)
{
WINDOW *panel;
int height;
int width;
chtype border_attribute;
if (top < 0 ||
left < 0 ||
bottom <= top ||
right <= left ||
bottom >= LINES ||
right >= COLS) {
return;
}
height = bottom - top + 1;
width = right - left + 1;
panel = derwin(
stdscr,
height,
width,
top,
left
);
if (panel == NULL) {
return;
}
border_attribute =
active
? A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_WARNING
)
: trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
);
wattron(
panel,
border_attribute
);
box(panel, 0, 0);
if (label != NULL &&
label[0] != '\0' &&
width > 8) {
mvwprintw(
panel,
0,
2,
" %.*s ",
width - 6,
label
);
}
wattroff(
panel,
border_attribute
);
syncok(panel, TRUE);
wsyncup(panel);
delwin(panel);
}
static void primary_top_navbar(
int active_page,
int selected_page,
bool focused
)
{
static const char *const labels[] = {
"0 Accueil",
"1 Séance",
"2 Historique",
"3 Exercices",
"4 Corps",
"5 Sync"
};
int column = 5;
int index;
focused_panel(
8,
2,
10,
COLS - 3,
"NAVIGATION",
focused
);
for (index = 0;
index < 6;
++index) {
int width =
(int)strlen(labels[index]) + 4;
if (index == selected_page) {
attron(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
} else if (index == active_page) {
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
mvprintw(
9,
column,
" %s ",
labels[index]
);
if (index == selected_page) {
attroff(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
} else if (index == active_page) {
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
column += width + 2;
}
}
static bool primary_top_nav_activate(
int selected_page
)
{
if (selected_page < 0 ||
selected_page > 5) {
return false;
}
if (selected_page == 0) {
return true;
}
return ungetch(
'0' + selected_page
) != ERR;
}
/*
* Primary screens return to the main dispatcher. Requeueing the numeric
* shortcut lets the dashboard consume it immediately, so cross-page
* navigation does not recursively nest TUI screens.
*/
static bool primary_top_nav_forward(
int key
)
{
int forwarded = 0;
switch (key) {
case '0':
case KEY_HOME:
return true;
case KEY_F(1):
case '1':
forwarded = '1';
break;
case KEY_F(2):
case '2':
forwarded = '2';
break;
case KEY_F(3):
case '3':
forwarded = '3';
break;
case KEY_F(4):
case '4':
forwarded = '4';
break;
case KEY_F(5):
case '5':
forwarded = '5';
break;
default:
return false;
}
return ungetch(forwarded) != ERR;
}
static void dashboard_ascii_header(void)
{
static const char *const logo[] = {
"TTTTT RRRR AAA IIIII N N L OOO GGG ",
" T R R A A I NN N L O O G ",
" T RRRR AAAAA I N N N L O O G GG",
" T R R A A I N NN L O O G G",
" T R R A A IIIII N N LLLLL OOO GGG "
};
const size_t line_count =
sizeof(logo) /
sizeof(logo[0]);
size_t index;
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
for (index = 0U;
index < line_count;
++index) {
int width =
(int)strlen(logo[index]);
int column =
(COLS - width) / 2;
if (column < 2) {
column = 2;
}
mvprintw(
1 + (int)index,
column,
"%.*s",
COLS - column - 2,
logo[index]
);
}
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
{
const char *label =
":: D A S H B O A R D ::";
int width =
(int)strlen(label);
int column =
(COLS - width) / 2;
if (column < 2) {
column = 2;
}
mvprintw(
6,
column,
"%s",
label
);
}
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
}
static void draw_dashboard_body_graph(
TrainlogDatabase *database
)
{
static TrainlogGlobalBodySeries series[14];
static TrainlogDashboardMonthValue
monthly[14][DASHBOARD_MONTH_COUNT];
TrainlogDashboardMonth
months[DASHBOARD_MONTH_COUNT];
const size_t metric_count =
sizeof(BODY_METRICS) /
sizeof(BODY_METRICS[0]);
bool large_layout =
COLS >= 100 &&
LINES >= 30;
int graph_panel_top =
large_layout ? 11 : 2;
int graph_panel_bottom =
large_layout ? 20 : 9;
int graph_top =
graph_panel_top + 1;
int graph_height =
graph_panel_bottom -
graph_panel_top -
3;
int graph_left =
large_layout ? 10 : 8;
int graph_right =
COLS - 5;
int graph_width =
graph_right -
graph_left +
1;
int axis_row =
graph_panel_bottom - 1;
int legend_panel_top =
large_layout
? graph_panel_bottom + 1
: 10;
int legend_rows =
large_layout ? 5 : 6;
int legend_panel_bottom =
legend_panel_top +
legend_rows +
1;
int legend_top =
legend_panel_top + 1;
size_t metric_index;
size_t plotted_series = 0U;
double minimum = 0.0;
double maximum = 0.0;
long current_key;
long first_key;
if (large_layout) {
dashboard_panel(
graph_panel_top,
2,
graph_panel_bottom,
COLS - 3,
"EVOLUTION CORPORELLE - 12 MOIS"
);
dashboard_panel(
legend_panel_top,
2,
legend_panel_bottom,
COLS - 3,
"MESURES"
);
} else {
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvprintw(
1,
3,
"TRAINLOG :: DASHBOARD"
);
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
if (!dashboard_current_month_key(
&current_key
)) {
mvprintw(
graph_top + 1,
5,
"Impossible de déterminer le mois courant."
);
return;
}
first_key =
current_key -
(long)(DASHBOARD_MONTH_COUNT - 1U);
for (metric_index = 0U;
metric_index < DASHBOARD_MONTH_COUNT;
++metric_index) {
dashboard_month_from_key(
first_key + (long)metric_index,
&months[metric_index]
);
}
(void)memset(series, 0, sizeof(series));
(void)memset(monthly, 0, sizeof(monthly));
for (metric_index = 0U;
metric_index < metric_count;
++metric_index) {
TrainlogGlobalBodySeries *item =
&series[metric_index];
size_t point_index;
size_t visible_count = 0U;
size_t first_visible = 0U;
size_t latest_visible = 0U;
bool have_first = false;
item->symbol =
GLOBAL_BODY_SYMBOLS[metric_index];
item->role =
GLOBAL_BODY_ROLES[metric_index];
if (trainlog_database_list_body_metric_points(
database,
BODY_METRICS[metric_index].metric,
item->points,
MAX_BODY_METRIC_POINTS,
&item->count
) != TRAINLOG_STATUS_OK ||
item->count == 0U) {
continue;
}
/*
* The dashboard is monthly: multiple observations in one month keep
* the last actual value, while absent months remain absent.
*/
for (point_index = 0U;
point_index < item->count;
++point_index) {
int year;
int month;
long key;
long offset;
if (!dashboard_parse_year_month(
item->points[point_index].observed_at,
&year,
&month
)) {
continue;
}
key =
dashboard_month_key(
year,
month
);
offset = key - first_key;
if (offset < 0L ||
offset >=
(long)DASHBOARD_MONTH_COUNT) {
continue;
}
monthly[metric_index][(size_t)offset]
.present = true;
monthly[metric_index][(size_t)offset]
.value =
item->points[point_index].value;
}
for (point_index = 0U;
point_index < DASHBOARD_MONTH_COUNT;
++point_index) {
if (!monthly[metric_index][point_index]
.present) {
continue;
}
if (!have_first) {
first_visible = point_index;
have_first = true;
}
latest_visible = point_index;
++visible_count;
}
if (!have_first) {
item->count = 0U;
continue;
}
item->baseline =
monthly[metric_index][first_visible]
.value;
if (trainlog_body_percent_change(
item->baseline,
monthly[metric_index][latest_visible]
.value,
&item->latest_percent
) != TRAINLOG_STATUS_OK) {
item->count = 0U;
continue;
}
item->count = visible_count;
if (visible_count < 2U) {
continue;
}
++plotted_series;
for (point_index = 0U;
point_index < DASHBOARD_MONTH_COUNT;
++point_index) {
double percent = 0.0;
if (!monthly[metric_index][point_index]
.present) {
continue;
}
if (trainlog_body_percent_change(
item->baseline,
monthly[metric_index][point_index]
.value,
&percent
) == TRAINLOG_STATUS_OK) {
if (percent < minimum) {
minimum = percent;
}
if (percent > maximum) {
maximum = percent;
}
}
}
}
if (graph_width < 12 ||
graph_height < 3) {
return;
}
if (plotted_series == 0U) {
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
mvprintw(
graph_top + 1,
graph_left,
"Premières courbes après 2 mois relevés pour une même mesure."
);
mvprintw(
graph_top + 2,
graph_left,
"Un mois sans relevé reste vide."
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
dashboard_draw_month_axis(
months,
axis_row,
graph_left,
graph_width
);
} else {
if (minimum == maximum) {
minimum = -1.0;
maximum = 1.0;
}
mvprintw(
graph_top,
3,
"%+.1f%%",
maximum
);
mvprintw(
graph_top + graph_height - 1,
3,
"%+.1f%%",
minimum
);
{
int zero_row =
normalized_graph_row(
0.0,
minimum,
maximum,
graph_top,
graph_height
);
int column;
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
for (column = graph_left;
column <= graph_right;
++column) {
mvaddch(
zero_row,
column,
(chtype)'.'
);
}
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
}
for (metric_index = 0U;
metric_index < metric_count;
++metric_index) {
TrainlogGlobalBodySeries *item =
&series[metric_index];
size_t month_index;
int previous_x = -1;
int previous_y = -1;
long previous_month = -2L;
if (item->count < 2U) {
continue;
}
for (month_index = 0U;
month_index < DASHBOARD_MONTH_COUNT;
++month_index) {
double percent = 0.0;
int x;
int y;
if (!monthly[metric_index][month_index]
.present) {
previous_x = -1;
previous_y = -1;
previous_month = -2L;
continue;
}
if (trainlog_body_percent_change(
item->baseline,
monthly[metric_index][month_index]
.value,
&percent
) != TRAINLOG_STATUS_OK) {
continue;
}
x =
dashboard_month_x(
month_index,
graph_left,
graph_width
);
y =
normalized_graph_row(
percent,
minimum,
maximum,
graph_top,
graph_height
);
if (previous_x >= 0 &&
previous_y >= 0 &&
previous_month + 1L ==
(long)month_index) {
global_plot_segment(
previous_x,
previous_y,
x,
y,
item->symbol,
item->role
);
} else {
global_plot_point(
y,
x,
item->symbol,
item->role
);
}
previous_x = x;
previous_y = y;
previous_month =
(long)month_index;
}
}
dashboard_draw_month_axis(
months,
axis_row,
graph_left,
graph_width
);
}
{
size_t legend_index = 0U;
int columns =
large_layout ? 3 : 2;
int cell_width =
(COLS - 8) /
columns;
for (metric_index = 0U;
metric_index < metric_count;
++metric_index) {
TrainlogGlobalBodySeries *item =
&series[metric_index];
char label[96];
char evolution[16];
int logical_column;
int logical_row;
int row;
int column;
int label_width;
size_t month_index;
size_t latest_month = 0U;
bool found = false;
double latest = 0.0;
if (item->count == 0U) {
continue;
}
for (month_index = 0U;
month_index < DASHBOARD_MONTH_COUNT;
++month_index) {
if (monthly[metric_index][month_index]
.present) {
latest_month = month_index;
found = true;
}
}
if (!found) {
continue;
}
logical_column =
(int)(
legend_index /
(size_t)legend_rows
);
logical_row =
(int)(
legend_index %
(size_t)legend_rows
);
if (logical_column >= columns) {
break;
}
latest =
monthly[metric_index][latest_month]
.value;
row =
legend_top +
logical_row;
column =
(large_layout ? 4 : 2) +
(logical_column * cell_width);
label_width =
cell_width - 18;
if (label_width < 7) {
label_width = 7;
}
(void)snprintf(
label,
sizeof(label),
"%s (%s)",
BODY_METRICS[metric_index].label,
BODY_METRICS[metric_index].unit
);
if (item->count < 2U) {
(void)snprintf(
evolution,
sizeof(evolution),
"%s",
"réf."
);
} else {
(void)snprintf(
evolution,
sizeof(evolution),
"%+.1f%%",
item->latest_percent
);
}
attron(
trainlog_theme_attribute(
item->role
)
);
mvprintw(
row,
column,
"%c %-*.*s %6.1f",
item->symbol,
label_width,
label_width,
label,
latest
);
mvprintw(
row,
column + cell_width - 7,
"%6s",
evolution
);
attroff(
trainlog_theme_attribute(
item->role
)
);
++legend_index;
}
}
}
static DashboardAction screen_dashboard(
TrainlogDatabase *database
)
{
static const char *const labels[] = {
"0 Accueil",
"1 Séance",
"2 Historique",
"3 Exercices",
"4 Corps",
"5 Sync"
};
static const char *const footer =
"←→ naviguer Entrée ouvrir 0 accueil F1-F4 accès direct q quitter";
int selected = 0;
for (;;) {
bool large_layout =
COLS >= 100 &&
LINES >= 30;
int nav_top =
large_layout ? 8 : LINES - 4;
int nav_bottom =
large_layout ? 10 : LINES - 3;
int key;
int index;
int column;
erase();
box(stdscr, 0, 0);
if (large_layout) {
dashboard_ascii_header();
focused_panel(
nav_top,
2,
nav_bottom,
COLS - 3,
"NAVIGATION",
true
);
}
draw_dashboard_body_graph(database);
column =
large_layout ? 5 : 3;
for (index = 0;
index < 6;
++index) {
int width =
(int)strlen(labels[index]) + 4;
if (index == selected) {
attron(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
mvprintw(
large_layout
? nav_top + 1
: LINES - 3,
column,
" %s ",
labels[index]
);
if (index == selected) {
attroff(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
column += width + 2;
}
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
mvprintw(
LINES - 2,
2,
"%.*s",
COLS - 4,
footer
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
refresh();
key = getch();
switch (key) {
case KEY_UP:
case KEY_LEFT:
selected =
selected > 0
? selected - 1
: 5;
break;
case KEY_DOWN:
case KEY_RIGHT:
selected =
selected < 5
? selected + 1
: 0;
break;
case '\n':
case KEY_ENTER:
switch (selected) {
case 0:
break;
case 1:
return DASHBOARD_NEW_SESSION;
case 2:
return DASHBOARD_HISTORY;
case 3:
return DASHBOARD_EXERCISES;
case 4:
return DASHBOARD_BODY;
case 5:
return DASHBOARD_SYNC;
default:
break;
}
break;
case '0':
case KEY_HOME:
selected = 0;
break;
case KEY_F(1):
case '1':
return DASHBOARD_NEW_SESSION;
case KEY_F(2):
case '2':
return DASHBOARD_HISTORY;
case KEY_F(3):
case '3':
return DASHBOARD_EXERCISES;
case KEY_F(4):
case '4':
return DASHBOARD_BODY;
case KEY_F(5):
case '5':
return DASHBOARD_SYNC;
case 'q':
case 'Q':
return DASHBOARD_QUIT;
default:
break;
}
}
}
static bool build_session_exercise(
TrainlogDatabase *database,
TrainlogSessionExerciseInput *output,
TrainlogSetInput *set_storage,
size_t set_capacity
)
{
TrainlogExercise exercise;
int load_mode = 1;
int target_sets = 3;
int target_metric;
int rest_seconds = 60;
int actual_sets = 0;
int rep_values[MAX_SETS_PER_EXERCISE];
size_t rep_count = 0U;
char rep_sequence[512];
bool target_has_weight = false;
double target_weight = 0.0;
size_t set_index;
if (!choose_exercise(
database,
&exercise
)) {
return false;
}
(void)memset(
output,
0,
sizeof(*output)
);
(void)snprintf(
output->exercise_id,
sizeof(output->exercise_id),
"%s",
exercise.exercise_id
);
output->recording_mode =
exercise.recording_mode;
output->data_fields =
exercise.data_fields;
if (exercise.recording_mode ==
TRAINLOG_RECORDING_CONTINUOUS) {
int duration_minutes = 30;
int duration_seconds;
bool has_value = false;
double value = 0.0;
draw_shell(
exercise.name,
"Échap annuler · activité continue"
);
if (!prompt_int_value(
4,
"Durée (minutes)",
1,
1440,
duration_minutes,
&duration_minutes
)) {
return false;
}
duration_seconds =
duration_minutes * 60;
output->continuous_duration_seconds =
duration_seconds;
output->load_mode =
TRAINLOG_LOAD_NONE;
output->rest_seconds = 0;
output->target_sets = 0;
output->target_reps = 0;
output->target_duration_seconds = 0;
output->target_has_weight = false;
output->sets = NULL;
output->set_count = 0U;
if ((exercise.data_fields &
TRAINLOG_EXERCISE_DATA_SPEED_KMH) != 0U) {
if (!prompt_optional_double(
5,
"Vitesse km/h : ",
&has_value,
&value
) ||
!has_value) {
return false;
}
output->continuous_has_speed =
true;
output->continuous_speed_kmh =
value;
}
if ((exercise.data_fields &
TRAINLOG_EXERCISE_DATA_DISTANCE_KM) != 0U) {
has_value = false;
value = 0.0;
if (!prompt_optional_double(
6,
"Distance km : ",
&has_value,
&value
) ||
!has_value) {
return false;
}
output->continuous_has_distance =
true;
output->continuous_distance_km =
value;
}
return true;
}
target_metric =
exercise.tracking_mode ==
TRAINLOG_TRACKING_REPS
? 10
: 45;
draw_shell(
exercise.name,
"Échap annuler · Durées : 90, 90s, 1:30, 1m30, 2m"
);
if (!prompt_int_value(
4,
"Charge 1=aucune 2=externe 3=assistance",
1,
3,
1,
&load_mode
)) {
return false;
}
if (load_mode != 1) {
if (!prompt_optional_double(
5,
"Charge cible kg : ",
&target_has_weight,
&target_weight
) ||
!target_has_weight) {
return false;
}
}
if (!prompt_int_value(
6,
"Séries prévues",
1,
(int)set_capacity,
3,
&target_sets
)) {
return false;
}
if (exercise.tracking_mode ==
TRAINLOG_TRACKING_REPS) {
if (!prompt_int_value(
7,
"Répétitions cibles",
1,
10000,
target_metric,
&target_metric
)) {
return false;
}
} else {
if (!prompt_duration_value(
7,
"Durée cible",
1,
86400,
target_metric,
&target_metric
)) {
return false;
}
}
if (!prompt_duration_value(
8,
"Repos prévu",
0,
86400,
60,
&rest_seconds
)) {
return false;
}
if (
exercise.tracking_mode ==
TRAINLOG_TRACKING_REPS
) {
for (;;) {
if (!prompt_text(
9,
"Séries réalisées (5x10 | 4,5,6,... | 4..10..4) : ",
rep_sequence,
sizeof(rep_sequence),
false
)) {
return false;
}
if (
trainlog_reps_parse_sequence(
rep_sequence,
rep_values,
set_capacity,
&rep_count
) ==
TRAINLOG_STATUS_OK
) {
break;
}
status_line(
"Séries invalides. Exemples : 5x10 · 4,5,6,7 · 4..10..4",
TRAINLOG_COLOR_ERROR
);
refresh();
}
} else {
if (!prompt_int_value(
9,
"Séries réellement faites",
0,
(int)set_capacity,
target_sets,
&actual_sets
)) {
return false;
}
}
output->load_mode =
load_mode == 1
? TRAINLOG_LOAD_NONE
: (
load_mode == 2
? TRAINLOG_LOAD_EXTERNAL
: TRAINLOG_LOAD_ASSISTANCE
);
output->rest_seconds = rest_seconds;
output->target_sets = target_sets;
output->target_reps =
exercise.tracking_mode ==
TRAINLOG_TRACKING_REPS
? target_metric
: 0;
output->target_duration_seconds =
exercise.tracking_mode ==
TRAINLOG_TRACKING_DURATION
? target_metric
: 0;
output->target_has_weight =
target_has_weight;
output->target_weight_kg =
target_weight;
output->sets = set_storage;
output->set_count =
exercise.tracking_mode ==
TRAINLOG_TRACKING_REPS
? rep_count
: (size_t)actual_sets;
for (set_index = 0U;
set_index < output->set_count;
++set_index) {
int actual_metric =
target_metric;
(void)memset(
&set_storage[set_index],
0,
sizeof(set_storage[set_index])
);
if (
exercise.tracking_mode ==
TRAINLOG_TRACKING_DURATION ||
target_has_weight
) {
draw_shell(
exercise.name,
"Échap annuler · Durées : 90, 90s, 1:30, 1m30, 2m"
);
mvprintw(
3,
4,
"Série %zu / %zu",
set_index + 1U,
output->set_count
);
}
if (
exercise.tracking_mode ==
TRAINLOG_TRACKING_REPS
) {
actual_metric =
rep_values[set_index];
set_storage[set_index].reps =
actual_metric;
} else {
if (!prompt_duration_value(
5,
"Durée réalisée",
1,
86400,
target_metric,
&actual_metric
)) {
return false;
}
set_storage[
set_index
].duration_seconds =
actual_metric;
}
if (target_has_weight) {
char buffer[64];
char prompt[128];
double actual_weight =
target_weight;
(void)snprintf(
prompt,
sizeof(prompt),
"Charge kg [%.1f] : ",
target_weight
);
if (!prompt_text(
6,
prompt,
buffer,
sizeof(buffer),
true
)) {
return false;
}
if (buffer[0] != '\0' &&
!parse_double_positive(
buffer,
&actual_weight
)) {
status_line(
"Charge invalide.",
TRAINLOG_COLOR_ERROR
);
wait_key();
return false;
}
set_storage[
set_index
].has_weight =
true;
set_storage[
set_index
].weight_kg =
actual_weight;
}
}
return true;
}
/* TRAINLOG_SESSION_TYPE_TUI */
static const char *session_type_label(
TrainlogSessionType type
)
{
switch (type) {
case TRAINLOG_SESSION_MAX_TEST:
return "Test de max";
case TRAINLOG_SESSION_TRAINING:
default:
return "Entraînement";
}
}
static const char *session_type_history_label(
TrainlogSessionType type
)
{
return type == TRAINLOG_SESSION_MAX_TEST
? "[MAX]"
: "[ENTRAINEMENT]";
}
static bool choose_session_type(
TrainlogSessionType *output
)
{
int selected = 0;
int nav_selected = 1;
int focus = 1;
if (output == NULL) {
return false;
}
for (;;) {
bool large_layout =
COLS >= 100 &&
LINES >= 30;
int key;
if (large_layout) {
erase();
box(stdscr, 0, 0);
section_ascii_header(
":: N O U V E L L E S E A N C E ::"
);
primary_top_navbar(
1,
nav_selected,
focus == 0
);
focused_panel(
11,
4,
20,
COLS - 5,
"TYPE DE SEANCE",
focus == 1
);
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
mvprintw(
LINES - 2,
2,
"%.*s",
COLS - 4,
"Tab zone ←→ menu ↑↓ type Entrée valider 0/Home accueil F2-F4 direct Échap annuler"
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
if (focus == 1 &&
selected == 0) {
attron(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
mvprintw(
13,
7,
" Entraînement "
);
if (focus == 1 &&
selected == 0) {
attroff(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
mvprintw(
14,
9,
"Séance normale : progression, volume, travail courant."
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
if (focus == 1 &&
selected == 1) {
attron(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
mvprintw(
17,
7,
" Test de max "
);
if (focus == 1 &&
selected == 1) {
attroff(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
mvprintw(
18,
9,
"Séance explicitement dédiée aux mesures de max."
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
} else {
WINDOW *panel;
int panel_width =
COLS - 8;
draw_shell(
"TRAINLOG — Nouvelle séance",
"↑↓ choisir Entrée valider Échap annuler"
);
if (panel_width < 40) {
panel_width = 40;
}
panel = derwin(
stdscr,
9,
panel_width,
3,
4
);
if (panel == NULL) {
return false;
}
box(panel, 0, 0);
wattron(
panel,
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvwprintw(
panel,
0,
2,
" TYPE DE SEANCE "
);
wattroff(
panel,
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
if (selected == 0) {
wattron(
panel,
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
mvwprintw(
panel,
2,
3,
" Entraînement "
);
if (selected == 0) {
wattroff(
panel,
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
mvwprintw(
panel,
3,
5,
"Séance normale : progression, volume, travail courant."
);
if (selected == 1) {
wattron(
panel,
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
mvwprintw(
panel,
5,
3,
" Test de max "
);
if (selected == 1) {
wattroff(
panel,
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
mvwprintw(
panel,
6,
5,
"Séance explicitement dédiée aux mesures de max."
);
syncok(panel, TRUE);
wsyncup(panel);
delwin(panel);
}
refresh();
key = getch();
if (large_layout &&
(key == '\t' ||
key == KEY_BTAB)) {
focus =
focus == 0
? 1
: 0;
continue;
}
if (key == 27) {
return false;
}
if (large_layout &&
(key == '0' ||
key == KEY_HOME)) {
return false;
}
if (large_layout &&
(key == '2' ||
key == KEY_F(2) ||
key == '3' ||
key == KEY_F(3) ||
key == '4' ||
key == KEY_F(4) ||
key == '5' ||
key == KEY_F(5))) {
if (primary_top_nav_forward(key)) {
return false;
}
continue;
}
if (large_layout &&
focus == 0) {
if (key == KEY_LEFT) {
nav_selected =
nav_selected > 0
? nav_selected - 1
: 5;
} else if (key == KEY_RIGHT) {
nav_selected =
nav_selected < 5
? nav_selected + 1
: 0;
} else if (
key == '\n' ||
key == KEY_ENTER
) {
if (nav_selected == 1) {
focus = 1;
} else if (
primary_top_nav_activate(
nav_selected
)
) {
return false;
}
}
continue;
}
if (key == KEY_UP ||
key == KEY_LEFT ||
key == KEY_DOWN ||
key == KEY_RIGHT) {
selected =
selected == 0
? 1
: 0;
continue;
}
if (key == '\n' ||
key == KEY_ENTER) {
*output =
selected == 0
? TRAINLOG_SESSION_TRAINING
: TRAINLOG_SESSION_MAX_TEST;
return true;
}
}
}
/* TRAINLOG_SESSION_EDIT_TUI */
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];
} TrainlogSessionDraftExercise;
static bool draft_lookup_exercise(
TrainlogDatabase *database,
const char *exercise_id,
char *output_name,
size_t output_name_size,
TrainlogTrackingMode *output_tracking
)
{
TrainlogExercise exercises[MAX_EXERCISES];
size_t count = 0U;
size_t index;
if (database == NULL ||
exercise_id == NULL ||
output_name == NULL ||
output_name_size == 0U ||
output_tracking == NULL) {
return false;
}
if (trainlog_database_list_exercises(
database,
exercises,
MAX_EXERCISES,
&count
) != TRAINLOG_STATUS_OK) {
return false;
}
for (index = 0U;
index < count;
++index) {
if (strcmp(
exercises[index].exercise_id,
exercise_id
) == 0) {
(void)snprintf(
output_name,
output_name_size,
"%s",
exercises[index].name
);
*output_tracking =
exercises[index].tracking_mode;
return true;
}
}
return false;
}
static bool draft_build_exercise(
TrainlogDatabase *database,
TrainlogSessionDraftExercise *draft,
const char *preserved_notes
)
{
TrainlogSessionExerciseInput input;
TrainlogSetInput sets[MAX_SETS_PER_EXERCISE];
if (database == NULL ||
draft == NULL) {
return false;
}
(void)memset(
&input,
0,
sizeof(input)
);
(void)memset(
sets,
0,
sizeof(sets)
);
if (!build_session_exercise(
database,
&input,
sets,
MAX_SETS_PER_EXERCISE
)) {
return false;
}
(void)memset(
draft,
0,
sizeof(*draft)
);
draft->input = input;
if (input.set_count >
MAX_SETS_PER_EXERCISE) {
return false;
}
(void)memcpy(
draft->sets,
sets,
input.set_count *
sizeof(draft->sets[0])
);
draft->input.sets =
draft->sets;
if (preserved_notes != NULL &&
preserved_notes[0] != '\0') {
(void)snprintf(
draft->notes,
sizeof(draft->notes),
"%s",
preserved_notes
);
draft->input.notes =
draft->notes;
} else {
draft->notes[0] = '\0';
draft->input.notes = NULL;
}
if (!draft_lookup_exercise(
database,
draft->input.exercise_id,
draft->name,
sizeof(draft->name),
&draft->tracking_mode
)) {
return false;
}
return true;
}
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_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
)
{
if (draft == NULL ||
output == NULL ||
output_size == 0U) {
return;
}
if (draft->input.load_mode ==
TRAINLOG_LOAD_EXTERNAL) {
(void)snprintf(
output,
output_size,
"%zu séries · %.1f kg",
draft->input.set_count,
draft->input.target_weight_kg
);
} else if (
draft->input.load_mode ==
TRAINLOG_LOAD_ASSISTANCE
) {
(void)snprintf(
output,
output_size,
"%zu séries · %.1f kg aide",
draft->input.set_count,
draft->input.target_weight_kg
);
} else {
(void)snprintf(
output,
output_size,
"%zu séries",
draft->input.set_count
);
}
}
static bool confirm_draft_delete(
const char *name
)
{
int answer = 0;
draw_shell(
"Supprimer de la séance ?",
"1 confirmer 0 annuler"
);
mvprintw(
4,
4,
"%s",
name != NULL
? name
: "Exercice"
);
if (!prompt_int_value(
6,
"Supprimer ? 1=oui 0=non",
0,
1,
0,
&answer
)) {
return false;
}
return answer == 1;
}
static bool edit_session_draft(
TrainlogDatabase *database,
TrainlogSessionDraftExercise drafts[MAX_SESSION_EXERCISES],
size_t *count,
TrainlogSessionType session_type
)
{
size_t selected = 0U;
if (database == NULL ||
drafts == NULL ||
count == NULL) {
return false;
}
for (;;) {
size_t index;
size_t top = 0U;
bool large_layout =
COLS >= 100 &&
LINES >= 30;
int frame_top =
large_layout ? 8 : 3;
int frame_bottom =
LINES - 4;
int first_row =
frame_top + 3;
int visible_rows =
frame_bottom -
first_row;
int key;
if (visible_rows < 1) {
return false;
}
if (*count > 0U &&
selected >= *count) {
selected =
*count - 1U;
}
if (*count > 0U &&
selected >=
(size_t)visible_rows) {
top =
selected -
(size_t)visible_rows +
1U;
}
if (large_layout) {
erase();
box(stdscr, 0, 0);
section_ascii_header(
":: S E A N C E E N C O U R S ::"
);
focused_panel(
frame_top,
2,
frame_bottom,
COLS - 3,
"RESUME AVANT ENREGISTREMENT",
true
);
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
mvprintw(
LINES - 2,
2,
"%.*s",
COLS - 4,
"↑↓ choisir e/Entrée modifier a ajouter d supprimer f enregistrer q/Échap abandonner"
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
} else {
draw_shell(
"TRAINLOG — Séance en cours",
"↑↓ choisir e/Entrée modifier a ajouter d supprimer f enregistrer q/Échap abandonner"
);
}
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvprintw(
frame_top + 1,
large_layout ? 5 : 4,
"Type : %s",
session_type_label(
session_type
)
);
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
if (*count == 0U) {
mvprintw(
first_row + 1,
large_layout ? 5 : 4,
"Aucun exercice saisi. a = ajouter."
);
}
for (index = 0U;
index < (size_t)visible_rows &&
top + index < *count;
++index) {
size_t absolute =
top + index;
char summary[96];
int row =
first_row +
(int)index;
draft_set_summary(
&drafts[absolute],
summary,
sizeof(summary)
);
if (absolute == selected) {
attron(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
mvprintw(
row,
large_layout ? 5 : 4,
" %2zu %-36.36s %-28.28s ",
absolute + 1U,
drafts[absolute].name,
summary
);
if (absolute == selected) {
attroff(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
}
refresh();
key = getch();
if (key == 'q' ||
key == 'Q' ||
key == 27) {
return false;
}
if (key == 'f' ||
key == 'F') {
if (*count == 0U) {
status_line(
"Ajoutez au moins un exercice avant d'enregistrer.",
TRAINLOG_COLOR_WARNING
);
refresh();
(void)getch();
continue;
}
return true;
}
if (key == KEY_UP &&
*count > 0U) {
selected =
selected > 0U
? selected - 1U
: *count - 1U;
continue;
}
if (key == KEY_DOWN &&
*count > 0U) {
selected =
selected + 1U < *count
? selected + 1U
: 0U;
continue;
}
if (key == 'a' ||
key == 'A') {
if (*count >=
MAX_SESSION_EXERCISES) {
status_line(
"Nombre maximal d'exercices atteint.",
TRAINLOG_COLOR_WARNING
);
refresh();
(void)getch();
continue;
}
if (draft_build_exercise(
database,
&drafts[*count],
NULL
)) {
selected = *count;
++(*count);
}
continue;
}
if ((key == 'e' ||
key == 'E' ||
key == '\n' ||
key == KEY_ENTER) &&
*count > 0U) {
TrainlogSessionDraftExercise replacement;
if (draft_build_exercise(
database,
&replacement,
drafts[selected].notes
)) {
drafts[selected] =
replacement;
draft_bind_input(
&drafts[selected]
);
}
continue;
}
if ((key == 'd' ||
key == 'D') &&
*count > 0U) {
if (confirm_draft_delete(
drafts[selected].name
)) {
draft_delete_exercise(
drafts,
count,
selected
);
if (*count > 0U &&
selected >= *count) {
selected =
*count - 1U;
}
}
}
}
}
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.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;
(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;
}
static void edit_persisted_session(
TrainlogDatabase *database,
const char *session_id
)
{
TrainlogSessionSummary session;
TrainlogSessionDraftExercise
drafts[MAX_SESSION_EXERCISES];
size_t count = 0U;
if (!load_persisted_draft(
database,
session_id,
&session,
drafts,
&count
)) {
status_line(
"Impossible de charger la séance à modifier.",
TRAINLOG_COLOR_ERROR
);
wait_key();
return;
}
if (!edit_session_draft(
database,
drafts,
&count,
session.session_type
)) {
status_line(
"Modification abandonnée : aucune donnée changée.",
TRAINLOG_COLOR_MUTED
);
wait_key();
return;
}
if (persist_draft_replacement(
database,
session_id,
drafts,
count
)) {
status_line(
"✓ Séance corrigée.",
TRAINLOG_COLOR_SUCCESS
);
} else {
status_line(
"Échec de la correction : ancienne séance conservée.",
TRAINLOG_COLOR_ERROR
);
}
wait_key();
}
static void screen_new_session(
TrainlogDatabase *database
)
{
TrainlogSessionDraftExercise
drafts[MAX_SESSION_EXERCISES];
TrainlogSessionExerciseInput
exercise_inputs[MAX_SESSION_EXERCISES];
TrainlogSessionInput session;
char session_id[TRAINLOG_GENERATED_ID_CAPACITY];
char started_at[TRAINLOG_TIMESTAMP_MAX + 1U];
char ended_at[TRAINLOG_TIMESTAMP_MAX + 1U];
TrainlogSessionType session_type =
TRAINLOG_SESSION_TRAINING;
size_t catalog_count = 0U;
size_t exercise_count = 0U;
size_t index;
TrainlogStatus status;
if (trainlog_database_exercise_count(
database,
&catalog_count
) != TRAINLOG_STATUS_OK) {
return;
}
if (catalog_count == 0U) {
draw_shell(
"Nouvelle séance",
"Une touche pour revenir"
);
status_line(
"Ajoutez d'abord au moins un exercice.",
TRAINLOG_COLOR_WARNING
);
wait_key();
return;
}
if (!choose_session_type(
&session_type
)) {
return;
}
if (trainlog_id_generate(
"se",
session_id,
sizeof(session_id)
) != TRAINLOG_STATUS_OK ||
trainlog_time_now_rfc3339(
started_at,
sizeof(started_at)
) != TRAINLOG_STATUS_OK) {
return;
}
(void)memset(
drafts,
0,
sizeof(drafts)
);
/*
* Start with the first exercise entry immediately. If the user cancels
* that form, nothing has been committed and the review screen still lets
* them add another exercise or abandon the whole session.
*/
if (draft_build_exercise(
database,
&drafts[0],
NULL
)) {
exercise_count = 1U;
}
if (!edit_session_draft(
database,
drafts,
&exercise_count,
session_type
)) {
draw_shell(
"Séance abandonnée",
"Une touche pour revenir"
);
status_line(
"Aucune donnée de séance n'a été enregistrée.",
TRAINLOG_COLOR_MUTED
);
wait_key();
return;
}
if (trainlog_time_now_rfc3339(
ended_at,
sizeof(ended_at)
) != TRAINLOG_STATUS_OK) {
return;
}
for (index = 0U;
index < exercise_count;
++index) {
draft_bind_input(
&drafts[index]
);
exercise_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 =
exercise_inputs;
session.exercise_count =
exercise_count;
status =
trainlog_database_insert_session(
database,
&session
);
draw_shell(
"Fin de séance",
"Une touche pour revenir"
);
if (status == TRAINLOG_STATUS_OK) {
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_SUCCESS
)
);
mvprintw(
4,
2,
"✓ Séance enregistrée."
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_SUCCESS
)
);
mvprintw(
6,
2,
"Début : %s",
started_at
);
mvprintw(
7,
2,
"Fin : %s",
ended_at
);
mvprintw(
8,
2,
"Type : %s",
session_type_label(
session_type
)
);
mvprintw(
9,
2,
"Exercices : %zu",
exercise_count
);
} else {
status_line(
"Échec lors de l'enregistrement de la séance.",
TRAINLOG_COLOR_ERROR
);
}
wait_key();
}
/* TRAINLOG_SESSION_DETAILS_SCREEN */
static const char *session_detail_load_label(TrainlogLoadMode mode)
{
switch (mode) {
case TRAINLOG_LOAD_EXTERNAL:
return "externe";
case TRAINLOG_LOAD_ASSISTANCE:
return "assistance";
case TRAINLOG_LOAD_NONE:
default:
return "aucune";
}
}
static void screen_session_detail(
TrainlogDatabase *database,
const char *session_id
)
{
TrainlogSessionSummary session;
TrainlogPersistedExerciseDetail
exercises[MAX_SESSION_EXERCISES];
size_t count = 0U;
size_t selected = 0U;
TrainlogStatus status;
status =
trainlog_database_get_session_details(
database,
session_id,
&session,
exercises,
MAX_SESSION_EXERCISES,
&count
);
if (status != TRAINLOG_STATUS_OK) {
draw_shell(
"Détail séance",
"Une touche pour revenir"
);
status_line(
"Impossible de charger la séance.",
TRAINLOG_COLOR_ERROR
);
wait_key();
return;
}
for (;;) {
bool decorated =
COLS >= 100 &&
LINES >= 32;
int key;
if (decorated) {
erase();
box(stdscr, 0, 0);
section_ascii_header(
":: D E T A I L S E A N C E ::"
);
dashboard_panel(
8,
2,
13,
COLS - 3,
"SEANCE"
);
focused_panel(
14,
2,
LINES - 4,
COLS - 3,
"EXERCICE",
true
);
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
mvprintw(
LINES - 2,
2,
"%.*s",
COLS - 4,
"←→/↑↓ exercice précédent/suivant e modifier la séance b/Échap retour"
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
mvprintw(
10,
5,
"Début : %s",
session.started_at
);
mvprintw(
11,
5,
"Fin : %s",
session.ended_at[0] != '\0'
? session.ended_at
: "séance ouverte"
);
mvprintw(
12,
5,
"Type : %s",
session_type_label(
session.session_type
)
);
} else {
draw_shell(
"TRAINLOG — Détail séance",
"←→/↑↓ naviguer e modifier b/Échap retour"
);
mvprintw(
3,
4,
"Début : %s",
session.started_at
);
mvprintw(
4,
4,
"Fin : %s",
session.ended_at[0] != '\0'
? session.ended_at
: "séance ouverte"
);
mvprintw(
5,
4,
"Type : %s",
session_type_label(
session.session_type
)
);
}
if (count == 0U) {
mvprintw(
decorated ? 17 : 7,
decorated ? 5 : 4,
"Aucun exercice dans cette séance."
);
} else {
TrainlogPersistedExerciseDetail *exercise =
&exercises[selected];
if (exercise->recording_mode ==
TRAINLOG_RECORDING_CONTINUOUS) {
char duration_text[64];
int title_row =
decorated ? 16 : 6;
int mode_row =
decorated ? 18 : 8;
int first_data_row =
decorated ? 20 : 10;
int row =
first_data_row;
if (trainlog_duration_format(
exercise->continuous_duration_seconds,
duration_text,
sizeof(duration_text)
) != TRAINLOG_STATUS_OK) {
(void)snprintf(
duration_text,
sizeof(duration_text),
"%d s",
exercise->continuous_duration_seconds
);
}
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvprintw(
title_row,
decorated ? 5 : 4,
"Exercice %zu/%zu — %s",
selected + 1U,
count,
exercise->name
);
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvprintw(
mode_row,
decorated ? 5 : 4,
"Mode : continu"
);
mvprintw(
row++,
decorated ? 5 : 4,
"Durée : %s",
duration_text
);
if (exercise->has_continuous_speed != 0) {
mvprintw(
row++,
decorated ? 5 : 4,
"Vitesse : %.1f km/h",
exercise->continuous_speed_kmh
);
}
if (exercise->has_continuous_distance != 0) {
mvprintw(
row++,
decorated ? 5 : 4,
"Distance : %.2f km",
exercise->continuous_distance_km
);
}
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_SUCCESS
)
);
mvprintw(
row + 1,
decorated ? 5 : 4,
"Réalisé : activité continue"
);
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_SUCCESS
)
);
} else {
char rest_text[64];
char target_duration_text[64];
int title_row =
decorated ? 16 : 6;
int mode_row =
decorated ? 18 : 8;
int target_row =
decorated ? 20 : 10;
int weight_row =
decorated ? 21 : 11;
int actual_row =
decorated ? 23 : 13;
int summary_row =
decorated ? 25 : 15;
int warning_row =
decorated ? 27 : 17;
if (trainlog_duration_format(
exercise->rest_seconds,
rest_text,
sizeof(rest_text)
) != TRAINLOG_STATUS_OK) {
(void)snprintf(
rest_text,
sizeof(rest_text),
"%ds",
exercise->rest_seconds
);
}
target_duration_text[0] = '\0';
if (exercise->tracking_mode ==
TRAINLOG_TRACKING_DURATION) {
(void)trainlog_duration_format(
exercise->target_duration_seconds,
target_duration_text,
sizeof(target_duration_text)
);
}
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvprintw(
title_row,
decorated ? 5 : 4,
"Exercice %zu/%zu — %s",
selected + 1U,
count,
exercise->name
);
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvprintw(
mode_row,
decorated ? 5 : 4,
"Mode : %-12s Charge : %-10s Repos : %s",
exercise->tracking_mode ==
TRAINLOG_TRACKING_REPS
? "répétitions"
: "durée",
session_detail_load_label(
exercise->load_mode
),
rest_text
);
if (
exercise->target_sets <= 0
) {
mvprintw(
target_row,
decorated ? 5 : 4,
"Cible : non renseignée"
);
} else if (
exercise->tracking_mode ==
TRAINLOG_TRACKING_REPS
) {
mvprintw(
target_row,
decorated ? 5 : 4,
"Cible : %d série(s) × %d reps",
exercise->target_sets,
exercise->target_reps
);
} else {
mvprintw(
target_row,
decorated ? 5 : 4,
"Cible : %d série(s) × %s",
exercise->target_sets,
target_duration_text
);
}
if (exercise->has_target_weight != 0) {
mvprintw(
weight_row,
decorated ? 5 : 4,
"Charge cible : %.1f kg",
exercise->target_weight_kg
);
} else {
mvprintw(
weight_row,
decorated ? 5 : 4,
"Charge cible : —"
);
}
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_SUCCESS
)
);
mvprintw(
actual_row,
decorated ? 5 : 4,
"Réalisé : %zu série(s)",
exercise->actual_set_count
);
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_SUCCESS
)
);
mvprintw(
summary_row,
decorated ? 5 : 4,
"%.*s",
COLS - 10,
exercise->actual_summary
);
if (exercise->load_mode ==
TRAINLOG_LOAD_ASSISTANCE) {
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_WARNING
)
);
mvprintw(
warning_row,
decorated ? 5 : 4,
"Assistance : plus de kg = davantage d'aide."
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_WARNING
)
);
}
}
}
refresh();
key = getch();
if (key == 'b' ||
key == 'B' ||
key == 27) {
return;
}
if (key == 'e' ||
key == 'E') {
edit_persisted_session(
database,
session_id
);
return;
}
if (count > 0U &&
(key == KEY_RIGHT ||
key == KEY_DOWN)) {
selected =
selected + 1U < count
? selected + 1U
: 0U;
} else if (
count > 0U &&
(key == KEY_LEFT ||
key == KEY_UP)
) {
selected =
selected > 0U
? selected - 1U
: count - 1U;
}
}
}
/* TRAINLOG_HISTORY_ASCII_HEADER */
static void history_ascii_header(void)
{
static const char *const logo[] = {
"TTTTT RRRR AAA IIIII N N L OOO GGG ",
" T R R A A I NN N L O O G ",
" T RRRR AAAAA I N N N L O O G GG",
" T R R A A I N NN L O O G G",
" T R R A A IIIII N N LLLLL OOO GGG "
};
const size_t line_count =
sizeof(logo) /
sizeof(logo[0]);
size_t index;
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
for (index = 0U;
index < line_count;
++index) {
int width =
(int)strlen(logo[index]);
int column =
(COLS - width) / 2;
if (column < 2) {
column = 2;
}
mvprintw(
1 + (int)index,
column,
"%.*s",
COLS - column - 2,
logo[index]
);
}
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
{
const char *label =
":: H I S T O R I Q U E ::";
int width =
(int)strlen(label);
int column =
(COLS - width) / 2;
if (column < 2) {
column = 2;
}
mvprintw(
6,
column,
"%s",
label
);
}
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
}
static void history_scrollbar(
int top,
int bottom,
int column,
size_t selected,
size_t count,
size_t visible
)
{
int track_height;
int row;
int thumb;
if (bottom <= top ||
count <= visible ||
count <= 1U) {
return;
}
track_height =
bottom - top + 1;
if (track_height < 2) {
return;
}
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
for (row = top;
row <= bottom;
++row) {
mvaddch(
row,
column,
ACS_VLINE
);
}
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
thumb =
top +
(int)(
(selected *
(size_t)(track_height - 1)) /
(count - 1U)
);
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvaddch(
thumb,
column,
ACS_CKBOARD
);
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
static void screen_history(
TrainlogDatabase *database
)
{
TrainlogSessionSummary
sessions[MAX_SESSIONS];
size_t selected = 0U;
int nav_selected = 2;
int focus = 1;
for (;;) {
size_t count = 0U;
size_t top = 0U;
size_t index;
bool large_layout =
COLS >= 100 &&
LINES >= 30;
int list_top =
large_layout
? 11
: 3;
int list_bottom =
LINES - 4;
int first_row =
list_top + 1;
int visible_rows =
list_bottom -
first_row;
int key;
if (visible_rows < 1) {
return;
}
if (trainlog_database_list_sessions(
database,
sessions,
MAX_SESSIONS,
&count
) != TRAINLOG_STATUS_OK) {
return;
}
erase();
box(stdscr, 0, 0);
if (large_layout) {
history_ascii_header();
primary_top_navbar(
2,
nav_selected,
focus == 0
);
focused_panel(
list_top,
2,
list_bottom,
COLS - 3,
"SEANCES ENREGISTREES",
focus == 1
);
} else {
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvprintw(
1,
2,
" TRAINLOG — Historique "
);
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
mvprintw(
LINES - 2,
2,
"%.*s",
COLS - 4,
large_layout
? "Tab zone ↑↓/PgUp/PgDn liste ←→ menu Entrée ouvrir e modifier 0/Home accueil F1-F4 direct b/Échap retour"
: "↑↓ naviguer Entrée détail e modifier b/Échap retour"
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
if (count == 0U) {
mvprintw(
first_row + 1,
large_layout ? 6 : 4,
"Aucune séance."
);
}
if (count > 0U) {
if (selected >= count) {
selected =
count - 1U;
}
if (selected >=
(size_t)visible_rows) {
top =
selected -
(size_t)visible_rows +
1U;
}
for (index = 0U;
index < (size_t)visible_rows &&
top + index < count;
++index) {
size_t absolute =
top + index;
int item_row =
first_row +
(int)index;
int item_col =
large_layout ? 5 : 4;
if (focus == 1 &&
absolute == selected) {
attron(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
{
char display_date[17];
session_history_datetime(
sessions[absolute].started_at,
display_date
);
mvprintw(
item_row,
item_col,
" %-16s %-14s %2zu exercice(s) ",
display_date,
session_type_history_label(
sessions[absolute].session_type
),
sessions[absolute].exercise_count
);
}
if (focus == 1 &&
absolute == selected) {
attroff(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
}
if (large_layout) {
history_scrollbar(
first_row,
list_bottom - 1,
COLS - 5,
selected,
count,
(size_t)visible_rows
);
}
}
refresh();
key = getch();
if (large_layout &&
(key == '\t' ||
key == KEY_BTAB)) {
focus =
focus == 0
? 1
: 0;
continue;
}
if (large_layout &&
primary_top_nav_forward(key)) {
return;
}
if (key == 'b' ||
key == 'B' ||
key == 27) {
return;
}
if (large_layout &&
focus == 0) {
if (key == KEY_LEFT) {
nav_selected =
nav_selected > 0
? nav_selected - 1
: 5;
} else if (key == KEY_RIGHT) {
nav_selected =
nav_selected < 5
? nav_selected + 1
: 0;
} else if (
key == '\n' ||
key == KEY_ENTER
) {
if (primary_top_nav_activate(
nav_selected
)) {
return;
}
}
continue;
}
if (count == 0U) {
continue;
}
if (key == KEY_UP) {
selected =
selected > 0U
? selected - 1U
: 0U;
continue;
}
if (key == KEY_DOWN) {
selected =
selected + 1U < count
? selected + 1U
: count - 1U;
continue;
}
if (key == KEY_PPAGE) {
size_t jump =
(size_t)visible_rows;
selected =
selected > jump
? selected - jump
: 0U;
continue;
}
if (key == KEY_NPAGE) {
size_t jump =
(size_t)visible_rows;
selected =
selected + jump < count
? selected + jump
: count - 1U;
continue;
}
if (key == '\n' ||
key == KEY_ENTER) {
screen_session_detail(
database,
sessions[selected].session_id
);
continue;
}
if (key == 'e' ||
key == 'E') {
edit_persisted_session(
database,
sessions[selected].session_id
);
}
}
}
/* TRAINLOG_BODY_RECORDS_TUI */
#define MAX_BODY_OBSERVATIONS 512U
static void body_panel(
int top,
int left,
int bottom,
int right,
const char *label
)
{
WINDOW *panel;
int height;
int width;
if (top < 0 ||
left < 0 ||
bottom <= top ||
right <= left ||
bottom >= LINES ||
right >= COLS) {
return;
}
height = bottom - top + 1;
width = right - left + 1;
panel = derwin(
stdscr,
height,
width,
top,
left
);
if (panel == NULL) {
return;
}
box(panel, 0, 0);
if (label != NULL &&
label[0] != '\0' &&
width > 8) {
wattron(
panel,
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvwprintw(
panel,
0,
2,
" %.*s ",
width - 6,
label
);
wattroff(
panel,
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
syncok(panel, TRUE);
wsyncup(panel);
delwin(panel);
}
static void body_short_date(
const char *timestamp,
char output[9]
)
{
if (timestamp == NULL ||
strlen(timestamp) < 10U) {
(void)snprintf(
output,
9U,
"%s",
"--/--/--"
);
return;
}
(void)snprintf(
output,
9U,
"%c%c/%c%c/%c%c",
timestamp[8],
timestamp[9],
timestamp[5],
timestamp[6],
timestamp[2],
timestamp[3]
);
}
static size_t body_record_metric_count(
const TrainlogBodyObservationRecord *record
)
{
size_t count = 0U;
if (record == NULL) {
return 0U;
}
#define COUNT_BODY_VALUE(flag_) \
do { \
if ((flag_)) { \
++count; \
} \
} while (0)
COUNT_BODY_VALUE(record->has_body_weight);
COUNT_BODY_VALUE(record->has_neck);
COUNT_BODY_VALUE(record->has_shoulders);
COUNT_BODY_VALUE(record->has_chest);
COUNT_BODY_VALUE(record->has_waist);
COUNT_BODY_VALUE(record->has_hips);
COUNT_BODY_VALUE(record->has_left_arm);
COUNT_BODY_VALUE(record->has_right_arm);
COUNT_BODY_VALUE(record->has_left_forearm);
COUNT_BODY_VALUE(record->has_right_forearm);
COUNT_BODY_VALUE(record->has_left_thigh);
COUNT_BODY_VALUE(record->has_right_thigh);
COUNT_BODY_VALUE(record->has_left_calf);
COUNT_BODY_VALUE(record->has_right_calf);
#undef COUNT_BODY_VALUE
return count;
}
static void body_draw_scrollbar(
int top,
int bottom,
int column,
size_t selected,
size_t count,
size_t visible
)
{
int track_height;
int row;
int thumb;
if (bottom <= top ||
count <= visible ||
count <= 1U) {
return;
}
track_height =
bottom - top + 1;
if (track_height < 2) {
return;
}
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
for (row = top;
row <= bottom;
++row) {
mvaddch(
row,
column,
ACS_VLINE
);
}
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
thumb =
top +
(int)(
(selected *
(size_t)(track_height - 1)) /
(count - 1U)
);
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvaddch(
thumb,
column,
ACS_CKBOARD
);
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
static void body_summary_text(
const TrainlogBodyObservationRecord *record,
char *output,
size_t output_size
)
{
char weight[24];
char waist[24];
char chest[24];
size_t metric_count;
if (record == NULL ||
output == NULL ||
output_size == 0U) {
return;
}
if (record->has_body_weight) {
(void)snprintf(
weight,
sizeof(weight),
"%.1f kg",
record->body_weight_kg
);
} else {
(void)snprintf(
weight,
sizeof(weight),
"%s",
"— kg"
);
}
if (record->has_waist) {
(void)snprintf(
waist,
sizeof(waist),
"taille %.1f",
record->waist_cm
);
} else {
(void)snprintf(
waist,
sizeof(waist),
"%s",
"taille —"
);
}
if (record->has_chest) {
(void)snprintf(
chest,
sizeof(chest),
"poitrine %.1f",
record->chest_cm
);
} else {
(void)snprintf(
chest,
sizeof(chest),
"%s",
"poitrine —"
);
}
metric_count =
body_record_metric_count(record);
(void)snprintf(
output,
output_size,
"%-9s %-9s %-14s %2zu valeur(s)",
weight,
waist,
chest,
metric_count
);
}
static bool prompt_optional_double_existing(
int row,
const char *label,
bool *present,
double *value
)
{
char prompt[160];
char buffer[64];
if (label == NULL ||
present == NULL ||
value == NULL) {
return false;
}
if (*present) {
(void)snprintf(
prompt,
sizeof(prompt),
"%s [%.1f] (Entrée=garder, -=effacer) : ",
label,
*value
);
} else {
(void)snprintf(
prompt,
sizeof(prompt),
"%s [vide] : ",
label
);
}
if (!prompt_text(
row,
prompt,
buffer,
sizeof(buffer),
true
)) {
return false;
}
if (buffer[0] == '\0') {
return true;
}
if (strcmp(buffer, "-") == 0) {
*present = false;
*value = 0.0;
return true;
}
if (!parse_double_positive(
buffer,
value
)) {
status_line(
"Nombre positif invalide.",
TRAINLOG_COLOR_ERROR
);
wait_key();
return false;
}
*present = true;
return true;
}
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;
}
static bool body_edit_record(
TrainlogDatabase *database,
const char *observation_id
)
{
TrainlogBodyObservationRecord record;
TrainlogBodyObservationInput input;
int row;
if (trainlog_database_get_body_observation(
database,
observation_id,
&record
) != TRAINLOG_STATUS_OK) {
return false;
}
body_input_from_record(
&record,
&input
);
draw_shell(
"Modifier relevé — 1/2",
"Entrée garde · - efface une mesure · Échap annule tout"
);
row = 4;
#define EDIT_BODY_VALUE(label_, flag_, value_) \
do { \
if (!prompt_optional_double_existing( \
row++, \
(label_), \
&(flag_), \
&(value_) \
)) { \
return false; \
} \
} while (0)
EDIT_BODY_VALUE(
"Poids kg",
input.has_body_weight,
input.body_weight_kg
);
EDIT_BODY_VALUE(
"Cou cm",
input.has_neck,
input.neck_cm
);
EDIT_BODY_VALUE(
"Épaules cm",
input.has_shoulders,
input.shoulders_cm
);
EDIT_BODY_VALUE(
"Poitrine cm",
input.has_chest,
input.chest_cm
);
EDIT_BODY_VALUE(
"Tour de taille cm",
input.has_waist,
input.waist_cm
);
EDIT_BODY_VALUE(
"Hanches cm",
input.has_hips,
input.hips_cm
);
draw_shell(
"Modifier relevé — 2/2",
"Entrée garde · - efface une mesure · Échap annule tout"
);
row = 4;
EDIT_BODY_VALUE(
"Bras gauche cm",
input.has_left_arm,
input.left_arm_cm
);
EDIT_BODY_VALUE(
"Bras droit cm",
input.has_right_arm,
input.right_arm_cm
);
EDIT_BODY_VALUE(
"Avant-bras gauche cm",
input.has_left_forearm,
input.left_forearm_cm
);
EDIT_BODY_VALUE(
"Avant-bras droit cm",
input.has_right_forearm,
input.right_forearm_cm
);
EDIT_BODY_VALUE(
"Cuisse gauche cm",
input.has_left_thigh,
input.left_thigh_cm
);
EDIT_BODY_VALUE(
"Cuisse droite cm",
input.has_right_thigh,
input.right_thigh_cm
);
EDIT_BODY_VALUE(
"Mollet gauche cm",
input.has_left_calf,
input.left_calf_cm
);
EDIT_BODY_VALUE(
"Mollet droit cm",
input.has_right_calf,
input.right_calf_cm
);
#undef EDIT_BODY_VALUE
if (trainlog_database_update_body_observation(
database,
&input
) == TRAINLOG_STATUS_OK) {
status_line(
"✓ Relevé corrigé.",
TRAINLOG_COLOR_SUCCESS
);
wait_key();
return true;
}
status_line(
"Correction impossible. Au moins une mesure doit rester présente.",
TRAINLOG_COLOR_ERROR
);
wait_key();
return false;
}
static void body_detail_value(
int row,
int column,
const char *label,
bool present,
double value,
const char *unit
)
{
if (present) {
mvprintw(
row,
column,
"%-24s %7.1f %s",
label,
value,
unit
);
} else {
mvprintw(
row,
column,
"%-24s %7s",
label,
""
);
}
}
static void screen_body_observation_detail(
TrainlogDatabase *database,
const char *observation_id
)
{
int page = 0;
for (;;) {
TrainlogBodyObservationRecord record;
char date[9];
bool decorated =
COLS >= 100 &&
LINES >= 34;
int frame_top =
decorated ? 8 : 3;
int key;
if (trainlog_database_get_body_observation(
database,
observation_id,
&record
) != TRAINLOG_STATUS_OK) {
status_line(
"Impossible de charger le relevé.",
TRAINLOG_COLOR_ERROR
);
wait_key();
return;
}
body_short_date(
record.observed_at,
date
);
if (decorated) {
erase();
box(stdscr, 0, 0);
section_ascii_header(
":: D E T A I L C O R P S ::"
);
focused_panel(
frame_top,
2,
LINES - 4,
COLS - 3,
page == 0
? "RELEVE — GENERAL"
: "RELEVE — MEMBRES",
true
);
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
mvprintw(
LINES - 2,
2,
"%.*s",
COLS - 4,
"←→ page e Modifier b/Échap retour"
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
} else {
draw_shell(
"TRAINLOG — Détail relevé",
"←→ page e Modifier b/Échap retour"
);
body_panel(
3,
2,
LINES - 4,
COLS - 3,
page == 0
? "RELEVE — GENERAL"
: "RELEVE — MEMBRES"
);
}
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvprintw(
frame_top + 2,
5,
"%s page %d/2",
date,
page + 1
);
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
if (page == 0) {
body_detail_value(
frame_top + 5,
6,
"Poids",
record.has_body_weight,
record.body_weight_kg,
"kg"
);
body_detail_value(
frame_top + 7,
6,
"Cou",
record.has_neck,
record.neck_cm,
"cm"
);
body_detail_value(
frame_top + 9,
6,
"Épaules",
record.has_shoulders,
record.shoulders_cm,
"cm"
);
body_detail_value(
frame_top + 11,
6,
"Poitrine",
record.has_chest,
record.chest_cm,
"cm"
);
body_detail_value(
frame_top + 13,
6,
"Tour de taille",
record.has_waist,
record.waist_cm,
"cm"
);
body_detail_value(
frame_top + 15,
6,
"Hanches",
record.has_hips,
record.hips_cm,
"cm"
);
} else {
body_detail_value(
frame_top + 5,
6,
"Bras gauche",
record.has_left_arm,
record.left_arm_cm,
"cm"
);
body_detail_value(
frame_top + 7,
6,
"Bras droit",
record.has_right_arm,
record.right_arm_cm,
"cm"
);
body_detail_value(
frame_top + 9,
6,
"Avant-bras gauche",
record.has_left_forearm,
record.left_forearm_cm,
"cm"
);
body_detail_value(
frame_top + 11,
6,
"Avant-bras droit",
record.has_right_forearm,
record.right_forearm_cm,
"cm"
);
body_detail_value(
frame_top + 13,
6,
"Cuisse gauche",
record.has_left_thigh,
record.left_thigh_cm,
"cm"
);
body_detail_value(
frame_top + 15,
6,
"Cuisse droite",
record.has_right_thigh,
record.right_thigh_cm,
"cm"
);
body_detail_value(
frame_top + 17,
6,
"Mollet gauche",
record.has_left_calf,
record.left_calf_cm,
"cm"
);
body_detail_value(
frame_top + 19,
6,
"Mollet droit",
record.has_right_calf,
record.right_calf_cm,
"cm"
);
}
refresh();
key = getch();
if (key == 'b' ||
key == 'B' ||
key == 27) {
return;
}
if (key == KEY_LEFT ||
key == KEY_RIGHT) {
page =
page == 0
? 1
: 0;
continue;
}
if (key == 'e' ||
key == 'E') {
(void)body_edit_record(
database,
observation_id
);
}
}
}
static void screen_body(
TrainlogDatabase *database
)
{
TrainlogBodyObservationRecord
records[MAX_BODY_OBSERVATIONS];
size_t selected = 0U;
int nav_selected = 4;
int focus = 1;
for (;;) {
TrainlogBodyMetricPoint
weight_points[MAX_BODY_METRIC_POINTS];
size_t count = 0U;
size_t weight_count = 0U;
size_t top = 0U;
size_t index;
bool large_layout =
COLS >= 100 &&
LINES >= 30;
int graph_top =
large_layout ? 11 : 3;
int graph_bottom =
large_layout ? 19 : 11;
int list_top =
graph_bottom + 1;
int list_bottom =
LINES - 4;
int first_row =
list_top + 1;
int visible_rows =
list_bottom -
first_row;
int key;
if (visible_rows < 1) {
return;
}
if (trainlog_database_list_body_observations(
database,
records,
MAX_BODY_OBSERVATIONS,
&count
) != TRAINLOG_STATUS_OK) {
return;
}
(void)trainlog_database_list_body_metric_points(
database,
TRAINLOG_BODY_METRIC_WEIGHT,
weight_points,
MAX_BODY_METRIC_POINTS,
&weight_count
);
if (count > 0U &&
selected >= count) {
selected =
count - 1U;
}
if (count > 0U &&
selected >=
(size_t)visible_rows) {
top =
selected -
(size_t)visible_rows +
1U;
}
if (large_layout) {
erase();
box(stdscr, 0, 0);
section_ascii_header(
":: C O R P S ::"
);
primary_top_navbar(
4,
nav_selected,
focus == 0
);
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
mvprintw(
LINES - 2,
2,
"%.*s",
COLS - 4,
"Tab zone ↑↓/PgUp/PgDn relevés ←→ menu Entrée détail e Modifier a ajouter g vue globale 0/Home accueil F1-F4 direct"
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
} else {
draw_shell(
"TRAINLOG — Corps",
"↑↓ choisir Entrée détail e Modifier a ajouter g vue globale b/Échap retour"
);
}
body_panel(
graph_top,
2,
graph_bottom,
COLS - 3,
"EVOLUTION DU POIDS"
);
if (weight_count == 0U) {
mvprintw(
graph_top + 3,
6,
"Aucune donnée de poids."
);
} else {
draw_body_metric_graph(
graph_top + 1,
graph_bottom -
graph_top -
2,
weight_points,
weight_count,
"kg"
);
}
if (large_layout) {
focused_panel(
list_top,
2,
list_bottom,
COLS - 3,
"RELEVES ENREGISTRES",
focus == 1
);
} else {
body_panel(
list_top,
2,
list_bottom,
COLS - 3,
"RELEVES ENREGISTRES"
);
}
if (count == 0U) {
mvprintw(
first_row + 1,
6,
"Aucun relevé. Appuyez sur a pour en ajouter un."
);
}
for (index = 0U;
index < (size_t)visible_rows &&
top + index < count;
++index) {
size_t absolute =
top + index;
char date[9];
char summary[128];
int row =
first_row +
(int)index;
body_short_date(
records[absolute].observed_at,
date
);
body_summary_text(
&records[absolute],
summary,
sizeof(summary)
);
if (focus == 1 &&
absolute == selected) {
attron(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
mvprintw(
row,
5,
" %-8s %.*s ",
date,
COLS - 20,
summary
);
if (focus == 1 &&
absolute == selected) {
attroff(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
}
body_draw_scrollbar(
first_row,
list_bottom - 1,
COLS - 5,
selected,
count,
(size_t)visible_rows
);
refresh();
key = getch();
if (large_layout &&
(key == '\t' ||
key == KEY_BTAB)) {
focus =
focus == 0
? 1
: 0;
continue;
}
if (large_layout &&
primary_top_nav_forward(key)) {
return;
}
if (key == 'b' ||
key == 'B' ||
key == 27) {
return;
}
if (large_layout &&
focus == 0) {
if (key == KEY_LEFT) {
nav_selected =
nav_selected > 0
? nav_selected - 1
: 5;
} else if (key == KEY_RIGHT) {
nav_selected =
nav_selected < 5
? nav_selected + 1
: 0;
} else if (
key == '\n' ||
key == KEY_ENTER
) {
if (primary_top_nav_activate(
nav_selected
)) {
return;
}
}
continue;
}
if (key == KEY_UP &&
count > 0U) {
selected =
selected > 0U
? selected - 1U
: 0U;
continue;
}
if (key == KEY_DOWN &&
count > 0U) {
selected =
selected + 1U < count
? selected + 1U
: count - 1U;
continue;
}
if (key == KEY_PPAGE &&
count > 0U) {
size_t jump =
(size_t)visible_rows;
selected =
selected > jump
? selected - jump
: 0U;
continue;
}
if (key == KEY_NPAGE &&
count > 0U) {
size_t jump =
(size_t)visible_rows;
selected =
selected + jump < count
? selected + jump
: count - 1U;
continue;
}
if ((key == '\n' ||
key == KEY_ENTER) &&
count > 0U) {
screen_body_observation_detail(
database,
records[selected].observation_id
);
continue;
}
if ((key == 'e' ||
key == 'E') &&
count > 0U) {
(void)body_edit_record(
database,
records[selected].observation_id
);
continue;
}
if (key == 'a' ||
key == 'A') {
add_body_observation(database);
selected = 0U;
continue;
}
if (key == 'g' ||
key == 'G') {
draw_shell(
"TRAINLOG — Corps — Vue globale",
"Une touche pour revenir"
);
draw_global_body_overlay(
database
);
refresh();
(void)getch();
}
}
}
/* TRAINLOG_SYNC_TUI */
/* TRAINLOG_SHARED_SYNC_ENGINE_V1 */
#define SYNC_HISTORY_CAPACITY 64U
#define SYNC_HISTORY_TEXT_MAX TRAINLOG_SYNC_SUMMARY_MAX
#define SYNC_DETAIL_LINE_CAPACITY 160U
#define SYNC_DETAIL_TEXT_CAPACITY 16384U
typedef struct TrainlogSyncHistoryEntry {
char sync_id[
TRAINLOG_ID_MAX + 1U
];
char timestamp[17];
bool success;
char summary[
SYNC_HISTORY_TEXT_MAX + 1U
];
} TrainlogSyncHistoryEntry;
static void session_history_datetime(
const char *timestamp,
char output[17]
)
{
if (
timestamp == NULL ||
strlen(timestamp) < 16U
) {
(void)snprintf(
output,
17U,
"%s",
"--/--/---- --:--"
);
return;
}
(void)snprintf(
output,
17U,
"%c%c/%c%c/%c%c%c%c %c%c:%c%c",
timestamp[8],
timestamp[9],
timestamp[5],
timestamp[6],
timestamp[0],
timestamp[1],
timestamp[2],
timestamp[3],
timestamp[11],
timestamp[12],
timestamp[14],
timestamp[15]
);
}
static bool sync_history_path(
char *output,
size_t output_size
)
{
const char *data_home =
getenv(
"XDG_DATA_HOME"
);
const char *home =
getenv(
"HOME"
);
int written;
if (
output == NULL ||
output_size == 0U
) {
return false;
}
if (
data_home != NULL &&
data_home[0] != '\0'
) {
written =
snprintf(
output,
output_size,
"%s/trainlog/sync_history.log",
data_home
);
} else if (
home != NULL &&
home[0] != '\0'
) {
written =
snprintf(
output,
output_size,
"%s/.local/share/trainlog/sync_history.log",
home
);
} else {
return false;
}
return
written >= 0 &&
(size_t)written <
output_size;
}
static bool sync_runs_path(
const char *sync_id,
char *output,
size_t output_size
)
{
const char *data_home =
getenv(
"XDG_DATA_HOME"
);
const char *home =
getenv(
"HOME"
);
int written;
if (
sync_id == NULL ||
sync_id[0] == '\0' ||
output == NULL ||
output_size == 0U
) {
return false;
}
if (
data_home != NULL &&
data_home[0] != '\0'
) {
written =
snprintf(
output,
output_size,
"%s/trainlog/sync_runs/%s.txt",
data_home,
sync_id
);
} else if (
home != NULL &&
home[0] != '\0'
) {
written =
snprintf(
output,
output_size,
"%s/.local/share/trainlog/sync_runs/%s.txt",
home,
sync_id
);
} else {
return false;
}
return
written >= 0 &&
(size_t)written <
output_size;
}
static void sync_history_load(
TrainlogSyncHistoryEntry *output,
size_t capacity,
size_t *output_count
)
{
char path[
PATH_MAX + 1U
];
TrainlogSyncHistoryEntry
ring[SYNC_HISTORY_CAPACITY];
size_t count = 0U;
size_t next = 0U;
size_t copied;
FILE *file;
char line[768];
if (
output_count == NULL ||
(
capacity > 0U &&
output == NULL
)
) {
return;
}
*output_count = 0U;
if (
!sync_history_path(
path,
sizeof(path)
)
) {
return;
}
file =
fopen(
path,
"rb"
);
if (file == NULL) {
return;
}
(void)memset(
ring,
0,
sizeof(ring)
);
while (
fgets(
line,
sizeof(line),
file
) != NULL
) {
char *first;
char *second;
char *third;
char *newline;
const char *sync_id;
const char *timestamp;
const char *status_text;
const char *summary;
TrainlogSyncHistoryEntry *entry;
newline =
strchr(
line,
'\n'
);
if (newline != NULL) {
*newline = '\0';
}
first =
strchr(
line,
'\t'
);
if (first == NULL) {
continue;
}
*first = '\0';
second =
strchr(
first + 1,
'\t'
);
if (second == NULL) {
continue;
}
*second = '\0';
third =
strchr(
second + 1,
'\t'
);
if (third != NULL) {
*third = '\0';
sync_id = line;
timestamp = first + 1;
status_text = second + 1;
summary = third + 1;
} else {
sync_id = "";
timestamp = line;
status_text = first + 1;
summary = second + 1;
}
entry =
&ring[next];
(void)snprintf(
entry->sync_id,
sizeof(entry->sync_id),
"%s",
sync_id
);
(void)snprintf(
entry->timestamp,
sizeof(entry->timestamp),
"%s",
timestamp
);
entry->success =
strcmp(
status_text,
"1"
) == 0;
(void)snprintf(
entry->summary,
sizeof(entry->summary),
"%s",
summary
);
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 void screen_sync_run_detail(
const char *sync_id
)
{
char path[
PATH_MAX + 1U
];
char text[
SYNC_DETAIL_TEXT_CAPACITY
];
char *lines[
SYNC_DETAIL_LINE_CAPACITY
];
size_t line_count = 0U;
size_t offset = 0U;
FILE *file;
size_t used;
if (
!sync_runs_path(
sync_id,
path,
sizeof(path)
)
) {
return;
}
file =
fopen(
path,
"rb"
);
if (file == NULL) {
return;
}
used =
fread(
text,
1U,
sizeof(text) - 1U,
file
);
text[used] = '\0';
(void)fclose(
file
);
if (used > 0U) {
char *cursor = text;
lines[line_count] =
cursor;
++line_count;
while (
*cursor != '\0' &&
line_count <
SYNC_DETAIL_LINE_CAPACITY
) {
if (*cursor == '\n') {
*cursor = '\0';
if (
cursor[1] != '\0'
) {
lines[line_count] =
cursor + 1;
++line_count;
}
}
++cursor;
}
}
for (;;) {
bool large_layout =
COLS >= 100 &&
LINES >= 30;
int panel_top =
large_layout
? 8
: 3;
int panel_bottom =
LINES - 4;
int first_row =
panel_top + 2;
int visible =
panel_bottom -
first_row;
int key;
size_t index;
if (visible < 1) {
return;
}
erase();
box(
stdscr,
0,
0
);
if (large_layout) {
section_ascii_header(
":: S Y N C S H O W ::"
);
focused_panel(
panel_top,
2,
panel_bottom,
COLS - 3,
"DETAIL SYNCHRONISATION",
true
);
} else {
draw_shell(
"TRAINLOG — Sync show",
"↑↓ défiler PgUp/PgDn page b/Échap retour"
);
}
for (
index = 0U;
index < (size_t)visible &&
offset + index <
line_count;
++index
) {
mvprintw(
first_row +
(int)index,
large_layout
? 5
: 4,
"%.*s",
COLS -
(
large_layout
? 10
: 8
),
lines[
offset +
index
]
);
}
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
mvprintw(
LINES - 2,
2,
"%.*s",
COLS - 4,
"↑↓ défiler PgUp/PgDn page b/Échap retour"
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
refresh();
key = getch();
if (
key == 'b' ||
key == 'B' ||
key == 27 ||
key == '\n' ||
key == KEY_ENTER
) {
return;
}
if (
key == KEY_UP &&
offset > 0U
) {
--offset;
} else if (
key == KEY_DOWN &&
offset +
(size_t)visible <
line_count
) {
++offset;
} else if (
key == KEY_PPAGE
) {
size_t jump =
(size_t)visible;
offset =
offset > jump
? offset - jump
: 0U;
} else if (
key == KEY_NPAGE
) {
size_t jump =
(size_t)visible;
if (
offset + jump <
line_count
) {
offset += jump;
}
if (
line_count >
(size_t)visible &&
offset +
(size_t)visible >
line_count
) {
offset =
line_count -
(size_t)visible;
}
}
}
}
static double sync_bytes_to_gib(
uint64_t bytes
)
{
return
(double)bytes /
(
1024.0 *
1024.0 *
1024.0
);
}
static void screen_sync(
TrainlogDatabase *database
)
{
size_t selected = 0U;
int nav_selected = 5;
int focus = 1;
TrainlogSyncDeviceInfo device;
TrainlogStatus probe_status =
TRAINLOG_STATUS_NOT_FOUND;
bool refresh_device = true;
(void)database;
(void)memset(
&device,
0,
sizeof(device)
);
for (;;) {
TrainlogSyncHistoryEntry
history[SYNC_HISTORY_CAPACITY];
size_t history_count = 0U;
bool large_layout =
COLS >= 100 &&
LINES >= 30;
int key;
if (refresh_device) {
probe_status =
trainlog_sync_probe(
&device
);
refresh_device = false;
}
sync_history_load(
history,
SYNC_HISTORY_CAPACITY,
&history_count
);
if (
history_count > 0U &&
selected >= history_count
) {
selected =
history_count - 1U;
}
erase();
box(
stdscr,
0,
0
);
if (large_layout) {
size_t index;
size_t top = 0U;
int history_top = 18;
int history_bottom =
LINES - 4;
int visible_rows =
history_bottom -
history_top -
2;
section_ascii_header(
":: S Y N C ::"
);
primary_top_navbar(
5,
nav_selected,
focus == 0
);
focused_panel(
11,
2,
16,
COLS - 3,
"APPAREIL CONNECTE",
false
);
if (
probe_status ==
TRAINLOG_STATUS_OK &&
device.connected &&
device.storage_ready
) {
mvprintw(
12,
5,
"✓ MTP direct connecté"
);
mvprintw(
13,
5,
"%s %s",
device.device.vendor,
device.device.model
);
mvprintw(
14,
5,
"Stockage interne : %.2f GiB libres / %.2f GiB",
sync_bytes_to_gib(
device.storage
.free_space_bytes
),
sync_bytes_to_gib(
device.storage
.max_capacity_bytes
)
);
} else if (
probe_status ==
TRAINLOG_STATUS_CONFLICT
) {
mvprintw(
13,
5,
"Service de synchronisation occupé."
);
} else {
mvprintw(
13,
5,
"Aucun appareil MTP Trainlog détecté."
);
}
focused_panel(
history_top,
2,
history_bottom,
COLS - 3,
"HISTORIQUE DES SYNCHRONISATIONS",
focus == 1
);
if (
history_count == 0U
) {
mvprintw(
history_top + 2,
5,
"Aucune synchronisation enregistrée."
);
} else {
if (visible_rows < 1) {
visible_rows = 1;
}
if (
selected >=
(size_t)visible_rows
) {
top =
selected -
(size_t)visible_rows +
1U;
}
for (
index = 0U;
index <
(size_t)visible_rows &&
top + index <
history_count;
++index
) {
size_t absolute =
top + index;
int row =
history_top +
2 +
(int)index;
if (
focus == 1 &&
absolute == selected
) {
attron(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
mvprintw(
row,
5,
" %-16s %c %-*.*s ",
history[absolute]
.timestamp,
history[absolute]
.success
? '+'
: '!',
COLS - 28,
COLS - 28,
history[absolute]
.summary
);
if (
focus == 1 &&
absolute == selected
) {
attroff(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
}
}
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
mvprintw(
LINES - 2,
2,
"%.*s",
COLS - 4,
"Tab zone ←→ menu ↑↓ historique Entrée détail s synchroniser r actualiser b/Échap retour"
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_MUTED
)
);
} else {
draw_shell(
"TRAINLOG — Sync",
"↑↓ historique Entrée détail s synchroniser r actualiser b/Échap retour"
);
if (
probe_status ==
TRAINLOG_STATUS_OK &&
device.connected
) {
mvprintw(
4,
4,
"✓ %s %s",
device.device.vendor,
device.device.model
);
} else {
mvprintw(
4,
4,
"Aucun appareil MTP."
);
}
if (
history_count == 0U
) {
mvprintw(
7,
4,
"Aucune synchronisation."
);
} else {
size_t index;
size_t limit =
history_count < 8U
? history_count
: 8U;
for (
index = 0U;
index < limit;
++index
) {
if (
index == selected
) {
attron(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
mvprintw(
7 + (int)index,
4,
"%-16s %c %.*s",
history[index]
.timestamp,
history[index]
.success
? '+'
: '!',
COLS - 25,
history[index]
.summary
);
if (
index == selected
) {
attroff(
A_REVERSE |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
}
}
}
}
refresh();
key = getch();
if (
key == 's' ||
key == 'S'
) {
TrainlogSyncReport report;
TrainlogStatus status;
status_line(
"Synchronisation bidirectionnelle en cours...",
TRAINLOG_COLOR_WARNING
);
refresh();
status =
trainlog_sync_run(
TRAINLOG_SYNC_TRIGGER_TUI,
false,
&report
);
if (
status ==
TRAINLOG_STATUS_OK &&
report.success
) {
status_line(
report.summary,
TRAINLOG_COLOR_SUCCESS
);
} else {
status_line(
report.error[0] != '\0'
? report.error
: "Synchronisation échouée.",
TRAINLOG_COLOR_ERROR
);
}
refresh();
(void)getch();
refresh_device = true;
continue;
}
if (
key == 'r' ||
key == 'R'
) {
refresh_device = true;
continue;
}
if (
key == 'b' ||
key == 'B' ||
key == 27
) {
return;
}
if (
large_layout &&
(
key == '\t' ||
key == KEY_BTAB
)
) {
focus =
focus == 0
? 1
: 0;
continue;
}
if (
large_layout &&
focus == 0
) {
if (
key == KEY_LEFT
) {
nav_selected =
nav_selected > 0
? nav_selected - 1
: 5;
} else if (
key == KEY_RIGHT
) {
nav_selected =
nav_selected < 5
? nav_selected + 1
: 0;
} else if (
key == '\n' ||
key == KEY_ENTER
) {
if (
nav_selected == 5
) {
focus = 1;
} else if (
primary_top_nav_activate(
nav_selected
)
) {
return;
}
}
continue;
}
if (
history_count > 0U &&
(
key == '\n' ||
key == KEY_ENTER
)
) {
if (
history[selected]
.sync_id[0] != '\0'
) {
screen_sync_run_detail(
history[selected]
.sync_id
);
}
continue;
}
if (
key == KEY_UP &&
history_count > 0U
) {
selected =
selected > 0U
? selected - 1U
: history_count - 1U;
} else if (
key == KEY_DOWN &&
history_count > 0U
) {
selected =
selected + 1U <
history_count
? selected + 1U
: 0U;
} else if (
key == '0' ||
key == KEY_HOME
) {
return;
} else if (
key == '1' ||
key == KEY_F(1) ||
key == '2' ||
key == KEY_F(2) ||
key == '3' ||
key == KEY_F(3) ||
key == '4' ||
key == KEY_F(4)
) {
if (
primary_top_nav_forward(
key
)
) {
return;
}
}
}
}
int trainlog_tui_run(TrainlogDatabase *database)
{
if (database == NULL) {
return 1;
}
(void)setlocale(LC_ALL, "");
if (initscr() == NULL) {
return 1;
}
cbreak();
noecho();
keypad(stdscr, true);
curs_set(0);
trainlog_theme_initialize();
for (;;) {
DashboardAction action;
if (LINES < 20 || COLS < 72) {
int key;
erase();
mvprintw(
1,
2,
"Terminal trop petit — minimum 72x20."
);
mvprintw(3, 2, "q pour quitter");
refresh();
key = getch();
if (key == 'q' || key == 'Q') {
break;
}
continue;
}
action = screen_dashboard(database);
switch (action) {
case DASHBOARD_NEW_SESSION:
screen_new_session(database);
break;
case DASHBOARD_HISTORY:
screen_history(database);
break;
case DASHBOARD_EXERCISES:
screen_exercises(database);
break;
case DASHBOARD_BODY:
screen_body(database);
break;
case DASHBOARD_SYNC:
screen_sync(database);
break;
case DASHBOARD_QUIT:
endwin();
return 0;
default:
break;
}
}
endwin();
return 0;
}