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,42 @@
|
|||||||
|
# On the Level
|
||||||
|
|
||||||
|
Offline-first Android pocket toolset: a trustworthy level, angle meter, and
|
||||||
|
calibrated screen ruler (Pro). Kotlin + Jetpack Compose, single module, no backend.
|
||||||
|
|
||||||
|
- **[BRIEF.md](BRIEF.md)** — the authoritative v1 product & technical brief
|
||||||
|
- **[AUDIT.md](AUDIT.md)** — audit of the brief; the amendments it lists are incorporated
|
||||||
|
- **resources/** — concept board (visual direction)
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Open in Android Studio, or:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./gradlew assembleDebug # build
|
||||||
|
./gradlew testDebugUnitTest # measurement-math unit tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scaffold status
|
||||||
|
|
||||||
|
The project skeleton follows BRIEF.md §Sensor and measurement architecture.
|
||||||
|
Implemented and tested:
|
||||||
|
|
||||||
|
- `core/sensors` — sensor selection (game rotation vector → gravity → low-passed
|
||||||
|
accelerometer), device-frame gravity stream, and the pure measurement math:
|
||||||
|
orientation mapping, two-sample per-mode calibration, EMA smoothing,
|
||||||
|
lock hysteresis/dwell/debounce, display deadband. All unit-tested.
|
||||||
|
- `core/settings` — DataStore preferences and per-mode calibration persistence.
|
||||||
|
- `core/billing` — entitlement interface with a stub (free-tier) implementation.
|
||||||
|
- `feature/level`, `feature/angle` — live numeric scaffolds wired to the real
|
||||||
|
sensor pipeline, with manual Surface|Edge selection, lock state, hold-to-zero.
|
||||||
|
- `feature/ruler`, `feature/tools` — static placeholders.
|
||||||
|
|
||||||
|
Not yet implemented (deliberately — see TODO markers):
|
||||||
|
|
||||||
|
- The tactile vial visuals and the spring-animated **display value** (value 3 of 3
|
||||||
|
in the brief); scaffold screens render the deadbanded stable value directly.
|
||||||
|
- Guided calibration flow UI (math and persistence are done).
|
||||||
|
- Edge-mode readout counter-rotation on the portrait-locked activity.
|
||||||
|
- Play Billing implementation of `ProEntitlementRepository`.
|
||||||
|
- Screen ruler measurement UI and per-display scale persistence.
|
||||||
|
- Audio cue, reduced-motion behaviors, preferences UI.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "9.2.1"
|
||||||
|
kotlin = "2.2.10"
|
||||||
|
coreKtx = "1.18.0"
|
||||||
|
activityCompose = "1.10.1"
|
||||||
|
lifecycle = "2.8.7"
|
||||||
|
composeBom = "2025.06.01"
|
||||||
|
navigation = "2.8.4"
|
||||||
|
datastore = "1.1.6"
|
||||||
|
billing = "9.1.0"
|
||||||
|
junit = "4.13.2"
|
||||||
|
coroutinesTest = "1.9.0"
|
||||||
|
androidxJunit = "1.3.0"
|
||||||
|
espresso = "3.7.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
|
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
|
||||||
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
|
||||||
|
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
|
billing-ktx = { group = "com.android.billingclient", name = "billing-ktx", version.ref = "billing" }
|
||||||
|
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" }
|
||||||
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
|
||||||
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
Vendored
BIN
Binary file not shown.
+8
@@ -0,0 +1,8 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+94
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google {
|
||||||
|
content {
|
||||||
|
includeGroupByRegex("com\\.android.*")
|
||||||
|
includeGroupByRegex("com\\.google.*")
|
||||||
|
includeGroupByRegex("androidx.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plugins {
|
||||||
|
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "OnTheLevel"
|
||||||
|
include(":app")
|
||||||
Reference in New Issue
Block a user