Compare commits
10
Commits
f2dac546d0
..
v0.003
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b008d0e01 | ||
|
|
ecd5c286dd | ||
|
|
ffad275e13 | ||
|
|
119f9b9554 | ||
|
|
9a4db39b58 | ||
|
|
13451a9955 | ||
|
|
a164d9c135 | ||
|
|
b95cbefeab | ||
|
|
0224e8f395 | ||
|
|
ee1079a079 |
No files matched your search
@@ -6,16 +6,20 @@ Pressures are PSI. Tire heat is °F, read inside, middle, then outside.
|
|||||||
|
|
||||||
## On the phone
|
## On the phone
|
||||||
|
|
||||||
|
The app opens on the list of cars. Tap a car to see its nights. Add one from that list, or paste a token from another phone.
|
||||||
|
|
||||||
The gear icon opens **Car**. A new install already has the server address filled in:
|
The gear icon opens **Car**. A new install already has the server address filled in:
|
||||||
|
|
||||||
`http://sync.sleepingliongames.com:27017`
|
`http://sync.sleepingliongames.com:27017`
|
||||||
|
|
||||||
**Sync on its own** sends a save right away, checks about every 30 seconds while the app is open, and about every 15 minutes while it is closed. **Sync now** sends every car on the phone.
|
**Sync on its own** sends a save right away and checks about every 30 seconds while the app is open. It stays quiet while the app is closed. **Sync now** sends every car on the phone.
|
||||||
|
|
||||||
Each car has its own token, shown on the **Car** screen. **Share token** sends that string to another phone. On the other phone, paste it under **Add a car from a token**. That phone then syncs that car. A different car gets a different token.
|
Each car has its own 8-digit token, shown on the **Car** screen. **Share token** sends those digits to another phone. On the other phone, paste it under **Add a car from a token**. That phone then syncs that car. A different car gets a different token.
|
||||||
|
|
||||||
**Remove from this phone** takes the car off that phone only. The other phone and the server keep it.
|
**Remove from this phone** takes the car off that phone only. The other phone and the server keep it.
|
||||||
|
|
||||||
|
**Check for updates** on the Car screen looks at the latest release on this git. When a newer one is posted, the app downloads it and Android asks you to install it.
|
||||||
|
|
||||||
The book icon opens the setup guide. **Car setup** on the home screen is the original setup for the selected car, plus a log of later changes. A race night keeps its own chassis sheet.
|
The book icon opens the setup guide. **Car setup** on the home screen is the original setup for the selected car, plus a log of later changes. A race night keeps its own chassis sheet.
|
||||||
|
|
||||||
## Sync server
|
## Sync server
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "com.racingapp.pitlog"
|
applicationId = "com.racingapp.pitlog"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 3
|
||||||
versionName = "1.0"
|
versionName = "0.003"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -70,7 +70,6 @@ dependencies {
|
|||||||
implementation(libs.androidx.room.runtime)
|
implementation(libs.androidx.room.runtime)
|
||||||
implementation(libs.androidx.room.ktx)
|
implementation(libs.androidx.room.ktx)
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
implementation(libs.androidx.work)
|
|
||||||
ksp(libs.androidx.room.compiler)
|
ksp(libs.androidx.room.compiler)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
|
||||||
@@ -10,6 +11,10 @@
|
|||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
<data android:mimeType="text/csv" />
|
<data android:mimeType="text/csv" />
|
||||||
</intent>
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<data android:mimeType="application/vnd.android.package-archive" />
|
||||||
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ open class RacingApp : Application() {
|
|||||||
val dao = AppDatabase.create(this).raceDao()
|
val dao = AppDatabase.create(this).raceDao()
|
||||||
val preferences = SyncPreferences(this)
|
val preferences = SyncPreferences(this)
|
||||||
runBlocking(Dispatchers.IO) { ensureCars(dao, preferences) }
|
runBlocking(Dispatchers.IO) { ensureCars(dao, preferences) }
|
||||||
val sync = PitSync(this, dao, preferences)
|
val sync = PitSync(dao, preferences)
|
||||||
repository = Repository(dao, sync, preferences)
|
repository = Repository(dao, sync, preferences)
|
||||||
sync.schedule()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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(
|
@Database(
|
||||||
entities = [RaceNight::class, RunLog::class, Car::class, SetupSheet::class],
|
entities = [RaceNight::class, RunLog::class, Car::class, SetupSheet::class],
|
||||||
version = 9,
|
version = 10,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
@@ -245,20 +257,7 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
companion object {
|
companion object {
|
||||||
fun create(context: Context): AppDatabase {
|
fun create(context: Context): AppDatabase {
|
||||||
return Room.databaseBuilder(context, AppDatabase::class.java, "pitlog.db")
|
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)
|
||||||
.addCallback(
|
|
||||||
object : Callback() {
|
|
||||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
|
||||||
super.onCreate(db)
|
|
||||||
db.execSQL(
|
|
||||||
"""
|
|
||||||
INSERT INTO cars (carName, raceClass, discipline, token, updatedAt, syncRev, deleted, pending)
|
|
||||||
VALUES ('$DEFAULT_CAR', '$DEFAULT_CLASS', '$DEFAULT_DISCIPLINE', lower(hex(randomblob(32))), 1, 0, 0, 1)
|
|
||||||
""".trimIndent(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package com.racingapp.pitlog.data
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
private const val LATEST_RELEASE =
|
||||||
|
"https://git.ny.daprogs.com/api/v1/repos/RallyBlock/Pit-Log-App/releases/latest"
|
||||||
|
|
||||||
|
private const val MAX_APK_BYTES = 80_000_000
|
||||||
|
|
||||||
|
private data class PostedRelease(val versionCode: Long, val apkUrl: String)
|
||||||
|
|
||||||
|
fun installedVersionCode(context: Context): Long {
|
||||||
|
val info = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
|
return if (Build.VERSION.SDK_INT >= 28) {
|
||||||
|
info.longVersionCode
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
info.versionCode.toLong()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun checkForAppUpdate(context: Context, onStatus: (String) -> Unit): String {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.packageManager.canRequestPackageInstalls()) {
|
||||||
|
val settings = Intent(
|
||||||
|
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||||
|
Uri.parse("package:${context.packageName}"),
|
||||||
|
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
context.startActivity(settings)
|
||||||
|
return "Allow Pit Log to install updates, then check again."
|
||||||
|
}
|
||||||
|
val release = withContext(Dispatchers.IO) { fetchLatest() } ?: return "Couldn't check for an update."
|
||||||
|
if (release.versionCode == 0L) return "No update has been posted."
|
||||||
|
if (release.versionCode <= installedVersionCode(context)) return "This is the latest version."
|
||||||
|
onStatus("Downloading…")
|
||||||
|
val file = withContext(Dispatchers.IO) { downloadApk(context, release.apkUrl) }
|
||||||
|
?: return "Couldn't download the update."
|
||||||
|
promptInstall(context, file)
|
||||||
|
return "Confirm the install when Android asks."
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchLatest(): PostedRelease? {
|
||||||
|
val connection = open(LATEST_RELEASE) ?: return null
|
||||||
|
return try {
|
||||||
|
val code = connection.responseCode
|
||||||
|
if (code == 404) return PostedRelease(0, "")
|
||||||
|
if (code !in 200..299) return null
|
||||||
|
val body = connection.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
parseRelease(body)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRelease(body: String): PostedRelease? {
|
||||||
|
val json = JSONObject(body)
|
||||||
|
val versionCode = versionCodeOf(json.optString("tag_name")) ?: return null
|
||||||
|
val assets = json.optJSONArray("assets") ?: return null
|
||||||
|
for (index in 0 until assets.length()) {
|
||||||
|
val asset = assets.optJSONObject(index) ?: continue
|
||||||
|
val name = asset.optString("name")
|
||||||
|
val url = asset.optString("browser_download_url")
|
||||||
|
if (name.endsWith(".apk") && url.startsWith("https://")) {
|
||||||
|
return PostedRelease(versionCode, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun versionCodeOf(tag: String): Long? {
|
||||||
|
val raw = tag.removePrefix("v")
|
||||||
|
raw.toLongOrNull()?.let { return it }
|
||||||
|
if (!raw.matches(Regex("""\d+\.\d{3}"""))) return null
|
||||||
|
val whole = raw.substringBefore('.').toLong()
|
||||||
|
val fraction = raw.substringAfter('.').toLong()
|
||||||
|
return whole * 1000 + fraction
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadApk(context: Context, apkUrl: String): File? {
|
||||||
|
val connection = open(apkUrl) ?: return null
|
||||||
|
return try {
|
||||||
|
if (connection.responseCode !in 200..299) return null
|
||||||
|
val length = connection.contentLengthLong
|
||||||
|
if (length > MAX_APK_BYTES) return null
|
||||||
|
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
|
||||||
|
val file = File(dir, "pit-log.apk")
|
||||||
|
connection.inputStream.use { input ->
|
||||||
|
file.outputStream().use { output ->
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
var total = 0L
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read < 0) break
|
||||||
|
total += read
|
||||||
|
if (total > MAX_APK_BYTES) {
|
||||||
|
file.delete()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val zip = file.inputStream().use { stream -> stream.read() == 0x50 && stream.read() == 0x4B }
|
||||||
|
if (!zip) {
|
||||||
|
file.delete()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
file
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun promptInstall(context: Context, file: File) {
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
val install = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
clipData = ClipData.newRawUri("Pit Log", uri)
|
||||||
|
}
|
||||||
|
context.startActivity(install)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun open(address: String): HttpURLConnection? {
|
||||||
|
return try {
|
||||||
|
(URL(address).openConnection() as HttpURLConnection).apply {
|
||||||
|
instanceFollowRedirects = true
|
||||||
|
connectTimeout = 20_000
|
||||||
|
readTimeout = 120_000
|
||||||
|
setRequestProperty("User-Agent", "PitLog")
|
||||||
|
setRequestProperty("Accept", "application/json, application/vnd.android.package-archive")
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,13 @@ data class SetupSheet(
|
|||||||
val blockRr: Double? = null,
|
val blockRr: Double? = null,
|
||||||
val toeFront: Double? = null,
|
val toeFront: Double? = null,
|
||||||
val toeRear: 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 = "''") val syncId: String = "",
|
||||||
@ColumnInfo(defaultValue = "0") val updatedAt: Long = 0,
|
@ColumnInfo(defaultValue = "0") val updatedAt: Long = 0,
|
||||||
@ColumnInfo(defaultValue = "0") val deleted: Boolean = false,
|
@ColumnInfo(defaultValue = "0") val deleted: Boolean = false,
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
package com.racingapp.pitlog.data
|
package com.racingapp.pitlog.data
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.work.Constraints
|
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy
|
|
||||||
import androidx.work.NetworkType
|
|
||||||
import androidx.work.PeriodicWorkRequestBuilder
|
|
||||||
import androidx.work.WorkManager
|
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -23,10 +20,7 @@ import org.json.JSONObject
|
|||||||
|
|
||||||
class SyncException(message: String) : Exception(message)
|
class SyncException(message: String) : Exception(message)
|
||||||
|
|
||||||
private const val WORK_NAME = "pitlog-sync"
|
|
||||||
|
|
||||||
class PitSync(
|
class PitSync(
|
||||||
private val appContext: Context,
|
|
||||||
private val dao: RaceDao,
|
private val dao: RaceDao,
|
||||||
private val preferences: SyncPreferences,
|
private val preferences: SyncPreferences,
|
||||||
) {
|
) {
|
||||||
@@ -36,33 +30,15 @@ class PitSync(
|
|||||||
|
|
||||||
private val gate = Mutex()
|
private val gate = Mutex()
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val _refreshing = MutableStateFlow(false)
|
||||||
|
val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
|
||||||
|
|
||||||
fun saveAddress(address: String) {
|
fun saveAddress(address: String) {
|
||||||
preferences.address = address.trim().trimEnd('/')
|
preferences.address = address.trim().trimEnd('/')
|
||||||
schedule()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setAutomatic(enabled: Boolean) {
|
fun setAutomatic(enabled: Boolean) {
|
||||||
preferences.automatic = enabled
|
preferences.automatic = enabled
|
||||||
schedule()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun schedule() {
|
|
||||||
try {
|
|
||||||
val manager = WorkManager.getInstance(appContext)
|
|
||||||
if (!preferences.automatic || preferences.address.isBlank()) {
|
|
||||||
manager.cancelUniqueWork(WORK_NAME)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val request = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
|
|
||||||
.setConstraints(
|
|
||||||
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(),
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
manager.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// Unit tests do not start WorkManager.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun launchAutomatic() {
|
fun launchAutomatic() {
|
||||||
@@ -92,6 +68,10 @@ class PitSync(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun sync(): String = gate.withLock { withContext(Dispatchers.IO) {
|
private suspend fun sync(): String = gate.withLock { withContext(Dispatchers.IO) {
|
||||||
|
val showRefresh = hasPending()
|
||||||
|
if (showRefresh) _refreshing.value = true
|
||||||
|
val started = System.currentTimeMillis()
|
||||||
|
try {
|
||||||
val base = preferences.address
|
val base = preferences.address
|
||||||
if (!base.startsWith("http://") && !base.startsWith("https://")) {
|
if (!base.startsWith("http://") && !base.startsWith("https://")) {
|
||||||
throw SyncException("The address needs to start with http:// or https://.")
|
throw SyncException("The address needs to start with http:// or https://.")
|
||||||
@@ -118,8 +98,22 @@ class PitSync(
|
|||||||
preferences.status = message
|
preferences.status = message
|
||||||
if (synced == 0) throw SyncException(message)
|
if (synced == 0) throw SyncException(message)
|
||||||
message
|
message
|
||||||
|
} finally {
|
||||||
|
if (showRefresh) {
|
||||||
|
val remaining = 700 - (System.currentTimeMillis() - started)
|
||||||
|
if (remaining > 0) delay(remaining)
|
||||||
|
_refreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
} }
|
} }
|
||||||
|
|
||||||
|
private suspend fun hasPending(): Boolean = dao.activeCars().any { car ->
|
||||||
|
car.pending ||
|
||||||
|
dao.pendingNights(car.id).isNotEmpty() ||
|
||||||
|
dao.pendingRuns(car.id).isNotEmpty() ||
|
||||||
|
dao.pendingSheets(car.id).isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun syncCar(base: String, car: Car) {
|
private suspend fun syncCar(base: String, car: Car) {
|
||||||
val token = car.token.ifBlank { newCarToken().also { dao.updateCar(car.copy(token = it)) } }
|
val token = car.token.ifBlank { newCarToken().also { dao.updateCar(car.copy(token = it)) } }
|
||||||
val current = dao.carById(car.id) ?: car
|
val current = dao.carById(car.id) ?: car
|
||||||
|
|||||||
@@ -123,6 +123,12 @@ interface RaceDao {
|
|||||||
@Query("SELECT * FROM cars ORDER BY id ASC")
|
@Query("SELECT * FROM cars ORDER BY id ASC")
|
||||||
suspend fun everyCar(): List<Car>
|
suspend fun everyCar(): List<Car>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM race_nights WHERE carId = :carId AND deleted = 0")
|
||||||
|
suspend fun nightCount(carId: Long): Int
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM setup_sheets WHERE carId = :carId AND deleted = 0")
|
||||||
|
suspend fun sheetCount(carId: Long): Int
|
||||||
|
|
||||||
@Query("SELECT * FROM cars WHERE deleted = 0 ORDER BY id ASC")
|
@Query("SELECT * FROM cars WHERE deleted = 0 ORDER BY id ASC")
|
||||||
suspend fun activeCars(): List<Car>
|
suspend fun activeCars(): List<Car>
|
||||||
|
|
||||||
@@ -132,6 +138,15 @@ interface RaceDao {
|
|||||||
@Query("SELECT * FROM cars WHERE id = :id")
|
@Query("SELECT * FROM cars WHERE id = :id")
|
||||||
suspend fun carById(id: Long): Car?
|
suspend fun carById(id: Long): Car?
|
||||||
|
|
||||||
|
@Query("UPDATE race_nights SET pending = 1 WHERE carId = :carId")
|
||||||
|
suspend fun markNightsPending(carId: Long)
|
||||||
|
|
||||||
|
@Query("UPDATE runs SET pending = 1 WHERE nightId IN (SELECT id FROM race_nights WHERE carId = :carId)")
|
||||||
|
suspend fun markRunsPending(carId: Long)
|
||||||
|
|
||||||
|
@Query("UPDATE setup_sheets SET pending = 1 WHERE carId = :carId")
|
||||||
|
suspend fun markSheetsPending(carId: Long)
|
||||||
|
|
||||||
@Query("SELECT * FROM cars WHERE token = :token LIMIT 1")
|
@Query("SELECT * FROM cars WHERE token = :token LIMIT 1")
|
||||||
suspend fun carByToken(token: String): Car?
|
suspend fun carByToken(token: String): Car?
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.racingapp.pitlog.data
|
package com.racingapp.pitlog.data
|
||||||
|
|
||||||
|
import java.security.MessageDigest
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -9,43 +10,54 @@ import kotlinx.coroutines.flow.flowOf
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
suspend fun ensureCars(dao: RaceDao, preferences: SyncPreferences) {
|
suspend fun ensureCars(dao: RaceDao, preferences: SyncPreferences) {
|
||||||
val cars = dao.everyCar()
|
for (car in dao.everyCar()) {
|
||||||
if (cars.isEmpty()) {
|
if (car.deleted) continue
|
||||||
val id = dao.insertCar(
|
val untouched = car.updatedAt == 1L &&
|
||||||
Car(
|
car.carName == DEFAULT_CAR &&
|
||||||
carName = DEFAULT_CAR,
|
car.raceClass == DEFAULT_CLASS &&
|
||||||
raceClass = DEFAULT_CLASS,
|
car.discipline == DEFAULT_DISCIPLINE &&
|
||||||
discipline = DEFAULT_DISCIPLINE,
|
dao.nightCount(car.id) == 0 &&
|
||||||
token = newCarToken(),
|
dao.sheetCount(car.id) == 0
|
||||||
updatedAt = 1,
|
if (untouched) {
|
||||||
pending = true,
|
dao.updateCar(car.copy(deleted = true, pending = false))
|
||||||
),
|
continue
|
||||||
)
|
}
|
||||||
preferences.selectedCarId = id
|
if (isEightDigitToken(car.token)) continue
|
||||||
|
val source = car.token.ifBlank { preferences.token.trim() }
|
||||||
|
val next = if (source.isBlank()) newCarToken() else eightDigitToken(source)
|
||||||
|
dao.updateCar(car.copy(token = next, syncRev = 0, pending = true))
|
||||||
|
dao.markNightsPending(car.id)
|
||||||
|
dao.markRunsPending(car.id)
|
||||||
|
dao.markSheetsPending(car.id)
|
||||||
|
}
|
||||||
|
val active = dao.activeCars()
|
||||||
|
if (active.isEmpty()) {
|
||||||
|
preferences.selectedCarId = 0
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val first = cars.firstOrNull { !it.deleted } ?: cars.first()
|
|
||||||
if (first.token.isBlank()) {
|
|
||||||
val kept = preferences.token.trim().ifBlank { newCarToken() }
|
|
||||||
val rev = if (kept == preferences.token.trim() && first.syncRev == 0L) preferences.rev else first.syncRev
|
|
||||||
dao.updateCar(first.copy(token = kept, syncRev = rev, pending = true))
|
|
||||||
}
|
|
||||||
val selected = preferences.selectedCarId
|
val selected = preferences.selectedCarId
|
||||||
if (selected == 0L || cars.none { it.id == selected && !it.deleted }) {
|
if (selected == 0L || active.none { it.id == selected }) {
|
||||||
preferences.selectedCarId = first.id
|
preferences.selectedCarId = active.first().id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun newCarToken(): String {
|
fun newCarToken(): String = "%08d".format(SecureRandom().nextInt(100_000_000))
|
||||||
val bytes = ByteArray(32)
|
|
||||||
SecureRandom().nextBytes(bytes)
|
fun eightDigitToken(current: String): String {
|
||||||
return bytes.joinToString("") { "%02x".format(it) }
|
val digest = MessageDigest.getInstance("SHA-256").digest(current.toByteArray(Charsets.UTF_8))
|
||||||
|
var value = 0L
|
||||||
|
for (index in 0 until 4) {
|
||||||
|
value = (value shl 8) or (digest[index].toLong() and 0xff)
|
||||||
|
}
|
||||||
|
return "%08d".format(value % 100_000_000L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isEightDigitToken(token: String): Boolean = token.length == 8 && token.all { it.isDigit() }
|
||||||
|
|
||||||
fun cleanCarToken(raw: String): String {
|
fun cleanCarToken(raw: String): String {
|
||||||
val line = raw.trim().lineSequence().firstOrNull().orEmpty().trim()
|
val line = raw.trim().lineSequence().firstOrNull().orEmpty().trim()
|
||||||
val withoutLabel = if (line.startsWith("token:", ignoreCase = true)) line.substringAfter(":").trim() else line
|
val withoutLabel = if (line.startsWith("token:", ignoreCase = true)) line.substringAfter(":").trim() else line
|
||||||
return withoutLabel.lowercase()
|
return withoutLabel.filter { !it.isWhitespace() }.lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
class Repository(
|
class Repository(
|
||||||
@@ -125,7 +137,7 @@ class Repository(
|
|||||||
|
|
||||||
suspend fun joinCar(raw: String): String {
|
suspend fun joinCar(raw: String): String {
|
||||||
val token = cleanCarToken(raw)
|
val token = cleanCarToken(raw)
|
||||||
if (token.length < 16 || token.any { it !in '0'..'9' && it !in 'a'..'f' }) {
|
if (!isEightDigitToken(token)) {
|
||||||
return "That token doesn't look right."
|
return "That token doesn't look right."
|
||||||
}
|
}
|
||||||
val existing = dao.carByToken(token)
|
val existing = dao.carByToken(token)
|
||||||
@@ -152,8 +164,7 @@ class Repository(
|
|||||||
val car = dao.carById(id) ?: return
|
val car = dao.carById(id) ?: return
|
||||||
dao.updateCar(car.copy(deleted = true))
|
dao.updateCar(car.copy(deleted = true))
|
||||||
if (selected.value == id) {
|
if (selected.value == id) {
|
||||||
val next = dao.activeCars().firstOrNull { it.id != id } ?: return
|
selectCar(dao.activeCars().firstOrNull { it.id != id }?.id ?: 0L)
|
||||||
selectCar(next.id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +232,8 @@ class Repository(
|
|||||||
sync?.setAutomatic(enabled)
|
sync?.setAutomatic(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun syncRefreshing(): Flow<Boolean> = sync?.refreshing ?: flowOf(false)
|
||||||
|
|
||||||
suspend fun syncNow(): String = sync?.run() ?: "Sync isn't available on this phone."
|
suspend fun syncNow(): String = sync?.run() ?: "Sync isn't available on this phone."
|
||||||
|
|
||||||
suspend fun syncAutomatic() {
|
suspend fun syncAutomatic() {
|
||||||
|
|||||||
@@ -200,6 +200,13 @@ private fun sheetJson(sheet: SetupSheet) = JSONObject()
|
|||||||
.putNullable("blockRr", sheet.blockRr)
|
.putNullable("blockRr", sheet.blockRr)
|
||||||
.putNullable("toeFront", sheet.toeFront)
|
.putNullable("toeFront", sheet.toeFront)
|
||||||
.putNullable("toeRear", sheet.toeRear)
|
.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(
|
private fun sheetFrom(json: JSONObject) = SetupSheet(
|
||||||
recordedAt = json.optLong("recordedAt"),
|
recordedAt = json.optLong("recordedAt"),
|
||||||
@@ -218,6 +225,13 @@ private fun sheetFrom(json: JSONObject) = SetupSheet(
|
|||||||
blockRr = json.optionalDouble("blockRr"),
|
blockRr = json.optionalDouble("blockRr"),
|
||||||
toeFront = json.optionalDouble("toeFront"),
|
toeFront = json.optionalDouble("toeFront"),
|
||||||
toeRear = json.optionalDouble("toeRear"),
|
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"),
|
syncId = json.optString("syncId"),
|
||||||
updatedAt = json.optLong("updatedAt"),
|
updatedAt = json.optLong("updatedAt"),
|
||||||
deleted = json.optBoolean("deleted"),
|
deleted = json.optBoolean("deleted"),
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
package com.racingapp.pitlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.work.CoroutineWorker
|
|
||||||
import androidx.work.WorkerParameters
|
|
||||||
import com.racingapp.pitlog.RacingApp
|
|
||||||
|
|
||||||
class SyncWorker(
|
|
||||||
context: Context,
|
|
||||||
params: WorkerParameters,
|
|
||||||
) : CoroutineWorker(context, params) {
|
|
||||||
override suspend fun doWork(): Result {
|
|
||||||
val app = applicationContext as? RacingApp ?: return Result.success()
|
|
||||||
app.repository.syncAutomatic()
|
|
||||||
return Result.success()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,6 +21,27 @@ fun setupLines(sheet: SetupSheet): List<String> = buildList {
|
|||||||
sheet.casterRf?.let { add("RF caster ${formatDegrees(it)}") }
|
sheet.casterRf?.let { add("RF caster ${formatDegrees(it)}") }
|
||||||
sheet.toeFront?.let { add("Front toe ${inches(it)} in out") }
|
sheet.toeFront?.let { add("Front toe ${inches(it)} in out") }
|
||||||
sheet.toeRear?.let { add("Rear toe ${inches(it)} in") }
|
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")
|
||||||
|
weightSplit(sheet.weightLf, sheet.weightRf, sheet.weightLr, sheet.weightRr)?.let { add("From the weights $it") }
|
||||||
|
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? {
|
||||||
|
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)}%"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setupDiffs(before: SetupSheet, after: SetupSheet): List<String> = buildList {
|
fun setupDiffs(before: SetupSheet, after: SetupSheet): List<String> = buildList {
|
||||||
@@ -38,10 +59,20 @@ fun setupDiffs(before: SetupSheet, after: SetupSheet): List<String> = buildList
|
|||||||
changed(before.casterRf, after.casterRf, ::formatDegrees)?.let { add("RF caster $it") }
|
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.toeFront, after.toeFront) { "${inches(it)} in" }?.let { add("Front toe $it") }
|
||||||
changed(before.toeRear, after.toeRear) { "${inches(it)} in" }?.let { add("Rear 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") }
|
||||||
|
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 percent(value: Double): String = String.format(Locale.US, "%.1f%%", value)
|
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 =
|
private fun inches(value: Double): String =
|
||||||
String.format(Locale.US, "%.3f", value).trimEnd('0').trimEnd('.')
|
String.format(Locale.US, "%.3f", value).trimEnd('0').trimEnd('.')
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
package com.racingapp.pitlog.ui
|
package com.racingapp.pitlog.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.racingapp.pitlog.ui.guide.SetupGuideScreen
|
import com.racingapp.pitlog.ui.guide.SetupGuideScreen
|
||||||
|
import com.racingapp.pitlog.ui.home.CarsScreen
|
||||||
import com.racingapp.pitlog.ui.home.HomeScreen
|
import com.racingapp.pitlog.ui.home.HomeScreen
|
||||||
import com.racingapp.pitlog.ui.night.NightDetailScreen
|
import com.racingapp.pitlog.ui.night.NightDetailScreen
|
||||||
import com.racingapp.pitlog.ui.night.NightEditorScreen
|
import com.racingapp.pitlog.ui.night.NightEditorScreen
|
||||||
@@ -22,20 +35,42 @@ import com.racingapp.pitlog.ui.setup.CarSetupScreen
|
|||||||
fun PitNav() {
|
fun PitNav() {
|
||||||
val nav = rememberNavController()
|
val nav = rememberNavController()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
NavHost(navController = nav, startDestination = "home") {
|
val refreshing by LocalRepository.current.syncRefreshing().collectAsStateWithLifecycle(false)
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
NavHost(navController = nav, startDestination = "cars") {
|
||||||
|
composable("cars") {
|
||||||
|
CarsScreen(
|
||||||
|
onOpenCar = {
|
||||||
|
nav.navigate("home") {
|
||||||
|
popUpTo("cars")
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onAddCar = { nav.navigate("settings/car") },
|
||||||
|
onSettings = { nav.navigate("settings") },
|
||||||
|
)
|
||||||
|
}
|
||||||
composable("home") {
|
composable("home") {
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
|
onBack = { nav.popBackStack() },
|
||||||
onNewNight = { nav.navigate("editor/night") },
|
onNewNight = { nav.navigate("editor/night") },
|
||||||
onOpenNight = { id -> nav.navigate("night/$id") },
|
onOpenNight = { id -> nav.navigate("night/$id") },
|
||||||
onLogRun = { id -> nav.navigate("editor/run/$id") },
|
onLogRun = { id -> nav.navigate("editor/run/$id") },
|
||||||
onSettings = { nav.navigate("settings") },
|
onSettings = { nav.navigate("settings/car") },
|
||||||
onGuide = { nav.navigate("guide") },
|
onGuide = { nav.navigate("guide") },
|
||||||
onCarSetup = { nav.navigate("car-setup") },
|
onCarSetup = { nav.navigate("car-setup") },
|
||||||
onExport = { name, csv -> shareCsv(context, name, csv) },
|
onExport = { name, csv -> shareCsv(context, name, csv) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable("settings") {
|
composable("settings") {
|
||||||
SettingsScreen(onBack = { nav.popBackStack() })
|
SettingsScreen(showCar = false, onBack = { nav.popBackStack() })
|
||||||
|
}
|
||||||
|
composable("settings/car") {
|
||||||
|
SettingsScreen(
|
||||||
|
showCar = true,
|
||||||
|
onBack = { nav.popBackStack() },
|
||||||
|
onCarRemoved = { nav.popBackStack("cars", inclusive = false) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable("guide") {
|
composable("guide") {
|
||||||
SetupGuideScreen(onBack = { nav.popBackStack() })
|
SetupGuideScreen(onBack = { nav.popBackStack() })
|
||||||
@@ -150,4 +185,16 @@ fun PitNav() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (refreshing) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.semantics { contentDescription = "Syncing" },
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
trackColor = MaterialTheme.colorScheme.background,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,21 +2,38 @@ package com.racingapp.pitlog.ui.guide
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
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.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 androidx.compose.ui.unit.dp
|
||||||
import com.racingapp.pitlog.ui.PitScroll
|
|
||||||
import com.racingapp.pitlog.ui.SectionLabel
|
import com.racingapp.pitlog.ui.SectionLabel
|
||||||
import com.racingapp.pitlog.ui.theme.pitTopBarColors
|
import com.racingapp.pitlog.ui.theme.pitTopBarColors
|
||||||
|
|
||||||
@@ -26,6 +43,112 @@ private data class Move(val change: String, val why: String)
|
|||||||
|
|
||||||
private data class HandlingCase(val title: String, val moves: List<Move>)
|
private data class HandlingCase(val title: String, val moves: List<Move>)
|
||||||
|
|
||||||
|
private val airEffects = listOf(
|
||||||
|
Tip("More air", "More spring rate and a smaller footprint, so that corner has less rubber on the ground. On a bias-ply tire the circumference grows."),
|
||||||
|
Tip("Less air", "Less spring rate and a bigger footprint, so that corner has more grip. Too little air cups the tread, side bite falls off, and the tire can slip on the wheel."),
|
||||||
|
Tip("Side bite", "Too much air crowns the tread. Too little cups it. The useful pressure holds the tread flat between the sidewalls. Camber still has to keep the tire flat."),
|
||||||
|
Tip("Forward bite", "Less air lets the sidewall give when the throttle comes in, so the tire can take the first hit. Keep the tread flat while you do it. Air low enough to cup the tread spends the side bite to get that launch."),
|
||||||
|
Tip("Right front", "More air grows the tire, adds rate, shrinks the footprint, adds front stagger, and adds cross. Less air reverses each of those."),
|
||||||
|
Tip("Left front", "More air grows the tire, adds rate, shrinks the footprint, cuts front stagger, and takes cross out. Less air reverses each of those."),
|
||||||
|
Tip("Right rear", "More air grows the tire, adds rate, shrinks the footprint, adds rear stagger, and takes cross out. Less air reverses each of those."),
|
||||||
|
Tip("Left rear", "More air grows the tire, adds rate, shrinks the footprint, cuts rear stagger, and adds cross. Less air reverses each of those."),
|
||||||
|
Tip("Stagger and cross", "More rear stagger takes cross out and frees the car. More front stagger adds cross. A bigger front-stagger change than the rear-stagger change loads the bar and tightens the exit. On a radial, the stagger part stays put. Rate, footprint, and cross still move."),
|
||||||
|
Tip("Bleed a hot tire", "The air you let out is hot and already expanded. A tire at 30 psi that you bleed to 25 can be under 20 once it cools. Read the cold pressure before the car goes back out."),
|
||||||
|
Tip("Set them together", "About 0.8 psi moves for every 10°F the tire warms or cools. Rears sitting in 85° sun and fronts sitting in a 65° trailer, all filled to 30 psi, are not equal. When the fronts reach 85° they are near 32. Set all four at the same temperature, out of the sun together."),
|
||||||
|
Tip("Let them come up", "The hot pressure is the one the tire runs. A climb on the track is usually extra. Starting low so the pressure comes up flexes the sidewall hard on the first laps."),
|
||||||
|
Tip("What builds", "Dry nitrogen and dried air build at the same rate. Nitrogen leaks slower, and the bottle is dry. Moisture is what adds build. Under about 150°F the extra is modest. Above 212°F it climbs fast and can add 20–30% more pressure than a dry tire."),
|
||||||
|
Tip("Where the water is", "A new tire already holds moisture in the rubber. The compressor and the soap used to mount it add more. One nitrogen fill leaves the wet air inside. Fill with nitrogen or dried air and drain it, about 5 or 6 times, down to empty, until a moisture reading on the stem is low. Leave the tire round. A vacuum that collapses the carcass can hurt the cord."),
|
||||||
|
Tip("Purge them hot", "Heat pulls moisture out of the rubber after a cold purge. A tire that read dry can be wet again once it is hot, which is why a nitrogen fill sometimes changes nothing. After the first practice, put the car on stands, drain, and refill 3 or 4 times while the tires are hot. Do it again after the next practice if the build is still high. A set that lives more than one week needs less of this the next time out. A shiny coating inside the tire holds some of that moisture in. It still gets a purge, and the moisture comes back less."),
|
||||||
|
Tip("New tires", "Extra air does not leave a bias-ply tire bigger. It grows, then it goes back to its old size. Air high enough to break the bands damages the tire. Fill a new tire to the maker's maximum the same way every time, then set the race pressure."),
|
||||||
|
Tip("Gauge", "Use the lowest gauge range that still covers the tire. A 15 or 30 psi gauge reads these tires more clearly than a 60. Keep that gauge on the race car, and use another one on the trailer. A drop can knock it off, so check it against a gauge you trust if it hits the ground. A gauge that reads tenths is enough to see these changes."),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val airHandling = listOf(
|
||||||
|
HandlingCase(
|
||||||
|
"Middle, tight",
|
||||||
|
listOf(
|
||||||
|
Move("More air in the right rear.", "More stagger, more rate, a smaller footprint, and less cross."),
|
||||||
|
Move("Less air in the left rear.", "More stagger, less cross, less rate, and a bigger footprint."),
|
||||||
|
Move("Less air in the right front.", "Less rate, a bigger footprint, less cross, and less front stagger. This one frees the middle without adding rear stagger."),
|
||||||
|
Move("More air in the left front.", "More rate, less cross, a smaller footprint, and less front stagger."),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
HandlingCase(
|
||||||
|
"Middle, loose",
|
||||||
|
listOf(
|
||||||
|
Move("More air in the left rear.", "Less stagger, more cross, more rate, and a smaller footprint."),
|
||||||
|
Move("Less air in the right rear.", "Less stagger, more cross, a bigger footprint, and less rate."),
|
||||||
|
Move("Less air in both rears.", "Less rear rate and a bigger footprint. Stay at or above the tire maker's minimum."),
|
||||||
|
Move("Less air in the left front.", "More cross, less rate, and a bigger footprint."),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
HandlingCase(
|
||||||
|
"Entry, tight",
|
||||||
|
listOf(
|
||||||
|
Move("Less air in the right front.", "Less rate, less cross, a bigger footprint, and less front stagger."),
|
||||||
|
Move("Less air in both fronts.", "Less front rate and a bigger footprint."),
|
||||||
|
Move("More air in the right rear.", "More rate, more stagger, less cross, and a smaller footprint."),
|
||||||
|
Move("Less air in the left rear.", "Less rate, less cross, more stagger, and a bigger footprint."),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
HandlingCase(
|
||||||
|
"Entry, loose",
|
||||||
|
listOf(
|
||||||
|
Move("More air in the right front.", "More rate, more cross, a smaller footprint, and more front stagger."),
|
||||||
|
Move("More air in both fronts.", "More front rate and a smaller footprint."),
|
||||||
|
Move("Less air in the right rear.", "Less rate, more cross, a bigger footprint, and less stagger."),
|
||||||
|
Move("More air in the left rear.", "Less stagger, more cross, more rate, and a smaller footprint."),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
HandlingCase(
|
||||||
|
"Exit, tight",
|
||||||
|
listOf(
|
||||||
|
Move("More air in the right rear.", "More rate, more stagger, less cross, and a smaller footprint. This is the air version of adding the stagger the radius still wants."),
|
||||||
|
Move("Less air in the left rear.", "Less rate, less cross, more stagger, and a bigger footprint."),
|
||||||
|
Move("Less air in the right front.", "Less rate, less cross, a bigger footprint, and less front stagger."),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
HandlingCase(
|
||||||
|
"Exit, loose",
|
||||||
|
listOf(
|
||||||
|
Move("Less air in the right rear.", "Less stagger, more cross, a bigger footprint, and less rate."),
|
||||||
|
Move("Less air in both rears.", "Less rear rate and a bigger footprint. Stay at or above the tire maker's minimum."),
|
||||||
|
Move("More air in the left rear.", "Less stagger, more cross, more rate, and a smaller footprint."),
|
||||||
|
Move("More air in the right front.", "More rate, more cross, a smaller footprint, and more front stagger."),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val scales = listOf(
|
||||||
|
Tip("Race ready", "Fuel, radiator, gear oil, and the transmission full. Oil full, and the motor has been run so it is not all sitting in the pan. Steering full if it is power. Lead bolted in. Stagger, air, and camber set. Rear end square. The next thing the car should do is go around the track."),
|
||||||
|
Tip("The pads", "Lay them flat, plug each cable into its own corner, and let them warm up two or three minutes. Zero them empty. A crew member should read within a pound or two on every pad, and the pads should return to zero. Then jack the car and slide the pads under."),
|
||||||
|
Tip("Settle it", "Bounce the rear, bounce the front, then shake the cage bar over the driver, and let go in the middle. Do that same routine every time, and again after any change to the cross, the lead, the stagger, or the air. Roll-off trays are optional. Repeat the bounces either way."),
|
||||||
|
Tip("The bar", "Disconnect the sway bar while you weigh. Hook it the same way afterward and write the cross with the bar on. A late-model bar is sometimes preloaded about half a percent. On this car the all-thread goes back to the same setting, and the note says whether the bar was on."),
|
||||||
|
Tip("At the track", "The pads have to sit in one plane. String the right front to the left rear, and the left front to the right rear, so the strings cross. If the top string holds the bottom one down, shim the pads on that top string. If the top string clears the bottom one, shim the other two. Chalk around the pads. A track reading that is 1% more cross than the shop, on the same setup, is the ground, and that 1% is the correction to remember."),
|
||||||
|
Tip("A spring change", "Set the air, weigh the car, and measure from the frame to the ground. Write both down. After the spring is in, put that height back and put the cross back to the number you wrote. On a leaf car that is the pocket adjuster, the shackles, or the blocks, whichever the book allows. String the rear after a block change. A coil-over car can estimate the preload from free height. A 400 lb spring at 12 1/8 inches, replaced by a 375 at 12 inches, wants about 1/8 inch more preload to hold the ride height, and the scales finish it."),
|
||||||
|
Tip("Back at the shop", "Fill the fluids when the car comes home, set the air, and weigh it again. That is the setup you take back to the same track."),
|
||||||
|
Tip("Stagger on a locked rear", "On a spool or a welded rear, a lot of stagger can make the axles climb the ring gear and the cross jumps, the same skip you feel pushing the car through a tight turn in the pits. Pull one axle out past the spline before you weigh. An open rear does this less often. Wenatchee allows an open rear. Stateline is spool or welded."),
|
||||||
|
Tip("When the cross moves", "If the total weight is the same, the scales are fine and the car moved. Uneven caster shims bind an arm. Dry or damaged ball joints do the same, so grease them. A camber change tilts the tire and moves the cross a little. Air has to be set first, and a tire that is leaking moves the number every time you look. More rear stagger takes cross out. Less rear stagger adds it. More front stagger adds cross. Less front stagger takes it out. If the total weight changes by more than a few pounds, the pad top is not free to move, or the scales are. A tray rubbing the pad can be 30 lb."),
|
||||||
|
Tip("Numbers that bounce", "A smashed cable, water in a connector, or a dead battery. Dry a wet plug, and try the scales on battery power before you blame the car. A blank screen in the sun is the display. Shade it and the numbers come back."),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val shop = listOf(
|
||||||
|
Tip("Square the housing", "Clamp a straight edge under the frame, square it to the straighter rail, then move it until a tape to both front inner pivots matches. Mark the frame so you can put it back at the track. Hang a nut on a string from each end of the housing and measure back to that edge. The side-to-side location and the square fight each other, so check both after every move. Write a tape from the right-rear rotor to a frame point. A leaf car is located by the springs and the shackles."),
|
||||||
|
Tip("Read the toe", "Center the box and string the right front in line with the right rear, so Ackermann is out of the static reading. Roll the car forward and leave it there. After each change, roll it back and then forward again. Chalk the high spots on the sidewall and read the even part. Air the tire enough that the sidewall is not bulged where you measure. Toe plates and a scribed line: a smaller number at the back is toe out. A toe bar reads the opposite. A larger number at the back is toe out. Split the change across both tie rods and tighten the jam nuts."),
|
||||||
|
Tip("Bump", "The wheels steer themselves when the tie rod does not travel the same arc as the arms. Measure at ride height with the bar disconnected, the steering locked and centered, and caster, camber, and toe already set. Jack each side about 2–3 inches up and down, then again with the wheel turned about as far as the driver does in the corner. The front of the plate moving toward the engine is bump in. Away from the engine is bump out. Aim for about 0.005–0.015 inch of bump out, and under 0.010 when the mounts allow it. Up to about 0.030 is still usable. Past about 0.050 the car slows down. If it will not reach zero, let the right front bump out on the way up and the left front bump in on the way up, so the driver adds a little more left. A little bump out can settle the entry. Using bump to fake Ackermann upsets the tire over every bump."),
|
||||||
|
Tip("Bump corrections", "A tie rod that is too short toes in on the way up and on the way down. Too long toes out both ways. Toes out going up and in coming down: less shim at the outer tie rod, or lower the inner. The opposite: more outer shim, or raise the inner. If the stock mounts and the book give you no shim, the tie-rod length is the piece you can see. Recheck after you move an arm or a tie rod."),
|
||||||
|
Tip("Camber", "Negative on the right, positive on the left. The bigger footprint can be the one that loads the inside of the right tire a little more, which is the 5–10° spread in the heat section. Camber set to an extreme wears the tire and can hurt the brakes. Check the toe after you move it. The book still stops you at 2 inches on the right and 1 inch on the left."),
|
||||||
|
Tip("Moment center", "Two cars that look the same can handle differently because the front moment center sits in a different place. On a stock spindle and stock arms that place is mostly frozen. Move only what the book allows."),
|
||||||
|
Tip("Getting off", "A pair of tires on the same axle makes the most traction when the two are loaded close to even. The corners that are hard to get off are the flatter ones with less grip. When the rear will spin if the throttle goes wide open, the driver feeds it just enough to accelerate."),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val tireLife = listOf(
|
||||||
|
Tip("Heat cycles", "Each time a tire gets hot it gives up chemicals and some grip, even when the tread is still there. Count the cycles on that set. A tire that looks new can be done."),
|
||||||
|
Tip("Temperature falloff", "A new tire makes more heat than a used one. When the temps after a run fall off from what that set made on its first runs, the grip is leaving. The tire set name on the run is how those nights line up."),
|
||||||
|
Tip("Tread depth", "Grip on many tires falls off at a depth you can learn. When the set quits, measure the depth and write it in the notes. The left front can keep working deeper than the left rear, because the left rear spins under power."),
|
||||||
|
Tip("Durometer", "Tires harden as they age. Press the durometer at the same tire temperature every time. A hot tire reads softer, and a tire in the sun reads softer than one in the shade. Check the temp with the probe so the hardness numbers compare."),
|
||||||
|
)
|
||||||
|
|
||||||
private val heatTips = listOf(
|
private val heatTips = listOf(
|
||||||
Tip("Middle hottest", "The crown is carrying the load. Pressure is high. Lower the cold pressure so the next hot pressure lands on the tire maker's target."),
|
Tip("Middle hottest", "The crown is carrying the load. Pressure is high. Lower the cold pressure so the next hot pressure lands on the tire maker's target."),
|
||||||
Tip("Both edges hottest", "The tire is folding. Pressure is low. Add air, and stay inside the tire maker's minimum."),
|
Tip("Both edges hottest", "The tire is folding. Pressure is low. Add air, and stay inside the tire maker's minimum."),
|
||||||
@@ -36,10 +159,10 @@ private val heatTips = listOf(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private val order = listOf(
|
private val order = listOf(
|
||||||
Tip("1. Square it", "String the car. Stock mounts can leave the wheelbase off by as much as 3/8 inch, and a quarter inch is enough to feel. Rear end square to the centerline. Right-side contact patches in a line with the right front pointed straight. Recheck after any wheel contact. U-bolts and blocks shift."),
|
Tip("1. Square it", "String the car at the bottom of the frame rail, so the right-rear contact patch lines up with the right front. Square the housing to the front pivots. Frame rails bow. Stock mounts can leave the wheelbase off by as much as 3/8 inch, and a quarter inch is enough to feel. Recheck after any wheel contact. U-bolts and blocks shift."),
|
||||||
Tip("2. Free the suspension", "Upper and lower arms have to move with no bind. The sway bar has to move freely. Space the mounts off the frame, or use a bushing about 1/8 inch larger than the bar, so the bar is not acting as a spring."),
|
Tip("2. Free the suspension", "Upper and lower arms have to move with no bind. The sway bar has to move freely. Space the mounts off the frame, or use a bushing about 1/8 inch larger than the bar, so the bar is not acting as a spring."),
|
||||||
Tip("3. Pinion and rear steer", "A leaf spring wraps under power, so the car wants more pinion angle than a coil-spring car. Rear steer to the right is not useful on asphalt. A slight steer to the left only while accelerating can add bite."),
|
Tip("3. Pinion and rear steer", "A leaf spring wraps under power, so the car wants more pinion angle than a coil-spring car. Rear steer to the right is not useful on asphalt. A slight steer to the left only while accelerating can add bite."),
|
||||||
Tip("4. Steering", "A stock box is slow and the driver ends up shuffling hands. A quicker box is a consistency item only if the rule book allows it. Keep bump steer small. Do not let the front tires toe against each other in a left turn."),
|
Tip("4. Steering", "A stock box is slow and the driver ends up shuffling hands. A quicker box is a consistency item only if the rule book allows it. Keep bump steer small, and keep it bump out if it will not reach zero. Do not let the front tires toe against each other in a left turn."),
|
||||||
Tip("5. Camber from the tires", "Set static camber so the heat spread above is in range. These books stop the right side at 2 inches and the left at 1 inch, measured with a square at the top of the wheel. A deep street-stock right front, about −6° to −8°, only counts if it still fits that measurement."),
|
Tip("5. Camber from the tires", "Set static camber so the heat spread above is in range. These books stop the right side at 2 inches and the left at 1 inch, measured with a square at the top of the wheel. A deep street-stock right front, about −6° to −8°, only counts if it still fits that measurement."),
|
||||||
Tip("6. Springs and cross", "A leaf-spring car starts around 52–55% crossweight. Keep the front spring split under about 100 lb. The rear roll center on a stock leaf car barely moves, so the front has to be kept from out-rolling it."),
|
Tip("6. Springs and cross", "A leaf-spring car starts around 52–55% crossweight. Keep the front spring split under about 100 lb. The rear roll center on a stock leaf car barely moves, so the front has to be kept from out-rolling it."),
|
||||||
Tip("7. Stagger", "Right rear circumference minus left rear, at race pressure. Size it so both rears turn the same number of times through the corner. Extra stagger gets loose on power. Too little drives the front toward the wall."),
|
Tip("7. Stagger", "Right rear circumference minus left rear, at race pressure. Size it so both rears turn the same number of times through the corner. Extra stagger gets loose on power. Too little drives the front toward the wall."),
|
||||||
@@ -102,9 +225,131 @@ 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 bar."),
|
||||||
|
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", "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."),
|
||||||
|
Tip("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."),
|
||||||
|
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. 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."),
|
||||||
|
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(
|
||||||
|
"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(
|
||||||
|
"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",
|
||||||
|
"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.",
|
||||||
|
blocksOf(airHandling),
|
||||||
|
),
|
||||||
|
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("Where to start", blocks = notesOf(starts)),
|
||||||
|
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 in the notes and the cross on the setup. 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 fun holds(words: List<String>, text: String) = words.all { text.contains(it, ignoreCase = true) }
|
||||||
|
|
||||||
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SetupGuideScreen(onBack: () -> Unit) {
|
fun SetupGuideScreen(onBack: () -> Unit) {
|
||||||
|
var query by remember { mutableStateOf("") }
|
||||||
|
val words = query.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||||
|
val shown = if (words.isEmpty()) book else book.mapNotNull { it.narrowed(words) }
|
||||||
|
val showOpening = words.isEmpty() || holds(words, OPENING)
|
||||||
|
val scroll = rememberScrollState()
|
||||||
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
|
LaunchedEffect(query) { scroll.scrollTo(0) }
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -118,77 +363,87 @@ fun SetupGuideScreen(onBack: () -> Unit) {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
PitScroll(padding) {
|
Column(
|
||||||
Text(
|
Modifier
|
||||||
"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.",
|
.fillMaxSize()
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
.padding(padding),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = { query = it },
|
||||||
|
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
|
||||||
|
.widthIn(max = 640.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
)
|
)
|
||||||
SectionLabel("Heat")
|
Column(
|
||||||
Text(
|
Modifier
|
||||||
"Probe inside, middle, outside right after a full-speed run, before the car sits. Same tires, same order, every time. A needle in the tread beats a surface gun. A spread across one tire past about 10% is the one to fix. Temps on a car that is way off are a weak signal until it is at least drivable.",
|
.weight(1f)
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
.fillMaxWidth()
|
||||||
)
|
.verticalScroll(scroll),
|
||||||
Column(Modifier.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
heatTips.forEach { tip ->
|
) {
|
||||||
TipLine(tip.see, tip.doThis)
|
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("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),
|
|
||||||
)
|
|
||||||
item.moves.forEach { move ->
|
|
||||||
TipLine(move.change, move.why)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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", "Keep the split under about 100 lb. If the front out-rolls the rear, stiffen the front springs and use less bar.")
|
|
||||||
TipLine("Caster", "A short track often wants about 2–4° of split. Left front around 1–2°, right front around 3–5°. More banking wants less caster. A tighter radius wants more split. At mid-turn the wheel should pull slightly, not push back.")
|
|
||||||
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, measured at the cold pressure you will race. More air on the right, or less on the left, grows the stagger before you change tires. More banking wants a little less stagger than the same corner on flat ground.")
|
|
||||||
TipLine("Toe", "1/16 to 1/8 inch out in front on asphalt. A tighter track wants the larger end. Rear wheels straight, no toe. Keep added toe small when the wheel is turned. A quarter-mile only needs about 1/16 inch of added toe. A half-mile needs about 1/64 inch. One degree of Ackermann is about 1/2 inch of toe on an 85-inch tire. Stock boxes add toe in a left turn.")
|
|
||||||
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.")
|
|
||||||
TipLine("Brakes", "About 60–65% on the front. 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", "As close to the rule book's left-side maximum as you can, without making the car heavy. One low clump, left of center, near the middle of the car. Splitting the lead front to rear, or left rail to right rail, transfers more in the corner and works the tires harder. Battery 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. 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. Two tires with the same size stamp can still differ by an inch or more around.")
|
|
||||||
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 trend. Stockers should not copy it. A bigger bar on a leaf-spring Camaro faded after a few laps.")
|
|
||||||
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.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package com.racingapp.pitlog.ui.home
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.unit.dp
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.racingapp.pitlog.data.Car
|
||||||
|
import com.racingapp.pitlog.data.Repository
|
||||||
|
import com.racingapp.pitlog.ui.pitViewModel
|
||||||
|
import com.racingapp.pitlog.ui.theme.pitTopBarColors
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class CarsViewModel(private val repository: Repository) : ViewModel() {
|
||||||
|
val cars: StateFlow<List<Car>?> = repository.observeCars()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
|
fun open(id: Long, onOpen: () -> Unit) {
|
||||||
|
repository.selectCar(id)
|
||||||
|
onOpen()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addCar(onDone: () -> Unit) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.addCar()
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(id: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.removeCar(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CarsScreen(
|
||||||
|
onOpenCar: () -> Unit,
|
||||||
|
onAddCar: () -> Unit,
|
||||||
|
onSettings: () -> Unit,
|
||||||
|
) {
|
||||||
|
val viewModel = pitViewModel { CarsViewModel(it) }
|
||||||
|
val cars by viewModel.cars.collectAsStateWithLifecycle()
|
||||||
|
var pendingDelete by remember { mutableStateOf<Car?>(null) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Pit Log") },
|
||||||
|
colors = pitTopBarColors(),
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onSettings) {
|
||||||
|
Icon(Icons.Filled.Settings, contentDescription = "Settings")
|
||||||
|
}
|
||||||
|
IconButton(onClick = { viewModel.addCar(onAddCar) }) {
|
||||||
|
Icon(Icons.Filled.Add, contentDescription = "Add a car")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
if (cars == null) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else if (cars!!.isEmpty()) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text("No car yet", style = MaterialTheme.typography.titleLarge)
|
||||||
|
Text(
|
||||||
|
"Add a car to start a log. Each car gets its own 8-digit token.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.addCar(onAddCar) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 16.dp),
|
||||||
|
) {
|
||||||
|
Text("Add a car")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = padding,
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"Cars",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(cars.orEmpty(), key = { it.id }) { car ->
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clickable { viewModel.open(car.id, onOpenCar) }
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(car.carName, style = MaterialTheme.typography.titleLarge)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { pendingDelete = car }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Delete,
|
||||||
|
contentDescription = "Remove ${car.carName}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingDelete?.let { car ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { pendingDelete = null },
|
||||||
|
title = { Text("Remove this car?") },
|
||||||
|
text = {
|
||||||
|
Text("${car.carName} comes off this phone. Join it again with the token to bring it back.")
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
pendingDelete = null
|
||||||
|
viewModel.remove(car.id)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Remove", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { pendingDelete = null }) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import androidx.compose.foundation.Canvas
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -13,6 +12,7 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.MenuBook
|
import androidx.compose.material.icons.automirrored.filled.MenuBook
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
@@ -20,8 +20,6 @@ import androidx.compose.material.icons.filled.Share
|
|||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -31,14 +29,11 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
@@ -49,9 +44,6 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.racingapp.pitlog.data.Car
|
import com.racingapp.pitlog.data.Car
|
||||||
import com.racingapp.pitlog.data.DEFAULT_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.NightWithRuns
|
||||||
import com.racingapp.pitlog.data.Repository
|
import com.racingapp.pitlog.data.Repository
|
||||||
import com.racingapp.pitlog.model.Corner
|
import com.racingapp.pitlog.model.Corner
|
||||||
@@ -94,19 +86,12 @@ class HomeViewModel(private val repository: Repository) : ViewModel() {
|
|||||||
)
|
)
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeState())
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeState())
|
||||||
|
|
||||||
fun selectCar(id: Long) = repository.selectCar(id)
|
|
||||||
|
|
||||||
fun addCar(onDone: () -> Unit) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
repository.addCar()
|
|
||||||
onDone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
onNewNight: () -> Unit,
|
onNewNight: () -> Unit,
|
||||||
onOpenNight: (Long) -> Unit,
|
onOpenNight: (Long) -> Unit,
|
||||||
onLogRun: (Long) -> Unit,
|
onLogRun: (Long) -> Unit,
|
||||||
@@ -126,8 +111,13 @@ fun HomeScreen(
|
|||||||
snackbarHost = { SnackbarHost(snackbar) },
|
snackbarHost = { SnackbarHost(snackbar) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Pit Log") },
|
title = { Text(state.profile?.carName?.ifBlank { "Pit Log" } ?: "Pit Log") },
|
||||||
colors = pitTopBarColors(),
|
colors = pitTopBarColors(),
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Cars")
|
||||||
|
}
|
||||||
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = onGuide) {
|
IconButton(onClick = onGuide) {
|
||||||
Icon(Icons.AutoMirrored.Filled.MenuBook, contentDescription = "Setup")
|
Icon(Icons.AutoMirrored.Filled.MenuBook, contentDescription = "Setup")
|
||||||
@@ -168,14 +158,10 @@ fun HomeScreen(
|
|||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
HomeHeader(
|
HomeHeader(
|
||||||
profile = state.profile,
|
|
||||||
cars = state.cars,
|
|
||||||
tonight = tonight,
|
tonight = tonight,
|
||||||
onLogRun = onLogRun,
|
onLogRun = onLogRun,
|
||||||
onNewNight = onNewNight,
|
onNewNight = onNewNight,
|
||||||
onCarSetup = onCarSetup,
|
onCarSetup = onCarSetup,
|
||||||
onSelectCar = viewModel::selectCar,
|
|
||||||
onAddCar = { viewModel.addCar(onSettings) },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (state.nights.isEmpty()) {
|
if (state.nights.isEmpty()) {
|
||||||
@@ -200,59 +186,13 @@ fun HomeScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HomeHeader(
|
private fun HomeHeader(
|
||||||
profile: Car?,
|
|
||||||
cars: List<Car>,
|
|
||||||
tonight: NightWithRuns?,
|
tonight: NightWithRuns?,
|
||||||
onLogRun: (Long) -> Unit,
|
onLogRun: (Long) -> Unit,
|
||||||
onNewNight: () -> Unit,
|
onNewNight: () -> Unit,
|
||||||
onCarSetup: () -> Unit,
|
onCarSetup: () -> Unit,
|
||||||
onSelectCar: (Long) -> Unit,
|
|
||||||
onAddCar: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
val car = profile?.carName?.ifBlank { DEFAULT_CAR } ?: DEFAULT_CAR
|
|
||||||
val raceClass = profile?.raceClass?.ifBlank { DEFAULT_CLASS } ?: DEFAULT_CLASS
|
|
||||||
val discipline = profile?.discipline?.ifBlank { DEFAULT_DISCIPLINE } ?: DEFAULT_DISCIPLINE
|
|
||||||
var menu by remember { mutableStateOf(false) }
|
|
||||||
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
CarMark()
|
||||||
CarMark()
|
|
||||||
Column(Modifier.padding(start = 12.dp)) {
|
|
||||||
Box {
|
|
||||||
TextButton(
|
|
||||||
onClick = { menu = true },
|
|
||||||
contentPadding = PaddingValues(0.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
car,
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
|
||||||
cars.forEach { item ->
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(item.carName) },
|
|
||||||
onClick = {
|
|
||||||
menu = false
|
|
||||||
onSelectCar(item.id)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Add a car") },
|
|
||||||
onClick = {
|
|
||||||
menu = false
|
|
||||||
onAddCar()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
"$raceClass · $discipline",
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (tonight != null) {
|
if (tonight != null) {
|
||||||
Button(
|
Button(
|
||||||
onClick = { onLogRun(tonight.night.id) },
|
onClick = { onLogRun(tonight.night.id) },
|
||||||
@@ -366,3 +306,4 @@ private fun CarMark() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,12 +286,9 @@ private fun NightHeader(night: RaceNight) {
|
|||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
Text(formatLongDate(night.dateEpochDay), style = MaterialTheme.typography.titleMedium)
|
Text(formatLongDate(night.dateEpochDay), style = MaterialTheme.typography.titleMedium)
|
||||||
Text(
|
if (night.carName.isNotBlank()) {
|
||||||
listOf(night.carName, night.raceClass, night.discipline)
|
Text(night.carName, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
.filter { it.isNotBlank() }
|
}
|
||||||
.joinToString(" · "),
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
night.airTempF?.let { Text("Air $it°F") }
|
night.airTempF?.let { Text("Air $it°F") }
|
||||||
if (night.notes.isNotBlank()) {
|
if (night.notes.isNotBlank()) {
|
||||||
Text(night.notes, modifier = Modifier.padding(top = 4.dp))
|
Text(night.notes, modifier = Modifier.padding(top = 4.dp))
|
||||||
|
|||||||
@@ -356,11 +356,6 @@ fun NightEditorScreen(
|
|||||||
value = form.carName,
|
value = form.carName,
|
||||||
onValue = { value -> viewModel.update { it.copy(carName = value.take(80)) } },
|
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)) } },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import android.content.Context
|
||||||
import com.racingapp.pitlog.data.Car
|
import com.racingapp.pitlog.data.Car
|
||||||
|
import com.racingapp.pitlog.data.checkForAppUpdate
|
||||||
import com.racingapp.pitlog.data.DEFAULT_CAR
|
import com.racingapp.pitlog.data.DEFAULT_CAR
|
||||||
import com.racingapp.pitlog.data.DEFAULT_CLASS
|
import com.racingapp.pitlog.data.DEFAULT_CLASS
|
||||||
import com.racingapp.pitlog.data.DEFAULT_DISCIPLINE
|
import com.racingapp.pitlog.data.DEFAULT_DISCIPLINE
|
||||||
@@ -45,13 +47,15 @@ import com.racingapp.pitlog.ui.shareText
|
|||||||
import com.racingapp.pitlog.ui.theme.pitTopBarColors
|
import com.racingapp.pitlog.ui.theme.pitTopBarColors
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class SettingsViewModel(private val repository: Repository) : ViewModel() {
|
class SettingsViewModel(
|
||||||
|
private val repository: Repository,
|
||||||
|
private val showCar: Boolean,
|
||||||
|
) : ViewModel() {
|
||||||
var carId by mutableStateOf(0L)
|
var carId by mutableStateOf(0L)
|
||||||
var carName by mutableStateOf(DEFAULT_CAR)
|
var carName by mutableStateOf(DEFAULT_CAR)
|
||||||
var raceClass by mutableStateOf(DEFAULT_CLASS)
|
var raceClass by mutableStateOf(DEFAULT_CLASS)
|
||||||
var discipline by mutableStateOf(DEFAULT_DISCIPLINE)
|
var discipline by mutableStateOf(DEFAULT_DISCIPLINE)
|
||||||
var token by mutableStateOf("")
|
var token by mutableStateOf("")
|
||||||
var cars by mutableStateOf<List<Car>>(emptyList())
|
|
||||||
var joinToken by mutableStateOf("")
|
var joinToken by mutableStateOf("")
|
||||||
var serverAddress by mutableStateOf("")
|
var serverAddress by mutableStateOf("")
|
||||||
var automatic by mutableStateOf(true)
|
var automatic by mutableStateOf(true)
|
||||||
@@ -62,29 +66,23 @@ class SettingsViewModel(private val repository: Repository) : ViewModel() {
|
|||||||
private set
|
private set
|
||||||
var syncing by mutableStateOf(false)
|
var syncing by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
var updating by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var updateStatus by mutableStateOf("")
|
||||||
|
|
||||||
init {
|
init {
|
||||||
serverAddress = repository.syncAddress()
|
serverAddress = repository.syncAddress()
|
||||||
automatic = repository.syncAutomaticEnabled()
|
automatic = repository.syncAutomaticEnabled()
|
||||||
syncStatus = repository.syncStatus()
|
syncStatus = repository.syncStatus()
|
||||||
viewModelScope.launch {
|
if (showCar) {
|
||||||
repository.observeCars().collect { cars = it }
|
viewModelScope.launch {
|
||||||
}
|
repository.observeSelectedCar().collect { car ->
|
||||||
viewModelScope.launch {
|
if (car != null && car.id != carId) show(car)
|
||||||
repository.observeSelectedCar().collect { car ->
|
ready = true
|
||||||
if (car != null && car.id != carId) show(car)
|
}
|
||||||
ready = true
|
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
}
|
ready = true
|
||||||
|
|
||||||
fun select(id: Long) {
|
|
||||||
repository.selectCar(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addCar() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
show(repository.addCar())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,20 +99,26 @@ class SettingsViewModel(private val repository: Repository) : ViewModel() {
|
|||||||
joinToken = ""
|
joinToken = ""
|
||||||
repository.saveAddress(serverAddress)
|
repository.saveAddress(serverAddress)
|
||||||
syncStatus = repository.syncNow()
|
syncStatus = repository.syncNow()
|
||||||
repository.selectedCar()?.let { show(it) }
|
if (showCar) repository.selectedCar()?.let { show(it) }
|
||||||
syncing = false
|
syncing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove() {
|
fun remove(onGone: () -> Unit) {
|
||||||
val id = carId
|
val id = carId
|
||||||
if (cars.size < 2 || id == 0L) return
|
if (id == 0L) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.removeCar(id)
|
repository.removeCar(id)
|
||||||
carId = 0L
|
carId = 0L
|
||||||
|
onGone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateServer(value: String) {
|
||||||
|
serverAddress = value.take(200).trim()
|
||||||
|
repository.saveAddress(serverAddress)
|
||||||
|
}
|
||||||
|
|
||||||
fun save(onDone: () -> Unit) {
|
fun save(onDone: () -> Unit) {
|
||||||
if (saving || carId == 0L) return
|
if (saving || carId == 0L) return
|
||||||
saving = true
|
saving = true
|
||||||
@@ -148,6 +152,20 @@ class SettingsViewModel(private val repository: Repository) : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun checkForUpdate(context: Context) {
|
||||||
|
if (updating) return
|
||||||
|
updating = true
|
||||||
|
updateStatus = "Checking…"
|
||||||
|
viewModelScope.launch {
|
||||||
|
updateStatus = try {
|
||||||
|
checkForAppUpdate(context) { updateStatus = it }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
"Couldn't check for an update."
|
||||||
|
}
|
||||||
|
updating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun changeAutomatic(enabled: Boolean) {
|
fun changeAutomatic(enabled: Boolean) {
|
||||||
automatic = enabled
|
automatic = enabled
|
||||||
repository.saveAddress(serverAddress)
|
repository.saveAddress(serverAddress)
|
||||||
@@ -165,13 +183,21 @@ class SettingsViewModel(private val repository: Repository) : ViewModel() {
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(onBack: () -> Unit) {
|
fun SettingsScreen(showCar: Boolean, onBack: () -> Unit, onCarRemoved: () -> Unit = onBack) {
|
||||||
val viewModel = pitViewModel { SettingsViewModel(it) }
|
val viewModel = pitViewModel { SettingsViewModel(it, showCar) }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Car") },
|
title = {
|
||||||
|
Text(
|
||||||
|
when {
|
||||||
|
showCar && viewModel.carId != 0L -> viewModel.carName.ifBlank { "Car" }
|
||||||
|
showCar -> "Car"
|
||||||
|
else -> "Settings"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
colors = pitTopBarColors(),
|
colors = pitTopBarColors(),
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
@@ -181,7 +207,7 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
if (viewModel.ready) {
|
if (showCar && viewModel.ready && viewModel.carId != 0L) {
|
||||||
PitActionButton(
|
PitActionButton(
|
||||||
text = if (viewModel.saving) "Saving…" else "Save",
|
text = if (viewModel.saving) "Saving…" else "Save",
|
||||||
onClick = { viewModel.save(onBack) },
|
onClick = { viewModel.save(onBack) },
|
||||||
@@ -196,41 +222,19 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
PitScroll(padding) {
|
PitScroll(padding) {
|
||||||
|
if (showCar && viewModel.carId != 0L) {
|
||||||
Text(
|
Text(
|
||||||
"New race nights start with this car. Pressures are PSI and tire heat is °F.",
|
"New race nights start with this car. Pressures are PSI and tire heat is °F.",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
if (viewModel.cars.size > 1) {
|
|
||||||
SectionLabel("Cars on this phone")
|
|
||||||
viewModel.cars.forEach { car ->
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { viewModel.select(car.id) },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 8.dp),
|
|
||||||
) {
|
|
||||||
Text(if (car.id == viewModel.carId) "${car.carName} · showing" else car.carName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WordsField(
|
WordsField(
|
||||||
label = "Car",
|
label = "Car",
|
||||||
value = viewModel.carName,
|
value = viewModel.carName,
|
||||||
onValue = { viewModel.carName = it.take(80) },
|
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")
|
SectionLabel("This car's token")
|
||||||
Text(
|
Text(
|
||||||
"Share this token 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, so it can sync with different phones.",
|
"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.",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
SelectionContainer {
|
SelectionContainer {
|
||||||
@@ -250,13 +254,15 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
Text("Share token")
|
Text("Share token")
|
||||||
}
|
}
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = viewModel::addCar,
|
onClick = { viewModel.remove(onCarRemoved) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 8.dp),
|
.padding(top = 8.dp),
|
||||||
) {
|
) {
|
||||||
Text("Add a car")
|
Text("Remove from this phone")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (!showCar) {
|
||||||
SectionLabel("Add a car from a token")
|
SectionLabel("Add a car from a token")
|
||||||
WordsField(
|
WordsField(
|
||||||
label = "Token from another phone",
|
label = "Token from another phone",
|
||||||
@@ -274,19 +280,9 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
) {
|
) {
|
||||||
Text(if (viewModel.syncing) "Syncing…" else "Add that car")
|
Text(if (viewModel.syncing) "Syncing…" else "Add that car")
|
||||||
}
|
}
|
||||||
if (viewModel.cars.size > 1) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = viewModel::remove,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 8.dp),
|
|
||||||
) {
|
|
||||||
Text("Remove from this phone")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SectionLabel("Server")
|
SectionLabel("Server")
|
||||||
Text(
|
Text(
|
||||||
"This is the Pelican address. Every car on this phone uses it. Sync on its own sends each save right away, checks every half minute while the app is open, and about every 15 minutes while it is closed.",
|
"This is the Pelican address. Every car on this phone uses it. Sync on its own sends each save right away and checks every half minute while the app is open.",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Row(
|
Row(
|
||||||
@@ -304,7 +300,7 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
WordsField(
|
WordsField(
|
||||||
label = "Server",
|
label = "Server",
|
||||||
value = viewModel.serverAddress,
|
value = viewModel.serverAddress,
|
||||||
onValue = { viewModel.serverAddress = it.take(200).trim() },
|
onValue = viewModel::updateServer,
|
||||||
placeholder = DEFAULT_SYNC_ADDRESS,
|
placeholder = DEFAULT_SYNC_ADDRESS,
|
||||||
capitalization = KeyboardCapitalization.None,
|
capitalization = KeyboardCapitalization.None,
|
||||||
keyboardType = KeyboardType.Uri,
|
keyboardType = KeyboardType.Uri,
|
||||||
@@ -325,7 +321,30 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
modifier = Modifier.padding(top = 8.dp),
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
SectionLabel("App")
|
||||||
|
Text(
|
||||||
|
"Alpha ${context.packageManager.getPackageInfo(context.packageName, 0).versionName.orEmpty()}",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.checkForUpdate(context) },
|
||||||
|
enabled = !viewModel.updating,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(if (viewModel.updating) "Checking…" else "Check for updates")
|
||||||
|
}
|
||||||
|
if (viewModel.updateStatus.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
viewModel.updateStatus,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ import com.racingapp.pitlog.model.sanitizeDecimal
|
|||||||
import com.racingapp.pitlog.model.sanitizeInt
|
import com.racingapp.pitlog.model.sanitizeInt
|
||||||
import com.racingapp.pitlog.model.sanitizeSignedDecimal
|
import com.racingapp.pitlog.model.sanitizeSignedDecimal
|
||||||
import com.racingapp.pitlog.model.setupDiffs
|
import com.racingapp.pitlog.model.setupDiffs
|
||||||
|
import com.racingapp.pitlog.model.weightSplit
|
||||||
import com.racingapp.pitlog.ui.FieldRow
|
import com.racingapp.pitlog.ui.FieldRow
|
||||||
import com.racingapp.pitlog.ui.NumberField
|
import com.racingapp.pitlog.ui.NumberField
|
||||||
import com.racingapp.pitlog.ui.PitActionButton
|
import com.racingapp.pitlog.ui.PitActionButton
|
||||||
@@ -64,6 +65,13 @@ private data class CarSetupForm(
|
|||||||
val blockRr: String = "",
|
val blockRr: String = "",
|
||||||
val toeFront: String = "",
|
val toeFront: String = "",
|
||||||
val toeRear: 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 = "",
|
val note: String = "",
|
||||||
) {
|
) {
|
||||||
fun toSheet(id: Long, recordedAt: Long) = SetupSheet(
|
fun toSheet(id: Long, recordedAt: Long) = SetupSheet(
|
||||||
@@ -84,6 +92,13 @@ private data class CarSetupForm(
|
|||||||
blockRr = parseDecimal(blockRr),
|
blockRr = parseDecimal(blockRr),
|
||||||
toeFront = parseDecimal(toeFront),
|
toeFront = parseDecimal(toeFront),
|
||||||
toeRear = parseDecimal(toeRear),
|
toeRear = parseDecimal(toeRear),
|
||||||
|
weightLf = parseIntField(weightLf),
|
||||||
|
weightRf = parseIntField(weightRf),
|
||||||
|
weightLr = parseIntField(weightLr),
|
||||||
|
weightRr = parseIntField(weightRr),
|
||||||
|
rideHeight = parseDecimal(rideHeight),
|
||||||
|
pinion = parseDecimal(pinion),
|
||||||
|
brakeFront = parseDecimal(brakeFront),
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -102,6 +117,13 @@ private data class CarSetupForm(
|
|||||||
blockRr = decimal(sheet.blockRr, 1),
|
blockRr = decimal(sheet.blockRr, 1),
|
||||||
toeFront = decimal(sheet.toeFront, 3),
|
toeFront = decimal(sheet.toeFront, 3),
|
||||||
toeRear = decimal(sheet.toeRear, 3),
|
toeRear = decimal(sheet.toeRear, 3),
|
||||||
|
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),
|
||||||
|
pinion = decimal(sheet.pinion, 1),
|
||||||
|
brakeFront = decimal(sheet.brakeFront, 1),
|
||||||
note = if (keepNote) sheet.note else "",
|
note = if (keepNote) sheet.note else "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -335,6 +357,84 @@ fun CarSetupEditorScreen(
|
|||||||
SignedField("Front", form.toeFront, { value -> viewModel.update { it.copy(toeFront = value) } }, Modifier.weight(1f), 1, 3)
|
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("Rear", form.toeRear, { value -> viewModel.update { it.copy(toeRear = value) } }, Modifier.weight(1f), 1, 3)
|
||||||
}
|
}
|
||||||
|
SectionLabel("Corner weights, lb")
|
||||||
|
Text(
|
||||||
|
"Pounds on the pads. Left, rear, and cross are figured from these four.",
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
weightSplit(
|
||||||
|
form.weightLf.toIntOrNull(),
|
||||||
|
form.weightRf.toIntOrNull(),
|
||||||
|
form.weightLr.toIntOrNull(),
|
||||||
|
form.weightRr.toIntOrNull(),
|
||||||
|
)?.let { split ->
|
||||||
|
Text(split, 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 = sanitizeDecimal(value, 2, 1)) } },
|
||||||
|
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")
|
SectionLabel(if (loggingChange) "What changed" else "Notes")
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = form.note,
|
value = form.note,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<paths>
|
<paths>
|
||||||
<cache-path name="exports" path="exports/" />
|
<cache-path name="exports" path="exports/" />
|
||||||
|
<cache-path name="updates" path="updates/" />
|
||||||
</paths>
|
</paths>
|
||||||
@@ -33,6 +33,14 @@ class PreviewApp : RacingApp() {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
repository.saveAddress("")
|
repository.saveAddress("")
|
||||||
runBlocking(Dispatchers.IO) {
|
runBlocking(Dispatchers.IO) {
|
||||||
|
val car = repository.addCar()
|
||||||
|
repository.saveCar(
|
||||||
|
car.copy(
|
||||||
|
carName = DEFAULT_CAR,
|
||||||
|
raceClass = DEFAULT_CLASS,
|
||||||
|
discipline = DEFAULT_DISCIPLINE,
|
||||||
|
),
|
||||||
|
)
|
||||||
val nightId = repository.saveNight(
|
val nightId = repository.saveNight(
|
||||||
RaceNight(
|
RaceNight(
|
||||||
dateEpochDay = LocalDate.now().toEpochDay(),
|
dateEpochDay = LocalDate.now().toEpochDay(),
|
||||||
@@ -104,6 +112,8 @@ class PreviewScreenshotTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun capturePitLogScreens() {
|
fun capturePitLogScreens() {
|
||||||
|
waitForText(DEFAULT_CAR)
|
||||||
|
composeRule.onNodeWithText(DEFAULT_CAR).performClick()
|
||||||
waitForText("Elko Speedway")
|
waitForText("Elko Speedway")
|
||||||
save("01-home")
|
save("01-home")
|
||||||
|
|
||||||
@@ -121,11 +131,19 @@ class PreviewScreenshotTest {
|
|||||||
waitForText("Elko Speedway")
|
waitForText("Elko Speedway")
|
||||||
|
|
||||||
composeRule.onNodeWithContentDescription("Car").performClick()
|
composeRule.onNodeWithContentDescription("Car").performClick()
|
||||||
|
waitForText("This car's token")
|
||||||
|
composeRule.onNodeWithContentDescription("Back").performClick()
|
||||||
|
waitForText("Elko Speedway")
|
||||||
|
composeRule.onNodeWithContentDescription("Cars").performClick()
|
||||||
|
waitForText(DEFAULT_CAR)
|
||||||
|
composeRule.onNodeWithContentDescription("Settings").performClick()
|
||||||
waitForText("Sync on its own")
|
waitForText("Sync on its own")
|
||||||
composeRule.onNodeWithText("Sync on its own").performScrollTo()
|
composeRule.onNodeWithText("Sync on its own").performScrollTo()
|
||||||
composeRule.waitForIdle()
|
composeRule.waitForIdle()
|
||||||
save("12-sync")
|
save("12-sync")
|
||||||
composeRule.onNodeWithContentDescription("Back").performClick()
|
composeRule.onNodeWithContentDescription("Back").performClick()
|
||||||
|
waitForText(DEFAULT_CAR)
|
||||||
|
composeRule.onNodeWithText(DEFAULT_CAR).performClick()
|
||||||
waitForText("Elko Speedway")
|
waitForText("Elko Speedway")
|
||||||
|
|
||||||
composeRule.onNodeWithContentDescription("New race night").performClick()
|
composeRule.onNodeWithContentDescription("New race night").performClick()
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ class SyncMigrationTest {
|
|||||||
raw.close()
|
raw.close()
|
||||||
|
|
||||||
val database = Room.databaseBuilder(context, AppDatabase::class.java, name)
|
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()
|
.build()
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val night = database.raceDao().nightById(1)
|
val night = database.raceDao().nightById(1)
|
||||||
|
|||||||
@@ -35,6 +35,45 @@ class SetupLogTest {
|
|||||||
assertTrue(setupDiffs(sheet, sheet.copy(note = "perch tweak")).isEmpty())
|
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",
|
||||||
|
"From the weights Left 51.3% Rear 48% Cross 50%",
|
||||||
|
"Ride height 5 in",
|
||||||
|
"Pinion -2.0°",
|
||||||
|
"Front brake 62%",
|
||||||
|
),
|
||||||
|
setupLines(original),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
"LF weight 800 lb → 820 lb",
|
||||||
|
"Ride height 5 in → 5.2 in",
|
||||||
|
"Pinion -2.0° → -1.5°",
|
||||||
|
"Front brake 62% → 65%",
|
||||||
|
),
|
||||||
|
setupDiffs(original, later),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun originalLinesSkipBlankFields() {
|
fun originalLinesSkipBlankFields() {
|
||||||
val lines = setupLines(SetupSheet(recordedAt = 1L, crossPercent = 53.5, blockLr = 2.0))
|
val lines = setupLines(SetupSheet(recordedAt = 1L, crossPercent = 53.5, blockLr = 2.0))
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ lifecycle = "2.11.0"
|
|||||||
activity = "1.12.4"
|
activity = "1.12.4"
|
||||||
core = "1.19.1"
|
core = "1.19.1"
|
||||||
coroutines = "1.10.1"
|
coroutines = "1.10.1"
|
||||||
work = "2.10.1"
|
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
robolectric = "4.16"
|
robolectric = "4.16"
|
||||||
androidxTestCore = "1.6.1"
|
androidxTestCore = "1.6.1"
|
||||||
@@ -32,7 +31,6 @@ androidx-room-runtime = { group = "androidx.room", name = "room-runtime", versio
|
|||||||
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
androidx-work = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
|
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
|
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
|
||||||
|
|||||||
+71
-3
@@ -27,6 +27,8 @@ SHEET_FIELDS = [
|
|||||||
"recordedAt", "note", "crossPercent", "springLf", "springRf", "springLr", "springRr",
|
"recordedAt", "note", "crossPercent", "springLf", "springRf", "springLr", "springRr",
|
||||||
"camberLf", "camberRf", "casterLf", "casterRf", "swayBarHole", "blockLr", "blockRr",
|
"camberLf", "camberRf", "casterLf", "casterRf", "swayBarHole", "blockLr", "blockRr",
|
||||||
"toeFront", "toeRear",
|
"toeFront", "toeRear",
|
||||||
|
"weightLf", "weightRf", "weightLr", "weightRr",
|
||||||
|
"rideHeight", "pinion", "brakeFront",
|
||||||
]
|
]
|
||||||
|
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
@@ -52,6 +54,12 @@ def listen_port():
|
|||||||
return 8787
|
return 8787
|
||||||
|
|
||||||
|
|
||||||
|
def token_ok(bearer):
|
||||||
|
if len(bearer) == 8 and bearer.isdigit():
|
||||||
|
return True
|
||||||
|
return len(bearer) >= 16 and all(character in "0123456789abcdef" for character in bearer)
|
||||||
|
|
||||||
|
|
||||||
def legacy_token():
|
def legacy_token():
|
||||||
inline = os.environ.get("PITLOG_TOKEN", "").strip().lower()
|
inline = os.environ.get("PITLOG_TOKEN", "").strip().lower()
|
||||||
if inline:
|
if inline:
|
||||||
@@ -83,10 +91,30 @@ def open_db(path):
|
|||||||
db.row_factory = sqlite3.Row
|
db.row_factory = sqlite3.Row
|
||||||
db.execute("PRAGMA journal_mode=WAL")
|
db.execute("PRAGMA journal_mode=WAL")
|
||||||
db.executescript(SCHEMA)
|
db.executescript(SCHEMA)
|
||||||
|
ensure_columns(
|
||||||
|
db,
|
||||||
|
"sheets",
|
||||||
|
{
|
||||||
|
"weightLf": "INTEGER",
|
||||||
|
"weightRf": "INTEGER",
|
||||||
|
"weightLr": "INTEGER",
|
||||||
|
"weightRr": "INTEGER",
|
||||||
|
"rideHeight": "REAL",
|
||||||
|
"pinion": "REAL",
|
||||||
|
"brakeFront": "REAL",
|
||||||
|
},
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return db
|
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):
|
def space(bearer):
|
||||||
path = space_path(bearer)
|
path = space_path(bearer)
|
||||||
with _lock:
|
with _lock:
|
||||||
@@ -176,7 +204,14 @@ SCHEMA = """
|
|||||||
blockLr REAL,
|
blockLr REAL,
|
||||||
blockRr REAL,
|
blockRr REAL,
|
||||||
toeFront REAL,
|
toeFront REAL,
|
||||||
toeRear REAL
|
toeRear REAL,
|
||||||
|
weightLf INTEGER,
|
||||||
|
weightRf INTEGER,
|
||||||
|
weightLr INTEGER,
|
||||||
|
weightRr INTEGER,
|
||||||
|
rideHeight REAL,
|
||||||
|
pinion REAL,
|
||||||
|
brakeFront REAL
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -355,6 +390,22 @@ def sync_body(payload, db=None):
|
|||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
protocol_version = "HTTP/1.1"
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def handle(self):
|
||||||
|
try:
|
||||||
|
super().handle()
|
||||||
|
except (ConnectionError, TimeoutError):
|
||||||
|
return
|
||||||
|
|
||||||
|
def send_error(self, code, message=None, explain=None):
|
||||||
|
try:
|
||||||
|
self.close_connection = True
|
||||||
|
self.send_response_only(code)
|
||||||
|
self.send_header("Content-Length", "0")
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
except (ConnectionError, TimeoutError):
|
||||||
|
self.close_connection = True
|
||||||
|
|
||||||
def log_message(self, fmt, *args):
|
def log_message(self, fmt, *args):
|
||||||
sys.stdout.write("%s - %s\n" % (self.address_string(), fmt % args))
|
sys.stdout.write("%s - %s\n" % (self.address_string(), fmt % args))
|
||||||
|
|
||||||
@@ -370,7 +421,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
header = self.headers.get("Authorization", "")
|
header = self.headers.get("Authorization", "")
|
||||||
bearer = header[7:].strip().lower() if header.startswith("Bearer ") else ""
|
bearer = header[7:].strip().lower() if header.startswith("Bearer ") else ""
|
||||||
if len(bearer) < 16 or any(character not in "0123456789abcdef" for character in bearer):
|
if not token_ok(bearer):
|
||||||
self._send(401, {"error": "The token was refused."})
|
self._send(401, {"error": "The token was refused."})
|
||||||
return
|
return
|
||||||
length = int(self.headers.get("Content-Length", "0"))
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
@@ -481,7 +532,22 @@ def selftest():
|
|||||||
"recordedAt": 11,
|
"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": []})
|
second = sync_body({"rev": 0, "profile": {"carName": "1976 Chevy Camaro", "raceClass": "Hobby Stock", "discipline": "Oval", "updatedAt": 0}, "nights": [], "runs": [], "sheets": []})
|
||||||
@@ -489,6 +555,8 @@ def selftest():
|
|||||||
raise SystemExit("second phone did not receive the night")
|
raise SystemExit("second phone did not receive the night")
|
||||||
if second["runs"][0]["nightSyncId"] != "night-a":
|
if second["runs"][0]["nightSyncId"] != "night-a":
|
||||||
raise SystemExit("second phone did not receive the run")
|
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(
|
older = sync_body(
|
||||||
{
|
{
|
||||||
"rev": first["rev"],
|
"rev": first["rev"],
|
||||||
|
|||||||
Reference in new issue
Block a user