Publish 0.023 alpha so inch fields take fractions and the scales read out on the car setup.

This commit is contained in:
RallyBlock committed 2026-10-22 23:57:27 -07:00
1 parent b1f0367b57
commit 6f1fdeeb2f
11 files changed
+291 -49

No files matched your search

@@ -1,6 +1,8 @@
package com.racingapp.pitlog.model
import java.util.Locale
import kotlin.math.abs
import kotlin.math.roundToInt
fun sanitizeSignedDecimal(raw: String, wholeDigits: Int, fractionDigits: Int): String {
val negative = raw.trimStart().startsWith('-')
@@ -31,6 +33,72 @@ fun parseDecimal(raw: String): Double? {
return trimmed.toDoubleOrNull()
}
fun sanitizeInches(raw: String, signed: Boolean = false): String {
val negative = signed && raw.trimStart().startsWith('-')
val source = raw.trim().removePrefix("-").removePrefix("+").replace(',', '.')
val out = StringBuilder()
var slash = false
var dot = false
var space = false
var digits = 0
for (ch in source) {
when {
ch.isDigit() -> {
val limit = when {
slash || dot -> if (dot) 4 else 2
else -> 2
}
if (digits < limit) {
out.append(ch)
digits++
}
}
ch == '/' && !slash && !dot && out.isNotEmpty() && !out.endsWith(' ') -> {
slash = true
digits = 0
out.append('/')
}
ch == '.' && !dot && !slash && !space -> {
dot = true
digits = 0
out.append('.')
}
ch == ' ' && !space && !slash && !dot && digits > 0 -> {
space = true
digits = 0
out.append(' ')
}
}
}
val text = out.toString()
if (text.isEmpty()) return if (negative) "-" else ""
return if (negative) "-$text" else text
}
fun parseInches(raw: String): Double? {
val trimmed = raw.trim().removeSuffix(" ")
if (trimmed.isEmpty() || trimmed == "-" || trimmed.endsWith("/") || trimmed.endsWith(".")) return null
val negative = trimmed.startsWith("-")
val body = trimmed.removePrefix("-").trim()
val value = if (body.contains("/")) parseFraction(body) else parseDecimal(body)
value ?: return null
return if (negative) -value else value
}
fun formatInches(value: Double?): String {
if (value == null) return ""
val negative = value < 0
val magnitude = abs(value)
val scaled = magnitude * 64.0
val nearest = scaled.roundToInt()
val body = if (abs(scaled - nearest) <= 0.04) {
fractionInches(nearest)
} else {
String.format(Locale.US, "%.3f", magnitude).trimEnd('0').trimEnd('.')
}
return if (negative && body != "0") "-$body" else body
}
fun parseIntField(raw: String): Int? = raw.trim().toIntOrNull()
fun formatPsi(value: Double?): String =
@@ -52,15 +120,48 @@ fun formatDegrees(value: Double?): String =
fun formatStagger(value: Double?): String {
if (value == null) return "—"
return String.format(Locale.US, "%.2f", value).trimEnd('0').trimEnd('.')
return formatInches(value)
}
fun psiField(value: Double?): String =
if (value == null) "" else String.format(Locale.US, "%.1f", value)
fun staggerField(value: Double?): String {
if (value == null) return ""
return String.format(Locale.US, "%.2f", value).trimEnd('0').trimEnd('.')
fun staggerField(value: Double?): String = formatInches(value)
private fun parseFraction(body: String): Double? {
val parts = body.split(' ').filter { it.isNotEmpty() }
val whole: Double
val token: String
when (parts.size) {
1 -> {
whole = 0.0
token = parts[0]
}
2 -> {
whole = parts[0].toDoubleOrNull() ?: return null
token = parts[1]
}
else -> return null
}
val bits = token.split('/')
if (bits.size != 2 || bits[1].isEmpty()) return null
val numerator = bits[0].toDoubleOrNull() ?: return null
val denominator = bits[1].toDoubleOrNull() ?: return null
if (denominator == 0.0) return null
return whole + numerator / denominator
}
private fun fractionInches(sixtyFourths: Int): String {
val whole = sixtyFourths / 64
var numerator = sixtyFourths % 64
var denominator = 64
if (numerator == 0) return whole.toString()
while (numerator % 2 == 0) {
numerator /= 2
denominator /= 2
}
val fraction = "$numerator/$denominator"
return if (whole == 0) fraction else "$whole $fraction"
}
fun intField(value: Int?): String = value?.toString().orEmpty()
@@ -123,7 +123,7 @@ data class RunForm(
if (sheet.cold.any() || sheet.hot.any()) return true
if (sheet.heat.values.any { it.any() }) return true
if (parseIntField(laps) != null || parseIntField(finish) != null) return true
if (parseDecimal(stagger) != null) return true
if (parseInches(stagger) != null) return true
if (tireSet.isNotBlank() || notes.isNotBlank()) return true
return handling != null || handlingWhere != null
}
@@ -137,7 +137,7 @@ data class RunForm(
tireSet = tireSet.trim(),
laps = parseIntField(laps),
finish = parseIntField(finish),
staggerInches = parseDecimal(stagger),
staggerInches = parseInches(stagger),
coldLf = sheet.cold.lf,
coldRf = sheet.cold.rf,
coldLr = sheet.cold.lr,
@@ -28,22 +28,61 @@ fun setupLines(sheet: SetupSheet): List<String> = buildList {
"RR" to sheet.weightRr,
).mapNotNull { (corner, pounds) -> pounds?.let { "$corner $it" } }
if (weights.isNotEmpty()) add("Weights ${weights.joinToString(" ")} lb")
weightSplit(sheet.weightLf, sheet.weightRf, sheet.weightLr, sheet.weightRr)?.let { add("From the weights $it") }
addAll(scaleLines(sheet.weightLf, sheet.weightRf, sheet.weightLr, sheet.weightRr))
sheet.rideHeight?.let { add("Ride height ${inches(it)} in") }
sheet.pinion?.let { add("Pinion ${formatDegrees(it)}°") }
sheet.brakeFront?.let { add("Front brake ${onePlace(it)}%") }
}
fun weightSplit(lf: Int?, rf: Int?, lr: Int?, rr: Int?): String? {
fun scaleLines(lf: Int?, rf: Int?, lr: Int?, rr: Int?): List<String> {
val reading = scaleReading(lf, rf, lr, rr) ?: return emptyList()
return listOf(
"Total ${reading.total} lb",
"Left ${pair(reading.left, reading.leftPercent)}",
"Front ${pair(reading.front, reading.frontPercent)}",
"Rear ${pair(reading.rear, reading.rearPercent)}",
"Cross ${pair(reading.cross, reading.crossPercent)}",
"Rear bite ${reading.rearBite}",
)
}
private data class ScaleReading(
val total: Int,
val left: Int,
val leftPercent: Double,
val front: Int,
val frontPercent: Double,
val rear: Int,
val rearPercent: Double,
val cross: Int,
val crossPercent: Double,
val rearBite: Int,
)
private fun scaleReading(lf: Int?, rf: Int?, lr: Int?, rr: Int?): ScaleReading? {
if (lf == null || rf == null || lr == null || rr == null) return null
val total = lf + rf + lr + rr
if (total <= 0) return null
val left = (lf + lr) * 100.0 / total
val rear = (lr + rr) * 100.0 / total
val cross = (rf + lr) * 100.0 / total
return "Left ${onePlace(left)}% Rear ${onePlace(rear)}% Cross ${onePlace(cross)}%"
val left = lf + lr
val front = lf + rf
val rear = lr + rr
val cross = rf + lr
return ScaleReading(
total = total,
left = left,
leftPercent = left * 100.0 / total,
front = front,
frontPercent = front * 100.0 / total,
rear = rear,
rearPercent = rear * 100.0 / total,
cross = cross,
crossPercent = cross * 100.0 / total,
rearBite = lr - rr,
)
}
private fun pair(pounds: Int, percent: Double) = "$pounds lb, ${onePlace(percent)}%"
fun setupDiffs(before: SetupSheet, after: SetupSheet): List<String> = buildList {
changed(before.crossPercent, after.crossPercent, ::percent)?.let { add("Cross $it") }
changedInt(before.springLf, after.springLf, " lb")?.let { add("LF spring $it") }
@@ -63,18 +102,40 @@ fun setupDiffs(before: SetupSheet, after: SetupSheet): List<String> = buildList
changedInt(before.weightRf, after.weightRf, " lb")?.let { add("RF weight $it") }
changedInt(before.weightLr, after.weightLr, " lb")?.let { add("LR weight $it") }
changedInt(before.weightRr, after.weightRr, " lb")?.let { add("RR weight $it") }
addAll(scaleChanges(before, after))
changed(before.rideHeight, after.rideHeight) { "${inches(it)} in" }?.let { add("Ride height $it") }
changed(before.pinion, after.pinion) { "${formatDegrees(it)}°" }?.let { add("Pinion $it") }
changed(before.brakeFront, after.brakeFront) { "${onePlace(it)}%" }?.let { add("Front brake $it") }
}
private fun scaleChanges(before: SetupSheet, after: SetupSheet): List<String> {
val old = scaleReading(before.weightLf, before.weightRf, before.weightLr, before.weightRr)
val next = scaleReading(after.weightLf, after.weightRf, after.weightLr, after.weightRr)
if (old == null || next == null || old == next) return emptyList()
return buildList {
if (old.total != next.total) add("Total ${old.total} lb → ${next.total} lb")
if (old.left != next.left || onePlace(old.leftPercent) != onePlace(next.leftPercent)) {
add("Left ${pair(old.left, old.leftPercent)} → ${pair(next.left, next.leftPercent)}")
}
if (old.front != next.front || onePlace(old.frontPercent) != onePlace(next.frontPercent)) {
add("Front ${pair(old.front, old.frontPercent)} → ${pair(next.front, next.frontPercent)}")
}
if (old.rear != next.rear || onePlace(old.rearPercent) != onePlace(next.rearPercent)) {
add("Rear ${pair(old.rear, old.rearPercent)} → ${pair(next.rear, next.rearPercent)}")
}
if (old.cross != next.cross || onePlace(old.crossPercent) != onePlace(next.crossPercent)) {
add("Cross ${pair(old.cross, old.crossPercent)} → ${pair(next.cross, next.crossPercent)}")
}
if (old.rearBite != next.rearBite) add("Rear bite ${old.rearBite} → ${next.rearBite}")
}
}
private fun percent(value: Double): String = String.format(Locale.US, "%.1f%%", value)
private fun onePlace(value: Double): String =
String.format(Locale.US, "%.1f", value).trimEnd('0').trimEnd('.')
private fun inches(value: Double): String =
String.format(Locale.US, "%.3f", value).trimEnd('0').trimEnd('.')
private fun inches(value: Double): String = formatInches(value)
private fun changed(
before: Double?,
@@ -94,14 +94,22 @@ fun NumberField(
onValue: (String) -> Unit,
modifier: Modifier = Modifier,
decimal: Boolean = true,
inches: Boolean = false,
placeholder: String = "",
) {
OutlinedTextField(
value = value,
onValueChange = onValue,
label = { Text(label) },
placeholder = if (placeholder.isEmpty()) null else ({ Text(placeholder) }),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = if (decimal) KeyboardType.Decimal else KeyboardType.Number,
capitalization = KeyboardCapitalization.None,
keyboardType = when {
inches -> KeyboardType.Uri
decimal -> KeyboardType.Decimal
else -> KeyboardType.Number
},
),
textStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
modifier = modifier,
@@ -49,6 +49,7 @@ import com.racingapp.pitlog.model.exportFileName
import com.racingapp.pitlog.model.chassisSetup
import com.racingapp.pitlog.model.chassisTips
import com.racingapp.pitlog.model.formatDegrees
import com.racingapp.pitlog.model.formatInches
import com.racingapp.pitlog.model.formatPsi
import com.racingapp.pitlog.model.formatStagger
import com.racingapp.pitlog.model.handlingText
@@ -237,8 +238,8 @@ private fun SetupBlock(night: RaceNight, latest: RunLog?, onSetup: () -> Unit) {
SetupLine(
"Blocks",
listOf(
night.blockLr?.let { "LR ${String.format(java.util.Locale.US, "%.1f", it)} in" },
night.blockRr?.let { "RR ${String.format(java.util.Locale.US, "%.1f", it)} in" },
night.blockLr?.let { "LR ${formatInches(it)} in" },
night.blockRr?.let { "RR ${formatInches(it)} in" },
).filterNotNull().joinToString(" ").ifBlank { null },
)
SetupLine(
@@ -31,9 +31,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.racingapp.pitlog.data.RaceNight
import com.racingapp.pitlog.data.Repository
import com.racingapp.pitlog.model.formatInches
import com.racingapp.pitlog.model.parseDecimal
import com.racingapp.pitlog.model.parseInches
import com.racingapp.pitlog.model.parseIntField
import com.racingapp.pitlog.model.sanitizeDecimal
import com.racingapp.pitlog.model.sanitizeInches
import com.racingapp.pitlog.model.sanitizeInt
import com.racingapp.pitlog.model.sanitizeSignedDecimal
import com.racingapp.pitlog.ui.FieldRow
@@ -71,8 +74,8 @@ private data class SetupForm(
casterLf = parseDecimal(casterLf),
casterRf = parseDecimal(casterRf),
swayBarHole = parseIntField(swayBarHole),
blockLr = parseDecimal(blockLr),
blockRr = parseDecimal(blockRr),
blockLr = parseInches(blockLr),
blockRr = parseInches(blockRr),
)
companion object {
@@ -87,8 +90,8 @@ private data class SetupForm(
casterLf = signedField(night.casterLf),
casterRf = signedField(night.casterRf),
swayBarHole = night.swayBarHole?.toString().orEmpty(),
blockLr = night.blockLr?.let { String.format(Locale.US, "%.1f", it) }.orEmpty(),
blockRr = night.blockRr?.let { String.format(Locale.US, "%.1f", it) }.orEmpty(),
blockLr = formatInches(night.blockLr),
blockRr = formatInches(night.blockRr),
)
}
}
@@ -254,16 +257,20 @@ fun SetupEditorScreen(nightId: Long, onBack: () -> Unit) {
label = "LR",
value = form.blockLr,
onValue = { value ->
viewModel.update { it.copy(blockLr = sanitizeDecimal(value, 1, 1)) }
viewModel.update { it.copy(blockLr = sanitizeInches(value)) }
},
inches = true,
placeholder = "1/2",
modifier = Modifier.weight(1f),
)
NumberField(
label = "RR",
value = form.blockRr,
onValue = { value ->
viewModel.update { it.copy(blockRr = sanitizeDecimal(value, 1, 1)) }
viewModel.update { it.copy(blockRr = sanitizeInches(value)) }
},
inches = true,
placeholder = "1/2",
modifier = Modifier.weight(1f),
)
}
@@ -52,6 +52,7 @@ import com.racingapp.pitlog.model.Tread
import com.racingapp.pitlog.model.formatPsi
import com.racingapp.pitlog.model.hasColdPressure
import com.racingapp.pitlog.model.sanitizeDecimal
import com.racingapp.pitlog.model.sanitizeInches
import com.racingapp.pitlog.model.sanitizeInt
import com.racingapp.pitlog.model.suspiciousReadings
import com.racingapp.pitlog.model.toSheet
@@ -261,8 +262,10 @@ fun RunEditorScreen(
label = "Stagger",
value = form.stagger,
onValue = { value ->
viewModel.update { it.copy(stagger = sanitizeDecimal(value, 2, 2)) }
viewModel.update { it.copy(stagger = sanitizeInches(value)) }
},
inches = true,
placeholder = "1/8",
modifier = Modifier.weight(1f),
)
}
@@ -32,13 +32,16 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.racingapp.pitlog.data.Repository
import com.racingapp.pitlog.data.SetupSheet
import com.racingapp.pitlog.model.formatInches
import com.racingapp.pitlog.model.parseDecimal
import com.racingapp.pitlog.model.parseInches
import com.racingapp.pitlog.model.parseIntField
import com.racingapp.pitlog.model.sanitizeDecimal
import com.racingapp.pitlog.model.sanitizeInches
import com.racingapp.pitlog.model.sanitizeInt
import com.racingapp.pitlog.model.sanitizeSignedDecimal
import com.racingapp.pitlog.model.scaleLines
import com.racingapp.pitlog.model.setupDiffs
import com.racingapp.pitlog.model.weightSplit
import com.racingapp.pitlog.ui.FieldRow
import com.racingapp.pitlog.ui.NumberField
import com.racingapp.pitlog.ui.PitActionButton
@@ -88,15 +91,15 @@ private data class CarSetupForm(
casterLf = parseDecimal(casterLf),
casterRf = parseDecimal(casterRf),
swayBarHole = parseIntField(swayBarHole),
blockLr = parseDecimal(blockLr),
blockRr = parseDecimal(blockRr),
toeFront = parseDecimal(toeFront),
toeRear = parseDecimal(toeRear),
blockLr = parseInches(blockLr),
blockRr = parseInches(blockRr),
toeFront = parseInches(toeFront),
toeRear = parseInches(toeRear),
weightLf = parseIntField(weightLf),
weightRf = parseIntField(weightRf),
weightLr = parseIntField(weightLr),
weightRr = parseIntField(weightRr),
rideHeight = parseDecimal(rideHeight),
rideHeight = parseInches(rideHeight),
pinion = parseDecimal(pinion),
brakeFront = parseDecimal(brakeFront),
)
@@ -113,15 +116,15 @@ private data class CarSetupForm(
casterLf = decimal(sheet.casterLf, 1),
casterRf = decimal(sheet.casterRf, 1),
swayBarHole = sheet.swayBarHole?.toString().orEmpty(),
blockLr = decimal(sheet.blockLr, 1),
blockRr = decimal(sheet.blockRr, 1),
toeFront = decimal(sheet.toeFront, 3),
toeRear = decimal(sheet.toeRear, 3),
blockLr = formatInches(sheet.blockLr),
blockRr = formatInches(sheet.blockRr),
toeFront = formatInches(sheet.toeFront),
toeRear = formatInches(sheet.toeRear),
weightLf = sheet.weightLf?.toString().orEmpty(),
weightRf = sheet.weightRf?.toString().orEmpty(),
weightLr = sheet.weightLr?.toString().orEmpty(),
weightRr = sheet.weightRr?.toString().orEmpty(),
rideHeight = decimal(sheet.rideHeight, 1),
rideHeight = formatInches(sheet.rideHeight),
pinion = decimal(sheet.pinion, 1),
brakeFront = decimal(sheet.brakeFront, 1),
note = if (keepNote) sheet.note else "",
@@ -320,13 +323,17 @@ fun CarSetupEditorScreen(
NumberField(
label = "LR",
value = form.blockLr,
onValue = { value -> viewModel.update { it.copy(blockLr = sanitizeDecimal(value, 1, 1)) } },
onValue = { value -> viewModel.update { it.copy(blockLr = sanitizeInches(value)) } },
inches = true,
placeholder = "1/2",
modifier = Modifier.weight(1f),
)
NumberField(
label = "RR",
value = form.blockRr,
onValue = { value -> viewModel.update { it.copy(blockRr = sanitizeDecimal(value, 1, 1)) } },
onValue = { value -> viewModel.update { it.copy(blockRr = sanitizeInches(value)) } },
inches = true,
placeholder = "1/2",
modifier = Modifier.weight(1f),
)
}
@@ -354,12 +361,12 @@ fun CarSetupEditorScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
FieldRow {
SignedField("Front", form.toeFront, { value -> viewModel.update { it.copy(toeFront = value) } }, Modifier.weight(1f), 1, 3)
SignedField("Rear", form.toeRear, { value -> viewModel.update { it.copy(toeRear = value) } }, Modifier.weight(1f), 1, 3)
SignedField("Front", form.toeFront, { value -> viewModel.update { it.copy(toeFront = value) } }, Modifier.weight(1f), inches = true)
SignedField("Rear", form.toeRear, { value -> viewModel.update { it.copy(toeRear = value) } }, Modifier.weight(1f), inches = true)
}
SectionLabel("Corner weights, lb")
Text(
"Pounds on the pads. Left, rear, and cross are figured from these four.",
"Pounds on the pads. Left, front, rear, and cross come out in pounds and percent. Rear bite is left rear minus right rear.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
FieldRow {
@@ -394,13 +401,13 @@ fun CarSetupEditorScreen(
modifier = Modifier.weight(1f),
)
}
weightSplit(
scaleLines(
form.weightLf.toIntOrNull(),
form.weightRf.toIntOrNull(),
form.weightLr.toIntOrNull(),
form.weightRr.toIntOrNull(),
)?.let { split ->
Text(split, modifier = Modifier.padding(top = 8.dp))
).forEach { line ->
Text(line, modifier = Modifier.padding(top = 8.dp))
}
SectionLabel("Ride height")
Text(
@@ -410,7 +417,9 @@ fun CarSetupEditorScreen(
NumberField(
label = "Inches",
value = form.rideHeight,
onValue = { value -> viewModel.update { it.copy(rideHeight = sanitizeDecimal(value, 2, 1)) } },
onValue = { value -> viewModel.update { it.copy(rideHeight = sanitizeInches(value)) } },
inches = true,
placeholder = "5",
modifier = Modifier.fillMaxWidth(),
)
SectionLabel("Pinion")
@@ -457,6 +466,7 @@ private fun SignedField(
value: String,
onValue: (String) -> Unit,
modifier: Modifier = Modifier,
inches: Boolean = false,
wholeDigits: Int = 2,
fractionDigits: Int = 1,
) {
@@ -464,7 +474,14 @@ private fun SignedField(
NumberField(
label = label,
value = value,
onValue = { onValue(sanitizeSignedDecimal(it, wholeDigits, fractionDigits)) },
onValue = {
onValue(
if (inches) sanitizeInches(it, signed = true)
else sanitizeSignedDecimal(it, wholeDigits, fractionDigits),
)
},
inches = inches,
placeholder = if (inches) "1/16" else "",
modifier = Modifier.fillMaxWidth(),
)
TextButton(