Scaffold Android project per BRIEF.md
Single-module Kotlin/Compose app with manual DI container. Pure-Kotlin measurement math (orientation mapping, two-sample per-mode calibration, EMA smoothing, lock hysteresis, display deadband) fully unit-tested. Sensor fallback: game rotation vector -> gravity -> low-passed accelerometer. Live Level (manual Surface|Edge) and Angle (hold-to-zero) scaffolds; Ruler/Tools placeholders; stub Pro entitlement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.onthelevel"
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.jsjdesigns.onthelevel"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "0.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables { useSupportLibrary = true }
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
// Declared now so core/billing's real implementation lands without build changes (BRIEF.md §Monetization).
|
||||
implementation(libs.billing.ktx)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- BRIEF.md: an accelerometer is the minimum viable sensor; devices without one are unsupported. -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.sensor.accelerometer"
|
||||
android:required="true" />
|
||||
|
||||
<application
|
||||
android:name=".OnTheLevelApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.OnTheLevel">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait"
|
||||
tools:ignore="LockedOrientationActivity">
|
||||
<!-- Portrait lock is a BRIEF.md v1 decision: sensor axes are remapped in code,
|
||||
and edge mode rotates its own readout rather than the Activity. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.onthelevel
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Adjust
|
||||
import androidx.compose.material.icons.outlined.Handyman
|
||||
import androidx.compose.material.icons.outlined.SquareFoot
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.onthelevel.core.design.OnTheLevelTheme
|
||||
import com.onthelevel.feature.angle.AngleScreen
|
||||
import com.onthelevel.feature.level.LevelScreen
|
||||
import com.onthelevel.feature.ruler.RulerScreen
|
||||
import com.onthelevel.feature.tools.ToolsScreen
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
val container = (application as OnTheLevelApp).container
|
||||
setContent {
|
||||
OnTheLevelTheme {
|
||||
AppRoot(container)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class TopDestination(
|
||||
val route: String,
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
)
|
||||
|
||||
private val topDestinations = listOf(
|
||||
TopDestination("level", "Level", Icons.Outlined.Adjust),
|
||||
TopDestination("angle", "Angle", Icons.Outlined.SquareFoot),
|
||||
TopDestination("tools", "Tools", Icons.Outlined.Handyman),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun AppRoot(container: AppContainer) {
|
||||
val navController = rememberNavController()
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = backStackEntry?.destination?.route
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
topDestinations.forEach { destination ->
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == destination.route,
|
||||
onClick = {
|
||||
navController.navigate(destination.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = { Icon(destination.icon, contentDescription = null) },
|
||||
label = { Text(destination.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "level",
|
||||
modifier = Modifier.padding(padding),
|
||||
) {
|
||||
composable("level") { LevelScreen(container) }
|
||||
composable("angle") { AngleScreen(container) }
|
||||
composable("tools") {
|
||||
ToolsScreen(onOpenRuler = { navController.navigate("ruler") })
|
||||
}
|
||||
composable("ruler") { RulerScreen(container) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.onthelevel
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import com.onthelevel.core.billing.ProEntitlementRepository
|
||||
import com.onthelevel.core.billing.StubProEntitlementRepository
|
||||
import com.onthelevel.core.sensors.AndroidSensorSource
|
||||
import com.onthelevel.core.sensors.SensorSource
|
||||
import com.onthelevel.core.settings.SettingsRepository
|
||||
|
||||
/**
|
||||
* Manual application container — deliberately no DI framework for an app this size
|
||||
* (BRIEF.md §Sensor and measurement architecture).
|
||||
*/
|
||||
class AppContainer(context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
val sensorSource: SensorSource by lazy { AndroidSensorSource(appContext) }
|
||||
val settings: SettingsRepository by lazy { SettingsRepository(appContext) }
|
||||
val entitlement: ProEntitlementRepository by lazy { StubProEntitlementRepository() }
|
||||
}
|
||||
|
||||
class OnTheLevelApp : Application() {
|
||||
val container: AppContainer by lazy { AppContainer(this) }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.onthelevel.core.billing
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Pro entitlement state (BRIEF.md §Monetization). One permanent non-consumable
|
||||
* product; no subscription.
|
||||
*/
|
||||
data class Entitlement(
|
||||
val isPro: Boolean,
|
||||
val source: Source,
|
||||
) {
|
||||
enum class Source {
|
||||
/** No confirmation yet — the default, and always safe: free tools are complete. */
|
||||
NONE,
|
||||
/** Confirmed by Google Play this session. */
|
||||
PLAY_CONFIRMED,
|
||||
/** Previously Play-confirmed, restored from local cache (offline job-site use). */
|
||||
CACHED,
|
||||
}
|
||||
|
||||
companion object {
|
||||
val FREE = Entitlement(isPro = false, source = Source.NONE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONLY doorway between billing and the rest of the app. Feature code observes
|
||||
* [entitlement]; nothing outside core/billing may touch Play Billing types
|
||||
* (AUDIT.md: Pro boundary containment).
|
||||
*/
|
||||
interface ProEntitlementRepository {
|
||||
val entitlement: StateFlow<Entitlement>
|
||||
|
||||
/** Re-query owned purchases when possible (app resume, purchase flow completion). */
|
||||
suspend fun refresh()
|
||||
}
|
||||
|
||||
/**
|
||||
* Scaffold stand-in: everyone is free tier. Replaced by the Play Billing
|
||||
* implementation, which must:
|
||||
* - initialize lazily/async — free tools never block on Play services
|
||||
* - cache a Play-confirmed entitlement in DataStore for offline use
|
||||
* - never grant from an arbitrary preference value or a PENDING purchase
|
||||
*/
|
||||
class StubProEntitlementRepository : ProEntitlementRepository {
|
||||
private val state = MutableStateFlow(Entitlement.FREE)
|
||||
override val entitlement: StateFlow<Entitlement> = state.asStateFlow()
|
||||
override suspend fun refresh() = Unit
|
||||
}
|
||||
|
||||
// TODO(billing): PlayBillingEntitlementRepository against the declared billing-ktx
|
||||
// dependency, product id "on_the_level_pro", per the contract above.
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.onthelevel.core.design
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
|
||||
/**
|
||||
* Levels are used hands-free; the screen must not time out mid-measurement
|
||||
* (BRIEF.md §Level). Scoped to the composable, so leaving the screen releases it.
|
||||
*/
|
||||
@Composable
|
||||
fun KeepScreenOn() {
|
||||
val view = LocalView.current
|
||||
DisposableEffect(view) {
|
||||
view.keepScreenOn = true
|
||||
onDispose { view.keepScreenOn = false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.onthelevel.core.design
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Palette from the concept board (resources/Bubble Level Concepts.dc.html):
|
||||
* deep graphite glass, warm amber spirit fluid, lime for the locked state.
|
||||
* The design is dark-only in v1 — this is an instrument, not a document.
|
||||
* Per BRIEF.md, color never carries state alone; labels and numbers always accompany it.
|
||||
*/
|
||||
object LevelColors {
|
||||
val Graphite = Color(0xFF0A0A0B)
|
||||
val GraphiteRaised = Color(0xFF15181D)
|
||||
val PanelStroke = Color(0x12FFFFFF)
|
||||
val Panel = Color(0x0AFFFFFF)
|
||||
|
||||
val Amber = Color(0xFFFFC44E)
|
||||
val AmberHighlight = Color(0xFFFFEEB0)
|
||||
val AmberDeep = Color(0xFFE0961E)
|
||||
|
||||
val LimeLock = Color(0xFFCBEF5C)
|
||||
val LimeLockText = Color(0xFFEAFFC2)
|
||||
|
||||
val Cream = Color(0xFFEDEBE6)
|
||||
val TextPrimary = Color(0xFFF5F4F1)
|
||||
val TextDim = Color(0x6BFFFFFF)
|
||||
val TextFaint = Color(0x55FFFFFF)
|
||||
}
|
||||
|
||||
private val DarkScheme = darkColorScheme(
|
||||
primary = LevelColors.Amber,
|
||||
onPrimary = LevelColors.Graphite,
|
||||
secondary = LevelColors.LimeLock,
|
||||
onSecondary = LevelColors.Graphite,
|
||||
background = LevelColors.Graphite,
|
||||
onBackground = LevelColors.TextPrimary,
|
||||
surface = LevelColors.GraphiteRaised,
|
||||
onSurface = LevelColors.TextPrimary,
|
||||
surfaceVariant = LevelColors.GraphiteRaised,
|
||||
onSurfaceVariant = LevelColors.TextDim,
|
||||
outline = LevelColors.PanelStroke,
|
||||
)
|
||||
|
||||
// TODO(design): bundle Space Grotesk + IBM Plex Mono per the concept board.
|
||||
// Until then: system sans for labels, platform monospace for all numeric readouts
|
||||
// so digits don't jitter horizontally as values change.
|
||||
val ReadoutFontFamily = FontFamily.Monospace
|
||||
|
||||
private val LevelTypography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = ReadoutFontFamily,
|
||||
fontWeight = FontWeight.Light,
|
||||
fontSize = 96.sp,
|
||||
letterSpacing = (-2).sp,
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = ReadoutFontFamily,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 28.sp,
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 11.sp,
|
||||
letterSpacing = 2.sp,
|
||||
),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun OnTheLevelTheme(content: @Composable () -> Unit) {
|
||||
// Dark-only by design; isSystemInDarkTheme() intentionally unused in v1.
|
||||
MaterialTheme(
|
||||
colorScheme = DarkScheme,
|
||||
typography = LevelTypography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
|
||||
/**
|
||||
* Sensor selection order (BRIEF.md): GAME_ROTATION_VECTOR → GRAVITY → low-passed
|
||||
* ACCELEROMETER. Game rotation vector is deliberate — it excludes the magnetometer,
|
||||
* which lies near the steel this app is used against (AUDIT.md finding 1). No
|
||||
* heading is needed; only the gravity direction matters.
|
||||
*/
|
||||
class AndroidSensorSource(context: Context) : SensorSource {
|
||||
|
||||
private val sensorManager: SensorManager? =
|
||||
context.getSystemService(SensorManager::class.java)
|
||||
|
||||
private val selected: Pair<Sensor, SensorSource.Kind>? = sensorManager?.let { sm ->
|
||||
sm.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR)
|
||||
?.let { it to SensorSource.Kind.GAME_ROTATION_VECTOR }
|
||||
?: sm.getDefaultSensor(Sensor.TYPE_GRAVITY)
|
||||
?.let { it to SensorSource.Kind.GRAVITY }
|
||||
?: sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
|
||||
?.let { it to SensorSource.Kind.ACCELEROMETER }
|
||||
}
|
||||
|
||||
override val isAvailable: Boolean = selected != null
|
||||
|
||||
override val kind: SensorSource.Kind = selected?.second ?: SensorSource.Kind.NONE
|
||||
|
||||
override val gravity: Flow<GravitySample> = if (selected == null || sensorManager == null) {
|
||||
emptyFlow()
|
||||
} else {
|
||||
callbackFlow {
|
||||
val (sensor, sensorKind) = selected
|
||||
val rotationMatrix = FloatArray(9)
|
||||
// Raw-accelerometer fallback only: strip linear acceleration before the
|
||||
// measurement-layer EMA sees the sample. TODO(tune) against recorded traces.
|
||||
var lp: Triple<Double, Double, Double>? = null
|
||||
|
||||
val listener = object : SensorEventListener {
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
val sample = when (sensorKind) {
|
||||
SensorSource.Kind.GAME_ROTATION_VECTOR -> {
|
||||
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
|
||||
// R maps device → world; world-up expressed in the device frame
|
||||
// is R's third row. Scale to standard gravity.
|
||||
GravitySample(
|
||||
x = rotationMatrix[6] * STANDARD_GRAVITY,
|
||||
y = rotationMatrix[7] * STANDARD_GRAVITY,
|
||||
z = rotationMatrix[8] * STANDARD_GRAVITY,
|
||||
timestampNanos = event.timestamp,
|
||||
)
|
||||
}
|
||||
SensorSource.Kind.GRAVITY -> GravitySample(
|
||||
x = event.values[0].toDouble(),
|
||||
y = event.values[1].toDouble(),
|
||||
z = event.values[2].toDouble(),
|
||||
timestampNanos = event.timestamp,
|
||||
)
|
||||
else -> {
|
||||
val prev = lp
|
||||
val next = if (prev == null) {
|
||||
Triple(
|
||||
event.values[0].toDouble(),
|
||||
event.values[1].toDouble(),
|
||||
event.values[2].toDouble(),
|
||||
)
|
||||
} else {
|
||||
Triple(
|
||||
prev.first + ACCEL_LP_ALPHA * (event.values[0] - prev.first),
|
||||
prev.second + ACCEL_LP_ALPHA * (event.values[1] - prev.second),
|
||||
prev.third + ACCEL_LP_ALPHA * (event.values[2] - prev.third),
|
||||
)
|
||||
}
|
||||
lp = next
|
||||
GravitySample(next.first, next.second, next.third, event.timestamp)
|
||||
}
|
||||
}
|
||||
trySend(sample)
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
}
|
||||
|
||||
sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_GAME)
|
||||
awaitClose { sensorManager.unregisterListener(listener) }
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STANDARD_GRAVITY = 9.80665
|
||||
const val ACCEL_LP_ALPHA = 0.15
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Quantizes a stable measurement to 0.1° for display, with hysteresis so the readout
|
||||
* doesn't flicker between adjacent values when the input sits on a bucket boundary
|
||||
* (BRIEF.md: "0.1° resolution and a display deadband").
|
||||
*
|
||||
* A displayed value of 0.1° owns the interval [0.05, 0.15]; the readout only moves
|
||||
* once the input leaves that interval by more than [hysteresisMarginDegrees].
|
||||
*/
|
||||
class DisplayDeadband(
|
||||
private val resolutionDegrees: Double = 0.1,
|
||||
private val hysteresisMarginDegrees: Double = 0.03,
|
||||
) {
|
||||
private var displayed: Double? = null
|
||||
|
||||
fun update(stableDegrees: Double): Double {
|
||||
val current = displayed
|
||||
val next = if (current == null ||
|
||||
abs(stableDegrees - current) > resolutionDegrees / 2 + hysteresisMarginDegrees
|
||||
) {
|
||||
(stableDegrees / resolutionDegrees).roundToInt() * resolutionDegrees
|
||||
} else {
|
||||
current
|
||||
}
|
||||
displayed = next
|
||||
return next
|
||||
}
|
||||
|
||||
fun reset() { displayed = null }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
import kotlin.math.exp
|
||||
|
||||
/**
|
||||
* Time-constant-based exponential moving average — the "stable calibrated measurement"
|
||||
* filter (BRIEF.md value 2 of 3). Using a time constant instead of a fixed alpha keeps
|
||||
* smoothing identical across devices with different sensor rates.
|
||||
*/
|
||||
class Ema(private val tauSeconds: Double) {
|
||||
private var value: Double? = null
|
||||
|
||||
fun update(sample: Double, dtSeconds: Double): Double {
|
||||
val prev = value
|
||||
val next = if (prev == null || dtSeconds <= 0.0) {
|
||||
sample
|
||||
} else {
|
||||
val alpha = 1.0 - exp(-dtSeconds / tauSeconds)
|
||||
prev + alpha * (sample - prev)
|
||||
}
|
||||
value = next
|
||||
return next
|
||||
}
|
||||
|
||||
fun reset() { value = null }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
/**
|
||||
* Gravity (reaction) vector in the DEVICE coordinate frame, in m/s².
|
||||
* Android convention: x → right edge, y → top edge, z → out of the screen.
|
||||
* A phone lying screen-up on a level surface reads approximately (0, 0, +9.81).
|
||||
*/
|
||||
data class GravitySample(
|
||||
val x: Double,
|
||||
val y: Double,
|
||||
val z: Double,
|
||||
val timestampNanos: Long,
|
||||
)
|
||||
|
||||
/** The two physical orientations v1 supports (BRIEF.md). Selected manually — never auto-switched. */
|
||||
enum class LevelMode { SURFACE, EDGE }
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Level-lock state machine with hysteresis, dwell, and haptic debounce (BRIEF.md
|
||||
* §Sensor and measurement architecture). Operates ONLY on the stable calibrated
|
||||
* measurement — the same value the numeric readout shows. The lock must never
|
||||
* disagree with the number on screen.
|
||||
*
|
||||
* Time is injected (callers pass `nowMillis`) so transitions are unit-testable.
|
||||
*/
|
||||
class LockDetector(
|
||||
private val enterThresholdDegrees: Double = 0.2,
|
||||
private val exitThresholdDegrees: Double = 0.35,
|
||||
private val dwellMillis: Long = 400,
|
||||
private val feedbackDebounceMillis: Long = 3_000,
|
||||
) {
|
||||
init {
|
||||
require(exitThresholdDegrees > enterThresholdDegrees) {
|
||||
"Hysteresis requires exit > enter threshold"
|
||||
}
|
||||
}
|
||||
|
||||
data class Result(
|
||||
val isLocked: Boolean,
|
||||
/** True exactly once per lock acquisition, and only outside the debounce window. */
|
||||
val fireFeedback: Boolean,
|
||||
)
|
||||
|
||||
private var locked = false
|
||||
private var withinEnterSinceMillis: Long? = null
|
||||
private var lastFeedbackAtMillis: Long? = null
|
||||
|
||||
fun update(stableTiltDegrees: Double, nowMillis: Long): Result {
|
||||
val magnitude = abs(stableTiltDegrees)
|
||||
var fire = false
|
||||
|
||||
if (!locked) {
|
||||
if (magnitude <= enterThresholdDegrees) {
|
||||
val since = withinEnterSinceMillis ?: nowMillis.also { withinEnterSinceMillis = it }
|
||||
if (nowMillis - since >= dwellMillis) {
|
||||
locked = true
|
||||
val last = lastFeedbackAtMillis
|
||||
if (last == null || nowMillis - last >= feedbackDebounceMillis) {
|
||||
fire = true
|
||||
lastFeedbackAtMillis = nowMillis
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withinEnterSinceMillis = null
|
||||
}
|
||||
} else if (magnitude >= exitThresholdDegrees) {
|
||||
locked = false
|
||||
withinEnterSinceMillis = null
|
||||
}
|
||||
|
||||
return Result(isLocked = locked, fireFeedback = fire)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
locked = false
|
||||
withinEnterSinceMillis = null
|
||||
// lastFeedbackAtMillis survives reset on purpose: switching modes must not
|
||||
// defeat the haptic debounce.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.acos
|
||||
import kotlin.math.asin
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.math.tan
|
||||
|
||||
/**
|
||||
* Pure measurement math. No Android types — everything here is unit-testable
|
||||
* with synthetic and recorded gravity vectors (BRIEF.md §Testability).
|
||||
*
|
||||
* Angle sign conventions (documented so calibration and UI agree):
|
||||
* - Surface pitch: positive when the TOP edge of the device is higher.
|
||||
* - Surface roll: positive when the RIGHT edge of the device is higher.
|
||||
* - Edge level: positive when the end the device Y axis points toward is higher.
|
||||
*/
|
||||
object OrientationMath {
|
||||
|
||||
/** Angle between gravity and the screen normal — the single honest "how far off flat" number. */
|
||||
fun surfaceTiltMagnitudeDegrees(g: GravitySample): Double {
|
||||
val n = norm(g)
|
||||
if (n == 0.0) return 0.0
|
||||
return Math.toDegrees(acos((g.z / n).coerceIn(-1.0, 1.0)))
|
||||
}
|
||||
|
||||
fun surfacePitchDegrees(g: GravitySample): Double =
|
||||
Math.toDegrees(atan2(g.y, g.z))
|
||||
|
||||
fun surfaceRollDegrees(g: GravitySample): Double =
|
||||
Math.toDegrees(atan2(g.x, g.z))
|
||||
|
||||
/**
|
||||
* Edge mode: device standing on a long edge (gravity mostly along ±x).
|
||||
* The reading is the deviation of the resting edge from horizontal —
|
||||
* the component of gravity along the device Y axis.
|
||||
*/
|
||||
fun edgeLevelDegrees(g: GravitySample): Double {
|
||||
val n = norm(g)
|
||||
if (n == 0.0) return 0.0
|
||||
return Math.toDegrees(asin((g.y / n).coerceIn(-1.0, 1.0)))
|
||||
}
|
||||
|
||||
/** Secondary edge reading: how far the screen leans from vertical (plumb). */
|
||||
fun edgePlumbLeanDegrees(g: GravitySample): Double {
|
||||
val n = norm(g)
|
||||
if (n == 0.0) return 0.0
|
||||
return Math.toDegrees(asin((g.z / n).coerceIn(-1.0, 1.0)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Percent grade = tan(angle) × 100. Returns signed infinity at/beyond
|
||||
* [VERTICAL_GRADE_CUTOFF_DEGREES]; UI renders that as "∞" (BRIEF.md §Angle).
|
||||
*/
|
||||
fun percentGrade(angleDegrees: Double): Double {
|
||||
if (abs(angleDegrees) >= VERTICAL_GRADE_CUTOFF_DEGREES) {
|
||||
return if (angleDegrees > 0) Double.POSITIVE_INFINITY else Double.NEGATIVE_INFINITY
|
||||
}
|
||||
return tan(Math.toRadians(angleDegrees)) * 100.0
|
||||
}
|
||||
|
||||
const val VERTICAL_GRADE_CUTOFF_DEGREES = 89.5
|
||||
|
||||
private fun norm(g: GravitySample): Double = sqrt(g.x * g.x + g.y * g.y + g.z * g.z)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* The seam between Android sensor I/O and the pure measurement math. Tests inject
|
||||
* fakes emitting synthetic or recorded traces (BRIEF.md §Testability).
|
||||
*/
|
||||
interface SensorSource {
|
||||
/** False on devices with no usable tilt sensor: show the unsupported-device state. */
|
||||
val isAvailable: Boolean
|
||||
|
||||
/** Which physical sensor backs [gravity]; surfaced in the header status line. */
|
||||
val kind: Kind
|
||||
|
||||
/**
|
||||
* Device-frame gravity stream. Cold: registering happens on collection and
|
||||
* unregistering on cancellation, so lifecycle-aware collection automatically
|
||||
* satisfies "sensors registered only while a relevant screen is foregrounded".
|
||||
*/
|
||||
val gravity: Flow<GravitySample>
|
||||
|
||||
enum class Kind { GAME_ROTATION_VECTOR, GRAVITY, ACCELEROMETER, NONE }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
/**
|
||||
* Two-sample (180° flip) calibration, per mode (BRIEF.md §Measurement modes and calibration).
|
||||
*
|
||||
* Math: the surface's true tilt is fixed in the world frame; the device's own bias is fixed
|
||||
* in the device frame. After rotating the device 180° about the CONTACT-PLANE NORMAL — on the
|
||||
* same, unmoved surface — the true tilt appears negated in device readings while the bias
|
||||
* does not move:
|
||||
*
|
||||
* reading₁ = tilt + bias
|
||||
* reading₂ = -tilt + bias
|
||||
* ⇒ bias = (reading₁ + reading₂) / 2
|
||||
*
|
||||
* This holds per axis for the small near-level angles calibration is used at. It is only
|
||||
* valid if (a) the rotation is about the contact-plane normal and (b) the surface does not
|
||||
* move between samples — the calibration UI must instruct exactly that.
|
||||
*
|
||||
* Biases are stored per mode and applied only to that mode's readings; a Surface
|
||||
* calibration must never touch Edge readings (AUDIT.md finding 3).
|
||||
*/
|
||||
object TwoSampleCalibration {
|
||||
|
||||
/** Derive one axis's fixed device bias from two readings taken 180° apart. */
|
||||
fun deriveBiasDegrees(reading1Degrees: Double, reading2Degrees: Double): Double =
|
||||
(reading1Degrees + reading2Degrees) / 2.0
|
||||
|
||||
fun deriveSurface(
|
||||
pitch1: Double, roll1: Double,
|
||||
pitch2: Double, roll2: Double,
|
||||
): SurfaceCalibration = SurfaceCalibration(
|
||||
pitchBiasDegrees = deriveBiasDegrees(pitch1, pitch2),
|
||||
rollBiasDegrees = deriveBiasDegrees(roll1, roll2),
|
||||
)
|
||||
|
||||
fun deriveEdge(level1: Double, level2: Double): EdgeCalibration =
|
||||
EdgeCalibration(levelBiasDegrees = deriveBiasDegrees(level1, level2))
|
||||
}
|
||||
|
||||
data class SurfaceCalibration(val pitchBiasDegrees: Double, val rollBiasDegrees: Double) {
|
||||
fun applyToPitch(rawPitchDegrees: Double): Double = rawPitchDegrees - pitchBiasDegrees
|
||||
fun applyToRoll(rawRollDegrees: Double): Double = rawRollDegrees - rollBiasDegrees
|
||||
|
||||
companion object { val NONE = SurfaceCalibration(0.0, 0.0) }
|
||||
}
|
||||
|
||||
data class EdgeCalibration(val levelBiasDegrees: Double) {
|
||||
fun applyToLevel(rawLevelDegrees: Double): Double = rawLevelDegrees - levelBiasDegrees
|
||||
|
||||
companion object { val NONE = EdgeCalibration(0.0) }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.onthelevel.core.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.doublePreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.onthelevel.core.sensors.EdgeCalibration
|
||||
import com.onthelevel.core.sensors.SurfaceCalibration
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||
|
||||
/**
|
||||
* DataStore-backed preferences and calibration persistence (BRIEF.md core/settings).
|
||||
* Calibration is stored PER MODE and applied only to that mode's readings.
|
||||
*/
|
||||
class SettingsRepository(context: Context) {
|
||||
|
||||
private val store = context.applicationContext.dataStore
|
||||
|
||||
val surfaceCalibration: Flow<SurfaceCalibration> = store.data.map { prefs ->
|
||||
SurfaceCalibration(
|
||||
pitchBiasDegrees = prefs[Keys.SURFACE_PITCH_BIAS] ?: 0.0,
|
||||
rollBiasDegrees = prefs[Keys.SURFACE_ROLL_BIAS] ?: 0.0,
|
||||
)
|
||||
}
|
||||
|
||||
val edgeCalibration: Flow<EdgeCalibration> = store.data.map { prefs ->
|
||||
EdgeCalibration(levelBiasDegrees = prefs[Keys.EDGE_LEVEL_BIAS] ?: 0.0)
|
||||
}
|
||||
|
||||
val hapticsEnabled: Flow<Boolean> = store.data.map { it[Keys.HAPTICS_ENABLED] ?: true }
|
||||
val audioCueEnabled: Flow<Boolean> = store.data.map { it[Keys.AUDIO_CUE_ENABLED] ?: false }
|
||||
|
||||
/** In-app reduced-motion preference; the system animator-scale signal is respected separately. */
|
||||
val reducedMotion: Flow<Boolean> = store.data.map { it[Keys.REDUCED_MOTION] ?: false }
|
||||
|
||||
suspend fun setSurfaceCalibration(calibration: SurfaceCalibration) {
|
||||
store.edit {
|
||||
it[Keys.SURFACE_PITCH_BIAS] = calibration.pitchBiasDegrees
|
||||
it[Keys.SURFACE_ROLL_BIAS] = calibration.rollBiasDegrees
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setEdgeCalibration(calibration: EdgeCalibration) {
|
||||
store.edit { it[Keys.EDGE_LEVEL_BIAS] = calibration.levelBiasDegrees }
|
||||
}
|
||||
|
||||
suspend fun setHapticsEnabled(enabled: Boolean) {
|
||||
store.edit { it[Keys.HAPTICS_ENABLED] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setAudioCueEnabled(enabled: Boolean) {
|
||||
store.edit { it[Keys.AUDIO_CUE_ENABLED] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setReducedMotion(enabled: Boolean) {
|
||||
store.edit { it[Keys.REDUCED_MOTION] = enabled }
|
||||
}
|
||||
|
||||
// TODO(ruler): screen-ruler scale keyed by display identity/characteristics (BRIEF.md).
|
||||
|
||||
private object Keys {
|
||||
val SURFACE_PITCH_BIAS = doublePreferencesKey("surface_pitch_bias_deg")
|
||||
val SURFACE_ROLL_BIAS = doublePreferencesKey("surface_roll_bias_deg")
|
||||
val EDGE_LEVEL_BIAS = doublePreferencesKey("edge_level_bias_deg")
|
||||
val HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled")
|
||||
val AUDIO_CUE_ENABLED = booleanPreferencesKey("audio_cue_enabled")
|
||||
val REDUCED_MOTION = booleanPreferencesKey("reduced_motion")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.onthelevel.feature.angle
|
||||
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.onthelevel.AppContainer
|
||||
import com.onthelevel.core.design.KeepScreenOn
|
||||
import com.onthelevel.core.design.LevelColors
|
||||
import com.onthelevel.core.sensors.Ema
|
||||
import com.onthelevel.core.sensors.OrientationMath
|
||||
import com.onthelevel.feature.level.formatDegrees
|
||||
import com.onthelevel.feature.level.performConfirmHaptic
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Scaffold Angle screen: live absolute/relative angle with hold-to-zero (always free).
|
||||
* TODO(pro): target-angle alerts and saved named references behind the entitlement.
|
||||
*/
|
||||
@Composable
|
||||
fun AngleScreen(container: AppContainer) {
|
||||
KeepScreenOn()
|
||||
|
||||
val sensorSource = container.sensorSource
|
||||
if (!sensorSource.isAvailable) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("No usable tilt sensor on this device.", color = LevelColors.TextDim)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
data class AngleReading(val tilt: Double, val pitch: Double, val roll: Double)
|
||||
|
||||
val readingFlow = remember {
|
||||
val tiltEma = Ema(0.15)
|
||||
val pitchEma = Ema(0.15)
|
||||
val rollEma = Ema(0.15)
|
||||
var lastNanos: Long? = null
|
||||
sensorSource.gravity.map { g ->
|
||||
val dt = lastNanos?.let { (g.timestampNanos - it) / 1e9 } ?: 0.0
|
||||
lastNanos = g.timestampNanos
|
||||
AngleReading(
|
||||
tilt = tiltEma.update(OrientationMath.surfaceTiltMagnitudeDegrees(g), dt),
|
||||
pitch = pitchEma.update(OrientationMath.surfacePitchDegrees(g), dt),
|
||||
roll = rollEma.update(OrientationMath.surfaceRollDegrees(g), dt),
|
||||
)
|
||||
}
|
||||
}
|
||||
val reading by readingFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||
|
||||
// Relative reference: "hold to zero" (BRIEF.md §Angle — always free).
|
||||
var zeroReferenceDegrees by rememberSaveable { mutableStateOf(0.0) }
|
||||
val view = LocalView.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text("ANGLE", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
|
||||
Text(
|
||||
text = if (zeroReferenceDegrees != 0.0) "RELATIVE · HOLD TO RE-ZERO" else "HOLD TO ZERO",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = LevelColors.TextFaint,
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.pointerInput(reading != null) {
|
||||
detectTapGestures(
|
||||
onLongPress = {
|
||||
reading?.let {
|
||||
zeroReferenceDegrees = it.tilt
|
||||
view.performConfirmHaptic()
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = reading?.let { formatDegrees(it.tilt - zeroReferenceDegrees) } ?: "—",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = LevelColors.TextPrimary,
|
||||
)
|
||||
if (zeroReferenceDegrees != 0.0) {
|
||||
Text(
|
||||
text = "zeroed at " + formatDegrees(zeroReferenceDegrees),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = LevelColors.Amber,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
SecondaryValue("PITCH", reading?.pitch?.let(::formatDegrees), Modifier.weight(1f))
|
||||
SecondaryValue("ROLL", reading?.roll?.let(::formatDegrees), Modifier.weight(1f))
|
||||
SecondaryValue("GRADE", reading?.pitch?.let { formatGrade(OrientationMath.percentGrade(it)) }, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecondaryValue(label: String, value: String?, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier) {
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
|
||||
Text(value ?: "—", style = MaterialTheme.typography.headlineMedium, color = LevelColors.TextPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatGrade(grade: Double): String = when {
|
||||
grade.isInfinite() -> "∞"
|
||||
else -> String.format(java.util.Locale.US, "%.1f%%", grade)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.onthelevel.feature.level
|
||||
|
||||
import com.onthelevel.core.sensors.DisplayDeadband
|
||||
import com.onthelevel.core.sensors.EdgeCalibration
|
||||
import com.onthelevel.core.sensors.Ema
|
||||
import com.onthelevel.core.sensors.GravitySample
|
||||
import com.onthelevel.core.sensors.LevelMode
|
||||
import com.onthelevel.core.sensors.LockDetector
|
||||
import com.onthelevel.core.sensors.OrientationMath
|
||||
import com.onthelevel.core.sensors.SurfaceCalibration
|
||||
import kotlin.math.hypot
|
||||
|
||||
/**
|
||||
* One reading through the raw → stable pipeline (BRIEF.md values 1 and 2 of 3).
|
||||
* Value 3 — the spring-animated display value that moves the tactile vial — is
|
||||
* NOT yet implemented; this scaffold renders the deadbanded stable value directly.
|
||||
*/
|
||||
data class LevelReading(
|
||||
val mode: LevelMode,
|
||||
/** Deadbanded stable magnitude for the big readout, in degrees. */
|
||||
val displayPrimaryDegrees: Double,
|
||||
/** Surface: pitch. Edge: signed level deviation. Deadbanded. */
|
||||
val secondaryADegrees: Double,
|
||||
/** Surface: roll. Edge: plumb lean. Deadbanded. */
|
||||
val secondaryBDegrees: Double,
|
||||
val isLocked: Boolean,
|
||||
val fireFeedback: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Stateful per-collection pipeline: calibration → EMA smoothing → lock detection →
|
||||
* display deadband. Created fresh when mode or calibration changes; the LockDetector
|
||||
* is shared across recreations so the haptic debounce survives mode switches.
|
||||
*/
|
||||
class LevelPipeline(
|
||||
private val mode: LevelMode,
|
||||
private val surfaceCalibration: SurfaceCalibration,
|
||||
private val edgeCalibration: EdgeCalibration,
|
||||
private val lockDetector: LockDetector,
|
||||
) {
|
||||
private val emaA = Ema(SMOOTHING_TAU_SECONDS)
|
||||
private val emaB = Ema(SMOOTHING_TAU_SECONDS)
|
||||
private val primaryDeadband = DisplayDeadband()
|
||||
private val aDeadband = DisplayDeadband()
|
||||
private val bDeadband = DisplayDeadband()
|
||||
private var lastTimestampNanos: Long? = null
|
||||
|
||||
fun process(g: GravitySample): LevelReading {
|
||||
val dtSeconds = lastTimestampNanos?.let { (g.timestampNanos - it) / 1e9 } ?: 0.0
|
||||
lastTimestampNanos = g.timestampNanos
|
||||
// Sensor timestamps are monotonic; using them (not wall clock) keeps the
|
||||
// lock state machine deterministic under recorded traces.
|
||||
val nowMillis = g.timestampNanos / 1_000_000
|
||||
|
||||
return when (mode) {
|
||||
LevelMode.SURFACE -> {
|
||||
val pitch = emaA.update(
|
||||
surfaceCalibration.applyToPitch(OrientationMath.surfacePitchDegrees(g)),
|
||||
dtSeconds,
|
||||
)
|
||||
val roll = emaB.update(
|
||||
surfaceCalibration.applyToRoll(OrientationMath.surfaceRollDegrees(g)),
|
||||
dtSeconds,
|
||||
)
|
||||
// Near level, calibrated tilt magnitude ≈ hypot of the two calibrated
|
||||
// axis angles — keeps lock detection consistent with the displayed axes.
|
||||
val magnitude = hypot(pitch, roll)
|
||||
val lock = lockDetector.update(magnitude, nowMillis)
|
||||
LevelReading(
|
||||
mode = mode,
|
||||
displayPrimaryDegrees = primaryDeadband.update(magnitude),
|
||||
secondaryADegrees = aDeadband.update(pitch),
|
||||
secondaryBDegrees = bDeadband.update(roll),
|
||||
isLocked = lock.isLocked,
|
||||
fireFeedback = lock.fireFeedback,
|
||||
)
|
||||
}
|
||||
LevelMode.EDGE -> {
|
||||
val level = emaA.update(
|
||||
edgeCalibration.applyToLevel(OrientationMath.edgeLevelDegrees(g)),
|
||||
dtSeconds,
|
||||
)
|
||||
val lean = emaB.update(OrientationMath.edgePlumbLeanDegrees(g), dtSeconds)
|
||||
val lock = lockDetector.update(level, nowMillis)
|
||||
LevelReading(
|
||||
mode = mode,
|
||||
displayPrimaryDegrees = primaryDeadband.update(level),
|
||||
secondaryADegrees = aDeadband.update(level),
|
||||
secondaryBDegrees = bDeadband.update(lean),
|
||||
isLocked = lock.isLocked,
|
||||
fireFeedback = lock.fireFeedback,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SMOOTHING_TAU_SECONDS = 0.15 // TODO(tune) against recorded traces
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package com.onthelevel.feature.level
|
||||
|
||||
import android.os.Build
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.View
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.onthelevel.AppContainer
|
||||
import com.onthelevel.core.design.KeepScreenOn
|
||||
import com.onthelevel.core.design.LevelColors
|
||||
import com.onthelevel.core.sensors.EdgeCalibration
|
||||
import com.onthelevel.core.sensors.LevelMode
|
||||
import com.onthelevel.core.sensors.LockDetector
|
||||
import com.onthelevel.core.sensors.SensorSource
|
||||
import com.onthelevel.core.sensors.SurfaceCalibration
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Scaffold Level screen: live numbers, manual Surface|Edge selector, lock state.
|
||||
* TODO(feature): the tactile cross-vial visual (spring-animated display value),
|
||||
* the brief lime lock reaction, audio cue, and the calibration flow entry.
|
||||
* TODO(feature): in Edge mode, counter-rotate the readout so it reads upright
|
||||
* while the portrait-locked device stands on its long edge.
|
||||
*/
|
||||
@Composable
|
||||
fun LevelScreen(container: AppContainer) {
|
||||
KeepScreenOn()
|
||||
|
||||
val sensorSource = container.sensorSource
|
||||
if (!sensorSource.isAvailable) {
|
||||
UnsupportedDeviceMessage()
|
||||
return
|
||||
}
|
||||
|
||||
var mode by rememberSaveable { mutableStateOf(LevelMode.SURFACE) }
|
||||
val surfaceCal by container.settings.surfaceCalibration
|
||||
.collectAsStateWithLifecycle(initialValue = SurfaceCalibration.NONE)
|
||||
val edgeCal by container.settings.edgeCalibration
|
||||
.collectAsStateWithLifecycle(initialValue = EdgeCalibration.NONE)
|
||||
val hapticsEnabled by container.settings.hapticsEnabled
|
||||
.collectAsStateWithLifecycle(initialValue = true)
|
||||
|
||||
val view = LocalView.current
|
||||
val lockDetector = remember { LockDetector() }
|
||||
LaunchedEffect(mode) { lockDetector.reset() }
|
||||
|
||||
val readingFlow = remember(mode, surfaceCal, edgeCal, hapticsEnabled, view) {
|
||||
val pipeline = LevelPipeline(mode, surfaceCal, edgeCal, lockDetector)
|
||||
sensorSource.gravity
|
||||
.map { pipeline.process(it) }
|
||||
.onEach { if (it.fireFeedback && hapticsEnabled) view.performConfirmHaptic() }
|
||||
}
|
||||
val reading by readingFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||
|
||||
val isLocked = reading?.isLocked == true
|
||||
val isCalibrated = when (mode) {
|
||||
LevelMode.SURFACE -> surfaceCal != SurfaceCalibration.NONE
|
||||
LevelMode.EDGE -> edgeCal != EdgeCalibration.NONE
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text("LEVEL", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
|
||||
Text(
|
||||
text = statusLine(isCalibrated, sensorSource.kind),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (isCalibrated) LevelColors.LimeLock else LevelColors.TextFaint,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { /* TODO(feature): calibration & settings entry */ }) {
|
||||
Icon(
|
||||
Icons.Outlined.Settings,
|
||||
contentDescription = "Calibration and settings",
|
||||
tint = LevelColors.TextDim,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
LevelMode.entries.forEachIndexed { index, entry ->
|
||||
SegmentedButton(
|
||||
selected = mode == entry,
|
||||
onClick = { mode = entry },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = index, count = LevelMode.entries.size),
|
||||
) {
|
||||
Text(if (entry == LevelMode.SURFACE) "Surface" else "Edge")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "—",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = if (isLocked) LevelColors.LimeLock else LevelColors.TextPrimary,
|
||||
)
|
||||
// Lock state is announced with a label, never color alone (BRIEF.md).
|
||||
Text(
|
||||
text = when {
|
||||
isLocked && mode == LevelMode.SURFACE -> "Surface is flat"
|
||||
isLocked -> "Edge is level"
|
||||
else -> ""
|
||||
},
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = LevelColors.LimeLockText,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
ValuePanel(
|
||||
label = if (mode == LevelMode.SURFACE) "PITCH" else "LEVEL",
|
||||
value = reading?.secondaryADegrees,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ValuePanel(
|
||||
label = if (mode == LevelMode.SURFACE) "ROLL" else "LEAN",
|
||||
value = reading?.secondaryBDegrees,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ValuePanel(label: String, value: Double?, modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = LevelColors.Panel,
|
||||
contentColor = LevelColors.TextPrimary,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
|
||||
Text(
|
||||
text = value?.let { formatDegrees(it) } ?: "—",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnsupportedDeviceMessage() {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "This device has no usable tilt sensor, so On the Level can't take measurements here.",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = LevelColors.TextDim,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusLine(isCalibrated: Boolean, kind: SensorSource.Kind): String {
|
||||
val source = when (kind) {
|
||||
SensorSource.Kind.GAME_ROTATION_VECTOR -> "fused"
|
||||
SensorSource.Kind.GRAVITY -> "gravity"
|
||||
SensorSource.Kind.ACCELEROMETER -> "accelerometer"
|
||||
SensorSource.Kind.NONE -> "no sensor"
|
||||
}
|
||||
return (if (isCalibrated) "calibrated" else "live") + " · " + source
|
||||
}
|
||||
|
||||
internal fun formatDegrees(value: Double): String = String.format(Locale.US, "%.1f°", value)
|
||||
|
||||
internal fun View.performConfirmHaptic() {
|
||||
val constant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
HapticFeedbackConstants.CONFIRM
|
||||
} else {
|
||||
HapticFeedbackConstants.VIRTUAL_KEY
|
||||
}
|
||||
performHapticFeedback(constant)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.onthelevel.feature.ruler
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.onthelevel.AppContainer
|
||||
import com.onthelevel.core.design.LevelColors
|
||||
|
||||
/**
|
||||
* Scaffold Screen Ruler: Pro preview only (BRIEF.md §Tools — calm, intentional
|
||||
* paywall; never an interruption in the free level/angle flow).
|
||||
* TODO(pro): guided calibration (credit-card 85.60 mm + conventional ruler),
|
||||
* scale stored per display identity, recalibration prompt on material display change.
|
||||
*/
|
||||
@Composable
|
||||
fun RulerScreen(container: AppContainer) {
|
||||
val entitlement by container.entitlement.entitlement.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text("SCREEN RULER", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
|
||||
Text(
|
||||
text = if (entitlement.isPro) {
|
||||
"Ruler coming in the feature build."
|
||||
} else {
|
||||
"The calibrated screen ruler is part of On the Level Pro."
|
||||
},
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
Text(
|
||||
text = "For short, rough measurements only — not a substitute for a tape measure.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = LevelColors.TextDim,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.onthelevel.feature.tools
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.onthelevel.core.design.LevelColors
|
||||
|
||||
@Composable
|
||||
fun ToolsScreen(onOpenRuler: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text("TOOLS", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
|
||||
|
||||
ToolCard(
|
||||
title = "Screen Ruler",
|
||||
subtitle = "Rough on-screen measuring",
|
||||
badge = "PRO",
|
||||
onClick = onOpenRuler,
|
||||
)
|
||||
ToolCard(
|
||||
title = "Calibration",
|
||||
subtitle = "Two-sample level calibration — coming with the feature build",
|
||||
onClick = { /* TODO(feature): guided calibration flow */ },
|
||||
)
|
||||
ToolCard(
|
||||
title = "Units & Feedback",
|
||||
subtitle = "Haptics, audio cue, reduced motion — coming with the feature build",
|
||||
onClick = { /* TODO(feature): preferences UI over SettingsRepository */ },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolCard(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
badge: String? = null,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
color = LevelColors.Panel,
|
||||
contentColor = LevelColors.TextPrimary,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(18.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(title, style = MaterialTheme.typography.headlineMedium)
|
||||
if (badge != null) {
|
||||
Text(
|
||||
text = " $badge",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = LevelColors.Amber,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = LevelColors.TextDim,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Placeholder launcher art: a cross-vial hairline with the amber bubble at center. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:pathData="M30,54 L78,54"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="1.5" />
|
||||
<path
|
||||
android:pathData="M54,30 L54,78"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="1.5" />
|
||||
<path
|
||||
android:pathData="M54,54 m-16,0 a16,16 0 1,1 32,0 a16,16 0 1,1 -32,0"
|
||||
android:strokeColor="#8CCBEF5C"
|
||||
android:strokeWidth="1.5" />
|
||||
<path
|
||||
android:pathData="M54,54 m-9,0 a9,9 0 1,1 18,0 a9,9 0 1,1 -18,0"
|
||||
android:fillColor="#FFC44E" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/graphite" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="graphite">#FF0A0A0B</color>
|
||||
<color name="amber">#FFFFC44E</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">On the Level</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Dark-only instrument look; Compose owns all real theming (core/design). -->
|
||||
<style name="Theme.OnTheLevel" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@color/graphite</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class DisplayDeadbandTest {
|
||||
|
||||
@Test
|
||||
fun `quantizes to tenths of a degree`() {
|
||||
val band = DisplayDeadband()
|
||||
assertEquals(0.1, band.update(0.14), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `noise on a boundary does not flicker the readout`() {
|
||||
val band = DisplayDeadband()
|
||||
val first = band.update(0.05) // boundary between 0.0 and 0.1
|
||||
// Jitter of ±0.02° around the boundary must hold the displayed value.
|
||||
assertEquals(first, band.update(0.06), 1e-9)
|
||||
assertEquals(first, band.update(0.04), 1e-9)
|
||||
assertEquals(first, band.update(0.05), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a real change moves the readout`() {
|
||||
val band = DisplayDeadband()
|
||||
band.update(0.0)
|
||||
assertEquals(0.2, band.update(0.2), 1e-9)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class EmaTest {
|
||||
|
||||
@Test
|
||||
fun `first sample passes through unfiltered`() {
|
||||
assertEquals(5.0, Ema(0.15).update(5.0, 0.0), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `converges toward a constant input`() {
|
||||
val ema = Ema(0.15)
|
||||
ema.update(0.0, 0.0)
|
||||
var value = 0.0
|
||||
repeat(100) { value = ema.update(10.0, 0.02) }
|
||||
assertEquals(10.0, value, 1e-3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one time constant covers ~63 percent of a step`() {
|
||||
val ema = Ema(1.0)
|
||||
ema.update(0.0, 0.0)
|
||||
val afterOneTau = ema.update(1.0, 1.0)
|
||||
assertEquals(0.632, afterOneTau, 0.01)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same elapsed time yields same smoothing regardless of sample rate`() {
|
||||
val fast = Ema(0.5)
|
||||
val slow = Ema(0.5)
|
||||
fast.update(0.0, 0.0)
|
||||
slow.update(0.0, 0.0)
|
||||
var fastValue = 0.0
|
||||
repeat(10) { fastValue = fast.update(1.0, 0.01) } // 100 ms in 10 steps
|
||||
val slowValue = slow.update(1.0, 0.1) // 100 ms in 1 step
|
||||
assertTrue(kotlin.math.abs(fastValue - slowValue) < 0.02)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LockDetectorTest {
|
||||
|
||||
private fun detector() = LockDetector(
|
||||
enterThresholdDegrees = 0.2,
|
||||
exitThresholdDegrees = 0.35,
|
||||
dwellMillis = 400,
|
||||
feedbackDebounceMillis = 3_000,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `lock requires dwell time inside the enter threshold`() {
|
||||
val d = detector()
|
||||
assertFalse(d.update(0.1, 0).isLocked)
|
||||
assertFalse(d.update(0.1, 200).isLocked)
|
||||
val result = d.update(0.1, 450)
|
||||
assertTrue(result.isLocked)
|
||||
assertTrue(result.fireFeedback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leaving the threshold before dwell completes resets the timer`() {
|
||||
val d = detector()
|
||||
d.update(0.1, 0)
|
||||
d.update(0.5, 200) // bounced out
|
||||
d.update(0.1, 300) // back in — dwell restarts
|
||||
assertFalse(d.update(0.1, 600).isLocked) // only 300ms since re-entry
|
||||
assertTrue(d.update(0.1, 750).isLocked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hysteresis holds the lock between enter and exit thresholds`() {
|
||||
val d = detector()
|
||||
d.update(0.1, 0)
|
||||
assertTrue(d.update(0.1, 500).isLocked)
|
||||
// 0.3° is above enter (0.2) but below exit (0.35): still locked.
|
||||
assertTrue(d.update(0.3, 600).isLocked)
|
||||
// 0.4° exceeds the exit threshold: unlocked.
|
||||
assertFalse(d.update(0.4, 700).isLocked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `feedback fires once per lock and respects the debounce window`() {
|
||||
val d = detector()
|
||||
d.update(0.1, 0)
|
||||
assertTrue(d.update(0.1, 500).fireFeedback)
|
||||
assertFalse(d.update(0.1, 600).fireFeedback) // still locked, no re-fire
|
||||
|
||||
// Rock out and back in quickly: re-lock at ~1500ms is inside the 3s debounce.
|
||||
d.update(0.5, 900)
|
||||
d.update(0.1, 1000)
|
||||
val relock = d.update(0.1, 1500)
|
||||
assertTrue(relock.isLocked)
|
||||
assertFalse(relock.fireFeedback)
|
||||
|
||||
// A re-lock after the debounce window fires again.
|
||||
d.update(0.5, 2000)
|
||||
d.update(0.1, 4000)
|
||||
assertTrue(d.update(0.1, 4500).fireFeedback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `negative readings lock on magnitude`() {
|
||||
val d = detector()
|
||||
d.update(-0.1, 0)
|
||||
assertTrue(d.update(-0.1, 500).isLocked)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
class OrientationMathTest {
|
||||
|
||||
private val g = 9.80665
|
||||
|
||||
private fun flat() = GravitySample(0.0, 0.0, g, 0)
|
||||
|
||||
/** Device tilted so the top edge is raised by [degrees] (rotation about the X axis). */
|
||||
private fun pitched(degrees: Double): GravitySample {
|
||||
val r = Math.toRadians(degrees)
|
||||
return GravitySample(0.0, g * sin(r), g * cos(r), 0)
|
||||
}
|
||||
|
||||
/** Device tilted so the right edge is raised by [degrees] (rotation about the Y axis). */
|
||||
private fun rolled(degrees: Double): GravitySample {
|
||||
val r = Math.toRadians(degrees)
|
||||
return GravitySample(g * sin(r), 0.0, g * cos(r), 0)
|
||||
}
|
||||
|
||||
/** Device standing on its long edge, tipped in the wall plane by [degrees]. */
|
||||
private fun onEdge(tipDegrees: Double): GravitySample {
|
||||
val r = Math.toRadians(tipDegrees)
|
||||
return GravitySample(g * cos(r), g * sin(r), 0.0, 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flat device reads zero everywhere in surface mode`() {
|
||||
assertEquals(0.0, OrientationMath.surfaceTiltMagnitudeDegrees(flat()), 1e-9)
|
||||
assertEquals(0.0, OrientationMath.surfacePitchDegrees(flat()), 1e-9)
|
||||
assertEquals(0.0, OrientationMath.surfaceRollDegrees(flat()), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pitch recovers the applied rotation with correct sign`() {
|
||||
assertEquals(1.0, OrientationMath.surfacePitchDegrees(pitched(1.0)), 1e-9)
|
||||
assertEquals(-2.5, OrientationMath.surfacePitchDegrees(pitched(-2.5)), 1e-9)
|
||||
assertEquals(0.0, OrientationMath.surfaceRollDegrees(pitched(1.0)), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `roll recovers the applied rotation with correct sign`() {
|
||||
assertEquals(3.0, OrientationMath.surfaceRollDegrees(rolled(3.0)), 1e-9)
|
||||
assertEquals(0.0, OrientationMath.surfacePitchDegrees(rolled(3.0)), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tilt magnitude matches single-axis rotations`() {
|
||||
assertEquals(1.0, OrientationMath.surfaceTiltMagnitudeDegrees(pitched(1.0)), 1e-9)
|
||||
assertEquals(4.0, OrientationMath.surfaceTiltMagnitudeDegrees(rolled(4.0)), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edge mode reads zero when the long edge is horizontal`() {
|
||||
assertEquals(0.0, OrientationMath.edgeLevelDegrees(onEdge(0.0)), 1e-9)
|
||||
assertEquals(0.0, OrientationMath.edgePlumbLeanDegrees(onEdge(0.0)), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edge mode recovers in-plane tip with correct sign`() {
|
||||
assertEquals(1.5, OrientationMath.edgeLevelDegrees(onEdge(1.5)), 1e-9)
|
||||
assertEquals(-2.0, OrientationMath.edgeLevelDegrees(onEdge(-2.0)), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `percent grade is tan-based and capped to infinity near vertical`() {
|
||||
assertEquals(0.0, OrientationMath.percentGrade(0.0), 1e-9)
|
||||
assertEquals(100.0, OrientationMath.percentGrade(45.0), 1e-6)
|
||||
assertTrue(OrientationMath.percentGrade(89.6).isInfinite())
|
||||
assertTrue(OrientationMath.percentGrade(-89.6) == Double.NEGATIVE_INFINITY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero vector does not produce NaN`() {
|
||||
val zero = GravitySample(0.0, 0.0, 0.0, 0)
|
||||
assertEquals(0.0, OrientationMath.surfaceTiltMagnitudeDegrees(zero), 1e-9)
|
||||
assertEquals(0.0, OrientationMath.edgeLevelDegrees(zero), 1e-9)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.onthelevel.core.sensors
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class TwoSampleCalibrationTest {
|
||||
|
||||
@Test
|
||||
fun `flip cancels true tilt and isolates device bias`() {
|
||||
// Surface truly tilted 0.5°, device bias +0.3°:
|
||||
val reading1 = 0.5 + 0.3 // as placed
|
||||
val reading2 = -0.5 + 0.3 // after 180° rotation about the surface normal
|
||||
val bias = TwoSampleCalibration.deriveBiasDegrees(reading1, reading2)
|
||||
assertEquals(0.3, bias, 1e-9)
|
||||
// Applying the bias recovers the true tilt from the original reading:
|
||||
assertEquals(0.5, reading1 - bias, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `surface calibration derives both axes independently`() {
|
||||
val cal = TwoSampleCalibration.deriveSurface(
|
||||
pitch1 = 0.8, roll1 = -0.1,
|
||||
pitch2 = -0.2, roll2 = 0.5,
|
||||
)
|
||||
assertEquals(0.3, cal.pitchBiasDegrees, 1e-9)
|
||||
assertEquals(0.2, cal.rollBiasDegrees, 1e-9)
|
||||
assertEquals(0.5, cal.applyToPitch(0.8), 1e-9)
|
||||
assertEquals(-0.3, cal.applyToRoll(-0.1), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unbiased device on a level surface derives zero bias`() {
|
||||
val cal = TwoSampleCalibration.deriveEdge(0.0, 0.0)
|
||||
assertEquals(0.0, cal.levelBiasDegrees, 1e-9)
|
||||
assertEquals(1.2, cal.applyToLevel(1.2), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `surface and edge calibrations are independent types applied per mode`() {
|
||||
val surface = TwoSampleCalibration.deriveSurface(1.0, 1.0, 0.0, 0.0)
|
||||
val edge = EdgeCalibration.NONE
|
||||
// An edge reading passed through the untouched edge calibration is unchanged,
|
||||
// regardless of surface calibration state (AUDIT.md finding 3).
|
||||
assertEquals(0.7, edge.applyToLevel(0.7), 1e-9)
|
||||
assertEquals(0.5, surface.pitchBiasDegrees, 1e-9)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user