Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 107 additions & 17 deletions app/src/main/kotlin/com/sploot/app/service/WhoopRecordingService.kt

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions app/src/main/kotlin/com/sploot/app/ui/debug/DebugScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,17 @@ fun DebugScreen(
}
}

Spacer(Modifier.height(10.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MetricLabel(label = "Live frames", value = "${state.realtimeFrameCount}")
MetricLabel(label = "History frames", value = "${state.historicalFrameCount}")
MetricLabel(label = "History batches", value = "${state.historicalBatchMarkers}")
MetricLabel(label = "History done", value = "${state.historicalCompleteMarkers}")
}

Spacer(Modifier.height(8.dp))
Text(
text = "Double-tap action: ${state.configuredDoubleTapAction}",
Expand Down Expand Up @@ -168,6 +179,12 @@ fun DebugScreen(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(
onClick = vm::startHistoricalBackfill,
modifier = Modifier.weight(1f),
) {
Text("Backfill Now")
}
FilledTonalButton(
onClick = vm::toggleHaptics,
modifier = Modifier.weight(1f),
Expand Down
34 changes: 28 additions & 6 deletions app/src/main/kotlin/com/sploot/app/ui/debug/DebugViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ data class DebugUiState(
val packetCount: Int = 0,
val outgoingPacketCount: Int = 0,
val incomingPacketCount: Int = 0,
val realtimeFrameCount: Int = 0,
val historicalFrameCount: Int = 0,
val historicalBatchMarkers: Int = 0,
val historicalCompleteMarkers: Int = 0,
val traceEvents: List<WhoopBleTraceEvent> = emptyList(),
val unknownAuditRows: List<UnknownAuditRow> = emptyList(),
)
Expand Down Expand Up @@ -82,12 +86,16 @@ class DebugViewModel @Inject constructor(
hrBpm = 0,
ppgChannelA = emptyList(),
hapticsActive = false,
packetCount = 0,
outgoingPacketCount = 0,
incomingPacketCount = 0,
traceEvents = emptyList(),
unknownAuditRows = emptyList(),
)
packetCount = 0,
outgoingPacketCount = 0,
incomingPacketCount = 0,
realtimeFrameCount = 0,
historicalFrameCount = 0,
historicalBatchMarkers = 0,
historicalCompleteMarkers = 0,
traceEvents = emptyList(),
unknownAuditRows = emptyList(),
)
}
}
}
Expand All @@ -110,6 +118,10 @@ class DebugViewModel @Inject constructor(
state.copy(
outgoingPacketCount = state.outgoingPacketCount + if (trace.direction == TraceDirection.OUTGOING) 1 else 0,
incomingPacketCount = state.incomingPacketCount + if (trace.direction == TraceDirection.INCOMING) 1 else 0,
realtimeFrameCount = state.realtimeFrameCount + if (trace.summary.contains("raw-realtime")) 1 else 0,
historicalFrameCount = state.historicalFrameCount + if (trace.summary.contains("historical-data")) 1 else 0,
historicalBatchMarkers = state.historicalBatchMarkers + if (trace.summary.contains("Historical batch marker")) 1 else 0,
historicalCompleteMarkers = state.historicalCompleteMarkers + if (trace.summary.contains("Historical sync complete")) 1 else 0,
traceEvents = (listOf(trace) + state.traceEvents).take(80),
)
}
Expand Down Expand Up @@ -204,6 +216,16 @@ class DebugViewModel @Inject constructor(
context.startService(WhoopRecordingService.stopIntent(context))
}

fun startHistoricalBackfill() {
val settings = settingsRepository.current()
if (settings.preferredWhoopDeviceAddress == null) {
_uiState.update { it.copy(latestBandEvent = "Pick a WHOOP device before backfill") }
return
}
context.startForegroundService(WhoopRecordingService.startHistoricalSyncIntent(context))
_uiState.update { it.copy(latestBandEvent = "Requested historical backfill") }
}

fun onScreenVisible() {
val settings = settingsRepository.current()
if (settings.preferredWhoopDeviceAddress == null) return
Expand Down
145 changes: 111 additions & 34 deletions app/src/main/kotlin/com/sploot/app/ui/screen/DashboardScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -921,21 +921,39 @@ private fun TodaySignalsSection(
)
TrendCard(
title = "Heart Rate Today",
subtitle = "15-minute average heart rate",
subtitle = "5-minute average heart rate",
accent = Color(0xFFF86D74),
points = state.todayHeartRateTrend,
yAxis = TrendYAxis(
min = 40f,
max = 200f,
ticks = listOf(200f, 160f, 120f, 80f, 40f),
suffix = "",
),
)
TrendCard(
title = "Breaths Per Minute Today",
subtitle = "Derived respiratory rate where available",
accent = Color(0xFF7AC7FF),
points = state.todayRespRateTrend,
yAxis = TrendYAxis(
min = 8f,
max = 30f,
ticks = listOf(30f, 24f, 18f, 12f, 8f),
suffix = "",
),
)
TrendCard(
title = "Skin Temperature Today",
subtitle = "Average of recorded temperature events",
accent = Color(0xFFFFA24C),
points = state.todayTempTrend,
yAxis = TrendYAxis(
min = 25f,
max = 40f,
ticks = listOf(40f, 35f, 30f, 25f),
suffix = "C",
),
)
}
}
Expand Down Expand Up @@ -1064,6 +1082,7 @@ private fun TrendCard(
subtitle: String,
accent: Color,
points: List<DashboardTrendPoint>,
yAxis: TrendYAxis? = null,
) {
Card(
modifier = Modifier.fillMaxWidth(),
Expand All @@ -1083,6 +1102,7 @@ private fun TrendCard(
LineTrendChart(
points = points,
accent = accent,
yAxis = yAxis,
modifier = Modifier
.fillMaxWidth()
.height(180.dp),
Expand Down Expand Up @@ -1498,56 +1518,99 @@ private fun CircularGauge(
private fun LineTrendChart(
points: List<DashboardTrendPoint>,
accent: Color,
yAxis: TrendYAxis? = null,
modifier: Modifier = Modifier,
) {
val validPoints = points.mapIndexedNotNull { index, point ->
point.value?.let { index to it }
}
val axisConfig = remember(points, yAxis) {
yAxis ?: validPoints
.takeIf { it.isNotEmpty() }
?.let { values ->
val min = values.minOf { it.second }
val max = values.maxOf { it.second }
val range = (max - min).takeIf { it > 0f } ?: 1f
TrendYAxis(
min = min,
max = max + if (max == min) range else 0f,
ticks = listOf(max, min + range / 2f, min),
suffix = "",
)
}
}
val axisLabelColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f)
val gridColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.10f)

Column(modifier = modifier) {
Canvas(
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
) {
val width = size.width
val height = size.height
val padding = 24.dp.toPx()
val usableWidth = (width - padding * 2).coerceAtLeast(1f)
val usableHeight = (height - padding * 2).coerceAtLeast(1f)

repeat(4) { line ->
val y = padding + usableHeight * (line / 3f)
drawLine(
color = Color.White.copy(alpha = 0.08f),
start = Offset(padding, y),
end = Offset(width - padding, y),
strokeWidth = 1.dp.toPx(),
)
if (axisConfig != null) {
Column(
modifier = Modifier
.width(42.dp)
.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween,
) {
axisConfig.ticks.forEach { tick ->
Text(
text = axisConfig.format(tick),
style = MaterialTheme.typography.labelSmall,
color = axisLabelColor,
textAlign = TextAlign.End,
modifier = Modifier
.fillMaxWidth(),
)
}
}
}

if (validPoints.size >= 2) {
val min = validPoints.minOf { it.second }
val max = validPoints.maxOf { it.second }
val range = (max - min).takeIf { it > 0f } ?: 1f
Canvas(
modifier = Modifier
.fillMaxSize(),
) {
val width = size.width
val height = size.height
val padding = 18.dp.toPx()
val usableWidth = (width - padding * 2).coerceAtLeast(1f)
val usableHeight = (height - padding * 2).coerceAtLeast(1f)
val axisMin = axisConfig?.min ?: validPoints.minOfOrNull { it.second } ?: 0f
val axisMax = axisConfig?.max ?: validPoints.maxOfOrNull { it.second } ?: 1f
val range = (axisMax - axisMin).takeIf { it > 0f } ?: 1f

val path = Path()
validPoints.forEachIndexed { idx, (index, value) ->
val x = padding + usableWidth * (index / (points.lastIndex.coerceAtLeast(1)).toFloat())
val y = height - padding - ((value - min) / range) * usableHeight
if (idx == 0) path.moveTo(x, y) else path.lineTo(x, y)
val tickValues = axisConfig?.ticks ?: listOf(axisMax, axisMin + range / 2f, axisMin)
tickValues.forEach { tick ->
val y = height - padding - ((tick - axisMin) / range).coerceIn(0f, 1f) * usableHeight
drawLine(
color = gridColor,
start = Offset(padding, y),
end = Offset(width - padding, y),
strokeWidth = 1.dp.toPx(),
)
}

drawPath(
path = path,
color = accent,
style = Stroke(width = 4.dp.toPx(), cap = StrokeCap.Round),
)
if (validPoints.size >= 2) {
val path = Path()
validPoints.forEachIndexed { idx, (index, value) ->
val x = padding + usableWidth * (index / (points.lastIndex.coerceAtLeast(1)).toFloat())
val y = height - padding - ((value - axisMin) / range).coerceIn(0f, 1f) * usableHeight
if (idx == 0) path.moveTo(x, y) else path.lineTo(x, y)
}

validPoints.forEach { (index, value) ->
val x = padding + usableWidth * (index / (points.lastIndex.coerceAtLeast(1)).toFloat())
val y = height - padding - ((value - min) / range) * usableHeight
drawCircle(color = accent, radius = 4.dp.toPx(), center = Offset(x, y))
drawPath(
path = path,
color = accent,
style = Stroke(width = 4.dp.toPx(), cap = StrokeCap.Round),
)

validPoints.forEach { (index, value) ->
val x = padding + usableWidth * (index / (points.lastIndex.coerceAtLeast(1)).toFloat())
val y = height - padding - ((value - axisMin) / range).coerceIn(0f, 1f) * usableHeight
drawCircle(color = accent, radius = 4.dp.toPx(), center = Offset(x, y))
}
}
}
}
Expand All @@ -1568,6 +1631,20 @@ private fun LineTrendChart(
}
}

private data class TrendYAxis(
val min: Float,
val max: Float,
val ticks: List<Float>,
val suffix: String,
) {
fun format(value: Float): String =
if (suffix.isBlank()) {
value.toInt().toString()
} else {
"${value.toInt()}$suffix"
}
}

private fun visibleAxisLabels(points: List<DashboardTrendPoint>, maxLabels: Int = 6): List<DashboardTrendPoint> {
if (points.isEmpty()) return emptyList()
if (points.size <= maxLabels) return points
Expand Down
29 changes: 23 additions & 6 deletions app/src/main/kotlin/com/sploot/app/ui/screen/DashboardViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -279,11 +279,20 @@ class DashboardViewModel @Inject constructor(
.atStartOfDay(zone)
.toEpochSecond()

val sessionsInRange = sleepRepo.getSessionsInRange(fromSeconds, nowSeconds)
val allSessionsInRange = sleepRepo.getSessionsInRange(fromSeconds, nowSeconds)
val sessionsInRange = allSessionsInRange
.filter { it.source == SleepSource.ALGO }
.filter { it.isPlausibleSleepSession() }
.sortedBy { it.startEpochSeconds }
val todaysSessions = sleepRepo.getSessionsInRange(startOfToday, nowSeconds)
val garminSessionsInRange = allSessionsInRange
.filter { it.source == SleepSource.GARMIN }
.filter { it.isPlausibleSleepSession() }
.sortedBy { it.startEpochSeconds }
val sleepDurationSessionsInRange = sessionsInRange.ifEmpty { garminSessionsInRange }
val allTodaysSessions = sleepRepo.getSessionsInRange(startOfToday, nowSeconds)
val todaysSessions = allTodaysSessions
.filter { it.source == SleepSource.ALGO }
.filter { it.isPlausibleSleepSession() }
.sortedBy { it.startEpochSeconds }
val latestAlgoSleep = sessionsInRange.lastOrNull()
val todaySleep = todaysSessions.lastOrNull()
Expand All @@ -301,10 +310,10 @@ class DashboardViewModel @Inject constructor(
}

val averageSleepScore = sessionsInRange.mapNotNull { it.totalScore }.averageNumberOrNull()
val averageSleepHours = sessionsInRange
val averageSleepHours = sleepDurationSessionsInRange
.map { (it.endEpochSeconds - it.startEpochSeconds).coerceAtLeast(0L) / 3600f }
.averageNumberOrNull()
val averageSleepEfficiency = sessionsInRange.mapNotNull { it.efficiencyPercent }.averageNumberOrNull()
val averageSleepEfficiency = sleepDurationSessionsInRange.mapNotNull { it.efficiencyPercent }.averageNumberOrNull()

val hrvWindows = sleepRepo.getHrvWindowsSince(fromSeconds)
val activities = activityRepo.getActivitiesInRange(fromSeconds, nowSeconds)
Expand Down Expand Up @@ -507,14 +516,14 @@ class DashboardViewModel @Inject constructor(
private fun buildIntradayTrend(
startOfToday: Long,
nowSeconds: Long,
bucketSeconds: Long = 15 * 60L,
bucketSeconds: Long = 5 * 60L,
valueForBucket: (Long, Long) -> Float?,
): List<DashboardTrendPoint> {
val bucketCount = ((24 * 3600L) / bucketSeconds).toInt()
return (0 until bucketCount).map { index ->
val bucketStart = startOfToday + index * bucketSeconds
val bucketEnd = bucketStart + bucketSeconds
val label = if (index % 8 == 0) {
val label = if (index % 12 == 0) {
Instant.ofEpochSecond(bucketStart)
.atZone(ZoneId.systemDefault())
.toLocalTime()
Expand All @@ -530,6 +539,14 @@ class DashboardViewModel @Inject constructor(
}
}

private fun com.sploot.domain.model.SleepSession.isPlausibleSleepSession(): Boolean {
val durationHours = (endEpochSeconds - startEpochSeconds).coerceAtLeast(0L) / 3600f
return durationHours in MIN_PLAUSIBLE_SLEEP_HOURS..MAX_PLAUSIBLE_SLEEP_HOURS
}

private const val MIN_PLAUSIBLE_SLEEP_HOURS = 2f
private const val MAX_PLAUSIBLE_SLEEP_HOURS = 14f

private fun estimateRedIrRatio(
red: IntArray,
infrared: IntArray,
Expand Down
Loading
Loading