diff --git a/AGENTS.md b/AGENTS.md index ad6a72c..b78db01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,29 +1,162 @@ # SensorApp -Android Kotlin app (minSdk 24, targetSdk 36, v1.7). Package: `com.shreyash.sensorapp`. +Android Kotlin app (minSdk 24, targetSdk 36). Package: `com.shreyash.sensorapp`. -## Tech -Kotlin 1.9.22 · Jetpack Compose (BOM 2024.02.00) · Material 3 · Hilt 2.50 · Room 2.6.1 · Coroutines 1.7.3 · Compose Navigation 2.7.7 - -## Architecture -Clean Architecture: data/domain/presentation layers with Dagger Hilt DI. +## Tech Stack +- **Kotlin** 1.9.22 · **Jetpack Compose** (BOM 2024.02.00) · **Material 3** +- **Hilt** 2.50 · **Room** 2.6.1 · **Coroutines** 1.7.3 · **Compose Navigation** 2.7.7 ## Build -`./gradlew assembleDebug` — requires JDK 17+ (use Android Studio JBR at `/Applications/Android Studio.app/Contents/jbr/Contents/Home`). +```bash +./gradlew assembleDebug +``` +Requires JDK 17+ (use Android Studio JBR at `/Applications/Android Studio.app/Contents/jbr/Contents/Home`). + +CI: `.github/workflows/build.yml` — lint on PR, bump+signed AAB+upload+commit on merge to `main`. + +--- + +## Architecture — Clean Architecture + +``` +data/ ← SensorDataSource (callbackFlow), Room DB, RepositoryImpl, HapticManager +domain/ ← Models (SensorType, SensorReading), Repository interface, UseCases +presentation/ ← Screens, ViewModels, Navigation, Theme +di/ ← Hilt modules (SensorModule, DatabaseModule) +``` + +--- ## Sensors (10) -Accelerometer, Gyroscope, Linear Acceleration, Magnetometer, Gravity, Rotation Vector, Light, Proximity, Barometer, Step Counter. + +| Sensor | Type | Category | Axes | Units | +|--------|------|----------|------|-------| +| Accelerometer | Hardware | Motion | 3 | m/s² | +| Gyroscope | Hardware | Motion | 3 | °/s | +| Linear Acceleration | Virtual | Motion | 3 | m/s² | +| Magnetometer | Hardware | Position | 3 | µT | +| Gravity | Virtual | Position | 3 | m/s² | +| Rotation Vector | Virtual | Position | 3 | — | +| Light | Hardware | Environmental | 1 | lx | +| Proximity | Hardware | Environmental | 1 | binary (OBSTRUCTED/CLEAR) | +| Barometer | Hardware | Environmental | 1 | hPa | +| Step Counter | Hardware | Activity | 1 | steps | + +--- ## Screens -- **Dashboard** — 2-col grid grouped by category (Motion/Position/Environmental/Activity). Compass card at top. -- **Compass** — tilt-compensated heading via accel+mag fusion with Canvas-drawn rose. -- **Sensor Detail** — live values, Canvas chart (60 readings), logging toggle, CSV export. -- **History** — session list with search, sort, clear. -- **Settings** — polling rate (FASTEST/GAME/UI/NORMAL), DB stats, credits. + +### Dashboard +- 2-column `LazyVerticalGrid`, grouped by `SensorCategory` +- `CompassDashboardCard` at top spanning full width +- Each `SensorGridItem` shows icon, name, availability +- Unavailable sensors show `ModalBottomSheet` on tap +- Permission dialog for `ACTIVITY_RECOGNITION` (step counter) +- **No real-time observation** (removed for perf — was causing lag) + +### Compass +- Rotating dial (iPhone-style), fixed red indicator at top +- Degree labels (30/60/120/150/…) drawn radially around dial +- Tilt-compensated heading via `SensorManager.getRotationMatrix()` +- Small disclaimer: "Accuracy depends on device calibration" +- Dark canvas, no Card/LIVE indicator + +### Sensor Detail Screens (10 individual files) +Each sensor has its own screen composable in `presentation/detail/`: + +| File | Sensor | Unique UI | +|------|--------|-----------| +| `AccelerometerScreen.kt` | Accelerometer | X/Y/Z values + chart | +| `GyroscopeScreen.kt` | Gyroscope | X/Y/Z values + **3D cube** + chart | +| `LinearAccelerationScreen.kt` | Linear Acceleration | X/Y/Z values + chart | +| `MagnetometerScreen.kt` | Magnetometer | X/Y/Z values + chart | +| `GravityScreen.kt` | Gravity | X/Y/Z values + chart | +| `RotationVectorScreen.kt` | Rotation Vector | X/Y/Z values + chart | +| `LightScreen.kt` | Light | Brightness level card (DARK→SUNLIGHT) + gradient bar + chart | +| `ProximityScreen.kt` | Proximity | OBSTRUCTED/CLEAR card + chart | +| `PressureScreen.kt` | Barometer | Weather condition card (STORM/RAIN/NORMAL/HIGH) + chart | +| `StepCounterScreen.kt` | Step Counter | Step count + chart | + +All screens share: +- `SensorDetailScaffold` — top bar, lifecycle binding, bottom bar with **Logging toggle** + **CSV Export** +- `LiveLineChart` — Canvas line chart (60 readings), touch crosshair with tooltip +- `LiveIndicator` — animated pulsing green dot +- `SensorUsageHint` — contextual usage tip per sensor type +- `SensorDetailScaffold` applies content padding (fixed: was hiding behind toolbar) + +### History +- Session list from Room DB, search bar, sort toggle (newest/oldest) +- Clear all button with confirmation + +### Settings +- **Polling Rate** — RadioGroup: FASTEST / GAME / UI / NORMAL +- **Haptic Feedback** — Switch toggle (on by default), controls vibration for proximity/step/gyro +- **Database Stats** — total row count +- **Credits** — developer info + +--- + +## Key Shared Components (`presentation/detail/`) + +| File | What it exports | +|------|-----------------| +| `SensorDetailScaffold.kt` | `SensorDetailScaffold()` — shared scaffold + lifecycle + CSV export | +| `SensorUtils.kt` | `LiveIndicator()`, `SensorUsageHint()`, `formatLargeValue()`, `formatDetailValue()` | +| `SensorChart.kt` | `LiveLineChart()` — Canvas chart with touch crosshair | +| `SensorDisplayCard.kt` | `LiveValueDisplay()`, `AxisValue()`, `PressureDisplay()`, `LightDisplay()`, `ProximityDisplay()` | +| `GyroscopeCube.kt` | `GyroscopeCube()` — 3D rotating cube on Canvas | +| `DetailViewModel.kt` | `DetailViewModel` — sensor observation, 60-reading buffer, logging, **haptic triggers** | + +--- + +## Haptic Feedback (#14) + +Implemented in `DetailViewModel.checkHapticTriggers()`: + +| Sensor | Trigger | Haptic | +|--------|---------|--------| +| Proximity | OBSTRUCTED ↔ CLEAR transition | `doubleTick()` | +| Step Counter | Step count increments | `tick()` | +| Gyroscope | Rotation magnitude > 5 rad/s | `tick()` | + +`HapticManager` (`data/sensor/HapticManager.kt`) wraps `Vibrator`/`VibratorManager`, compatible with API 24+. Toggle in Settings (stored in-memory via `@Volatile` in `SensorRepositoryImpl`). + +**Requires `android.permission.VIBRATE`** in manifest (normal permission, granted at install time). + +--- + +## Navigation + +`Routes.kt` — sealed `Route` class with per-sensor routes: +``` +dashboard, history, settings, compass +sensor/accelerometer, sensor/gyroscope, sensor/linear_acceleration, sensor/magnetometer +sensor/gravity, sensor/rotation_vector, sensor/light, sensor/proximity +sensor/pressure, sensor/step_counter +``` + +`SensorType.toRoute()` extension maps each enum to its route string. `AppNavGraph.kt` registers individual composable destinations. No nav arguments needed — each screen passes sensor type directly to `SensorDetailScaffold`. + +--- + +## Data Flow + +``` +SensorManager callbackFlow + → SensorDataSource.observeSensor(type, delay) + → SensorRepositoryImpl.observeSensor(type) + → ObserveSensorUseCase(sensorType) + → DetailViewModel (collect → buffer 60 → check haptics) + → Screen composable (collectAsStateWithLifecycle) +``` + +Polling delay from `SensorRepositoryImpl.currentDelay` (`@Volatile`, defaults to `SENSOR_DELAY_UI`). Haptic toggle from `SensorRepositoryImpl.hapticEnabled`. + +--- ## Key Patterns -- Sensor observation via `callbackFlow` in `SensorDataSource`, scaled by polling delay from Settings. -- `combine` in CompassViewModel fuses accel+mag flows using `SensorManager.getRotationMatrix()`. -- Navigation via sealed `Route` class in `Routes.kt`. -- Previews use extracted `*ScreenContent` composables with mock data (no Hilt). -- Dark theme only. +- Sensor observation via `callbackFlow` in `SensorDataSource` +- DI via Hilt (`@HiltViewModel`, `@Inject`, `@Module`) +- Dark theme only (no light/auto switch yet) +- Previews use `*Content` composables with mock data (no Hilt) +- No DataStore/SharedPreferences — all settings are in-memory `@Volatile` vars (lost on app restart) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index abcd4e5..7b5bb46 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + diff --git a/app/src/main/java/com/shreyash/sensorapp/data/repository/SensorRepositoryImpl.kt b/app/src/main/java/com/shreyash/sensorapp/data/repository/SensorRepositoryImpl.kt index ea735e8..c73c4e3 100644 --- a/app/src/main/java/com/shreyash/sensorapp/data/repository/SensorRepositoryImpl.kt +++ b/app/src/main/java/com/shreyash/sensorapp/data/repository/SensorRepositoryImpl.kt @@ -27,6 +27,9 @@ class SensorRepositoryImpl @Inject constructor( @Volatile private var currentDelay: Int = android.hardware.SensorManager.SENSOR_DELAY_UI + @Volatile + private var hapticEnabled: Boolean = true + override fun observeSensor(sensorType: SensorType): Flow { return sensorDataSource.observeSensor(sensorType, currentDelay) } @@ -114,6 +117,12 @@ class SensorRepositoryImpl @Inject constructor( override suspend fun setDelay(delay: Int) { currentDelay = delay } + + override fun isHapticEnabled(): Boolean = hapticEnabled + + override fun setHapticEnabled(enabled: Boolean) { + hapticEnabled = enabled + } } private fun SensorReadingEntity.toDomainModel() = SensorReading( diff --git a/app/src/main/java/com/shreyash/sensorapp/data/sensor/HapticManager.kt b/app/src/main/java/com/shreyash/sensorapp/data/sensor/HapticManager.kt new file mode 100644 index 0000000..230c0cc --- /dev/null +++ b/app/src/main/java/com/shreyash/sensorapp/data/sensor/HapticManager.kt @@ -0,0 +1,45 @@ +package com.shreyash.sensorapp.data.sensor + +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class HapticManager @Inject constructor( + @ApplicationContext context: Context +) { + private val vibrator: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vm = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager + vm?.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + } + + fun tick() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator?.vibrate( + VibrationEffect.createOneShot(20L, VibrationEffect.DEFAULT_AMPLITUDE) + ) + } else { + @Suppress("DEPRECATION") + vibrator?.vibrate(20L) + } + } + + fun doubleTick() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator?.vibrate( + VibrationEffect.createWaveform(longArrayOf(0L, 20L, 40L, 20L), intArrayOf(0, 128, 0, 128), -1) + ) + } else { + @Suppress("DEPRECATION") + vibrator?.vibrate(longArrayOf(0, 20, 40, 20), -1) + } + } +} diff --git a/app/src/main/java/com/shreyash/sensorapp/di/SensorModule.kt b/app/src/main/java/com/shreyash/sensorapp/di/SensorModule.kt index d2a3990..5a169e6 100644 --- a/app/src/main/java/com/shreyash/sensorapp/di/SensorModule.kt +++ b/app/src/main/java/com/shreyash/sensorapp/di/SensorModule.kt @@ -2,6 +2,7 @@ package com.shreyash.sensorapp.di import android.content.Context import android.hardware.SensorManager +import com.shreyash.sensorapp.data.sensor.HapticManager import com.shreyash.sensorapp.data.sensor.SensorDataSource import com.shreyash.sensorapp.data.repository.SensorRepositoryImpl import com.shreyash.sensorapp.domain.repository.SensorRepository diff --git a/app/src/main/java/com/shreyash/sensorapp/domain/repository/SensorRepository.kt b/app/src/main/java/com/shreyash/sensorapp/domain/repository/SensorRepository.kt index 4b84d61..5d92bff 100644 --- a/app/src/main/java/com/shreyash/sensorapp/domain/repository/SensorRepository.kt +++ b/app/src/main/java/com/shreyash/sensorapp/domain/repository/SensorRepository.kt @@ -34,4 +34,8 @@ interface SensorRepository { suspend fun getDelay(): Int suspend fun setDelay(delay: Int) + + fun isHapticEnabled(): Boolean + + fun setHapticEnabled(enabled: Boolean) } 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 1960657..654ca26 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 @@ -43,9 +43,11 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.shreyash.sensorapp.presentation.theme.SensorAppTheme +import kotlin.math.abs import kotlin.math.cos import kotlin.math.roundToInt import kotlin.math.sin +import kotlin.math.sqrt @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -54,6 +56,8 @@ fun CompassScreen( viewModel: CompassViewModel = hiltViewModel() ) { val heading by viewModel.heading.collectAsStateWithLifecycle() + val pitch by viewModel.pitch.collectAsStateWithLifecycle() + val roll by viewModel.roll.collectAsStateWithLifecycle() val isAvailable by viewModel.isAvailable.collectAsStateWithLifecycle() val lifecycleOwner = LocalLifecycleOwner.current @@ -86,6 +90,8 @@ fun CompassScreen( ) { padding -> CompassScreenContent( heading = heading, + pitch = pitch, + roll = roll, isAvailable = isAvailable, modifier = Modifier.padding(padding) ) @@ -95,6 +101,8 @@ fun CompassScreen( @Composable private fun CompassScreenContent( heading: Float, + pitch: Float, + roll: Float, isAvailable: Boolean, modifier: Modifier = Modifier ) { @@ -120,6 +128,8 @@ private fun CompassScreenContent( ) { CompassView( heading = heading, + pitch = pitch, + roll = roll, modifier = Modifier .fillMaxWidth() .aspectRatio(1f) @@ -168,9 +178,13 @@ private fun CompassScreenContent( @Composable private fun CompassView( heading: Float, + pitch: Float, + roll: Float, modifier: Modifier = Modifier ) { val animatedHeading = remember { Animatable(0f) } + val animatedPitch = remember { Animatable(0f) } + val animatedRoll = remember { Animatable(0f) } LaunchedEffect(heading) { val target = heading @@ -180,28 +194,38 @@ private fun CompassView( animatedHeading.animateTo(adjustedTarget, tween(durationMillis = 80)) } + LaunchedEffect(pitch) { + animatedPitch.animateTo(pitch, tween(120)) + } + + LaunchedEffect(roll) { + animatedRoll.animateTo(roll, tween(120)) + } + val outlineVariant = MaterialTheme.colorScheme.outlineVariant val onSurface = MaterialTheme.colorScheme.onSurface val primary = MaterialTheme.colorScheme.primary val surface = MaterialTheme.colorScheme.surface + val topIndicatorColor = Color(0xFFFF4444) + Canvas(modifier = modifier.padding(8.dp)) { val cx = size.width / 2f val cy = size.height / 2f - val r = minOf(cx, cy) + val canvasR = minOf(cx, cy) * 0.78f val canvas = drawContext.canvas.nativeCanvas canvas.save() canvas.rotate(-animatedHeading.value, cx, cy) - drawCircle(color = outlineVariant, radius = r, center = Offset(cx, cy), style = Stroke(2.dp.toPx())) - drawCircle(color = outlineVariant.copy(alpha = 0.2f), radius = r * 0.96f, center = Offset(cx, cy), style = Stroke(1.dp.toPx())) + drawCircle(color = outlineVariant, radius = canvasR, center = Offset(cx, cy), style = Stroke(2.dp.toPx())) + drawCircle(color = outlineVariant.copy(alpha = 0.2f), radius = canvasR * 0.96f, center = Offset(cx, cy), style = Stroke(1.dp.toPx())) for (i in 0 until 72) { val angle = Math.toRadians((i * 5).toDouble()).toFloat() val isMajor = i % 6 == 0 - val tickLen = if (isMajor) r * 0.1f else r * 0.05f - val outerR = if (isMajor) r * 0.88f else r * 0.9f + val tickLen = if (isMajor) canvasR * 0.1f else canvasR * 0.05f + val outerR = if (isMajor) canvasR * 0.88f else canvasR * 0.9f val x1 = cx + outerR * sin(angle) val y1 = cy - outerR * cos(angle) val x2 = cx + (outerR - tickLen) * sin(angle) @@ -214,65 +238,168 @@ private fun CompassView( ) } - val paint = android.graphics.Paint().apply { - textSize = 34f + val labelR = canvasR * 0.65f + + val northPaint = android.graphics.Paint().apply { + textSize = 60f textAlign = android.graphics.Paint.Align.CENTER isAntiAlias = true isFakeBoldText = true + color = android.graphics.Color.rgb(255, 80, 80) + } + val cardinalPaint = android.graphics.Paint().apply { + textSize = 50f + textAlign = android.graphics.Paint.Align.CENTER + isAntiAlias = true + isFakeBoldText = true + color = android.graphics.Color.WHITE + } + val intercardinalPaint = android.graphics.Paint().apply { + textSize = 40f + textAlign = android.graphics.Paint.Align.CENTER + isAntiAlias = true + color = android.graphics.Color.WHITE + alpha = 180 + } + val degreePaint = android.graphics.Paint().apply { + textSize = 17f + textAlign = android.graphics.Paint.Align.CENTER + isAntiAlias = true + color = android.graphics.Color.WHITE + alpha = 140 } - val labelR = r * 0.72f - val compassLabels = listOf( - "N" to 0f, "NE" to 45f, "E" to 90f, "SE" to 135f, - "S" to 180f, "SW" to 225f, "W" to 270f, "NW" to 315f + val outerLabelR = minOf(cx, cy) * 0.82f + val interLabelR = canvasR * 0.72f + + val cardinalEntries = listOf( + 0 to ("N" to northPaint), + 90 to ("E" to cardinalPaint), + 180 to ("S" to cardinalPaint), + 270 to ("W" to cardinalPaint) ) - for ((label, angleDeg) in compassLabels) { - val a = Math.toRadians(angleDeg.toDouble()).toFloat() + for ((deg, lp) in cardinalEntries) { + val a = Math.toRadians(deg.toDouble()).toFloat() val lx = cx + labelR * sin(a) val ly = cy - labelR * cos(a) - paint.color = if (label == "N") android.graphics.Color.rgb(255, 80, 80) - else android.graphics.Color.WHITE - canvas.drawText(label, lx, ly + 12f, paint) + canvas.save() + canvas.translate(lx, ly) + canvas.rotate(deg.toFloat()) + canvas.drawText(lp.first, 0f, 0f, lp.second) + canvas.restore() + } + + val degreeEntries = (0 until 360 step 15).map { deg -> + deg to ("$deg" to degreePaint) + } + for ((deg, lp) in degreeEntries) { + val a = Math.toRadians(deg.toDouble()).toFloat() + val lx = cx + outerLabelR * sin(a) + val ly = cy - outerLabelR * cos(a) + canvas.save() + canvas.translate(lx, ly) + canvas.rotate(deg.toFloat()) + canvas.drawText(lp.first, 0f, 0f, lp.second) + canvas.restore() } - val degreeLabels = listOf(30, 60, 120, 150, 210, 240, 300, 330) - paint.textSize = 20f - paint.alpha = 120 - val degreeLabelR = r * 0.76f - for (deg in degreeLabels) { + val interEntries = listOf( + 45 to ("NE" to intercardinalPaint), + 135 to ("SE" to intercardinalPaint), + 225 to ("SW" to intercardinalPaint), + 315 to ("NW" to intercardinalPaint) + ) + for ((deg, lp) in interEntries) { val a = Math.toRadians(deg.toDouble()).toFloat() - val lx = cx + degreeLabelR * sin(a) - val ly = cy - degreeLabelR * cos(a) - canvas.drawText("$deg", lx, ly + 7f, paint) + val lx = cx + interLabelR * sin(a) + val ly = cy - interLabelR * cos(a) + canvas.save() + canvas.translate(lx, ly) + canvas.rotate(deg.toFloat()) + canvas.drawText(lp.first, 0f, 0f, lp.second) + canvas.restore() } canvas.restore() - val topIndicator = Path().apply { - moveTo(cx, cy - r * 0.9f) - lineTo(cx - r * 0.08f, cy - r * 0.78f) - lineTo(cx + r * 0.08f, cy - r * 0.78f) - close() - } - drawPath(topIndicator, color = Color(0xFFFF4444)) + val edgeR = minOf(cx, cy) + val indicatorBase = edgeR * 0.92f + val indicatorTip = edgeR * 0.99f + drawPath( + Path().apply { + moveTo(cx, cy - indicatorTip) + lineTo(cx - edgeR * 0.09f, cy - indicatorBase) + lineTo(cx + edgeR * 0.09f, cy - indicatorBase) + close() + }, + color = topIndicatorColor + ) - val bottomIndicator = Path().apply { - moveTo(cx, cy + r * 0.9f) - lineTo(cx - r * 0.06f, cy + r * 0.78f) - lineTo(cx + r * 0.06f, cy + r * 0.78f) - close() - } - drawPath(bottomIndicator, color = Color(0xFF444444)) + drawPath( + Path().apply { + moveTo(cx, cy + indicatorTip) + lineTo(cx - edgeR * 0.06f, cy + indicatorBase) + lineTo(cx + edgeR * 0.06f, cy + indicatorBase) + close() + }, + color = Color(0xFF444444) + ) + val levelR = 18.dp.toPx() drawCircle( color = surface, - radius = 6.dp.toPx(), + radius = levelR, center = Offset(cx, cy) ) drawCircle( - color = primary, - radius = 3.dp.toPx(), - center = Offset(cx, cy) + color = outlineVariant.copy(alpha = 0.25f), + radius = levelR, + center = Offset(cx, cy), + style = Stroke(1.5.dp.toPx()) + ) + drawCircle( + color = outlineVariant.copy(alpha = 0.1f), + radius = levelR * 0.7f, + center = Offset(cx, cy), + style = Stroke(0.5.dp.toPx()) + ) + drawLine( + outlineVariant.copy(alpha = 0.15f), + Offset(cx - levelR, cy), + Offset(cx + levelR, cy), + strokeWidth = 0.5.dp.toPx() + ) + drawLine( + outlineVariant.copy(alpha = 0.15f), + Offset(cx, cy - levelR), + Offset(cx, cy + levelR), + strokeWidth = 0.5.dp.toPx() + ) + + val maxTilt = 45f + val dx = (animatedRoll.value / maxTilt).coerceIn(-1f, 1f) * levelR * 0.65f + val dy = (animatedPitch.value / maxTilt).coerceIn(-1f, 1f) * levelR * 0.65f + val tiltMag = sqrt(dx * dx + dy * dy) / (levelR * 0.65f) + val dotColor = when { + tiltMag < 0.35f -> Color(0xFF4CAF50) + tiltMag < 0.7f -> Color(0xFFFFC107) + else -> Color(0xFFE53935) + } + val dotPos = Offset(cx + dx, cy + dy) + drawCircle( + color = dotColor.copy(alpha = 0.25f), + radius = 10.dp.toPx(), + center = dotPos + ) + drawCircle( + color = Color.White.copy(alpha = 0.6f), + radius = 6.dp.toPx(), + center = dotPos + ) + drawCircle( + color = dotColor, + radius = 4.5.dp.toPx(), + center = dotPos ) } } @@ -308,6 +435,8 @@ private fun PreviewCompassScreen() { ) { padding -> CompassScreenContent( heading = 45f, + pitch = 5f, + roll = -3f, isAvailable = true, modifier = Modifier.padding(padding) ) @@ -337,6 +466,8 @@ private fun PreviewCompassScreenUnavailable() { ) { padding -> CompassScreenContent( heading = 0f, + pitch = 0f, + roll = 0f, isAvailable = false, modifier = Modifier.padding(padding) ) diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/compass/CompassViewModel.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/compass/CompassViewModel.kt index 8d64cd7..333b005 100644 --- a/app/src/main/java/com/shreyash/sensorapp/presentation/compass/CompassViewModel.kt +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/compass/CompassViewModel.kt @@ -24,6 +24,12 @@ class CompassViewModel @Inject constructor( private val _heading = MutableStateFlow(0f) val heading: StateFlow = _heading.asStateFlow() + private val _pitch = MutableStateFlow(0f) + val pitch: StateFlow = _pitch.asStateFlow() + + private val _roll = MutableStateFlow(0f) + val roll: StateFlow = _roll.asStateFlow() + private val _isAvailable = MutableStateFlow(true) val isAvailable: StateFlow = _isAvailable.asStateFlow() @@ -48,9 +54,11 @@ class CompassViewModel @Inject constructor( combine( repository.observeSensor(SensorType.ACCELEROMETER), repository.observeSensor(SensorType.MAGNETOMETER), - ::computeHeading - ).collect { heading -> - _heading.value = heading + ::computeOrientation + ).collect { (h, p, r) -> + _heading.value = h + _pitch.value = p + _roll.value = r } } } @@ -60,7 +68,7 @@ class CompassViewModel @Inject constructor( sensorJob = null } - private fun computeHeading(accel: SensorReading, mag: SensorReading): Float { + private fun computeOrientation(accel: SensorReading, mag: SensorReading): Triple { val rotationMatrix = FloatArray(9) val success = SensorManager.getRotationMatrix( rotationMatrix, null, @@ -71,9 +79,12 @@ class CompassViewModel @Inject constructor( val orientation = FloatArray(3) SensorManager.getOrientation(rotationMatrix, orientation) val azimuthDeg = Math.toDegrees(orientation[0].toDouble()).toFloat() - return (azimuthDeg + 360) % 360 + val heading = (azimuthDeg + 360) % 360 + val pitchDeg = Math.toDegrees(orientation[1].toDouble()).toFloat() + val rollDeg = Math.toDegrees(orientation[2].toDouble()).toFloat() + return Triple(heading, pitchDeg, rollDeg) } - return _heading.value + return Triple(_heading.value, _pitch.value, _roll.value) } override fun onCleared() { diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/DetailViewModel.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/DetailViewModel.kt index 68e68f8..628da35 100644 --- a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/DetailViewModel.kt +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/DetailViewModel.kt @@ -1,8 +1,8 @@ package com.shreyash.sensorapp.presentation.detail -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.shreyash.sensorapp.data.sensor.HapticManager import com.shreyash.sensorapp.domain.model.SensorReading import com.shreyash.sensorapp.domain.model.SensorType import com.shreyash.sensorapp.domain.usecase.LogSensorReadingUseCase @@ -16,18 +16,18 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +import kotlin.math.sqrt @HiltViewModel class DetailViewModel @Inject constructor( private val observeSensorUseCase: ObserveSensorUseCase, private val logSensorReadingUseCase: LogSensorReadingUseCase, private val repository: SensorRepository, - savedStateHandle: SavedStateHandle + private val hapticManager: HapticManager ) : ViewModel() { - val sensorType: SensorType = SensorType.valueOf( - savedStateHandle.get("sensorType") ?: "ACCELEROMETER" - ) + private var _sensorType: SensorType = SensorType.ACCELEROMETER + val sensorType: SensorType get() = _sensorType private val _currentReading = MutableStateFlow(null) val currentReading: StateFlow = _currentReading.asStateFlow() @@ -40,8 +40,13 @@ class DetailViewModel @Inject constructor( private var sensorJob: Job? = null private var currentSessionId: Long? = null + private var previousReading: SensorReading? = null + private var initialized = false - init { + fun initialize(sensorType: SensorType) { + if (initialized) return + initialized = true + _sensorType = sensorType startObserving() } @@ -49,6 +54,9 @@ class DetailViewModel @Inject constructor( sensorJob?.cancel() sensorJob = viewModelScope.launch { observeSensorUseCase(sensorType).collect { reading -> + val prev = previousReading + previousReading = reading + _currentReading.value = reading _chartReadings.update { buffer -> (buffer + reading).takeLast(60) @@ -56,7 +64,42 @@ class DetailViewModel @Inject constructor( if (_isLogging.value) { logSensorReadingUseCase(reading) } + + if (repository.isHapticEnabled()) { + checkHapticTriggers(reading, prev) + } + } + } + } + + private fun checkHapticTriggers(reading: SensorReading, prev: SensorReading?) { + when (sensorType) { + SensorType.PROXIMITY -> { + val currVal = reading.values.getOrNull(0) ?: return + val prevVal = prev?.values?.getOrNull(0) ?: return + val currObstructed = currVal < 1f + val prevObstructed = prevVal < 1f + if (prevObstructed != currObstructed) { + hapticManager.doubleTick() + } + } + SensorType.STEP_COUNTER -> { + val currVal = reading.values.getOrNull(0) ?: 0f + val prevVal = prev?.values?.getOrNull(0) ?: currVal + if (currVal > prevVal) { + hapticManager.tick() + } + } + SensorType.GYROSCOPE -> { + val gx = reading.values.getOrNull(0) ?: 0f + val gy = reading.values.getOrNull(1) ?: 0f + val gz = reading.values.getOrNull(2) ?: 0f + val magnitude = sqrt((gx * gx + gy * gy + gz * gz).toDouble()).toFloat() + if (magnitude > 5f) { + hapticManager.tick() + } } + else -> {} } } 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 index 37c4d85..a1535b2 100644 --- a/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScaffold.kt +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScaffold.kt @@ -32,6 +32,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.remember import androidx.compose.runtime.rememberCoroutineScope @@ -66,6 +67,10 @@ fun SensorDetailScaffold( val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() + LaunchedEffect(sensorType) { + viewModel.initialize(sensorType) + } + val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/settings/SettingsScreen.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/settings/SettingsScreen.kt index 9c94b10..8638a3e 100644 --- a/app/src/main/java/com/shreyash/sensorapp/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/settings/SettingsScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults @@ -51,6 +52,7 @@ fun SettingsScreen( ) { val selectedDelay by viewModel.selectedDelay.collectAsStateWithLifecycle() val totalRows by viewModel.totalRows.collectAsStateWithLifecycle() + val hapticEnabled by viewModel.hapticEnabled.collectAsStateWithLifecycle() Scaffold( topBar = { @@ -70,7 +72,9 @@ fun SettingsScreen( SettingsScreenContent( selectedDelay = selectedDelay, totalRows = totalRows, + hapticEnabled = hapticEnabled, onDelayChanged = { viewModel.setDelay(it) }, + onHapticToggle = { viewModel.setHapticEnabled(it) }, modifier = Modifier.padding(padding) ) } @@ -80,7 +84,9 @@ fun SettingsScreen( private fun SettingsScreenContent( selectedDelay: Int, totalRows: Int, + hapticEnabled: Boolean, onDelayChanged: (Int) -> Unit, + onHapticToggle: (Boolean) -> Unit, modifier: Modifier = Modifier ) { Column( @@ -125,6 +131,43 @@ private fun SettingsScreenContent( Spacer(Modifier.height(24.dp)) + SettingsSection(title = "Haptic Feedback") { + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onHapticToggle(!hapticEnabled) } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Sensor haptics", + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "Vibrate on proximity change, step count, and fast rotation", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(Modifier.width(16.dp)) + Switch( + checked = hapticEnabled, + onCheckedChange = onHapticToggle + ) + } + } + } + + Spacer(Modifier.height(24.dp)) + SettingsSection(title = "Database Stats") { Card( shape = RoundedCornerShape(12.dp), @@ -247,7 +290,9 @@ private fun PreviewSettingsScreen() { SettingsScreenContent( selectedDelay = 2, totalRows = 15234, + hapticEnabled = true, onDelayChanged = {}, + onHapticToggle = {}, modifier = Modifier.padding(padding) ) } diff --git a/app/src/main/java/com/shreyash/sensorapp/presentation/settings/SettingsViewModel.kt b/app/src/main/java/com/shreyash/sensorapp/presentation/settings/SettingsViewModel.kt index 17c95bf..879e934 100644 --- a/app/src/main/java/com/shreyash/sensorapp/presentation/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/shreyash/sensorapp/presentation/settings/SettingsViewModel.kt @@ -21,10 +21,14 @@ class SettingsViewModel @Inject constructor( private val _totalRows = MutableStateFlow(0) val totalRows: StateFlow = _totalRows.asStateFlow() + private val _hapticEnabled = MutableStateFlow(true) + val hapticEnabled: StateFlow = _hapticEnabled.asStateFlow() + init { viewModelScope.launch { _selectedDelay.value = repository.getDelay() _totalRows.value = repository.getTotalRowCount() + _hapticEnabled.value = repository.isHapticEnabled() } } @@ -35,6 +39,13 @@ class SettingsViewModel @Inject constructor( } } + fun setHapticEnabled(enabled: Boolean) { + viewModelScope.launch { + repository.setHapticEnabled(enabled) + _hapticEnabled.value = enabled + } + } + companion object { val delayOptions = listOf( 0 to "FASTEST",