Skip to content

Commit a5de99a

Browse files
authored
Haptic feedback + proximity/perf fixes (#15)
* Fix: capture surface color variable before Canvas scope * Polish compass UI: remove LIVE indicator and card background * Add compass disclaimer at bottom of screen * Fix: run lint and release on PR merge (closed + merged event) * Polish proximity sensor: add NEAR/FAR status with visual bar indicator * Simplify proximity display to OBSTRUCTED/CLEAR with usage hint * Polish all sensor screens: light (brightness levels + bar), barometer (weather conditions), usage hints for all sensors * Add 3D rotating cube visualization for gyroscope sensor * 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) * Add haptic feedback (#14) + fix proximity crash - HapticManager: tick/doubleTick vibration on proximity change, step count, gyro rotation - Manifest: add VIBRATE permission (missing — caused crash) - DetailViewModel: fix sensorType init (was always defaulting to ACCELEROMETER via SavedStateHandle) - SensorDetailScaffold: call viewModel.initialize(sensorType) via LaunchedEffect - Settings: add Haptic Feedback toggle switch - SettingsViewModel: expose hapticEnabled state
1 parent 7582dc5 commit a5de99a

9 files changed

Lines changed: 171 additions & 6 deletions

File tree

‎app/src/main/AndroidManifest.xml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
33

4+
<uses-permission android:name="android.permission.VIBRATE" />
5+
46
<!-- Declared but requested just-in-time -->
57
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
68

‎app/src/main/java/com/shreyash/sensorapp/data/repository/SensorRepositoryImpl.kt‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ class SensorRepositoryImpl @Inject constructor(
2727
@Volatile
2828
private var currentDelay: Int = android.hardware.SensorManager.SENSOR_DELAY_UI
2929

30+
@Volatile
31+
private var hapticEnabled: Boolean = true
32+
3033
override fun observeSensor(sensorType: SensorType): Flow<SensorReading> {
3134
return sensorDataSource.observeSensor(sensorType, currentDelay)
3235
}
@@ -114,6 +117,12 @@ class SensorRepositoryImpl @Inject constructor(
114117
override suspend fun setDelay(delay: Int) {
115118
currentDelay = delay
116119
}
120+
121+
override fun isHapticEnabled(): Boolean = hapticEnabled
122+
123+
override fun setHapticEnabled(enabled: Boolean) {
124+
hapticEnabled = enabled
125+
}
117126
}
118127

119128
private fun SensorReadingEntity.toDomainModel() = SensorReading(
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.shreyash.sensorapp.data.sensor
2+
3+
import android.content.Context
4+
import android.os.Build
5+
import android.os.VibrationEffect
6+
import android.os.Vibrator
7+
import android.os.VibratorManager
8+
import dagger.hilt.android.qualifiers.ApplicationContext
9+
import javax.inject.Inject
10+
import javax.inject.Singleton
11+
12+
@Singleton
13+
class HapticManager @Inject constructor(
14+
@ApplicationContext context: Context
15+
) {
16+
private val vibrator: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
17+
val vm = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
18+
vm?.defaultVibrator
19+
} else {
20+
@Suppress("DEPRECATION")
21+
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
22+
}
23+
24+
fun tick() {
25+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
26+
vibrator?.vibrate(
27+
VibrationEffect.createOneShot(20L, VibrationEffect.DEFAULT_AMPLITUDE)
28+
)
29+
} else {
30+
@Suppress("DEPRECATION")
31+
vibrator?.vibrate(20L)
32+
}
33+
}
34+
35+
fun doubleTick() {
36+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
37+
vibrator?.vibrate(
38+
VibrationEffect.createWaveform(longArrayOf(0L, 20L, 40L, 20L), intArrayOf(0, 128, 0, 128), -1)
39+
)
40+
} else {
41+
@Suppress("DEPRECATION")
42+
vibrator?.vibrate(longArrayOf(0, 20, 40, 20), -1)
43+
}
44+
}
45+
}

‎app/src/main/java/com/shreyash/sensorapp/di/SensorModule.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.shreyash.sensorapp.di
22

33
import android.content.Context
44
import android.hardware.SensorManager
5+
import com.shreyash.sensorapp.data.sensor.HapticManager
56
import com.shreyash.sensorapp.data.sensor.SensorDataSource
67
import com.shreyash.sensorapp.data.repository.SensorRepositoryImpl
78
import com.shreyash.sensorapp.domain.repository.SensorRepository

‎app/src/main/java/com/shreyash/sensorapp/domain/repository/SensorRepository.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,8 @@ interface SensorRepository {
3434
suspend fun getDelay(): Int
3535

3636
suspend fun setDelay(delay: Int)
37+
38+
fun isHapticEnabled(): Boolean
39+
40+
fun setHapticEnabled(enabled: Boolean)
3741
}

‎app/src/main/java/com/shreyash/sensorapp/presentation/detail/DetailViewModel.kt‎

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
package com.shreyash.sensorapp.presentation.detail
22

3-
import androidx.lifecycle.SavedStateHandle
43
import androidx.lifecycle.ViewModel
54
import androidx.lifecycle.viewModelScope
5+
import com.shreyash.sensorapp.data.sensor.HapticManager
66
import com.shreyash.sensorapp.domain.model.SensorReading
77
import com.shreyash.sensorapp.domain.model.SensorType
88
import com.shreyash.sensorapp.domain.usecase.LogSensorReadingUseCase
@@ -16,18 +16,18 @@ import kotlinx.coroutines.flow.asStateFlow
1616
import kotlinx.coroutines.flow.update
1717
import kotlinx.coroutines.launch
1818
import javax.inject.Inject
19+
import kotlin.math.sqrt
1920

2021
@HiltViewModel
2122
class DetailViewModel @Inject constructor(
2223
private val observeSensorUseCase: ObserveSensorUseCase,
2324
private val logSensorReadingUseCase: LogSensorReadingUseCase,
2425
private val repository: SensorRepository,
25-
savedStateHandle: SavedStateHandle
26+
private val hapticManager: HapticManager
2627
) : ViewModel() {
2728

28-
val sensorType: SensorType = SensorType.valueOf(
29-
savedStateHandle.get<String>("sensorType") ?: "ACCELEROMETER"
30-
)
29+
private var _sensorType: SensorType = SensorType.ACCELEROMETER
30+
val sensorType: SensorType get() = _sensorType
3131

3232
private val _currentReading = MutableStateFlow<SensorReading?>(null)
3333
val currentReading: StateFlow<SensorReading?> = _currentReading.asStateFlow()
@@ -40,23 +40,66 @@ class DetailViewModel @Inject constructor(
4040

4141
private var sensorJob: Job? = null
4242
private var currentSessionId: Long? = null
43+
private var previousReading: SensorReading? = null
44+
private var initialized = false
4345

44-
init {
46+
fun initialize(sensorType: SensorType) {
47+
if (initialized) return
48+
initialized = true
49+
_sensorType = sensorType
4550
startObserving()
4651
}
4752

4853
fun startObserving() {
4954
sensorJob?.cancel()
5055
sensorJob = viewModelScope.launch {
5156
observeSensorUseCase(sensorType).collect { reading ->
57+
val prev = previousReading
58+
previousReading = reading
59+
5260
_currentReading.value = reading
5361
_chartReadings.update { buffer ->
5462
(buffer + reading).takeLast(60)
5563
}
5664
if (_isLogging.value) {
5765
logSensorReadingUseCase(reading)
5866
}
67+
68+
if (repository.isHapticEnabled()) {
69+
checkHapticTriggers(reading, prev)
70+
}
71+
}
72+
}
73+
}
74+
75+
private fun checkHapticTriggers(reading: SensorReading, prev: SensorReading?) {
76+
when (sensorType) {
77+
SensorType.PROXIMITY -> {
78+
val currVal = reading.values.getOrNull(0) ?: return
79+
val prevVal = prev?.values?.getOrNull(0) ?: return
80+
val currObstructed = currVal < 1f
81+
val prevObstructed = prevVal < 1f
82+
if (prevObstructed != currObstructed) {
83+
hapticManager.doubleTick()
84+
}
85+
}
86+
SensorType.STEP_COUNTER -> {
87+
val currVal = reading.values.getOrNull(0) ?: 0f
88+
val prevVal = prev?.values?.getOrNull(0) ?: currVal
89+
if (currVal > prevVal) {
90+
hapticManager.tick()
91+
}
92+
}
93+
SensorType.GYROSCOPE -> {
94+
val gx = reading.values.getOrNull(0) ?: 0f
95+
val gy = reading.values.getOrNull(1) ?: 0f
96+
val gz = reading.values.getOrNull(2) ?: 0f
97+
val magnitude = sqrt((gx * gx + gy * gy + gz * gz).toDouble()).toFloat()
98+
if (magnitude > 5f) {
99+
hapticManager.tick()
100+
}
59101
}
102+
else -> {}
60103
}
61104
}
62105

‎app/src/main/java/com/shreyash/sensorapp/presentation/detail/SensorDetailScaffold.kt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import androidx.compose.material3.Text
3232
import androidx.compose.material3.TopAppBarDefaults
3333
import androidx.compose.runtime.Composable
3434
import androidx.compose.runtime.DisposableEffect
35+
import androidx.compose.runtime.LaunchedEffect
3536
import androidx.compose.runtime.getValue
3637
import androidx.compose.runtime.remember
3738
import androidx.compose.runtime.rememberCoroutineScope
@@ -66,6 +67,10 @@ fun SensorDetailScaffold(
6667
val snackbarHostState = remember { SnackbarHostState() }
6768
val scope = rememberCoroutineScope()
6869

70+
LaunchedEffect(sensorType) {
71+
viewModel.initialize(sensorType)
72+
}
73+
6974
val lifecycleOwner = LocalLifecycleOwner.current
7075
DisposableEffect(lifecycleOwner) {
7176
val observer = LifecycleEventObserver { _, event ->

‎app/src/main/java/com/shreyash/sensorapp/presentation/settings/SettingsScreen.kt‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import androidx.compose.material3.IconButton
2828
import androidx.compose.material3.MaterialTheme
2929
import androidx.compose.material3.RadioButton
3030
import androidx.compose.material3.Scaffold
31+
import androidx.compose.material3.Switch
3132
import androidx.compose.material3.Text
3233
import androidx.compose.material3.TextButton
3334
import androidx.compose.material3.TopAppBarDefaults
@@ -51,6 +52,7 @@ fun SettingsScreen(
5152
) {
5253
val selectedDelay by viewModel.selectedDelay.collectAsStateWithLifecycle()
5354
val totalRows by viewModel.totalRows.collectAsStateWithLifecycle()
55+
val hapticEnabled by viewModel.hapticEnabled.collectAsStateWithLifecycle()
5456

5557
Scaffold(
5658
topBar = {
@@ -70,7 +72,9 @@ fun SettingsScreen(
7072
SettingsScreenContent(
7173
selectedDelay = selectedDelay,
7274
totalRows = totalRows,
75+
hapticEnabled = hapticEnabled,
7376
onDelayChanged = { viewModel.setDelay(it) },
77+
onHapticToggle = { viewModel.setHapticEnabled(it) },
7478
modifier = Modifier.padding(padding)
7579
)
7680
}
@@ -80,7 +84,9 @@ fun SettingsScreen(
8084
private fun SettingsScreenContent(
8185
selectedDelay: Int,
8286
totalRows: Int,
87+
hapticEnabled: Boolean,
8388
onDelayChanged: (Int) -> Unit,
89+
onHapticToggle: (Boolean) -> Unit,
8490
modifier: Modifier = Modifier
8591
) {
8692
Column(
@@ -125,6 +131,43 @@ private fun SettingsScreenContent(
125131

126132
Spacer(Modifier.height(24.dp))
127133

134+
SettingsSection(title = "Haptic Feedback") {
135+
Card(
136+
shape = RoundedCornerShape(12.dp),
137+
colors = CardDefaults.cardColors(
138+
containerColor = MaterialTheme.colorScheme.surfaceVariant
139+
)
140+
) {
141+
Row(
142+
modifier = Modifier
143+
.fillMaxWidth()
144+
.clickable { onHapticToggle(!hapticEnabled) }
145+
.padding(16.dp),
146+
verticalAlignment = Alignment.CenterVertically,
147+
horizontalArrangement = Arrangement.SpaceBetween
148+
) {
149+
Column(modifier = Modifier.weight(1f)) {
150+
Text(
151+
text = "Sensor haptics",
152+
style = MaterialTheme.typography.bodyLarge
153+
)
154+
Text(
155+
text = "Vibrate on proximity change, step count, and fast rotation",
156+
style = MaterialTheme.typography.bodySmall,
157+
color = MaterialTheme.colorScheme.onSurfaceVariant
158+
)
159+
}
160+
Spacer(Modifier.width(16.dp))
161+
Switch(
162+
checked = hapticEnabled,
163+
onCheckedChange = onHapticToggle
164+
)
165+
}
166+
}
167+
}
168+
169+
Spacer(Modifier.height(24.dp))
170+
128171
SettingsSection(title = "Database Stats") {
129172
Card(
130173
shape = RoundedCornerShape(12.dp),
@@ -247,7 +290,9 @@ private fun PreviewSettingsScreen() {
247290
SettingsScreenContent(
248291
selectedDelay = 2,
249292
totalRows = 15234,
293+
hapticEnabled = true,
250294
onDelayChanged = {},
295+
onHapticToggle = {},
251296
modifier = Modifier.padding(padding)
252297
)
253298
}

‎app/src/main/java/com/shreyash/sensorapp/presentation/settings/SettingsViewModel.kt‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,14 @@ class SettingsViewModel @Inject constructor(
2121
private val _totalRows = MutableStateFlow(0)
2222
val totalRows: StateFlow<Int> = _totalRows.asStateFlow()
2323

24+
private val _hapticEnabled = MutableStateFlow(true)
25+
val hapticEnabled: StateFlow<Boolean> = _hapticEnabled.asStateFlow()
26+
2427
init {
2528
viewModelScope.launch {
2629
_selectedDelay.value = repository.getDelay()
2730
_totalRows.value = repository.getTotalRowCount()
31+
_hapticEnabled.value = repository.isHapticEnabled()
2832
}
2933
}
3034

@@ -35,6 +39,13 @@ class SettingsViewModel @Inject constructor(
3539
}
3640
}
3741

42+
fun setHapticEnabled(enabled: Boolean) {
43+
viewModelScope.launch {
44+
repository.setHapticEnabled(enabled)
45+
_hapticEnabled.value = enabled
46+
}
47+
}
48+
3849
companion object {
3950
val delayOptions = listOf(
4051
0 to "FASTEST",

0 commit comments

Comments
 (0)