diff --git a/app/src/main/java/com/mg4/control/MainActivity.kt b/app/src/main/java/com/mg4/control/MainActivity.kt index 62b5c030..3ecd1ef1 100644 --- a/app/src/main/java/com/mg4/control/MainActivity.kt +++ b/app/src/main/java/com/mg4/control/MainActivity.kt @@ -4,7 +4,6 @@ import android.app.AlertDialog import android.content.Context import android.content.Intent import android.graphics.Color -import android.graphics.Paint import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.view.LayoutInflater @@ -12,9 +11,11 @@ import android.view.View import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment import com.google.android.material.button.MaterialButton +import com.mg4.control.debug.AppLogger import com.mg4.control.hardware.MG4Hardware import com.mg4.control.profile.ProfileManager import com.mg4.control.service.MG4ControlService @@ -36,6 +37,25 @@ class MainActivity : AppCompatActivity() { super.attachBaseContext(LocaleHelper.applyLocale(newBase)) } + /** + * [THEME-AUTO] Re-resout le theme a chaque retour au premier plan. + * + * Indispensable sur old-SDK : le launcher change bien le night mode (UiModeManager le reflete) + * mais le systeme ne met PAS a jour Configuration.uiMode, donc AUCUN changement de + * configuration n'est delivre et l'activite n'est jamais recreee toute seule. Le retour au + * premier plan est le bon moment : on ne peut pas changer le theme du launcher sans quitter + * MG4Control. + */ + override fun onResume() { + super.onResume() + val target = ThemeHelper.resolveNightMode(this) + if (target != AppCompatDelegate.getDefaultNightMode()) { + AppLogger.i("MG4_THEME", "onResume : theme change -> application du mode $target") + // Recree les activites vivantes : pas de recreate() manuel a ajouter. + AppCompatDelegate.setDefaultNightMode(target) + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -44,6 +64,7 @@ class MainActivity : AppCompatActivity() { FirmwareInfo.initWithContext(this) // [THEME-AUTO] Recrée l'activité quand le launcher MG change de thème en mode "auto" + // (voie A9 : broadcast com.saicmotor.changeSkin reçu par le service) ThemeHelper.onThemeChanged = { recreate() } // Premier lancement : choix de la langue avant tout @@ -61,10 +82,9 @@ class MainActivity : AppCompatActivity() { .findFragmentById(R.id.nav_host_fragment) as NavHostFragment navController = navHostFragment.navController - setupFirmwareChips() setupNavButtons() setupDiagnosticUnlock() - checkUnknownFirmware() // après setupFirmwareChips pour que les chips soient prêtes + checkUnknownFirmware() navigateToDefaultScreen(savedInstanceState) checkForUpdates() checkProfileRestore() @@ -159,103 +179,6 @@ class MainActivity : AppCompatActivity() { ) } - // ── Indicateur firmware (chips SWI133 / SWI68 / SWI69 / SWI131 / SWI165) ── - - private fun setupFirmwareChips() { - val chip133 = findViewById(R.id.chip_swi133) - val chip132 = findViewById(R.id.chip_swi132) - val chip68 = findViewById(R.id.chip_swi68) - val chip69 = findViewById(R.id.chip_swi69) - val chip131 = findViewById(R.id.chip_swi131) - val chip165 = findViewById(R.id.chip_swi165) - val gen = FirmwareInfo.getGeneration() - val forced = FirmwareInfo.isForced(this) - - fun styleChipActive(tv: TextView) { - tv.setBackgroundResource(R.drawable.bg_chip_active) - tv.setTextColor(getColor(R.color.dash_accent)) - tv.alpha = 1f - tv.paintFlags = tv.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() - } - - fun styleChipInactive(tv: TextView) { - tv.setBackgroundResource(R.drawable.bg_chip_inactive) - tv.setTextColor(getColor(R.color.dash_text_lo)) - tv.alpha = 0.4f - tv.paintFlags = tv.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG - } - - fun styleChipSelectable(tv: TextView) { - // Firmware inconnu sans choix forcé : chip cliquable, surlignée en rouge - tv.setBackgroundResource(R.drawable.bg_chip_inactive) - tv.setTextColor(getColor(R.color.dash_danger)) - tv.alpha = 0.75f - tv.paintFlags = tv.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() - } - - val isNaturalUnknown = gen == FirmwareInfo.Gen.UNKNOWN && !forced - val allChips = listOf(chip133, chip132, chip68, chip69, chip131, chip165) - - when { - isNaturalUnknown -> { - // Les six chips en mode "à choisir" (rouge dim, aucune barrée) - allChips.forEach { styleChipSelectable(it) } - } - gen == FirmwareInfo.Gen.SWI165 -> { - styleChipActive(chip165) - listOf(chip133, chip132, chip68, chip69, chip131).forEach { styleChipInactive(it) } - } - gen == FirmwareInfo.Gen.SWI131 -> { - styleChipActive(chip131) - listOf(chip133, chip132, chip68, chip69, chip165).forEach { styleChipInactive(it) } - } - gen == FirmwareInfo.Gen.SWI69 -> { - styleChipActive(chip69) - listOf(chip133, chip132, chip68, chip131, chip165).forEach { styleChipInactive(it) } - } - gen == FirmwareInfo.Gen.SWI68 -> { - styleChipActive(chip68) - listOf(chip133, chip132, chip69, chip131, chip165).forEach { styleChipInactive(it) } - } - gen == FirmwareInfo.Gen.SWI132 -> { - styleChipActive(chip132) - listOf(chip133, chip68, chip69, chip131, chip165).forEach { styleChipInactive(it) } - } - else -> { // SWI133 ou forcé SWI133 - styleChipActive(chip133) - listOf(chip132, chip68, chip69, chip131, chip165).forEach { styleChipInactive(it) } - } - } - - // Chips cliquables si firmware inconnu (naturel ou forcé) pour changer de mode - if (gen == FirmwareInfo.Gen.UNKNOWN || forced) { - chip133.setOnClickListener { - FirmwareInfo.forceGeneration(this, FirmwareInfo.Gen.SWI133) - recreate() - } - chip132.setOnClickListener { - FirmwareInfo.forceGeneration(this, FirmwareInfo.Gen.SWI132) - recreate() - } - chip68.setOnClickListener { - FirmwareInfo.forceGeneration(this, FirmwareInfo.Gen.SWI68) - recreate() - } - chip69.setOnClickListener { - FirmwareInfo.forceGeneration(this, FirmwareInfo.Gen.SWI69) - recreate() - } - chip131.setOnClickListener { - FirmwareInfo.forceGeneration(this, FirmwareInfo.Gen.SWI131) - recreate() - } - chip165.setOnClickListener { - FirmwareInfo.forceGeneration(this, FirmwareInfo.Gen.SWI165) - recreate() - } - } - } - // ── Dialog firmware non reconnu ─────────────────────────────────────────── private fun checkUnknownFirmware() { diff --git a/app/src/main/java/com/mg4/control/automation/ClimateAutomationDecision.kt b/app/src/main/java/com/mg4/control/automation/ClimateAutomationDecision.kt new file mode 100644 index 00000000..7c147842 --- /dev/null +++ b/app/src/main/java/com/mg4/control/automation/ClimateAutomationDecision.kt @@ -0,0 +1,23 @@ +package com.mg4.control.automation + +/** Décision pure de l'automatisation climatisation (testable sans Android). */ +object ClimateAutomationDecision { + + /** Règle retenue, ou NONE si rien ne s'applique. */ + enum class Outcome { NONE, HOT, COLD } + + /** + * Choisit la règle à appliquer d'après la température extérieure. + * + * Conditions inclusives : chaud si `temp >= seuilChaud`, froid si `temp <= seuilFroid`. + * Si les deux se déclenchent (seuils qui se chevauchent — configuration incohérente), on + * retient **CHAUD** de façon déterministe plutôt que de dépendre d'un ordre implicite. + */ + fun evaluate(config: ClimateAutomationSettings.Config, temp: Float?): Outcome = when { + !config.enabled -> Outcome.NONE + temp == null || temp.isNaN() -> Outcome.NONE + config.hot.active && temp >= config.hot.threshold.toFloat() -> Outcome.HOT + config.cold.active && temp <= config.cold.threshold.toFloat() -> Outcome.COLD + else -> Outcome.NONE + } +} diff --git a/app/src/main/java/com/mg4/control/automation/ClimateAutomationSettings.kt b/app/src/main/java/com/mg4/control/automation/ClimateAutomationSettings.kt new file mode 100644 index 00000000..01854661 --- /dev/null +++ b/app/src/main/java/com/mg4/control/automation/ClimateAutomationSettings.kt @@ -0,0 +1,90 @@ +package com.mg4.control.automation + +import android.content.Context + +/** + * Réglages de l'automatisation « Déclenchement A/C via la température ». + * + * Deux règles INDÉPENDANTES : une quand il fait chaud (temp ≥ seuil), une quand il fait froid + * (temp ≤ seuil). Chacune porte ses propres réglages clim — les mêmes valeurs n'auraient aucun + * sens à 35 °C et à −5 °C. + * + * Volontairement séparée d'[AutomationSettings] : ce n'est pas une application de profil, elle + * n'est donc PAS soumise au toggle « application auto du profil » et a son propre interrupteur. + */ +object ClimateAutomationSettings { + + const val PREFS = "mg4_settings" + + const val KEY_ENABLED = "ac_auto_enabled" + + // Une règle = un préfixe ; les clés sont dérivées pour éviter douze constantes quasi jumelles. + private const val HOT = "ac_auto_hot_" + private const val COLD = "ac_auto_cold_" + + const val DEFAULT_HOT_THRESHOLD = 28 + const val DEFAULT_COLD_THRESHOLD = 5 + const val DEFAULT_HOT_TARGET = 20 + const val DEFAULT_COLD_TARGET = 24 + const val DEFAULT_FAN = 4 + + /** Bornes de saisie — larges à dessein, les vraies limites du véhicule sont lues au moment + * d'appliquer (getClimateState) et la consigne y est clampée. */ + const val MIN_TEMP = -20 + const val MAX_TEMP = 60 + const val MIN_TARGET = 15 + const val MAX_TARGET = 33 + const val MIN_FAN = 1 + const val MAX_FAN = 10 + + /** Une règle : sa condition de déclenchement et les réglages clim à appliquer. */ + data class Rule( + val active: Boolean, + val threshold: Int, + val targetTemp: Int, + val fanLevel: Int, + val defrostFront: Boolean, + val defrostRear: Boolean + ) + + data class Config( + val enabled: Boolean, + val hot: Rule, + val cold: Rule + ) + + fun read(context: Context): Config { + val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + fun rule(prefix: String, defThreshold: Int, defTarget: Int) = Rule( + active = p.getBoolean(prefix + "on", false), + threshold = p.getInt(prefix + "threshold", defThreshold), + targetTemp = p.getInt(prefix + "target", defTarget), + fanLevel = p.getInt(prefix + "fan", DEFAULT_FAN), + defrostFront = p.getBoolean(prefix + "def_front", false), + defrostRear = p.getBoolean(prefix + "def_rear", false) + ) + return Config( + enabled = p.getBoolean(KEY_ENABLED, false), + hot = rule(HOT, DEFAULT_HOT_THRESHOLD, DEFAULT_HOT_TARGET), + cold = rule(COLD, DEFAULT_COLD_THRESHOLD, DEFAULT_COLD_TARGET) + ) + } + + // ── Écriture (utilisée par l'UI) ───────────────────────────────────────── + fun keyOn(hot: Boolean) = (if (hot) HOT else COLD) + "on" + fun keyThreshold(hot: Boolean) = (if (hot) HOT else COLD) + "threshold" + fun keyTarget(hot: Boolean) = (if (hot) HOT else COLD) + "target" + fun keyFan(hot: Boolean) = (if (hot) HOT else COLD) + "fan" + fun keyDefFront(hot: Boolean) = (if (hot) HOT else COLD) + "def_front" + fun keyDefRear(hot: Boolean) = (if (hot) HOT else COLD) + "def_rear" + + fun clampThreshold(raw: Int?, hot: Boolean): Int = + (raw ?: if (hot) DEFAULT_HOT_THRESHOLD else DEFAULT_COLD_THRESHOLD) + .coerceIn(MIN_TEMP, MAX_TEMP) + + fun clampTarget(raw: Int?, hot: Boolean): Int = + (raw ?: if (hot) DEFAULT_HOT_TARGET else DEFAULT_COLD_TARGET) + .coerceIn(MIN_TARGET, MAX_TARGET) + + fun clampFan(raw: Int?): Int = (raw ?: DEFAULT_FAN).coerceIn(MIN_FAN, MAX_FAN) +} diff --git a/app/src/main/java/com/mg4/control/hardware/MG4Hardware.kt b/app/src/main/java/com/mg4/control/hardware/MG4Hardware.kt index a8c18d33..2bdb9ac0 100644 --- a/app/src/main/java/com/mg4/control/hardware/MG4Hardware.kt +++ b/app/src/main/java/com/mg4/control/hardware/MG4Hardware.kt @@ -3486,6 +3486,563 @@ object MG4Hardware { AppLogger.i(SPEED_TAG, "Comparer au compteur : identique = km/h OK ; ~3,6x plus petit = m/s") } + // ── Sonde climatisation (bouton Diagnostic) ─────────────────────────────── + private const val CLIM_TAG = "MG4_CLIM" + + /** + * Propriétés HVAC vendor SAIC (table YFVehicleProperty), zone SEAT — mêmes famille et + * zone (0x75) que les sièges chauffants et la temp extérieure, qui fonctionnent déjà. + * Le type se lit dans l'ID : 0x..6..=FLOAT, 0x..2..=BOOLEAN, sinon INT32. + */ + private val CLIMATE_PROPS: List> = listOf( + "POWER_ON" to 0x15400510, + "POWER_STATUS" to 0x1540250f, + "AC_ON" to 0x15402500, + "AUTO_ON" to 0x15402502, + "FAN_SPEED" to 0x15400500, + "BLOWER_SPEED" to 0x1540250d, + "FAN_DIRECTION" to 0x15400501, + "DRVTEMP_SET" to 0x1560250b, + "PSGTEMP_SET" to 0x1560250c, + "TEMPERATURE_SET" to 0x15600503, + "RECIRC_ON" to 0x15200508, + "AC_LOOP_MODE" to 0x15402507, + "ECON_ON" to 0x15402504, + "DUAL_ON" to 0x15402501, + "DEFROST_FRONT" to 0x15402515, // orthographe SAIC : FORNT + "DEFROST_REAR" to 0x15402516, + "SEAT_VENT_DRV" to 0x15402525, + "SEAT_VENT_PSG" to 0x15402526, + "PM25_CONCENTR" to 0x15402509, + "ANION_STATUS" to 0x15402510 + ) + + /** Lecture typée via CarHvacManager. Renvoie la valeur ou la raison de l'échec. LECTURE SEULE. */ + private fun climRead(propId: Int, area: Int): String { + val hvac = sCarHvacManager ?: return "HVAC absent" + return try { + val getter = when (propId and 0x00FF0000) { + 0x00600000 -> "getFloatProperty" + 0x00200000 -> "getBooleanProperty" + else -> "getIntProperty" + } + val v = hvac.javaClass.getMethod(getter, Int::class.java, Int::class.java) + .invoke(hvac, propId, area) + v?.toString() ?: "null" + } catch (e: Exception) { + "illisible(${(e.cause ?: e).javaClass.simpleName})" + } + } + + /** + * Sonde du bouton Diagnostic : tente de LIRE les propriétés de climatisation à la zone + * HVAC (0x75). **Aucune écriture** — on ne fait que constater ce qui répond, firmware par + * firmware, avant d'envisager un pilotage. + * + * Les deux dernières lignes sont des TÉMOINS : des propriétés déjà connues pour marcher + * (siège chauffant, temp extérieure). Si elles répondent et que les autres non, l'écart + * est significatif ; si elles échouent aussi, c'est le manager qui n'est pas prêt. + */ + fun runClimateDiag() { + AppLogger.i(CLIM_TAG, "── DIAG climatisation (lecture seule) ──") + AppLogger.i(CLIM_TAG, "HVAC manager=${sCarHvacManager != null} zone=0x${Integer.toHexString(AREA_HVAC)}") + for ((label, propId) in CLIMATE_PROPS) { + AppLogger.i(CLIM_TAG, " ${label.padEnd(16)} 0x${Integer.toHexString(propId)} = ${climRead(propId, AREA_HVAC)}") + } + AppLogger.i(CLIM_TAG, "TÉMOIN siègeChauffG(0x15402513) = ${climRead(PROP_SEAT_HEAT_L, AREA_HVAC)}") + AppLogger.i(CLIM_TAG, "TÉMOIN tempExt(0x15602511) = ${climRead(PROP_HVAC_TEMP_OUTCAR, AREA_HVAC)}") + + // Voie OEM en parallèle des propriétés : le dégivrage arrière est piloté par un bouton + // PHYSIQUE sur le véhicule — on veut savoir si le service en reflète l'état malgré tout. + // (une propriété à 0 ne prouve rien ; si l'OEM renvoie autre chose, l'état est lisible) + if (sAirCondition != null) { + AppLogger.i(CLIM_TAG, "OEM dégivrage AV=${acInt("getFrontWindowDefroster") ?: "n/a"} " + + "AR=${acInt("getBackWindowDefroster") ?: "n/a"} (−1 = non exposé)") + AppLogger.i(CLIM_TAG, "OEM power=${acInt("getHvacPowerStatus") ?: "n/a"} ac=${acInt("getAcSwitch") ?: "n/a"} " + + "auto=${acInt("getAutoStatus") ?: "n/a"} loop=${acInt("getLoopMode") ?: "n/a"}") + } + + // Voie A9 : lit le CarHvacClient (queryClient 0x7). Sert à mesurer les deux inconnues — + // l'encodage de la recirculation et les bornes réelles température/ventilation. + if (isClimateA9()) { + if (hvacA9() == null) { + AppLogger.w(CLIM_TAG, "A9: CarHvacClient indisponible (queryClient(0x7) muet)") + } else { + AppLogger.i(CLIM_TAG, "A9 power=${a9Get("getHvacPowerStatus")} ac=${a9Get("getACStatus")} " + + "auto=${a9Get("getAutoStatus")}") + AppLogger.i(CLIM_TAG, "A9 drvTemp=${a9Get("getDriverTemperature")} psgTemp=${a9Get("getPassengerTemperature")} " + + "fan=${a9Get("getFanSpeed")} fanDir=${a9Get("getFanDirection")}") + AppLogger.i(CLIM_TAG, "A9 recirc=${a9Get("getAirCirculationStatus")} " + + "(à comparer au mode affiché : 0/1/2 = intérieur/extérieur/auto ?)") + AppLogger.i(CLIM_TAG, "A9 dégivrageAV=${a9Get("getFrontDefrostStatus")} " + + "dégivrageAR=${a9Get("getRearDefrostStatus")} tempExt=${a9Get("getOutSideTemperature")}") + } + } + } + + /** + * Candidats pour la CONSIGNE de température. Les variantes FLOAT (…SET) ont échoué à la + * zone 0x75 ; la table SAIC propose aussi des variantes ENTIÈRES suffixées "SWA", et la + * consigne est une propriété par siège → la bonne zone n'est peut-être pas le masque 0x75. + */ + private val TEMP_SETPOINT_CANDIDATES: List> = listOf( + "DRVTEMP_SET" to 0x1560250b, // FLOAT + "PSGTEMP_SET" to 0x1560250c, // FLOAT + "TEMPERATURE_SET" to 0x15600503, // FLOAT + "AC_DRVRTEMSWA" to 0x1540252e, // INT ← variante entière + "AC_PSNGTEMSWA" to 0x15402544, // INT ← variante entière + "REAR_TEMPERATURE" to 0x15602536, + "SEAT_TEMPERATURE" to 0x1540050b, + "TEMPERATURE_CURRENT" to 0x15600502 + ) + + private val TEMP_SETPOINT_AREAS = intArrayOf(AREA_HVAC, 0x1, 0x2, 0x4, AREA_GLOBAL, 0) + + /** + * Chasse à la consigne de température : balaye candidats × zones et ne journalise que les + * lectures QUI RÉUSSISSENT (sinon le log serait noyé). Lecture seule. + * + * Mode d'emploi : noter la consigne réelle affichée par la voiture, puis chercher cette + * valeur dans les résultats (attention à un éventuel encodage ×10 : 25 °C → 250). + */ + fun runClimateSetpointHunt() { + AppLogger.i(CLIM_TAG, "── CHASSE consigne température (lecture seule) ──") + var hits = 0 + var fails = 0 + for ((label, propId) in TEMP_SETPOINT_CANDIDATES) { + for (area in TEMP_SETPOINT_AREAS) { + val r = climRead(propId, area) + if (r.startsWith("illisible") || r == "null" || r == "HVAC absent") { fails++; continue } + hits++ + AppLogger.i(CLIM_TAG, " ✔ ${label.padEnd(20)} 0x${Integer.toHexString(propId)} " + + "@0x${Integer.toHexString(area)} = $r") + } + } + AppLogger.i(CLIM_TAG, " → $hits lecture(s) réussie(s), $fails échec(s)") + // Voie OEM (AirConditionManager) — déjà bindée par la feature température, old-SDK. + AppLogger.i(CLIM_TAG, "OEM drvTemp=${acInt("getDrvTemp") ?: "n/a"} psgTemp=${acInt("getPsgTemp") ?: "n/a"} " + + "min=${acInt("getMinTemp") ?: "n/a"} max=${acInt("getMaxTemp") ?: "n/a"} " + + "airVol=${acInt("getAirVolumeLevel") ?: "n/a"} acSwitch=${acInt("getAcSwitch") ?: "n/a"}") + AppLogger.i(CLIM_TAG, "→ repérer la consigne affichée par la voiture (ex. 25, ou 250 si ×10)") + } + + // ── Voie A9 (SWI69/131/132) : carapi CarHvacClient via queryClient(0x7) ────── + // Le SDK vehiclesettings est ABSENT sur A9 ; la clim passe par l'adaptateur carapi, + // exactement comme le power-off (queryClient(0xf)) déjà en place. + private const val HVAC_SERVICE_CODE = 0x7 + private const val HVAC_CLIENT_CLASS = "com.saicmotor.carapi.client.CarHvacClient" + + @Volatile private var sCarHvacA9: Any? = null + + /** Obtient (et mémorise) le CarHvacClient A9. null si indisponible. */ + private fun hvacA9(): Any? { + sCarHvacA9?.let { return it } + val cl = sVsm?.javaClass?.classLoader ?: return null + return try { + val adapterClass = cl.loadClass(CAR_ADAPTER_CLIENT_CLASS) + val adapter = adapterClass.getMethod("getInstance", Context::class.java).invoke(null, sAppContext) + val binder = adapterClass.getMethod("queryClient", Int::class.javaPrimitiveType) + .invoke(adapter, HVAC_SERVICE_CODE) as? IBinder ?: return null + val clientClass = cl.loadClass(HVAC_CLIENT_CLASS) + clientClass.getConstructor(IBinder::class.java).newInstance(binder).also { + sCarHvacA9 = it + AppLogger.i(CLIM_TAG, "A9: CarHvacClient obtenu via queryClient(0x7) ✓") + } + } catch (e: Exception) { + AppLogger.d(CLIM_TAG, "A9: CarHvacClient indisponible : ${(e.cause ?: e).message}") + null + } + } + + /** Lecture sans argument sur le client HVAC A9 (Boolean, Int ou Float selon la méthode). */ + private fun a9Get(name: String): Any? { + val c = hvacA9() ?: return null + return try { c.javaClass.getMethod(name).invoke(c) } catch (_: Exception) { null } + } + + /** Appel sans argument (les `switch…()` : bascules). */ + private fun a9Call(name: String): Boolean { + val c = hvacA9() ?: return false + return try { c.javaClass.getMethod(name).invoke(c); true } + catch (e: Exception) { AppLogger.w(CLIM_TAG, "A9 $name() échec : ${(e.cause ?: e).message}"); false } + } + + /** Écriture typée (setFanSpeed(Int), setDriverTemperature(Float)). */ + private fun a9Set(name: String, value: Any): Boolean { + val c = hvacA9() ?: return false + val type = if (value is Float) Float::class.javaPrimitiveType else Int::class.javaPrimitiveType + return try { c.javaClass.getMethod(name, type).invoke(c, value); true } + catch (e: Exception) { AppLogger.w(CLIM_TAG, "A9 $name($value) échec : ${(e.cause ?: e).message}"); false } + } + + /** + * Amène une bascule A9 (`switch…()`) jusqu'à l'état voulu — équivalent de [hvacCycleTo], + * mais l'état se lit par méthode et non par propriété. + * ⚠️ Bloquant → hors du thread principal. + */ + private fun a9CycleTo(label: String, getter: String, target: Int, maxSteps: Int, advance: String): Boolean { + var steps = 0 + while (steps <= maxSteps) { + val cur = when (val v = a9Get(getter)) { + is Boolean -> if (v) 1 else 0 + is Int -> v + else -> -1 + } + if (cur == target) { climLog("A9 $label=$target atteint ($steps avance(s))", true); return true } + if (cur < 0) { climLog("A9 $label=$target — état illisible", false); return false } + a9Call(advance) + steps++ + try { Thread.sleep(400) } catch (_: InterruptedException) {} + } + climLog("A9 $label=$target NON atteint", false) + return false + } + + /** Écrit une valeur entière sur le manager clim SAIC. false si la méthode échoue. */ + private fun acSet(name: String, value: Int): Boolean { + val ac = sAirCondition ?: return false + return try { + ac.javaClass.getMethod(name, Int::class.javaPrimitiveType).invoke(ac, value) + true + } catch (e: Exception) { + AppLogger.w(CLIM_TAG, " $name($value) échec : ${(e.cause ?: e).message}") + false + } + } + + /** + * Un cycle de test sur une grandeur : lit, écrit une valeur voisine, relit pour vérifier, + * puis RESTAURE la valeur d'origine et revérifie. Bloquant (attentes) → appeler hors du + * thread principal. + */ + private fun climWriteProbe(label: String, getter: String, setter: String, minGetter: String, maxGetter: String) { + val before = acInt(getter) + if (before == null || before < 0) { + AppLogger.w(CLIM_TAG, "$label : lecture initiale impossible ($getter=${before ?: "null"}) → test ignoré") + return + } + val lo = acInt(minGetter)?.takeIf { it >= 0 } ?: 0 + val hi = acInt(maxGetter)?.takeIf { it > lo } ?: (before + 1) + // Valeur voisine, en restant dans la plage : un écart de 1 suffit à prouver l'écriture. + val target = if (before < hi) before + 1 else before - 1 + if (target < lo || target > hi) { + AppLogger.w(CLIM_TAG, "$label : pas de valeur voisine dans la plage $lo..$hi → test ignoré") + return + } + + AppLogger.i(CLIM_TAG, "$label : actuel=$before plage=$lo..$hi → tentative $target") + val written = acSet(setter, target) + Thread.sleep(800) + val after = acInt(getter) + AppLogger.i(CLIM_TAG, " écriture=$written relecture=$after " + + if (after == target) "✅ PRISE EN COMPTE" else "❌ non prise") + + // Restauration systématique, même si l'écriture a échoué. + val restoredOk = acSet(setter, before) + Thread.sleep(800) + val restored = acInt(getter) + AppLogger.i(CLIM_TAG, " restauration=$restoredOk → $restored " + + if (restored == before) "✅ état d'origine rétabli" else "⚠️ VÉRIFIER MANUELLEMENT (attendu $before)") + } + + /** + * Test d'ÉCRITURE de la climatisation — **réversible**. Modifie brièvement la consigne de + * température puis la ventilation, vérifie que la voiture prend la valeur, et remet + * systématiquement l'état d'origine. + * + * Confort uniquement : ne touche à aucun réglage de conduite, donc hors périmètre du + * verrou de vitesse (VehicleWriteGate), conformément à la politique T-904. + * + * ⚠️ Bloquant (~3,5 s) → appeler depuis un thread IO, jamais depuis le thread principal. + */ + fun runClimateWriteTest() { + AppLogger.i(CLIM_TAG, "── TEST ÉCRITURE climatisation (réversible) ──") + if (sAirCondition == null) { + AppLogger.w(CLIM_TAG, "AirConditionManager indisponible → test impossible sur ce firmware") + return + } + climWriteProbe("Consigne conducteur", "getDrvTemp", "setDrvTemp", "getMinTemp", "getMaxTemp") + climWriteProbe("Ventilation", "getAirVolumeLevel", "setAirVolumeLevel", "getMinAirVolume", "getMaxAirVolume") + AppLogger.i(CLIM_TAG, "── fin du test — l'état d'origine doit être rétabli ──") + } + + // ═════════════════════════════════════════════════════════════════════════ + // Pilotage climatisation (page Dashboard) — voie OEM AirConditionManager + // Les propriétés CarHvacManager ne portent PAS la consigne (0.0 partout) : + // tout passe donc par le manager SAIC, seul à exposer lecture ET écriture. + // ═════════════════════════════════════════════════════════════════════════ + + /** + * Modes de recirculation. Encodage **mesuré sur véhicule** (SWI133) en changeant le mode + * depuis l'écran de la voiture et en relisant HVAC_AC_LOOP_MODE : + * intérieur → 0, extérieur → 1, auto → 2. + * ⚠️ HVAC_RECIRC_ON (0x15200508) reste à `false` dans les trois cas : propriété inutilisable. + */ + object LoopMode { + const val INNER = 0 // air recyclé + const val OUTSIDE = 1 // air extérieur + const val AUTO = 2 + } + + /** Mode de recirculation — seule source fiable (l'OEM getLoopMode n'est pas vérifié). */ + private const val PROP_HVAC_LOOP_MODE = 0x15402507 + /** État A/C — encodage mesuré sur véhicule : 1 = allumé, 0 = éteint. */ + private const val PROP_HVAC_AC_ON = 0x15402500 + + /** + * Amène une commande **cyclique** du HVAC SAIC jusqu'à un état voulu. + * + * Plusieurs commandes de ce service ignorent leur argument et se contentent d'avancer d'un + * cran (constaté pour la recirculation, et l'A9 le nomme explicitement `switch…()`). La + * seule façon fiable d'atteindre un état précis est donc : lire, comparer, avancer, relire. + * + * [maxSteps] borne la boucle au nombre d'états du cycle : au-delà, la commande n'agit pas + * sur ce firmware et on abandonne en le journalisant plutôt que de tourner en rond. + * ⚠️ Bloquant (~400 ms par cran) → hors du thread principal. + */ + private fun hvacCycleTo(label: String, propId: Int, target: Int, maxSteps: Int, advance: () -> Unit): Boolean { + var steps = 0 + while (steps <= maxSteps) { + val current = getIntPropertyHvac(propId, AREA_HVAC) + if (current == target) { + climLog("$label=$target atteint ($steps avance(s))", true) + return true + } + if (current < 0) { + climLog("$label=$target — état courant illisible", false) + return false + } + advance() + steps++ + try { Thread.sleep(400) } catch (_: InterruptedException) {} + } + climLog("$label=$target NON atteint (lu=${getIntPropertyHvac(propId, AREA_HVAC)})", false) + return false + } + + /** + * Instantané complet de la climatisation, bornes incluses — lu en une passe pour éviter + * une dizaine d'allers-retours binder à chaque rafraîchissement. + * Un champ à null = non lisible sur ce firmware ; l'UI grise le contrôle correspondant. + */ + data class ClimateState( + val powerOn: Boolean?, + val tempC: Int?, + val tempMin: Int, + val tempMax: Int, + val fanLevel: Int?, + val fanMin: Int, + val fanMax: Int, + val acOn: Boolean?, + val autoOn: Boolean?, + val loopMode: Int?, + val defrostFront: Boolean?, + val defrostRear: Boolean? + ) + + /** + * Vrai si le firmware expose le SDK clim SAIC (old-SDK : SWI133/68/165 ; absent sur A9). + * + * ⚠️ Critère volontairement **déterministe** : surtout PAS `sAirCondition != null`, qui + * dépend d'une liaison asynchrone. L'adapter du ViewPager n'appelle getItemCount() qu'une + * fois, à sa création : si la liaison n'était pas encore faite, la page disparaissait + * définitivement. Ici la réponse est connue dès le démarrage et ne change jamais. + * Si la liaison n'est pas encore prête, la page s'affiche avec ses contrôles grisés, puis + * se remplit au premier rafraîchissement réussi. + */ + fun hasClimateControl(): Boolean = FirmwareInfo.getGeneration() != FirmwareInfo.Gen.UNKNOWN + + /** Vrai si ce firmware passe par la voie carapi (A9) plutôt que par le SDK vehiclesettings. */ + private fun isClimateA9(): Boolean { + val gen = FirmwareInfo.getGeneration() + return gen == FirmwareInfo.Gen.SWI69 || + gen == FirmwareInfo.Gen.SWI131 || + gen == FirmwareInfo.Gen.SWI132 + } + + /** + * Lecture complète de l'état clim. null si le service n'est pas (encore) disponible — + * l'UI grise alors ses contrôles. La liaison est retentée à chaque appel : elle peut + * n'aboutir qu'après la création de la page (bind asynchrone), auquel cas le prochain + * rafraîchissement périodique remplit l'écran tout seul. + */ + fun getClimateState(): ClimateState? { + if (isClimateA9()) return getClimateStateA9() + if (sAirCondition == null) sAppContext?.let { initAirCondition(it) } + if (sAirCondition == null) return null + // Bornes lues sur la voiture (jamais codées en dur) ; repli sur des valeurs sûres. + val tMin = acInt("getMinTemp")?.takeIf { it in 0..50 } ?: 16 + val tMax = acInt("getMaxTemp")?.takeIf { it > tMin } ?: 32 + val fMin = acInt("getMinAirVolume")?.takeIf { it >= 0 } ?: 1 + val fMax = acInt("getMaxAirVolume")?.takeIf { it > fMin } ?: 10 + return ClimateState( + powerOn = acInt("getHvacPowerStatus")?.takeIf { it >= 0 }?.let { it == 1 }, + tempC = acInt("getDrvTemp")?.takeIf { it in tMin..tMax }, + tempMin = tMin, + tempMax = tMax, + fanLevel = acInt("getAirVolumeLevel")?.takeIf { it >= 0 }, + fanMin = fMin, + fanMax = fMax, + acOn = acInt("getAcSwitch")?.takeIf { it >= 0 }?.let { it == 1 }, + autoOn = acInt("getAutoStatus")?.takeIf { it >= 0 }?.let { it == 1 }, + // Lu via la PROPRIÉTÉ (0/1/2 mesurés sur véhicule), pas via l'OEM getLoopMode. + loopMode = getIntPropertyHvac(PROP_HVAC_LOOP_MODE, AREA_HVAC).takeIf { it >= 0 }, + defrostFront = acInt("getFrontWindowDefroster")?.takeIf { it >= 0 }?.let { it == 1 }, + defrostRear = acInt("getBackWindowDefroster")?.takeIf { it >= 0 }?.let { it == 1 } + ) + } + + /** + * État clim sur A9, via CarHvacClient. + * + * ⚠️ Deux inconnues à mesurer sur véhicule (l'API n'expose pas de bornes) : + * • la plage de température et le niveau de ventilation max — valeurs par défaut prudentes ; + * • l'encodage de `getAirCirculationStatus()` : on suppose la même convention que l'old-SDK + * (0=intérieur, 1=extérieur, 2=auto), à confirmer par la sonde Diagnostic. + */ + private fun getClimateStateA9(): ClimateState? { + if (hvacA9() == null) return null + val temp = (a9Get("getDriverTemperature") as? Float)?.takeIf { !it.isNaN() && it > 0f }?.toInt() + return ClimateState( + powerOn = a9Get("getHvacPowerStatus") as? Boolean, + tempC = temp, + tempMin = 16, + tempMax = 32, + fanLevel = (a9Get("getFanSpeed") as? Int)?.takeIf { it >= 0 }, + fanMin = 1, + fanMax = 10, + acOn = a9Get("getACStatus") as? Boolean, + autoOn = a9Get("getAutoStatus") as? Boolean, + loopMode = (a9Get("getAirCirculationStatus") as? Int)?.takeIf { it >= 0 }, + defrostFront = a9Get("getFrontDefrostStatus") as? Boolean, + defrostRear = a9Get("getRearDefrostStatus") as? Boolean + ) + } + + /** Appelle une méthode sans argument du manager clim (open…/close…). */ + private fun acCall(name: String): Boolean { + val ac = sAirCondition ?: return false + return try { + ac.javaClass.getMethod(name).invoke(ac); true + } catch (e: Exception) { + AppLogger.w(CLIM_TAG, " $name() échec : ${(e.cause ?: e).message}"); false + } + } + + // ── Écritures (confort : hors périmètre du verrou de vitesse, cf. T-904) ── + // Chaque écriture est journalisée : c'est la seule trace exploitable quand un réglage + // « ne prend pas » sur un firmware donné (le service peut acquiescer sans rien faire). + private fun climLog(what: String, ok: Boolean) = AppLogger.i(CLIM_TAG, "SET $what → $ok") + + fun setClimatePower(on: Boolean): Boolean = + if (isClimateA9()) + a9CycleTo("power", "getHvacPowerStatus", if (on) 1 else 0, 2, "switchHvacPowerStatus") + else + acCall(if (on) "openHvacPower" else "closeHvacPower").also { climLog("power=$on", it) } + + /** Consigne : Int sur old-SDK, **Float** sur A9 (setDriverTemperature(F)). */ + fun setClimateTemp(celsius: Int): Boolean = + if (isClimateA9()) + a9Set("setDriverTemperature", celsius.toFloat()).also { climLog("A9 temp=$celsius", it) } + else + acSet("setDrvTemp", celsius).also { climLog("temp=$celsius", it) } + + fun setClimateFan(level: Int): Boolean = + if (isClimateA9()) + a9Set("setFanSpeed", level).also { climLog("A9 fan=$level", it) } + else + acSet("setAirVolumeLevel", level).also { climLog("fan=$level", it) } + + /** + * A/C — bascule, comme la recirculation. L'encodage de lecture (1=ON, 0=OFF) a été mesuré + * sur véhicule ; envoyer `setAcStatus(0)` restait sans effet, ce qui trahit un argument + * ignoré. On avance donc jusqu'à l'état voulu. Deux états ⇒ une bascule suffit. + */ + fun setClimateAc(on: Boolean): Boolean = + if (isClimateA9()) + a9CycleTo("ac", "getACStatus", if (on) 1 else 0, 2, "switchACStatus") + else + hvacCycleTo("ac", PROP_HVAC_AC_ON, if (on) 1 else 0, maxSteps = 2) { + acSet("setAcStatus", 1) + } + + fun setClimateAuto(on: Boolean): Boolean = + if (isClimateA9()) + a9CycleTo("auto", "getAutoStatus", if (on) 1 else 0, 2, "switchAutoStatus") + else + acSet("setAutoStatus", if (on) 1 else 0).also { climLog("auto=$on", it) } + + /** + * Recirculation — commande **CYCLIQUE**, pas une affectation. + * + * Constaté sur véhicule (SWI133) : `setLoopMode(n)` **ignore son argument** et fait avancer + * d'un cran dans le cycle extérieur → intérieur → auto → extérieur… Les méthodes + * `openLoopInner/Outside/Auto()` avaient le même défaut (on demandait « intérieur » et la + * voiture passait en « auto »). Même sémantique que `switchAirCirculationStatus()` sur A9. + * + * On procède donc comme les sièges chauffants ([setHvacLevelWithToggle]) : avancer d'un cran + * puis relire, jusqu'à atteindre la cible. La lecture se fait sur la PROPRIÉTÉ, dont + * l'encodage a été mesuré (0/1/2) ; l'avance utilise la seule commande observée efficace. + * + * Trois modes ⇒ deux avances suffisent depuis n'importe quel état ; au-delà de 3 on + * abandonne, c'est que la commande n'agit pas sur ce firmware. + * + * ⚠️ Bloquant (jusqu'à ~1,2 s) → appeler hors du thread principal. + */ + fun setClimateLoopMode(target: Int): Boolean { + if (target !in LoopMode.INNER..LoopMode.AUTO) return false + // 3 modes ⇒ 2 avances suffisent depuis n'importe quel état. + return if (isClimateA9()) + a9CycleTo("loopMode", "getAirCirculationStatus", target, 3, "switchAirCirculationStatus") + else + hvacCycleTo("loopMode", PROP_HVAC_LOOP_MODE, target, maxSteps = 3) { + acSet("setLoopMode", 1) // l'argument est ignoré : avance d'un cran + } + } + + fun setClimateDefrostFront(on: Boolean): Boolean = + if (isClimateA9()) + a9CycleTo("defrostFront", "getFrontDefrostStatus", if (on) 1 else 0, 2, "switchFrontDefrostStatus") + else + acCall(if (on) "openFrontWindowDefroster" else "closeFrontWindowDefroster") + .also { climLog("defrostFront=$on", it) } + + fun setClimateDefrostRear(on: Boolean): Boolean = + if (isClimateA9()) + a9CycleTo("defrostRear", "getRearDefrostStatus", if (on) 1 else 0, 2, "switchRearDefrostStatus") + else + acCall(if (on) "openBackWindowDefroster" else "closeBackWindowDefroster") + .also { climLog("defrostRear=$on", it) } + + /** + * Applique un préréglage complet de climatisation (automatisation température). + * + * Met d'abord le système **et l'A/C en marche** : régler une consigne sur une clim éteinte + * ne produit rien de visible, et l'automatisation semblerait ne pas fonctionner. + * + * La consigne et la ventilation sont **clampées aux bornes réelles du véhicule** (lues dans + * l'état), pas aux bornes de saisie de l'UI — un firmware peut accepter 17–33 quand un autre + * fait 15–31. Les dégivrages ne sont écrits que si leur état est lisible : sinon on + * n'enverrait qu'une commande à l'aveugle. + * + * ⚠️ Bloquant (plusieurs secondes avec les bascules) → appeler depuis un thread IO. + */ + fun applyClimatePreset(targetTemp: Int, fanLevel: Int, defrostFront: Boolean, defrostRear: Boolean): Boolean { + val state = getClimateState() ?: run { + AppLogger.w(CLIM_TAG, "Préréglage : état clim illisible → abandon") + return false + } + var ok = setClimatePower(true) + ok = setClimateAc(true) && ok + ok = setClimateTemp(targetTemp.coerceIn(state.tempMin, state.tempMax)) && ok + ok = setClimateFan(fanLevel.coerceIn(state.fanMin, state.fanMax)) && ok + if (state.defrostFront != null) ok = setClimateDefrostFront(defrostFront) && ok + if (state.defrostRear != null) ok = setClimateDefrostRear(defrostRear) && ok + AppLogger.i(CLIM_TAG, "Préréglage appliqué : consigne=$targetTemp vent=$fanLevel " + + "dégAV=$defrostFront dégAR=$defrostRear → ok=$ok") + return ok + } + /** Connexion (async) à l'API Car AOSP → CarPropertyManager ("property") ET CarDoorLockManager * ("doorlock"). Selon le firmware, la porte est exposée par l'un ou l'autre → on lit via les deux. */ private fun connectCarProperty() { diff --git a/app/src/main/java/com/mg4/control/model/DrivingProfile.kt b/app/src/main/java/com/mg4/control/model/DrivingProfile.kt index f6693c8e..acd05376 100644 --- a/app/src/main/java/com/mg4/control/model/DrivingProfile.kt +++ b/app/src/main/java/com/mg4/control/model/DrivingProfile.kt @@ -10,6 +10,14 @@ data class DrivingProfile( val steeringHeat: Boolean = false, val seatHeatLeft: Int = 0, // 0=off, 1, 2, 3 val seatHeatRight: Int = 0, + // Prise en compte du chauffage : décorrélée de la valeur. `seatHeatLeft=0` voulait dire + // « appliquer : éteint » — il manquait un moyen de dire « ne pas y toucher ». + // ⚠️ NULLABLES À DESSEIN : Gson n'appelle pas le constructeur Kotlin, donc un défaut `= true` + // ne s'appliquerait PAS aux profils déjà enregistrés (champ absent → false) et le chauffage + // cesserait silencieusement d'être appliqué. Avec `Boolean?`, un champ absent reste null, et + // null se lit « activé » via les accesseurs ci-dessous → aucune migration, aucune régression. + val steeringHeatEnabled: Boolean? = null, + val seatHeatEnabled: Boolean? = null, // ADAS SWI133 (Katman4) — valeurs par défaut OFF pour compatibilité profils existants val overspeedAlarm: Boolean = false, val speedLimitTone: Boolean = false, @@ -38,4 +46,10 @@ data class DrivingProfile( val isDefault: Boolean = false, // [BT-PROFILES] MAC de l'appareil Bluetooth associé à ce profil (null = aucun) val btDeviceMac: String? = null -) +) { + /** Volant chauffant à appliquer ? (profil d'avant la fonction → oui, comportement inchangé) */ + val appliesSteeringHeat: Boolean get() = steeringHeatEnabled ?: true + + /** Sièges chauffants à appliquer ? (idem) */ + val appliesSeatHeat: Boolean get() = seatHeatEnabled ?: true +} diff --git a/app/src/main/java/com/mg4/control/profile/ProfileApplier.kt b/app/src/main/java/com/mg4/control/profile/ProfileApplier.kt index 7d3bafb9..80804974 100644 --- a/app/src/main/java/com/mg4/control/profile/ProfileApplier.kt +++ b/app/src/main/java/com/mg4/control/profile/ProfileApplier.kt @@ -94,12 +94,22 @@ object ProfileApplier { // Volant + Sièges chauffants — uniquement SWI133 et SWI68 (SWI69/SWI131 n'ont pas ces équipements) if (FirmwareInfo.hasHeatFeatures()) { - val shOk = MG4Hardware.setSteeringHeat(profile.steeringHeat) - AppLogger.i(TAG, " SteeringHeat=${profile.steeringHeat} → $shOk") - val slOk = MG4Hardware.setSeatHeatLeft(profile.seatHeatLeft) - AppLogger.i(TAG, " SeatHeatLeft=${profile.seatHeatLeft} → $slOk") - val srOk = MG4Hardware.setSeatHeatRight(profile.seatHeatRight) - AppLogger.i(TAG, " SeatHeatRight=${profile.seatHeatRight} → $srOk") + // Chauffages décochés dans le profil = on NE TOUCHE À RIEN (ni allumer, ni éteindre), + // sinon appliquer un profil écraserait un réglage que le conducteur vient de faire. + if (profile.appliesSteeringHeat) { + val shOk = MG4Hardware.setSteeringHeat(profile.steeringHeat) + AppLogger.i(TAG, " SteeringHeat=${profile.steeringHeat} → $shOk") + } else { + AppLogger.i(TAG, " SteeringHeat non pris en compte par ce profil — inchangé") + } + if (profile.appliesSeatHeat) { + val slOk = MG4Hardware.setSeatHeatLeft(profile.seatHeatLeft) + AppLogger.i(TAG, " SeatHeatLeft=${profile.seatHeatLeft} → $slOk") + val srOk = MG4Hardware.setSeatHeatRight(profile.seatHeatRight) + AppLogger.i(TAG, " SeatHeatRight=${profile.seatHeatRight} → $srOk") + } else { + AppLogger.i(TAG, " SeatHeat non pris en compte par ce profil — inchangé") + } } AppLogger.i(TAG, "Profil '${profile.name}' Katman1 terminé — ok=$ok") diff --git a/app/src/main/java/com/mg4/control/service/MG4ControlService.kt b/app/src/main/java/com/mg4/control/service/MG4ControlService.kt index 398957c4..31500ba0 100644 --- a/app/src/main/java/com/mg4/control/service/MG4ControlService.kt +++ b/app/src/main/java/com/mg4/control/service/MG4ControlService.kt @@ -22,6 +22,8 @@ import com.mg4.control.util.LocaleHelper import com.mg4.control.R import com.mg4.control.automation.AutomationDecision import com.mg4.control.automation.AutomationSettings +import com.mg4.control.automation.ClimateAutomationDecision +import com.mg4.control.automation.ClimateAutomationSettings import com.mg4.control.bluetooth.BluetoothProfileManager import com.mg4.control.debug.AppLogger import com.mg4.control.hardware.MG4Hardware @@ -67,6 +69,12 @@ class MG4ControlService : Service() { */ @Volatile private var profileScheduled = false + /** Dernière exécution de l'automatisation clim — anti-rebond (démarrage service et + * IGNITION_RUN arrivent souvent à quelques secondes d'écart : sans ça, on écraserait + * un réglage que l'utilisateur vient de faire à la main entre les deux). */ + @Volatile private var climateAutoLastRunMs = 0L + private const val CLIMATE_AUTO_DEBOUNCE_MS = 60_000L + } // ── Hardkey receiver ───────────────────────────────────────────────────── @@ -115,6 +123,7 @@ class MG4ControlService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { AppLogger.i(TAG, "onStartCommand") + tryClimateAutomation("démarrage service") scheduleDefaultProfileOnce() return START_STICKY } @@ -454,6 +463,7 @@ class MG4ControlService : Service() { AppLogger.i(TAG, "Katman5 IGNITION_RUN → application du profil") Handler(Looper.getMainLooper()).postDelayed({ applyDefaultProfileOnIgnition() + tryClimateAutomation("IGNITION_RUN") }, 500L) } MG4Hardware.CarIgnitionItem.OFF -> { @@ -552,6 +562,59 @@ class MG4ControlService : Service() { } } + /** + * Automatisation « Déclenchement A/C via la température ». + * + * Indépendante des profils : elle a son propre interrupteur et n'est donc **pas** soumise à + * `auto_apply_profile` (ce n'est pas une application de profil). Elle ne remplace ni ne + * retarde la chaîne profil — les deux tournent en parallèle. + * + * Anti-rebond [CLIMATE_AUTO_DEBOUNCE_MS] : démarrage service et IGNITION_RUN se suivent de + * près, et réappliquer écraserait un réglage manuel fait entre les deux. + */ + private fun tryClimateAutomation(origin: String) { + val ctx = applicationContext + val cfg = ClimateAutomationSettings.read(ctx) + // Comme pour l'auto température : on trace toujours, même désactivée — sinon un + // utilisateur qui dit « ça ne marche pas » ne laisse aucune trace exploitable. + if (!cfg.enabled) { + AppLogger.i(TAG, "Auto A/C ($origin) : DÉSACTIVÉE dans Automatisation") + return + } + if (!MG4Hardware.hasClimateControl()) { + AppLogger.i(TAG, "Auto A/C ($origin) : clim non pilotable sur ce firmware") + return + } + val since = System.currentTimeMillis() - climateAutoLastRunMs + if (climateAutoLastRunMs != 0L && since < CLIMATE_AUTO_DEBOUNCE_MS) { + AppLogger.i(TAG, "Auto A/C ($origin) : déjà appliquée il y a ${since / 1000}s — skip") + return + } + + MG4Hardware.whenKatman1Ready { + val temp = MG4Hardware.getOutsideTempCelsius() + val outcome = ClimateAutomationDecision.evaluate(cfg, temp) + AppLogger.i(TAG, "Auto A/C ($origin) : chaud=${cfg.hot.active}/≥${cfg.hot.threshold}°C " + + "froid=${cfg.cold.active}/≤${cfg.cold.threshold}°C | temp lue=${temp ?: "illisible"} → $outcome") + val rule = when (outcome) { + ClimateAutomationDecision.Outcome.HOT -> cfg.hot + ClimateAutomationDecision.Outcome.COLD -> cfg.cold + ClimateAutomationDecision.Outcome.NONE -> return@whenKatman1Ready + } + climateAutoLastRunMs = System.currentTimeMillis() + // applyClimatePreset enchaîne des bascules (plusieurs secondes) → jamais sur le main thread. + CoroutineScope(Dispatchers.IO).launch { + val ok = MG4Hardware.applyClimatePreset( + targetTemp = rule.targetTemp, + fanLevel = rule.fanLevel, + defrostFront = rule.defrostFront, + defrostRear = rule.defrostRear + ) + AppLogger.i(TAG, "Auto A/C ($origin) : règle $outcome appliquée — ok=$ok") + } + } + } + /** * Applique le profil approprié suite à un événement IGNITION_STATE=RUN. * Priorité : choix manuel récent (popup/app) → profil BT associé → profil par défaut. @@ -648,15 +711,19 @@ class MG4ControlService : Service() { * l'utilisateur a choisi un thème manuel (mode ≠ "auto"). */ private fun registerSkinChangeReceiver() { - if (!ThemeHelper.hasSkinThemeConfig(this)) { - // SWI133/68 : MODE_NIGHT_FOLLOW_SYSTEM gère la sync automatiquement - AppLogger.i(TAG, "[THEME] SKIN_THEME_CONFIG absent — FOLLOW_SYSTEM actif, broadcast non requis") - return - } + // ⚠️ NE PLUS conditionner l'enregistrement à hasSkinThemeConfig(). Le service démarre sur + // LOCKED_BOOT_COMPLETED, donc AVANT le déverrouillage et avant que le launcher soit debout : + // si SKIN_THEME_CONFIG n'est pas encore lisible à cet instant, l'ancienne sonde one-shot + // concluait « firmware sans skin » et le receiver n'était JAMAIS enregistré de toute la vie + // du process — le thème auto restait mort jusqu'au prochain redémarrage. Sur un firmware + // qui n'émet pas ce broadcast, un receiver inutilisé ne coûte rien. + AppLogger.i(TAG, "[THEME] SKIN_THEME_CONFIG lisible au démarrage=${ThemeHelper.hasSkinThemeConfig(this)}") skinChangeReceiver = object : BroadcastReceiver() { override fun onReceive(ctx: Context, intent: Intent) { val prefs = getSharedPreferences("mg4_settings", MODE_PRIVATE) - if (prefs.getString(ThemeHelper.PREF_THEME_MODE, "dark") != "auto") return + // Défaut "auto" — cohérent avec ThemeHelper et MG4App. L'ancien défaut "dark" + // faisait sortir le receiver si la clé manquait. + if (prefs.getString(ThemeHelper.PREF_THEME_MODE, "auto") != "auto") return val nightMode = ThemeHelper.getLauncherNightMode(ctx) Handler(Looper.getMainLooper()).post { diff --git a/app/src/main/java/com/mg4/control/ui/AutomationFragment.kt b/app/src/main/java/com/mg4/control/ui/AutomationFragment.kt index 2ee6ded5..382fd0f1 100644 --- a/app/src/main/java/com/mg4/control/ui/AutomationFragment.kt +++ b/app/src/main/java/com/mg4/control/ui/AutomationFragment.kt @@ -16,6 +16,8 @@ import androidx.fragment.app.Fragment import com.google.android.material.button.MaterialButton import com.mg4.control.R import com.mg4.control.automation.AutomationSettings +import com.mg4.control.automation.ClimateAutomationSettings +import com.mg4.control.hardware.MG4Hardware import com.mg4.control.model.DrivingProfile import com.mg4.control.profile.ProfileManager @@ -39,14 +41,15 @@ class AutomationFragment : Fragment() { val enabled = prefs.getBoolean(AutomationSettings.KEY_ENABLED, false) switchAuto.isChecked = enabled - rowConfig.visibility = if (enabled) View.VISIBLE else View.GONE inputTemp.setText(prefs.getInt(AutomationSettings.KEY_THRESHOLD, AutomationSettings.DEFAULT_THRESHOLD).toString()) checkAuto.isChecked = prefs.getBoolean(AutomationSettings.KEY_AUTO_EXECUTE, false) + // L'interrupteur ne commande QUE l'activation : le parametrage reste consultable + // automatisation eteinte, c'est le chevron qui le replie. switchAuto.setOnCheckedChangeListener { _, checked -> prefs.edit().putBoolean(AutomationSettings.KEY_ENABLED, checked).apply() - rowConfig.visibility = if (checked) View.VISIBLE else View.GONE } + bindExpander(view.findViewById(R.id.btn_automation_expand), rowConfig, expanded = enabled) fun commitTemp() { val clamped = AutomationSettings.clampTemp(inputTemp.text.toString().toIntOrNull()) @@ -86,6 +89,127 @@ class AutomationFragment : Fragment() { btnDirAbove.setOnClickListener { setDirection(AutomationSettings.Direction.ABOVE) } setupSpinner(spinner, prefs) + bindClimateAutomation(view, prefs) + } + + // ══════════ Automatisation « Déclenchement A/C via la température » ══════════ + + /** + * Les deux règles (chaud / froid) ont exactement la même structure : on les câble via + * [bindClimateRule] plutôt que de dupliquer six écouteurs, sinon une correction sur l'une + * finit tôt ou tard par manquer sur l'autre. + * + * Les réglages partagent le fichier de préférences des profils ([AutomationSettings.PREFS]) + * mais pas leur interrupteur : cette automatisation n'est pas une application de profil. + */ + private fun bindClimateAutomation(view: View, prefs: android.content.SharedPreferences) { + val card = view.findViewById(R.id.card_ac_automation) + // Firmware inconnu = aucune voie clim → afficher des réglages sans effet serait trompeur. + if (!MG4Hardware.hasClimateControl()) { + card.visibility = View.GONE + return + } + + val switchAc = view.findViewById(R.id.switch_ac_auto) + val rowConfig = view.findViewById(R.id.row_ac_auto_config) + + val enabled = prefs.getBoolean(ClimateAutomationSettings.KEY_ENABLED, false) + switchAc.isChecked = enabled + switchAc.setOnCheckedChangeListener { _, checked -> + prefs.edit().putBoolean(ClimateAutomationSettings.KEY_ENABLED, checked).apply() + } + bindExpander(view.findViewById(R.id.btn_ac_auto_expand), rowConfig, expanded = enabled) + + bindClimateRule( + view, prefs, hot = true, + checkId = R.id.check_ac_hot, rowId = R.id.row_ac_hot_config, + thresholdId = R.id.input_ac_hot_threshold, targetId = R.id.input_ac_hot_target, + fanId = R.id.input_ac_hot_fan, + defFrontId = R.id.check_ac_hot_def_front, defRearId = R.id.check_ac_hot_def_rear + ) + bindClimateRule( + view, prefs, hot = false, + checkId = R.id.check_ac_cold, rowId = R.id.row_ac_cold_config, + thresholdId = R.id.input_ac_cold_threshold, targetId = R.id.input_ac_cold_target, + fanId = R.id.input_ac_cold_fan, + defFrontId = R.id.check_ac_cold_def_front, defRearId = R.id.check_ac_cold_def_rear + ) + } + + private fun bindClimateRule( + view: View, + prefs: android.content.SharedPreferences, + hot: Boolean, + checkId: Int, rowId: Int, + thresholdId: Int, targetId: Int, fanId: Int, + defFrontId: Int, defRearId: Int + ) { + val check = view.findViewById(checkId) + val row = view.findViewById(rowId) + val threshold = view.findViewById(thresholdId) + val target = view.findViewById(targetId) + val fan = view.findViewById(fanId) + val defFront = view.findViewById(defFrontId) + val defRear = view.findViewById(defRearId) + + val active = prefs.getBoolean(ClimateAutomationSettings.keyOn(hot), false) + check.isChecked = active + row.visibility = if (active) View.VISIBLE else View.GONE + check.setOnCheckedChangeListener { _, checked -> + prefs.edit().putBoolean(ClimateAutomationSettings.keyOn(hot), checked).apply() + row.visibility = if (checked) View.VISIBLE else View.GONE + } + + val defThreshold = if (hot) ClimateAutomationSettings.DEFAULT_HOT_THRESHOLD + else ClimateAutomationSettings.DEFAULT_COLD_THRESHOLD + val defTarget = if (hot) ClimateAutomationSettings.DEFAULT_HOT_TARGET + else ClimateAutomationSettings.DEFAULT_COLD_TARGET + threshold.setText(prefs.getInt(ClimateAutomationSettings.keyThreshold(hot), defThreshold).toString()) + target.setText(prefs.getInt(ClimateAutomationSettings.keyTarget(hot), defTarget).toString()) + fan.setText(prefs.getInt(ClimateAutomationSettings.keyFan(hot), ClimateAutomationSettings.DEFAULT_FAN).toString()) + + bindIntField(threshold, ClimateAutomationSettings.keyThreshold(hot), prefs) { + ClimateAutomationSettings.clampThreshold(it, hot) + } + bindIntField(target, ClimateAutomationSettings.keyTarget(hot), prefs) { + ClimateAutomationSettings.clampTarget(it, hot) + } + bindIntField(fan, ClimateAutomationSettings.keyFan(hot), prefs) { + ClimateAutomationSettings.clampFan(it) + } + + defFront.isChecked = prefs.getBoolean(ClimateAutomationSettings.keyDefFront(hot), false) + defRear.isChecked = prefs.getBoolean(ClimateAutomationSettings.keyDefRear(hot), false) + defFront.setOnCheckedChangeListener { _, c -> + prefs.edit().putBoolean(ClimateAutomationSettings.keyDefFront(hot), c).apply() + } + defRear.setOnCheckedChangeListener { _, c -> + prefs.edit().putBoolean(ClimateAutomationSettings.keyDefRear(hot), c).apply() + } + } + + /** + * Enregistre un champ numérique à la perte de focus et sur « Terminé », en réécrivant la + * valeur bornée dans le champ : sans ça l'utilisateur voit 99 alors que 33 a été enregistré. + * Même motif que le seuil de l'automatisation profil au-dessus. + */ + private fun bindIntField( + field: EditText, + key: String, + prefs: android.content.SharedPreferences, + clamp: (Int?) -> Int + ) { + fun commit() { + val clamped = clamp(field.text.toString().toIntOrNull()) + prefs.edit().putInt(key, clamped).apply() + val txt = clamped.toString() + if (field.text.toString() != txt) field.setText(txt) + } + field.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) commit() } + field.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) commit() + false + } } override fun onResume() { @@ -119,4 +243,23 @@ class AutomationFragment : Fragment() { override fun onNothingSelected(parent: android.widget.AdapterView<*>?) {} } } + + /** + * Chevron de depliage d'une carte d'automatisation. + * + * Volontairement decorrele de l'interrupteur d'activation : on doit pouvoir consulter et + * modifier le parametrage sans activer l'automatisation, et inversement la laisser active + * en repliant la carte. L'etat initial suit quand meme l'activation — une automatisation + * eteinte s'ouvre repliee, ce qui reproduit le comportement precedent. + */ + private fun bindExpander(btn: MaterialButton, content: View, expanded: Boolean) { + var open = expanded + fun apply() { + content.visibility = if (open) View.VISIBLE else View.GONE + btn.text = if (open) "▾" else "▸" // chevron bas / droite + } + apply() + btn.setOnClickListener { open = !open; apply() } + } + } diff --git a/app/src/main/java/com/mg4/control/ui/DashboardFragment.kt b/app/src/main/java/com/mg4/control/ui/DashboardFragment.kt index 025734ca..108b0834 100644 --- a/app/src/main/java/com/mg4/control/ui/DashboardFragment.kt +++ b/app/src/main/java/com/mg4/control/ui/DashboardFragment.kt @@ -2,15 +2,20 @@ package com.mg4.control.ui import android.content.res.ColorStateList import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ScrollView import android.widget.Button import android.widget.LinearLayout import android.widget.Switch +import android.widget.TextView +import android.widget.Toast import androidx.fragment.app.Fragment -import androidx.recyclerview.widget.RecyclerView -import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.button.MaterialButton +import com.google.android.material.slider.Slider import com.mg4.control.R import com.mg4.control.hardware.MG4Hardware import com.mg4.control.hardware.MG4Hardware.AebMode @@ -23,19 +28,20 @@ import com.mg4.control.model.RegenLevel import com.mg4.control.util.FirmwareInfo import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** - * Fragment principal du dashboard — ViewPager2 horizontal. + * Fragment principal du dashboard — rail de categories a gauche, contenu defilant. * Page 0 : paramètres de conduite, climat, alertes, AEB * Page 1 : Assistant de sortie de voie (ELK) */ class DashboardFragment : Fragment() { // ── ViewPager ──────────────────────────────────────────────────────────── - private var pager: ViewPager2? = null - private var dots: Array? = null + /** Onglet courant du rail : 0=Conduite, 1=Securite, 2=Confort. Remplace pager.currentItem. */ + private var currentTab = 0 // ── Page 0 — Drive mode ───────────────────────────────────────────────── private val driveModeButtons = mutableMapOf() @@ -131,13 +137,15 @@ class DashboardFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - setupPager(view) + setupDashboard(view) } override fun onResume() { super.onResume() refreshDriveRegen() refreshClimate() + refreshClimatePage(force = true) // page 2 — no-op si elle n'a pas été créée (A9) + if (currentTab == TAB_COMFORT) startClimatePolling() MG4Hardware.whenKatman4Ready { if (isAdded) { refreshAdas() @@ -147,62 +155,92 @@ class DashboardFragment : Fragment() { refreshElk() // SWI133 — sVsm133 indépendant de Katman4 } + override fun onPause() { + super.onPause() + stopClimatePolling() // pas de sondage binder quand l'écran n'est plus visible + } + // ═════════════════════════════════════════════════════════════════════════ - // ViewPager2 — Adapter + Dots + // Rail de categories + binding des trois sections // ═════════════════════════════════════════════════════════════════════════ - private fun setupPager(root: View) { - pager = root.findViewById(R.id.dashboard_pager) - val dotsContainer = root.findViewById(R.id.pager_dots) - - pager?.adapter = DashboardPagerAdapter() - pager?.offscreenPageLimit = 1 // garde les 2 pages en mémoire - - // Dots - val dotCount = 2 - val dotViews = Array(dotCount) { i -> - View(requireContext()).apply { - val size = 10 - val lp = LinearLayout.LayoutParams(size, size) - lp.setMargins(6, 0, 6, 0) - layoutParams = lp - setBackgroundResource(R.drawable.dot_indicator) - isSelected = i == 0 - } - } - dotViews.forEach { dotsContainer.addView(it) } - dots = dotViews - - pager?.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - dots?.forEachIndexed { i, dot -> dot.isSelected = i == position } - // Refresh ELK quand on arrive sur la page 1 - if (position == 1) refreshElk() - } - }) + private companion object { + const val TAB_DRIVE = 0 + const val TAB_SAFETY = 1 + const val TAB_COMFORT = 2 } - private inner class DashboardPagerAdapter : - RecyclerView.Adapter() { - - inner class PageHolder(val view: View) : RecyclerView.ViewHolder(view) - - override fun getItemCount() = 2 - override fun getItemViewType(position: Int) = position + /** + * Le ViewPager2 a ete remplace par un rail : les trois anciennes pages vivent desormais + * dans le MEME arbre de vues, on peut donc toutes les lier d'un coup sur la racine. + * C'est sûr parce que leurs ids ne se recouvrent pas (verifie a la refonte). + * + * Consequence a ne pas perdre de vue : les visibilites conditionnelles au firmware sont + * appliquees par bindMainPage/bindElkPage comme avant — le rail passe APRES, pour que sa + * garde d'onglet vide voie l'etat final. + */ + private fun setupDashboard(root: View) { + bindMainPage(root) + bindElkPage(root) + + // La clim n'est liee QUE si le firmware l'expose : avant, la page n'etait meme pas + // creee par l'adapter. Ici elle existe toujours dans l'arbre, il faut donc la masquer. + val hasClim = MG4Hardware.hasClimateControl() + root.findViewById(R.id.climate_page_section)?.visibility = + if (hasClim) View.VISIBLE else View.GONE + if (hasClim) bindClimatePage(root) + + bindCategoryRail(root) + } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PageHolder { - val layoutId = if (viewType == 0) R.layout.page_dashboard_main - else R.layout.page_dashboard_elk - val v = LayoutInflater.from(parent.context).inflate(layoutId, parent, false) - return PageHolder(v) + /** + * Rail de gauche — meme motif que les autres ecrans refondus. Un onglet dont la page n'a + * plus aucune section visible sur ce firmware est masque. + */ + private fun bindCategoryRail(root: View) { + val tabs = listOf( + root.findViewById(R.id.btn_dash_cat_drive) to root.findViewById(R.id.page_dash_drive), + root.findViewById(R.id.btn_dash_cat_safety) to root.findViewById(R.id.page_dash_safety), + root.findViewById(R.id.btn_dash_cat_comfort) to root.findViewById(R.id.page_dash_comfort) + ) + val scroll = root.findViewById(R.id.scroll_dashboard) + val dimColor = requireContext().getColor(R.color.dash_accent_dim) + val accent = requireContext().getColor(R.color.dash_accent) + val offBg = requireContext().getColor(R.color.dash_btn) + val border = requireContext().getColor(R.color.dash_border) + val textOff = requireContext().getColor(R.color.text_secondary) + + fun hasVisibleContent(page: ViewGroup): Boolean = + (0 until page.childCount).any { page.getChildAt(it).visibility == View.VISIBLE } + + val usable = tabs.filterIndexed { _, (_, page) -> hasVisibleContent(page) } + tabs.forEach { (btn, page) -> + btn.visibility = if (usable.any { it.second === page }) View.VISIBLE else View.GONE } - - override fun onBindViewHolder(holder: PageHolder, position: Int) { - when (position) { - 0 -> bindMainPage(holder.view) - 1 -> bindElkPage(holder.view) + if (usable.isEmpty()) return + + fun select(index: Int) { + currentTab = index + tabs.forEachIndexed { i, (btn, page) -> + val on = i == index + page.visibility = if (on) View.VISIBLE else View.GONE + btn.backgroundTintList = ColorStateList.valueOf(if (on) dimColor else offBg) + btn.setTextColor(if (on) accent else textOff) + btn.strokeColor = ColorStateList.valueOf(if (on) accent else border) + } + scroll?.scrollTo(0, 0) + // Rafraichissements qui etaient declenches par onPageSelected du pager. + if (index == TAB_SAFETY) refreshElk() + if (index == TAB_COMFORT && MG4Hardware.hasClimateControl()) { + refreshClimatePage(force = true) + startClimatePolling() + } else { + stopClimatePolling() } } + + tabs.forEachIndexed { i, (btn, _) -> btn.setOnClickListener { select(i) } } + select(tabs.indexOfFirst { it.second === usable.first().second }) } // ═════════════════════════════════════════════════════════════════════════ @@ -966,4 +1004,202 @@ class DashboardFragment : Fragment() { switchElkVibration?.isEnabled = enabled switchElkVibration?.alpha = if (enabled) 1f else 0.35f } + + // ═════════════════════════════════════════════════════════════════════════ + // Page 2 — Climatisation (old-SDK : voie AirConditionManager) + // Confort : ces écritures ne passent PAS par le verrou de vitesse (T-904). + // ═════════════════════════════════════════════════════════════════════════ + + /** Délai laissé au véhicule pour propager une écriture avant de relire son état. */ + private val CLIM_SETTLE_MS = 700L + /** Période de suivi tant que la page clim est affichée. */ + private val CLIM_POLL_MS = 2_000L + + private var climTempSlider: Slider? = null + private var climFanSlider: Slider? = null + private var climTempValue: TextView? = null + private var climFanValue: TextView? = null + private var climBtnPower: MaterialButton? = null + private var climBtnAc: MaterialButton? = null + private var climBtnAuto: MaterialButton? = null + private var climBtnDefFront: MaterialButton? = null + private var climBtnDefRear: MaterialButton? = null + private var climLoopButtons: Map = emptyMap() + /** Dernier état connu — sert à savoir vers quoi basculer au clic d'un bouton. */ + private var climLastState: MG4Hardware.ClimateState? = null + + /** Anti-rebond des sliders : un glissement enverrait sinon des dizaines d'écritures binder. */ + private val climHandler = Handler(Looper.getMainLooper()) + private var climPendingWrite: Runnable? = null + /** Vrai entre l'envoi d'une écriture et la relecture : bloque tout rafraîchissement. */ + @Volatile private var climWriteInFlight = false + /** Vrai pendant qu'un doigt tient un slider : ne pas lui réimposer une valeur. */ + private var climSliderTouched = false + + /** Suivi périodique tant que la page clim est affichée (changements faits sur l'écran voiture). */ + private val climPollRunnable = object : Runnable { + override fun run() { + refreshClimatePage() + climHandler.postDelayed(this, CLIM_POLL_MS) + } + } + + private fun startClimatePolling() { + stopClimatePolling() + climHandler.postDelayed(climPollRunnable, CLIM_POLL_MS) + } + + private fun stopClimatePolling() = climHandler.removeCallbacks(climPollRunnable) + + private fun bindClimatePage(view: View) { + climTempSlider = view.findViewById(R.id.clim_temp_slider) + climFanSlider = view.findViewById(R.id.clim_fan_slider) + climTempValue = view.findViewById(R.id.clim_temp_value) + climFanValue = view.findViewById(R.id.clim_fan_value) + climBtnPower = view.findViewById(R.id.clim_btn_power) + climBtnAc = view.findViewById(R.id.clim_btn_ac) + climBtnAuto = view.findViewById(R.id.clim_btn_auto) + climBtnDefFront = view.findViewById(R.id.clim_btn_defrost_front) + climBtnDefRear = view.findViewById(R.id.clim_btn_defrost_rear) + climLoopButtons = mapOf( + MG4Hardware.LoopMode.INNER to view.findViewById(R.id.clim_btn_loop_inner), + MG4Hardware.LoopMode.OUTSIDE to view.findViewById(R.id.clim_btn_loop_outside), + MG4Hardware.LoopMode.AUTO to view.findViewById(R.id.clim_btn_loop_auto) + ) + setupClimateListeners() + refreshClimatePage() + } + + /** + * Écrit en IO puis rafraîchit — mais **après un délai**. Le véhicule met un instant à + * propager la nouvelle valeur : relire immédiatement renvoyait l'ANCIENNE et la + * réappliquait à l'UI, d'où le slider qui revenait en arrière avec un cran de retard. + */ + private fun climateWrite(action: () -> Boolean) { + climWriteInFlight = true + CoroutineScope(Dispatchers.IO).launch { + val ok = action() + withContext(Dispatchers.Main) { + if (isAdded && !ok) + Toast.makeText(requireContext(), R.string.clim_write_failed, Toast.LENGTH_SHORT).show() + } + delay(CLIM_SETTLE_MS) + climWriteInFlight = false + refreshClimatePage(force = true) + } + } + + /** Écriture différée (anti-rebond) — une seule requête part à la fin du glissement. */ + private fun climateWriteDebounced(action: () -> Boolean) { + climPendingWrite?.let { climHandler.removeCallbacks(it) } + val r = Runnable { climateWrite(action) } + climPendingWrite = r + climHandler.postDelayed(r, 200L) + } + + private fun setupClimateListeners() { + // Pendant qu'un doigt tient le slider, aucun rafraîchissement ne doit le déplacer. + val touchGuard = object : Slider.OnSliderTouchListener { + override fun onStartTrackingTouch(s: Slider) { climSliderTouched = true } + override fun onStopTrackingTouch(s: Slider) { climSliderTouched = false } + } + climTempSlider?.addOnSliderTouchListener(touchGuard) + climFanSlider?.addOnSliderTouchListener(touchGuard) + + climTempSlider?.addOnChangeListener { _, value, fromUser -> + climTempValue?.text = "${value.toInt()}°" + if (fromUser && !isRefreshing) { + val target = value.toInt() + climateWriteDebounced { MG4Hardware.setClimateTemp(target) } + } + } + climFanSlider?.addOnChangeListener { _, value, fromUser -> + climFanValue?.text = value.toInt().toString() + if (fromUser && !isRefreshing) { + val target = value.toInt() + climateWriteDebounced { MG4Hardware.setClimateFan(target) } + } + } + // Boutons bascule : on vise l'inverse du dernier état lu. Un état inconnu (null) + // signifie « non lisible sur ce firmware » → le bouton est déjà désactivé. + climBtnPower?.setOnClickListener { + climLastState?.powerOn?.let { cur -> climateWrite { MG4Hardware.setClimatePower(!cur) } } + } + climBtnAc?.setOnClickListener { + climLastState?.acOn?.let { cur -> climateWrite { MG4Hardware.setClimateAc(!cur) } } + } + climBtnAuto?.setOnClickListener { + climLastState?.autoOn?.let { cur -> climateWrite { MG4Hardware.setClimateAuto(!cur) } } + } + climBtnDefFront?.setOnClickListener { + climLastState?.defrostFront?.let { cur -> climateWrite { MG4Hardware.setClimateDefrostFront(!cur) } } + } + climBtnDefRear?.setOnClickListener { + climLastState?.defrostRear?.let { cur -> climateWrite { MG4Hardware.setClimateDefrostRear(!cur) } } + } + climLoopButtons.forEach { (mode, btn) -> + btn?.setOnClickListener { climateWrite { MG4Hardware.setClimateLoopMode(mode) } } + } + } + + /** + * [force] = rafraîchissement explicite (arrivée sur la page, fin d'écriture). Sinon on + * s'abstient si une écriture est en vol ou si l'utilisateur tient un slider — lui + * réimposer une valeur pendant qu'il agit serait le pire des comportements. + */ + private fun refreshClimatePage(force: Boolean = false) { + if (climTempSlider == null) return + if (!force && (climWriteInFlight || climSliderTouched)) return + CoroutineScope(Dispatchers.IO).launch { + val s = MG4Hardware.getClimateState() + withContext(Dispatchers.Main) { + if (!isAdded || s == null) return@withContext + isRefreshing = true + + // Bornes lues sur le véhicule — jamais celles du XML. + climTempSlider?.apply { + valueFrom = s.tempMin.toFloat() + valueTo = s.tempMax.toFloat() + s.tempC?.let { value = it.coerceIn(s.tempMin, s.tempMax).toFloat() } + isEnabled = s.tempC != null + } + climTempValue?.text = s.tempC?.let { "$it°" } ?: "--°" + + climFanSlider?.apply { + valueFrom = s.fanMin.toFloat() + valueTo = s.fanMax.toFloat() + s.fanLevel?.let { value = it.coerceIn(s.fanMin, s.fanMax).toFloat() } + isEnabled = s.fanLevel != null + } + climFanValue?.text = s.fanLevel?.toString() ?: "--" + + climLastState = s + bindClimToggle(climBtnPower, s.powerOn) + bindClimToggle(climBtnAc, s.acOn) + bindClimToggle(climBtnAuto, s.autoOn) + bindClimToggle(climBtnDefFront, s.defrostFront) + bindClimToggle(climBtnDefRear, s.defrostRear) + + climLoopButtons.forEach { (mode, btn) -> + val active = s.loopMode == mode + btn?.backgroundTintList = ColorStateList.valueOf(if (active) colorActive else colorInactive) + btn?.setTextColor(if (active) colorTextActive else colorTextInactive) + } + + isRefreshing = false + } + } + } + + /** + * Bouton bascule : accentué quand actif, comme les boutons de recirculation. + * Un état null = non lisible sur ce firmware → bouton grisé et inerte, plutôt que menteur. + */ + private fun bindClimToggle(btn: MaterialButton?, state: Boolean?) { + val active = state == true + btn?.backgroundTintList = ColorStateList.valueOf(if (active) colorActive else colorInactive) + btn?.setTextColor(if (active) colorTextActive else colorTextInactive) + btn?.isEnabled = state != null + btn?.alpha = if (state != null) 1f else 0.35f + } } diff --git a/app/src/main/java/com/mg4/control/ui/ProfileEditFragment.kt b/app/src/main/java/com/mg4/control/ui/ProfileEditFragment.kt new file mode 100644 index 00000000..d46d93f4 --- /dev/null +++ b/app/src/main/java/com/mg4/control/ui/ProfileEditFragment.kt @@ -0,0 +1,562 @@ +package com.mg4.control.ui + +import android.content.res.ColorStateList +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import com.google.android.material.button.MaterialButton +import com.mg4.control.R +import com.mg4.control.bluetooth.BluetoothProfileManager +import com.mg4.control.hardware.MG4Hardware +import com.mg4.control.hardware.MG4Hardware.AebMode +import com.mg4.control.hardware.MG4Hardware.AebSensitivity +import com.mg4.control.hardware.MG4Hardware.ElkMode +import com.mg4.control.hardware.MG4Hardware.ElkSensitivity +import com.mg4.control.hardware.MG4Hardware.Swi68Mode +import com.mg4.control.model.DriveMode +import com.mg4.control.model.DrivingProfile +import com.mg4.control.model.RegenLevel +import com.mg4.control.profile.ProfileManager +import com.mg4.control.util.FirmwareInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Création / édition d'un profil, en plein écran (auparavant un AlertDialog trop à l'étroit). + * + * Les options sont réparties en trois catégories sélectionnées par le rail de gauche ; le nom du + * profil et le réglage « par défaut » restent visibles quelle que soit la catégorie, car ils + * n'appartiennent à aucune d'elles. + */ +class ProfileEditFragment : Fragment() { + + companion object { + /** + * Passage de données depuis [ProfileFragment]. Un profil complet ne tient pas + * confortablement dans un Bundle (enums + une vingtaine de champs) et l'écran n'est ouvert + * que depuis la liste, dans le même processus. + * + * `pendingData` = valeurs à afficher (profil existant, ou pré-remplissage lu sur la + * voiture) ; `pendingExisting` = profil édité, ou null en création. + */ + @Volatile var pendingData: DrivingProfile? = null + @Volatile var pendingExisting: DrivingProfile? = null + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.fragment_profile_edit, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Après une mort du processus, le fragment est recréé sans les données transmises : + // on retourne à la liste plutôt que d'afficher un formulaire vide. + val data = pendingData + if (data == null) { + findNavController().popBackStack() + return + } + val existing = pendingExisting + val manager = ProfileManager(requireContext()) + val ctx = requireContext() + val gen = FirmwareInfo.getGeneration() + + // ── Couleurs ───────────────────────────────────────────────────────── + fun activateBtn(btn: MaterialButton, active: Boolean) { + btn.backgroundTintList = ColorStateList.valueOf( + ctx.getColor(if (active) R.color.dash_accent_dim else R.color.dash_btn)) + btn.setTextColor(ctx.getColor( + if (active) R.color.dash_accent else R.color.text_secondary)) + btn.strokeColor = ColorStateList.valueOf( + ctx.getColor(if (active) R.color.dash_accent else R.color.dash_border)) + } + + /** Lie un groupe de boutons : un seul actif à la fois. */ + fun bindGroup(pairs: List>, initial: T, onSelect: (T) -> Unit) { + pairs.forEach { (btn, value) -> activateBtn(btn, value == initial) } + pairs.forEach { (btn, value) -> + btn.setOnClickListener { + pairs.forEach { (b, v) -> activateBtn(b, v == value) } + onSelect(value) + } + } + } + + /** Grise un groupe de boutons sans les masquer (l'utilisateur voit ce qui est configuré). */ + fun setBtnsEnabled(btns: List, enabled: Boolean) { + btns.forEach { btn -> + btn?.isEnabled = enabled + btn?.alpha = if (enabled) 1f else 0.35f + } + } + + // ── Variables de sélection ─────────────────────────────────────────── + var selectedBtMac: String? = data.btDeviceMac // [BT-PROFILES] + var selectedDrive = data.driveMode + var selectedRegen = data.regenLevel + var steeringOn = data.steeringHeat + var steeringEnabled = data.appliesSteeringHeat + var seatHeatEnabled = data.appliesSeatHeat + var seatLeft = data.seatHeatLeft + var seatRight = data.seatHeatRight + var adasMode = data.adasMode + var swi68Mode = data.swi68AdasMode + var swi132SasMode = data.swi132SasMode // 0=Off, 2=Manuel, 3=Intelligent + var swi132LimiterConfigured = data.swi132LimiterConfigured + var aebEnabledSel = data.aebEnabled + var aebModeSel = data.aebMode + var aebSenSel = data.aebSensitivity.let { if (it == 0) AebSensitivity.STANDARD else it } + var elkModeSel = data.elkMode.let { if (it == 0) ElkMode.EMERGENCY else it } + var elkSenSel = data.elkSensitivity.let { if (it == 0) ElkSensitivity.STANDARD else it } + var elkEnabledSel = elkModeSel != ElkMode.OFF + /** Dernier mode ELK actif pour restauration après toggle ON */ + var lastActiveElkModeD = if (elkModeSel != ElkMode.OFF) elkModeSel else ElkMode.EMERGENCY + var lasAudibleWarningSel = data.lasAudibleWarning + var lasVibrationReminderSel = data.lasVibrationReminder + var energySavingSel = data.energySaving + var tsrEnabledSel = data.tsrEnabled + + // ── Bouton Éco énergie — déclaré tôt pour le binding du mode de conduite ─ + val btnEnergy = view.findViewById(R.id.btn_energy_saving_d) + + // ── Mode de conduite ───────────────────────────────────────────────── + val drivePairs = listOf( + view.findViewById(R.id.btn_drive_eco_d) to DriveMode.ECO, + view.findViewById(R.id.btn_drive_normal_d) to DriveMode.NORMAL, + view.findViewById(R.id.btn_drive_sport_d) to DriveMode.SPORT, + view.findViewById(R.id.btn_drive_snow_d) to DriveMode.SNOW, + view.findViewById(R.id.btn_drive_custom_d) to DriveMode.CUSTOM + ) + val regenBtns = listOf( + view.findViewById(R.id.btn_regen_off_d), + view.findViewById(R.id.btn_regen_low_d), + view.findViewById(R.id.btn_regen_medium_d), + view.findViewById(R.id.btn_regen_high_d), + view.findViewById(R.id.btn_regen_adaptive_d), + view.findViewById(R.id.btn_regen_one_pedal_d) + ) + val btnOnePedal = view.findViewById(R.id.btn_regen_one_pedal_d) + + fun setRegenEnabled(enabled: Boolean) { + val isSnow = selectedDrive == DriveMode.SNOW + regenBtns.forEach { btn -> + // ONE_PEDAL reste accessible même quand Éco énergie est actif, + // sauf en mode SNOW où tous les niveaux de regen sont indisponibles. + val btnEnabled = enabled || (btn == btnOnePedal && !isSnow) + btn.isEnabled = btnEnabled + btn.alpha = if (btnEnabled) 1f else 0.35f + } + } + + bindGroup(drivePairs, selectedDrive) { mode -> + selectedDrive = mode + val isSnow = mode == DriveMode.SNOW + setRegenEnabled(!isSnow && !energySavingSel) + if (gen != FirmwareInfo.Gen.UNKNOWN) { + btnEnergy.isEnabled = !isSnow + btnEnergy.alpha = if (isSnow) 0.35f else 1f + } + } + setRegenEnabled(data.driveMode != DriveMode.SNOW && !energySavingSel) + + // ── Régénération ───────────────────────────────────────────────────── + val regenPairs = listOf( + view.findViewById(R.id.btn_regen_off_d) to RegenLevel.OFF, + view.findViewById(R.id.btn_regen_low_d) to RegenLevel.LOW, + view.findViewById(R.id.btn_regen_medium_d) to RegenLevel.MEDIUM, + view.findViewById(R.id.btn_regen_high_d) to RegenLevel.HIGH, + view.findViewById(R.id.btn_regen_adaptive_d) to RegenLevel.ADAPTIVE, + view.findViewById(R.id.btn_regen_one_pedal_d) to RegenLevel.ONE_PEDAL + ) + bindGroup(regenPairs, selectedRegen) { selectedRegen = it } + + // ── Volant chauffant + prise en compte ─────────────────────────────── + val steerBtns = listOf( + view.findViewById(R.id.btn_steer_off_d), + view.findViewById(R.id.btn_steer_on_d) + ) + bindGroup(listOf(steerBtns[0] to false, steerBtns[1] to true), steeringOn) { steeringOn = it } + + val swSteering = view.findViewById(R.id.sw_steering_enabled) + swSteering.isChecked = steeringEnabled + setBtnsEnabled(steerBtns, steeringEnabled) + swSteering.setOnCheckedChangeListener { _, checked -> + steeringEnabled = checked + setBtnsEnabled(steerBtns, checked) + } + + // ── Sièges chauffants + prise en compte ────────────────────────────── + val seatLeftBtns = listOf( + view.findViewById(R.id.btn_sl_0_d), + view.findViewById(R.id.btn_sl_1_d), + view.findViewById(R.id.btn_sl_2_d), + view.findViewById(R.id.btn_sl_3_d) + ) + val seatRightBtns = listOf( + view.findViewById(R.id.btn_sr_0_d), + view.findViewById(R.id.btn_sr_1_d), + view.findViewById(R.id.btn_sr_2_d), + view.findViewById(R.id.btn_sr_3_d) + ) + bindGroup(seatLeftBtns.mapIndexed { i, b -> b to i }, seatLeft) { seatLeft = it } + bindGroup(seatRightBtns.mapIndexed { i, b -> b to i }, seatRight) { seatRight = it } + + val swSeatHeat = view.findViewById(R.id.sw_seat_heat_enabled) + swSeatHeat.isChecked = seatHeatEnabled + setBtnsEnabled(seatLeftBtns + seatRightBtns, seatHeatEnabled) + swSeatHeat.setOnCheckedChangeListener { _, checked -> + seatHeatEnabled = checked + setBtnsEnabled(seatLeftBtns + seatRightBtns, checked) + } + + // ── Sections Climat — masquées si pas de chauffage (SWI69/SWI131) ──── + val hasHeat = FirmwareInfo.hasHeatFeatures() + val heatVis = if (hasHeat) View.VISIBLE else View.GONE + view.findViewById(R.id.section_steering_dialog)?.visibility = heatVis + // section_seats_dialog est desormais A L'INTERIEUR de section_seats_header : masquer + // l'entete suffit. On garde les deux pour rester robuste a un futur deplacement. + view.findViewById(R.id.section_seats_header)?.visibility = heatVis + view.findViewById(R.id.section_seats_dialog)?.visibility = heatVis + + // ── Section AEB (commune SWI133 + SWI68 + SWI69) ───────────────────── + val sectionAeb = view.findViewById(R.id.adas_section_aeb) + if (gen != FirmwareInfo.Gen.UNKNOWN) { + sectionAeb.visibility = View.VISIBLE + val swAeb = view.findViewById(R.id.sw_aeb_enabled) + val btnAebAlarmD = view.findViewById(R.id.btn_aeb_alarm_d) + val btnAebBrakeD = view.findViewById(R.id.btn_aeb_alarm_brake_d) + + val aebSenSectionD = view.findViewById(R.id.aeb_sen_section_d) + val btnAebSenLowD = view.findViewById(R.id.btn_aeb_sen_low_d) + val btnAebSenStdD = view.findViewById(R.id.btn_aeb_sen_standard_d) + val btnAebSenHighD = view.findViewById(R.id.btn_aeb_sen_high_d) + + aebSenSectionD.visibility = View.VISIBLE + val aebBtns = listOf(btnAebAlarmD, btnAebBrakeD, btnAebSenLowD, btnAebSenStdD, btnAebSenHighD) + + swAeb.isChecked = aebEnabledSel + setBtnsEnabled(aebBtns, aebEnabledSel) + swAeb.setOnCheckedChangeListener { _, checked -> + aebEnabledSel = checked + setBtnsEnabled(aebBtns, checked) + } + + bindGroup(listOf(btnAebAlarmD to AebMode.ALARM, btnAebBrakeD to AebMode.ALARM_BRAKE), aebModeSel) { + aebModeSel = it + } + bindGroup(listOf( + btnAebSenLowD to AebSensitivity.LOW, + btnAebSenStdD to AebSensitivity.STANDARD, + btnAebSenHighD to AebSensitivity.HIGH + ), aebSenSel) { aebSenSel = it } + } + + // ── Section ELK (tous firmwares connus) ────────────────────────────── + val sectionElk = view.findViewById(R.id.elk_section_dialog) + if (gen != FirmwareInfo.Gen.UNKNOWN) { + sectionElk.visibility = View.VISIBLE + val isSWI132elk = gen == FirmwareInfo.Gen.SWI132 + + val swElk = view.findViewById(R.id.sw_elk_enabled) + val btnElkAlertD = view.findViewById(R.id.btn_elk_alert_d) + val btnElkAssistD = view.findViewById(R.id.btn_elk_assist_d) + val btnElkEmergD = view.findViewById(R.id.btn_elk_emergency_d) + val btnElkSenLowD = view.findViewById(R.id.btn_elk_sen_low_d) + val btnElkSenStdD = view.findViewById(R.id.btn_elk_sen_standard_d) + val btnElkSenHighD = view.findViewById(R.id.btn_elk_sen_high_d) + + // SWI132 : pas de mode Emergency + 2 switches supplémentaires + if (isSWI132elk) { + btnElkEmergD?.visibility = View.GONE + view.findViewById(R.id.elk_sound_row_d)?.visibility = View.VISIBLE + view.findViewById(R.id.elk_vibration_row_d)?.visibility = View.VISIBLE + if (elkModeSel == ElkMode.EMERGENCY) { + elkModeSel = ElkMode.ALERT + lastActiveElkModeD = ElkMode.ALERT + elkEnabledSel = true + } + } + + val elkModeBtns = if (isSWI132elk) listOf(btnElkAlertD, btnElkAssistD) + else listOf(btnElkAlertD, btnElkAssistD, btnElkEmergD) + val elkSenBtns = listOf(btnElkSenLowD, btnElkSenStdD, btnElkSenHighD) + + val swElkSound = view.findViewById(R.id.sw_elk_sound_d) + val swElkVibration = view.findViewById(R.id.sw_elk_vibration_d) + + fun setElkEnabled(enabled: Boolean) { + setBtnsEnabled(elkModeBtns + elkSenBtns, enabled) + if (isSWI132elk) { + swElkSound?.isEnabled = enabled + swElkSound?.alpha = if (enabled) 1f else 0.35f + swElkVibration?.isEnabled = enabled + swElkVibration?.alpha = if (enabled) 1f else 0.35f + } + } + + swElk.isChecked = elkEnabledSel + setElkEnabled(elkEnabledSel) + + if (isSWI132elk) { + swElkSound?.isChecked = lasAudibleWarningSel + swElkVibration?.isChecked = lasVibrationReminderSel + swElkSound?.setOnCheckedChangeListener { _, checked -> lasAudibleWarningSel = checked } + swElkVibration?.setOnCheckedChangeListener { _, checked -> lasVibrationReminderSel = checked } + } + + swElk.setOnCheckedChangeListener { _, checked -> + elkEnabledSel = checked + elkModeSel = if (checked) lastActiveElkModeD else ElkMode.OFF + setElkEnabled(checked) + } + + val initialElkMode = if (isSWI132elk && elkModeSel == ElkMode.EMERGENCY) ElkMode.ALERT + else if (elkEnabledSel) elkModeSel else ElkMode.ALERT + val elkModePairs = if (isSWI132elk) + listOf(btnElkAlertD to ElkMode.ALERT, btnElkAssistD to ElkMode.ASSIST) + else + listOf(btnElkAlertD to ElkMode.ALERT, btnElkAssistD to ElkMode.ASSIST, btnElkEmergD to ElkMode.EMERGENCY) + bindGroup(elkModePairs, initialElkMode) { mode -> + elkModeSel = mode + lastActiveElkModeD = mode + } + + bindGroup(listOf( + btnElkSenLowD to ElkSensitivity.LOW, + btnElkSenStdD to ElkSensitivity.STANDARD, + btnElkSenHighD to ElkSensitivity.HIGH + ), elkSenSel) { elkSenSel = it } + } + + // ── Sections ADAS ──────────────────────────────────────────────────── + val sectionSwi133 = view.findViewById(R.id.adas_section_swi133) + val sectionSwi68 = view.findViewById(R.id.adas_section_swi68) + val isSWI132Profile = gen == FirmwareInfo.Gen.SWI132 + + /** Sélecteur ADAS unique : mode ACC/TJA et limiteur sont deux réglages indépendants + * côté voiture, l'exclusivité est imposée ici pour rester compréhensible. */ + fun applyAdasIndex(idx: Int) { + swi132LimiterConfigured = true + when (idx) { + 1 -> { swi68Mode = Swi68Mode.OFF; swi132SasMode = MG4Hardware.SasMode.MANUEL } + 2 -> { swi68Mode = Swi68Mode.OFF; swi132SasMode = MG4Hardware.SasMode.INTELLIGENT } + 3 -> { swi68Mode = Swi68Mode.ACC; swi132SasMode = MG4Hardware.SasMode.OFF } + 4 -> { swi68Mode = Swi68Mode.TJA; swi132SasMode = MG4Hardware.SasMode.OFF } + else -> { swi68Mode = Swi68Mode.OFF; swi132SasMode = MG4Hardware.SasMode.OFF } + } + } + val initialAdasIdx = when { + data.swi132SasMode == MG4Hardware.SasMode.MANUEL -> 1 + data.swi132SasMode == MG4Hardware.SasMode.INTELLIGENT -> 2 + data.swi68AdasMode == Swi68Mode.ACC -> 3 + data.swi68AdasMode == Swi68Mode.TJA -> 4 + else -> 0 + } + + when { + isSWI132Profile -> { + sectionSwi133.visibility = View.VISIBLE + sectionSwi68.visibility = View.GONE + view.findViewById(R.id.btn_adas_auto_d)?.visibility = View.VISIBLE + view.findViewById(R.id.sw_overspeed_alarm).isChecked = data.overspeedAlarm + view.findViewById(R.id.sw_speed_limit_tone).isChecked = data.speedLimitTone + bindGroup(listOf( + view.findViewById(R.id.btn_adas_off_d) to 0, + view.findViewById(R.id.btn_adas_lim_d) to 1, + view.findViewById(R.id.btn_adas_auto_d) to 2, + view.findViewById(R.id.btn_adas_acc_d) to 3, + view.findViewById(R.id.btn_adas_ica_d) to 4 + ), initialAdasIdx) { applyAdasIndex(it) } + } + FirmwareInfo.isVsmBased() -> { + sectionSwi68.visibility = View.VISIBLE + sectionSwi133.visibility = View.GONE + view.findViewById(R.id.sw_sound_warning).isChecked = data.soundWarning + bindGroup(listOf( + view.findViewById(R.id.btn_adas_swi68_off_d) to 0, + view.findViewById(R.id.btn_adas_swi68_lim_d) to 1, + view.findViewById(R.id.btn_adas_swi68_auto_d) to 2, + view.findViewById(R.id.btn_adas_swi68_acc_d) to 3, + view.findViewById(R.id.btn_adas_swi68_tja_d) to 4 + ), initialAdasIdx) { applyAdasIndex(it) } + } + else -> { + sectionSwi133.visibility = View.VISIBLE + sectionSwi68.visibility = View.GONE + view.findViewById(R.id.sw_overspeed_alarm).isChecked = data.overspeedAlarm + view.findViewById(R.id.sw_speed_limit_tone).isChecked = data.speedLimitTone + bindGroup(listOf( + view.findViewById(R.id.btn_adas_off_d) to 0, + view.findViewById(R.id.btn_adas_lim_d) to 1, + view.findViewById(R.id.btn_adas_auto_d) to 2, + view.findViewById(R.id.btn_adas_acc_d) to 3, + view.findViewById(R.id.btn_adas_ica_d) to 4 + ), adasMode) { adasMode = it } + } + } + + // ── Économie d'énergie + TSR (tous firmwares connus) ───────────────── + val sectionTsr = view.findViewById(R.id.section_tsr_dialog) + if (gen != FirmwareInfo.Gen.UNKNOWN) { + btnEnergy.visibility = View.VISIBLE + val initSnow = data.driveMode == DriveMode.SNOW + btnEnergy.isEnabled = !initSnow + btnEnergy.alpha = if (initSnow) 0.35f else 1f + activateBtn(btnEnergy, energySavingSel) + btnEnergy.setOnClickListener { + energySavingSel = !energySavingSel + activateBtn(btnEnergy, energySavingSel) + setRegenEnabled(!energySavingSel && selectedDrive != DriveMode.SNOW) + } + + sectionTsr.visibility = View.VISIBLE + val swTsr = view.findViewById(R.id.sw_tsr_d) + swTsr.isChecked = tsrEnabledSel + swTsr.setOnCheckedChangeListener { _, checked -> tsrEnabledSel = checked } + } + + // ── [BT-PROFILES] Spinner appareil Bluetooth ───────────────────────── + val spinnerBt = view.findViewById(R.id.spinner_bt_device) + data class BtEntry(val label: String, val mac: String?) + val noneLabel = getString(R.string.profile_bt_none) + + CoroutineScope(Dispatchers.IO).launch { + val bonded = BluetoothProfileManager.getBondedDevices(requireContext()) + val entries = mutableListOf(BtEntry(noneLabel, null)) + entries.addAll(bonded.map { BtEntry("${it.name} (${it.mac})", it.mac) }) + + withContext(Dispatchers.Main) { + if (!isAdded) return@withContext + val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, entries.map { it.label }) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + spinnerBt.adapter = adapter + + val selIdx = entries.indexOfFirst { it.mac.equals(data.btDeviceMac, ignoreCase = true) } + .takeIf { it >= 0 } ?: 0 + spinnerBt.setSelection(selIdx) + + spinnerBt.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, v: View?, pos: Int, id: Long) { + selectedBtMac = entries.getOrNull(pos)?.mac + } + override fun onNothingSelected(parent: AdapterView<*>?) { selectedBtMac = null } + } + selectedBtMac = entries.getOrNull(selIdx)?.mac + } + } + + // ── Bandeau persistant : titre, nom, profil par défaut ─────────────── + view.findViewById(R.id.tv_dialog_title).text = + if (existing != null) getString(R.string.profile_edit) else getString(R.string.profile_add) + + val swDefault = view.findViewById(R.id.sw_set_default) + swDefault.isChecked = existing?.id == manager.getDefaultId() + + val etName = view.findViewById(R.id.et_profile_name) + if (existing != null) etName.setText(existing.name) + + // ── Rail de catégories ─────────────────────────────────────────────── + bindCategoryRail(view) { btn, on -> activateBtn(btn, on) } + + // ── Annuler ────────────────────────────────────────────────────────── + view.findViewById(R.id.btn_dialog_cancel).setOnClickListener { + findNavController().popBackStack() + } + + // ── Enregistrer : ne quitte PAS si le nom est vide ─────────────────── + view.findViewById(R.id.btn_dialog_save).setOnClickListener { + val name = etName.text.toString().trim() + if (name.isEmpty()) { + etName.error = getString(R.string.profile_name_required) + etName.requestFocus() + return@setOnClickListener + } + + val overspeedAlarm = view.findViewById(R.id.sw_overspeed_alarm)?.isChecked ?: false + val speedLimitTone = view.findViewById(R.id.sw_speed_limit_tone)?.isChecked ?: false + val soundWarning = view.findViewById(R.id.sw_sound_warning)?.isChecked ?: false + + val profile = DrivingProfile( + id = existing?.id ?: java.util.UUID.randomUUID().toString(), + name = name, + driveMode = selectedDrive, + regenLevel = selectedRegen, + steeringHeat = steeringOn, + seatHeatLeft = seatLeft, + seatHeatRight = seatRight, + steeringHeatEnabled = steeringEnabled, + seatHeatEnabled = seatHeatEnabled, + overspeedAlarm = overspeedAlarm, + speedLimitTone = speedLimitTone, + adasMode = adasMode, + soundWarning = soundWarning, + swi68AdasMode = swi68Mode, + swi132LimiterConfigured = swi132LimiterConfigured, + swi132SasMode = swi132SasMode, + aebEnabled = aebEnabledSel, + aebMode = aebModeSel, + aebSensitivity = aebSenSel, + elkMode = elkModeSel, + elkSensitivity = elkSenSel, + lasAudibleWarning = lasAudibleWarningSel, + lasVibrationReminder = lasVibrationReminderSel, + energySaving = energySavingSel, + tsrEnabled = tsrEnabledSel, + btDeviceMac = selectedBtMac // [BT-PROFILES] + ) + manager.save(profile) + if (swDefault.isChecked) manager.setDefault(profile.id) + // La liste se rafraîchit dans ProfileFragment.onResume + findNavController().popBackStack() + } + } + + override fun onDestroyView() { + super.onDestroyView() + pendingData = null + pendingExisting = null + } + + /** + * Rail de gauche : une catégorie visible à la fois. + * + * Un onglet dont la page n'a plus aucune section visible sur ce firmware est masqué — mieux + * vaut pas d'onglet qu'un onglet qui ouvre une page blanche. Appelé APRÈS les décisions de + * visibilité, sinon le décompte serait faux. + */ + private fun bindCategoryRail(view: View, activate: (MaterialButton, Boolean) -> Unit) { + val tabs = listOf( + view.findViewById(R.id.btn_cat_drive) to view.findViewById(R.id.page_cat_drive), + view.findViewById(R.id.btn_cat_safety) to view.findViewById(R.id.page_cat_safety), + view.findViewById(R.id.btn_cat_comfort) to view.findViewById(R.id.page_cat_comfort) + ) + val scroll = view.findViewById(R.id.scroll_profile_edit) + + fun hasVisibleContent(page: ViewGroup): Boolean = + (0 until page.childCount).any { page.getChildAt(it).visibility == View.VISIBLE } + + val usable = tabs.filter { (_, page) -> hasVisibleContent(page) } + tabs.forEach { (btn, page) -> + val ok = usable.any { it.second === page } + btn.visibility = if (ok) View.VISIBLE else View.GONE + } + if (usable.isEmpty()) return + + fun select(target: ViewGroup) { + tabs.forEach { (btn, page) -> + val on = page === target + page.visibility = if (on) View.VISIBLE else View.GONE + activate(btn, on) + } + scroll?.scrollTo(0, 0) // changer d'onglet en gardant le scroll précédent désoriente + } + usable.forEach { (btn, page) -> btn.setOnClickListener { select(page) } } + select(usable.first().second) + } +} diff --git a/app/src/main/java/com/mg4/control/ui/ProfileFragment.kt b/app/src/main/java/com/mg4/control/ui/ProfileFragment.kt index 3df827a7..c9ab5a05 100644 --- a/app/src/main/java/com/mg4/control/ui/ProfileFragment.kt +++ b/app/src/main/java/com/mg4/control/ui/ProfileFragment.kt @@ -1,9 +1,6 @@ package com.mg4.control.ui import android.app.AlertDialog -import android.content.res.ColorStateList -import android.graphics.Color -import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -15,7 +12,6 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.button.MaterialButton import com.mg4.control.R -import com.mg4.control.bluetooth.BluetoothProfileManager import com.mg4.control.hardware.MG4Hardware import com.mg4.control.hardware.MG4Hardware.AebMode import com.mg4.control.hardware.MG4Hardware.AebSensitivity @@ -64,7 +60,7 @@ class ProfileFragment : Fragment() { Toast.makeText(context, "Profil par défaut : ${profile.name}", Toast.LENGTH_SHORT).show() }, onEdit = { profile -> - showProfileDialog(existing = profile, data = profile) + openEditor(existing = profile, data = profile) }, onDelete = { profile -> AlertDialog.Builder(requireContext()) @@ -92,7 +88,7 @@ class ProfileFragment : Fragment() { if (manager.getAll().size >= ProfileManager.MAX_PROFILES) { Toast.makeText(context, getString(R.string.profile_max_reached, ProfileManager.MAX_PROFILES), Toast.LENGTH_SHORT).show() } else { - openNewProfileDialog() + openNewProfileEditor() } } @@ -109,10 +105,10 @@ class ProfileFragment : Fragment() { } // ------------------------------------------------------------------------- - // Nouveau profil : lit l'état hardware courant puis ouvre le dialog pré-rempli + // Nouveau profil : lit l'état hardware courant puis ouvre l'éditeur pré-rempli // ------------------------------------------------------------------------- - private fun openNewProfileDialog() { + private fun openNewProfileEditor() { CoroutineScope(Dispatchers.IO).launch { val hasHeat = FirmwareInfo.hasHeatFeatures() val isSWI132 = FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132 @@ -175,518 +171,22 @@ class ProfileFragment : Fragment() { ) } withContext(Dispatchers.Main) { - if (isAdded) showProfileDialog(existing = null, data = prefill) + if (isAdded) openEditor(existing = null, data = prefill) } } } // ------------------------------------------------------------------------- - // Dialog d'édition / création — style dark MaterialButton + // Ouverture de l'editeur plein ecran // ------------------------------------------------------------------------- - private fun showProfileDialog(existing: DrivingProfile?, data: DrivingProfile) { - val ctx = requireContext() - val dialogView = LayoutInflater.from(ctx).inflate(R.layout.dialog_profile_edit, null) - val gen = FirmwareInfo.getGeneration() - - // ── Couleurs ───────────────────────────────────────────────────────── - fun activateBtn(btn: MaterialButton, active: Boolean) { - btn.backgroundTintList = ColorStateList.valueOf( - ctx.getColor(if (active) R.color.dash_accent_dim else R.color.dash_btn)) - btn.setTextColor(ctx.getColor( - if (active) R.color.dash_accent else R.color.text_secondary)) - btn.strokeColor = ColorStateList.valueOf( - ctx.getColor(if (active) R.color.dash_accent else R.color.dash_border)) - } - - /** Lie un groupe de boutons : un seul actif à la fois. Retourne une lambda pour lire la valeur courante. */ - fun bindGroup(pairs: List>, initial: T, onSelect: (T) -> Unit) { - pairs.forEach { (btn, value) -> activateBtn(btn, value == initial) } - pairs.forEach { (btn, value) -> - btn.setOnClickListener { - pairs.forEach { (b, v) -> activateBtn(b, v == value) } - onSelect(value) - } - } - } - - // ── Variables de sélection ─────────────────────────────────────────── - var selectedBtMac: String? = data.btDeviceMac // [BT-PROFILES] - var selectedDrive = data.driveMode - var selectedRegen = data.regenLevel - var steeringOn = data.steeringHeat - var seatLeft = data.seatHeatLeft - var seatRight = data.seatHeatRight - var adasMode = data.adasMode - var swi68Mode = data.swi68AdasMode - var swi132SasMode = data.swi132SasMode // 0=Off, 2=Manuel, 3=Intelligent - var swi132LimiterConfigured = data.swi132LimiterConfigured - var aebEnabledSel = data.aebEnabled - var aebModeSel = data.aebMode - var aebSenSel = data.aebSensitivity.let { if (it == 0) AebSensitivity.STANDARD else it } - var elkModeSel = data.elkMode.let { if (it == 0) ElkMode.EMERGENCY else it } - var elkSenSel = data.elkSensitivity.let { if (it == 0) ElkSensitivity.STANDARD else it } - var elkEnabledSel = elkModeSel != ElkMode.OFF - /** Dernier mode ELK actif pour restauration après toggle ON */ - var lastActiveElkModeD = if (elkModeSel != ElkMode.OFF) elkModeSel else ElkMode.EMERGENCY - var lasAudibleWarningSel = data.lasAudibleWarning - var lasVibrationReminderSel = data.lasVibrationReminder - var energySavingSel = data.energySaving - var tsrEnabledSel = data.tsrEnabled - - // ── Bouton Éco énergie — déclaré tôt pour être accessible dans le binding drive mode ─ - val btnEnergy = dialogView.findViewById(R.id.btn_energy_saving_d) - - // ── Mode de conduite ───────────────────────────────────────────────── - val drivePairs = listOf( - dialogView.findViewById(R.id.btn_drive_eco_d) to DriveMode.ECO, - dialogView.findViewById(R.id.btn_drive_normal_d) to DriveMode.NORMAL, - dialogView.findViewById(R.id.btn_drive_sport_d) to DriveMode.SPORT, - dialogView.findViewById(R.id.btn_drive_snow_d) to DriveMode.SNOW, - dialogView.findViewById(R.id.btn_drive_custom_d) to DriveMode.CUSTOM - ) - val regenSection = dialogView.findViewById(R.id.section_regen_dialog) - val regenBtns = listOf( - dialogView.findViewById(R.id.btn_regen_off_d), - dialogView.findViewById(R.id.btn_regen_low_d), - dialogView.findViewById(R.id.btn_regen_medium_d), - dialogView.findViewById(R.id.btn_regen_high_d), - dialogView.findViewById(R.id.btn_regen_adaptive_d), - dialogView.findViewById(R.id.btn_regen_one_pedal_d) - ) - - val btnOnePedal = dialogView.findViewById(R.id.btn_regen_one_pedal_d) - - fun setRegenEnabled(enabled: Boolean) { - val isSnow = selectedDrive == DriveMode.SNOW - regenBtns.forEach { btn -> - // ONE_PEDAL reste accessible même quand Éco énergie est actif, - // sauf en mode SNOW où tous les niveaux de regen sont indisponibles. - val btnEnabled = enabled || (btn == btnOnePedal && !isSnow) - btn.isEnabled = btnEnabled - btn.alpha = if (btnEnabled) 1f else 0.35f - } - } - - bindGroup(drivePairs, selectedDrive) { mode -> - selectedDrive = mode - val isSnow = mode == DriveMode.SNOW - // Regen : indisponible si SNOW ou Éco énergie actif (ONE_PEDAL exempt de l'Éco) - setRegenEnabled(!isSnow && !energySavingSel) - // Bouton Éco énergie : indisponible en mode SNOW (modes exclusifs) - if (gen != FirmwareInfo.Gen.UNKNOWN) { - btnEnergy.isEnabled = !isSnow - btnEnergy.alpha = if (isSnow) 0.35f else 1f - } - } - // État initial : regen indisponible si SNOW ou Éco énergie déjà actif - setRegenEnabled(data.driveMode != DriveMode.SNOW && !energySavingSel) - - // ── Régénération ───────────────────────────────────────────────────── - val regenPairs = listOf( - dialogView.findViewById(R.id.btn_regen_off_d) to RegenLevel.OFF, - dialogView.findViewById(R.id.btn_regen_low_d) to RegenLevel.LOW, - dialogView.findViewById(R.id.btn_regen_medium_d) to RegenLevel.MEDIUM, - dialogView.findViewById(R.id.btn_regen_high_d) to RegenLevel.HIGH, - dialogView.findViewById(R.id.btn_regen_adaptive_d) to RegenLevel.ADAPTIVE, - dialogView.findViewById(R.id.btn_regen_one_pedal_d) to RegenLevel.ONE_PEDAL - ) - bindGroup(regenPairs, selectedRegen) { selectedRegen = it } - - // ── Volant chauffant ───────────────────────────────────────────────── - val steerPairs = listOf( - dialogView.findViewById(R.id.btn_steer_off_d) to false, - dialogView.findViewById(R.id.btn_steer_on_d) to true - ) - bindGroup(steerPairs, steeringOn) { steeringOn = it } - - // ── Siège gauche ───────────────────────────────────────────────────── - val seatLeftPairs = listOf( - dialogView.findViewById(R.id.btn_sl_0_d) to 0, - dialogView.findViewById(R.id.btn_sl_1_d) to 1, - dialogView.findViewById(R.id.btn_sl_2_d) to 2, - dialogView.findViewById(R.id.btn_sl_3_d) to 3 - ) - bindGroup(seatLeftPairs, seatLeft) { seatLeft = it } - - // ── Siège droit ────────────────────────────────────────────────────── - val seatRightPairs = listOf( - dialogView.findViewById(R.id.btn_sr_0_d) to 0, - dialogView.findViewById(R.id.btn_sr_1_d) to 1, - dialogView.findViewById(R.id.btn_sr_2_d) to 2, - dialogView.findViewById(R.id.btn_sr_3_d) to 3 - ) - bindGroup(seatRightPairs, seatRight) { seatRight = it } - - // ── Sections Climat (Volant + Sièges) — masquées si pas de chauffage (SWI69/SWI131) ─ - val hasHeat = FirmwareInfo.hasHeatFeatures() - dialogView.findViewById(R.id.section_steering_dialog)?.visibility = - if (hasHeat) View.VISIBLE else View.GONE - dialogView.findViewById(R.id.section_seats_dialog)?.visibility = - if (hasHeat) View.VISIBLE else View.GONE - - // ── Section AEB (commune SWI133 + SWI68 + SWI69) ──────────────────── - val sectionAeb = dialogView.findViewById(R.id.adas_section_aeb) - if (gen != FirmwareInfo.Gen.UNKNOWN) { - sectionAeb.visibility = View.VISIBLE - val swAeb = dialogView.findViewById(R.id.sw_aeb_enabled) - val btnAebAlarmD = dialogView.findViewById(R.id.btn_aeb_alarm_d) - val btnAebBrakeD = dialogView.findViewById(R.id.btn_aeb_alarm_brake_d) - - // Sensibilité AEB — SWI133 uniquement - val aebSenSectionD = dialogView.findViewById(R.id.aeb_sen_section_d) - val btnAebSenLowD = dialogView.findViewById(R.id.btn_aeb_sen_low_d) - val btnAebSenStdD = dialogView.findViewById(R.id.btn_aeb_sen_standard_d) - val btnAebSenHighD = dialogView.findViewById(R.id.btn_aeb_sen_high_d) - - val showSensitivity = gen != FirmwareInfo.Gen.UNKNOWN - aebSenSectionD.visibility = if (showSensitivity) View.VISIBLE else View.GONE - - fun setAebModeButtonsEnabled(enabled: Boolean) { - listOf(btnAebAlarmD, btnAebBrakeD).forEach { btn -> - btn.isEnabled = enabled - btn.alpha = if (enabled) 1f else 0.35f - } - if (showSensitivity) { - listOf(btnAebSenLowD, btnAebSenStdD, btnAebSenHighD).forEach { btn -> - btn.isEnabled = enabled - btn.alpha = if (enabled) 1f else 0.35f - } - } - } - - swAeb.isChecked = aebEnabledSel - setAebModeButtonsEnabled(aebEnabledSel) - swAeb.setOnCheckedChangeListener { _, checked -> - aebEnabledSel = checked - setAebModeButtonsEnabled(checked) - } - - val aebModePairs = listOf(btnAebAlarmD to AebMode.ALARM, btnAebBrakeD to AebMode.ALARM_BRAKE) - bindGroup(aebModePairs, aebModeSel) { aebModeSel = it } - - if (showSensitivity) { - val aebSenPairs = listOf( - btnAebSenLowD to AebSensitivity.LOW, - btnAebSenStdD to AebSensitivity.STANDARD, - btnAebSenHighD to AebSensitivity.HIGH - ) - bindGroup(aebSenPairs, aebSenSel) { aebSenSel = it } - } - } - - // ── Section ELK (tous firmwares connus) ───────────────────────────── - val sectionElk = dialogView.findViewById(R.id.elk_section_dialog) - if (gen != FirmwareInfo.Gen.UNKNOWN) { - sectionElk.visibility = View.VISIBLE - val isSWI132elk = gen == FirmwareInfo.Gen.SWI132 - - val swElk = dialogView.findViewById(R.id.sw_elk_enabled) - val btnElkAlertD = dialogView.findViewById(R.id.btn_elk_alert_d) - val btnElkAssistD = dialogView.findViewById(R.id.btn_elk_assist_d) - val btnElkEmergD = dialogView.findViewById(R.id.btn_elk_emergency_d) - val btnElkSenLowD = dialogView.findViewById(R.id.btn_elk_sen_low_d) - val btnElkSenStdD = dialogView.findViewById(R.id.btn_elk_sen_standard_d) - val btnElkSenHighD = dialogView.findViewById(R.id.btn_elk_sen_high_d) - - // SWI132 : pas de mode Emergency + 2 switches supplémentaires - if (isSWI132elk) { - btnElkEmergD?.visibility = View.GONE - dialogView.findViewById(R.id.elk_sound_row_d)?.visibility = View.VISIBLE - dialogView.findViewById(R.id.elk_vibration_row_d)?.visibility = View.VISIBLE - if (elkModeSel == ElkMode.EMERGENCY) { - elkModeSel = ElkMode.ALERT - lastActiveElkModeD = ElkMode.ALERT - elkEnabledSel = true - } - } - - val elkModeBtns = if (isSWI132elk) - listOf(btnElkAlertD, btnElkAssistD) - else - listOf(btnElkAlertD, btnElkAssistD, btnElkEmergD) - val elkSenBtns = listOf(btnElkSenLowD, btnElkSenStdD, btnElkSenHighD) - - val swElkSound = dialogView.findViewById(R.id.sw_elk_sound_d) - val swElkVibration= dialogView.findViewById(R.id.sw_elk_vibration_d) - - fun setElkButtonsEnabled(enabled: Boolean) { - (elkModeBtns + elkSenBtns).forEach { btn -> - btn?.isEnabled = enabled - btn?.alpha = if (enabled) 1f else 0.35f - } - if (isSWI132elk) { - swElkSound?.isEnabled = enabled - swElkSound?.alpha = if (enabled) 1f else 0.35f - swElkVibration?.isEnabled = enabled - swElkVibration?.alpha = if (enabled) 1f else 0.35f - } - } - - swElk.isChecked = elkEnabledSel - setElkButtonsEnabled(elkEnabledSel) - - if (isSWI132elk) { - swElkSound?.isChecked = lasAudibleWarningSel - swElkVibration?.isChecked= lasVibrationReminderSel - swElkSound?.setOnCheckedChangeListener { _, checked -> lasAudibleWarningSel = checked } - swElkVibration?.setOnCheckedChangeListener { _, checked -> lasVibrationReminderSel = checked } - } - - swElk.setOnCheckedChangeListener { _, checked -> - elkEnabledSel = checked - elkModeSel = if (checked) lastActiveElkModeD else ElkMode.OFF - setElkButtonsEnabled(checked) - } - - val initialElkMode = if (isSWI132elk && elkModeSel == ElkMode.EMERGENCY) ElkMode.ALERT - else if (elkEnabledSel) elkModeSel else ElkMode.ALERT - val elkModePairs = if (isSWI132elk) - listOf(btnElkAlertD to ElkMode.ALERT, btnElkAssistD to ElkMode.ASSIST) - else - listOf(btnElkAlertD to ElkMode.ALERT, btnElkAssistD to ElkMode.ASSIST, btnElkEmergD to ElkMode.EMERGENCY) - bindGroup(elkModePairs, initialElkMode) { mode -> - elkModeSel = mode - lastActiveElkModeD = mode - } - - val elkSenPairs = listOf( - btnElkSenLowD to ElkSensitivity.LOW, - btnElkSenStdD to ElkSensitivity.STANDARD, - btnElkSenHighD to ElkSensitivity.HIGH - ) - bindGroup(elkSenPairs, elkSenSel) { elkSenSel = it } - } - - // ── Sections ADAS ──────────────────────────────────────────────────── - val sectionSwi133 = dialogView.findViewById(R.id.adas_section_swi133) - val sectionSwi68 = dialogView.findViewById(R.id.adas_section_swi68) - val isSWI132Profile = FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132 - - when { - isSWI132Profile -> { - // SWI132 : 5 boutons ADAS Off/Lim.Manuel/Lim.Auto/ACC/ICA + alertes séparées. - // Le mode ACC/TJA (swi68Mode) et le limiteur de vitesse (swi132SasMode) sont - // deux réglages indépendants ; le sélecteur unique impose l'exclusivité. - sectionSwi133.visibility = View.VISIBLE - sectionSwi68.visibility = View.GONE - dialogView.findViewById(R.id.btn_adas_auto_d)?.visibility = View.VISIBLE - dialogView.findViewById(R.id.sw_overspeed_alarm).isChecked = data.overspeedAlarm - dialogView.findViewById(R.id.sw_speed_limit_tone).isChecked = data.speedLimitTone - val initialIdx = when { - data.swi132SasMode == MG4Hardware.SasMode.MANUEL -> 1 - data.swi132SasMode == MG4Hardware.SasMode.INTELLIGENT -> 2 - data.swi68AdasMode == Swi68Mode.ACC -> 3 - data.swi68AdasMode == Swi68Mode.TJA -> 4 - else -> 0 - } - val adasSwi132Pairs = listOf( - dialogView.findViewById(R.id.btn_adas_off_d) to 0, - dialogView.findViewById(R.id.btn_adas_lim_d) to 1, - dialogView.findViewById(R.id.btn_adas_auto_d) to 2, - dialogView.findViewById(R.id.btn_adas_acc_d) to 3, - dialogView.findViewById(R.id.btn_adas_ica_d) to 4 - ) - bindGroup(adasSwi132Pairs, initialIdx) { idx -> - swi132LimiterConfigured = true - when (idx) { - 1 -> { swi68Mode = Swi68Mode.OFF; swi132SasMode = MG4Hardware.SasMode.MANUEL } - 2 -> { swi68Mode = Swi68Mode.OFF; swi132SasMode = MG4Hardware.SasMode.INTELLIGENT } - 3 -> { swi68Mode = Swi68Mode.ACC; swi132SasMode = MG4Hardware.SasMode.OFF } - 4 -> { swi68Mode = Swi68Mode.TJA; swi132SasMode = MG4Hardware.SasMode.OFF } - else -> { swi68Mode = Swi68Mode.OFF; swi132SasMode = MG4Hardware.SasMode.OFF } - } - } - } - FirmwareInfo.isVsmBased() -> { - // SWI68/SWI69/SWI131/SWI165 : section SWI68 (5 boutons + alerte sonore). - // Off / Lim.Manuel / Lim.Auto / ACC / TJA — mode ACC/TJA + limiteur indépendants. - sectionSwi68.visibility = View.VISIBLE - sectionSwi133.visibility = View.GONE - dialogView.findViewById(R.id.sw_sound_warning).isChecked = data.soundWarning - val initialIdx = when { - data.swi132SasMode == MG4Hardware.SasMode.MANUEL -> 1 - data.swi132SasMode == MG4Hardware.SasMode.INTELLIGENT -> 2 - data.swi68AdasMode == Swi68Mode.ACC -> 3 - data.swi68AdasMode == Swi68Mode.TJA -> 4 - else -> 0 - } - val adasSwi68Pairs = listOf( - dialogView.findViewById(R.id.btn_adas_swi68_off_d) to 0, - dialogView.findViewById(R.id.btn_adas_swi68_lim_d) to 1, - dialogView.findViewById(R.id.btn_adas_swi68_auto_d) to 2, - dialogView.findViewById(R.id.btn_adas_swi68_acc_d) to 3, - dialogView.findViewById(R.id.btn_adas_swi68_tja_d) to 4 - ) - bindGroup(adasSwi68Pairs, initialIdx) { idx -> - swi132LimiterConfigured = true - when (idx) { - 1 -> { swi68Mode = Swi68Mode.OFF; swi132SasMode = MG4Hardware.SasMode.MANUEL } - 2 -> { swi68Mode = Swi68Mode.OFF; swi132SasMode = MG4Hardware.SasMode.INTELLIGENT } - 3 -> { swi68Mode = Swi68Mode.ACC; swi132SasMode = MG4Hardware.SasMode.OFF } - 4 -> { swi68Mode = Swi68Mode.TJA; swi132SasMode = MG4Hardware.SasMode.OFF } - else -> { swi68Mode = Swi68Mode.OFF; swi132SasMode = MG4Hardware.SasMode.OFF } - } - } - } - else -> { - // SWI133/UNKNOWN : section SWI133 (overspeed + speedTone + 5 boutons ADAS) - sectionSwi133.visibility = View.VISIBLE - sectionSwi68.visibility = View.GONE - val swOverspeed = dialogView.findViewById(R.id.sw_overspeed_alarm) - val swSpeedTone = dialogView.findViewById(R.id.sw_speed_limit_tone) - swOverspeed.isChecked = data.overspeedAlarm - swSpeedTone.isChecked = data.speedLimitTone - val adasSwi133Pairs = listOf( - dialogView.findViewById(R.id.btn_adas_off_d) to 0, - dialogView.findViewById(R.id.btn_adas_lim_d) to 1, - dialogView.findViewById(R.id.btn_adas_auto_d) to 2, - dialogView.findViewById(R.id.btn_adas_acc_d) to 3, - dialogView.findViewById(R.id.btn_adas_ica_d) to 4 - ) - bindGroup(adasSwi133Pairs, adasMode) { adasMode = it } - } - } - - // ── Économie d'énergie + TSR (tous firmwares connus) ─────────────── - // btn_energy_saving_d est en Col 1 (drive section), section_tsr_dialog en Col 2 - val sectionTsr = dialogView.findViewById(R.id.section_tsr_dialog) - if (gen != FirmwareInfo.Gen.UNKNOWN) { - btnEnergy.visibility = View.VISIBLE - // Grisé si SNOW est déjà sélectionné à l'ouverture du dialog - val initSnow = data.driveMode == DriveMode.SNOW - btnEnergy.isEnabled = !initSnow - btnEnergy.alpha = if (initSnow) 0.35f else 1f - activateBtn(btnEnergy, energySavingSel) - btnEnergy.setOnClickListener { - energySavingSel = !energySavingSel - activateBtn(btnEnergy, energySavingSel) - // Regen : indisponible si Éco actif ou si SNOW sélectionné - setRegenEnabled(!energySavingSel && selectedDrive != DriveMode.SNOW) - } - // Note : l'état initial de la regen est déjà géré après le bindGroup des modes - - sectionTsr.visibility = View.VISIBLE - val swTsr = dialogView.findViewById(R.id.sw_tsr_d) - swTsr.isChecked = tsrEnabledSel - swTsr.setOnCheckedChangeListener { _, checked -> tsrEnabledSel = checked } - } - - // ── [BT-PROFILES] Spinner appareil Bluetooth ──────────────────────── - val spinnerBt = dialogView.findViewById(R.id.spinner_bt_device) - - // "Aucun" en première entrée, suivi des appareils appairés - data class BtEntry(val label: String, val mac: String?) - val noneLabel = getString(R.string.profile_bt_none) - - // Chargement async des appareils appairés, puis population du Spinner - CoroutineScope(Dispatchers.IO).launch { - val bonded = BluetoothProfileManager.getBondedDevices(requireContext()) - val entries = mutableListOf(BtEntry(noneLabel, null)) - entries.addAll(bonded.map { BtEntry("${it.name} (${it.mac})", it.mac) }) - - withContext(Dispatchers.Main) { - if (!isAdded) return@withContext - val labels = entries.map { it.label } - val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, labels) - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - spinnerBt.adapter = adapter - - // Pré-sélectionner l'appareil déjà associé au profil - val currentMac = data.btDeviceMac - val selIdx = entries.indexOfFirst { it.mac.equals(currentMac, ignoreCase = true) } - .takeIf { it >= 0 } ?: 0 - spinnerBt.setSelection(selIdx) - - // Callback de sélection — stocké dans une var accessible lors du Save - spinnerBt.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, v: View?, pos: Int, id: Long) { - selectedBtMac = entries.getOrNull(pos)?.mac - } - override fun onNothingSelected(parent: AdapterView<*>?) { selectedBtMac = null } - } - // Initialise selectedBtMac avec la valeur pré-sélectionnée - selectedBtMac = entries.getOrNull(selIdx)?.mac - } - } - - // ── Profil par défaut ──────────────────────────────────────────────── - val swDefault = dialogView.findViewById(R.id.sw_set_default) - swDefault.isChecked = existing?.id == manager.getDefaultId() - - // ── Nom ────────────────────────────────────────────────────────────── - val etName = dialogView.findViewById(R.id.et_profile_name) - if (existing != null) etName.setText(existing.name) - - // ── Création du dialog sans chrome Android ─────────────────────────── - val dialog = AlertDialog.Builder(ctx) - .setView(dialogView) - .setCancelable(true) - .create() - dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) - - // Titre dynamique intégré dans le layout - dialogView.findViewById(R.id.tv_dialog_title).text = - if (existing != null) getString(R.string.profile_edit) else getString(R.string.profile_add) - - // ── Bouton Annuler ─────────────────────────────────────────────────── - dialogView.findViewById(R.id.btn_dialog_cancel).setOnClickListener { - dialog.dismiss() - } - - // ── Bouton Enregistrer : ne ferme PAS si le nom est vide ──────────── - dialogView.findViewById(R.id.btn_dialog_save).setOnClickListener { - val name = etName.text.toString().trim() - if (name.isEmpty()) { - etName.error = getString(R.string.profile_name_required) - etName.requestFocus() - return@setOnClickListener - } - - // SWI132 utilise désormais sectionSwi133 (mêmes IDs que SWI133 — sans suffixe _d) - val overspeedAlarm = dialogView.findViewById(R.id.sw_overspeed_alarm)?.isChecked ?: false - val speedLimitTone = dialogView.findViewById(R.id.sw_speed_limit_tone)?.isChecked ?: false - val soundWarning = dialogView.findViewById(R.id.sw_sound_warning)?.isChecked ?: false - - val profile = DrivingProfile( - id = existing?.id ?: java.util.UUID.randomUUID().toString(), - name = name, - driveMode = selectedDrive, - regenLevel = selectedRegen, - steeringHeat = steeringOn, - seatHeatLeft = seatLeft, - seatHeatRight = seatRight, - overspeedAlarm = overspeedAlarm, - speedLimitTone = speedLimitTone, - adasMode = adasMode, - soundWarning = soundWarning, - swi68AdasMode = swi68Mode, - swi132LimiterConfigured = swi132LimiterConfigured, - swi132SasMode = swi132SasMode, - aebEnabled = aebEnabledSel, - aebMode = aebModeSel, - aebSensitivity = aebSenSel, - elkMode = elkModeSel, - elkSensitivity = elkSenSel, - lasAudibleWarning = lasAudibleWarningSel, - lasVibrationReminder = lasVibrationReminderSel, - energySaving = energySavingSel, - tsrEnabled = tsrEnabledSel, - btDeviceMac = selectedBtMac // [BT-PROFILES] - ) - manager.save(profile) - if (swDefault.isChecked) manager.setDefault(profile.id) - refreshList() - dialog.dismiss() - } - - dialog.show() - - // Borner la taille du dialog : footer toujours visible + largeur adaptée à 3 colonnes - val dm = requireActivity().resources.displayMetrics - dialog.window?.setLayout( - (dm.widthPixels * 0.94).toInt(), - (dm.heightPixels * 0.88).toInt() - ) + /** + * Ouvre [ProfileEditFragment]. `existing` = profil edite (null en creation), + * `data` = valeurs a afficher (profil existant, ou pre-remplissage lu sur la voiture). + */ + private fun openEditor(existing: DrivingProfile?, data: DrivingProfile) { + ProfileEditFragment.pendingExisting = existing + ProfileEditFragment.pendingData = data + findNavController().navigate(R.id.profileEditFragment) } } diff --git a/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt b/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt index d5adf06e..3cf8a689 100644 --- a/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt +++ b/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt @@ -11,6 +11,7 @@ import com.mg4.control.MainActivity import com.mg4.control.util.ThemeHelper import android.graphics.Bitmap import android.graphics.Color +import android.graphics.Paint import android.graphics.Typeface import android.graphics.drawable.ColorDrawable import android.net.Uri @@ -43,6 +44,7 @@ import com.mg4.control.update.UpdateChecker import com.mg4.control.update.UpdateDialogManager import java.io.File import com.mg4.control.util.FirmwareHelper +import com.mg4.control.util.FirmwareInfo import com.mg4.control.util.LocaleHelper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -317,6 +319,19 @@ class SettingsFragment : Fragment() { showDiagnosticDialog() } + // [TEST TEMPORAIRE] Appui LONG sur Diagnostic → test d'écriture climatisation. + // Délibérément pas sur le clic simple : le Diagnostic s'ouvre souvent et ce test + // modifie brièvement la clim de la voiture (puis restaure l'état d'origine). + btnDiagnostic.setOnLongClickListener { + Toast.makeText( + requireContext(), + "Test écriture climatisation lancé — voir les logs MG4_CLIM (~4 s)", + Toast.LENGTH_LONG + ).show() + CoroutineScope(Dispatchers.IO).launch { MG4Hardware.runClimateWriteTest() } + true + } + // ── Bouton Infos ───────────────────────────────────────────────────── view.findViewById(R.id.btn_infos).setOnClickListener { showInfosDialog() @@ -326,6 +341,53 @@ class SettingsFragment : Fragment() { view.findViewById(R.id.btn_close_settings).setOnClickListener { findNavController().popBackStack(R.id.dashboardFragment, false) } + + // En dernier : le rail masque un onglet dont la page n'a plus rien de visible, il doit + // donc être câblé APRÈS toutes les décisions de visibilité ci-dessus (build offline, + // firmware sans extinction véhicule…), sinon le décompte serait faux. + setupFirmwareChips(view) + bindCategoryRail(view, accentDim, inactiveColor, accentColor, textActive, textInactive) + } + + /** + * Rail de gauche : une catégorie visible à la fois — même motif que l'éditeur de profil. + * + * Le bouton Diagnostic reste dans l'arbre même sur une page masquée : [MainActivity] peut donc + * continuer à le révéler en direct après les 5 clics sur le logo, quel que soit l'onglet ouvert. + */ + private fun bindCategoryRail( + view: View, accentDim: Int, inactive: Int, accent: Int, textOn: Int, textOff: Int + ) { + val tabs = listOf( + view.findViewById(R.id.btn_set_cat_lang) to view.findViewById(R.id.page_set_lang), + view.findViewById(R.id.btn_set_cat_ui) to view.findViewById(R.id.page_set_ui), + view.findViewById(R.id.btn_set_cat_advanced) to view.findViewById(R.id.page_set_advanced), + view.findViewById(R.id.btn_set_cat_info) to view.findViewById(R.id.page_set_info) + ) + val scroll = view.findViewById(R.id.scroll_settings) + + fun hasVisibleContent(page: ViewGroup): Boolean = + (0 until page.childCount).any { page.getChildAt(it).visibility == View.VISIBLE } + + val usable = tabs.filter { (_, page) -> hasVisibleContent(page) } + tabs.forEach { (btn, page) -> + btn.visibility = if (usable.any { it.second === page }) View.VISIBLE else View.GONE + } + if (usable.isEmpty()) return + + fun select(target: ViewGroup) { + tabs.forEach { (btn, page) -> + val on = page === target + page.visibility = if (on) View.VISIBLE else View.GONE + btn.backgroundTintList = ColorStateList.valueOf(if (on) accentDim else inactive) + btn.setTextColor(if (on) textOn else textOff) + btn.strokeColor = ColorStateList.valueOf( + if (on) accent else requireContext().getColor(R.color.dash_border)) + } + scroll?.scrollTo(0, 0) // changer d'onglet en gardant le scroll précédent désoriente + } + usable.forEach { (btn, page) -> btn.setOnClickListener { select(page) } } + select(usable.first().second) } @@ -395,6 +457,13 @@ class SettingsFragment : Fragment() { MG4Hardware.runTemperatureDiag() // Sonde vitesse : logge la vitesse brute (validation de l'unité par firmware). MG4Hardware.runSpeedDiag() + // Sonde climatisation : lecture seule, repère ce qui répond avant tout pilotage. + MG4Hardware.runClimateDiag() + // Chasse à la consigne de température (candidats × zones + voie OEM). + MG4Hardware.runClimateSetpointHunt() + // Sonde thème : quelle source de day/night répond sur ce firmware. Contexte d'ACTIVITÉ — + // c'est sa configuration qui décide des ressources affichées. + ThemeHelper.runDiagnostic(ctx) val appVersion = try { ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "?" @@ -595,4 +664,102 @@ class SettingsFragment : Fragment() { dialog.show() } + // ── Indicateur firmware (deplace depuis la barre du haut) ──────────────── + // Les pastilles affichent la generation detectee et, quand la voiture n'est pas reconnue, + // permettent d'en forcer une. Elles vivent desormais dans Reglages > Infos. + private fun setupFirmwareChips(view: View) { + val chip133 = view.findViewById(R.id.chip_swi133) + val chip132 = view.findViewById(R.id.chip_swi132) + val chip68 = view.findViewById(R.id.chip_swi68) + val chip69 = view.findViewById(R.id.chip_swi69) + val chip131 = view.findViewById(R.id.chip_swi131) + val chip165 = view.findViewById(R.id.chip_swi165) + val gen = FirmwareInfo.getGeneration() + val forced = FirmwareInfo.isForced(requireContext()) + + fun styleChipActive(tv: TextView) { + tv.setBackgroundResource(R.drawable.bg_chip_active) + tv.setTextColor(requireContext().getColor(R.color.dash_accent)) + tv.alpha = 1f + tv.paintFlags = tv.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() + } + + fun styleChipInactive(tv: TextView) { + tv.setBackgroundResource(R.drawable.bg_chip_inactive) + tv.setTextColor(requireContext().getColor(R.color.dash_text_lo)) + tv.alpha = 0.4f + tv.paintFlags = tv.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG + } + + fun styleChipSelectable(tv: TextView) { + // Firmware inconnu sans choix forcé : chip cliquable, surlignée en rouge + tv.setBackgroundResource(R.drawable.bg_chip_inactive) + tv.setTextColor(requireContext().getColor(R.color.dash_danger)) + tv.alpha = 0.75f + tv.paintFlags = tv.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() + } + + val isNaturalUnknown = gen == FirmwareInfo.Gen.UNKNOWN && !forced + val allChips = listOf(chip133, chip132, chip68, chip69, chip131, chip165) + + when { + isNaturalUnknown -> { + // Les six chips en mode "à choisir" (rouge dim, aucune barrée) + allChips.forEach { styleChipSelectable(it) } + } + gen == FirmwareInfo.Gen.SWI165 -> { + styleChipActive(chip165) + listOf(chip133, chip132, chip68, chip69, chip131).forEach { styleChipInactive(it) } + } + gen == FirmwareInfo.Gen.SWI131 -> { + styleChipActive(chip131) + listOf(chip133, chip132, chip68, chip69, chip165).forEach { styleChipInactive(it) } + } + gen == FirmwareInfo.Gen.SWI69 -> { + styleChipActive(chip69) + listOf(chip133, chip132, chip68, chip131, chip165).forEach { styleChipInactive(it) } + } + gen == FirmwareInfo.Gen.SWI68 -> { + styleChipActive(chip68) + listOf(chip133, chip132, chip69, chip131, chip165).forEach { styleChipInactive(it) } + } + gen == FirmwareInfo.Gen.SWI132 -> { + styleChipActive(chip132) + listOf(chip133, chip68, chip69, chip131, chip165).forEach { styleChipInactive(it) } + } + else -> { // SWI133 ou forcé SWI133 + styleChipActive(chip133) + listOf(chip132, chip68, chip69, chip131, chip165).forEach { styleChipInactive(it) } + } + } + + // Chips cliquables si firmware inconnu (naturel ou forcé) pour changer de mode + if (gen == FirmwareInfo.Gen.UNKNOWN || forced) { + chip133.setOnClickListener { + FirmwareInfo.forceGeneration(requireContext(), FirmwareInfo.Gen.SWI133) + requireActivity().recreate() + } + chip132.setOnClickListener { + FirmwareInfo.forceGeneration(requireContext(), FirmwareInfo.Gen.SWI132) + requireActivity().recreate() + } + chip68.setOnClickListener { + FirmwareInfo.forceGeneration(requireContext(), FirmwareInfo.Gen.SWI68) + requireActivity().recreate() + } + chip69.setOnClickListener { + FirmwareInfo.forceGeneration(requireContext(), FirmwareInfo.Gen.SWI69) + requireActivity().recreate() + } + chip131.setOnClickListener { + FirmwareInfo.forceGeneration(requireContext(), FirmwareInfo.Gen.SWI131) + requireActivity().recreate() + } + chip165.setOnClickListener { + FirmwareInfo.forceGeneration(requireContext(), FirmwareInfo.Gen.SWI165) + requireActivity().recreate() + } + } + } + } diff --git a/app/src/main/java/com/mg4/control/ui/ShortcutsFragment.kt b/app/src/main/java/com/mg4/control/ui/ShortcutsFragment.kt index 4283c21e..fed75829 100644 --- a/app/src/main/java/com/mg4/control/ui/ShortcutsFragment.kt +++ b/app/src/main/java/com/mg4/control/ui/ShortcutsFragment.kt @@ -10,6 +10,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ScrollView import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.Spinner @@ -105,7 +106,7 @@ class ShortcutsFragment : Fragment() { // ── Affichage des sections de config selon firmware ─────────────── // Tous les firmwares connus utilisent la config 5 modes (Off/Lim.Manuel/Lim.Auto/ACC/ICA|TJA). - view.findViewById(R.id.config_adas_section)?.visibility = if (isKnown) View.VISIBLE else View.GONE + adasSupported = isKnown view.findViewById(R.id.config_adas_swi133)?.visibility = if (isKnown) View.VISIBLE else View.GONE view.findViewById(R.id.config_adas_swi68)?.visibility = View.GONE @@ -117,6 +118,94 @@ class ShortcutsFragment : Fragment() { setupSpinners(view) setupConfigListeners(view) restoreState() + + // En dernier : le rail compte les sections visibles, il doit donc voir l'état final. + rootView = view + refreshActionConfigVisibility() + bindCategoryRail(view) + } + + // ── Onglets « Boutons » / « Actions » ──────────────────────────────── + + /** Vrai si le firmware expose le cycle ADAS (sinon la section reste masquée en permanence). */ + private var adasSupported = false + private var rootView: View? = null + /** Rejoue la sélection d'onglet après un changement de visibilité (le rail peut apparaître + * ou disparaître quand l'utilisateur attribue ou retire une action). */ + private var reselectTabs: (() -> Unit)? = null + + /** + * N'affiche un réglage d'action que si l'action est réellement attribuée à un bouton : + * régler le niveau de retour du mode 1 pédale n'a aucun sens si aucun bouton ne le déclenche. + * + * Appelée au démarrage ET à chaque changement de sélection dans un spinner — sinon le réglage + * n'apparaîtrait qu'au prochain passage sur l'écran. + */ + private fun refreshActionConfigVisibility() { + val view = rootView ?: return + val assigned = slotPressList.map { ShortcutAction.fromId(prefs.getInt("shortcut_$it", 0)) } + + val showOnePedal = assigned.any { it == ShortcutAction.ONE_PEDAL } + val showAdas = adasSupported && assigned.any { it == ShortcutAction.ADAS_CYCLE } + + view.findViewById(R.id.config_onepedal_section)?.visibility = + if (showOnePedal) View.VISIBLE else View.GONE + view.findViewById(R.id.config_adas_section)?.visibility = + if (showAdas) View.VISIBLE else View.GONE + + reselectTabs?.invoke() + } + + /** + * Rail de gauche — même motif que l'éditeur de profil et les Réglages, à ceci près que le + * contenu de l'onglet Actions dépend des choix de l'utilisateur : si plus rien n'y est + * visible, l'onglet disparaît et l'écran redevient une page unique. + */ + private fun bindCategoryRail(view: View) { + val tabs = listOf( + view.findViewById(R.id.btn_sc_cat_buttons) to view.findViewById(R.id.page_sc_buttons), + view.findViewById(R.id.btn_sc_cat_actions) to view.findViewById(R.id.page_sc_actions) + ) + val scroll = view.findViewById(R.id.scroll_shortcuts) + // Le rail reprend l'accent des deux autres écrans refondus (dash_accent), pas l'accent vert + // propre aux boutons de cet écran : c'est le même composant de navigation partout. + val dimColor = requireContext().getColor(R.color.dash_accent_dim) + val railOn = requireContext().getColor(R.color.dash_accent) + val railOff = requireContext().getColor(R.color.dash_btn) + val border = requireContext().getColor(R.color.dash_border) + val textOff = requireContext().getColor(R.color.text_secondary) + + fun hasVisibleContent(page: ViewGroup): Boolean = + (0 until page.childCount).any { page.getChildAt(it).visibility == View.VISIBLE } + + fun apply() { + val usable = tabs.filter { (_, page) -> hasVisibleContent(page) } + tabs.forEach { (btn, page) -> + btn.visibility = if (usable.any { it.second === page }) View.VISIBLE else View.GONE + } + // L'onglet courant vient d'être masqué (action retirée) → retomber sur le premier. + if (usable.none { it.second.visibility == View.VISIBLE }) { + usable.firstOrNull()?.let { (_, page) -> page.visibility = View.VISIBLE } + } + tabs.forEach { (btn, page) -> + val on = page.visibility == View.VISIBLE + btn.backgroundTintList = ColorStateList.valueOf(if (on) dimColor else railOff) + btn.setTextColor(if (on) railOn else textOff) + btn.strokeColor = ColorStateList.valueOf(if (on) railOn else border) + } + } + + tabs.forEach { (btn, page) -> + btn.setOnClickListener { + tabs.forEach { (_, p) -> p.visibility = if (p === page) View.VISIBLE else View.GONE } + scroll?.scrollTo(0, 0) + apply() + } + } + reselectTabs = { apply() } + tabs.first().second.visibility = View.VISIBLE + tabs.drop(1).forEach { (_, p) -> p.visibility = View.GONE } + apply() } // ── Spinners (un adapter par spinner) ──────────────────────────────── @@ -149,6 +238,8 @@ class ShortcutsFragment : Fragment() { override fun onItemSelected(parent: AdapterView<*>, v: View?, pos: Int, id: Long) { val action = baseActionItems[pos].action saveInt("shortcut_$slotKey", action.id) + // Le réglage lié à l'action doit apparaître (ou disparaître) tout de suite. + refreshActionConfigVisibility() if (initialized) { when (action) { ShortcutAction.OPEN_CUSTOM_APP -> showAppPickerDialog(slotKey) diff --git a/app/src/main/java/com/mg4/control/util/ThemeHelper.kt b/app/src/main/java/com/mg4/control/util/ThemeHelper.kt index afd17741..8ead0f80 100644 --- a/app/src/main/java/com/mg4/control/util/ThemeHelper.kt +++ b/app/src/main/java/com/mg4/control/util/ThemeHelper.kt @@ -1,8 +1,10 @@ package com.mg4.control.util +import android.app.UiModeManager import android.content.Context import android.provider.Settings import androidx.appcompat.app.AppCompatDelegate +import com.mg4.control.debug.AppLogger /** * Gestion du thème de l'application (sombre / clair / auto-sync launcher). @@ -71,18 +73,42 @@ object ThemeHelper { // ── Résolution du mode à appliquer ─────────────────────────────────────── + /** + * Night mode réglé par le launcher, lu sur **`UiModeManager`** et NON sur + * `Configuration.uiMode`. + * + * ⚠️ MESURÉ SUR SWI133 (2026-08-16), ne pas « simplifier » en revenant à FOLLOW_SYSTEM : + * quand on change le thème depuis l'écran voiture, `UiModeManager.getNightMode()` passe bien + * de 2 (YES) à 1 (NO) — le launcher appelle donc `setNightMode()` — mais le système **ne + * propage pas** le changement dans la Configuration, qui reste figée à `0x23` (TYPE_CAR | + * NIGHT_YES) dans les deux relevés. Or `MODE_NIGHT_FOLLOW_SYSTEM` se base sur la + * Configuration : il est donc aveugle ici, et l'app restait bloquée en sombre. + * + * MODE_NIGHT_AUTO / CUSTOM (0 / 3) : pas de valeur exploitable → on rend la main à AppCompat. + */ + fun getSystemNightMode(context: Context): Int = try { + val umm = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager + when (umm.nightMode) { + UiModeManager.MODE_NIGHT_YES -> AppCompatDelegate.MODE_NIGHT_YES + UiModeManager.MODE_NIGHT_NO -> AppCompatDelegate.MODE_NIGHT_NO + else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + } catch (e: Exception) { + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + /** * Retourne le night mode AppCompat à appliquer selon la préférence "theme_mode". * * "auto" sur SWI69/131/132 → YES ou NO selon SKIN_THEME_CONFIG - * "auto" sur SWI133/68 → MODE_NIGHT_FOLLOW_SYSTEM (suit le uiMode Android) + * "auto" sur SWI133/68/165 → YES ou NO selon [getSystemNightMode] */ fun resolveNightMode(context: Context): Int { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) return when (prefs.getString(PREF_THEME_MODE, "auto")) { "light" -> AppCompatDelegate.MODE_NIGHT_NO "auto" -> if (hasSkinThemeConfig(context)) getLauncherNightMode(context) - else AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + else getSystemNightMode(context) else -> AppCompatDelegate.MODE_NIGHT_YES // "dark" + fallback } } @@ -93,4 +119,86 @@ object ThemeHelper { fun notifyThemeChanged() { onThemeChanged?.invoke() } + + // ── Sonde diagnostic ───────────────────────────────────────────────────── + + private const val DIAG_TAG = "MG4_THEME" + + /** URI du fournisseur de thème SAIC (voie SWI133 : le launcher, SystemUI et VehicleSettings + * y posent un ContentObserver — c'est leur seul mécanisme de synchro). */ + private const val SKIN_PROVIDER_URI = "content://com.saicmotor.skinProvider/current" + + private fun nightLabel(uiMode: Int): String = + when (uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) { + android.content.res.Configuration.UI_MODE_NIGHT_YES -> "NUIT" + android.content.res.Configuration.UI_MODE_NIGHT_NO -> "JOUR" + else -> "INDÉFINI" + } + + private fun modeLabel(mode: Int): String = when (mode) { + AppCompatDelegate.MODE_NIGHT_NO -> "NO (clair)" + AppCompatDelegate.MODE_NIGHT_YES -> "YES (sombre)" + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> "FOLLOW_SYSTEM" + else -> "autre($mode)" + } + + /** + * Sonde du bouton Diagnostic. Lecture seule. + * + * Objectif : identifier, sur un firmware donné, QUELLE source de thème répond réellement. + * Manip côté testeur : cliquer Diagnostic, changer le thème depuis l'écran voiture, recliquer + * Diagnostic, et comparer les deux relevés — c'est la ligne qui bouge qui désigne le mécanisme. + * + * [context] doit être le contexte d'ACTIVITÉ : c'est sa configuration qui décide des ressources + * réellement affichées, et elle peut différer de celle du contexte applicatif. + */ + fun runDiagnostic(context: Context) { + AppLogger.i(DIAG_TAG, "── DIAG thème ──") + + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + AppLogger.i(DIAG_TAG, "pref theme_mode=${prefs.getString(PREF_THEME_MODE, "(absent)")} " + + "| defaultNightMode=${modeLabel(AppCompatDelegate.getDefaultNightMode())} " + + "| resolveNightMode=${modeLabel(resolveNightMode(context))}") + + val actUi = context.resources.configuration.uiMode + val appUi = context.applicationContext.resources.configuration.uiMode + AppLogger.i(DIAG_TAG, "uiMode activité=0x${Integer.toHexString(actUi)} (${nightLabel(actUi)}) " + + "| application=0x${Integer.toHexString(appUi)} (${nightLabel(appUi)})" + + if (actUi != appUi) " ⚠ DIVERGENTS" else "") + + // Voie SWI68/165 : le uiMode Android, piloté par le launcher ET par CarNightService. + try { + val umm = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager + AppLogger.i(DIAG_TAG, "UiModeManager nightMode=${umm.nightMode} (1=NO/jour, 2=YES/nuit) " + + "currentModeType=${umm.currentModeType} → mappé ${modeLabel(getSystemNightMode(context))}") + } catch (e: Exception) { + AppLogger.w(DIAG_TAG, "UiModeManager indisponible : ${e.message}") + } + + // Voie A9 (SWI69/131/132) : clé Settings.System + broadcast com.saicmotor.changeSkin. + val skinCfg = try { + Settings.System.getInt(context.contentResolver, SKIN_THEME_KEY, -1) + } catch (e: Exception) { -2 } + AppLogger.i(DIAG_TAG, "Settings.System.$SKIN_THEME_KEY=$skinCfg " + + "(-1=absent, -2=erreur, 0=sombre, 1=clair)") + + // Voie SWI133 : ContentProvider du launcher. Détermine s'il est lisible par MG4Control + // ET quelle valeur porte le thème courant (inconnue jusqu'ici). + try { + context.contentResolver.query( + android.net.Uri.parse(SKIN_PROVIDER_URI), null, null, null, null + )?.use { c -> + AppLogger.i(DIAG_TAG, "skinProvider : ${c.count} ligne(s), colonnes=${c.columnNames.joinToString()}") + var n = 0 + while (c.moveToNext() && n++ < 10) { + val row = (0 until c.columnCount).joinToString(" | ") { i -> + "${c.getColumnName(i)}=${runCatching { c.getString(i) }.getOrNull()}" + } + AppLogger.i(DIAG_TAG, " $row") + } + } ?: AppLogger.i(DIAG_TAG, "skinProvider : query a renvoyé null (provider absent ?)") + } catch (e: Exception) { + AppLogger.w(DIAG_TAG, "skinProvider illisible : ${e.javaClass.simpleName} ${e.message}") + } + } } diff --git a/app/src/main/res/drawable/spacer_v12.xml b/app/src/main/res/drawable/spacer_v12.xml new file mode 100644 index 00000000..d781f8e5 --- /dev/null +++ b/app/src/main/res/drawable/spacer_v12.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 8b7cd214..d23e7e13 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -39,117 +39,6 @@ android:adjustViewBounds="true" android:contentDescription="@string/app_name" /> - - - - - - - - - - - - - - - - - - - - - - - - + android:padding="14dp"> + android:orientation="vertical"> - + + android:orientation="vertical" + android:background="@drawable/bg_card_rounded" + android:padding="14dp"> - + - - + + + + + + + + + + android:orientation="horizontal" + android:gravity="center_vertical"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - + android:background="@drawable/bg_card_rounded" + android:padding="14dp"> + + android:gravity="center_vertical" + android:layout_marginBottom="8dp"> - + android:text="@string/ac_auto_title" + android:textColor="@color/text_primary" + android:textSize="20sp" + android:textStyle="bold" /> - - - + android:textSize="16sp" /> - + android:checked="false" /> - - - - + android:orientation="vertical" + android:visibility="gone"> - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_dashboard.xml b/app/src/main/res/layout/fragment_dashboard.xml index 63698b7f..ecee3f3a 100644 --- a/app/src/main/res/layout/fragment_dashboard.xml +++ b/app/src/main/res/layout/fragment_dashboard.xml @@ -1,27 +1,2044 @@ - + android:orientation="horizontal" + android:background="@color/dash_bg" + android:padding="14dp"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +