Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
393716b97c | ||
|
|
f1502b2de1 | ||
|
|
6f1fdeeb2f | ||
|
|
b1f0367b57 | ||
|
|
6b008d0e01 | ||
|
|
ecd5c286dd |
No files matched your search
@@ -13,8 +13,11 @@ android {
|
||||
applicationId = "com.racingapp.pitlog"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.001"
|
||||
// Three digits. versionCode is that number with the dot removed.
|
||||
// A fix adds 0.001. One feature adds 0.010. A batch of features adds 0.100.
|
||||
// The release tag is v plus versionName, such as v0.013.
|
||||
versionCode = 44
|
||||
versionName = "0.044"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -234,9 +234,21 @@ internal val MIGRATION_8_9 = object : Migration(8, 9) {
|
||||
}
|
||||
}
|
||||
|
||||
internal val MIGRATION_9_10 = object : Migration(9, 10) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE setup_sheets ADD COLUMN weightLf INTEGER")
|
||||
db.execSQL("ALTER TABLE setup_sheets ADD COLUMN weightRf INTEGER")
|
||||
db.execSQL("ALTER TABLE setup_sheets ADD COLUMN weightLr INTEGER")
|
||||
db.execSQL("ALTER TABLE setup_sheets ADD COLUMN weightRr INTEGER")
|
||||
db.execSQL("ALTER TABLE setup_sheets ADD COLUMN rideHeight REAL")
|
||||
db.execSQL("ALTER TABLE setup_sheets ADD COLUMN pinion REAL")
|
||||
db.execSQL("ALTER TABLE setup_sheets ADD COLUMN brakeFront REAL")
|
||||
}
|
||||
}
|
||||
|
||||
@Database(
|
||||
entities = [RaceNight::class, RunLog::class, Car::class, SetupSheet::class],
|
||||
version = 9,
|
||||
version = 10,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
@@ -245,7 +257,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
companion object {
|
||||
fun create(context: Context): AppDatabase {
|
||||
return Room.databaseBuilder(context, AppDatabase::class.java, "pitlog.db")
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,13 @@ data class SetupSheet(
|
||||
val blockRr: Double? = null,
|
||||
val toeFront: Double? = null,
|
||||
val toeRear: Double? = null,
|
||||
val weightLf: Int? = null,
|
||||
val weightRf: Int? = null,
|
||||
val weightLr: Int? = null,
|
||||
val weightRr: Int? = null,
|
||||
val rideHeight: Double? = null,
|
||||
val pinion: Double? = null,
|
||||
val brakeFront: Double? = null,
|
||||
@ColumnInfo(defaultValue = "''") val syncId: String = "",
|
||||
@ColumnInfo(defaultValue = "0") val updatedAt: Long = 0,
|
||||
@ColumnInfo(defaultValue = "0") val deleted: Boolean = false,
|
||||
|
||||
@@ -200,6 +200,13 @@ private fun sheetJson(sheet: SetupSheet) = JSONObject()
|
||||
.putNullable("blockRr", sheet.blockRr)
|
||||
.putNullable("toeFront", sheet.toeFront)
|
||||
.putNullable("toeRear", sheet.toeRear)
|
||||
.putNullable("weightLf", sheet.weightLf)
|
||||
.putNullable("weightRf", sheet.weightRf)
|
||||
.putNullable("weightLr", sheet.weightLr)
|
||||
.putNullable("weightRr", sheet.weightRr)
|
||||
.putNullable("rideHeight", sheet.rideHeight)
|
||||
.putNullable("pinion", sheet.pinion)
|
||||
.putNullable("brakeFront", sheet.brakeFront)
|
||||
|
||||
private fun sheetFrom(json: JSONObject) = SetupSheet(
|
||||
recordedAt = json.optLong("recordedAt"),
|
||||
@@ -218,6 +225,13 @@ private fun sheetFrom(json: JSONObject) = SetupSheet(
|
||||
blockRr = json.optionalDouble("blockRr"),
|
||||
toeFront = json.optionalDouble("toeFront"),
|
||||
toeRear = json.optionalDouble("toeRear"),
|
||||
weightLf = json.optionalInt("weightLf"),
|
||||
weightRf = json.optionalInt("weightRf"),
|
||||
weightLr = json.optionalInt("weightLr"),
|
||||
weightRr = json.optionalInt("weightRr"),
|
||||
rideHeight = json.optionalDouble("rideHeight"),
|
||||
pinion = json.optionalDouble("pinion"),
|
||||
brakeFront = json.optionalDouble("brakeFront"),
|
||||
syncId = json.optString("syncId"),
|
||||
updatedAt = json.optLong("updatedAt"),
|
||||
deleted = json.optBoolean("deleted"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -21,8 +21,68 @@ fun setupLines(sheet: SetupSheet): List<String> = buildList {
|
||||
sheet.casterRf?.let { add("RF caster ${formatDegrees(it)}") }
|
||||
sheet.toeFront?.let { add("Front toe ${inches(it)} in out") }
|
||||
sheet.toeRear?.let { add("Rear toe ${inches(it)} in") }
|
||||
val weights = listOf(
|
||||
"LF" to sheet.weightLf,
|
||||
"RF" to sheet.weightRf,
|
||||
"LR" to sheet.weightLr,
|
||||
"RR" to sheet.weightRr,
|
||||
).mapNotNull { (corner, pounds) -> pounds?.let { "$corner $it" } }
|
||||
if (weights.isNotEmpty()) add("Weights ${weights.joinToString(" ")} lb")
|
||||
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 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
|
||||
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") }
|
||||
@@ -38,12 +98,44 @@ fun setupDiffs(before: SetupSheet, after: SetupSheet): List<String> = buildList
|
||||
changed(before.casterRf, after.casterRf, ::formatDegrees)?.let { add("RF caster $it") }
|
||||
changed(before.toeFront, after.toeFront) { "${inches(it)} in" }?.let { add("Front toe $it") }
|
||||
changed(before.toeRear, after.toeRear) { "${inches(it)} in" }?.let { add("Rear toe $it") }
|
||||
changedInt(before.weightLf, after.weightLf, " lb")?.let { add("LF weight $it") }
|
||||
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 inches(value: Double): String =
|
||||
String.format(Locale.US, "%.3f", value).trimEnd('0').trimEnd('.')
|
||||
private fun onePlace(value: Double): String =
|
||||
String.format(Locale.US, "%.1f", value).trimEnd('0').trimEnd('.')
|
||||
|
||||
private fun inches(value: Double): String = formatInches(value)
|
||||
|
||||
private fun changed(
|
||||
before: Double?,
|
||||
|
||||
@@ -282,8 +282,8 @@ private fun swayBarTip(turns: Int?, handling: String): SetupTip? {
|
||||
val tight = handling.equals(Handling.Tight.label, ignoreCase = true)
|
||||
val loose = handling.equals(Handling.Loose.label, ignoreCase = true)
|
||||
return when {
|
||||
tight -> SetupTip("Sway bar", "Less bar.")
|
||||
loose -> SetupTip("Sway bar", "More bar.")
|
||||
tight -> SetupTip("Front roll stiffness", "Less front roll stiffness.")
|
||||
loose -> SetupTip("Front roll stiffness", "More front roll stiffness.")
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -299,8 +299,8 @@ private fun blockTip(left: Double?, right: Double?, handling: String, where: Str
|
||||
val tight = handling.equals(Handling.Tight.label, ignoreCase = true)
|
||||
val loose = handling.equals(Handling.Loose.label, ignoreCase = true)
|
||||
return when {
|
||||
tight -> SetupTip("Blocks", "Less block.")
|
||||
loose -> SetupTip("Blocks", "More block.")
|
||||
tight -> SetupTip("Rear roll stiffness", "Less rear roll stiffness. Take block out of the right rear.")
|
||||
loose -> SetupTip("Rear roll stiffness", "More rear roll stiffness. Add block to the right rear.")
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -2,21 +2,40 @@ package com.racingapp.pitlog.ui.guide
|
||||
|
||||
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.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
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.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.racingapp.pitlog.ui.PitScroll
|
||||
import com.racingapp.pitlog.ui.SectionLabel
|
||||
import com.racingapp.pitlog.ui.theme.pitTopBarColors
|
||||
|
||||
@@ -158,26 +177,26 @@ private val handling = listOf(
|
||||
"Middle, tight",
|
||||
listOf(
|
||||
Move("Take crossweight out.", "Once the car is square and the geometry is honest, a tight middle wants less crossweight."),
|
||||
Move("Less block.", "A taller block under the leaves increases roll and tightens the entry and the middle. Over 3 inches cuts forward bite. String the rear after a block change, because the blocks and U-bolts shift."),
|
||||
Move("Less rear roll stiffness.", "Take block out of the right rear. A taller right rear block tightens the entry and the middle. A taller left rear block frees them. Over 3 inches cuts forward bite. String the rear after a block change, because the blocks and U-bolts shift."),
|
||||
Move("Let the front roll closer to the rear.", "A tight middle often means the rear wants to roll more than the front. Soften the right-front spring, or stiffen the left-front. A stiffer left-front does not work well above about 10 degrees of banking."),
|
||||
Move("Read the left-rear temperature.", "If the left rear is the coolest tire, the rear spring split is too large and the right-rear spring is too stiff."),
|
||||
Move("Less bar.", "Less bar if the car is tight. On a leaf-spring Camaro a bigger bar faded after a few laps. If more bar only helps a few laps, put it back and stiffen the front springs."),
|
||||
Move("Less front roll stiffness.", "That is less bar on the all-thread. On a leaf-spring Camaro a stiffer bar faded after a few laps. If more front roll stiffness only helps a few laps, put it back and stiffen the front springs."),
|
||||
),
|
||||
),
|
||||
HandlingCase(
|
||||
"Middle, loose",
|
||||
listOf(
|
||||
Move("Add crossweight.", "Once alignment and balance are right, a loose middle wants more crossweight."),
|
||||
Move("More block.", "A taller block tightens the entry and the middle. Stay at 3 inches or under, or forward bite falls off."),
|
||||
Move("More rear roll stiffness.", "Add block to the right rear. A taller right rear block tightens the entry and the middle. Stay at 3 inches or under, or forward bite falls off."),
|
||||
Move("Ease a right-rear spring that is much stiffer than the left rear.", "If the front is rolling more than the rear, load piles onto the right rear."),
|
||||
Move("More bar.", "More bar if the car is loose. The bar has to move freely in the stock saddles."),
|
||||
Move("More front roll stiffness.", "That is more bar on the all-thread. The bar has to move freely in the stock saddles."),
|
||||
Move("Do not add stagger to make it turn.", "Extra stagger used to free a tight middle shows up as loose off the corner under power."),
|
||||
),
|
||||
),
|
||||
HandlingCase(
|
||||
"Entry, tight",
|
||||
listOf(
|
||||
Move("Less block.", "More block tightens the entry as well as the middle."),
|
||||
Move("Less rear roll stiffness.", "Take block out of the right rear. A taller right rear block tightens the entry as well as the middle. A taller left rear block frees them."),
|
||||
Move("Check right-front camber through the dive.", "If the nose dives hard, the right front loses camber and the push starts. Once it starts, it is hard to drive out of. The left front also loses camber in the dive. It wants about 1/2–1° positive while it is loaded. The right-front camber change through the dive and the roll should stay near zero."),
|
||||
Move("Do not fix the entry with brake bias alone.", "Compare a light brake with a hard brake. If they feel the same, bias is not the problem."),
|
||||
),
|
||||
@@ -185,7 +204,7 @@ private val handling = listOf(
|
||||
HandlingCase(
|
||||
"Entry, loose",
|
||||
listOf(
|
||||
Move("More block.", "More block tightens the entry. Keep each block at 3 inches or under."),
|
||||
Move("More rear roll stiffness.", "Add block to the right rear. Keep each block at 3 inches or under."),
|
||||
Move("Move brake bias forward.", "Too much rear brake loosens the car on a hard entry. A usual asphalt split is about 60–65% front and 35–40% rear, if the book allows an adjuster. The adjuster has to be under the dash or the hood, out of the driver's reach, on the tri-state book."),
|
||||
Move("Take out rear steer to the right.", "If the right rear is farther back than the left rear as the car rolls in, the rear is steering right and the car gets loose."),
|
||||
),
|
||||
@@ -193,9 +212,7 @@ private val handling = listOf(
|
||||
HandlingCase(
|
||||
"Exit, tight",
|
||||
listOf(
|
||||
Move("Add stagger for the radius, then leave it.", "Too little stagger for the corner leaves the car tight getting off. On a spool both rear tires turn the same number of times, and stagger steers the middle when the axles are locked. Size it for the radius and the banking, then stop using it as a handling knob."),
|
||||
Move("A locked rear and an open rear are different.", "On a spool or a welded rear, both tires turn the same number of times the whole corner, so stagger steers the middle. An open rear lets the tires turn at different speeds, so stagger does not steer the middle the same way. Wenatchee allows an open rear. Stateline is spool or welded. These books do not allow a Detroit Locker. A rear that unlocks in the middle and locks on power would be sized for the wider exit radius, which wants less stagger than a spool."),
|
||||
Move("More banking wants a little less stagger.", "The same corner on a bank wants less stagger than it would on flat ground. Radius is still the big piece. About 10 degrees is roughly 1.5% less. About 20 degrees is roughly 6% less."),
|
||||
Move("Add stagger for the radius, then leave it.", "Too little for the corner leaves the car tight getting off. Size it for the radius and the banking, then stop using it as a handling knob."),
|
||||
),
|
||||
),
|
||||
HandlingCase(
|
||||
@@ -208,9 +225,154 @@ private val handling = listOf(
|
||||
),
|
||||
)
|
||||
|
||||
private val starts = listOf(
|
||||
Tip("Crossweight", "52–55% on a leaf-spring car. Less than a coil car usually wants."),
|
||||
Tip("Front springs", "Rate each spring at the height it sits in the car. Replace a bent or tired one. Keep the split under about 100 lb. Softening the front adds front traction, so the cross has to come up or the car gets loose. If the front out-rolls the rear, stiffen the front springs and use less front roll stiffness."),
|
||||
Tip("Caster", "Top of the ball-joint line tilted back is positive. Tilted forward is negative. Positive caster holds a line. Too much makes the wheel heavy. Negative caster lets the car wander on the straight. A short track often wants about 2–4° of split, left front around 1–2° and right front around 3–5°. More banking wants less caster. A tighter radius wants more split. At mid-turn the wheel should pull slightly. Check the toe after you change caster."),
|
||||
Tip("Blocks", "The right rear block is the one that tightens. A taller right rear block is more rear roll stiffness. A taller left rear block frees the entry and the middle. Over 3 inches cuts forward bite. South Sound allows steel leaves, at least four, with blocks, shackles, and sliders. The tri-state book allows steel or poly leaves and shackles."),
|
||||
Tip("Sway bar", "The all-thread sets front roll stiffness. Less if the car is tight. More if it is loose. The tri-state book allows a 1-1/4 inch bar in the stock saddles, no heim joints. South Sound's book does not list a bar."),
|
||||
Tip("Stagger", "Right rear minus left rear, around the center of the tread, with a flexible 1/4-inch tape, at the cold pressure you will race. A tighter radius wants more. More banking wants a little less: about 10 degrees is roughly 1.5% less, and about 20 degrees is roughly 6% less."),
|
||||
Tip("Locked or open", "On a spool or a welded rear, both tires turn together the whole corner, so stagger steers the middle. An open rear does not. Wenatchee allows an open rear. Stateline is spool or welded. These books do not allow a Detroit Locker. Too much stagger makes the car turn left on the straight. On a bias-ply tire a sliding right rear heats, grows, and the loose gets worse as the run goes on. Too little heats the right front and the push gets worse the same way."),
|
||||
Tip("Air moves stagger", "On a bias-ply tire, more air on the right, or less on the left, grows the stagger before you change tires. That rear-stagger gain takes cross out. More air that grows the right front adds cross."),
|
||||
Tip("Toe", "1/16 to 1/8 inch out in front on asphalt. A tighter track wants the larger end. Tracks under a half mile are published out to 1/4 inch. Rear wheels straight. Toe out takes the slack out of the tie-rod ends. Check it after a camber change, a caster change, a ride-height change, or any wheel contact."),
|
||||
Tip("Added toe", "Toe that grows as the wheel turns is Ackermann. Toe that shrinks is reverse Ackermann. One degree is about 1/2 inch of toe on an 85-inch tire. A tight quarter-mile only needs about 1/16 inch more on the left front. A half-mile needs about 1/64 inch. Stock boxes already add toe in a left turn, so measure it before you add any. Another note calls about 3° of extra left-front steer, in 10° of wheel turn, a common amount, and more of it on a 3/8-mile and under. Past what the corner needs, the left front grains, runs hot, and drags, and the car gets loose off. A little can free a tight middle. You feel it at the apex."),
|
||||
Tip("String check", "Fronts straight. Mark the floor 10 feet ahead of each spindle. Turn the wheel about as far as the driver does in the corner and mark again. If the left mark moved farther than the right, the car gained toe. If the right mark moved farther, the car lost toe. Tie rods that angle forward from the inner pivot add Ackermann. If the spindle arm is slotted and the book allows it, moving the left tie-rod end closer to the ball joint adds more."),
|
||||
Tip("Brakes", "About 60–65% of the braking force on the front. A gauge in the bleeder tells you the pressure at that wheel. More rear brake loosens a hard entry. If the adjuster ends up slammed to one side, the master-cylinder size or the pads are doing the work the knob cannot do."),
|
||||
Tip("Lead", "Look for quarter pounds when you build the car, and leave the safety parts alone. Stay at the book's minimum total. Running heavy to buy left-side percent makes the tires work harder. One low clump, left of center, near the middle of the car, as close to the left-side maximum as that weight allows. Lead split front to rear, or left rail to right rail, reads the same on the scales and transfers more once the car is moving. Battery, tanks, and other heavy parts go on the left. Paint the lead white with the car number."),
|
||||
Tip("Pinion", "A leaf car needs more angle than a coil car because the spring wraps and the pinion rises. Measure pinion versus driveshaft at ride height, car weighted. The U-joint wants at least 1/2° so the needles turn, and most shops want it under 3°. About 2° nose-down from the transmission centerline is a circle-track leaf note so the joints oppose. On asphalt, make a rear-steer change small, then leave it and tune the car with the other pieces. The 5–7° nose-down figure is a drag-racing launch number."),
|
||||
Tip("Tires", "Hot pressure has to land within about 0.5 psi of the tire maker's target. The tread, inside middle outside, says whether that tire was high or low once it was hot. Air is also rate, footprint, and cross. Two tires with the same size stamp can still differ by an inch or more around. Buy that rollout. Air only fine-tunes it."),
|
||||
)
|
||||
|
||||
private val tracks = listOf(
|
||||
Tip("Camber", "2 inches on the right and 1 inch on the left, measured with a square at the top of the wheel."),
|
||||
Tip("Tri-state book", "Stateline hobby stock and Wenatchee thunder cars. A second-gen Camaro: 3,200 lb, 55% left maximum, 48% rear maximum, on a stock-dimension engine under 360 cubic inches. Hoosier 970, and the tire you qualify on starts the main. Sway bar 1-1/4 inch maximum, stock saddles, all-thread adjuster. No weight jacks. Pocket spring adjusters, steel or poly leaves, and shackles are allowed. Wenatchee allows an open rear. Stateline is spool or welded. No limited slip and no Detroit Locker. Ride height 5 inches. Lead on 1/2-inch grade 5 bolts."),
|
||||
Tip("South Sound book", "3,250 lb, 54% left maximum, 49% rear maximum. American Racer take-off slicks. Steel leaves, at least four, with blocks, shackles, and sliders. Open, welded, or a spool. Same 2-inch and 1-inch camber, and the same 5-inch ride height. The book does not list a sway bar."),
|
||||
Tip("Ride height", "5 inches from the lowest frame at the driver's door."),
|
||||
)
|
||||
|
||||
private val leaveAlone = listOf(
|
||||
Tip("Big-bar soft-spring", "A late-model package. Soft front springs and a big bar are there to hold a sleek nose on the ground so the aero can work. The bar adds rate, so the car is stiffer, and the tires have to live with that load. A stock-body leaf-spring car does not get that aero, and a bigger bar on a leaf-spring Camaro faded after a few laps. Banking already holds the nose down, so the bar does less of that job."),
|
||||
Tip("Dirt sheets", "Clay numbers, including a half inch of front toe-out, are for a slick rutted track."),
|
||||
Tip("Drag pinion", "5–7° nose-down is a hard launch on a soft spring, not an asphalt oval setting."),
|
||||
Tip("Even temps", "A simulator that wants the inside hottest on every tire is not the paved-oval target above."),
|
||||
)
|
||||
|
||||
private data class Note(val title: String, val body: String)
|
||||
|
||||
private data class Block(val heading: String = "", val notes: List<Note>)
|
||||
|
||||
private data class Chapter(val title: String, val intro: String = "", val blocks: List<Block> = emptyList())
|
||||
|
||||
private fun notesOf(tips: List<Tip>) = listOf(Block(notes = tips.map { Note(it.see, it.doThis) }))
|
||||
|
||||
private fun blocksOf(cases: List<HandlingCase>) =
|
||||
cases.map { Block(it.title, it.moves.map { move -> Note(move.change, move.why) }) }
|
||||
|
||||
private const val OPENING =
|
||||
"Leaf-spring Camaro on asphalt. Hobby stock or thunder car. If the rule book disagrees, follow the rule book. Change one thing, write it in the log, and read the tires before the next guess."
|
||||
|
||||
private val book = listOf(
|
||||
Chapter(
|
||||
"How to tell",
|
||||
"Fix the middle before the entry or the exit. A tight car can feel loose: the driver adds steering, the front bites, and the rear snaps when the throttle comes in. If the wheel is turned farther at speed than on a moderate lap, the car is tight. If it is turned less, it is loose. If you are steering right in the middle, bring it in.",
|
||||
),
|
||||
Chapter(
|
||||
"What to change",
|
||||
"One change, then another run. Skip a move the rule book does not allow.",
|
||||
blocksOf(handling),
|
||||
),
|
||||
Chapter(
|
||||
"Air for handling",
|
||||
"Use this when the only change is air on a bias-ply tire. The same change moves rate, footprint, stagger, and cross. If you only wanted the stagger, put the cross back on the scales. Do not add rear stagger just to free a tight middle.",
|
||||
blocksOf(airHandling),
|
||||
),
|
||||
Chapter(
|
||||
"Heat",
|
||||
"Probe inside, middle, outside right after a full-speed run, before the car sits in the stall. Start at the right front, then the right rear, the left rear, and the left front. The right sides cool first. One person probes and one writes. Same order every time. A needle in the tread beats a surface gun. The surface is usually 20–40° cooler than the probe, and brake heat or engine heat can fool the gun. A spread across one tire past about 10% is the one to fix. A new tire runs hotter than a used one, so read camber and pressure from the spread across that tire. Temps on a car that is way off are a weak signal until it is at least drivable.",
|
||||
notesOf(heatTips),
|
||||
),
|
||||
Chapter(
|
||||
"Air pressure",
|
||||
"Air is a chassis change. It moves spring rate, the footprint, and crossweight. On a bias-ply tire it also moves stagger. The Hoosier 970 in the tri-state book is a bias ply, so air changes its rollout. A radial holds its size, and air on a radial changes rate and footprint only. An American Racer take-off can be either, so read the tire before you use air for stagger. A few pounds is the whole window. Buy the rollout you need, and let air fine-tune it. Stay at or above the tire maker's minimum. Air under that can tear the sidewall.",
|
||||
notesOf(airEffects),
|
||||
),
|
||||
Chapter(
|
||||
"When the set is done",
|
||||
"Write the tire set on the run. Temps, depth, and heat cycles from that set tell you when another week is wishful.",
|
||||
notesOf(tireLife),
|
||||
),
|
||||
Chapter("Where to start", blocks = notesOf(starts)),
|
||||
Chapter(
|
||||
"Order of work",
|
||||
"Geometry and alignment come before brakes and aero. On a hobby-stock F-body a lot of the geometry is frozen. Do the items the book allows, in this order, and stop when the car is balanced.",
|
||||
notesOf(order),
|
||||
),
|
||||
Chapter(
|
||||
"In the shop",
|
||||
"Do this before the first race, and again when something hits the wall or the car starts doing something new. An alignment with a couple of people is an hour or two.",
|
||||
notesOf(shop),
|
||||
),
|
||||
Chapter(
|
||||
"On the scales",
|
||||
"Write the four corner weights and the cross. Same routine every time, or the number does not compare with last week.",
|
||||
notesOf(scales),
|
||||
),
|
||||
Chapter(
|
||||
"These tracks",
|
||||
"Tri-City Raceway, Stateline Speedway, South Sound Speedway, and Wenatchee Valley Super Oval. Hobby stock or thunder car. A second-generation Camaro is not a Gen 3, so the Gen 3 left-side cap does not apply. The book at that gate wins. A weekly bulletin wins over this page.",
|
||||
notesOf(tracks),
|
||||
),
|
||||
Chapter("Leave these alone", blocks = notesOf(leaveAlone)),
|
||||
)
|
||||
|
||||
private data class QuickCase(val label: String, val heading: String)
|
||||
|
||||
private val quickCases = listOf(
|
||||
QuickCase("Tight entry", "Entry, tight"),
|
||||
QuickCase("Loose entry", "Entry, loose"),
|
||||
QuickCase("Tight middle", "Middle, tight"),
|
||||
QuickCase("Loose middle", "Middle, loose"),
|
||||
QuickCase("Tight exit", "Exit, tight"),
|
||||
QuickCase("Loose exit", "Exit, loose"),
|
||||
)
|
||||
|
||||
private fun holds(words: List<String>, text: String) = words.all { text.contains(it, ignoreCase = true) }
|
||||
|
||||
private fun Chapter.forCase(heading: String): Chapter? {
|
||||
val kept = blocks.filter { it.heading == heading }
|
||||
if (kept.isEmpty()) return null
|
||||
return copy(intro = "", blocks = kept)
|
||||
}
|
||||
|
||||
private fun Chapter.narrowed(words: List<String>): Chapter? {
|
||||
if (holds(words, title)) return this
|
||||
val introShown = intro.isNotBlank() && holds(words, intro)
|
||||
val kept = blocks.mapNotNull { block ->
|
||||
if (block.heading.isNotBlank() && holds(words, block.heading)) {
|
||||
block
|
||||
} else {
|
||||
val notes = block.notes.filter { holds(words, it.title + "\n" + it.body) }
|
||||
if (notes.isEmpty()) null else block.copy(notes = notes)
|
||||
}
|
||||
}
|
||||
if (!introShown && kept.isEmpty()) return null
|
||||
return copy(intro = if (introShown) intro else "", blocks = kept)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SetupGuideScreen(onBack: () -> Unit) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
var picked by remember { mutableStateOf<QuickCase?>(null) }
|
||||
val words = query.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||
val shown = when (val choice = picked) {
|
||||
null -> if (words.isEmpty()) book else book.mapNotNull { it.narrowed(words) }
|
||||
else -> book.mapNotNull { it.forCase(choice.heading) }
|
||||
}
|
||||
val showOpening = picked == null && (words.isEmpty() || holds(words, OPENING))
|
||||
val scroll = rememberScrollState()
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
LaunchedEffect(query, picked) { scroll.scrollTo(0) }
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -224,126 +386,114 @@ fun SetupGuideScreen(onBack: () -> Unit) {
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
PitScroll(padding) {
|
||||
Text(
|
||||
"Leaf-spring Camaro on asphalt. Hobby stock or thunder car. If the rule book disagrees, follow the rule book. Change one thing, write it in the log, and read the tires before the next guess.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
SectionLabel("Heat")
|
||||
Text(
|
||||
"Probe inside, middle, outside right after a full-speed run, before the car sits in the stall. Start at the right front, then the right rear, the left rear, and the left front. The right sides cool first. One person probes and one writes. Same order every time. A needle in the tread beats a surface gun. The surface is usually 20–40° cooler than the probe, and brake heat or engine heat can fool the gun. A spread across one tire past about 10% is the one to fix. A new tire runs hotter than a used one, so read camber and pressure from the spread across that tire. Temps on a car that is way off are a weak signal until it is at least drivable.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Column(Modifier.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
heatTips.forEach { tip ->
|
||||
TipLine(tip.see, tip.doThis)
|
||||
}
|
||||
}
|
||||
SectionLabel("Air pressure")
|
||||
Text(
|
||||
"Air is a chassis change. It moves spring rate, the footprint, and crossweight. On a bias-ply tire it also moves stagger. The Hoosier 970 in the tri-state book is a bias ply, so air changes its rollout. A radial holds its size, and air on a radial changes rate and footprint only. An American Racer take-off can be either, so read the tire before you use air for stagger. A few pounds is the whole window. Buy the rollout you need, and let air fine-tune it. Stay at or above the tire maker's minimum. Air under that can tear the sidewall.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
airEffects.forEach { tip ->
|
||||
TipLine(tip.see, tip.doThis)
|
||||
}
|
||||
SectionLabel("When the set is done")
|
||||
Text(
|
||||
"Write the tire set on the run. Temps, depth, and heat cycles from that set tell you when another week is wishful.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
tireLife.forEach { tip ->
|
||||
TipLine(tip.see, tip.doThis)
|
||||
}
|
||||
SectionLabel("How to tell")
|
||||
Text(
|
||||
"Fix the middle before the entry or the exit. A tight car can feel loose: the driver adds steering, the front bites, and the rear snaps when the throttle comes in. If the wheel is turned farther at speed than on a moderate lap, the car is tight. If it is turned less, it is loose. If you are steering right in the middle, bring it in.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
SectionLabel("What to change")
|
||||
Text(
|
||||
"One change, then another run. Skip a move the rule book does not allow.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
handling.forEach { item ->
|
||||
Text(
|
||||
item.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.widthIn(max = 640.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = {
|
||||
query = it
|
||||
if (it.isNotEmpty()) picked = null
|
||||
},
|
||||
label = { Text("Look up") },
|
||||
placeholder = { Text("stagger, camber, tight") },
|
||||
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = { query = "" }) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Clear lookup")
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Search,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onSearch = { keyboard?.hide() }),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
item.moves.forEach { move ->
|
||||
TipLine(move.change, move.why)
|
||||
quickCases.chunked(2).forEach { row ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
row.forEach { choice ->
|
||||
FilterChip(
|
||||
selected = picked == choice,
|
||||
onClick = {
|
||||
picked = if (picked == choice) null else choice
|
||||
query = ""
|
||||
keyboard?.hide()
|
||||
},
|
||||
label = { Text(choice.label) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionLabel("Air for handling")
|
||||
Text(
|
||||
"Longacre's order when the only change is air on a bias-ply tire. The same change moves rate, footprint, stagger, and cross. If you only wanted the stagger, put the cross back on the scales. Size rear stagger for the radius, then leave it. Extra past that gets loose on power, so do not add rear stagger just to free a tight middle.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
airHandling.forEach { item ->
|
||||
Text(
|
||||
item.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
)
|
||||
item.moves.forEach { move ->
|
||||
TipLine(move.change, move.why)
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scroll),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.widthIn(max = 640.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
if (showOpening) {
|
||||
Text(OPENING, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
if (shown.isEmpty()) {
|
||||
Text(
|
||||
"Nothing in the book matches that.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
)
|
||||
}
|
||||
shown.forEach { chapter ->
|
||||
SectionLabel(chapter.title)
|
||||
if (chapter.intro.isNotEmpty()) {
|
||||
Text(chapter.intro, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
chapter.blocks.forEach { block ->
|
||||
if (block.heading.isNotEmpty()) {
|
||||
Text(
|
||||
block.heading,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
)
|
||||
}
|
||||
if (chapter.title == "Heat" && block.heading.isEmpty()) {
|
||||
Column(
|
||||
Modifier.padding(top = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
block.notes.forEach { TipLine(it.title, it.body) }
|
||||
}
|
||||
} else {
|
||||
block.notes.forEach { TipLine(it.title, it.body) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionLabel("Order of work")
|
||||
Text(
|
||||
"Geometry and alignment come before brakes and aero. On a hobby-stock F-body a lot of the geometry is frozen. Do the items the book allows, in this order, and stop when the car is balanced.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
order.forEach { tip ->
|
||||
TipLine(tip.see, tip.doThis)
|
||||
}
|
||||
SectionLabel("Where to start")
|
||||
TipLine("Crossweight", "52–55% on a leaf-spring car. Less than a coil car usually wants.")
|
||||
TipLine("Front springs", "Rate each spring at the height it sits in the car. Replace a bent or tired one. Keep the split under about 100 lb. Softening the front adds front traction, so the cross has to come up or the car gets loose. If the front out-rolls the rear, stiffen the front springs and use less bar.")
|
||||
TipLine("Caster", "Top of the ball-joint line tilted back is positive. Tilted forward is negative. Positive caster holds a line. Too much makes the wheel heavy. Negative caster lets the car wander on the straight. A short track often wants about 2–4° of split, left front around 1–2° and right front around 3–5°. More banking wants less caster. A tighter radius wants more split. At mid-turn the wheel should pull slightly. Check the toe after you change caster.")
|
||||
TipLine("Blocks", "Under the leaves. More block tightens the entry and the middle. Over 3 inches cuts forward bite. South Sound allows steel leaves, at least four, with blocks, shackles, and sliders. The tri-state book allows steel or poly leaves and shackles.")
|
||||
TipLine("Sway bar", "Less bar if the car is tight. More bar if it is loose. The tri-state book allows a 1-1/4 inch bar in the stock saddles, adjusted with all-thread, no heim joints. South Sound's book does not list a bar.")
|
||||
TipLine("Stagger", "Right rear minus left rear, around the center of the tread, with a flexible 1/4-inch tape, at the cold pressure you will race. On a bias-ply tire, more air on the right, or less on the left, grows the stagger before you change tires. That rear-stagger gain takes cross out. More air that grows the right front adds cross. A tighter radius wants more. More banking wants a little less. Too much makes the car turn left on the straight, so the driver steers right and scrubs speed. On a bias-ply tire a sliding right rear heats, grows, and the loose gets worse as the run goes on. Too little heats the right front and the push gets worse the same way.")
|
||||
TipLine("Toe", "1/16 to 1/8 inch out in front on asphalt. A tighter track wants the larger end. Tracks under a half mile are published out to 1/4 inch. Rear wheels straight. Toe out takes the slack out of the tie-rod ends. Check it after a camber change, a caster change, a ride-height change, or any wheel contact.")
|
||||
TipLine("Added toe", "Toe that grows as the wheel turns is Ackermann. Toe that shrinks is reverse Ackermann. One degree is about 1/2 inch of toe on an 85-inch tire. A tight quarter-mile only needs about 1/16 inch more on the left front. A half-mile needs about 1/64 inch. Stock boxes already add toe in a left turn, so measure it before you add any. Another note calls about 3° of extra left-front steer, in 10° of wheel turn, a common amount, and more of it on a 3/8-mile and under. Past what the corner needs, the left front grains, runs hot, and drags, and the car gets loose off. A little can free a tight middle. You feel it at the apex.")
|
||||
TipLine("String check", "Fronts straight. Mark the floor 10 feet ahead of each spindle. Turn the wheel about as far as the driver does in the corner and mark again. If the left mark moved farther than the right, the car gained toe. If the right mark moved farther, the car lost toe. Tie rods that angle forward from the inner pivot add Ackermann. If the spindle arm is slotted and the book allows it, moving the left tie-rod end closer to the ball joint adds more.")
|
||||
TipLine("Brakes", "About 60–65% of the braking force on the front. A gauge in the bleeder tells you the pressure at that wheel. More rear brake loosens a hard entry. If the adjuster ends up slammed to one side, the master-cylinder size or the pads are doing the work the knob cannot do.")
|
||||
TipLine("Lead", "Look for quarter pounds when you build the car, and leave the safety parts alone. Stay at the book's minimum total. Running heavy to buy left-side percent makes the tires work harder. One low clump, left of center, near the middle of the car, as close to the left-side maximum as that weight allows. Lead split front to rear, or left rail to right rail, reads the same on the scales and transfers more once the car is moving. Battery, tanks, and other heavy parts go on the left. Paint the lead white with the car number.")
|
||||
TipLine("Pinion", "A leaf car needs more angle than a coil car because the spring wraps and the pinion rises. Measure pinion versus driveshaft at ride height, car weighted. The U-joint wants at least 1/2° so the needles turn, and most shops want it under 3°. About 2° nose-down from the transmission centerline is a circle-track leaf note so the joints oppose. On asphalt, make a rear-steer change small, then leave it and tune the car with the other pieces. The 5–7° nose-down figure is a drag-racing launch number.")
|
||||
TipLine("Tires", "Hot pressure has to land within about 0.5 psi of the tire maker's target. The tread, inside middle outside, says whether that tire was high or low once it was hot. Air is also rate, footprint, and cross. Two tires with the same size stamp can still differ by an inch or more around. Buy that rollout. Air only fine-tunes it.")
|
||||
SectionLabel("In the shop")
|
||||
Text(
|
||||
"Do this before the first race, and again when something hits the wall or the car starts doing something new. An alignment with a couple of people is an hour or two.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
shop.forEach { tip ->
|
||||
TipLine(tip.see, tip.doThis)
|
||||
}
|
||||
SectionLabel("On the scales")
|
||||
Text(
|
||||
"Write the four corner weights in the notes and the cross on the setup. Same routine every time, or the number does not compare with last week.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
scales.forEach { tip ->
|
||||
TipLine(tip.see, tip.doThis)
|
||||
}
|
||||
SectionLabel("These tracks")
|
||||
Text(
|
||||
"Tri-City Raceway, Stateline Speedway, South Sound Speedway, and Wenatchee Valley Super Oval. Hobby stock or thunder car. A second-generation Camaro is not a Gen 3, so the Gen 3 left-side cap does not apply. The book at that gate wins. A weekly bulletin wins over this page.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TipLine("Camber", "2 inches on the right and 1 inch on the left, measured with a square at the top of the wheel.")
|
||||
TipLine("Tri-state book", "Stateline hobby stock and Wenatchee thunder cars. A second-gen Camaro: 3,200 lb, 55% left maximum, 48% rear maximum, on a stock-dimension engine under 360 cubic inches. Hoosier 970, and the tire you qualify on starts the main. Sway bar 1-1/4 inch maximum, stock saddles, all-thread adjuster. No weight jacks. Pocket spring adjusters, steel or poly leaves, and shackles are allowed. Wenatchee allows an open rear. Stateline is spool or welded. No limited slip and no Detroit Locker. Ride height 5 inches. Lead on 1/2-inch grade 5 bolts.")
|
||||
TipLine("South Sound book", "3,250 lb, 54% left maximum, 49% rear maximum. American Racer take-off slicks. Steel leaves, at least four, with blocks, shackles, and sliders. Open, welded, or a spool. Same 2-inch and 1-inch camber, and the same 5-inch ride height. The book does not list a sway bar.")
|
||||
TipLine("Ride height", "5 inches from the lowest frame at the driver's door.")
|
||||
SectionLabel("Leave these alone")
|
||||
TipLine("Big-bar soft-spring", "A late-model package. Soft front springs and a big bar are there to hold a sleek nose on the ground so the aero can work. The bar adds rate, so the car is stiffer, and the tires have to live with that load. A stock-body leaf-spring car does not get that aero, and a bigger bar on a leaf-spring Camaro faded after a few laps. Banking already holds the nose down, so the bar does less of that job.")
|
||||
TipLine("Dirt sheets", "Clay numbers, including a half inch of front toe-out, are for a slick rutted track.")
|
||||
TipLine("Drag pinion", "5–7° nose-down is a hard launch on a soft spring, not an asphalt oval setting.")
|
||||
TipLine("Even temps", "A simulator that wants the inside hottest on every tire is not the paved-oval target above.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -355,3 +505,4 @@ private fun TipLine(title: String, body: String) {
|
||||
Text(body, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,10 +153,6 @@ fun CarsScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(car.carName, style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
"${car.raceClass} · ${car.discipline}",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { pendingDelete = car }) {
|
||||
Icon(
|
||||
|
||||
@@ -44,8 +44,6 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.racingapp.pitlog.data.Car
|
||||
import com.racingapp.pitlog.data.DEFAULT_CLASS
|
||||
import com.racingapp.pitlog.data.DEFAULT_DISCIPLINE
|
||||
import com.racingapp.pitlog.data.NightWithRuns
|
||||
import com.racingapp.pitlog.data.Repository
|
||||
import com.racingapp.pitlog.model.Corner
|
||||
@@ -160,7 +158,6 @@ fun HomeScreen(
|
||||
) {
|
||||
item {
|
||||
HomeHeader(
|
||||
profile = state.profile,
|
||||
tonight = tonight,
|
||||
onLogRun = onLogRun,
|
||||
onNewNight = onNewNight,
|
||||
@@ -189,24 +186,13 @@ fun HomeScreen(
|
||||
|
||||
@Composable
|
||||
private fun HomeHeader(
|
||||
profile: Car?,
|
||||
tonight: NightWithRuns?,
|
||||
onLogRun: (Long) -> Unit,
|
||||
onNewNight: () -> Unit,
|
||||
onCarSetup: () -> Unit,
|
||||
) {
|
||||
val raceClass = profile?.raceClass?.ifBlank { DEFAULT_CLASS } ?: DEFAULT_CLASS
|
||||
val discipline = profile?.discipline?.ifBlank { DEFAULT_DISCIPLINE } ?: DEFAULT_DISCIPLINE
|
||||
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CarMark()
|
||||
Column(Modifier.padding(start = 12.dp)) {
|
||||
Text(
|
||||
"$raceClass · $discipline",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
CarMark()
|
||||
if (tonight != null) {
|
||||
Button(
|
||||
onClick = { onLogRun(tonight.night.id) },
|
||||
|
||||
@@ -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(
|
||||
@@ -286,12 +287,9 @@ private fun NightHeader(night: RaceNight) {
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(formatLongDate(night.dateEpochDay), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
listOf(night.carName, night.raceClass, night.discipline)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" · "),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (night.carName.isNotBlank()) {
|
||||
Text(night.carName, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
night.airTempF?.let { Text("Air $it°F") }
|
||||
if (night.notes.isNotBlank()) {
|
||||
Text(night.notes, modifier = Modifier.padding(top = 4.dp))
|
||||
|
||||
@@ -356,11 +356,6 @@ fun NightEditorScreen(
|
||||
value = form.carName,
|
||||
onValue = { value -> viewModel.update { it.copy(carName = value.take(80)) } },
|
||||
)
|
||||
WordsField(
|
||||
label = "Class",
|
||||
value = form.raceClass,
|
||||
onValue = { value -> viewModel.update { it.copy(raceClass = value.take(80)) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -246,7 +249,7 @@ fun SetupEditorScreen(nightId: Long, onBack: () -> Unit) {
|
||||
}
|
||||
SectionLabel("Blocks, inches")
|
||||
Text(
|
||||
"Under the leaves. More block tightens entry and the middle. Over 3 inches cuts forward bite. String the rear after you move them.",
|
||||
"The right rear block is the one that tightens. A taller right rear block is more rear roll stiffness. A taller left rear block frees the entry and the middle. Over 3 inches cuts forward bite. String the rear after you move them.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
FieldRow {
|
||||
@@ -254,22 +257,26 @@ 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),
|
||||
)
|
||||
}
|
||||
SectionLabel("Sway bar")
|
||||
Text(
|
||||
"Where the bar is now. Less bar if the car is tight. More bar if it is loose. The bar has to move freely: space the mounts off the frame, or use a bushing about 1/8 inch larger than the bar.",
|
||||
"The all-thread sets front roll stiffness. Less if the car is tight. More if it is loose. The bar has to move freely: space the mounts off the frame, or use a bushing about 1/8 inch larger than the bar.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
NumberField(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -232,16 +232,6 @@ fun SettingsScreen(showCar: Boolean, onBack: () -> Unit, onCarRemoved: () -> Uni
|
||||
value = viewModel.carName,
|
||||
onValue = { viewModel.carName = it.take(80) },
|
||||
)
|
||||
WordsField(
|
||||
label = "Class",
|
||||
value = viewModel.raceClass,
|
||||
onValue = { viewModel.raceClass = it.take(80) },
|
||||
)
|
||||
WordsField(
|
||||
label = "Type of racing",
|
||||
value = viewModel.discipline,
|
||||
onValue = { viewModel.discipline = it.take(40) },
|
||||
)
|
||||
SectionLabel("This car's token")
|
||||
Text(
|
||||
"This token is 8 digits. Share it with another phone, then on that phone use Add a car from a token. That phone syncs this car. A different car gets a different token.",
|
||||
@@ -332,6 +322,10 @@ fun SettingsScreen(showCar: Boolean, onBack: () -> Unit, onCarRemoved: () -> Uni
|
||||
)
|
||||
}
|
||||
SectionLabel("App")
|
||||
Text(
|
||||
"Alpha ${context.packageManager.getPackageInfo(context.packageName, 0).versionName.orEmpty()}",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.checkForUpdate(context) },
|
||||
enabled = !viewModel.updating,
|
||||
|
||||
@@ -32,11 +32,15 @@ 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.ui.FieldRow
|
||||
import com.racingapp.pitlog.ui.NumberField
|
||||
@@ -64,6 +68,13 @@ private data class CarSetupForm(
|
||||
val blockRr: String = "",
|
||||
val toeFront: String = "",
|
||||
val toeRear: String = "",
|
||||
val weightLf: String = "",
|
||||
val weightRf: String = "",
|
||||
val weightLr: String = "",
|
||||
val weightRr: String = "",
|
||||
val rideHeight: String = "",
|
||||
val pinion: String = "",
|
||||
val brakeFront: String = "",
|
||||
val note: String = "",
|
||||
) {
|
||||
fun toSheet(id: Long, recordedAt: Long) = SetupSheet(
|
||||
@@ -80,10 +91,17 @@ 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 = parseInches(rideHeight),
|
||||
pinion = parseDecimal(pinion),
|
||||
brakeFront = parseDecimal(brakeFront),
|
||||
)
|
||||
|
||||
companion object {
|
||||
@@ -98,10 +116,17 @@ 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 = formatInches(sheet.rideHeight),
|
||||
pinion = decimal(sheet.pinion, 1),
|
||||
brakeFront = decimal(sheet.brakeFront, 1),
|
||||
note = if (keepNote) sheet.note else "",
|
||||
)
|
||||
}
|
||||
@@ -294,17 +319,25 @@ fun CarSetupEditorScreen(
|
||||
)
|
||||
}
|
||||
SectionLabel("Blocks, inches")
|
||||
Text(
|
||||
"A taller right rear block tightens the entry and the middle. A taller left rear block frees them.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
FieldRow {
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -332,9 +365,89 @@ 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, front, rear, and cross come out in pounds and percent. Rear bite is left rear minus right rear.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
FieldRow {
|
||||
NumberField(
|
||||
label = "LF",
|
||||
value = form.weightLf,
|
||||
onValue = { value -> viewModel.update { it.copy(weightLf = sanitizeInt(value, 4)) } },
|
||||
decimal = false,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
NumberField(
|
||||
label = "RF",
|
||||
value = form.weightRf,
|
||||
onValue = { value -> viewModel.update { it.copy(weightRf = sanitizeInt(value, 4)) } },
|
||||
decimal = false,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
FieldRow {
|
||||
NumberField(
|
||||
label = "LR",
|
||||
value = form.weightLr,
|
||||
onValue = { value -> viewModel.update { it.copy(weightLr = sanitizeInt(value, 4)) } },
|
||||
decimal = false,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
NumberField(
|
||||
label = "RR",
|
||||
value = form.weightRr,
|
||||
onValue = { value -> viewModel.update { it.copy(weightRr = sanitizeInt(value, 4)) } },
|
||||
decimal = false,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
scaleLines(
|
||||
form.weightLf.toIntOrNull(),
|
||||
form.weightRf.toIntOrNull(),
|
||||
form.weightLr.toIntOrNull(),
|
||||
form.weightRr.toIntOrNull(),
|
||||
).forEach { line ->
|
||||
Text(line, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
SectionLabel("Ride height")
|
||||
Text(
|
||||
"Inches from the lowest frame at the driver's door.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
NumberField(
|
||||
label = "Inches",
|
||||
value = form.rideHeight,
|
||||
onValue = { value -> viewModel.update { it.copy(rideHeight = sanitizeInches(value)) } },
|
||||
inches = true,
|
||||
placeholder = "5",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
SectionLabel("Pinion")
|
||||
Text(
|
||||
"Degrees at ride height. Negative is nose-down.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
SignedField(
|
||||
"Degrees",
|
||||
form.pinion,
|
||||
{ value -> viewModel.update { it.copy(pinion = value) } },
|
||||
Modifier.fillMaxWidth(),
|
||||
)
|
||||
SectionLabel("Front brake")
|
||||
Text(
|
||||
"Percent of the braking force on the front.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
NumberField(
|
||||
label = "Front %",
|
||||
value = form.brakeFront,
|
||||
onValue = { value -> viewModel.update { it.copy(brakeFront = sanitizeDecimal(value, 2, 1)) } },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
SectionLabel(if (loggingChange) "What changed" else "Notes")
|
||||
OutlinedTextField(
|
||||
value = form.note,
|
||||
@@ -357,6 +470,7 @@ private fun SignedField(
|
||||
value: String,
|
||||
onValue: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
inches: Boolean = false,
|
||||
wholeDigits: Int = 2,
|
||||
fractionDigits: Int = 1,
|
||||
) {
|
||||
@@ -364,7 +478,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(
|
||||
|
||||
@@ -115,7 +115,7 @@ class SyncMigrationTest {
|
||||
raw.close()
|
||||
|
||||
val database = Room.databaseBuilder(context, AppDatabase::class.java, name)
|
||||
.addMigrations(MIGRATION_7_8, MIGRATION_8_9)
|
||||
.addMigrations(MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10)
|
||||
.build()
|
||||
runBlocking {
|
||||
val night = database.raceDao().nightById(1)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.racingapp.pitlog.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class InchesTest {
|
||||
@Test
|
||||
fun aFractionParsesAndComesBackAsAFraction() {
|
||||
assertEquals(0.0625, parseInches("1/16")!!, 0.00001)
|
||||
assertEquals(0.125, parseInches("1/8")!!, 0.00001)
|
||||
assertEquals(2.5, parseInches("2 1/2")!!, 0.00001)
|
||||
assertEquals(-0.0625, parseInches("-1/16")!!, 0.00001)
|
||||
assertEquals(1.125, parseInches("1.125")!!, 0.00001)
|
||||
assertEquals("1/16", formatInches(parseInches("1/16")))
|
||||
assertEquals("1/8", formatInches(parseInches("1/8")))
|
||||
assertEquals("2 1/2", formatInches(parseInches("2 1/2")))
|
||||
assertEquals("-1/16", formatInches(parseInches("-1/16")))
|
||||
assertEquals("1 1/8", formatInches(1.125))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typingKeepsTheSlashUntilTheFractionIsFinished() {
|
||||
assertEquals("1/16", sanitizeInches("1/16"))
|
||||
assertEquals("1/", sanitizeInches("1/"))
|
||||
assertEquals("2 1/2", sanitizeInches("2 1/2"))
|
||||
assertEquals("-1/8", sanitizeInches("-1/8", signed = true))
|
||||
assertNull(parseInches("1/"))
|
||||
assertNull(parseInches("1/0"))
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ class SetupLogTest {
|
||||
listOf(
|
||||
"RF spring 1000 lb → 1050 lb",
|
||||
"Sway bar Setting 4 → Setting 3",
|
||||
"Front toe 0.125 in → 0.063 in",
|
||||
"Front toe 1/8 in → 1/16 in",
|
||||
),
|
||||
setupDiffs(original, later),
|
||||
)
|
||||
@@ -35,6 +35,55 @@ class SetupLogTest {
|
||||
assertTrue(setupDiffs(sheet, sheet.copy(note = "perch tweak")).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deepSetupShowsWeightsHeightPinionAndBrake() {
|
||||
val original = SetupSheet(
|
||||
recordedAt = 1L,
|
||||
weightLf = 800,
|
||||
weightRf = 760,
|
||||
weightLr = 740,
|
||||
weightRr = 700,
|
||||
rideHeight = 5.0,
|
||||
pinion = -2.0,
|
||||
brakeFront = 62.0,
|
||||
)
|
||||
val later = original.copy(
|
||||
weightLf = 820,
|
||||
rideHeight = 5.2,
|
||||
pinion = -1.5,
|
||||
brakeFront = 65.0,
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"Weights LF 800 RF 760 LR 740 RR 700 lb",
|
||||
"Total 3000 lb",
|
||||
"Left 1540 lb, 51.3%",
|
||||
"Front 1560 lb, 52%",
|
||||
"Rear 1440 lb, 48%",
|
||||
"Cross 1500 lb, 50%",
|
||||
"Rear bite 40",
|
||||
"Ride height 5 in",
|
||||
"Pinion -2.0°",
|
||||
"Front brake 62%",
|
||||
),
|
||||
setupLines(original),
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"LF weight 800 lb → 820 lb",
|
||||
"Total 3000 lb → 3020 lb",
|
||||
"Left 1540 lb, 51.3% → 1560 lb, 51.7%",
|
||||
"Front 1560 lb, 52% → 1580 lb, 52.3%",
|
||||
"Rear 1440 lb, 48% → 1440 lb, 47.7%",
|
||||
"Cross 1500 lb, 50% → 1500 lb, 49.7%",
|
||||
"Ride height 5 in → 5.2 in",
|
||||
"Pinion -2.0° → -1.5°",
|
||||
"Front brake 62% → 65%",
|
||||
),
|
||||
setupDiffs(original, later),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun originalLinesSkipBlankFields() {
|
||||
val lines = setupLines(SetupSheet(recordedAt = 1L, crossPercent = 53.5, blockLr = 2.0))
|
||||
|
||||
@@ -116,9 +116,9 @@ class TireSheetTest {
|
||||
assertTrue(softRear.single().say.contains("softer right rear"))
|
||||
|
||||
val tightBar = chassisTips(ChassisSetup(swayBarHole = 4), handling = "Tight")
|
||||
assertEquals("Less bar.", tightBar.single().say)
|
||||
assertEquals("Less front roll stiffness.", tightBar.single().say)
|
||||
val looseBar = chassisTips(ChassisSetup(swayBarHole = 4), handling = "Loose")
|
||||
assertEquals("More bar.", looseBar.single().say)
|
||||
assertEquals("More front roll stiffness.", looseBar.single().say)
|
||||
assertTrue(chassisTips(ChassisSetup(swayBarHole = 4)).isEmpty())
|
||||
|
||||
val tightBlocks = chassisTips(
|
||||
@@ -126,13 +126,13 @@ class TireSheetTest {
|
||||
handling = "Tight",
|
||||
handlingWhere = "Entry",
|
||||
)
|
||||
assertEquals("Less block.", tightBlocks.single().say)
|
||||
assertEquals("Less rear roll stiffness. Take block out of the right rear.", tightBlocks.single().say)
|
||||
val looseBlocks = chassisTips(
|
||||
ChassisSetup(blockLr = 2.0, blockRr = 2.0),
|
||||
handling = "Loose",
|
||||
handlingWhere = "Center",
|
||||
)
|
||||
assertEquals("More block.", looseBlocks.single().say)
|
||||
assertEquals("More rear roll stiffness. Add block to the right rear.", looseBlocks.single().say)
|
||||
assertTrue(
|
||||
chassisTips(
|
||||
ChassisSetup(blockLr = 2.0, blockRr = 2.0),
|
||||
|
||||
+54
-3
@@ -27,6 +27,8 @@ SHEET_FIELDS = [
|
||||
"recordedAt", "note", "crossPercent", "springLf", "springRf", "springLr", "springRr",
|
||||
"camberLf", "camberRf", "casterLf", "casterRf", "swayBarHole", "blockLr", "blockRr",
|
||||
"toeFront", "toeRear",
|
||||
"weightLf", "weightRf", "weightLr", "weightRr",
|
||||
"rideHeight", "pinion", "brakeFront",
|
||||
]
|
||||
|
||||
_lock = threading.Lock()
|
||||
@@ -89,10 +91,30 @@ def open_db(path):
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.executescript(SCHEMA)
|
||||
ensure_columns(
|
||||
db,
|
||||
"sheets",
|
||||
{
|
||||
"weightLf": "INTEGER",
|
||||
"weightRf": "INTEGER",
|
||||
"weightLr": "INTEGER",
|
||||
"weightRr": "INTEGER",
|
||||
"rideHeight": "REAL",
|
||||
"pinion": "REAL",
|
||||
"brakeFront": "REAL",
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
return db
|
||||
|
||||
|
||||
def ensure_columns(db, table, columns):
|
||||
present = {row["name"] for row in db.execute(f"PRAGMA table_info({table})")}
|
||||
for name, kind in columns.items():
|
||||
if name not in present:
|
||||
db.execute(f"ALTER TABLE {table} ADD COLUMN {name} {kind}")
|
||||
|
||||
|
||||
def space(bearer):
|
||||
path = space_path(bearer)
|
||||
with _lock:
|
||||
@@ -182,7 +204,14 @@ SCHEMA = """
|
||||
blockLr REAL,
|
||||
blockRr REAL,
|
||||
toeFront REAL,
|
||||
toeRear REAL
|
||||
toeRear REAL,
|
||||
weightLf INTEGER,
|
||||
weightRf INTEGER,
|
||||
weightLr INTEGER,
|
||||
weightRr INTEGER,
|
||||
rideHeight REAL,
|
||||
pinion REAL,
|
||||
brakeFront REAL
|
||||
);
|
||||
"""
|
||||
|
||||
@@ -422,7 +451,12 @@ def serve():
|
||||
print(f"Pit Log sync listening on {port}", flush=True)
|
||||
print("Each car in the app has its own token.", flush=True)
|
||||
server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
|
||||
server.serve_forever()
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("Pit Log sync stopped.", flush=True)
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
def selftest():
|
||||
@@ -503,7 +537,22 @@ def selftest():
|
||||
"recordedAt": 11,
|
||||
}
|
||||
],
|
||||
"sheets": [],
|
||||
"sheets": [
|
||||
{
|
||||
"syncId": "sheet-a",
|
||||
"updatedAt": 12,
|
||||
"deleted": False,
|
||||
"recordedAt": 12,
|
||||
"note": "",
|
||||
"weightLf": 800,
|
||||
"weightRf": 760,
|
||||
"weightLr": 740,
|
||||
"weightRr": 700,
|
||||
"rideHeight": 5.0,
|
||||
"pinion": -2.0,
|
||||
"brakeFront": 62.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
second = sync_body({"rev": 0, "profile": {"carName": "1976 Chevy Camaro", "raceClass": "Hobby Stock", "discipline": "Oval", "updatedAt": 0}, "nights": [], "runs": [], "sheets": []})
|
||||
@@ -511,6 +560,8 @@ def selftest():
|
||||
raise SystemExit("second phone did not receive the night")
|
||||
if second["runs"][0]["nightSyncId"] != "night-a":
|
||||
raise SystemExit("second phone did not receive the run")
|
||||
if second["sheets"][0]["weightLf"] != 800 or second["sheets"][0]["pinion"] != -2.0:
|
||||
raise SystemExit("second phone did not receive the setup")
|
||||
older = sync_body(
|
||||
{
|
||||
"rev": first["rev"],
|
||||
|
||||
Reference in new issue
Block a user