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:
Jay
2026-07-11 18:34:57 -04:00
parent bcda2856f7
commit 2a48d79c77
41 changed files with 2244 additions and 0 deletions
+73
View File
@@ -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))
}
+1
View File
@@ -0,0 +1 @@
# Add project specific ProGuard rules here.
+31
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="graphite">#FF0A0A0B</color>
<color name="amber">#FFFFC44E</color>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">On the Level</string>
</resources>
+7
View File
@@ -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)
}
}