Referencia completa de pantallas, widgets y motores.
Pulsera Meitt SDK 2208 vía BLE 5.0. Todos los datos se obtienen exclusivamente del hardware. Sin cloud, sin APIs externas.
BleRepositoryAndroid.kt
SQLite local (Room). Historial de FC, sueño, SpO2, pasos, sesiones de actividad. Todo en el dispositivo del usuario.
shared/data/local/
Patrón MVI: Intent → State → Effect.
DashboardViewModel centraliza el estado de salud.
Coroutines para streaming y sync.
DashboardViewModel.kt
Kotlin Multiplatform + Compose Multiplatform. Android e iOS desde el mismo código. Tema oscuro OLED (#121416).
composeApp/ui/
data class DashboardState( selectedDate: LocalDate, latestVitals: VitalReading?, // streaming live hrHistory: List<HeartRateRecord>, sleepHistory: List<SleepRecord>, spo2History: List<Spo2Record>, selectedDaySteps: StepsRecord?, physiologyInsight: PhysiologyInsight?, // HRV engine trueAgeInsight: TrueAgeInsight?, // TrueAge™ engine trueAgeYears: Int?, activityScore: Int, isSyncing: Boolean, isStreaming: Boolean, )
// Pull-to-refresh (HealthScreen) SyncHistoricalData → syncData() // Si pulsera conectada → sync real: syncHeartRate() + syncSteps() + syncSleepHistory() + syncSpo2() // Si desconectada → solo recarga local: fetchDataForSelectedDate() // Stream en tiempo real (Dashboard): StartVitalsStream → bleRepository .startVitalsStream() .collect { vitals → updateState }
connectionState.value is Connected de forma
inmediata. Si no hay conexión, recarga desde SQLite sin intentar
reconectar.
DashboardViewModel +
ActivityViewModel
DashboardViewModelActivityViewModel
DashboardStateHealthScreen.kt. El cambio se refleja
automáticamente en ambas pantallas.
| Clave | Condición |
|---|---|
HABITS_HOLDING_BACK |
habitsDriver es el factor dominante negativo |
TREND_DECLINING |
trend10d > 0.5 |
TREND_IMPROVING |
trend10d < −0.5 |
HRV_SUSTAINS_COMPENSATES |
hrv alto compensa sueño bajo |
SLEEP_DRIVES_RECOVERY |
sleepDriver dominante positivo |
ACTIVITY_BOOSTS_VITALITY |
activityDriver dominante positivo |
BALANCED |
todos los factores equilibrados |
NEUTRAL_INSUFFICIENT_DATA |
daysWithData < 3 |
TrueAgeWidget.kt. El anillo, el tamaño de número
y los colores están en RevealedView. El estado de
cálculo en CalculatingView.
data class TrueAgeInsight( biologicalAge: Double, // edad calculada chronologicalAge: Int, offset: Double, // + = mayor, - = menor trend10d: Double, // + = empeorando dominantFactor: TrueAgeFactor, insightKey: TrueAgeInsightKey, sleepDriver: Double, hrvDriver: Double, activityDriver: Double, habitsDriver: Double, daysWithData: Int, avgHrvMs: Double, avgSleepScore: Double, avgActivityScore: Double, )
DailyMetrics por día y llama a
calculateWindowed().
physiologyInsight.rmssd
spo2History.lastOrNull()?.percent
trueAgeInsight.avgSleepScore
physiologyInsight.recoveryScore
| Score | Label | Color |
|---|---|---|
| ≥ 85 | ÓPTIMO | Cyan |
| 65-84 | BUENO | Verde |
| 40-64 | BAJO | Amber |
| < 40 | CRÍTICO | Rojo |
ResilienceRadarChart.kt. Los 4 ejes están fijos
(N/E/S/O = HRV/SpO2/Sueño/Recovery). Para añadir ejes, cambiar
el loop de 0..3 y los ángulos a
i * 360/n.
internal fun HealthSectionContent(state: DashboardState, modifier:
Modifier)
— vive en HealthScreen.kt y se usa en
DashboardScreen y HealthScreen sin
duplicación.
| Anillo | Color | Objetivo | Fuente |
|---|---|---|---|
| Pasos | Cyan | 10 000 pasos | StepsRecord |
| Calorías | Naranja | 500 kcal | StepsRecord |
| Distancia | Violeta | 5 km | StepsRecord |
Cada anillo usa animateFloatAsState con
spring(DampingRatioMediumBouncy, StiffnessLow).
El bounce es suave y da sensación de física real al cargar.
HealthScreen.kt:
STEPS_GOAL_DEFAULT = 10_000,
CALS_GOAL = 500, DIST_GOAL_KM = 5f.
state.trueAgeYears (Int, redondeado)
| diff | Color | Mensaje |
|---|---|---|
| ≥ 5 | Cyan | Muy joven para tu edad |
| 1-4 | Teal | Más joven de lo esperado |
| 0 | Teal | Acorde a tu edad |
| −1 a −4 | Amber | Ligeramente por encima |
| ≤ −5 | Rojo | Por encima de tu edad |
state.activityScore (0-100)
physiologyInsight.recoveryScore o estimado
local
PhysiologyInsight?
RecoveryWidget.kt)
LoadRecoveryCard en HealthScreen.kt.
El RecoveryWidget vive en
ui/component/RecoveryWidget.kt.
| # | Capa | Color | Fuente |
|---|---|---|---|
| 1 | Fases sueño |
#1A237E α22%
|
SleepRecord (excluye AWAKE) |
| 2 | Fases actividad |
#E65100 α14%
|
FC ≥ 90 bpm × ≥3 muestras consecutivas |
| 3 | Etiquetas tiempo | Blanco α28% | Calculadas sobre ventana 24h (cada 6h) |
| 4 | Línea HR |
#26C6DA /
#E65100
|
HeartRateRecord.bpm |
| 5 | Puntos estrés |
#B71C1C (blink)
|
sedentaryStressAlert=true + máximos
locales 75-100 bpm
|
PhysiologyInsight.RecoveryStatus.label +
subtítulo HRV/RHR
latestVitals.heartRate o último bpm
state.hrHistory (lista completa 24h)
state.sleepHistory para overlays
state.physiologyInsight
fun HeartRateVitalityMap en
ui/component/Charts.kt.HrVitalityInsights y
VitalityStatChip en
HealthScreen.kt.rec.bpm >= 90 en
ambas ubicaciones.
spo2History.lastOrNull()?.percent
| Valor | Estado | Color |
|---|---|---|
| ≥ 96% | Normal | Cyan |
| 90-95% | Aceptable | Amber |
| < 90% | Bajo — Consulta médico | Rojo |
durationMin donde level != AWAKE
level == DEEP
level == REM
level == LIGHT
userProfile.sleepGoalMin ?: 480 (8h por
defecto)
| Fase | Color | Hex |
|---|---|---|
| Profundo | #7986CB |
|
| REM | #4DD0E1 |
|
| Ligero | #80CBC4 |
| % del objetivo | Color |
|---|---|
| ≥ 85% | Cyan (bueno) |
| 60-84% | Teal (aceptable) |
| < 60% | Amber (insuficiente) |
SleepCard,
SleepStagesBar, SleepStageItem en
HealthScreen.kt. El estado vacío usa
SleepEmptyState
(ui/component/SleepEmptyState.kt).
latestVitals.hrv o
estimateHrv(hrHistory)
| HRV (ms) | Estado | Color |
|---|---|---|
| ≥ 60 | Excelente | Cyan |
| 35-59 | Normal | Teal |
| 20-34 | Bajo | Amber |
| < 20 | Muy bajo | Rojo |
| Score | Estado | Color |
|---|---|---|
| 0-25 | Relajado | Cyan |
| 26-50 | Moderado | Teal |
| 51-75 | Elevado | Amber |
| 76-100 | Alto | Rojo |
spring(DampingRatioMediumBouncy).
hrBpms.minOrNull())
| RHR | Perspectiva | Color |
|---|---|---|
| < 50 | Presión óptima · Perfil atlético | Cyan |
| 50-59 | Presión óptima · FC excelente | Cyan |
| 60-69 | Presión normal | Teal |
| 70-79 | Presión normal-alta | Amber |
| 80-89 | Atención — FC elevada | Naranja |
| ≥ 90 | Consulta médico | Rojo |
activityState.history
activityState.isLoadingHistory
Instant.toLocalDateTime)
padStart(2,'0')
Muestra
máximo 3 sesiones
(sessions.take(3)). La pantalla completa se ve
en ActivityScreen.
| # | Componente | Archivo | Pantallas | Datos desde |
|---|---|---|---|---|
| ★ | TrueAgeWidget |
TrueAgeWidget.kt | Dashboard | state.trueAgeInsight / trueAgeYears |
| R | ResilienceRadarChart |
ResilienceRadarChart.kt | Dashboard |
physiologyInsight, spo2History, trueAgeInsight
|
| 1 | ActivityRingsWidget |
HealthScreen.kt | Dashboard + Health | selectedDaySteps, latestVitals |
| 2a | BioAgeCard |
HealthScreen.kt | Dashboard + Health | state.trueAgeYears, userProfile |
| 2b | LoadRecoveryCard |
HealthScreen.kt | Dashboard + Health | activityScore, physiologyInsight |
| 3 |
HeartRateCard + HeartRateVitalityMap
|
HealthScreen.kt + Charts.kt | Dashboard + Health | hrHistory, sleepHistory, physiologyInsight |
| 4a | SpO2Card |
HealthScreen.kt | Dashboard + Health | spo2History |
| 4b | MetabolismCard |
HealthScreen.kt | Dashboard + Health | steps, calories, userProfile |
| 5 | SleepCard |
HealthScreen.kt | Dashboard + Health | sleepHistory, userProfile.sleepGoalMin |
| 6a | HrvCard |
HealthScreen.kt | Dashboard + Health | latestVitals.hrv o estimado |
| 6b | StressCard |
HealthScreen.kt | Dashboard + Health | HRV o variación FC |
| 7 | BloodPressureCard |
HealthScreen.kt | Dashboard + Health | hrHistory.minOrNull() (RHR) |
| A | RecentActivitiesWidget |
DashboardScreen.kt | Dashboard | activityState.history |
Auditoría de 4 capas del flujo de datos de Frecuencia Cardíaca.
Se identificaron y corrigieron 6 bugs
que impedían que la gráfica HeartRateVitalityMap recibiera datos.
JStyle SDK 2208
FFF7 notify → DataListener2025
→ _parsedData SharedFlow
HeartRateRecordinsertHeartRate()
by timestampMs
HRIntelligenceEngineTrueAgeEngine
ventana 7–10 días
HeartRateVitalityMapDashboardHrSectionstate.hrHistory
| # | Capa | Bug | Archivo | Fix |
|---|---|---|---|---|
| 1 | SDK → DB | _syncCompletion(replay=1) — la 2ª llamada a syncHeartRateForDate devolvía el valor cacheado del sync anterior y salía sin esperar los datos reales |
BleRepositoryAndroid.kt | resetReplayCache() antes de cada enqueueWrite |
| 2 | SDK → Sleep | handleSleepPayload: el paquete de fin 53 FF (2 bytes, sin array) hacía ?: return antes del check isEnd. El loop de paginación de sueño nunca terminaba, saturando la cola BLE e impidiendo que los comandos HR llegaran al dispositivo |
BleRepositoryAndroid.kt | Mover isEnd → tryEmit(26) antes del ?: return + flag sleepSyncActive |
| 3 | ViewModel | 4 operaciones de sync BLE lanzadas en paralelo (coroutineScope { launch{} × 4 }). El protocolo JStyle es estrictamente command-response: comandos concurrentes producen respuestas entremezcladas o silenciosas |
DashboardViewModel.kt | Sync secuencial: HR → Steps → Sleep → SpO2 |
| 4 | DB Query | Rango de fechas calculado con date.toEpochDays() * 86400000L (medianoche UTC). parseSdkDate() usa SimpleDateFormat en timezone local. En UTC+N los registros nocturnos caían fuera del rango |
BleRepositoryAndroid.kt:949 | date.atStartOfDayIn(TimeZone.currentSystemDefault()) |
| 5 | SDK → HR | GetDynamicHRWithMode("2026.03.31") devuelve vacío en firmware sin sesiones de ejercicio para esa fecha exacta. El dispositivo solo almacena HR dinámica durante actividad activa |
BleRepositoryAndroid.kt:841 | Cambiar a "" (sin filtro de fecha) como steps/sleep para recuperar todo el historial disponible |
| 6 | SDK → DB | El dispositivo no expone historial HR estático vía GetStaticHRWithMode en este firmware. Los vitales en tiempo real (dataType=23, hr=57 cada ~1s) no se persistían en HeartRateRecord |
BleRepositoryAndroid.kt | Case 23 en _parsedData.collect{}: persiste HR en tiempo real con throttle de 5 min → 288 puntos/día |
HeartRateRecordtimestampMs INTEGER PK ← epoch ms exacto bpm INTEGER ← latidos/min type TEXT ← CONTINUOUS | STATIC
1 fila = 1 punto de medición. Granularidad: 5 min (288 pts/día).
| Reposo nocturno | bpm < 60 · 00:00–07:00 |
| Reposo diurno | 60–70 bpm |
| Actividad ligera | 70–100 bpm |
| Actividad intensa | ≥ 100 bpm |
| Estrés sedentario | 75–100 · máx local |
| HRV / recuperación | Δbpm entre puntos |
getHeartRateByDateRange(from, to) getLatestHeartRate() getAvailableDates() getFirstDataDate() updateAvgHrForDay(epochDay)
El engine HeartRateIntelligenceEngine ya consume ventana de 7 días para calcular RHR, HRV y recoveryScore.