projet crepp_git/crepp-projects/station-meteo/firmware/receiver/v1.0 · branche main
// Tabs.cpp - Dessin des onglets de l'interface
#include "Tabs.h"
#include "WeatherData.h"
#include "Theme.h"
#include "Widget.h"
#include "Widgets.h"
#include "TabManager.h"
#include <stdarg.h>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <math.h>

// ─── Constantes layout ───────────────────────────────────────────────────────
#define TAB_BAR_HEIGHT      30
#define BME280_UPDATE_MS    60000

// ─── Données onglet Intérieur ────────────────────────────────────────────────
IndoorSensor gSensors[4] = {
    {"Salon",   21.5, 85, true},
    {"Chambre", 19.2, 72, true},
    {"Cuisine", 22.8, 60, true},
    {"Garage",  14.5, 45, false}
};
int gSensorCount       = 4;
int gCurrentSensorPage = 0;

// ─── Données onglet Debug ────────────────────────────────────────────────────
char debugBuffer[2048];
int  debugIndex = 0;

// ─── Gestionnaires de widgets ────────────────────────────────────────────────
WidgetManager              weatherWidgetManager;
UpdatableMainTempWidget*   mainTempWidget = nullptr;
UpdatableForecastWidget*   forecastWidget = nullptr;
UpdatableChartWidget*      chartWidget    = nullptr;
UpdatableSunWidget*        sunWidget      = nullptr;
UpdatableAQIWidget*        aqiWidget      = nullptr;

// ─── Debug log ───────────────────────────────────────────────────────────────
void addDebugMessage(const char* format, ...)
{
    char buffer[128];
    va_list args;
    va_start(args, format);
    vsnprintf(buffer, sizeof(buffer), format, args);
    va_end(args);

    int len = strlen(buffer);
    if (debugIndex + len + 2 < (int)sizeof(debugBuffer)) {
        if (debugIndex > 0) debugBuffer[debugIndex++] = '\n';
        memcpy(&debugBuffer[debugIndex], buffer, len);
        debugIndex += len;
        debugBuffer[debugIndex] = '\0';
    } else {
        int half = sizeof(debugBuffer) / 2;
        memmove(debugBuffer, debugBuffer + half, half);
        debugIndex = half;
        addDebugMessage(buffer);
    }
}

// ─── Helpers ─────────────────────────────────────────────────────────────────
static void hline(TFT_eSPI& tft, int x, int y, int w, uint16_t c) {
    tft.drawFastHLine(x, y, w, c);
}
static void vline(TFT_eSPI& tft, int x, int y, int h, uint16_t c) {
    tft.drawFastVLine(x, y, h, c);
}

// Extrait "HH:MM" depuis "Jeu. 29 mai  14:32"
static void extractTime(const char* src, char* out5)
{
    int len = src ? (int)strlen(src) : 0;
    if (len >= 5) {
        strncpy(out5, src + len - 5, 5);
        out5[5] = '\0';
    } else {
        strcpy(out5, "--:--");
    }
}

// Extrait la partie date "Jeu. 29 mai" depuis "Jeu. 29 mai  14:32"
static void extractDate(const char* src, char* outDate, int maxLen)
{
    if (!src) { strncpy(outDate, "--", maxLen); return; }
    int len = (int)strlen(src);
    if (len > 8) {
        int cutPos = len - 7; // "  HH:MM" = 7 chars
        if (cutPos > 0 && cutPos < maxLen) {
            strncpy(outDate, src, cutPos);
            while (cutPos > 0 && outDate[cutPos-1] == ' ') cutPos--;
            outDate[cutPos] = '\0';
            return;
        }
    }
    strncpy(outDate, src, maxLen - 1);
    outDate[maxLen - 1] = '\0';
}

// ─── Icône météo ──────────────────────────────────────────────────────────────
static void drawWeatherIconByType(TFT_eSPI& tft, int cx, int cy, int r, int iconType)
{
    switch (iconType) {
    case 0: // Soleil
        tft.fillCircle(cx, cy, r - 2, C_ACCENT_GOLD);
        for (int a = 0; a < 8; a++) {
            float angle = a * PI / 4.0f;
            tft.drawLine(cx + (int)((r)     * cosf(angle)),
                         cy + (int)((r)     * sinf(angle)),
                         cx + (int)((r + 5) * cosf(angle)),
                         cy + (int)((r + 5) * sinf(angle)),
                         C_ACCENT_GOLD);
        }
        break;
    case 1: // Nuages
        tft.fillCircle(cx - 5, cy + 2, r - 5, C_MUTED);
        tft.fillCircle(cx + 5, cy + 2, r - 6, C_MUTED);
        tft.fillCircle(cx,     cy,     r - 4, 0xCE79);
        break;
    case 2: // Pluie
        tft.fillCircle(cx - 4, cy - 3, r - 5, C_MUTED);
        tft.fillCircle(cx + 4, cy - 3, r - 6, C_MUTED);
        tft.fillCircle(cx,     cy - 5, r - 4, 0xCE79);
        for (int d = 0; d < 3; d++) {
            int lx = cx - 6 + d * 6;
            tft.drawLine(lx, cy + 4, lx - 2, cy + 11, C_ACCENT_BLUE);
        }
        break;
    case 3: // Soleil + nuage
        tft.fillCircle(cx + r - 5, cy - r + 5, r - 7, C_ACCENT_GOLD);
        tft.fillCircle(cx - 3, cy + 2, r - 5, C_MUTED);
        tft.fillCircle(cx + 4, cy + 2, r - 6, 0x9CD3);
        tft.fillCircle(cx,     cy,     r - 5, 0xCE79);
        break;
    default:
        tft.fillCircle(cx, cy, r - 3, C_MUTED);
        break;
    }
}

static void drawWeatherIconByCondition(TFT_eSPI& tft, int cx, int cy, int r,
                                        const char* condition)
{
    int type = 1;
    if (!condition) { drawWeatherIconByType(tft, cx, cy, r, type); return; }
    if (strstr(condition, "Soleil") || strstr(condition, "Clair")
     || strstr(condition, "Agreable") || strstr(condition, "Sec"))   type = 0;
    else if (strstr(condition, "Pluie") || strstr(condition, "pluie")) type = 2;
    else if (strstr(condition, "nuageux") || strstr(condition, "Nuageux")
          || strstr(condition, "Humide"))                              type = 1;
    drawWeatherIconByType(tft, cx, cy, r, type);
}

// ─── Section SOLEIL ───────────────────────────────────────────────────────────
static void drawSunSection(TFT_eSPI& tft, int x, int y, int w, int h)
{
    tft.fillRoundRect(x, y, w, h, RADIUS, C_PANEL);
    tft.drawRoundRect(x, y, w, h, RADIUS, C_BORDER);

    tft.setTextSize(1);
    tft.setTextColor(C_MUTED, C_PANEL);
    tft.setCursor(x + 6, y + 5);
    tft.print("SOLEIL");

    // Lever
    tft.fillTriangle(x + 8, y + 28, x + 14, y + 20, x + 20, y + 28, C_ACCENT_GOLD);
    tft.setTextColor(C_WHITE, C_PANEL);
    tft.setCursor(x + 24, y + 20);
    tft.print(gWeather.sunriseTime ? gWeather.sunriseTime : "--:--");

    // Coucher
    tft.fillTriangle(x + 8, y + 42, x + 14, y + 50, x + 20, y + 42, C_ACCENT_RED);
    tft.setTextColor(C_WHITE, C_PANEL);
    tft.setCursor(x + 24, y + 40);
    tft.print(gWeather.sunsetTime ? gWeather.sunsetTime : "--:--");
}

// ─── Section AQI + UV ─────────────────────────────────────────────────────────
static void drawAqiUvSection(TFT_eSPI& tft, int x, int y, int w, int h)
{
    tft.fillRoundRect(x, y, w, h, RADIUS, C_PANEL);
    tft.drawRoundRect(x, y, w, h, RADIUS, C_BORDER);

    tft.setTextSize(1);
    tft.setTextColor(C_MUTED, C_PANEL);
    tft.setCursor(x + 6, y + 5);
    tft.print("AIR & UV");

    uint16_t aqiColor = (gWeather.aqi < 50)  ? C_ACCENT_GREEN :
                        (gWeather.aqi < 100) ? C_ACCENT_GOLD : C_ACCENT_RED;
    tft.setTextSize(2);
    tft.setTextColor(aqiColor, C_PANEL);
    tft.setCursor(x + 6, y + 18);
    tft.print(gWeather.aqi);
    tft.setTextSize(1);
    tft.setTextColor(C_MUTED, C_PANEL);
    tft.print(" AQI");

    uint16_t uvColor = (gWeather.uvIndex <= 2) ? C_ACCENT_GREEN :
                       (gWeather.uvIndex <= 5) ? C_ACCENT_GOLD : C_ACCENT_RED;
    tft.setTextSize(2);
    tft.setTextColor(uvColor, C_PANEL);
    tft.setCursor(x + 6, y + 40);
    tft.print(gWeather.uvIndex);
    tft.setTextSize(1);
    tft.setTextColor(C_MUTED, C_PANEL);
    tft.print(" UV");
}

// ─── Mini graphique horaire ───────────────────────────────────────────────────
static void drawHourlyChart(TFT_eSPI& tft, int x, int y, int w, int h)
{
    tft.fillRoundRect(x, y, w, h, RADIUS, C_PANEL);
    tft.drawRoundRect(x, y, w, h, RADIUS, C_BORDER);

    int tMin = gHourly[0].temp, tMax = gHourly[0].temp;
    for (int i = 1; i < 8; i++) {
        if (gHourly[i].temp < tMin) tMin = gHourly[i].temp;
        if (gHourly[i].temp > tMax) tMax = gHourly[i].temp;
    }
    int range = max(1, tMax - tMin);

    int chartX = x + 4, chartY = y + 6;
    int chartW = w - 8, chartH = h - 18;
    int stepX  = chartW / 7;

    for (int i = 0; i < 7; i++) {
        int x1 = chartX + i       * stepX;
        int x2 = chartX + (i + 1) * stepX;
        int y1 = chartY + chartH - (gHourly[i].temp   - tMin) * chartH / range;
        int y2 = chartY + chartH - (gHourly[i+1].temp - tMin) * chartH / range;
        tft.drawLine(x1, y1, x2, y2, C_ACCENT_BLUE);
        tft.fillCircle(x1, y1, 2, C_WHITE);
    }
    int xl = chartX + 7 * stepX;
    int yl = chartY + chartH - (gHourly[7].temp - tMin) * chartH / range;
    tft.fillCircle(xl, yl, 2, C_WHITE);

    for (int i = 0; i < 8; i += 2) {
        int lx = chartX + i * stepX - 4;
        tft.setTextSize(1);
        tft.setTextColor(C_DIM, C_PANEL);
        tft.setCursor(lx, y + h - 10);
        if (gHourly[i].label) tft.print(gHourly[i].label);
    }
}

// ─── Prévisions 5 jours ───────────────────────────────────────────────────────
static void drawForecastBar(TFT_eSPI& tft, int x, int y, int w, int h)
{
    tft.fillRoundRect(x, y, w, h, RADIUS, C_PANEL);
    tft.drawRoundRect(x, y, w, h, RADIUS, C_BORDER);

    int itemW = w / 5;
    for (int i = 0; i < 5; i++) {
        int ix = x + i * itemW;
        uint16_t bg = (i == 0) ? 0x1082 : C_PANEL;
        if (i == 0)
            tft.fillRoundRect(ix + 2, y + 2, itemW - 4, h - 4, 3, bg);

        tft.setTextSize(1);
        tft.setTextColor(i == 0 ? C_ACCENT_BLUE : C_MUTED, bg);
        const char* lbl = gForecast[i].label ? gForecast[i].label : "?";
        tft.setCursor(ix + (itemW - (int)strlen(lbl) * 6) / 2, y + 5);
        tft.print(lbl);

        uint16_t dotColor;
        switch (gForecast[i].iconType) {
            case 0: dotColor = C_ACCENT_GOLD; break;
            case 2: dotColor = C_ACCENT_BLUE; break;
            default: dotColor = C_MUTED; break;
        }
        tft.fillCircle(ix + itemW / 2, y + h / 2 + 2, 4, dotColor);

        char tStr[6]; itoa(gForecast[i].temp, tStr, 10);
        tft.setTextSize(1);
        tft.setTextColor(C_WHITE, bg);
        tft.setCursor(ix + (itemW - (int)strlen(tStr) * 6) / 2, y + h - 12);
        tft.print(tStr);
        tft.setTextColor(C_DIM, bg);
        tft.print("o");

        if (i < 4) vline(tft, ix + itemW - 1, y + 4, h - 8, C_BORDER);
    }
}

// ════════════════════════════════════════════════════════════════════════════
//  ONGLET MÉTÉO
// ════════════════════════════════════════════════════════════════════════════
void drawWeatherTab(TFT_eSPI& tft)
{
    const int PAD = 6;
    const int TOP = TAB_BAR_HEIGHT + 2;
    const int W   = SCREEN_W;

    // ── Zone 1 : HEURE (grand) + DATE + VILLE ───────────────────────────────
    const int Z1H = 52;
    tft.fillRect(0, TOP, W, Z1H, C_BG);

    char heureStr[6];
    extractTime(gWeather.time, heureStr);

    tft.setTextSize(4);
    int heureW = (int)strlen(heureStr) * 6 * 4;
    tft.setTextColor(C_WHITE, C_BG);
    tft.setCursor((W - heureW) / 2, TOP + 6);
    tft.print(heureStr);

    char dateStr[32] = "";
    extractDate(gWeather.time, dateStr, sizeof(dateStr));
    tft.setTextSize(1);
    tft.setTextColor(C_ACCENT_GOLD, C_BG);
    tft.setCursor(PAD, TOP + Z1H - 12);
    tft.print(dateStr);

    if (gWeather.city) {
        tft.setTextColor(C_MUTED, C_BG);
        int locW = (int)strlen(gWeather.city) * 6;
        tft.setCursor(W - locW - PAD, TOP + Z1H - 12);
        tft.print(gWeather.city);
    }

    hline(tft, PAD, TOP + Z1H, W - PAD * 2, C_BORDER);
    int curY = TOP + Z1H + PAD;

    // ── Zone 2 : TEMPÉRATURE + ICÔNE + CONDITION ────────────────────────────
    const int Z2H = 58;
    const int LW  = W * 50 / 100;
    const int RW  = W - LW - PAD;

    tft.fillRect(0, curY, W, Z2H, C_BG);

    char bigTemp[8];
    itoa(gWeather.temp, bigTemp, 10);
    tft.setTextSize(5);
    tft.setTextColor(C_WHITE, C_BG);
    tft.setCursor(PAD, curY + 4);
    tft.print(bigTemp);
    tft.setTextSize(2);
    tft.setTextColor(C_MUTED, C_BG);
    tft.setCursor(PAD + (int)strlen(bigTemp) * 30, curY + 4);
    tft.print("o");

    tft.setTextSize(1);
    tft.setTextColor(C_MUTED, C_BG);
    tft.setCursor(PAD, curY + Z2H - 14);
    tft.print("Ressenti "); tft.print(gWeather.feelsLike);
    tft.print("C  "); tft.print(gWeather.tempMin);
    tft.print("-"); tft.print(gWeather.tempMax); tft.print("C");

    int iconCX = LW + RW / 2;
    drawWeatherIconByCondition(tft, iconCX, curY + 20, 18, gWeather.condition);
    tft.setTextSize(1);
    tft.setTextColor(C_MUTED, C_BG);
    if (gWeather.condition) {
        int condW = (int)strlen(gWeather.condition) * 6;
        tft.setCursor(LW + max(0, (RW - condW) / 2), curY + Z2H - 14);
        tft.print(gWeather.condition);
    }

    hline(tft, PAD, curY + Z2H, W - PAD * 2, C_BORDER);
    curY += Z2H + PAD;

    // ── Zone 3 : VENT + HUMIDITÉ + PRESSION ─────────────────────────────────
    const int Z3H = 34;
    const int C3W = (W - PAD * 4) / 3;
    tft.fillRect(0, curY, W, Z3H, C_BG);

    // Vent
    tft.setTextSize(1);
    tft.setTextColor(C_MUTED, C_BG);
    tft.setCursor(PAD, curY + 2);
    tft.print("VENT");
    tft.setTextSize(2);
    tft.setTextColor(C_ACCENT_GOLD, C_BG);
    tft.setCursor(PAD, curY + 13);
    tft.print(gWeather.windSpeed);
    tft.setTextSize(1);
    tft.setTextColor(C_MUTED, C_BG);
    tft.print("km/h");
    if (gWeather.windDir) {
        tft.setTextColor(C_WHITE, C_BG);
        tft.print(" ");
        tft.print(gWeather.windDir);
    }

    // Humidité
    int c2x = PAD * 2 + C3W;
    tft.setTextSize(1);
    tft.setTextColor(C_MUTED, C_BG);
    tft.setCursor(c2x, curY + 2);
    tft.print("HUMIDITE");
    tft.setTextSize(2);
    tft.setTextColor(C_ACCENT_BLUE, C_BG);
    tft.setCursor(c2x, curY + 13);
    tft.print(gWeather.humidity);
    tft.setTextSize(1);
    tft.setTextColor(C_MUTED, C_BG);
    tft.print("%");

    // Pression
    int c3x = PAD * 3 + C3W * 2;
    tft.setTextSize(1);
    tft.setTextColor(C_MUTED, C_BG);
    tft.setCursor(c3x, curY + 2);
    tft.print("PRESSION");
    tft.setTextSize(2);
    tft.setTextColor(C_ACCENT_GOLD, C_BG);
    tft.setCursor(c3x, curY + 13);
    tft.print(gWeather.pressure);
    tft.setTextSize(1);
    tft.setTextColor(C_MUTED, C_BG);
    tft.print("hPa");

    hline(tft, PAD, curY + Z3H, W - PAD * 2, C_BORDER);
    curY += Z3H + PAD;

    // ── Zone 4 : SOLEIL | AQI+UV  (2 panneaux) ──────────────────────────────
    const int Z4H  = 70;
    const int HALF = (W - PAD * 3) / 2;

    drawSunSection   (tft, PAD,            curY, HALF, Z4H);
    drawAqiUvSection (tft, PAD * 2 + HALF, curY, HALF, Z4H);

    curY += Z4H + PAD;

    // ── Zone 5 : Graphique températures 24h ─────────────────────────────────
    const int FORECAST_H = 38;
    int chartH = SCREEN_H - curY - FORECAST_H - PAD - 2;
    if (chartH >= 20) {
        drawHourlyChart(tft, 0, curY, W, chartH);
        curY += chartH + PAD;
    }

    // ── Zone 6 : Prévisions 5 jours ─────────────────────────────────────────
    int z6H = SCREEN_H - curY;
    if (z6H >= 18)
        drawForecastBar(tft, 0, curY, W, z6H);
}

void initWeatherTab()
{
    // Widgets en colonnes désactivés : ils dessinaient une disposition
    // différente (colonne gauche/droite) par-dessus le layout plein-largeur
    // de drawWeatherTab(), à des intervalles différents (10s/30s/60s),
    // ce qui donnait une deuxième courbe fantôme en arrière-plan.
    // drawWeatherTab() dessine désormais seul tout l'onglet Météo.
    /*
    int contentY = TAB_BAR_HEIGHT + 2;
    int contentH = SCREEN_H - contentY;

    mainTempWidget = new UpdatableMainTempWidget(0, contentY, LEFT_W, contentH);
    forecastWidget = new UpdatableForecastWidget(RIGHT_X, contentY, RIGHT_W, 68);
    chartWidget    = new UpdatableChartWidget(RIGHT_X, contentY + 73, RIGHT_W, 130);
    sunWidget      = new UpdatableSunWidget(RIGHT_X, contentY + 208, RIGHT_W / 2 - 2, contentH - 208);
    aqiWidget      = new UpdatableAQIWidget(RIGHT_X + RIGHT_W / 2 + 2, contentY + 208,
                                             RIGHT_W / 2 - 2, contentH - 208);

    weatherWidgetManager.addWidget(mainTempWidget);
    weatherWidgetManager.addWidget(forecastWidget);
    weatherWidgetManager.addWidget(chartWidget);
    weatherWidgetManager.addWidget(sunWidget);
    weatherWidgetManager.addWidget(aqiWidget);
    */
}

void updateWeatherTab()
{
    // Plus rien à mettre à jour ici : le rafraîchissement de l'onglet Météo
    // passe uniquement par drawWeatherTab(), appelé via TabManager::forceRedraw()
    // quand randomizeData() change les valeurs (voir main.cpp / loop1()).
    /*
    extern TFT_eSPI tft;
    weatherWidgetManager.updateAll(tft, false);
    */
}

// ════════════════════════════════════════════════════════════════════════════
//  ONGLET DEBUG
// ════════════════════════════════════════════════════════════════════════════

void drawDebugTab(TFT_eSPI& tft)
{
    int contentY = TAB_BAR_HEIGHT + 2;
    int startX   = 5;
    int width    = SCREEN_W - 10;
    int textH    = 12;

    // Efface toute la zone de contenu (pas seulement les cartes) pour ne
    // jamais laisser de marge/interstice montrer un reliquat d'un autre onglet.
    tft.fillRect(0, contentY - 2, SCREEN_W, SCREEN_H - contentY + 2, C_BG);

    tft.fillRoundRect(startX, contentY, width, 50, 3, C_PANEL);
    tft.drawRoundRect(startX, contentY, width, 50, 3, C_BORDER);

    tft.setTextSize(1);
    tft.setTextColor(C_ACCENT_BLUE, C_PANEL);

    tft.setCursor(startX + 5, contentY + 5);
    tft.print("SYSTEME: OK | Uptime: ");
    tft.print(millis() / 60000);
    tft.print(" min");

    tft.setCursor(startX + 5, contentY + 18);
    tft.print("Memoire libre: ");
    tft.print(rp2040.getFreeHeap());
    tft.print(" bytes");

    tft.setCursor(startX + 5, contentY + 31);
    tft.print("CPU: ");
    tft.print(clock_get_hz(clk_sys) / 1000000);
    tft.print(" MHz");

    int logStartY = contentY + 60;
    int logH = SCREEN_H - logStartY - 10;
    tft.fillRoundRect(startX, logStartY, width, logH, 3, C_PANEL);
    tft.drawRoundRect(startX, logStartY, width, logH, 3, C_BORDER);
    tft.setTextColor(C_MUTED, C_PANEL);
    tft.setCursor(startX + 5, logStartY + 5);
    tft.print("LOGS:");

    int lineY   = logStartY + 18;
    int lineNum = 0;
    static char tmpBuf[2048];
    strncpy(tmpBuf, debugBuffer, sizeof(tmpBuf) - 1);
    tmpBuf[sizeof(tmpBuf) - 1] = '\0';
    char* line = strtok(tmpBuf, "\n");
    while (line && lineNum < 10) {
        tft.setTextColor(C_WHITE, C_PANEL);
        tft.setCursor(startX + 3, lineY);
        tft.print(line);
        lineY += textH;
        lineNum++;
        line = strtok(nullptr, "\n");
    }
}

void updateDebugTab()
{
    static unsigned long lastDebugUpdate = 0;
    if (millis() - lastDebugUpdate > 5000) {
        lastDebugUpdate = millis();
        addDebugMessage("T=%dC H=%d%% P=%dhPa",
                        gWeather.temp, gWeather.humidity, gWeather.pressure);
        TabManager::forceRedraw();
    }
}

// ════════════════════════════════════════════════════════════════════════════
//  ONGLET TEMPÉRATURE INTÉRIEURE
// ════════════════════════════════════════════════════════════════════════════

void drawIndoorTab(TFT_eSPI& tft)
{
    int contentY   = TAB_BAR_HEIGHT + 2;
    int startX     = 10;
    int cardWidth  = (SCREEN_W - 40) / 2;
    int cardHeight = 110;

    // Efface toute la zone de contenu (pas seulement les cartes) pour ne
    // jamais laisser de marge/interstice montrer un reliquat d'un autre onglet.
    tft.fillRect(0, contentY - 2, SCREEN_W, SCREEN_H - contentY + 2, C_BG);

    tft.setTextSize(2);
    tft.setTextColor(C_WHITE, C_BG);
    tft.setCursor(startX, contentY + 5);
    tft.print("CAPTEURS INTERIEURS");
    tft.drawFastHLine(startX, contentY + 28, SCREEN_W - 20, C_BORDER);

    for (int i = 0; i < gSensorCount && i < 4; i++) {
        int col = i % 2, row = i / 2;
        int cx = startX + col * (cardWidth + 10);
        int cy = contentY + 40 + row * (cardHeight + 10);
        uint16_t bg = gSensors[i].connected ? C_PANEL : 0x28A4; // #2a1620 — panneau "hors ligne"

        tft.fillRoundRect(cx, cy, cardWidth, cardHeight, 5, bg);
        tft.drawRoundRect(cx, cy, cardWidth, cardHeight, 5, C_BORDER);
        tft.setTextSize(1);
        tft.setTextColor(C_ACCENT_BLUE, bg);
        tft.setCursor(cx + 10, cy + 8);
        tft.print(gSensors[i].name);
        tft.setTextSize(3);
        uint16_t tc = (gSensors[i].temperature < 18) ? C_ACCENT_BLUE :
                      (gSensors[i].temperature > 25) ? C_ACCENT_RED : C_ACCENT_GOLD;
        tft.setTextColor(tc, bg);
        tft.setCursor(cx + 10, cy + 35);
        char ts[10]; dtostrf(gSensors[i].temperature, 4, 1, ts);
        tft.print(ts);
        tft.setTextSize(1);
        tft.print("C");
        tft.setTextSize(1);
        tft.setTextColor(C_MUTED, bg);
        tft.setCursor(cx + 10, cy + 75);
        tft.print("Batterie: "); tft.print(gSensors[i].battery); tft.print("%");
        tft.fillCircle(cx + cardWidth - 15, cy + 15, 5,
                       gSensors[i].connected ? C_ACCENT_GREEN : C_ACCENT_RED);
    }

    tft.setTextSize(1);
    tft.setTextColor(C_DIM, C_BG);
    tft.setCursor(startX, SCREEN_H - 15);
    tft.print("U/D: Vue  |  OK: Rafraichir");
}

void updateIndoorTab()
{
    static unsigned long lastTempUpdate = 0;
    extern TFT_eSPI tft;
    if (millis() - lastTempUpdate > 3000) {
        lastTempUpdate = millis();
        for (int i = 0; i < gSensorCount; i++) {
            if (gSensors[i].connected) {
                float delta = (random(-30, 30)) / 100.0f;
                gSensors[i].temperature = constrain(
                    gSensors[i].temperature + delta, 10.0f, 30.0f);
                if (random(100) < 10 && gSensors[i].battery > 0)
                    gSensors[i].battery = max(0, gSensors[i].battery - 1);
            }
        }
        TabManager::forceRedraw();
    }
}

// ════════════════════════════════════════════════════════════════════════════
//  ONGLET STATISTIQUES (capteur BME280)
// ════════════════════════════════════════════════════════════════════════════

void drawStatsTab(TFT_eSPI& tft)
{
    int contentY    = TAB_BAR_HEIGHT + 2;
    int startX      = MARGIN * 2;
    int cardWidth   = (SCREEN_W - MARGIN * 5) / 2;
    int cardHeight  = 70;
    int cardSpacing = MARGIN;

    tft.fillRect(0, contentY, SCREEN_W, SCREEN_H - contentY, C_BG);

    tft.setTextSize(2);
    if (bmeInitialized) {
        tft.setTextColor(C_ACCENT_GREEN, C_BG);
        tft.setCursor(startX, contentY + 5);
        tft.print("CONNECTE");
    } else {
        tft.setTextColor(C_ACCENT_RED, C_BG);
        tft.setCursor(startX, contentY + 5);
        tft.print("DECONNECTE");
    }
    tft.drawFastHLine(startX, contentY + 28, SCREEN_W - MARGIN * 4, C_BORDER);

    if (!bmeInitialized) {
        tft.setTextSize(1);
        tft.setTextColor(C_MUTED, C_BG);
        tft.setCursor(startX, contentY + 50);
        tft.print("Verifier cablage I2C:");
        tft.setCursor(startX, contentY + 65);
        tft.print("SDA=GPIO2  SCL=GPIO3");
        return;
    }

    int row1Y = contentY + 42;
    int row2Y = row1Y + cardHeight + cardSpacing;

    tft.fillRoundRect(startX, row1Y, cardWidth, cardHeight, RADIUS, C_PANEL);
    tft.drawRoundRect(startX, row1Y, cardWidth, cardHeight, RADIUS, C_BORDER);
    tft.setTextSize(1);
    tft.setTextColor(C_MUTED, C_PANEL);
    tft.setCursor(startX + 8, row1Y + 8);
    tft.print("TEMPERATURE");
    uint16_t tc = (bmeTemperature > 25) ? C_ACCENT_RED :
                  (bmeTemperature < 15) ? C_ACCENT_BLUE : C_ACCENT_GREEN;
    tft.setTextSize(3);
    tft.setTextColor(tc, C_PANEL);
    tft.setCursor(startX + 8, row1Y + 30);
    char tempStr[10]; dtostrf(bmeTemperature, 4, 1, tempStr);
    tft.print(tempStr); tft.setTextSize(1); tft.print("C");

    int humX = startX + cardWidth + cardSpacing;
    tft.fillRoundRect(humX, row1Y, cardWidth, cardHeight, RADIUS, C_PANEL);
    tft.drawRoundRect(humX, row1Y, cardWidth, cardHeight, RADIUS, C_BORDER);
    tft.setTextSize(1);
    tft.setTextColor(C_MUTED, C_PANEL);
    tft.setCursor(humX + 8, row1Y + 8);
    tft.print("HUMIDITE");
    uint16_t hc = (bmeHumidity > 70) ? C_ACCENT_BLUE :
                  (bmeHumidity < 30) ? C_ACCENT_GOLD : C_ACCENT_GREEN;
    tft.setTextSize(3);
    tft.setTextColor(hc, C_PANEL);
    tft.setCursor(humX + 8, row1Y + 30);
    char humStr[10]; dtostrf(bmeHumidity, 4, 0, humStr);
    tft.print(humStr); tft.setTextSize(1); tft.print("%");

    tft.fillRoundRect(startX, row2Y, cardWidth, cardHeight, RADIUS, C_PANEL);
    tft.drawRoundRect(startX, row2Y, cardWidth, cardHeight, RADIUS, C_BORDER);
    tft.setTextSize(1);
    tft.setTextColor(C_MUTED, C_PANEL);
    tft.setCursor(startX + 8, row2Y + 8);
    tft.print("PRESSION");
    tft.setTextSize(2);
    tft.setTextColor(C_ACCENT_GOLD, C_PANEL);
    tft.setCursor(startX + 8, row2Y + 30);
    char pressStr[10]; dtostrf(bmePressure, 5, 1, pressStr);
    tft.print(pressStr); tft.setTextSize(1); tft.print("hPa");

    int altX = startX + cardWidth + cardSpacing;
    tft.fillRoundRect(altX, row2Y, cardWidth, cardHeight, RADIUS, C_PANEL);
    tft.drawRoundRect(altX, row2Y, cardWidth, cardHeight, RADIUS, C_BORDER);
    tft.setTextSize(1);
    tft.setTextColor(C_MUTED, C_PANEL);
    tft.setCursor(altX + 8, row2Y + 8);
    tft.print("ALTITUDE");
    float altitude = 44330.0f * (1.0f - powf(bmePressure / 1013.25f, 0.1903f));
    tft.setTextSize(2);
    tft.setTextColor(C_ACCENT_GREEN, C_PANEL);
    tft.setCursor(altX + 8, row2Y + 30);
    tft.print((int)altitude); tft.setTextSize(1); tft.print("m");

    tft.setTextSize(1);
    tft.setTextColor(C_DIM, C_BG);
    tft.setCursor(startX, SCREEN_H - 15);
    tft.print("Mise a jour: "); tft.print(millis() / 1000); tft.print("s");
}

void updateStatsTab()
{
    static unsigned long lastBMEUpdate = 0;
    if (millis() - lastBMEUpdate > BME280_UPDATE_MS) {
        lastBMEUpdate = millis();
        if (bmeInitialized) TabManager::forceRedraw();
    }
}

// ════════════════════════════════════════════════════════════════════════════
//  BARRE D'ONGLETS
// ════════════════════════════════════════════════════════════════════════════

void drawTabBar(TFT_eSPI& tft, int currentTab)
{
    const char* tabs[]  = {"Meteo", "Debug", "Interieur", "Statistiques"};
    const int   numTabs  = TAB_COUNT;
    const int   tabWidth = SCREEN_W / numTabs;

    tft.fillRect(0, 0, SCREEN_W, 30, C_BG);
    tft.drawFastHLine(0, 30, SCREEN_W, C_BORDER);

    for (int i = 0; i < numTabs; i++) {
        int      tabX  = i * tabWidth;
        uint16_t color = (i == currentTab) ? C_ACCENT_BLUE : C_MUTED;
        tft.setTextSize(1);
        tft.setTextColor(color, C_BG);
        // Centrer le label dans la tab
        int labelW = (int)strlen(tabs[i]) * 6;
        tft.setCursor(tabX + max(0, (tabWidth - labelW) / 2), 10);
        tft.print(tabs[i]);
        if (i == currentTab)
            tft.drawFastHLine(tabX, 28, tabWidth, C_ACCENT_BLUE);
    }
}