From fba228f9304419ebee168b57a0b2c1a1bb7e7c65 Mon Sep 17 00:00:00 2001 From: Shreyash Pattewar <34227385+shreyashp47@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:33:47 +0530 Subject: [PATCH 1/9] Fix: capture surface color variable before Canvas scope --- .../shreyash/sensorapp/presentation/compass/CompassScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/compass/CompassScreen.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/compass/CompassScreen.kt index 18da049..f884ab4 100644 --- a/app/src/main/java/com/shreyash/sensorapp/presentation/compass/CompassScreen.kt +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/compass/CompassScreen.kt @@ -230,6 +230,7 @@ private fun CompassView( val outlineVariant = MaterialTheme.colorScheme.outlineVariant val onSurface = MaterialTheme.colorScheme.onSurface val primary = MaterialTheme.colorScheme.primary + val surface = MaterialTheme.colorScheme.surface Card( modifier = modifier, @@ -319,7 +320,7 @@ private fun CompassView( drawPath(bottomIndicator, color = Color(0xFF444444)) drawCircle( - color = MaterialTheme.colorScheme.surface, + color = surface, radius = 6.dp.toPx(), center = Offset(cx, cy) ) From bf79796b024374a7902dd526f0889546c1d4542b Mon Sep 17 00:00:00 2001 From: Shreyash Pattewar <34227385+shreyashp47@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:41:26 +0530 Subject: [PATCH 2/9] Polish compass UI: remove LIVE indicator and card background --- .../presentation/compass/CompassScreen.kt | 67 +------------------ 1 file changed, 1 insertion(+), 66 deletions(-) diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/compass/CompassScreen.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/compass/CompassScreen.kt index f884ab4..3d3c458 100644 --- a/app/src/main/java/com/shreyash/sensorapp/presentation/compass/CompassScreen.kt +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/compass/CompassScreen.kt @@ -1,30 +1,18 @@ package com.shreyash.sensorapp.presentation.compass import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -40,11 +28,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.platform.LocalLifecycleOwner @@ -57,7 +43,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.shreyash.sensorapp.presentation.theme.SensorAppTheme -import com.shreyash.sensorapp.presentation.theme.SensorGreen import kotlin.math.cos import kotlin.math.roundToInt import kotlin.math.sin @@ -133,10 +118,6 @@ private fun CompassScreenContent( .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - LiveIndicator() - - Spacer(Modifier.height(24.dp)) - CompassView( heading = heading, modifier = Modifier @@ -174,44 +155,6 @@ private fun CompassScreenContent( } } -@Composable -private fun LiveIndicator() { - val infiniteTransition = rememberInfiniteTransition() - val alpha by infiniteTransition.animateFloat( - initialValue = 1f, - targetValue = 0.3f, - animationSpec = infiniteRepeatable( - animation = tween(800), - repeatMode = RepeatMode.Reverse - ) - ) - Card( - shape = RoundedCornerShape(20.dp), - colors = CardDefaults.cardColors( - containerColor = SensorGreen.copy(alpha = 0.15f) - ) - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(10.dp) - .clip(CircleShape) - .background(SensorGreen.copy(alpha = alpha)) - ) - Spacer(Modifier.width(8.dp)) - Text( - text = "LIVE", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = SensorGreen - ) - } - } -} - @Composable private fun CompassView( heading: Float, @@ -232,15 +175,7 @@ private fun CompassView( val primary = MaterialTheme.colorScheme.primary val surface = MaterialTheme.colorScheme.surface - Card( - modifier = modifier, - shape = RoundedCornerShape(20.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) - ) { - Canvas(modifier = Modifier.fillMaxSize().padding(28.dp)) { + Canvas(modifier = modifier.padding(8.dp)) { val cx = size.width / 2f val cy = size.height / 2f val r = minOf(cx, cy) From 9b24099a972182abee43ad6d3829a7cc5aa4936b Mon Sep 17 00:00:00 2001 From: Shreyash Pattewar <34227385+shreyashp47@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:43:19 +0530 Subject: [PATCH 3/9] Add compass disclaimer at bottom of screen --- .../sensorapp/presentation/compass/CompassScreen.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/compass/CompassScreen.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/compass/CompassScreen.kt index 3d3c458..1960657 100644 --- a/app/src/main/java/com/shreyash/sensorapp/presentation/compass/CompassScreen.kt +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/compass/CompassScreen.kt @@ -151,6 +151,16 @@ private fun CompassScreenContent( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) + + Spacer(Modifier.weight(1f)) + + Text( + text = "Accuracy may be affected by nearby magnetic fields, electronic devices, or metal objects.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) } } } @@ -265,7 +275,6 @@ private fun CompassView( center = Offset(cx, cy) ) } - } } private fun directionFromHeading(heading: Float): String { From 4cab5bd065d508ca34e40aeba987f9da55082bb8 Mon Sep 17 00:00:00 2001 From: Shreyash Pattewar <34227385+shreyashp47@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:48:35 +0530 Subject: [PATCH 4/9] Fix: run lint and release on PR merge (closed + merged event) --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9104e95..c447dae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,7 +20,7 @@ permissions: jobs: lint: - if: github.event_name == 'workflow_dispatch' || github.event.action != 'closed' + if: github.event_name == 'workflow_dispatch' || github.event.action != 'closed' || github.event.pull_request.merged == true runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 From 204db5c91b117456909ed514d9ba6d3c914eece0 Mon Sep 17 00:00:00 2001 From: Shreyash Pattewar <34227385+shreyashp47@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:53:35 +0530 Subject: [PATCH 5/9] Polish proximity sensor: add NEAR/FAR status with visual bar indicator --- .../presentation/detail/SensorDetailScreen.kt | 85 ++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScreen.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScreen.kt index 9762f86..cf49376 100644 --- a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScreen.kt +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -291,7 +292,89 @@ private fun LiveValueDisplay( return } - if (sensorType.axisCount == 1) { + if (sensorType == SensorType.PROXIMITY) { + val value = reading.values.firstOrNull() ?: 0f + val maxRange = 8f + val fraction = (value / maxRange).coerceIn(0f, 1f) + val isNear = value < 1f + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ), + shape = RoundedCornerShape(24.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = if (isNear) "NEAR" else "FAR", + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.Bold, + color = if (isNear) Color(0xFFFF6B6B) else SensorGreen + ) + + Spacer(Modifier.height(4.dp)) + + Text( + text = "Object is ${if (isNear) "close to" else "away from"} the screen", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(Modifier.height(16.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(12.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { + Box( + modifier = Modifier + .fillMaxWidth(fraction) + .fillMaxHeight() + .clip(RoundedCornerShape(6.dp)) + .background( + if (isNear) Color(0xFFFF6B6B) + else SensorGreen + ) + ) + } + + Spacer(Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "0 cm", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "${maxRange.toInt()} cm", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(Modifier.height(12.dp)) + + Text( + text = "${formatLargeValue(value)} cm", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } else if (sensorType.axisCount == 1) { val value = reading.values.firstOrNull() ?: 0f Text( text = formatLargeValue(value), From 87e881c2a96a0f4687640803a525272270df60d1 Mon Sep 17 00:00:00 2001 From: Shreyash Pattewar <34227385+shreyashp47@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:55:24 +0530 Subject: [PATCH 6/9] Simplify proximity display to OBSTRUCTED/CLEAR with usage hint --- .../presentation/detail/SensorDetailScreen.kt | 80 +++++-------------- 1 file changed, 22 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScreen.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScreen.kt index cf49376..9b115eb 100644 --- a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScreen.kt +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScreen.kt @@ -236,6 +236,18 @@ private fun SensorDetailScreenContent( textAlign = TextAlign.Center ) + if (sensorType == SensorType.PROXIMITY) { + Spacer(Modifier.height(16.dp)) + + Text( + text = "Cover the top of the device to trigger the sensor. " + + "The proximity sensor is typically used to detect when the phone is held to the ear during a call.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + textAlign = TextAlign.Center + ) + } + Spacer(Modifier.height(16.dp)) } } @@ -294,9 +306,7 @@ private fun LiveValueDisplay( if (sensorType == SensorType.PROXIMITY) { val value = reading.values.firstOrNull() ?: 0f - val maxRange = 8f - val fraction = (value / maxRange).coerceIn(0f, 1f) - val isNear = value < 1f + val isObstructed = value < 1f Card( colors = CardDefaults.cardColors( @@ -307,70 +317,24 @@ private fun LiveValueDisplay( Column( modifier = Modifier .fillMaxWidth() - .padding(24.dp), + .padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = if (isNear) "NEAR" else "FAR", - style = MaterialTheme.typography.displayMedium, + text = if (isObstructed) "OBSTRUCTED" else "CLEAR", + style = MaterialTheme.typography.displayLarge, fontWeight = FontWeight.Bold, - color = if (isNear) Color(0xFFFF6B6B) else SensorGreen - ) - - Spacer(Modifier.height(4.dp)) - - Text( - text = "Object is ${if (isNear) "close to" else "away from"} the screen", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = if (isObstructed) Color(0xFFFF6B6B) else SensorGreen ) - Spacer(Modifier.height(16.dp)) - - Box( - modifier = Modifier - .fillMaxWidth() - .height(12.dp) - .clip(RoundedCornerShape(6.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) - ) { - Box( - modifier = Modifier - .fillMaxWidth(fraction) - .fillMaxHeight() - .clip(RoundedCornerShape(6.dp)) - .background( - if (isNear) Color(0xFFFF6B6B) - else SensorGreen - ) - ) - } - Spacer(Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "0 cm", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = "${maxRange.toInt()} cm", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - Spacer(Modifier.height(12.dp)) - Text( - text = "${formatLargeValue(value)} cm", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + text = if (isObstructed) "An object is covering the sensor" + else "No object detected near the sensor", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center ) } } From 3b55cb647aa065974872cae248925e72a5a6c53b Mon Sep 17 00:00:00 2001 From: Shreyash Pattewar <34227385+shreyashp47@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:59:38 +0530 Subject: [PATCH 7/9] Polish all sensor screens: light (brightness levels + bar), barometer (weather conditions), usage hints for all sensors --- .../presentation/detail/SensorDetailScreen.kt | 189 +++++++++++++++++- 1 file changed, 178 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScreen.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScreen.kt index 9b115eb..1398876 100644 --- a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScreen.kt +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScreen.kt @@ -236,22 +236,37 @@ private fun SensorDetailScreenContent( textAlign = TextAlign.Center ) - if (sensorType == SensorType.PROXIMITY) { - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(16.dp)) - Text( - text = "Cover the top of the device to trigger the sensor. " + - "The proximity sensor is typically used to detect when the phone is held to the ear during a call.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - textAlign = TextAlign.Center - ) - } + SensorUsageHint(sensorType = sensorType) Spacer(Modifier.height(16.dp)) } } +@Composable +private fun SensorUsageHint(sensorType: SensorType) { + val hint = when (sensorType) { + SensorType.ACCELEROMETER -> "Tilt or shake the device to see acceleration forces change across X, Y, and Z axes." + SensorType.GYROSCOPE -> "Rotate the device to measure the rate of rotation around each axis." + SensorType.LINEAR_ACCELERATION -> "Move the device quickly to measure acceleration excluding gravity." + SensorType.MAGNETOMETER -> "Move the device near a metal object or wave it in a figure-8 pattern to test." + SensorType.GRAVITY -> "Tilt the device to see how gravity distributes across each axis." + SensorType.ROTATION_VECTOR -> "Rotate the device to observe orientation changes relative to the world." + SensorType.LIGHT -> "Cover and uncover the light sensor (usually near the front camera) to see lux level changes." + SensorType.PROXIMITY -> "Cover the top of the device to trigger the sensor. Used to detect when the phone is held to the ear." + SensorType.PRESSURE -> "Ambient air pressure changes with altitude. Try moving to a different floor or elevation." + SensorType.STEP_COUNTER -> "Walk or simulate steps to see the step count increment in real time." + } + + Text( + text = hint, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + textAlign = TextAlign.Center + ) +} + @Composable private fun LiveIndicator() { val infiniteTransition = rememberInfiniteTransition() @@ -304,7 +319,159 @@ private fun LiveValueDisplay( return } - if (sensorType == SensorType.PROXIMITY) { + if (sensorType == SensorType.PRESSURE) { + val value = reading.values.firstOrNull() ?: 0f + val condition = when { + value < 980f -> "STORM" + value < 1010f -> "RAIN" + value < 1025f -> "NORMAL" + else -> "HIGH" + } + val conditionColor = when (condition) { + "STORM" -> Color(0xFFE53935) + "RAIN" -> Color(0xFF42A5F5) + "NORMAL" -> SensorGreen + else -> Color(0xFFFFB300) + } + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ), + shape = RoundedCornerShape(24.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = condition, + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = conditionColor + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = when (condition) { + "STORM" -> "Very low pressure — stormy weather likely" + "RAIN" -> "Low pressure — rain or unsettled weather" + "NORMAL" -> "Standard atmospheric pressure" + else -> "High pressure — fair and stable weather" + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(12.dp)) + + Text( + text = "${formatLargeValue(value)} hPa", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = "Sea level: 1013.25 hPa", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + } + } else if (sensorType == SensorType.LIGHT) { + val value = reading.values.firstOrNull() ?: 0f + val level = when { + value <= 1f -> "DARK" + value <= 50f -> "DIM" + value <= 500f -> "INDOOR" + value <= 10000f -> "OUTDOOR" + else -> "SUNLIGHT" + } + val levelColor = when (level) { + "DARK" -> Color(0xFF6B6B6B) + "DIM" -> Color(0xFF9E9E9E) + "INDOOR" -> Color(0xFFFFD54F) + "OUTDOOR" -> Color(0xFFFFB300) + else -> Color(0xFFFF6F00) + } + val fraction = (value / 50000f).coerceIn(0f, 1f) + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ), + shape = RoundedCornerShape(24.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = level, + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = levelColor + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = when (level) { + "DARK" -> "Very little or no light detected" + "DIM" -> "Low light conditions, like a dimly lit room" + "INDOOR" -> "Typical indoor lighting level" + "OUTDOOR" -> "Outdoor conditions, cloudy or shaded" + else -> "Bright direct sunlight" + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(16.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(12.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { + Box( + modifier = Modifier + .fillMaxWidth(fraction) + .fillMaxHeight() + .clip(RoundedCornerShape(6.dp)) + .background( + Brush.horizontalGradient(listOf( + Color(0xFF6B6B6B), + Color(0xFFFFD54F), + Color(0xFFFF6F00) + )) + ) + ) + } + + Spacer(Modifier.height(12.dp)) + + Text( + text = "${formatLargeValue(value)} lx", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } else if (sensorType == SensorType.PROXIMITY) { val value = reading.values.firstOrNull() ?: 0f val isObstructed = value < 1f From 56bc2c57f22fc494bb29c12aa91c7e1759a7b7e0 Mon Sep 17 00:00:00 2001 From: Shreyash Pattewar <34227385+shreyashp47@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:04:10 +0530 Subject: [PATCH 8/9] Add 3D rotating cube visualization for gyroscope sensor --- .../presentation/detail/SensorDetailScreen.kt | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScreen.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScreen.kt index 1398876..d49f034 100644 --- a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScreen.kt +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScreen.kt @@ -5,6 +5,7 @@ import android.content.Context import android.os.Build import android.os.Environment import android.provider.MediaStore +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable @@ -51,6 +52,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -91,6 +93,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.BufferedWriter import java.io.OutputStreamWriter +import kotlin.math.cos +import kotlin.math.sin @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -227,6 +231,18 @@ private fun SensorDetailScreenContent( Spacer(Modifier.height(12.dp)) + if (sensorType == SensorType.GYROSCOPE && currentReading != null) { + GyroscopeCube( + reading = currentReading, + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .padding(16.dp) + ) + + Spacer(Modifier.height(12.dp)) + } + Text( text = "X: ${formatDetailValue(currentReading?.values?.getOrNull(0))} ${sensorType.unitX}" + if (sensorType.axisCount >= 2) " Y: ${formatDetailValue(currentReading?.values?.getOrNull(1))} ${sensorType.unitY}" else "" + @@ -896,6 +912,111 @@ private fun PreviewSensorDetailScreen() { } } +@Composable +private fun GyroscopeCube( + reading: SensorReading, + modifier: Modifier = Modifier +) { + val gx = reading.values.getOrNull(0) ?: 0f + val gy = reading.values.getOrNull(1) ?: 0f + val gz = reading.values.getOrNull(2) ?: 0f + + val rotationX = remember { Animatable(0f) } + val rotationY = remember { Animatable(0f) } + val rotationZ = remember { Animatable(0f) } + + LaunchedEffect(gx, gy, gz) { + rotationX.animateTo(rotationX.value + gx * 0.05f, tween(100)) + rotationY.animateTo(rotationY.value + gy * 0.05f, tween(100)) + rotationZ.animateTo(rotationZ.value + gz * 0.05f, tween(100)) + } + + val primary = MaterialTheme.colorScheme.primary + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "3D Visualization", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp) + ) + + Canvas(modifier = Modifier.fillMaxSize()) { + val cx = size.width / 2f + val cy = size.height / 2f + val s = minOf(cx, cy) * 0.6f + + val rx = rotationX.value + val ry = rotationY.value + val rz = rotationZ.value + + val vertices = listOf( + floatArrayOf(-1f, -1f, -1f), floatArrayOf(1f, -1f, -1f), + floatArrayOf(1f, -1f, 1f), floatArrayOf(-1f, -1f, 1f), + floatArrayOf(-1f, 1f, -1f), floatArrayOf(1f, 1f, -1f), + floatArrayOf(1f, 1f, 1f), floatArrayOf(-1f, 1f, 1f) + ) + + val projected = vertices.map { v -> + var x = v[0]; var y = v[1]; var z = v[2] + + val rxRad = Math.toRadians(rx.toDouble()).toFloat() + val ryRad = Math.toRadians(ry.toDouble()).toFloat() + val rzRad = Math.toRadians(rz.toDouble()).toFloat() + + val cosX = cos(rxRad); val sinX = sin(rxRad) + val cosY = cos(ryRad); val sinY = sin(ryRad) + val cosZ = cos(rzRad); val sinZ = sin(rzRad) + + val y1 = y * cosX - z * sinX + val z1 = y * sinX + z * cosX + y = y1; z = z1 + + val x1 = x * cosY + z * sinY + val z2 = -x * sinY + z * cosY + x = x1; z = z2 + + val x2 = x * cosZ - y * sinZ + val y2 = x * sinZ + y * cosZ + x = x2; y = y2 + + Offset(cx + x * s, cy + y * s) + } + + val edges = listOf( + 0 to 1, 1 to 2, 2 to 3, 3 to 0, + 4 to 5, 5 to 6, 6 to 7, 7 to 4, + 0 to 4, 1 to 5, 2 to 6, 3 to 7 + ) + + for ((i, j) in edges) { + val zAvg = (listOf(vertices[i][2], vertices[j][2]).average()).toFloat() + val alpha = ((zAvg + 1f) / 2f).coerceIn(0.3f, 1f) + drawLine( + color = primary.copy(alpha = alpha), + start = projected[i], + end = projected[j], + strokeWidth = 2.dp.toPx() + ) + } + + for (v in projected) { + drawCircle(color = primary, radius = 3.dp.toPx(), center = v) + } + } + } + } +} + private fun formatLargeValue(value: Float): String { return String.format("%.1f", value) } From ed7a3c7f71be3207902af129055238de783ec8d6 Mon Sep 17 00:00:00 2001 From: Shreyash Pattewar <34227385+shreyashp47@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:14:41 +0530 Subject: [PATCH 9/9] Refactor: per-sensor screens, dashboard lag fix, scaffold padding fix - Separate detail screens for all 10 sensors (AccelerometerScreen, GyroscopeScreen, LightScreen, PressureScreen, etc.) with dedicated UIs - Shared SensorDetailScaffold for toolbar, lifecycle, logging & CSV export - Individual sensor routes in Routes.kt + SensorType.toRoute() helper - DashboardViewModel: removed real-time sensor observation (caused lag) - SensorDetailScaffold: apply Scaffold content padding so data isn't hidden by toolbar - Delete monolithic SensorDetailScreen.kt (1079 lines) --- .../dashboard/DashboardViewModel.kt | 31 +- .../detail/AccelerometerScreen.kt | 77 ++ .../presentation/detail/GravityScreen.kt | 77 ++ .../presentation/detail/GyroscopeCube.kt | 130 ++ .../presentation/detail/GyroscopeScreen.kt | 86 ++ .../presentation/detail/LightScreen.kt | 63 + .../detail/LinearAccelerationScreen.kt | 77 ++ .../presentation/detail/MagnetometerScreen.kt | 77 ++ .../presentation/detail/PressureScreen.kt | 63 + .../presentation/detail/ProximityScreen.kt | 63 + .../detail/RotationVectorScreen.kt | 77 ++ .../presentation/detail/SensorChart.kt | 230 ++++ .../detail/SensorDetailScaffold.kt | 200 +++ .../presentation/detail/SensorDetailScreen.kt | 1079 ----------------- .../presentation/detail/SensorDisplayCard.kt | 317 +++++ .../presentation/detail/SensorUtils.kt | 100 ++ .../presentation/detail/StepCounterScreen.kt | 63 + .../presentation/navigation/AppNavGraph.kt | 66 +- .../presentation/navigation/Routes.kt | 29 +- 19 files changed, 1773 insertions(+), 1132 deletions(-) create mode 100644 app/src/main/java/com/shreyash/sensorapp/presentation/detail/AccelerometerScreen.kt create mode 100644 app/src/main/java/com/shreyash/sensorapp/presentation/detail/GravityScreen.kt create mode 100644 app/src/main/java/com/shreyash/sensorapp/presentation/detail/GyroscopeCube.kt create mode 100644 app/src/main/java/com/shreyash/sensorapp/presentation/detail/GyroscopeScreen.kt create mode 100644 app/src/main/java/com/shreyash/sensorapp/presentation/detail/LightScreen.kt create mode 100644 app/src/main/java/com/shreyash/sensorapp/presentation/detail/LinearAccelerationScreen.kt create mode 100644 app/src/main/java/com/shreyash/sensorapp/presentation/detail/MagnetometerScreen.kt create mode 100644 app/src/main/java/com/shreyash/sensorapp/presentation/detail/PressureScreen.kt create mode 100644 app/src/main/java/com/shreyash/sensorapp/presentation/detail/ProximityScreen.kt create mode 100644 app/src/main/java/com/shreyash/sensorapp/presentation/detail/RotationVectorScreen.kt create mode 100644 app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorChart.kt create mode 100644 app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScaffold.kt delete mode 100644 app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScreen.kt create mode 100644 app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDisplayCard.kt create mode 100644 app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorUtils.kt create mode 100644 app/src/main/java/com/shreyash/sensorapp/presentation/detail/StepCounterScreen.kt diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/dashboard/DashboardViewModel.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/dashboard/DashboardViewModel.kt index bcc414c..2dab4fe 100644 --- a/app/src/main/java/com/shreyash/sensorapp/presentation/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/dashboard/DashboardViewModel.kt @@ -3,32 +3,25 @@ package com.shreyash.sensorapp.presentation.dashboard import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.shreyash.sensorapp.domain.model.SensorAvailability -import com.shreyash.sensorapp.domain.model.SensorReading import com.shreyash.sensorapp.domain.model.SensorState import com.shreyash.sensorapp.domain.model.SensorType import com.shreyash.sensorapp.domain.usecase.GetSensorAvailabilityUseCase -import com.shreyash.sensorapp.domain.usecase.ObserveSensorUseCase import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class DashboardViewModel @Inject constructor( - private val getSensorAvailabilityUseCase: GetSensorAvailabilityUseCase, - private val observeSensorUseCase: ObserveSensorUseCase + private val getSensorAvailabilityUseCase: GetSensorAvailabilityUseCase ) : ViewModel() { private val _sensorStates = MutableStateFlow>(emptyList()) val sensorStates: StateFlow> = _sensorStates.asStateFlow() - private val activeJobs = mutableMapOf() - init { viewModelScope.launch { val sensorTypes = SensorType.entries @@ -40,28 +33,6 @@ class DashboardViewModel @Inject constructor( ) } _sensorStates.value = states - states.filter { it.availability is SensorAvailability.Available } - .forEach { observeSensor(it.type) } } } - - private fun observeSensor(sensorType: SensorType) { - activeJobs[sensorType] = viewModelScope.launch { - observeSensorUseCase(sensorType).collect { reading: SensorReading -> - _sensorStates.update { states -> - states.map { state -> - if (state.type == sensorType) { - state.copy(latestReading = reading) - } else state - } - } - } - } - } - - override fun onCleared() { - activeJobs.values.forEach { it.cancel() } - activeJobs.clear() - super.onCleared() - } } diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/AccelerometerScreen.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/AccelerometerScreen.kt new file mode 100644 index 0000000..f0ba780 --- /dev/null +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/AccelerometerScreen.kt @@ -0,0 +1,77 @@ +package com.shreyash.sensorapp.presentation.detail + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.shreyash.sensorapp.domain.model.SensorReading +import com.shreyash.sensorapp.domain.model.SensorType + +@Composable +fun AccelerometerScreen( + onBack: () -> Unit, + sensorType: SensorType = SensorType.ACCELEROMETER +) { + SensorDetailScaffold(sensorType = sensorType, onBack = onBack) { currentReading, chartReadings -> + AccelerometerContent(currentReading = currentReading, chartReadings = chartReadings) + } +} + +@Composable +private fun AccelerometerContent( + currentReading: SensorReading?, + chartReadings: List +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + LiveIndicator() + + Spacer(Modifier.height(16.dp)) + + LiveValueDisplay( + reading = currentReading, + sensorType = SensorType.ACCELEROMETER + ) + + Spacer(Modifier.height(24.dp)) + + LiveLineChart( + readings = chartReadings, + sensorType = SensorType.ACCELEROMETER, + modifier = Modifier.fillMaxWidth().height(260.dp) + ) + + Spacer(Modifier.height(12.dp)) + + Text( + text = "X: ${formatDetailValue(currentReading?.values?.getOrNull(0))} ${SensorType.ACCELEROMETER.unitX}" + + " Y: ${formatDetailValue(currentReading?.values?.getOrNull(1))} ${SensorType.ACCELEROMETER.unitY}" + + " Z: ${formatDetailValue(currentReading?.values?.getOrNull(2))} ${SensorType.ACCELEROMETER.unitZ}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(16.dp)) + + SensorUsageHint(sensorType = SensorType.ACCELEROMETER) + + Spacer(Modifier.height(16.dp)) + } +} diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/GravityScreen.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/GravityScreen.kt new file mode 100644 index 0000000..395eed5 --- /dev/null +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/GravityScreen.kt @@ -0,0 +1,77 @@ +package com.shreyash.sensorapp.presentation.detail + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.shreyash.sensorapp.domain.model.SensorReading +import com.shreyash.sensorapp.domain.model.SensorType + +@Composable +fun GravityScreen( + onBack: () -> Unit, + sensorType: SensorType = SensorType.GRAVITY +) { + SensorDetailScaffold(sensorType = sensorType, onBack = onBack) { currentReading, chartReadings -> + GravityContent(currentReading = currentReading, chartReadings = chartReadings) + } +} + +@Composable +private fun GravityContent( + currentReading: SensorReading?, + chartReadings: List +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + LiveIndicator() + + Spacer(Modifier.height(16.dp)) + + LiveValueDisplay( + reading = currentReading, + sensorType = SensorType.GRAVITY + ) + + Spacer(Modifier.height(24.dp)) + + LiveLineChart( + readings = chartReadings, + sensorType = SensorType.GRAVITY, + modifier = Modifier.fillMaxWidth().height(260.dp) + ) + + Spacer(Modifier.height(12.dp)) + + Text( + text = "X: ${formatDetailValue(currentReading?.values?.getOrNull(0))} ${SensorType.GRAVITY.unitX}" + + " Y: ${formatDetailValue(currentReading?.values?.getOrNull(1))} ${SensorType.GRAVITY.unitY}" + + " Z: ${formatDetailValue(currentReading?.values?.getOrNull(2))} ${SensorType.GRAVITY.unitZ}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(16.dp)) + + SensorUsageHint(sensorType = SensorType.GRAVITY) + + Spacer(Modifier.height(16.dp)) + } +} diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/GyroscopeCube.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/GyroscopeCube.kt new file mode 100644 index 0000000..95b6bc9 --- /dev/null +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/GyroscopeCube.kt @@ -0,0 +1,130 @@ +package com.shreyash.sensorapp.presentation.detail + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.shreyash.sensorapp.domain.model.SensorReading +import kotlin.math.cos +import kotlin.math.sin + +@Composable +fun GyroscopeCube( + reading: SensorReading, + modifier: Modifier = Modifier +) { + val gx = reading.values.getOrNull(0) ?: 0f + val gy = reading.values.getOrNull(1) ?: 0f + val gz = reading.values.getOrNull(2) ?: 0f + + val rotationX = remember { Animatable(0f) } + val rotationY = remember { Animatable(0f) } + val rotationZ = remember { Animatable(0f) } + + LaunchedEffect(gx, gy, gz) { + rotationX.animateTo(rotationX.value + gx * 0.05f, tween(100)) + rotationY.animateTo(rotationY.value + gy * 0.05f, tween(100)) + rotationZ.animateTo(rotationZ.value + gz * 0.05f, tween(100)) + } + + val primary = MaterialTheme.colorScheme.primary + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "3D Visualization", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp) + ) + + Canvas(modifier = Modifier.fillMaxSize().padding(bottom = 8.dp)) { + val cx = size.width / 2f + val cy = size.height / 2f + val s = minOf(cx, cy) * 0.6f + + val rx = rotationX.value + val ry = rotationY.value + val rz = rotationZ.value + + val vertices = listOf( + floatArrayOf(-1f, -1f, -1f), floatArrayOf(1f, -1f, -1f), + floatArrayOf(1f, -1f, 1f), floatArrayOf(-1f, -1f, 1f), + floatArrayOf(-1f, 1f, -1f), floatArrayOf(1f, 1f, -1f), + floatArrayOf(1f, 1f, 1f), floatArrayOf(-1f, 1f, 1f) + ) + + val projected = vertices.map { v -> + var x = v[0]; var y = v[1]; var z = v[2] + + val rxRad = Math.toRadians(rx.toDouble()).toFloat() + val ryRad = Math.toRadians(ry.toDouble()).toFloat() + val rzRad = Math.toRadians(rz.toDouble()).toFloat() + + val cosX = cos(rxRad); val sinX = sin(rxRad) + val cosY = cos(ryRad); val sinY = sin(ryRad) + val cosZ = cos(rzRad); val sinZ = sin(rzRad) + + val y1 = y * cosX - z * sinX + val z1 = y * sinX + z * cosX + y = y1; z = z1 + + val x1 = x * cosY + z * sinY + z = -x * sinY + z * cosY + x = x1 + + val xRot = x * cosZ - y * sinZ + val yRot = x * sinZ + y * cosZ + x = xRot; y = yRot + + Offset(cx + x * s, cy + y * s) + } + + val edges = listOf( + 0 to 1, 1 to 2, 2 to 3, 3 to 0, + 4 to 5, 5 to 6, 6 to 7, 7 to 4, + 0 to 4, 1 to 5, 2 to 6, 3 to 7 + ) + + for ((i, j) in edges) { + val zAvg = (listOf(vertices[i][2], vertices[j][2]).average()).toFloat() + val alpha = ((zAvg + 1f) / 2f).coerceIn(0.3f, 1f) + drawLine( + color = primary.copy(alpha = alpha), + start = projected[i], + end = projected[j], + strokeWidth = 2.dp.toPx() + ) + } + + for (v in projected) { + drawCircle(color = primary, radius = 3.dp.toPx(), center = v) + } + } + } + } +} diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/GyroscopeScreen.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/GyroscopeScreen.kt new file mode 100644 index 0000000..ca24674 --- /dev/null +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/GyroscopeScreen.kt @@ -0,0 +1,86 @@ +package com.shreyash.sensorapp.presentation.detail + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.shreyash.sensorapp.domain.model.SensorReading +import com.shreyash.sensorapp.domain.model.SensorType + +@Composable +fun GyroscopeScreen( + onBack: () -> Unit, + sensorType: SensorType = SensorType.GYROSCOPE +) { + SensorDetailScaffold(sensorType = sensorType, onBack = onBack) { currentReading, chartReadings -> + GyroscopeContent(currentReading = currentReading, chartReadings = chartReadings) + } +} + +@Composable +private fun GyroscopeContent( + currentReading: SensorReading?, + chartReadings: List +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + LiveIndicator() + + Spacer(Modifier.height(16.dp)) + + LiveValueDisplay( + reading = currentReading, + sensorType = SensorType.GYROSCOPE + ) + + Spacer(Modifier.height(24.dp)) + + LiveLineChart( + readings = chartReadings, + sensorType = SensorType.GYROSCOPE, + modifier = Modifier.fillMaxWidth().height(260.dp) + ) + + Spacer(Modifier.height(12.dp)) + + if (currentReading != null) { + GyroscopeCube( + reading = currentReading, + modifier = Modifier.fillMaxWidth().height(200.dp).padding(16.dp) + ) + + Spacer(Modifier.height(12.dp)) + } + + Text( + text = "X: ${formatDetailValue(currentReading?.values?.getOrNull(0))} ${SensorType.GYROSCOPE.unitX}" + + " Y: ${formatDetailValue(currentReading?.values?.getOrNull(1))} ${SensorType.GYROSCOPE.unitY}" + + " Z: ${formatDetailValue(currentReading?.values?.getOrNull(2))} ${SensorType.GYROSCOPE.unitZ}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(16.dp)) + + SensorUsageHint(sensorType = SensorType.GYROSCOPE) + + Spacer(Modifier.height(16.dp)) + } +} diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/LightScreen.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/LightScreen.kt new file mode 100644 index 0000000..81def46 --- /dev/null +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/LightScreen.kt @@ -0,0 +1,63 @@ +package com.shreyash.sensorapp.presentation.detail + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.shreyash.sensorapp.domain.model.SensorReading +import com.shreyash.sensorapp.domain.model.SensorType + +@Composable +fun LightScreen( + onBack: () -> Unit, + sensorType: SensorType = SensorType.LIGHT +) { + SensorDetailScaffold(sensorType = sensorType, onBack = onBack) { currentReading, chartReadings -> + LightContent(currentReading = currentReading, chartReadings = chartReadings) + } +} + +@Composable +private fun LightContent( + currentReading: SensorReading?, + chartReadings: List +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + LiveIndicator() + + Spacer(Modifier.height(16.dp)) + + LiveValueDisplay( + reading = currentReading, + sensorType = SensorType.LIGHT + ) + + Spacer(Modifier.height(24.dp)) + + LiveLineChart( + readings = chartReadings, + sensorType = SensorType.LIGHT, + modifier = Modifier.fillMaxWidth().height(260.dp) + ) + + Spacer(Modifier.height(16.dp)) + + SensorUsageHint(sensorType = SensorType.LIGHT) + + Spacer(Modifier.height(16.dp)) + } +} diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/LinearAccelerationScreen.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/LinearAccelerationScreen.kt new file mode 100644 index 0000000..a3edd9d --- /dev/null +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/LinearAccelerationScreen.kt @@ -0,0 +1,77 @@ +package com.shreyash.sensorapp.presentation.detail + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.shreyash.sensorapp.domain.model.SensorReading +import com.shreyash.sensorapp.domain.model.SensorType + +@Composable +fun LinearAccelerationScreen( + onBack: () -> Unit, + sensorType: SensorType = SensorType.LINEAR_ACCELERATION +) { + SensorDetailScaffold(sensorType = sensorType, onBack = onBack) { currentReading, chartReadings -> + LinearAccelerationContent(currentReading = currentReading, chartReadings = chartReadings) + } +} + +@Composable +private fun LinearAccelerationContent( + currentReading: SensorReading?, + chartReadings: List +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + LiveIndicator() + + Spacer(Modifier.height(16.dp)) + + LiveValueDisplay( + reading = currentReading, + sensorType = SensorType.LINEAR_ACCELERATION + ) + + Spacer(Modifier.height(24.dp)) + + LiveLineChart( + readings = chartReadings, + sensorType = SensorType.LINEAR_ACCELERATION, + modifier = Modifier.fillMaxWidth().height(260.dp) + ) + + Spacer(Modifier.height(12.dp)) + + Text( + text = "X: ${formatDetailValue(currentReading?.values?.getOrNull(0))} ${SensorType.LINEAR_ACCELERATION.unitX}" + + " Y: ${formatDetailValue(currentReading?.values?.getOrNull(1))} ${SensorType.LINEAR_ACCELERATION.unitY}" + + " Z: ${formatDetailValue(currentReading?.values?.getOrNull(2))} ${SensorType.LINEAR_ACCELERATION.unitZ}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(16.dp)) + + SensorUsageHint(sensorType = SensorType.LINEAR_ACCELERATION) + + Spacer(Modifier.height(16.dp)) + } +} diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/MagnetometerScreen.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/MagnetometerScreen.kt new file mode 100644 index 0000000..9893744 --- /dev/null +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/MagnetometerScreen.kt @@ -0,0 +1,77 @@ +package com.shreyash.sensorapp.presentation.detail + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.shreyash.sensorapp.domain.model.SensorReading +import com.shreyash.sensorapp.domain.model.SensorType + +@Composable +fun MagnetometerScreen( + onBack: () -> Unit, + sensorType: SensorType = SensorType.MAGNETOMETER +) { + SensorDetailScaffold(sensorType = sensorType, onBack = onBack) { currentReading, chartReadings -> + MagnetometerContent(currentReading = currentReading, chartReadings = chartReadings) + } +} + +@Composable +private fun MagnetometerContent( + currentReading: SensorReading?, + chartReadings: List +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + LiveIndicator() + + Spacer(Modifier.height(16.dp)) + + LiveValueDisplay( + reading = currentReading, + sensorType = SensorType.MAGNETOMETER + ) + + Spacer(Modifier.height(24.dp)) + + LiveLineChart( + readings = chartReadings, + sensorType = SensorType.MAGNETOMETER, + modifier = Modifier.fillMaxWidth().height(260.dp) + ) + + Spacer(Modifier.height(12.dp)) + + Text( + text = "X: ${formatDetailValue(currentReading?.values?.getOrNull(0))} ${SensorType.MAGNETOMETER.unitX}" + + " Y: ${formatDetailValue(currentReading?.values?.getOrNull(1))} ${SensorType.MAGNETOMETER.unitY}" + + " Z: ${formatDetailValue(currentReading?.values?.getOrNull(2))} ${SensorType.MAGNETOMETER.unitZ}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(16.dp)) + + SensorUsageHint(sensorType = SensorType.MAGNETOMETER) + + Spacer(Modifier.height(16.dp)) + } +} diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/PressureScreen.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/PressureScreen.kt new file mode 100644 index 0000000..9ed201c --- /dev/null +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/PressureScreen.kt @@ -0,0 +1,63 @@ +package com.shreyash.sensorapp.presentation.detail + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.shreyash.sensorapp.domain.model.SensorReading +import com.shreyash.sensorapp.domain.model.SensorType + +@Composable +fun PressureScreen( + onBack: () -> Unit, + sensorType: SensorType = SensorType.PRESSURE +) { + SensorDetailScaffold(sensorType = sensorType, onBack = onBack) { currentReading, chartReadings -> + PressureContent(currentReading = currentReading, chartReadings = chartReadings) + } +} + +@Composable +private fun PressureContent( + currentReading: SensorReading?, + chartReadings: List +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + LiveIndicator() + + Spacer(Modifier.height(16.dp)) + + LiveValueDisplay( + reading = currentReading, + sensorType = SensorType.PRESSURE + ) + + Spacer(Modifier.height(24.dp)) + + LiveLineChart( + readings = chartReadings, + sensorType = SensorType.PRESSURE, + modifier = Modifier.fillMaxWidth().height(260.dp) + ) + + Spacer(Modifier.height(16.dp)) + + SensorUsageHint(sensorType = SensorType.PRESSURE) + + Spacer(Modifier.height(16.dp)) + } +} diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/ProximityScreen.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/ProximityScreen.kt new file mode 100644 index 0000000..18a0ab6 --- /dev/null +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/ProximityScreen.kt @@ -0,0 +1,63 @@ +package com.shreyash.sensorapp.presentation.detail + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.shreyash.sensorapp.domain.model.SensorReading +import com.shreyash.sensorapp.domain.model.SensorType + +@Composable +fun ProximityScreen( + onBack: () -> Unit, + sensorType: SensorType = SensorType.PROXIMITY +) { + SensorDetailScaffold(sensorType = sensorType, onBack = onBack) { currentReading, chartReadings -> + ProximityContent(currentReading = currentReading, chartReadings = chartReadings) + } +} + +@Composable +private fun ProximityContent( + currentReading: SensorReading?, + chartReadings: List +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + LiveIndicator() + + Spacer(Modifier.height(16.dp)) + + LiveValueDisplay( + reading = currentReading, + sensorType = SensorType.PROXIMITY + ) + + Spacer(Modifier.height(24.dp)) + + LiveLineChart( + readings = chartReadings, + sensorType = SensorType.PROXIMITY, + modifier = Modifier.fillMaxWidth().height(260.dp) + ) + + Spacer(Modifier.height(16.dp)) + + SensorUsageHint(sensorType = SensorType.PROXIMITY) + + Spacer(Modifier.height(16.dp)) + } +} diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/RotationVectorScreen.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/RotationVectorScreen.kt new file mode 100644 index 0000000..872ad54 --- /dev/null +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/RotationVectorScreen.kt @@ -0,0 +1,77 @@ +package com.shreyash.sensorapp.presentation.detail + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.shreyash.sensorapp.domain.model.SensorReading +import com.shreyash.sensorapp.domain.model.SensorType + +@Composable +fun RotationVectorScreen( + onBack: () -> Unit, + sensorType: SensorType = SensorType.ROTATION_VECTOR +) { + SensorDetailScaffold(sensorType = sensorType, onBack = onBack) { currentReading, chartReadings -> + RotationVectorContent(currentReading = currentReading, chartReadings = chartReadings) + } +} + +@Composable +private fun RotationVectorContent( + currentReading: SensorReading?, + chartReadings: List +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + LiveIndicator() + + Spacer(Modifier.height(16.dp)) + + LiveValueDisplay( + reading = currentReading, + sensorType = SensorType.ROTATION_VECTOR + ) + + Spacer(Modifier.height(24.dp)) + + LiveLineChart( + readings = chartReadings, + sensorType = SensorType.ROTATION_VECTOR, + modifier = Modifier.fillMaxWidth().height(260.dp) + ) + + Spacer(Modifier.height(12.dp)) + + Text( + text = "X: ${formatDetailValue(currentReading?.values?.getOrNull(0))} ${SensorType.ROTATION_VECTOR.unitX}" + + " Y: ${formatDetailValue(currentReading?.values?.getOrNull(1))} ${SensorType.ROTATION_VECTOR.unitY}" + + " Z: ${formatDetailValue(currentReading?.values?.getOrNull(2))} ${SensorType.ROTATION_VECTOR.unitZ}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(16.dp)) + + SensorUsageHint(sensorType = SensorType.ROTATION_VECTOR) + + Spacer(Modifier.height(16.dp)) + } +} diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorChart.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorChart.kt new file mode 100644 index 0000000..3e34ebe --- /dev/null +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorChart.kt @@ -0,0 +1,230 @@ +package com.shreyash.sensorapp.presentation.detail + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import com.shreyash.sensorapp.domain.model.SensorReading +import com.shreyash.sensorapp.domain.model.SensorType + +@Composable +fun LiveLineChart( + readings: List, + @Suppress("UNUSED_PARAMETER") sensorType: SensorType, + modifier: Modifier = Modifier +) { + if (readings.isEmpty()) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Text( + text = "No data yet", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + return + } + + val axisIndex = 0 + val values = readings.map { it.values.getOrElse(axisIndex) { 0f } } + val lineColor = MaterialTheme.colorScheme.primary + val fillColor = MaterialTheme.colorScheme.primary + val gridColor = MaterialTheme.colorScheme.outlineVariant + val crosshairColor = MaterialTheme.colorScheme.tertiary + + var touchIndex by remember { mutableStateOf(-1) } + + Card( + modifier = modifier, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Canvas( + modifier = Modifier + .fillMaxSize() + .padding(start = 8.dp, top = 24.dp, end = 8.dp, bottom = 24.dp) + .pointerInput(values) { + detectTapGestures { offset -> + if (values.size < 2) return@detectTapGestures + val stepX = size.width.toFloat() / (values.size - 1) + val index = ((offset.x / stepX) + 0.5f).toInt() + .coerceIn(0, values.size - 1) + touchIndex = if (touchIndex == index) -1 else index + } + } + ) { + if (values.size < 2) return@Canvas + + val minVal = values.min() + val maxVal = values.max() + val range = if (maxVal - minVal == 0f) 1f else maxVal - minVal + val paddingV = 0.1f + val adjustedMin = minVal - range * paddingV + val adjustedMax = maxVal + range * paddingV + val adjustedRange = adjustedMax - adjustedMin + + val stepX = size.width / (values.size - 1).coerceAtLeast(1) + + fun yForValue(value: Float): Float { + val normY = ((value - adjustedMin) / adjustedRange).coerceIn(0f, 1f) + return size.height - normY * size.height + } + + for (i in 0 until values.size) { + val x = i * stepX + drawLine( + color = gridColor, + start = Offset(x, 0f), + end = Offset(x, size.height), + strokeWidth = 0.5f + ) + } + + val linePath = Path() + val fillPath = Path() + values.forEachIndexed { index, value -> + val x = index * stepX + val y = yForValue(value) + if (index == 0) { + linePath.moveTo(x, y) + fillPath.moveTo(x, size.height) + fillPath.lineTo(x, y) + } else { + linePath.lineTo(x, y) + fillPath.lineTo(x, y) + } + } + fillPath.lineTo((values.size - 1) * stepX, size.height) + fillPath.close() + + drawPath( + path = fillPath, + brush = Brush.verticalGradient( + colors = listOf(fillColor.copy(alpha = 0.2f), fillColor.copy(alpha = 0.02f)), + endY = size.height + ) + ) + + drawPath( + path = linePath, + color = lineColor, + style = Stroke( + width = 2.5.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round + ) + ) + + val labelPaint = android.graphics.Paint().apply { + color = android.graphics.Color.GRAY + textSize = 22f + textAlign = android.graphics.Paint.Align.RIGHT + isAntiAlias = true + } + + drawContext.canvas.nativeCanvas.drawText( + formatLargeValue(adjustedMax), + -4.dp.toPx(), + 0f, + labelPaint + ) + + drawContext.canvas.nativeCanvas.drawText( + formatLargeValue(adjustedMin), + -4.dp.toPx(), + size.height, + labelPaint + ) + + if (touchIndex in values.indices && values.size >= 2) { + val x = touchIndex * stepX + val value = values[touchIndex] + val y = yForValue(value) + val point = Offset(x, y) + + drawLine( + color = crosshairColor, + start = Offset(x, 0f), + end = Offset(x, size.height), + strokeWidth = 1.5f + ) + + drawCircle( + color = crosshairColor, + radius = 5.dp.toPx(), + center = point + ) + drawCircle( + color = crosshairColor.copy(alpha = 0.3f), + radius = 10.dp.toPx(), + center = point + ) + + drawCrosshairTooltip( + value = value, + x = x, + y = y + ) + } + } + } +} + +private fun DrawScope.drawCrosshairTooltip(value: Float, x: Float, y: Float) { + val text = formatLargeValue(value) + val paint = android.graphics.Paint().apply { + color = android.graphics.Color.WHITE + textSize = 28f + textAlign = android.graphics.Paint.Align.CENTER + isAntiAlias = true + isFakeBoldText = true + } + + val textWidth = paint.measureText(text) + val padding = 16f + val tooltipHeight = paint.textSize + padding * 2 + val rectWidth = textWidth + padding * 2 + val rectLeft = (x - rectWidth / 2).coerceIn(0f, size.width - rectWidth) + val rectTop = (y - tooltipHeight - 16.dp.toPx()).coerceAtLeast(0f) + drawRoundRect( + color = Color(50, 50, 55, 230), + topLeft = Offset(rectLeft, rectTop), + size = Size(rectWidth, tooltipHeight), + cornerRadius = CornerRadius(10f, 10f) + ) + + drawContext.canvas.nativeCanvas.drawText( + text, + x, + rectTop + padding + paint.textSize / 2 + 2f, + paint + ) +} diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScaffold.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScaffold.kt new file mode 100644 index 0000000..37c4d85 --- /dev/null +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScaffold.kt @@ -0,0 +1,200 @@ +package com.shreyash.sensorapp.presentation.detail + +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.shreyash.sensorapp.domain.model.SensorReading +import com.shreyash.sensorapp.domain.model.SensorType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.BufferedWriter +import java.io.OutputStreamWriter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SensorDetailScaffold( + sensorType: SensorType, + onBack: () -> Unit, + viewModel: DetailViewModel = hiltViewModel(), + content: @Composable (currentReading: SensorReading?, chartReadings: List) -> Unit +) { + val currentReading by viewModel.currentReading.collectAsStateWithLifecycle() + val isLogging by viewModel.isLogging.collectAsStateWithLifecycle() + val chartReadings by viewModel.chartReadings.collectAsStateWithLifecycle() + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> viewModel.startObserving() + Lifecycle.Event.ON_STOP -> viewModel.stopObserving() + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text(sensorType.displayName) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + bottomBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = { viewModel.toggleLogging() }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = if (isLogging) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary + ) + ) { + Icon( + imageVector = if (isLogging) Icons.Default.Stop else Icons.Default.PlayArrow, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text(if (isLogging) "Stop Logging" else "Start Logging") + } + + Button( + onClick = { + scope.launch { + val uri = exportToCsv(context, chartReadings, sensorType) + if (uri != null) { + snackbarHostState.showSnackbar("CSV exported to Downloads") + } + } + }, + modifier = Modifier.weight(1f), + enabled = chartReadings.isNotEmpty() + ) { + Icon( + imageVector = Icons.Default.Download, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text("Export CSV") + } + } + } + ) { padding -> + Box(modifier = Modifier.padding(padding)) { + content(currentReading, chartReadings) + } + } +} + +private suspend fun exportToCsv( + context: Context, + readings: List, + sensorType: SensorType +): android.net.Uri? = withContext(Dispatchers.IO) { + try { + val fileName = "${sensorType.name}_${System.currentTimeMillis()}.csv" + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val contentValues = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, fileName) + put(MediaStore.Downloads.MIME_TYPE, "text/csv") + put(MediaStore.Downloads.IS_PENDING, 1) + } + + val uri = context.contentResolver.insert( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues + ) ?: return@withContext null + + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + writeCsv(outputStream, readings) + } + + contentValues.clear() + contentValues.put(MediaStore.Downloads.IS_PENDING, 0) + context.contentResolver.update(uri, contentValues, null, null) + uri + } else { + val dir = Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS + ) + dir.mkdirs() + val file = java.io.File(dir, fileName) + file.outputStream().use { outputStream -> + writeCsv(outputStream, readings) + } + android.net.Uri.fromFile(file) + } + } catch (e: Exception) { + null + } +} + +private fun writeCsv(outputStream: java.io.OutputStream, readings: List) { + val writer = BufferedWriter(OutputStreamWriter(outputStream)) + writer.write("Timestamp_ms,Value1,Value2,Value3\n") + readings.forEach { reading -> + writer.write("${reading.timestampMs},${reading.values.joinToString(",")}\n") + } + writer.flush() +} diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScreen.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScreen.kt deleted file mode 100644 index d49f034..0000000 --- a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScreen.kt +++ /dev/null @@ -1,1079 +0,0 @@ -package com.shreyash.sensorapp.presentation.detail - -import android.content.ContentValues -import android.content.Context -import android.os.Build -import android.os.Environment -import android.provider.MediaStore -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Stop -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.shreyash.sensorapp.domain.model.SensorReading -import com.shreyash.sensorapp.domain.model.SensorType -import com.shreyash.sensorapp.presentation.theme.SensorAppTheme -import com.shreyash.sensorapp.presentation.theme.SensorGreen -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.BufferedWriter -import java.io.OutputStreamWriter -import kotlin.math.cos -import kotlin.math.sin - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SensorDetailScreen( - sensorType: SensorType, - onBack: () -> Unit, - viewModel: DetailViewModel = hiltViewModel() -) { - val currentReading by viewModel.currentReading.collectAsStateWithLifecycle() - val isLogging by viewModel.isLogging.collectAsStateWithLifecycle() - val chartReadings by viewModel.chartReadings.collectAsStateWithLifecycle() - val context = LocalContext.current - val snackbarHostState = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() - - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_START -> viewModel.startObserving() - Lifecycle.Event.ON_STOP -> viewModel.stopObserving() - else -> {} - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } - - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { Text(sensorType.displayName) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) - }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - bottomBar = { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Button( - onClick = { viewModel.toggleLogging() }, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors( - containerColor = if (isLogging) MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.primary - ) - ) { - Icon( - imageVector = if (isLogging) Icons.Default.Stop else Icons.Default.PlayArrow, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(Modifier.width(8.dp)) - Text(if (isLogging) "Stop Logging" else "Start Logging") - } - - Button( - onClick = { - scope.launch { - val uri = exportToCsv(context, chartReadings, sensorType) - if (uri != null) { - snackbarHostState.showSnackbar("CSV exported to Downloads") - } - } - }, - modifier = Modifier.weight(1f), - enabled = chartReadings.isNotEmpty() - ) { - Icon( - imageVector = Icons.Default.Download, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(Modifier.width(8.dp)) - Text("Export CSV") - } - } - } - ) { padding -> - SensorDetailScreenContent( - sensorType = sensorType, - currentReading = currentReading, - isLogging = isLogging, - chartReadings = chartReadings, - modifier = Modifier.padding(padding) - ) - } -} - -@Composable -private fun SensorDetailScreenContent( - sensorType: SensorType, - currentReading: SensorReading?, - isLogging: Boolean, - chartReadings: List, - modifier: Modifier = Modifier -) { - Column( - modifier = modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - LiveIndicator() - - Spacer(Modifier.height(16.dp)) - - LiveValueDisplay( - reading = currentReading, - sensorType = sensorType - ) - - Spacer(Modifier.height(24.dp)) - - LiveLineChart( - readings = chartReadings, - sensorType = sensorType, - modifier = Modifier - .fillMaxWidth() - .height(260.dp) - ) - - Spacer(Modifier.height(12.dp)) - - if (sensorType == SensorType.GYROSCOPE && currentReading != null) { - GyroscopeCube( - reading = currentReading, - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - .padding(16.dp) - ) - - Spacer(Modifier.height(12.dp)) - } - - Text( - text = "X: ${formatDetailValue(currentReading?.values?.getOrNull(0))} ${sensorType.unitX}" + - if (sensorType.axisCount >= 2) " Y: ${formatDetailValue(currentReading?.values?.getOrNull(1))} ${sensorType.unitY}" else "" + - if (sensorType.axisCount >= 3) " Z: ${formatDetailValue(currentReading?.values?.getOrNull(2))} ${sensorType.unitZ}" else "", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - Spacer(Modifier.height(16.dp)) - - SensorUsageHint(sensorType = sensorType) - - Spacer(Modifier.height(16.dp)) - } -} - -@Composable -private fun SensorUsageHint(sensorType: SensorType) { - val hint = when (sensorType) { - SensorType.ACCELEROMETER -> "Tilt or shake the device to see acceleration forces change across X, Y, and Z axes." - SensorType.GYROSCOPE -> "Rotate the device to measure the rate of rotation around each axis." - SensorType.LINEAR_ACCELERATION -> "Move the device quickly to measure acceleration excluding gravity." - SensorType.MAGNETOMETER -> "Move the device near a metal object or wave it in a figure-8 pattern to test." - SensorType.GRAVITY -> "Tilt the device to see how gravity distributes across each axis." - SensorType.ROTATION_VECTOR -> "Rotate the device to observe orientation changes relative to the world." - SensorType.LIGHT -> "Cover and uncover the light sensor (usually near the front camera) to see lux level changes." - SensorType.PROXIMITY -> "Cover the top of the device to trigger the sensor. Used to detect when the phone is held to the ear." - SensorType.PRESSURE -> "Ambient air pressure changes with altitude. Try moving to a different floor or elevation." - SensorType.STEP_COUNTER -> "Walk or simulate steps to see the step count increment in real time." - } - - Text( - text = hint, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - textAlign = TextAlign.Center - ) -} - -@Composable -private fun LiveIndicator() { - val infiniteTransition = rememberInfiniteTransition() - val alpha by infiniteTransition.animateFloat( - initialValue = 1f, - targetValue = 0.3f, - animationSpec = infiniteRepeatable( - animation = tween(800), - repeatMode = RepeatMode.Reverse - ) - ) - Card( - shape = RoundedCornerShape(20.dp), - colors = CardDefaults.cardColors( - containerColor = SensorGreen.copy(alpha = 0.15f) - ) - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(10.dp) - .clip(CircleShape) - .background(SensorGreen.copy(alpha = alpha)) - ) - Spacer(Modifier.width(8.dp)) - Text( - text = "LIVE", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = SensorGreen - ) - } - } -} - -@Composable -private fun LiveValueDisplay( - reading: SensorReading?, - sensorType: SensorType -) { - if (reading == null) { - Text( - text = "Waiting for sensor data...", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - return - } - - if (sensorType == SensorType.PRESSURE) { - val value = reading.values.firstOrNull() ?: 0f - val condition = when { - value < 980f -> "STORM" - value < 1010f -> "RAIN" - value < 1025f -> "NORMAL" - else -> "HIGH" - } - val conditionColor = when (condition) { - "STORM" -> Color(0xFFE53935) - "RAIN" -> Color(0xFF42A5F5) - "NORMAL" -> SensorGreen - else -> Color(0xFFFFB300) - } - - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - shape = RoundedCornerShape(24.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = condition, - style = MaterialTheme.typography.displayLarge, - fontWeight = FontWeight.Bold, - color = conditionColor - ) - - Spacer(Modifier.height(8.dp)) - - Text( - text = when (condition) { - "STORM" -> "Very low pressure — stormy weather likely" - "RAIN" -> "Low pressure — rain or unsettled weather" - "NORMAL" -> "Standard atmospheric pressure" - else -> "High pressure — fair and stable weather" - }, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - Spacer(Modifier.height(12.dp)) - - Text( - text = "${formatLargeValue(value)} hPa", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - - Spacer(Modifier.height(8.dp)) - - Text( - text = "Sea level: 1013.25 hPa", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ) - } - } - } else if (sensorType == SensorType.LIGHT) { - val value = reading.values.firstOrNull() ?: 0f - val level = when { - value <= 1f -> "DARK" - value <= 50f -> "DIM" - value <= 500f -> "INDOOR" - value <= 10000f -> "OUTDOOR" - else -> "SUNLIGHT" - } - val levelColor = when (level) { - "DARK" -> Color(0xFF6B6B6B) - "DIM" -> Color(0xFF9E9E9E) - "INDOOR" -> Color(0xFFFFD54F) - "OUTDOOR" -> Color(0xFFFFB300) - else -> Color(0xFFFF6F00) - } - val fraction = (value / 50000f).coerceIn(0f, 1f) - - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - shape = RoundedCornerShape(24.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = level, - style = MaterialTheme.typography.displayLarge, - fontWeight = FontWeight.Bold, - color = levelColor - ) - - Spacer(Modifier.height(8.dp)) - - Text( - text = when (level) { - "DARK" -> "Very little or no light detected" - "DIM" -> "Low light conditions, like a dimly lit room" - "INDOOR" -> "Typical indoor lighting level" - "OUTDOOR" -> "Outdoor conditions, cloudy or shaded" - else -> "Bright direct sunlight" - }, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - Spacer(Modifier.height(16.dp)) - - Box( - modifier = Modifier - .fillMaxWidth() - .height(12.dp) - .clip(RoundedCornerShape(6.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) - ) { - Box( - modifier = Modifier - .fillMaxWidth(fraction) - .fillMaxHeight() - .clip(RoundedCornerShape(6.dp)) - .background( - Brush.horizontalGradient(listOf( - Color(0xFF6B6B6B), - Color(0xFFFFD54F), - Color(0xFFFF6F00) - )) - ) - ) - } - - Spacer(Modifier.height(12.dp)) - - Text( - text = "${formatLargeValue(value)} lx", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - } - } - } else if (sensorType == SensorType.PROXIMITY) { - val value = reading.values.firstOrNull() ?: 0f - val isObstructed = value < 1f - - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - shape = RoundedCornerShape(24.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = if (isObstructed) "OBSTRUCTED" else "CLEAR", - style = MaterialTheme.typography.displayLarge, - fontWeight = FontWeight.Bold, - color = if (isObstructed) Color(0xFFFF6B6B) else SensorGreen - ) - - Spacer(Modifier.height(8.dp)) - - Text( - text = if (isObstructed) "An object is covering the sensor" - else "No object detected near the sensor", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } - } - } else if (sensorType.axisCount == 1) { - val value = reading.values.firstOrNull() ?: 0f - Text( - text = formatLargeValue(value), - style = MaterialTheme.typography.displayLarge, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - maxLines = 1 - ) - if (sensorType.unitSingle != null && sensorType.unitSingle.isNotEmpty()) { - Text( - text = sensorType.unitSingle, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.primary - ) - } - } else { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - AxisValue( - label = "X", - value = reading.values.getOrNull(0) ?: 0f, - unit = sensorType.unitX - ) - if (sensorType.axisCount >= 2) { - AxisValue( - label = "Y", - value = reading.values.getOrNull(1) ?: 0f, - unit = sensorType.unitY - ) - } - if (sensorType.axisCount >= 3) { - AxisValue( - label = "Z", - value = reading.values.getOrNull(2) ?: 0f, - unit = sensorType.unitZ - ) - } - } - } -} - -@Composable -private fun AxisValue( - label: String, - value: Float, - unit: String -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.widthIn(min = 96.dp) - ) { - Text( - text = label, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - Text( - text = formatLargeValue(value), - style = MaterialTheme.typography.displayMedium, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - maxLines = 1 - ) - if (unit.isNotEmpty()) { - Text( - text = unit, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - -@Composable -fun LiveLineChart( - readings: List, - sensorType: SensorType, - modifier: Modifier = Modifier -) { - if (readings.isEmpty()) { - Box(modifier = modifier, contentAlignment = Alignment.Center) { - Text( - text = "No data yet", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - return - } - - val axisIndex = 0 - val values = readings.map { it.values.getOrElse(axisIndex) { 0f } } - val lineColor = MaterialTheme.colorScheme.primary - val fillColor = MaterialTheme.colorScheme.primary - val gridColor = MaterialTheme.colorScheme.outlineVariant - val crosshairColor = MaterialTheme.colorScheme.tertiary - - var touchIndex by remember { mutableStateOf(-1) } - - Card( - modifier = modifier, - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) - ) { - Canvas( - modifier = Modifier - .fillMaxSize() - .padding(start = 8.dp, top = 24.dp, end = 8.dp, bottom = 24.dp) - .pointerInput(values) { - detectTapGestures { offset -> - if (values.size < 2) return@detectTapGestures - val stepX = size.width.toFloat() / (values.size - 1) - val index = ((offset.x / stepX) + 0.5f).toInt() - .coerceIn(0, values.size - 1) - touchIndex = if (touchIndex == index) -1 else index - } - } - ) { - if (values.size < 2) return@Canvas - - val minVal = values.min() - val maxVal = values.max() - val range = if (maxVal - minVal == 0f) 1f else maxVal - minVal - val paddingV = 0.1f - val adjustedMin = minVal - range * paddingV - val adjustedMax = maxVal + range * paddingV - val adjustedRange = adjustedMax - adjustedMin - - val stepX = size.width / (values.size - 1).coerceAtLeast(1) - - fun yForValue(value: Float): Float { - val normY = ((value - adjustedMin) / adjustedRange).coerceIn(0f, 1f) - return size.height - normY * size.height - } - - for (i in 0 until values.size) { - val x = i * stepX - drawLine( - color = gridColor, - start = Offset(x, 0f), - end = Offset(x, size.height), - strokeWidth = 0.5f - ) - } - - val linePath = Path() - val fillPath = Path() - values.forEachIndexed { index, value -> - val x = index * stepX - val y = yForValue(value) - if (index == 0) { - linePath.moveTo(x, y) - fillPath.moveTo(x, size.height) - fillPath.lineTo(x, y) - } else { - linePath.lineTo(x, y) - fillPath.lineTo(x, y) - } - } - fillPath.lineTo((values.size - 1) * stepX, size.height) - fillPath.close() - - drawPath( - path = fillPath, - brush = Brush.verticalGradient( - colors = listOf(fillColor.copy(alpha = 0.2f), fillColor.copy(alpha = 0.02f)), - endY = size.height - ) - ) - - drawPath( - path = linePath, - color = lineColor, - style = Stroke( - width = 2.5.dp.toPx(), - cap = StrokeCap.Round, - join = StrokeJoin.Round - ) - ) - - val labelPaint = android.graphics.Paint().apply { - color = android.graphics.Color.GRAY - textSize = 22f - textAlign = android.graphics.Paint.Align.RIGHT - isAntiAlias = true - } - - drawContext.canvas.nativeCanvas.drawText( - formatLargeValue(adjustedMax), - -4.dp.toPx(), - 0f, - labelPaint - ) - - drawContext.canvas.nativeCanvas.drawText( - formatLargeValue(adjustedMin), - -4.dp.toPx(), - size.height, - labelPaint - ) - - if (touchIndex in values.indices && values.size >= 2) { - val x = touchIndex * stepX - val value = values[touchIndex] - val y = yForValue(value) - val point = Offset(x, y) - - drawLine( - color = crosshairColor, - start = Offset(x, 0f), - end = Offset(x, size.height), - strokeWidth = 1.5f - ) - - drawCircle( - color = crosshairColor, - radius = 5.dp.toPx(), - center = point - ) - drawCircle( - color = crosshairColor.copy(alpha = 0.3f), - radius = 10.dp.toPx(), - center = point - ) - - drawCrosshairTooltip( - value = value, - x = x, - y = y - ) - } - } - } -} - -private fun DrawScope.drawCrosshairTooltip(value: Float, x: Float, y: Float) { - val text = formatLargeValue(value) - val paint = android.graphics.Paint().apply { - color = android.graphics.Color.WHITE - textSize = 28f - textAlign = android.graphics.Paint.Align.CENTER - isAntiAlias = true - isFakeBoldText = true - } - - val textWidth = paint.measureText(text) - val padding = 16f - val tooltipHeight = paint.textSize + padding * 2 - val rectWidth = textWidth + padding * 2 - val rectLeft = (x - rectWidth / 2).coerceIn(0f, size.width - rectWidth) - val rectTop = (y - tooltipHeight - 16.dp.toPx()).coerceAtLeast(0f) - val rectBottom = rectTop + tooltipHeight - val rectRight = rectLeft + rectWidth - - drawRoundRect( - color = Color(50, 50, 55, 230), - topLeft = Offset(rectLeft, rectTop), - size = Size(rectWidth, tooltipHeight), - cornerRadius = CornerRadius(10f, 10f) - ) - - drawContext.canvas.nativeCanvas.drawText( - text, - x, - rectTop + padding + paint.textSize / 2 + 2f, - paint - ) -} - -@Preview(showBackground = true, backgroundColor = 0xFF1A1C1E) -@Composable -private fun PreviewLiveIndicator() { - SensorAppTheme { - LiveIndicator() - } -} - -@Preview(showBackground = true, backgroundColor = 0xFF1A1C1E) -@Composable -private fun PreviewLiveValueDisplay() { - SensorAppTheme { - LiveValueDisplay( - reading = SensorReading( - sensorType = SensorType.ACCELEROMETER, - values = listOf(9.8f, 0.0f, 0.0f), - accuracy = 3, - timestampMs = System.currentTimeMillis() - ), - sensorType = SensorType.ACCELEROMETER - ) - } -} - -@Preview(showBackground = true, backgroundColor = 0xFF1A1C1E) -@Composable -private fun PreviewAxisValue() { - SensorAppTheme { - AxisValue(label = "X", value = 9.8f, unit = "m/s²") - } -} - -private val mockChartReadings = List(60) { i -> - SensorReading( - sensorType = SensorType.ACCELEROMETER, - values = listOf(9.8f + kotlin.math.sin(i * 0.5f) * 2f, 0.5f, -0.3f), - accuracy = 3, - timestampMs = System.currentTimeMillis() - (60 - i) * 100L - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Preview(showBackground = true, backgroundColor = 0xFF1A1C1E) -@Composable -private fun PreviewSensorDetailScreen() { - SensorAppTheme { - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { Text("Accelerometer") }, - navigationIcon = { - IconButton(onClick = {}) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) - }, - bottomBar = { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Button( - onClick = {}, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(Modifier.width(8.dp)) - Text("Start Logging") - } - - Button( - onClick = {}, - modifier = Modifier.weight(1f), - enabled = true - ) { - Icon( - imageVector = Icons.Default.Download, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(Modifier.width(8.dp)) - Text("Export CSV") - } - } - } - ) { padding -> - SensorDetailScreenContent( - sensorType = SensorType.ACCELEROMETER, - currentReading = SensorReading( - sensorType = SensorType.ACCELEROMETER, - values = listOf(9.8f, 0.0f, 0.0f), - accuracy = 3, - timestampMs = System.currentTimeMillis() - ), - isLogging = false, - chartReadings = mockChartReadings, - modifier = Modifier.padding(padding) - ) - } - } -} - -@Composable -private fun GyroscopeCube( - reading: SensorReading, - modifier: Modifier = Modifier -) { - val gx = reading.values.getOrNull(0) ?: 0f - val gy = reading.values.getOrNull(1) ?: 0f - val gz = reading.values.getOrNull(2) ?: 0f - - val rotationX = remember { Animatable(0f) } - val rotationY = remember { Animatable(0f) } - val rotationZ = remember { Animatable(0f) } - - LaunchedEffect(gx, gy, gz) { - rotationX.animateTo(rotationX.value + gx * 0.05f, tween(100)) - rotationY.animateTo(rotationY.value + gy * 0.05f, tween(100)) - rotationZ.animateTo(rotationZ.value + gz * 0.05f, tween(100)) - } - - val primary = MaterialTheme.colorScheme.primary - - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ), - shape = RoundedCornerShape(16.dp) - ) { - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "3D Visualization", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp) - ) - - Canvas(modifier = Modifier.fillMaxSize()) { - val cx = size.width / 2f - val cy = size.height / 2f - val s = minOf(cx, cy) * 0.6f - - val rx = rotationX.value - val ry = rotationY.value - val rz = rotationZ.value - - val vertices = listOf( - floatArrayOf(-1f, -1f, -1f), floatArrayOf(1f, -1f, -1f), - floatArrayOf(1f, -1f, 1f), floatArrayOf(-1f, -1f, 1f), - floatArrayOf(-1f, 1f, -1f), floatArrayOf(1f, 1f, -1f), - floatArrayOf(1f, 1f, 1f), floatArrayOf(-1f, 1f, 1f) - ) - - val projected = vertices.map { v -> - var x = v[0]; var y = v[1]; var z = v[2] - - val rxRad = Math.toRadians(rx.toDouble()).toFloat() - val ryRad = Math.toRadians(ry.toDouble()).toFloat() - val rzRad = Math.toRadians(rz.toDouble()).toFloat() - - val cosX = cos(rxRad); val sinX = sin(rxRad) - val cosY = cos(ryRad); val sinY = sin(ryRad) - val cosZ = cos(rzRad); val sinZ = sin(rzRad) - - val y1 = y * cosX - z * sinX - val z1 = y * sinX + z * cosX - y = y1; z = z1 - - val x1 = x * cosY + z * sinY - val z2 = -x * sinY + z * cosY - x = x1; z = z2 - - val x2 = x * cosZ - y * sinZ - val y2 = x * sinZ + y * cosZ - x = x2; y = y2 - - Offset(cx + x * s, cy + y * s) - } - - val edges = listOf( - 0 to 1, 1 to 2, 2 to 3, 3 to 0, - 4 to 5, 5 to 6, 6 to 7, 7 to 4, - 0 to 4, 1 to 5, 2 to 6, 3 to 7 - ) - - for ((i, j) in edges) { - val zAvg = (listOf(vertices[i][2], vertices[j][2]).average()).toFloat() - val alpha = ((zAvg + 1f) / 2f).coerceIn(0.3f, 1f) - drawLine( - color = primary.copy(alpha = alpha), - start = projected[i], - end = projected[j], - strokeWidth = 2.dp.toPx() - ) - } - - for (v in projected) { - drawCircle(color = primary, radius = 3.dp.toPx(), center = v) - } - } - } - } -} - -private fun formatLargeValue(value: Float): String { - return String.format("%.1f", value) -} - -private fun formatDetailValue(value: Float?): String { - if (value == null) return "--" - return formatLargeValue(value) -} - -private suspend fun exportToCsv( - context: Context, - readings: List, - sensorType: SensorType -): android.net.Uri? = withContext(Dispatchers.IO) { - try { - val fileName = "${sensorType.name}_${System.currentTimeMillis()}.csv" - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val contentValues = ContentValues().apply { - put(MediaStore.Downloads.DISPLAY_NAME, fileName) - put(MediaStore.Downloads.MIME_TYPE, "text/csv") - put(MediaStore.Downloads.IS_PENDING, 1) - } - - val uri = context.contentResolver.insert( - MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues - ) ?: return@withContext null - - context.contentResolver.openOutputStream(uri)?.use { outputStream -> - writeCsv(outputStream, readings) - } - - contentValues.clear() - contentValues.put(MediaStore.Downloads.IS_PENDING, 0) - context.contentResolver.update(uri, contentValues, null, null) - uri - } else { - val dir = Environment.getExternalStoragePublicDirectory( - Environment.DIRECTORY_DOWNLOADS - ) - dir.mkdirs() - val file = java.io.File(dir, fileName) - file.outputStream().use { outputStream -> - writeCsv(outputStream, readings) - } - android.net.Uri.fromFile(file) - } - } catch (e: Exception) { - null - } -} - -private fun writeCsv(outputStream: java.io.OutputStream, readings: List) { - val writer = BufferedWriter(OutputStreamWriter(outputStream)) - writer.write("Timestamp_ms,Value1,Value2,Value3\n") - readings.forEach { reading -> - writer.write("${reading.timestampMs},${reading.values.joinToString(",")}\n") - } - writer.flush() -} diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDisplayCard.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDisplayCard.kt new file mode 100644 index 0000000..eb3cae2 --- /dev/null +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDisplayCard.kt @@ -0,0 +1,317 @@ +package com.shreyash.sensorapp.presentation.detail + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.shreyash.sensorapp.domain.model.SensorReading +import com.shreyash.sensorapp.domain.model.SensorType +import com.shreyash.sensorapp.presentation.theme.SensorGreen + +@Composable +fun LiveValueDisplay( + reading: SensorReading?, + sensorType: SensorType +) { + if (reading == null) { + Text( + text = "Waiting for sensor data...", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + return + } + + if (sensorType == SensorType.PRESSURE) { + PressureDisplay(reading) + } else if (sensorType == SensorType.LIGHT) { + LightDisplay(reading) + } else if (sensorType == SensorType.PROXIMITY) { + ProximityDisplay(reading) + } else if (sensorType.axisCount == 1) { + SingleAxisDisplay(reading, sensorType) + } else { + MultiAxisDisplay(reading, sensorType) + } +} + +@Composable +private fun PressureDisplay(reading: SensorReading) { + val value = reading.values.firstOrNull() ?: 0f + val condition = when { + value < 980f -> "STORM" + value < 1010f -> "RAIN" + value < 1025f -> "NORMAL" + else -> "HIGH" + } + val conditionColor = when (condition) { + "STORM" -> Color(0xFFE53935) + "RAIN" -> Color(0xFF42A5F5) + "NORMAL" -> SensorGreen + else -> Color(0xFFFFB300) + } + + SensorCard { + Text( + text = condition, + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = conditionColor + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = when (condition) { + "STORM" -> "Very low pressure — stormy weather likely" + "RAIN" -> "Low pressure — rain or unsettled weather" + "NORMAL" -> "Standard atmospheric pressure" + else -> "High pressure — fair and stable weather" + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(12.dp)) + + Text( + text = "${formatLargeValue(value)} hPa", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = "Sea level: 1013.25 hPa", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } +} + +@Composable +private fun LightDisplay(reading: SensorReading) { + val value = reading.values.firstOrNull() ?: 0f + val level = when { + value <= 1f -> "DARK" + value <= 50f -> "DIM" + value <= 500f -> "INDOOR" + value <= 10000f -> "OUTDOOR" + else -> "SUNLIGHT" + } + val levelColor = when (level) { + "DARK" -> Color(0xFF6B6B6B) + "DIM" -> Color(0xFF9E9E9E) + "INDOOR" -> Color(0xFFFFD54F) + "OUTDOOR" -> Color(0xFFFFB300) + else -> Color(0xFFFF6F00) + } + val fraction = (value / 50000f).coerceIn(0f, 1f) + + SensorCard { + Text( + text = level, + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = levelColor + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = when (level) { + "DARK" -> "Very little or no light detected" + "DIM" -> "Low light conditions, like a dimly lit room" + "INDOOR" -> "Typical indoor lighting level" + "OUTDOOR" -> "Outdoor conditions, cloudy or shaded" + else -> "Bright direct sunlight" + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(16.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(12.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { + Box( + modifier = Modifier + .fillMaxWidth(fraction) + .fillMaxHeight() + .clip(RoundedCornerShape(6.dp)) + .background( + Brush.horizontalGradient(listOf( + Color(0xFF6B6B6B), + Color(0xFFFFD54F), + Color(0xFFFF6F00) + )) + ) + ) + } + + Spacer(Modifier.height(12.dp)) + + Text( + text = "${formatLargeValue(value)} lx", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +@Composable +private fun ProximityDisplay(reading: SensorReading) { + val value = reading.values.firstOrNull() ?: 0f + val isObstructed = value < 1f + + SensorCard { + Text( + text = if (isObstructed) "OBSTRUCTED" else "CLEAR", + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = if (isObstructed) Color(0xFFFF6B6B) else SensorGreen + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = if (isObstructed) "An object is covering the sensor" + else "No object detected near the sensor", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun SingleAxisDisplay(reading: SensorReading, sensorType: SensorType) { + val value = reading.values.firstOrNull() ?: 0f + Text( + text = formatLargeValue(value), + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + maxLines = 1 + ) + if (sensorType.unitSingle != null && sensorType.unitSingle.isNotEmpty()) { + Text( + text = sensorType.unitSingle, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + } +} + +@Composable +private fun MultiAxisDisplay(reading: SensorReading, sensorType: SensorType) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + AxisValue( + label = "X", + value = reading.values.getOrNull(0) ?: 0f, + unit = sensorType.unitX + ) + if (sensorType.axisCount >= 2) { + AxisValue( + label = "Y", + value = reading.values.getOrNull(1) ?: 0f, + unit = sensorType.unitY + ) + } + if (sensorType.axisCount >= 3) { + AxisValue( + label = "Z", + value = reading.values.getOrNull(2) ?: 0f, + unit = sensorType.unitZ + ) + } + } +} + +@Composable +fun AxisValue( + label: String, + value: Float, + unit: String +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.widthIn(min = 96.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = formatLargeValue(value), + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + maxLines = 1 + ) + if (unit.isNotEmpty()) { + Text( + text = unit, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun SensorCard( + content: @Composable () -> Unit +) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ), + shape = RoundedCornerShape(24.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + content() + } + } +} diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorUtils.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorUtils.kt new file mode 100644 index 0000000..bca3bca --- /dev/null +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorUtils.kt @@ -0,0 +1,100 @@ +package com.shreyash.sensorapp.presentation.detail + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.shreyash.sensorapp.domain.model.SensorType +import com.shreyash.sensorapp.presentation.theme.SensorGreen + +@Composable +fun LiveIndicator() { + val infiniteTransition = rememberInfiniteTransition() + val alpha by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0.3f, + animationSpec = infiniteRepeatable( + animation = tween(800), + repeatMode = RepeatMode.Reverse + ) + ) + Card( + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = SensorGreen.copy(alpha = 0.15f) + ) + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(SensorGreen.copy(alpha = alpha)) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "LIVE", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = SensorGreen + ) + } + } +} + +@Composable +fun SensorUsageHint(sensorType: SensorType) { + val hint = when (sensorType) { + SensorType.ACCELEROMETER -> "Tilt or shake the device to see acceleration forces change across X, Y, and Z axes." + SensorType.GYROSCOPE -> "Rotate the device to measure the rate of rotation around each axis." + SensorType.LINEAR_ACCELERATION -> "Move the device quickly to measure acceleration excluding gravity." + SensorType.MAGNETOMETER -> "Move the device near a metal object or wave it in a figure-8 pattern to test." + SensorType.GRAVITY -> "Tilt the device to see how gravity distributes across each axis." + SensorType.ROTATION_VECTOR -> "Rotate the device to observe orientation changes relative to the world." + SensorType.LIGHT -> "Cover and uncover the light sensor (usually near the front camera) to see lux level changes." + SensorType.PROXIMITY -> "Cover the top of the device to trigger the sensor. Used to detect when the phone is held to the ear." + SensorType.PRESSURE -> "Ambient air pressure changes with altitude. Try moving to a different floor or elevation." + SensorType.STEP_COUNTER -> "Walk or simulate steps to see the step count increment in real time." + } + + Text( + text = hint, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + textAlign = TextAlign.Center + ) +} + +fun formatLargeValue(value: Float): String { + return String.format("%.1f", value) +} + +fun formatDetailValue(value: Float?): String { + if (value == null) return "--" + return formatLargeValue(value) +} diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/StepCounterScreen.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/StepCounterScreen.kt new file mode 100644 index 0000000..2cb5c5f --- /dev/null +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/StepCounterScreen.kt @@ -0,0 +1,63 @@ +package com.shreyash.sensorapp.presentation.detail + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.shreyash.sensorapp.domain.model.SensorReading +import com.shreyash.sensorapp.domain.model.SensorType + +@Composable +fun StepCounterScreen( + onBack: () -> Unit, + sensorType: SensorType = SensorType.STEP_COUNTER +) { + SensorDetailScaffold(sensorType = sensorType, onBack = onBack) { currentReading, chartReadings -> + StepCounterContent(currentReading = currentReading, chartReadings = chartReadings) + } +} + +@Composable +private fun StepCounterContent( + currentReading: SensorReading?, + chartReadings: List +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + LiveIndicator() + + Spacer(Modifier.height(16.dp)) + + LiveValueDisplay( + reading = currentReading, + sensorType = SensorType.STEP_COUNTER + ) + + Spacer(Modifier.height(24.dp)) + + LiveLineChart( + readings = chartReadings, + sensorType = SensorType.STEP_COUNTER, + modifier = Modifier.fillMaxWidth().height(260.dp) + ) + + Spacer(Modifier.height(16.dp)) + + SensorUsageHint(sensorType = SensorType.STEP_COUNTER) + + Spacer(Modifier.height(16.dp)) + } +} diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/navigation/AppNavGraph.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/navigation/AppNavGraph.kt index cd66b7f..91bc63b 100644 --- a/app/src/main/java/com/shreyash/sensorapp/presentation/navigation/AppNavGraph.kt +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/navigation/AppNavGraph.kt @@ -2,14 +2,20 @@ package com.shreyash.sensorapp.presentation.navigation import androidx.compose.runtime.Composable import androidx.navigation.NavHostController -import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import androidx.navigation.navArgument -import com.shreyash.sensorapp.domain.model.SensorType import com.shreyash.sensorapp.presentation.compass.CompassScreen import com.shreyash.sensorapp.presentation.dashboard.DashboardScreen -import com.shreyash.sensorapp.presentation.detail.SensorDetailScreen +import com.shreyash.sensorapp.presentation.detail.AccelerometerScreen +import com.shreyash.sensorapp.presentation.detail.GravityScreen +import com.shreyash.sensorapp.presentation.detail.GyroscopeScreen +import com.shreyash.sensorapp.presentation.detail.LightScreen +import com.shreyash.sensorapp.presentation.detail.LinearAccelerationScreen +import com.shreyash.sensorapp.presentation.detail.MagnetometerScreen +import com.shreyash.sensorapp.presentation.detail.PressureScreen +import com.shreyash.sensorapp.presentation.detail.ProximityScreen +import com.shreyash.sensorapp.presentation.detail.RotationVectorScreen +import com.shreyash.sensorapp.presentation.detail.StepCounterScreen import com.shreyash.sensorapp.presentation.history.HistoryScreen import com.shreyash.sensorapp.presentation.settings.SettingsScreen @@ -22,7 +28,7 @@ fun AppNavGraph(navController: NavHostController) { composable(Route.Dashboard.route) { DashboardScreen( onNavigateToDetail = { sensorType -> - navController.navigate(Route.SensorDetail(sensorType).route) + navController.navigate(sensorType.toRoute()) }, onNavigateToCompass = { navController.navigate(Route.Compass.route) }, onNavigateToHistory = { navController.navigate(Route.History.route) }, @@ -30,18 +36,44 @@ fun AppNavGraph(navController: NavHostController) { ) } - composable( - route = Route.SensorDetail.ROUTE_PATTERN, - arguments = listOf( - navArgument("sensorType") { type = NavType.StringType } - ) - ) { backStackEntry -> - val sensorTypeName = backStackEntry.arguments?.getString("sensorType") - val sensorType = SensorType.valueOf(sensorTypeName ?: "ACCELEROMETER") - SensorDetailScreen( - sensorType = sensorType, - onBack = { navController.popBackStack() } - ) + composable(Route.Accelerometer.route) { + AccelerometerScreen(onBack = { navController.popBackStack() }) + } + + composable(Route.Gyroscope.route) { + GyroscopeScreen(onBack = { navController.popBackStack() }) + } + + composable(Route.LinearAcceleration.route) { + LinearAccelerationScreen(onBack = { navController.popBackStack() }) + } + + composable(Route.Magnetometer.route) { + MagnetometerScreen(onBack = { navController.popBackStack() }) + } + + composable(Route.Gravity.route) { + GravityScreen(onBack = { navController.popBackStack() }) + } + + composable(Route.RotationVector.route) { + RotationVectorScreen(onBack = { navController.popBackStack() }) + } + + composable(Route.Light.route) { + LightScreen(onBack = { navController.popBackStack() }) + } + + composable(Route.Proximity.route) { + ProximityScreen(onBack = { navController.popBackStack() }) + } + + composable(Route.Pressure.route) { + PressureScreen(onBack = { navController.popBackStack() }) + } + + composable(Route.StepCounter.route) { + StepCounterScreen(onBack = { navController.popBackStack() }) } composable(Route.History.route) { diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/navigation/Routes.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/navigation/Routes.kt index 524ec1e..2fc047e 100644 --- a/app/src/main/java/com/shreyash/sensorapp/presentation/navigation/Routes.kt +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/navigation/Routes.kt @@ -6,12 +6,29 @@ sealed class Route(val route: String) { data object Dashboard : Route("dashboard") data object History : Route("history") data object Settings : Route("settings") + data object Compass : Route("compass") - data class SensorDetail(val sensorType: SensorType) : Route("sensor_detail/${sensorType.name}") { - companion object { - const val ROUTE_PATTERN = "sensor_detail/{sensorType}" - } - } + data object Accelerometer : Route("sensor/accelerometer") + data object Gyroscope : Route("sensor/gyroscope") + data object LinearAcceleration : Route("sensor/linear_acceleration") + data object Magnetometer : Route("sensor/magnetometer") + data object Gravity : Route("sensor/gravity") + data object RotationVector : Route("sensor/rotation_vector") + data object Light : Route("sensor/light") + data object Proximity : Route("sensor/proximity") + data object Pressure : Route("sensor/pressure") + data object StepCounter : Route("sensor/step_counter") +} - data object Compass : Route("compass") +fun SensorType.toRoute(): String = when (this) { + SensorType.ACCELEROMETER -> Route.Accelerometer.route + SensorType.GYROSCOPE -> Route.Gyroscope.route + SensorType.LINEAR_ACCELERATION -> Route.LinearAcceleration.route + SensorType.MAGNETOMETER -> Route.Magnetometer.route + SensorType.GRAVITY -> Route.Gravity.route + SensorType.ROTATION_VECTOR -> Route.RotationVector.route + SensorType.LIGHT -> Route.Light.route + SensorType.PROXIMITY -> Route.Proximity.route + SensorType.PRESSURE -> Route.Pressure.route + SensorType.STEP_COUNTER -> Route.StepCounter.route }