Put Pit Log under version control so the app and sync server can live on git.
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
sync/*.sh text eol=lf
|
||||||
|
sync/*.py text eol=lf
|
||||||
|
sync/*.yaml text eol=lf
|
||||||
|
sync/**/*.sh text eol=lf
|
||||||
|
sync/**/*.py text eol=lf
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle/
|
||||||
|
.kotlin/
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
build/
|
||||||
|
captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
*.apk
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
*.hprof
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.compose)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.racingapp.pitlog"
|
||||||
|
compileSdk = 37
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.racingapp.pitlog"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests.isIncludeAndroidResources = true
|
||||||
|
unitTests.isReturnDefaultValues = true
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.compose.ui)
|
||||||
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
implementation(libs.androidx.room.ktx)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
implementation(libs.androidx.work)
|
||||||
|
ksp(libs.androidx.room.compiler)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
testImplementation(libs.androidx.test.core)
|
||||||
|
testImplementation(libs.robolectric)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Room ships its own consumer rules. Minify is off for this app.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<data android:mimeType="text/csv" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".RacingApp"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.PitLog"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.racingapp.pitlog
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.SystemBarStyle
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import com.racingapp.pitlog.ui.LocalRepository
|
||||||
|
import com.racingapp.pitlog.ui.PitNav
|
||||||
|
import com.racingapp.pitlog.ui.theme.PitLogTheme
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
private var syncJob: Job? = null
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val asphalt = Color.rgb(16, 17, 20)
|
||||||
|
enableEdgeToEdge(
|
||||||
|
statusBarStyle = SystemBarStyle.dark(asphalt),
|
||||||
|
navigationBarStyle = SystemBarStyle.dark(asphalt),
|
||||||
|
)
|
||||||
|
setContent {
|
||||||
|
PitLogTheme {
|
||||||
|
CompositionLocalProvider(LocalRepository provides (application as RacingApp).repository) {
|
||||||
|
PitNav()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
syncJob?.cancel()
|
||||||
|
syncJob = lifecycleScope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
(application as RacingApp).repository.syncAutomatic()
|
||||||
|
delay(30_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
syncJob?.cancel()
|
||||||
|
super.onStop()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.racingapp.pitlog
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import com.racingapp.pitlog.data.AppDatabase
|
||||||
|
import com.racingapp.pitlog.data.PitSync
|
||||||
|
import com.racingapp.pitlog.data.Repository
|
||||||
|
import com.racingapp.pitlog.data.SyncPreferences
|
||||||
|
import com.racingapp.pitlog.data.ensureCars
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
open class RacingApp : Application() {
|
||||||
|
lateinit var repository: Repository
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
val dao = AppDatabase.create(this).raceDao()
|
||||||
|
val preferences = SyncPreferences(this)
|
||||||
|
runBlocking(Dispatchers.IO) { ensureCars(dao, preferences) }
|
||||||
|
val sync = PitSync(this, dao, preferences)
|
||||||
|
repository = Repository(dao, sync, preferences)
|
||||||
|
sync.schedule()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package com.racingapp.pitlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE race_nights ADD COLUMN crossPercent REAL")
|
||||||
|
db.execSQL("ALTER TABLE race_nights ADD COLUMN springLf INTEGER")
|
||||||
|
db.execSQL("ALTER TABLE race_nights ADD COLUMN springRf INTEGER")
|
||||||
|
db.execSQL("ALTER TABLE race_nights ADD COLUMN springLr INTEGER")
|
||||||
|
db.execSQL("ALTER TABLE race_nights ADD COLUMN springRr INTEGER")
|
||||||
|
db.execSQL("ALTER TABLE race_nights ADD COLUMN camberLf REAL")
|
||||||
|
db.execSQL("ALTER TABLE race_nights ADD COLUMN camberRf REAL")
|
||||||
|
db.execSQL("ALTER TABLE race_nights ADD COLUMN casterLf REAL")
|
||||||
|
db.execSQL("ALTER TABLE race_nights ADD COLUMN casterRf REAL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE race_nights ADD COLUMN swayBarHole INTEGER")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE race_nights ADD COLUMN blockLr REAL")
|
||||||
|
db.execSQL("ALTER TABLE race_nights ADD COLUMN blockRr REAL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.setForeignKeyConstraintsEnabled(false)
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE race_nights_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
dateEpochDay INTEGER NOT NULL,
|
||||||
|
trackName TEXT NOT NULL,
|
||||||
|
seriesName TEXT NOT NULL,
|
||||||
|
carName TEXT NOT NULL,
|
||||||
|
raceClass TEXT NOT NULL,
|
||||||
|
discipline TEXT NOT NULL,
|
||||||
|
airTempF INTEGER,
|
||||||
|
notes TEXT NOT NULL,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
crossPercent REAL,
|
||||||
|
springLf INTEGER,
|
||||||
|
springRf INTEGER,
|
||||||
|
springLr INTEGER,
|
||||||
|
springRr INTEGER,
|
||||||
|
camberLf REAL,
|
||||||
|
camberRf REAL,
|
||||||
|
casterLf REAL,
|
||||||
|
casterRf REAL,
|
||||||
|
swayBarHole INTEGER,
|
||||||
|
blockLr REAL,
|
||||||
|
blockRr REAL
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT INTO race_nights_new (
|
||||||
|
id, dateEpochDay, trackName, seriesName, carName, raceClass, discipline,
|
||||||
|
airTempF, notes, createdAt, crossPercent, springLf, springRf, springLr, springRr,
|
||||||
|
camberLf, camberRf, casterLf, casterRf, swayBarHole, blockLr, blockRr
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, dateEpochDay, trackName, seriesName, carName, raceClass, discipline,
|
||||||
|
airTempF, notes, createdAt, crossPercent, springLf, springRf, springLr, springRr,
|
||||||
|
camberLf, camberRf, casterLf, casterRf, swayBarHole, blockLr, blockRr
|
||||||
|
FROM race_nights
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
db.execSQL("DROP TABLE race_nights")
|
||||||
|
db.execSQL("ALTER TABLE race_nights_new RENAME TO race_nights")
|
||||||
|
db.setForeignKeyConstraintsEnabled(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MIGRATION_5_6 = object : Migration(5, 6) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.setForeignKeyConstraintsEnabled(false)
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE race_nights_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
dateEpochDay INTEGER NOT NULL,
|
||||||
|
trackName TEXT NOT NULL,
|
||||||
|
carName TEXT NOT NULL,
|
||||||
|
raceClass TEXT NOT NULL,
|
||||||
|
discipline TEXT NOT NULL,
|
||||||
|
airTempF INTEGER,
|
||||||
|
notes TEXT NOT NULL,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
crossPercent REAL,
|
||||||
|
springLf INTEGER,
|
||||||
|
springRf INTEGER,
|
||||||
|
springLr INTEGER,
|
||||||
|
springRr INTEGER,
|
||||||
|
camberLf REAL,
|
||||||
|
camberRf REAL,
|
||||||
|
casterLf REAL,
|
||||||
|
casterRf REAL,
|
||||||
|
swayBarHole INTEGER,
|
||||||
|
blockLr REAL,
|
||||||
|
blockRr REAL
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT INTO race_nights_new (
|
||||||
|
id, dateEpochDay, trackName, carName, raceClass, discipline,
|
||||||
|
airTempF, notes, createdAt, crossPercent, springLf, springRf, springLr, springRr,
|
||||||
|
camberLf, camberRf, casterLf, casterRf, swayBarHole, blockLr, blockRr
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, dateEpochDay, trackName, carName, raceClass, discipline,
|
||||||
|
airTempF, notes, createdAt, crossPercent, springLf, springRf, springLr, springRr,
|
||||||
|
camberLf, camberRf, casterLf, casterRf, swayBarHole, blockLr, blockRr
|
||||||
|
FROM race_nights
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
db.execSQL("DROP TABLE race_nights")
|
||||||
|
db.execSQL("ALTER TABLE race_nights_new RENAME TO race_nights")
|
||||||
|
db.setForeignKeyConstraintsEnabled(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MIGRATION_6_7 = object : Migration(6, 7) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS setup_sheets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
recordedAt INTEGER NOT NULL,
|
||||||
|
note TEXT NOT NULL,
|
||||||
|
crossPercent REAL,
|
||||||
|
springLf INTEGER,
|
||||||
|
springRf INTEGER,
|
||||||
|
springLr INTEGER,
|
||||||
|
springRr INTEGER,
|
||||||
|
camberLf REAL,
|
||||||
|
camberRf REAL,
|
||||||
|
casterLf REAL,
|
||||||
|
casterRf REAL,
|
||||||
|
swayBarHole INTEGER,
|
||||||
|
blockLr REAL,
|
||||||
|
blockRr REAL,
|
||||||
|
toeFront REAL,
|
||||||
|
toeRear REAL
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val MIGRATION_7_8 = object : Migration(7, 8) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE car_profile ADD COLUMN updatedAt INTEGER NOT NULL DEFAULT 0")
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
UPDATE car_profile SET updatedAt = 1
|
||||||
|
WHERE carName != '1976 Chevy Camaro'
|
||||||
|
OR raceClass != 'Hobby Stock'
|
||||||
|
OR discipline != 'Oval'
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
addSyncColumns(db, "race_nights")
|
||||||
|
db.execSQL("UPDATE race_nights SET syncId = lower(hex(randomblob(16))) WHERE syncId = ''")
|
||||||
|
db.execSQL("UPDATE race_nights SET updatedAt = createdAt WHERE updatedAt = 0 AND createdAt != 0")
|
||||||
|
addSyncColumns(db, "setup_sheets")
|
||||||
|
db.execSQL("UPDATE setup_sheets SET syncId = lower(hex(randomblob(16))) WHERE syncId = ''")
|
||||||
|
db.execSQL("UPDATE setup_sheets SET updatedAt = recordedAt WHERE updatedAt = 0 AND recordedAt != 0")
|
||||||
|
db.execSQL("ALTER TABLE runs ADD COLUMN syncId TEXT NOT NULL DEFAULT ''")
|
||||||
|
db.execSQL("ALTER TABLE runs ADD COLUMN nightSyncId TEXT NOT NULL DEFAULT ''")
|
||||||
|
db.execSQL("ALTER TABLE runs ADD COLUMN updatedAt INTEGER NOT NULL DEFAULT 0")
|
||||||
|
db.execSQL("ALTER TABLE runs ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0")
|
||||||
|
db.execSQL("ALTER TABLE runs ADD COLUMN pending INTEGER NOT NULL DEFAULT 1")
|
||||||
|
db.execSQL("UPDATE runs SET syncId = lower(hex(randomblob(16))) WHERE syncId = ''")
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
UPDATE runs SET nightSyncId = (
|
||||||
|
SELECT race_nights.syncId FROM race_nights WHERE race_nights.id = runs.nightId
|
||||||
|
) WHERE nightSyncId = ''
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
db.execSQL("UPDATE runs SET updatedAt = recordedAt WHERE updatedAt = 0 AND recordedAt != 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addSyncColumns(db: SupportSQLiteDatabase, table: String) {
|
||||||
|
db.execSQL("ALTER TABLE $table ADD COLUMN syncId TEXT NOT NULL DEFAULT ''")
|
||||||
|
db.execSQL("ALTER TABLE $table ADD COLUMN updatedAt INTEGER NOT NULL DEFAULT 0")
|
||||||
|
db.execSQL("ALTER TABLE $table ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0")
|
||||||
|
db.execSQL("ALTER TABLE $table ADD COLUMN pending INTEGER NOT NULL DEFAULT 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val MIGRATION_8_9 = object : Migration(8, 9) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS cars (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
carName TEXT NOT NULL,
|
||||||
|
raceClass TEXT NOT NULL,
|
||||||
|
discipline TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL DEFAULT '',
|
||||||
|
updatedAt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
syncRev INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pending INTEGER NOT NULL DEFAULT 1
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT INTO cars (carName, raceClass, discipline, token, updatedAt, syncRev, deleted, pending)
|
||||||
|
SELECT carName, raceClass, discipline, '', updatedAt, 0, 0, 1 FROM car_profile WHERE id = 1
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
db.execSQL("ALTER TABLE race_nights ADD COLUMN carId INTEGER NOT NULL DEFAULT 0")
|
||||||
|
db.execSQL("ALTER TABLE setup_sheets ADD COLUMN carId INTEGER NOT NULL DEFAULT 0")
|
||||||
|
db.execSQL("UPDATE race_nights SET carId = (SELECT id FROM cars ORDER BY id LIMIT 1) WHERE carId = 0")
|
||||||
|
db.execSQL("UPDATE setup_sheets SET carId = (SELECT id FROM cars ORDER BY id LIMIT 1) WHERE carId = 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [RaceNight::class, RunLog::class, Car::class, SetupSheet::class],
|
||||||
|
version = 9,
|
||||||
|
exportSchema = false,
|
||||||
|
)
|
||||||
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
abstract fun raceDao(): RaceDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun create(context: Context): AppDatabase {
|
||||||
|
return Room.databaseBuilder(context, AppDatabase::class.java, "pitlog.db")
|
||||||
|
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9)
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.racingapp.pitlog.data
|
||||||
|
|
||||||
|
const val DEFAULT_CAR = "1976 Chevy Camaro"
|
||||||
|
const val DEFAULT_CLASS = "Hobby Stock"
|
||||||
|
const val DEFAULT_DISCIPLINE = "Oval"
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.racingapp.pitlog.data
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Embedded
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import androidx.room.Relation
|
||||||
|
|
||||||
|
@Entity(tableName = "cars")
|
||||||
|
data class Car(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val carName: String = DEFAULT_CAR,
|
||||||
|
val raceClass: String = DEFAULT_CLASS,
|
||||||
|
val discipline: String = DEFAULT_DISCIPLINE,
|
||||||
|
@ColumnInfo(defaultValue = "''") val token: String = "",
|
||||||
|
@ColumnInfo(defaultValue = "0") val updatedAt: Long = 0,
|
||||||
|
@ColumnInfo(defaultValue = "0") val syncRev: Long = 0,
|
||||||
|
@ColumnInfo(defaultValue = "0") val deleted: Boolean = false,
|
||||||
|
@ColumnInfo(defaultValue = "1") val pending: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "setup_sheets")
|
||||||
|
data class SetupSheet(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val recordedAt: Long,
|
||||||
|
val note: String = "",
|
||||||
|
val crossPercent: Double? = null,
|
||||||
|
val springLf: Int? = null,
|
||||||
|
val springRf: Int? = null,
|
||||||
|
val springLr: Int? = null,
|
||||||
|
val springRr: Int? = null,
|
||||||
|
val camberLf: Double? = null,
|
||||||
|
val camberRf: Double? = null,
|
||||||
|
val casterLf: Double? = null,
|
||||||
|
val casterRf: Double? = null,
|
||||||
|
val swayBarHole: Int? = null,
|
||||||
|
val blockLr: Double? = null,
|
||||||
|
val blockRr: Double? = null,
|
||||||
|
val toeFront: Double? = null,
|
||||||
|
val toeRear: Double? = null,
|
||||||
|
@ColumnInfo(defaultValue = "''") val syncId: String = "",
|
||||||
|
@ColumnInfo(defaultValue = "0") val updatedAt: Long = 0,
|
||||||
|
@ColumnInfo(defaultValue = "0") val deleted: Boolean = false,
|
||||||
|
@ColumnInfo(defaultValue = "1") val pending: Boolean = true,
|
||||||
|
@ColumnInfo(defaultValue = "0") val carId: Long = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "race_nights")
|
||||||
|
data class RaceNight(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val dateEpochDay: Long,
|
||||||
|
val trackName: String,
|
||||||
|
val carName: String = DEFAULT_CAR,
|
||||||
|
val raceClass: String = DEFAULT_CLASS,
|
||||||
|
val discipline: String = DEFAULT_DISCIPLINE,
|
||||||
|
val airTempF: Int? = null,
|
||||||
|
val notes: String = "",
|
||||||
|
val createdAt: Long = 0,
|
||||||
|
val crossPercent: Double? = null,
|
||||||
|
val springLf: Int? = null,
|
||||||
|
val springRf: Int? = null,
|
||||||
|
val springLr: Int? = null,
|
||||||
|
val springRr: Int? = null,
|
||||||
|
val camberLf: Double? = null,
|
||||||
|
val camberRf: Double? = null,
|
||||||
|
val casterLf: Double? = null,
|
||||||
|
val casterRf: Double? = null,
|
||||||
|
val swayBarHole: Int? = null,
|
||||||
|
val blockLr: Double? = null,
|
||||||
|
val blockRr: Double? = null,
|
||||||
|
@ColumnInfo(defaultValue = "''") val syncId: String = "",
|
||||||
|
@ColumnInfo(defaultValue = "0") val updatedAt: Long = 0,
|
||||||
|
@ColumnInfo(defaultValue = "0") val deleted: Boolean = false,
|
||||||
|
@ColumnInfo(defaultValue = "1") val pending: Boolean = true,
|
||||||
|
@ColumnInfo(defaultValue = "0") val carId: Long = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "runs",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = RaceNight::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["nightId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index("nightId")],
|
||||||
|
)
|
||||||
|
data class RunLog(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val nightId: Long,
|
||||||
|
val session: String,
|
||||||
|
val tireSet: String = "",
|
||||||
|
val laps: Int? = null,
|
||||||
|
val finish: Int? = null,
|
||||||
|
val staggerInches: Double? = null,
|
||||||
|
val coldLf: Double? = null,
|
||||||
|
val coldRf: Double? = null,
|
||||||
|
val coldLr: Double? = null,
|
||||||
|
val coldRr: Double? = null,
|
||||||
|
val hotLf: Double? = null,
|
||||||
|
val hotRf: Double? = null,
|
||||||
|
val hotLr: Double? = null,
|
||||||
|
val hotRr: Double? = null,
|
||||||
|
val lfInside: Int? = null,
|
||||||
|
val lfMiddle: Int? = null,
|
||||||
|
val lfOutside: Int? = null,
|
||||||
|
val rfInside: Int? = null,
|
||||||
|
val rfMiddle: Int? = null,
|
||||||
|
val rfOutside: Int? = null,
|
||||||
|
val lrInside: Int? = null,
|
||||||
|
val lrMiddle: Int? = null,
|
||||||
|
val lrOutside: Int? = null,
|
||||||
|
val rrInside: Int? = null,
|
||||||
|
val rrMiddle: Int? = null,
|
||||||
|
val rrOutside: Int? = null,
|
||||||
|
val handling: String = "",
|
||||||
|
val handlingWhere: String = "",
|
||||||
|
val notes: String = "",
|
||||||
|
val recordedAt: Long = 0,
|
||||||
|
@ColumnInfo(defaultValue = "''") val syncId: String = "",
|
||||||
|
@ColumnInfo(defaultValue = "''") val nightSyncId: String = "",
|
||||||
|
@ColumnInfo(defaultValue = "0") val updatedAt: Long = 0,
|
||||||
|
@ColumnInfo(defaultValue = "0") val deleted: Boolean = false,
|
||||||
|
@ColumnInfo(defaultValue = "1") val pending: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class NightWithRuns(
|
||||||
|
@Embedded val night: RaceNight,
|
||||||
|
@Relation(parentColumn = "id", entityColumn = "nightId")
|
||||||
|
val runs: List<RunLog>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
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.URL
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class SyncException(message: String) : Exception(message)
|
||||||
|
|
||||||
|
private const val WORK_NAME = "pitlog-sync"
|
||||||
|
|
||||||
|
class PitSync(
|
||||||
|
private val appContext: Context,
|
||||||
|
private val dao: RaceDao,
|
||||||
|
private val preferences: SyncPreferences,
|
||||||
|
) {
|
||||||
|
val address: String get() = preferences.address
|
||||||
|
val status: String get() = preferences.status
|
||||||
|
val automatic: Boolean get() = preferences.automatic
|
||||||
|
|
||||||
|
private val gate = Mutex()
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
fun saveAddress(address: String) {
|
||||||
|
preferences.address = address.trim().trimEnd('/')
|
||||||
|
schedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAutomatic(enabled: Boolean) {
|
||||||
|
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() {
|
||||||
|
scope.launch { syncAutomatic() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun syncAutomatic() {
|
||||||
|
if (!preferences.automatic) return
|
||||||
|
if (preferences.address.isBlank()) return
|
||||||
|
try {
|
||||||
|
sync()
|
||||||
|
} catch (error: SyncException) {
|
||||||
|
preferences.status = error.message ?: "Couldn't sync."
|
||||||
|
} catch (_: Exception) {
|
||||||
|
preferences.status = "Couldn't reach the server."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun run(): String = try {
|
||||||
|
sync()
|
||||||
|
} catch (error: SyncException) {
|
||||||
|
preferences.status = error.message ?: "Couldn't sync."
|
||||||
|
preferences.status
|
||||||
|
} catch (_: Exception) {
|
||||||
|
preferences.status = "Couldn't reach the server."
|
||||||
|
preferences.status
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sync(): String = gate.withLock { withContext(Dispatchers.IO) {
|
||||||
|
val base = preferences.address
|
||||||
|
if (!base.startsWith("http://") && !base.startsWith("https://")) {
|
||||||
|
throw SyncException("The address needs to start with http:// or https://.")
|
||||||
|
}
|
||||||
|
preferences.lastAttempt = System.currentTimeMillis()
|
||||||
|
val cars = dao.activeCars()
|
||||||
|
if (cars.isEmpty()) throw SyncException("Add a car first.")
|
||||||
|
val errors = mutableListOf<String>()
|
||||||
|
var synced = 0
|
||||||
|
cars.forEach { car ->
|
||||||
|
try {
|
||||||
|
syncCar(base, car)
|
||||||
|
synced += 1
|
||||||
|
} catch (error: SyncException) {
|
||||||
|
errors.add("${car.carName}: ${error.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val clock = DateTimeFormatter.ofPattern("h:mm a").format(LocalTime.now())
|
||||||
|
val message = when {
|
||||||
|
errors.isEmpty() -> "Synced at $clock."
|
||||||
|
synced == 0 -> errors.first()
|
||||||
|
else -> "Synced $synced cars at $clock. ${errors.first()}"
|
||||||
|
}
|
||||||
|
preferences.status = message
|
||||||
|
if (synced == 0) throw SyncException(message)
|
||||||
|
message
|
||||||
|
} }
|
||||||
|
|
||||||
|
private suspend fun syncCar(base: String, car: Car) {
|
||||||
|
val token = car.token.ifBlank { newCarToken().also { dao.updateCar(car.copy(token = it)) } }
|
||||||
|
val current = dao.carById(car.id) ?: car
|
||||||
|
ensureIds(current.id)
|
||||||
|
val nights = dao.pendingNights(current.id)
|
||||||
|
val runs = dao.pendingRuns(current.id)
|
||||||
|
val sheets = dao.pendingSheets(current.id)
|
||||||
|
val body = encodeSync(current.syncRev, current, nights, runs, sheets)
|
||||||
|
val response = decodeSync(post(base, token, body))
|
||||||
|
apply(current, response)
|
||||||
|
clearAccepted(response.accepted, nights, runs, sheets)
|
||||||
|
val after = dao.carById(current.id) ?: current
|
||||||
|
dao.updateCar(after.copy(syncRev = response.rev))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureIds(carId: Long) {
|
||||||
|
dao.nightsMissingSyncId(carId).forEach { night ->
|
||||||
|
dao.updateNight(night.copy(syncId = newSyncId(), pending = true))
|
||||||
|
}
|
||||||
|
dao.sheetsMissingSyncId(carId).forEach { sheet ->
|
||||||
|
dao.updateSetupSheet(sheet.copy(syncId = newSyncId(), pending = true))
|
||||||
|
}
|
||||||
|
dao.runsMissingSyncId(carId).forEach { run ->
|
||||||
|
val night = dao.nightById(run.nightId)
|
||||||
|
dao.updateRun(
|
||||||
|
run.copy(
|
||||||
|
syncId = run.syncId.ifBlank { newSyncId() },
|
||||||
|
nightSyncId = run.nightSyncId.ifBlank { night?.syncId.orEmpty() },
|
||||||
|
pending = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun apply(car: Car, response: SyncResponse) {
|
||||||
|
val localCar = dao.carById(car.id) ?: car
|
||||||
|
val remoteProfile = response.profile
|
||||||
|
if (remoteProfile != null && remoteProfile.updatedAt >= localCar.updatedAt) {
|
||||||
|
dao.updateCar(
|
||||||
|
localCar.copy(
|
||||||
|
carName = remoteProfile.carName.ifBlank { localCar.carName },
|
||||||
|
raceClass = remoteProfile.raceClass.ifBlank { localCar.raceClass },
|
||||||
|
discipline = remoteProfile.discipline.ifBlank { localCar.discipline },
|
||||||
|
updatedAt = remoteProfile.updatedAt,
|
||||||
|
pending = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
response.nights.forEach { remote ->
|
||||||
|
val local = dao.nightBySyncId(remote.syncId, car.id)
|
||||||
|
val placed = remote.copy(id = local?.id ?: 0, carId = car.id, pending = false)
|
||||||
|
when {
|
||||||
|
local == null -> dao.insertNight(placed)
|
||||||
|
remote.updatedAt >= local.updatedAt -> dao.updateNight(placed)
|
||||||
|
!local.pending -> dao.updateNight(local.copy(pending = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.runs.forEach { remote ->
|
||||||
|
val night = dao.nightBySyncId(remote.nightSyncId, car.id)
|
||||||
|
?: throw SyncException("A run came in without its race night. Sync again.")
|
||||||
|
val local = dao.runBySyncId(remote.syncId)
|
||||||
|
val placed = remote.copy(id = local?.id ?: 0, nightId = night.id, pending = false)
|
||||||
|
when {
|
||||||
|
local == null -> dao.insertRun(placed)
|
||||||
|
remote.updatedAt >= local.updatedAt -> dao.updateRun(placed)
|
||||||
|
!local.pending -> dao.updateRun(local.copy(pending = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.sheets.forEach { remote ->
|
||||||
|
val local = dao.sheetBySyncId(remote.syncId, car.id)
|
||||||
|
val placed = remote.copy(id = local?.id ?: 0, carId = car.id, pending = false)
|
||||||
|
when {
|
||||||
|
local == null -> dao.insertSetupSheet(placed)
|
||||||
|
remote.updatedAt >= local.updatedAt -> dao.updateSetupSheet(placed)
|
||||||
|
!local.pending -> dao.updateSetupSheet(local.copy(pending = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun clearAccepted(
|
||||||
|
accepted: Set<String>,
|
||||||
|
nights: List<RaceNight>,
|
||||||
|
runs: List<RunLog>,
|
||||||
|
sheets: List<SetupSheet>,
|
||||||
|
) {
|
||||||
|
nights.filter { it.syncId in accepted }.forEach { sent ->
|
||||||
|
val current = dao.nightBySyncId(sent.syncId, sent.carId) ?: return@forEach
|
||||||
|
if (current.pending && current.updatedAt == sent.updatedAt) {
|
||||||
|
dao.updateNight(current.copy(pending = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runs.filter { it.syncId in accepted }.forEach { sent ->
|
||||||
|
val current = dao.runBySyncId(sent.syncId) ?: return@forEach
|
||||||
|
if (current.pending && current.updatedAt == sent.updatedAt) {
|
||||||
|
dao.updateRun(current.copy(pending = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sheets.filter { it.syncId in accepted }.forEach { sent ->
|
||||||
|
val current = dao.sheetBySyncId(sent.syncId, sent.carId) ?: return@forEach
|
||||||
|
if (current.pending && current.updatedAt == sent.updatedAt) {
|
||||||
|
dao.updateSetupSheet(current.copy(pending = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun post(base: String, token: String, body: String): String {
|
||||||
|
val connection = (URL("$base/v1/sync").openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "POST"
|
||||||
|
connectTimeout = 20_000
|
||||||
|
readTimeout = 20_000
|
||||||
|
doOutput = true
|
||||||
|
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||||
|
setRequestProperty("Authorization", "Bearer $token")
|
||||||
|
setRequestProperty("User-Agent", "PitLog")
|
||||||
|
}
|
||||||
|
val bytes = body.toByteArray(Charsets.UTF_8)
|
||||||
|
connection.setFixedLengthStreamingMode(bytes.size)
|
||||||
|
connection.outputStream.use { it.write(bytes) }
|
||||||
|
val stream = if (connection.responseCode in 200..299) connection.inputStream else connection.errorStream
|
||||||
|
val text = stream?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||||
|
connection.disconnect()
|
||||||
|
if (connection.responseCode == 401) throw SyncException("The token was refused.")
|
||||||
|
if (connection.responseCode !in 200..299) {
|
||||||
|
val message = runCatching { JSONObject(text).optString("error") }.getOrNull().orEmpty()
|
||||||
|
throw SyncException(message.ifBlank { "The server couldn't save that sync." })
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newSyncId(): String = UUID.randomUUID().toString()
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package com.racingapp.pitlog.data
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import androidx.room.Update
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface RaceDao {
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM race_nights WHERE deleted = 0 AND carId = :carId ORDER BY dateEpochDay DESC, id DESC")
|
||||||
|
fun observeNightsWithRuns(carId: Long): Flow<List<NightWithRuns>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM race_nights WHERE id = :id AND deleted = 0")
|
||||||
|
fun observeNight(id: Long): Flow<RaceNight?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM race_nights WHERE id = :id AND deleted = 0")
|
||||||
|
suspend fun getNight(id: Long): RaceNight?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM race_nights WHERE id = :id")
|
||||||
|
suspend fun nightById(id: Long): RaceNight?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM race_nights WHERE syncId = :syncId AND carId = :carId LIMIT 1")
|
||||||
|
suspend fun nightBySyncId(syncId: String, carId: Long): RaceNight?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM race_nights WHERE pending = 1 AND carId = :carId")
|
||||||
|
suspend fun pendingNights(carId: Long): List<RaceNight>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM race_nights WHERE syncId = '' AND carId = :carId")
|
||||||
|
suspend fun nightsMissingSyncId(carId: Long): List<RaceNight>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT trackName FROM race_nights
|
||||||
|
WHERE deleted = 0 AND carId = :carId AND TRIM(trackName) != ''
|
||||||
|
GROUP BY trackName COLLATE NOCASE
|
||||||
|
ORDER BY MAX(dateEpochDay) DESC, trackName COLLATE NOCASE
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
fun observeTrackNames(carId: Long): Flow<List<String>>
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
suspend fun insertNight(night: RaceNight): Long
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateNight(night: RaceNight)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM runs WHERE nightId = :nightId AND deleted = 0 ORDER BY recordedAt DESC")
|
||||||
|
fun observeRuns(nightId: Long): Flow<List<RunLog>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM runs WHERE nightId = :nightId")
|
||||||
|
suspend fun runsForNight(nightId: Long): List<RunLog>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM runs WHERE id = :id AND deleted = 0")
|
||||||
|
fun observeRun(id: Long): Flow<RunLog?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM runs WHERE id = :id AND deleted = 0")
|
||||||
|
suspend fun getRun(id: Long): RunLog?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM runs WHERE id = :id")
|
||||||
|
suspend fun runById(id: Long): RunLog?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM runs WHERE syncId = :syncId LIMIT 1")
|
||||||
|
suspend fun runBySyncId(syncId: String): RunLog?
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM runs
|
||||||
|
WHERE pending = 1 AND nightId IN (SELECT id FROM race_nights WHERE carId = :carId)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
suspend fun pendingRuns(carId: Long): List<RunLog>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM runs
|
||||||
|
WHERE (syncId = '' OR nightSyncId = '')
|
||||||
|
AND nightId IN (SELECT id FROM race_nights WHERE carId = :carId)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
suspend fun runsMissingSyncId(carId: Long): List<RunLog>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM runs WHERE nightId = :nightId AND deleted = 0 ORDER BY recordedAt DESC LIMIT 1")
|
||||||
|
suspend fun latestRun(nightId: Long): RunLog?
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT * FROM runs WHERE nightId = :nightId AND deleted = 0 AND recordedAt < :before ORDER BY recordedAt DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
suspend fun previousRun(nightId: Long, before: Long): RunLog?
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
suspend fun insertRun(run: RunLog): Long
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateRun(run: RunLog)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM setup_sheets WHERE deleted = 0 AND carId = :carId ORDER BY recordedAt ASC, id ASC")
|
||||||
|
fun observeSetupSheets(carId: Long): Flow<List<SetupSheet>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM setup_sheets WHERE id = :id")
|
||||||
|
suspend fun sheetById(id: Long): SetupSheet?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM setup_sheets WHERE syncId = :syncId AND carId = :carId LIMIT 1")
|
||||||
|
suspend fun sheetBySyncId(syncId: String, carId: Long): SetupSheet?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM setup_sheets WHERE pending = 1 AND carId = :carId")
|
||||||
|
suspend fun pendingSheets(carId: Long): List<SetupSheet>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM setup_sheets WHERE syncId = '' AND carId = :carId")
|
||||||
|
suspend fun sheetsMissingSyncId(carId: Long): List<SetupSheet>
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
suspend fun insertSetupSheet(sheet: SetupSheet): Long
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateSetupSheet(sheet: SetupSheet)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cars WHERE deleted = 0 ORDER BY id ASC")
|
||||||
|
fun observeCars(): Flow<List<Car>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cars ORDER BY id ASC")
|
||||||
|
suspend fun everyCar(): List<Car>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cars WHERE deleted = 0 ORDER BY id ASC")
|
||||||
|
suspend fun activeCars(): List<Car>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cars WHERE id = :id")
|
||||||
|
fun observeCar(id: Long): Flow<Car?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cars WHERE id = :id")
|
||||||
|
suspend fun carById(id: Long): Car?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cars WHERE token = :token LIMIT 1")
|
||||||
|
suspend fun carByToken(token: String): Car?
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
suspend fun insertCar(car: Car): Long
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateCar(car: Car)
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package com.racingapp.pitlog.data
|
||||||
|
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
suspend fun ensureCars(dao: RaceDao, preferences: SyncPreferences) {
|
||||||
|
val cars = dao.everyCar()
|
||||||
|
if (cars.isEmpty()) {
|
||||||
|
val id = dao.insertCar(
|
||||||
|
Car(
|
||||||
|
carName = DEFAULT_CAR,
|
||||||
|
raceClass = DEFAULT_CLASS,
|
||||||
|
discipline = DEFAULT_DISCIPLINE,
|
||||||
|
token = newCarToken(),
|
||||||
|
updatedAt = 1,
|
||||||
|
pending = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
preferences.selectedCarId = id
|
||||||
|
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
|
||||||
|
if (selected == 0L || cars.none { it.id == selected && !it.deleted }) {
|
||||||
|
preferences.selectedCarId = first.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun newCarToken(): String {
|
||||||
|
val bytes = ByteArray(32)
|
||||||
|
SecureRandom().nextBytes(bytes)
|
||||||
|
return bytes.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cleanCarToken(raw: String): String {
|
||||||
|
val line = raw.trim().lineSequence().firstOrNull().orEmpty().trim()
|
||||||
|
val withoutLabel = if (line.startsWith("token:", ignoreCase = true)) line.substringAfter(":").trim() else line
|
||||||
|
return withoutLabel.lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
class Repository(
|
||||||
|
private val dao: RaceDao,
|
||||||
|
private val sync: PitSync? = null,
|
||||||
|
private val preferences: SyncPreferences? = null,
|
||||||
|
) {
|
||||||
|
private val selected = MutableStateFlow(preferences?.selectedCarId ?: 0L)
|
||||||
|
|
||||||
|
fun observeCars(): Flow<List<Car>> = dao.observeCars()
|
||||||
|
|
||||||
|
fun observeSelectedCar(): Flow<Car?> = selected.flatMapLatest { id ->
|
||||||
|
if (id == 0L) flowOf(null) else dao.observeCar(id)
|
||||||
|
}.map { car -> car?.takeUnless { it.deleted } }
|
||||||
|
|
||||||
|
fun observeNightsWithRuns(): Flow<List<NightWithRuns>> = selected.flatMapLatest { carId ->
|
||||||
|
dao.observeNightsWithRuns(carId).map { nights ->
|
||||||
|
nights.map { item -> item.copy(runs = item.runs.filter { !it.deleted }) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeNight(id: Long): Flow<RaceNight?> = dao.observeNight(id)
|
||||||
|
|
||||||
|
fun observeRuns(nightId: Long): Flow<List<RunLog>> = dao.observeRuns(nightId)
|
||||||
|
|
||||||
|
fun observeRun(id: Long): Flow<RunLog?> = dao.observeRun(id)
|
||||||
|
|
||||||
|
fun observeSetupSheets(): Flow<List<SetupSheet>> = selected.flatMapLatest { carId ->
|
||||||
|
dao.observeSetupSheets(carId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveOriginalSetup(sheet: SetupSheet) {
|
||||||
|
val stamped = stampSheet(sheet)
|
||||||
|
if (stamped.id == 0L) dao.insertSetupSheet(stamped) else dao.updateSetupSheet(stamped)
|
||||||
|
sync?.launchAutomatic()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun logSetupChange(sheet: SetupSheet) {
|
||||||
|
dao.insertSetupSheet(stampSheet(sheet))
|
||||||
|
sync?.launchAutomatic()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeTrackNames(): Flow<List<String>> = selected.flatMapLatest { carId ->
|
||||||
|
dao.observeTrackNames(carId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getNight(id: Long): RaceNight? = dao.getNight(id)
|
||||||
|
|
||||||
|
suspend fun getRun(id: Long): RunLog? = dao.getRun(id)
|
||||||
|
|
||||||
|
suspend fun latestRun(nightId: Long): RunLog? = dao.latestRun(nightId)
|
||||||
|
|
||||||
|
suspend fun previousRun(nightId: Long, before: Long): RunLog? = dao.previousRun(nightId, before)
|
||||||
|
|
||||||
|
fun selectCar(id: Long) {
|
||||||
|
selected.value = id
|
||||||
|
preferences?.selectedCarId = id
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun selectedCar(): Car? = dao.carById(selected.value)?.takeUnless { it.deleted }
|
||||||
|
|
||||||
|
suspend fun addCar(): Car {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val car = Car(
|
||||||
|
carName = "New car",
|
||||||
|
raceClass = DEFAULT_CLASS,
|
||||||
|
discipline = DEFAULT_DISCIPLINE,
|
||||||
|
token = newCarToken(),
|
||||||
|
updatedAt = now,
|
||||||
|
pending = true,
|
||||||
|
)
|
||||||
|
val id = dao.insertCar(car)
|
||||||
|
selectCar(id)
|
||||||
|
sync?.launchAutomatic()
|
||||||
|
return car.copy(id = id)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun joinCar(raw: String): String {
|
||||||
|
val token = cleanCarToken(raw)
|
||||||
|
if (token.length < 16 || token.any { it !in '0'..'9' && it !in 'a'..'f' }) {
|
||||||
|
return "That token doesn't look right."
|
||||||
|
}
|
||||||
|
val existing = dao.carByToken(token)
|
||||||
|
if (existing != null) {
|
||||||
|
if (existing.deleted) dao.updateCar(existing.copy(deleted = false))
|
||||||
|
selectCar(existing.id)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
val id = dao.insertCar(
|
||||||
|
Car(
|
||||||
|
carName = "Shared car",
|
||||||
|
raceClass = DEFAULT_CLASS,
|
||||||
|
discipline = DEFAULT_DISCIPLINE,
|
||||||
|
token = token,
|
||||||
|
updatedAt = 0,
|
||||||
|
pending = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
selectCar(id)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeCar(id: Long) {
|
||||||
|
val car = dao.carById(id) ?: return
|
||||||
|
dao.updateCar(car.copy(deleted = true))
|
||||||
|
if (selected.value == id) {
|
||||||
|
val next = dao.activeCars().firstOrNull { it.id != id } ?: return
|
||||||
|
selectCar(next.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveNight(night: RaceNight): Long {
|
||||||
|
val stamped = stampNight(night)
|
||||||
|
return if (stamped.id == 0L) {
|
||||||
|
dao.insertNight(stamped)
|
||||||
|
} else {
|
||||||
|
dao.updateNight(stamped)
|
||||||
|
stamped.id
|
||||||
|
}.also { sync?.launchAutomatic() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteNight(id: Long) {
|
||||||
|
val night = dao.nightById(id) ?: return
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
dao.updateNight(night.copy(deleted = true, pending = true, updatedAt = now))
|
||||||
|
dao.runsForNight(id).forEach { run ->
|
||||||
|
dao.updateRun(run.copy(deleted = true, pending = true, updatedAt = now))
|
||||||
|
}
|
||||||
|
sync?.launchAutomatic()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveRun(run: RunLog): Long {
|
||||||
|
val stamped = stampRun(run)
|
||||||
|
return if (stamped.id == 0L) {
|
||||||
|
dao.insertRun(stamped)
|
||||||
|
} else {
|
||||||
|
dao.updateRun(stamped)
|
||||||
|
stamped.id
|
||||||
|
}.also { sync?.launchAutomatic() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteRun(id: Long) {
|
||||||
|
val run = dao.runById(id) ?: return
|
||||||
|
dao.updateRun(run.copy(deleted = true, pending = true, updatedAt = System.currentTimeMillis()))
|
||||||
|
sync?.launchAutomatic()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveCar(car: Car) {
|
||||||
|
val existing = dao.carById(car.id) ?: return
|
||||||
|
dao.updateCar(
|
||||||
|
existing.copy(
|
||||||
|
carName = car.carName,
|
||||||
|
raceClass = car.raceClass,
|
||||||
|
discipline = car.discipline,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
pending = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sync?.launchAutomatic()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun syncAddress(): String = sync?.address.orEmpty()
|
||||||
|
|
||||||
|
fun syncStatus(): String = sync?.status.orEmpty()
|
||||||
|
|
||||||
|
fun saveAddress(address: String) {
|
||||||
|
sync?.saveAddress(address)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun syncAutomaticEnabled(): Boolean = sync?.automatic ?: true
|
||||||
|
|
||||||
|
fun setSyncAutomatic(enabled: Boolean) {
|
||||||
|
sync?.setAutomatic(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun syncNow(): String = sync?.run() ?: "Sync isn't available on this phone."
|
||||||
|
|
||||||
|
suspend fun syncAutomatic() {
|
||||||
|
sync?.syncAutomatic()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun stampNight(night: RaceNight): RaceNight {
|
||||||
|
val existing = if (night.id != 0L) dao.nightById(night.id) else null
|
||||||
|
val carId = night.carId.takeIf { it != 0L } ?: existing?.carId?.takeIf { it != 0L } ?: selected.value
|
||||||
|
return night.copy(
|
||||||
|
carId = carId,
|
||||||
|
syncId = night.syncId.ifBlank { existing?.syncId?.ifBlank { null } ?: newSyncId() },
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
pending = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun stampRun(run: RunLog): RunLog {
|
||||||
|
val existing = if (run.id != 0L) dao.runById(run.id) else null
|
||||||
|
val night = dao.nightById(run.nightId)
|
||||||
|
var nightSyncId = run.nightSyncId.ifBlank { existing?.nightSyncId?.ifBlank { null } ?: night?.syncId.orEmpty() }
|
||||||
|
if (nightSyncId.isBlank() && night != null) {
|
||||||
|
val fixed = night.copy(syncId = newSyncId(), pending = true, updatedAt = System.currentTimeMillis())
|
||||||
|
dao.updateNight(fixed)
|
||||||
|
nightSyncId = fixed.syncId
|
||||||
|
}
|
||||||
|
return run.copy(
|
||||||
|
syncId = run.syncId.ifBlank { existing?.syncId?.ifBlank { null } ?: newSyncId() },
|
||||||
|
nightSyncId = nightSyncId,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
pending = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun stampSheet(sheet: SetupSheet): SetupSheet {
|
||||||
|
val existing = if (sheet.id != 0L) dao.sheetById(sheet.id) else null
|
||||||
|
val carId = sheet.carId.takeIf { it != 0L } ?: existing?.carId?.takeIf { it != 0L } ?: selected.value
|
||||||
|
return sheet.copy(
|
||||||
|
carId = carId,
|
||||||
|
syncId = sheet.syncId.ifBlank { existing?.syncId?.ifBlank { null } ?: newSyncId() },
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
pending = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newSyncId(): String = UUID.randomUUID().toString()
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package com.racingapp.pitlog.data
|
||||||
|
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
data class SyncResponse(
|
||||||
|
val rev: Long,
|
||||||
|
val accepted: Set<String>,
|
||||||
|
val profile: Car?,
|
||||||
|
val nights: List<RaceNight>,
|
||||||
|
val runs: List<RunLog>,
|
||||||
|
val sheets: List<SetupSheet>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun encodeSync(
|
||||||
|
rev: Long,
|
||||||
|
profile: Car,
|
||||||
|
nights: List<RaceNight>,
|
||||||
|
runs: List<RunLog>,
|
||||||
|
sheets: List<SetupSheet>,
|
||||||
|
): String = JSONObject()
|
||||||
|
.put("rev", rev)
|
||||||
|
.put("profile", profileJson(profile))
|
||||||
|
.put("nights", JSONArray().apply { nights.forEach { put(nightJson(it)) } })
|
||||||
|
.put("runs", JSONArray().apply { runs.forEach { put(runJson(it)) } })
|
||||||
|
.put("sheets", JSONArray().apply { sheets.forEach { put(sheetJson(it)) } })
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
fun decodeSync(body: String): SyncResponse {
|
||||||
|
val json = JSONObject(body)
|
||||||
|
val accepted = buildSet {
|
||||||
|
val array = json.optJSONArray("accepted") ?: JSONArray()
|
||||||
|
for (index in 0 until array.length()) add(array.optString(index))
|
||||||
|
}
|
||||||
|
return SyncResponse(
|
||||||
|
rev = json.optLong("rev"),
|
||||||
|
accepted = accepted,
|
||||||
|
profile = if (json.isNull("profile")) null else profileFrom(json.getJSONObject("profile")),
|
||||||
|
nights = json.optJSONArray("nights").toList { nightFrom(it) },
|
||||||
|
runs = json.optJSONArray("runs").toList { runFrom(it) },
|
||||||
|
sheets = json.optJSONArray("sheets").toList { sheetFrom(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun profileJson(profile: Car) = JSONObject()
|
||||||
|
.put("carName", profile.carName)
|
||||||
|
.put("raceClass", profile.raceClass)
|
||||||
|
.put("discipline", profile.discipline)
|
||||||
|
.put("updatedAt", profile.updatedAt)
|
||||||
|
|
||||||
|
private fun profileFrom(json: JSONObject) = Car(
|
||||||
|
carName = json.optString("carName"),
|
||||||
|
raceClass = json.optString("raceClass"),
|
||||||
|
discipline = json.optString("discipline"),
|
||||||
|
updatedAt = json.optLong("updatedAt"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun nightJson(night: RaceNight) = JSONObject()
|
||||||
|
.put("syncId", night.syncId)
|
||||||
|
.put("updatedAt", night.updatedAt)
|
||||||
|
.put("deleted", night.deleted)
|
||||||
|
.put("dateEpochDay", night.dateEpochDay)
|
||||||
|
.put("trackName", night.trackName)
|
||||||
|
.put("carName", night.carName)
|
||||||
|
.put("raceClass", night.raceClass)
|
||||||
|
.put("discipline", night.discipline)
|
||||||
|
.putNullable("airTempF", night.airTempF)
|
||||||
|
.put("notes", night.notes)
|
||||||
|
.put("createdAt", night.createdAt)
|
||||||
|
.putNullable("crossPercent", night.crossPercent)
|
||||||
|
.putNullable("springLf", night.springLf)
|
||||||
|
.putNullable("springRf", night.springRf)
|
||||||
|
.putNullable("springLr", night.springLr)
|
||||||
|
.putNullable("springRr", night.springRr)
|
||||||
|
.putNullable("camberLf", night.camberLf)
|
||||||
|
.putNullable("camberRf", night.camberRf)
|
||||||
|
.putNullable("casterLf", night.casterLf)
|
||||||
|
.putNullable("casterRf", night.casterRf)
|
||||||
|
.putNullable("swayBarHole", night.swayBarHole)
|
||||||
|
.putNullable("blockLr", night.blockLr)
|
||||||
|
.putNullable("blockRr", night.blockRr)
|
||||||
|
|
||||||
|
private fun nightFrom(json: JSONObject) = RaceNight(
|
||||||
|
dateEpochDay = json.optLong("dateEpochDay"),
|
||||||
|
trackName = json.optString("trackName"),
|
||||||
|
carName = json.optString("carName"),
|
||||||
|
raceClass = json.optString("raceClass"),
|
||||||
|
discipline = json.optString("discipline"),
|
||||||
|
airTempF = json.optionalInt("airTempF"),
|
||||||
|
notes = json.optString("notes"),
|
||||||
|
createdAt = json.optLong("createdAt"),
|
||||||
|
crossPercent = json.optionalDouble("crossPercent"),
|
||||||
|
springLf = json.optionalInt("springLf"),
|
||||||
|
springRf = json.optionalInt("springRf"),
|
||||||
|
springLr = json.optionalInt("springLr"),
|
||||||
|
springRr = json.optionalInt("springRr"),
|
||||||
|
camberLf = json.optionalDouble("camberLf"),
|
||||||
|
camberRf = json.optionalDouble("camberRf"),
|
||||||
|
casterLf = json.optionalDouble("casterLf"),
|
||||||
|
casterRf = json.optionalDouble("casterRf"),
|
||||||
|
swayBarHole = json.optionalInt("swayBarHole"),
|
||||||
|
blockLr = json.optionalDouble("blockLr"),
|
||||||
|
blockRr = json.optionalDouble("blockRr"),
|
||||||
|
syncId = json.optString("syncId"),
|
||||||
|
updatedAt = json.optLong("updatedAt"),
|
||||||
|
deleted = json.optBoolean("deleted"),
|
||||||
|
pending = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun runJson(run: RunLog) = JSONObject()
|
||||||
|
.put("syncId", run.syncId)
|
||||||
|
.put("nightSyncId", run.nightSyncId)
|
||||||
|
.put("updatedAt", run.updatedAt)
|
||||||
|
.put("deleted", run.deleted)
|
||||||
|
.put("session", run.session)
|
||||||
|
.put("tireSet", run.tireSet)
|
||||||
|
.putNullable("laps", run.laps)
|
||||||
|
.putNullable("finish", run.finish)
|
||||||
|
.putNullable("staggerInches", run.staggerInches)
|
||||||
|
.putNullable("coldLf", run.coldLf)
|
||||||
|
.putNullable("coldRf", run.coldRf)
|
||||||
|
.putNullable("coldLr", run.coldLr)
|
||||||
|
.putNullable("coldRr", run.coldRr)
|
||||||
|
.putNullable("hotLf", run.hotLf)
|
||||||
|
.putNullable("hotRf", run.hotRf)
|
||||||
|
.putNullable("hotLr", run.hotLr)
|
||||||
|
.putNullable("hotRr", run.hotRr)
|
||||||
|
.putNullable("lfInside", run.lfInside)
|
||||||
|
.putNullable("lfMiddle", run.lfMiddle)
|
||||||
|
.putNullable("lfOutside", run.lfOutside)
|
||||||
|
.putNullable("rfInside", run.rfInside)
|
||||||
|
.putNullable("rfMiddle", run.rfMiddle)
|
||||||
|
.putNullable("rfOutside", run.rfOutside)
|
||||||
|
.putNullable("lrInside", run.lrInside)
|
||||||
|
.putNullable("lrMiddle", run.lrMiddle)
|
||||||
|
.putNullable("lrOutside", run.lrOutside)
|
||||||
|
.putNullable("rrInside", run.rrInside)
|
||||||
|
.putNullable("rrMiddle", run.rrMiddle)
|
||||||
|
.putNullable("rrOutside", run.rrOutside)
|
||||||
|
.put("handling", run.handling)
|
||||||
|
.put("handlingWhere", run.handlingWhere)
|
||||||
|
.put("notes", run.notes)
|
||||||
|
.put("recordedAt", run.recordedAt)
|
||||||
|
|
||||||
|
private fun runFrom(json: JSONObject) = RunLog(
|
||||||
|
nightId = 0,
|
||||||
|
session = json.optString("session"),
|
||||||
|
tireSet = json.optString("tireSet"),
|
||||||
|
laps = json.optionalInt("laps"),
|
||||||
|
finish = json.optionalInt("finish"),
|
||||||
|
staggerInches = json.optionalDouble("staggerInches"),
|
||||||
|
coldLf = json.optionalDouble("coldLf"),
|
||||||
|
coldRf = json.optionalDouble("coldRf"),
|
||||||
|
coldLr = json.optionalDouble("coldLr"),
|
||||||
|
coldRr = json.optionalDouble("coldRr"),
|
||||||
|
hotLf = json.optionalDouble("hotLf"),
|
||||||
|
hotRf = json.optionalDouble("hotRf"),
|
||||||
|
hotLr = json.optionalDouble("hotLr"),
|
||||||
|
hotRr = json.optionalDouble("hotRr"),
|
||||||
|
lfInside = json.optionalInt("lfInside"),
|
||||||
|
lfMiddle = json.optionalInt("lfMiddle"),
|
||||||
|
lfOutside = json.optionalInt("lfOutside"),
|
||||||
|
rfInside = json.optionalInt("rfInside"),
|
||||||
|
rfMiddle = json.optionalInt("rfMiddle"),
|
||||||
|
rfOutside = json.optionalInt("rfOutside"),
|
||||||
|
lrInside = json.optionalInt("lrInside"),
|
||||||
|
lrMiddle = json.optionalInt("lrMiddle"),
|
||||||
|
lrOutside = json.optionalInt("lrOutside"),
|
||||||
|
rrInside = json.optionalInt("rrInside"),
|
||||||
|
rrMiddle = json.optionalInt("rrMiddle"),
|
||||||
|
rrOutside = json.optionalInt("rrOutside"),
|
||||||
|
handling = json.optString("handling"),
|
||||||
|
handlingWhere = json.optString("handlingWhere"),
|
||||||
|
notes = json.optString("notes"),
|
||||||
|
recordedAt = json.optLong("recordedAt"),
|
||||||
|
syncId = json.optString("syncId"),
|
||||||
|
nightSyncId = json.optString("nightSyncId"),
|
||||||
|
updatedAt = json.optLong("updatedAt"),
|
||||||
|
deleted = json.optBoolean("deleted"),
|
||||||
|
pending = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun sheetJson(sheet: SetupSheet) = JSONObject()
|
||||||
|
.put("syncId", sheet.syncId)
|
||||||
|
.put("updatedAt", sheet.updatedAt)
|
||||||
|
.put("deleted", sheet.deleted)
|
||||||
|
.put("recordedAt", sheet.recordedAt)
|
||||||
|
.put("note", sheet.note)
|
||||||
|
.putNullable("crossPercent", sheet.crossPercent)
|
||||||
|
.putNullable("springLf", sheet.springLf)
|
||||||
|
.putNullable("springRf", sheet.springRf)
|
||||||
|
.putNullable("springLr", sheet.springLr)
|
||||||
|
.putNullable("springRr", sheet.springRr)
|
||||||
|
.putNullable("camberLf", sheet.camberLf)
|
||||||
|
.putNullable("camberRf", sheet.camberRf)
|
||||||
|
.putNullable("casterLf", sheet.casterLf)
|
||||||
|
.putNullable("casterRf", sheet.casterRf)
|
||||||
|
.putNullable("swayBarHole", sheet.swayBarHole)
|
||||||
|
.putNullable("blockLr", sheet.blockLr)
|
||||||
|
.putNullable("blockRr", sheet.blockRr)
|
||||||
|
.putNullable("toeFront", sheet.toeFront)
|
||||||
|
.putNullable("toeRear", sheet.toeRear)
|
||||||
|
|
||||||
|
private fun sheetFrom(json: JSONObject) = SetupSheet(
|
||||||
|
recordedAt = json.optLong("recordedAt"),
|
||||||
|
note = json.optString("note"),
|
||||||
|
crossPercent = json.optionalDouble("crossPercent"),
|
||||||
|
springLf = json.optionalInt("springLf"),
|
||||||
|
springRf = json.optionalInt("springRf"),
|
||||||
|
springLr = json.optionalInt("springLr"),
|
||||||
|
springRr = json.optionalInt("springRr"),
|
||||||
|
camberLf = json.optionalDouble("camberLf"),
|
||||||
|
camberRf = json.optionalDouble("camberRf"),
|
||||||
|
casterLf = json.optionalDouble("casterLf"),
|
||||||
|
casterRf = json.optionalDouble("casterRf"),
|
||||||
|
swayBarHole = json.optionalInt("swayBarHole"),
|
||||||
|
blockLr = json.optionalDouble("blockLr"),
|
||||||
|
blockRr = json.optionalDouble("blockRr"),
|
||||||
|
toeFront = json.optionalDouble("toeFront"),
|
||||||
|
toeRear = json.optionalDouble("toeRear"),
|
||||||
|
syncId = json.optString("syncId"),
|
||||||
|
updatedAt = json.optLong("updatedAt"),
|
||||||
|
deleted = json.optBoolean("deleted"),
|
||||||
|
pending = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun JSONObject.putNullable(key: String, value: Any?): JSONObject =
|
||||||
|
put(key, value ?: JSONObject.NULL)
|
||||||
|
|
||||||
|
private fun JSONObject.optionalInt(key: String): Int? =
|
||||||
|
if (isNull(key)) null else optInt(key)
|
||||||
|
|
||||||
|
private fun JSONObject.optionalDouble(key: String): Double? =
|
||||||
|
if (isNull(key)) null else optDouble(key)
|
||||||
|
|
||||||
|
private fun <T> JSONArray?.toList(read: (JSONObject) -> T): List<T> {
|
||||||
|
if (this == null) return emptyList()
|
||||||
|
return buildList {
|
||||||
|
for (index in 0 until length()) add(read(getJSONObject(index)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.racingapp.pitlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
class SyncPreferences(context: Context) {
|
||||||
|
private val prefs = context.getSharedPreferences("pitlog-sync", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
var address: String
|
||||||
|
get() = prefs.getString(ADDRESS, "").orEmpty()
|
||||||
|
set(value) = prefs.edit().putString(ADDRESS, value).apply()
|
||||||
|
|
||||||
|
var token: String
|
||||||
|
get() = prefs.getString(TOKEN, "").orEmpty()
|
||||||
|
set(value) = prefs.edit().putString(TOKEN, value).apply()
|
||||||
|
|
||||||
|
var status: String
|
||||||
|
get() = prefs.getString(STATUS, "").orEmpty()
|
||||||
|
set(value) = prefs.edit().putString(STATUS, value).apply()
|
||||||
|
|
||||||
|
var rev: Long
|
||||||
|
get() = prefs.getLong(REV, 0L)
|
||||||
|
set(value) = prefs.edit().putLong(REV, value).apply()
|
||||||
|
|
||||||
|
var lastAttempt: Long
|
||||||
|
get() = prefs.getLong(ATTEMPT, 0L)
|
||||||
|
set(value) = prefs.edit().putLong(ATTEMPT, value).apply()
|
||||||
|
|
||||||
|
var automatic: Boolean
|
||||||
|
get() = prefs.getBoolean(AUTOMATIC, true)
|
||||||
|
set(value) = prefs.edit().putBoolean(AUTOMATIC, value).apply()
|
||||||
|
|
||||||
|
var selectedCarId: Long
|
||||||
|
get() = prefs.getLong(SELECTED, 0L)
|
||||||
|
set(value) = prefs.edit().putLong(SELECTED, value).apply()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val ADDRESS = "address"
|
||||||
|
const val TOKEN = "token"
|
||||||
|
const val STATUS = "status"
|
||||||
|
const val REV = "rev"
|
||||||
|
const val ATTEMPT = "attempt"
|
||||||
|
const val AUTOMATIC = "automatic"
|
||||||
|
const val SELECTED = "selectedCar"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.racingapp.pitlog.model
|
||||||
|
|
||||||
|
import com.racingapp.pitlog.data.NightWithRuns
|
||||||
|
import com.racingapp.pitlog.data.RaceNight
|
||||||
|
import com.racingapp.pitlog.data.RunLog
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
private val recordedFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm", Locale.US)
|
||||||
|
|
||||||
|
private const val HEADER = "car,class,discipline,date,track,air_f," +
|
||||||
|
"cross_pct,spring_lf,spring_rf,spring_lr,spring_rr,camber_lf,camber_rf,caster_lf,caster_rf,sway_bar,block_lr_in,block_rr_in," +
|
||||||
|
"session,tire_set,laps,finish,stagger_in," +
|
||||||
|
"cold_lf_psi,cold_rf_psi,cold_lr_psi,cold_rr_psi," +
|
||||||
|
"hot_lf_psi,hot_rf_psi,hot_lr_psi,hot_rr_psi," +
|
||||||
|
"build_lf_psi,build_rf_psi,build_lr_psi,build_rr_psi," +
|
||||||
|
"lf_inside_f,lf_middle_f,lf_outside_f,lf_avg_f,lf_spread_f," +
|
||||||
|
"rf_inside_f,rf_middle_f,rf_outside_f,rf_avg_f,rf_spread_f," +
|
||||||
|
"lr_inside_f,lr_middle_f,lr_outside_f,lr_avg_f,lr_spread_f," +
|
||||||
|
"rr_inside_f,rr_middle_f,rr_outside_f,rr_avg_f,rr_spread_f," +
|
||||||
|
"handling,handling_where,notes,recorded"
|
||||||
|
|
||||||
|
fun exportFileName(track: String, date: LocalDate): String {
|
||||||
|
val slug = track.lowercase(Locale.US)
|
||||||
|
.replace(Regex("[^a-z0-9]+"), "-")
|
||||||
|
.trim('-')
|
||||||
|
.ifBlank { "track" }
|
||||||
|
.take(40)
|
||||||
|
return "pitlog-$slug-$date.csv"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildCsv(nights: List<NightWithRuns>): String {
|
||||||
|
val lines = mutableListOf(HEADER)
|
||||||
|
for (item in nights) {
|
||||||
|
val ordered = item.runs.sortedBy { it.recordedAt }
|
||||||
|
for (run in ordered) {
|
||||||
|
lines += csvRow(item.night, run)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.joinToString("\n") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun csvRow(night: RaceNight, run: RunLog): String {
|
||||||
|
val sheet = run.toSheet()
|
||||||
|
val cells = listOf(
|
||||||
|
night.carName,
|
||||||
|
night.raceClass,
|
||||||
|
night.discipline,
|
||||||
|
LocalDate.ofEpochDay(night.dateEpochDay).toString(),
|
||||||
|
night.trackName,
|
||||||
|
intCell(night.airTempF),
|
||||||
|
decimalCell(night.crossPercent, 1),
|
||||||
|
intCell(night.springLf),
|
||||||
|
intCell(night.springRf),
|
||||||
|
intCell(night.springLr),
|
||||||
|
intCell(night.springRr),
|
||||||
|
decimalCell(night.camberLf, 1),
|
||||||
|
decimalCell(night.camberRf, 1),
|
||||||
|
decimalCell(night.casterLf, 1),
|
||||||
|
decimalCell(night.casterRf, 1),
|
||||||
|
intCell(night.swayBarHole),
|
||||||
|
decimalCell(night.blockLr, 1),
|
||||||
|
decimalCell(night.blockRr, 1),
|
||||||
|
run.session,
|
||||||
|
run.tireSet,
|
||||||
|
intCell(run.laps),
|
||||||
|
intCell(run.finish),
|
||||||
|
decimalCell(run.staggerInches, 2),
|
||||||
|
decimalCell(sheet.cold.lf, 1),
|
||||||
|
decimalCell(sheet.cold.rf, 1),
|
||||||
|
decimalCell(sheet.cold.lr, 1),
|
||||||
|
decimalCell(sheet.cold.rr, 1),
|
||||||
|
decimalCell(sheet.hot.lf, 1),
|
||||||
|
decimalCell(sheet.hot.rf, 1),
|
||||||
|
decimalCell(sheet.hot.lr, 1),
|
||||||
|
decimalCell(sheet.hot.rr, 1),
|
||||||
|
decimalCell(sheet.build(Corner.LF), 1),
|
||||||
|
decimalCell(sheet.build(Corner.RF), 1),
|
||||||
|
decimalCell(sheet.build(Corner.LR), 1),
|
||||||
|
decimalCell(sheet.build(Corner.RR), 1),
|
||||||
|
) + heatCells(sheet.heatOf(Corner.LF)) +
|
||||||
|
heatCells(sheet.heatOf(Corner.RF)) +
|
||||||
|
heatCells(sheet.heatOf(Corner.LR)) +
|
||||||
|
heatCells(sheet.heatOf(Corner.RR)) +
|
||||||
|
listOf(
|
||||||
|
run.handling,
|
||||||
|
run.handlingWhere,
|
||||||
|
run.notes,
|
||||||
|
recordedCell(run.recordedAt),
|
||||||
|
)
|
||||||
|
return cells.joinToString(",") { csvCell(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun heatCells(reading: HeatReading): List<String> = listOf(
|
||||||
|
intCell(reading.inside),
|
||||||
|
intCell(reading.middle),
|
||||||
|
intCell(reading.outside),
|
||||||
|
reading.average?.let { String.format(Locale.US, "%.0f", it) }.orEmpty(),
|
||||||
|
reading.spread?.toString().orEmpty(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun intCell(value: Int?): String = value?.toString().orEmpty()
|
||||||
|
|
||||||
|
private fun decimalCell(value: Double?, digits: Int): String {
|
||||||
|
if (value == null) return ""
|
||||||
|
return String.format(Locale.US, "%.${digits}f", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recordedCell(epochMillis: Long): String {
|
||||||
|
if (epochMillis == 0L) return ""
|
||||||
|
return Instant.ofEpochMilli(epochMillis)
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.format(recordedFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun csvCell(value: String): String {
|
||||||
|
val escaped = value.replace("\"", "\"\"")
|
||||||
|
val needsQuotes = escaped.any { it == ',' || it == '"' || it == '\n' || it == '\r' }
|
||||||
|
return if (needsQuotes) "\"$escaped\"" else escaped
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package com.racingapp.pitlog.model
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.location.Location
|
||||||
|
import android.location.LocationListener
|
||||||
|
import android.location.LocationManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.CancellationSignal
|
||||||
|
import android.os.Looper
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
|
data class LocalWeather(
|
||||||
|
val airF: Int,
|
||||||
|
val summary: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
class WeatherUnavailable(message: String) : Exception(message)
|
||||||
|
|
||||||
|
suspend fun readLocalWeather(context: Context): LocalWeather {
|
||||||
|
val location = locate(context)
|
||||||
|
val body = withContext(Dispatchers.IO) {
|
||||||
|
fetchForecast(location.latitude, location.longitude)
|
||||||
|
}
|
||||||
|
return parseOpenMeteo(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun parseOpenMeteo(body: String): LocalWeather {
|
||||||
|
val temp = numberAfter(body, "temperature_2m") ?: throw WeatherUnavailable("Couldn't read the weather here.")
|
||||||
|
val code = numberAfter(body, "weather_code")?.toInt() ?: -1
|
||||||
|
return LocalWeather(temp.roundToInt(), weatherWords(code))
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun weatherWords(code: Int): String = when (code) {
|
||||||
|
0 -> "Clear"
|
||||||
|
1 -> "Mostly clear"
|
||||||
|
2 -> "Partly cloudy"
|
||||||
|
3 -> "Overcast"
|
||||||
|
45, 48 -> "Fog"
|
||||||
|
51, 53, 55, 56, 57 -> "Drizzle"
|
||||||
|
61, 63, 65, 66, 67 -> "Rain"
|
||||||
|
71, 73, 75, 77 -> "Snow"
|
||||||
|
80, 81, 82 -> "Rain showers"
|
||||||
|
85, 86 -> "Snow showers"
|
||||||
|
95, 96, 99 -> "Thunderstorm"
|
||||||
|
else -> "Weather"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun numberAfter(body: String, key: String): Double? {
|
||||||
|
val marker = "\"$key\""
|
||||||
|
var search = 0
|
||||||
|
while (search < body.length) {
|
||||||
|
val start = body.indexOf(marker, search)
|
||||||
|
if (start < 0) return null
|
||||||
|
val colon = body.indexOf(':', start + marker.length)
|
||||||
|
if (colon < 0) return null
|
||||||
|
val slice = body.substring(colon + 1).trimStart()
|
||||||
|
val end = slice.indexOfFirst { it != '-' && it != '.' && !it.isDigit() }
|
||||||
|
val token = if (end < 0) slice else slice.substring(0, end)
|
||||||
|
val number = token.toDoubleOrNull()
|
||||||
|
if (number != null) return number
|
||||||
|
search = colon + 1
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchForecast(latitude: Double, longitude: Double): String {
|
||||||
|
val url = URL(
|
||||||
|
"https://api.open-meteo.com/v1/forecast" +
|
||||||
|
"?latitude=$latitude&longitude=$longitude" +
|
||||||
|
"¤t=temperature_2m,weather_code&temperature_unit=fahrenheit",
|
||||||
|
)
|
||||||
|
val connection = (url.openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = 8_000
|
||||||
|
readTimeout = 8_000
|
||||||
|
requestMethod = "GET"
|
||||||
|
setRequestProperty("User-Agent", "PitLog")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (connection.responseCode !in 200..299) {
|
||||||
|
throw WeatherUnavailable("Couldn't read the weather here.")
|
||||||
|
}
|
||||||
|
return connection.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private suspend fun locate(context: Context): Location {
|
||||||
|
val manager = context.getSystemService(LocationManager::class.java)
|
||||||
|
?: throw WeatherUnavailable("Turn on location so the air temperature can be filled in.")
|
||||||
|
val providers = locationProviders(manager)
|
||||||
|
if (providers.isEmpty()) {
|
||||||
|
throw WeatherUnavailable("Turn on location so the air temperature can be filled in.")
|
||||||
|
}
|
||||||
|
val recent = providers.firstNotNullOfOrNull { provider ->
|
||||||
|
runCatching { manager.getLastKnownLocation(provider) }.getOrNull()
|
||||||
|
}
|
||||||
|
if (recent != null && System.currentTimeMillis() - recent.time < 30 * 60 * 1000) return recent
|
||||||
|
for (provider in providers) {
|
||||||
|
val fresh = try {
|
||||||
|
requestLocation(manager, provider)
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (fresh != null) return fresh
|
||||||
|
}
|
||||||
|
return recent
|
||||||
|
?: throw WeatherUnavailable("Turn on location so the air temperature can be filled in.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun locationProviders(manager: LocationManager): List<String> {
|
||||||
|
val names = buildList {
|
||||||
|
if (Build.VERSION.SDK_INT >= 31) add(LocationManager.FUSED_PROVIDER)
|
||||||
|
add(LocationManager.NETWORK_PROVIDER)
|
||||||
|
add(LocationManager.GPS_PROVIDER)
|
||||||
|
}
|
||||||
|
return names.filter { provider ->
|
||||||
|
runCatching { manager.isProviderEnabled(provider) }.getOrDefault(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private suspend fun requestLocation(manager: LocationManager, provider: String): Location? = withTimeoutOrNull(8_000) {
|
||||||
|
if (Build.VERSION.SDK_INT >= 30) {
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val signal = CancellationSignal()
|
||||||
|
cont.invokeOnCancellation { signal.cancel() }
|
||||||
|
manager.getCurrentLocation(provider, signal, Runnable::run) { location ->
|
||||||
|
if (cont.isActive) cont.resume(location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val listener = object : LocationListener {
|
||||||
|
override fun onLocationChanged(location: Location) {
|
||||||
|
manager.removeUpdates(this)
|
||||||
|
if (cont.isActive) cont.resume(location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cont.invokeOnCancellation { manager.removeUpdates(listener) }
|
||||||
|
manager.requestLocationUpdates(provider, 0L, 0f, listener, Looper.getMainLooper())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.racingapp.pitlog.model
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
fun sanitizeSignedDecimal(raw: String, wholeDigits: Int, fractionDigits: Int): String {
|
||||||
|
val negative = raw.trimStart().startsWith('-')
|
||||||
|
val body = sanitizeDecimal(raw, wholeDigits, fractionDigits)
|
||||||
|
if (body.isEmpty()) return if (negative) "-" else ""
|
||||||
|
return if (negative) "-$body" else body
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sanitizeDecimal(raw: String, wholeDigits: Int, fractionDigits: Int): String {
|
||||||
|
val cleaned = raw.replace(',', '.').filter { it.isDigit() || it == '.' }
|
||||||
|
val dot = cleaned.indexOf('.')
|
||||||
|
if (dot < 0) return cleaned.take(wholeDigits)
|
||||||
|
val whole = cleaned.substring(0, dot).take(wholeDigits)
|
||||||
|
val fraction = cleaned.substring(dot + 1).filter { it.isDigit() }.take(fractionDigits)
|
||||||
|
return "$whole.$fraction"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sanitizeInt(raw: String, digits: Int, allowNegative: Boolean = false): String {
|
||||||
|
val negative = allowNegative && raw.trimStart().startsWith('-')
|
||||||
|
val numbers = raw.filter { it.isDigit() }.take(digits)
|
||||||
|
if (numbers.isEmpty()) return if (negative) "-" else ""
|
||||||
|
return if (negative) "-$numbers" else numbers
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseDecimal(raw: String): Double? {
|
||||||
|
val trimmed = raw.trim().replace(',', '.').trimEnd('.')
|
||||||
|
if (trimmed.isEmpty() || trimmed == "-" || trimmed == ".") return null
|
||||||
|
return trimmed.toDoubleOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseIntField(raw: String): Int? = raw.trim().toIntOrNull()
|
||||||
|
|
||||||
|
fun formatPsi(value: Double?): String =
|
||||||
|
if (value == null) "—" else String.format(Locale.US, "%.1f", value)
|
||||||
|
|
||||||
|
fun formatSignedPsi(value: Double?): String =
|
||||||
|
if (value == null) "—" else String.format(Locale.US, "%+.1f", value)
|
||||||
|
|
||||||
|
fun formatTemp(value: Int?): String = value?.toString() ?: "—"
|
||||||
|
|
||||||
|
fun formatAverage(value: Double?): String =
|
||||||
|
if (value == null) "—" else String.format(Locale.US, "%.0f", value)
|
||||||
|
|
||||||
|
fun formatSpread(value: Int?): String =
|
||||||
|
if (value == null) "—" else String.format(Locale.US, "%+d", value)
|
||||||
|
|
||||||
|
fun formatDegrees(value: Double?): String =
|
||||||
|
if (value == null) "—" else String.format(Locale.US, "%+.1f", value)
|
||||||
|
|
||||||
|
fun formatStagger(value: Double?): String {
|
||||||
|
if (value == null) return "—"
|
||||||
|
return String.format(Locale.US, "%.2f", value).trimEnd('0').trimEnd('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
fun psiField(value: Double?): String =
|
||||||
|
if (value == null) "" else String.format(Locale.US, "%.1f", value)
|
||||||
|
|
||||||
|
fun staggerField(value: Double?): String {
|
||||||
|
if (value == null) return ""
|
||||||
|
return String.format(Locale.US, "%.2f", value).trimEnd('0').trimEnd('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
fun intField(value: Int?): String = value?.toString().orEmpty()
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package com.racingapp.pitlog.model
|
||||||
|
|
||||||
|
import com.racingapp.pitlog.data.RunLog
|
||||||
|
|
||||||
|
data class RunForm(
|
||||||
|
val session: SessionKind = SessionKind.Practice,
|
||||||
|
val tireSet: String = "",
|
||||||
|
val laps: String = "",
|
||||||
|
val finish: String = "",
|
||||||
|
val stagger: String = "",
|
||||||
|
val coldLf: String = "",
|
||||||
|
val coldRf: String = "",
|
||||||
|
val coldLr: String = "",
|
||||||
|
val coldRr: String = "",
|
||||||
|
val hotLf: String = "",
|
||||||
|
val hotRf: String = "",
|
||||||
|
val hotLr: String = "",
|
||||||
|
val hotRr: String = "",
|
||||||
|
val lfIn: String = "",
|
||||||
|
val lfMid: String = "",
|
||||||
|
val lfOut: String = "",
|
||||||
|
val rfIn: String = "",
|
||||||
|
val rfMid: String = "",
|
||||||
|
val rfOut: String = "",
|
||||||
|
val lrIn: String = "",
|
||||||
|
val lrMid: String = "",
|
||||||
|
val lrOut: String = "",
|
||||||
|
val rrIn: String = "",
|
||||||
|
val rrMid: String = "",
|
||||||
|
val rrOut: String = "",
|
||||||
|
val handling: Handling? = null,
|
||||||
|
val handlingWhere: HandlingSpot? = null,
|
||||||
|
val notes: String = "",
|
||||||
|
) {
|
||||||
|
fun cold(corner: Corner): String = when (corner) {
|
||||||
|
Corner.LF -> coldLf
|
||||||
|
Corner.RF -> coldRf
|
||||||
|
Corner.LR -> coldLr
|
||||||
|
Corner.RR -> coldRr
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hot(corner: Corner): String = when (corner) {
|
||||||
|
Corner.LF -> hotLf
|
||||||
|
Corner.RF -> hotRf
|
||||||
|
Corner.LR -> hotLr
|
||||||
|
Corner.RR -> hotRr
|
||||||
|
}
|
||||||
|
|
||||||
|
fun heat(corner: Corner, tread: Tread): String = when (corner) {
|
||||||
|
Corner.LF -> tread.pick(lfIn, lfMid, lfOut)
|
||||||
|
Corner.RF -> tread.pick(rfIn, rfMid, rfOut)
|
||||||
|
Corner.LR -> tread.pick(lrIn, lrMid, lrOut)
|
||||||
|
Corner.RR -> tread.pick(rrIn, rrMid, rrOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun withCold(corner: Corner, value: String): RunForm = when (corner) {
|
||||||
|
Corner.LF -> copy(coldLf = value)
|
||||||
|
Corner.RF -> copy(coldRf = value)
|
||||||
|
Corner.LR -> copy(coldLr = value)
|
||||||
|
Corner.RR -> copy(coldRr = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun withHot(corner: Corner, value: String): RunForm = when (corner) {
|
||||||
|
Corner.LF -> copy(hotLf = value)
|
||||||
|
Corner.RF -> copy(hotRf = value)
|
||||||
|
Corner.LR -> copy(hotLr = value)
|
||||||
|
Corner.RR -> copy(hotRr = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun withHeat(corner: Corner, tread: Tread, value: String): RunForm = when (corner) {
|
||||||
|
Corner.LF -> when (tread) {
|
||||||
|
Tread.Inside -> copy(lfIn = value)
|
||||||
|
Tread.Middle -> copy(lfMid = value)
|
||||||
|
Tread.Outside -> copy(lfOut = value)
|
||||||
|
}
|
||||||
|
Corner.RF -> when (tread) {
|
||||||
|
Tread.Inside -> copy(rfIn = value)
|
||||||
|
Tread.Middle -> copy(rfMid = value)
|
||||||
|
Tread.Outside -> copy(rfOut = value)
|
||||||
|
}
|
||||||
|
Corner.LR -> when (tread) {
|
||||||
|
Tread.Inside -> copy(lrIn = value)
|
||||||
|
Tread.Middle -> copy(lrMid = value)
|
||||||
|
Tread.Outside -> copy(lrOut = value)
|
||||||
|
}
|
||||||
|
Corner.RR -> when (tread) {
|
||||||
|
Tread.Inside -> copy(rrIn = value)
|
||||||
|
Tread.Middle -> copy(rrMid = value)
|
||||||
|
Tread.Outside -> copy(rrOut = value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun withColdFrom(run: RunLog): RunForm = copy(
|
||||||
|
coldLf = psiField(run.coldLf),
|
||||||
|
coldRf = psiField(run.coldRf),
|
||||||
|
coldLr = psiField(run.coldLr),
|
||||||
|
coldRr = psiField(run.coldRr),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toSheet(): TireSheet = TireSheet(
|
||||||
|
cold = Pressures(
|
||||||
|
lf = parseDecimal(coldLf),
|
||||||
|
rf = parseDecimal(coldRf),
|
||||||
|
lr = parseDecimal(coldLr),
|
||||||
|
rr = parseDecimal(coldRr),
|
||||||
|
),
|
||||||
|
hot = Pressures(
|
||||||
|
lf = parseDecimal(hotLf),
|
||||||
|
rf = parseDecimal(hotRf),
|
||||||
|
lr = parseDecimal(hotLr),
|
||||||
|
rr = parseDecimal(hotRr),
|
||||||
|
),
|
||||||
|
heat = mapOf(
|
||||||
|
Corner.LF to HeatReading(parseIntField(lfIn), parseIntField(lfMid), parseIntField(lfOut)),
|
||||||
|
Corner.RF to HeatReading(parseIntField(rfIn), parseIntField(rfMid), parseIntField(rfOut)),
|
||||||
|
Corner.LR to HeatReading(parseIntField(lrIn), parseIntField(lrMid), parseIntField(lrOut)),
|
||||||
|
Corner.RR to HeatReading(parseIntField(rrIn), parseIntField(rrMid), parseIntField(rrOut)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun hasContent(): Boolean {
|
||||||
|
val sheet = toSheet()
|
||||||
|
if (sheet.cold.any() || sheet.hot.any()) return true
|
||||||
|
if (sheet.heat.values.any { it.any() }) return true
|
||||||
|
if (parseIntField(laps) != null || parseIntField(finish) != null) return true
|
||||||
|
if (parseDecimal(stagger) != null) return true
|
||||||
|
if (tireSet.isNotBlank() || notes.isNotBlank()) return true
|
||||||
|
return handling != null || handlingWhere != null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toRunLog(nightId: Long, id: Long = 0, recordedAt: Long = System.currentTimeMillis()): RunLog {
|
||||||
|
val sheet = toSheet()
|
||||||
|
return RunLog(
|
||||||
|
id = id,
|
||||||
|
nightId = nightId,
|
||||||
|
session = session.label,
|
||||||
|
tireSet = tireSet.trim(),
|
||||||
|
laps = parseIntField(laps),
|
||||||
|
finish = parseIntField(finish),
|
||||||
|
staggerInches = parseDecimal(stagger),
|
||||||
|
coldLf = sheet.cold.lf,
|
||||||
|
coldRf = sheet.cold.rf,
|
||||||
|
coldLr = sheet.cold.lr,
|
||||||
|
coldRr = sheet.cold.rr,
|
||||||
|
hotLf = sheet.hot.lf,
|
||||||
|
hotRf = sheet.hot.rf,
|
||||||
|
hotLr = sheet.hot.lr,
|
||||||
|
hotRr = sheet.hot.rr,
|
||||||
|
lfInside = sheet.heatOf(Corner.LF).inside,
|
||||||
|
lfMiddle = sheet.heatOf(Corner.LF).middle,
|
||||||
|
lfOutside = sheet.heatOf(Corner.LF).outside,
|
||||||
|
rfInside = sheet.heatOf(Corner.RF).inside,
|
||||||
|
rfMiddle = sheet.heatOf(Corner.RF).middle,
|
||||||
|
rfOutside = sheet.heatOf(Corner.RF).outside,
|
||||||
|
lrInside = sheet.heatOf(Corner.LR).inside,
|
||||||
|
lrMiddle = sheet.heatOf(Corner.LR).middle,
|
||||||
|
lrOutside = sheet.heatOf(Corner.LR).outside,
|
||||||
|
rrInside = sheet.heatOf(Corner.RR).inside,
|
||||||
|
rrMiddle = sheet.heatOf(Corner.RR).middle,
|
||||||
|
rrOutside = sheet.heatOf(Corner.RR).outside,
|
||||||
|
handling = handling?.label.orEmpty(),
|
||||||
|
handlingWhere = handlingWhere?.label.orEmpty(),
|
||||||
|
notes = notes.trim(),
|
||||||
|
recordedAt = recordedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(run: RunLog) = RunForm(
|
||||||
|
session = SessionKind.fromLabel(run.session),
|
||||||
|
tireSet = run.tireSet,
|
||||||
|
laps = intField(run.laps),
|
||||||
|
finish = intField(run.finish),
|
||||||
|
stagger = staggerField(run.staggerInches),
|
||||||
|
coldLf = psiField(run.coldLf),
|
||||||
|
coldRf = psiField(run.coldRf),
|
||||||
|
coldLr = psiField(run.coldLr),
|
||||||
|
coldRr = psiField(run.coldRr),
|
||||||
|
hotLf = psiField(run.hotLf),
|
||||||
|
hotRf = psiField(run.hotRf),
|
||||||
|
hotLr = psiField(run.hotLr),
|
||||||
|
hotRr = psiField(run.hotRr),
|
||||||
|
lfIn = intField(run.lfInside),
|
||||||
|
lfMid = intField(run.lfMiddle),
|
||||||
|
lfOut = intField(run.lfOutside),
|
||||||
|
rfIn = intField(run.rfInside),
|
||||||
|
rfMid = intField(run.rfMiddle),
|
||||||
|
rfOut = intField(run.rfOutside),
|
||||||
|
lrIn = intField(run.lrInside),
|
||||||
|
lrMid = intField(run.lrMiddle),
|
||||||
|
lrOut = intField(run.lrOutside),
|
||||||
|
rrIn = intField(run.rrInside),
|
||||||
|
rrMid = intField(run.rrMiddle),
|
||||||
|
rrOut = intField(run.rrOutside),
|
||||||
|
handling = Handling.fromLabel(run.handling),
|
||||||
|
handlingWhere = HandlingSpot.fromLabel(run.handlingWhere),
|
||||||
|
notes = run.notes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Tread.pick(inside: String, middle: String, outside: String): String = when (this) {
|
||||||
|
Tread.Inside -> inside
|
||||||
|
Tread.Middle -> middle
|
||||||
|
Tread.Outside -> outside
|
||||||
|
}
|
||||||
|
|
||||||
|
fun RunLog.toSheet(): TireSheet = TireSheet(
|
||||||
|
cold = Pressures(coldLf, coldRf, coldLr, coldRr),
|
||||||
|
hot = Pressures(hotLf, hotRf, hotLr, hotRr),
|
||||||
|
heat = mapOf(
|
||||||
|
Corner.LF to HeatReading(lfInside, lfMiddle, lfOutside),
|
||||||
|
Corner.RF to HeatReading(rfInside, rfMiddle, rfOutside),
|
||||||
|
Corner.LR to HeatReading(lrInside, lrMiddle, lrOutside),
|
||||||
|
Corner.RR to HeatReading(rrInside, rrMiddle, rrOutside),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun RunLog.hasColdPressure(): Boolean = listOf(coldLf, coldRf, coldLr, coldRr).any { it != null }
|
||||||
|
|
||||||
|
fun handlingText(run: RunLog): String = when {
|
||||||
|
run.handling.isNotBlank() && run.handlingWhere.isNotBlank() ->
|
||||||
|
"${run.handling} on ${run.handlingWhere.lowercase()}"
|
||||||
|
run.handling.isNotBlank() -> run.handling
|
||||||
|
run.handlingWhere.isNotBlank() -> run.handlingWhere
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.racingapp.pitlog.model
|
||||||
|
|
||||||
|
enum class Corner(val short: String, val label: String) {
|
||||||
|
LF("LF", "Left front"),
|
||||||
|
RF("RF", "Right front"),
|
||||||
|
LR("LR", "Left rear"),
|
||||||
|
RR("RR", "Right rear"),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Tread(val label: String) {
|
||||||
|
Inside("Inside"),
|
||||||
|
Middle("Middle"),
|
||||||
|
Outside("Outside"),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SessionKind(val label: String) {
|
||||||
|
Practice("Practice"),
|
||||||
|
HotLaps("Hot laps"),
|
||||||
|
Qualifying("Qualifying"),
|
||||||
|
Heat("Heat"),
|
||||||
|
BMain("B-main"),
|
||||||
|
Feature("Feature"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromLabel(value: String): SessionKind =
|
||||||
|
entries.firstOrNull { it.label.equals(value, ignoreCase = true) } ?: Practice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Handling(val label: String) {
|
||||||
|
Tight("Tight"),
|
||||||
|
Neutral("Neutral"),
|
||||||
|
Loose("Loose"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromLabel(value: String): Handling? =
|
||||||
|
entries.firstOrNull { it.label.equals(value, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class HandlingSpot(val label: String) {
|
||||||
|
Entry("Entry"),
|
||||||
|
Center("Center"),
|
||||||
|
Exit("Exit"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromLabel(value: String): HandlingSpot? =
|
||||||
|
entries.firstOrNull { it.label.equals(value, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.racingapp.pitlog.model
|
||||||
|
|
||||||
|
import com.racingapp.pitlog.data.SetupSheet
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
fun setupLines(sheet: SetupSheet): List<String> = buildList {
|
||||||
|
sheet.crossPercent?.let { add("Cross ${percent(it)}") }
|
||||||
|
val springs = listOf(
|
||||||
|
"LF" to sheet.springLf,
|
||||||
|
"RF" to sheet.springRf,
|
||||||
|
"LR" to sheet.springLr,
|
||||||
|
"RR" to sheet.springRr,
|
||||||
|
).mapNotNull { (corner, rate) -> rate?.let { "$corner $it" } }
|
||||||
|
if (springs.isNotEmpty()) add("Springs ${springs.joinToString(" ")} lb")
|
||||||
|
sheet.blockLr?.let { add("LR block ${inches(it)} in") }
|
||||||
|
sheet.blockRr?.let { add("RR block ${inches(it)} in") }
|
||||||
|
sheet.swayBarHole?.let { add("Sway bar Setting $it") }
|
||||||
|
sheet.camberLf?.let { add("LF camber ${formatDegrees(it)}") }
|
||||||
|
sheet.camberRf?.let { add("RF camber ${formatDegrees(it)}") }
|
||||||
|
sheet.casterLf?.let { add("LF caster ${formatDegrees(it)}") }
|
||||||
|
sheet.casterRf?.let { add("RF caster ${formatDegrees(it)}") }
|
||||||
|
sheet.toeFront?.let { add("Front toe ${inches(it)} in out") }
|
||||||
|
sheet.toeRear?.let { add("Rear toe ${inches(it)} in") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setupDiffs(before: SetupSheet, after: SetupSheet): List<String> = buildList {
|
||||||
|
changed(before.crossPercent, after.crossPercent, ::percent)?.let { add("Cross $it") }
|
||||||
|
changedInt(before.springLf, after.springLf, " lb")?.let { add("LF spring $it") }
|
||||||
|
changedInt(before.springRf, after.springRf, " lb")?.let { add("RF spring $it") }
|
||||||
|
changedInt(before.springLr, after.springLr, " lb")?.let { add("LR spring $it") }
|
||||||
|
changedInt(before.springRr, after.springRr, " lb")?.let { add("RR spring $it") }
|
||||||
|
changed(before.blockLr, after.blockLr) { "${inches(it)} in" }?.let { add("LR block $it") }
|
||||||
|
changed(before.blockRr, after.blockRr) { "${inches(it)} in" }?.let { add("RR block $it") }
|
||||||
|
changedInt(before.swayBarHole, after.swayBarHole, "") { "Setting $it" }?.let { add("Sway bar $it") }
|
||||||
|
changed(before.camberLf, after.camberLf, ::formatDegrees)?.let { add("LF camber $it") }
|
||||||
|
changed(before.camberRf, after.camberRf, ::formatDegrees)?.let { add("RF camber $it") }
|
||||||
|
changed(before.casterLf, after.casterLf, ::formatDegrees)?.let { add("LF 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.toeRear, after.toeRear) { "${inches(it)} in" }?.let { add("Rear toe $it") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun percent(value: Double): String = String.format(Locale.US, "%.1f%%", value)
|
||||||
|
|
||||||
|
private fun inches(value: Double): String =
|
||||||
|
String.format(Locale.US, "%.3f", value).trimEnd('0').trimEnd('.')
|
||||||
|
|
||||||
|
private fun changed(
|
||||||
|
before: Double?,
|
||||||
|
after: Double?,
|
||||||
|
format: (Double) -> String,
|
||||||
|
): String? {
|
||||||
|
if (same(before, after)) return null
|
||||||
|
return "${show(before, format)} → ${show(after, format)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun changedInt(
|
||||||
|
before: Int?,
|
||||||
|
after: Int?,
|
||||||
|
suffix: String,
|
||||||
|
format: (Int) -> String = { "$it$suffix" },
|
||||||
|
): String? {
|
||||||
|
if (before == after) return null
|
||||||
|
val left = before?.let(format) ?: "—"
|
||||||
|
val right = after?.let(format) ?: "—"
|
||||||
|
return "$left → $right"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun show(value: Double?, format: (Double) -> String): String = value?.let(format) ?: "—"
|
||||||
|
|
||||||
|
private fun same(before: Double?, after: Double?): Boolean {
|
||||||
|
if (before == null || after == null) return before == null && after == null
|
||||||
|
return String.format(Locale.US, "%.3f", before) == String.format(Locale.US, "%.3f", after)
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
package com.racingapp.pitlog.model
|
||||||
|
|
||||||
|
import com.racingapp.pitlog.data.RaceNight
|
||||||
|
|
||||||
|
data class SetupTip(
|
||||||
|
val about: String,
|
||||||
|
val say: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val shoulderGap = 5
|
||||||
|
private const val camberLimit = 10
|
||||||
|
|
||||||
|
fun setupTips(sheet: TireSheet, handling: String = "", handlingWhere: String = ""): List<SetupTip> {
|
||||||
|
val tips = mutableListOf<SetupTip>()
|
||||||
|
for (corner in Corner.entries) {
|
||||||
|
val heat = sheet.heatOf(corner)
|
||||||
|
pressureTip(corner, heat)?.let(tips::add)
|
||||||
|
camberTip(corner, heat)?.let(tips::add)
|
||||||
|
}
|
||||||
|
coolestLeftRear(sheet, handling)?.let(tips::add)
|
||||||
|
feelTip(handling, handlingWhere)?.let(tips::add)
|
||||||
|
return tips
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pressureTip(corner: Corner, heat: HeatReading): SetupTip? {
|
||||||
|
val inside = heat.inside ?: return null
|
||||||
|
val middle = heat.middle ?: return null
|
||||||
|
val outside = heat.outside ?: return null
|
||||||
|
return when {
|
||||||
|
middle >= inside + shoulderGap && middle >= outside + shoulderGap ->
|
||||||
|
SetupTip(corner.label, "Middle is the hottest. Pressure is high. Lower it.")
|
||||||
|
inside >= middle + shoulderGap && outside >= middle + shoulderGap ->
|
||||||
|
SetupTip(corner.label, "Both edges are hotter than the middle. Pressure is low. Add air.")
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun camberTip(corner: Corner, heat: HeatReading): SetupTip? {
|
||||||
|
val inside = heat.inside ?: return null
|
||||||
|
val outside = heat.outside ?: return null
|
||||||
|
val insideHotterBy = inside - outside
|
||||||
|
val rightSide = corner == Corner.RF || corner == Corner.RR
|
||||||
|
return if (rightSide) {
|
||||||
|
when {
|
||||||
|
insideHotterBy > camberLimit ->
|
||||||
|
SetupTip(
|
||||||
|
corner.label,
|
||||||
|
"Inside is $insideHotterBy° hotter than outside. Too much negative camber. Take some out.",
|
||||||
|
)
|
||||||
|
insideHotterBy < 0 ->
|
||||||
|
SetupTip(
|
||||||
|
corner.label,
|
||||||
|
"Outside is ${-insideHotterBy}° hotter than inside. Need more negative camber.",
|
||||||
|
)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val outsideHotterBy = -insideHotterBy
|
||||||
|
when {
|
||||||
|
outsideHotterBy > camberLimit ->
|
||||||
|
SetupTip(
|
||||||
|
corner.label,
|
||||||
|
"Outside is $outsideHotterBy° hotter than inside. Take positive camber out.",
|
||||||
|
)
|
||||||
|
outsideHotterBy < 0 ->
|
||||||
|
SetupTip(
|
||||||
|
corner.label,
|
||||||
|
"Inside is ${-outsideHotterBy}° hotter than outside. Add positive camber.",
|
||||||
|
)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun coolestLeftRear(sheet: TireSheet, handling: String): SetupTip? {
|
||||||
|
if (!handling.equals(Handling.Tight.label, ignoreCase = true)) return null
|
||||||
|
val averages = Corner.entries.mapNotNull { corner ->
|
||||||
|
sheet.heatOf(corner).average?.let { corner to it }
|
||||||
|
}
|
||||||
|
if (averages.size < Corner.entries.size) return null
|
||||||
|
val coolest = averages.minBy { it.second }
|
||||||
|
val tied = averages.count { it.second == coolest.second } > 1
|
||||||
|
if (tied || coolest.first != Corner.LR) return null
|
||||||
|
return SetupTip(
|
||||||
|
"Left rear",
|
||||||
|
"Coolest tire, and the car was tight. The right-rear spring may be too stiff.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun feelTip(handling: String, handlingWhere: String): SetupTip? {
|
||||||
|
val feel = Handling.fromLabel(handling) ?: return null
|
||||||
|
if (feel == Handling.Neutral) return null
|
||||||
|
val where = HandlingSpot.fromLabel(handlingWhere) ?: return null
|
||||||
|
val say = when (where) {
|
||||||
|
HandlingSpot.Entry -> if (feel == Handling.Tight) {
|
||||||
|
"The right-front may be losing camber as the nose dives."
|
||||||
|
} else {
|
||||||
|
"Move brake bias forward. Too much rear brake loosens the car on entry."
|
||||||
|
}
|
||||||
|
HandlingSpot.Center -> if (feel == Handling.Tight) {
|
||||||
|
"Take crossweight out."
|
||||||
|
} else {
|
||||||
|
"Add crossweight. Do not add stagger to make the car turn."
|
||||||
|
}
|
||||||
|
HandlingSpot.Exit -> if (feel == Handling.Tight) {
|
||||||
|
"Too little stagger for this track leaves the car tight off the corner."
|
||||||
|
} else {
|
||||||
|
"Extra stagger makes the car loose on power. A softer right-rear adds bite only as the car squats."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SetupTip("${feel.label} on ${where.label.lowercase()}", say)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ChassisSetup(
|
||||||
|
val crossPercent: Double? = null,
|
||||||
|
val springLf: Int? = null,
|
||||||
|
val springRf: Int? = null,
|
||||||
|
val springLr: Int? = null,
|
||||||
|
val springRr: Int? = null,
|
||||||
|
val camberLf: Double? = null,
|
||||||
|
val camberRf: Double? = null,
|
||||||
|
val casterLf: Double? = null,
|
||||||
|
val casterRf: Double? = null,
|
||||||
|
val swayBarHole: Int? = null,
|
||||||
|
val blockLr: Double? = null,
|
||||||
|
val blockRr: Double? = null,
|
||||||
|
) {
|
||||||
|
fun any(): Boolean = listOf(
|
||||||
|
crossPercent,
|
||||||
|
springLf,
|
||||||
|
springRf,
|
||||||
|
springLr,
|
||||||
|
springRr,
|
||||||
|
camberLf,
|
||||||
|
camberRf,
|
||||||
|
casterLf,
|
||||||
|
casterRf,
|
||||||
|
swayBarHole,
|
||||||
|
blockLr,
|
||||||
|
blockRr,
|
||||||
|
).any { it != null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun RaceNight.chassisSetup() = ChassisSetup(
|
||||||
|
crossPercent = crossPercent,
|
||||||
|
springLf = springLf,
|
||||||
|
springRf = springRf,
|
||||||
|
springLr = springLr,
|
||||||
|
springRr = springRr,
|
||||||
|
camberLf = camberLf,
|
||||||
|
camberRf = camberRf,
|
||||||
|
casterLf = casterLf,
|
||||||
|
casterRf = casterRf,
|
||||||
|
swayBarHole = swayBarHole,
|
||||||
|
blockLr = blockLr,
|
||||||
|
blockRr = blockRr,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun chassisTips(
|
||||||
|
setup: ChassisSetup,
|
||||||
|
handling: String = "",
|
||||||
|
sheet: TireSheet? = null,
|
||||||
|
handlingWhere: String = "",
|
||||||
|
): List<SetupTip> {
|
||||||
|
if (!setup.any()) return emptyList()
|
||||||
|
val tips = mutableListOf<SetupTip>()
|
||||||
|
crossTip(setup.crossPercent, handling)?.let(tips::add)
|
||||||
|
frontSplitTip(setup.springLf, setup.springRf)?.let(tips::add)
|
||||||
|
rearSpringTip(setup.springLr, setup.springRr, handling)?.let(tips::add)
|
||||||
|
leftCamberTip(setup.camberLf, sheet)?.let(tips::add)
|
||||||
|
rightCamberTip(setup.camberRf, sheet)?.let(tips::add)
|
||||||
|
casterTip(setup.casterLf, setup.casterRf)?.let(tips::add)
|
||||||
|
swayBarTip(setup.swayBarHole, handling)?.let(tips::add)
|
||||||
|
blockTip(setup.blockLr, setup.blockRr, handling, handlingWhere)?.let(tips::add)
|
||||||
|
return tips
|
||||||
|
}
|
||||||
|
|
||||||
|
fun combineTips(fromTheRun: List<SetupTip>, fromTheSetup: List<SetupTip>): List<SetupTip> {
|
||||||
|
val setupTalksCross = fromTheSetup.any { it.about == "Crossweight" }
|
||||||
|
val runTips = if (setupTalksCross) {
|
||||||
|
fromTheRun.filterNot {
|
||||||
|
it.say.startsWith("Take crossweight") || it.say.startsWith("Add crossweight")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fromTheRun
|
||||||
|
}
|
||||||
|
return fromTheSetup + runTips
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun crossTip(cross: Double?, handling: String): SetupTip? {
|
||||||
|
if (cross == null) return null
|
||||||
|
val shown = String.format(java.util.Locale.US, "%.1f", cross)
|
||||||
|
val tight = handling.equals(Handling.Tight.label, ignoreCase = true)
|
||||||
|
val loose = handling.equals(Handling.Loose.label, ignoreCase = true)
|
||||||
|
return when {
|
||||||
|
cross > 55.0 && tight ->
|
||||||
|
SetupTip("Crossweight", "Cross is $shown% and the car was tight. Take crossweight out.")
|
||||||
|
cross < 52.0 && loose ->
|
||||||
|
SetupTip("Crossweight", "Cross is $shown% and the car was loose. Add crossweight.")
|
||||||
|
cross < 52.0 && tight ->
|
||||||
|
SetupTip("Crossweight", "Cross is already $shown%, under the 52–55% start. Don't take more out.")
|
||||||
|
cross > 55.0 && loose ->
|
||||||
|
SetupTip("Crossweight", "Cross is already $shown%. Don't add more for a loose car.")
|
||||||
|
cross < 52.0 || cross > 55.0 ->
|
||||||
|
SetupTip("Crossweight", "Cross is $shown%. A leaf-spring Camaro often starts at 52–55%.")
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun frontSplitTip(left: Int?, right: Int?): SetupTip? {
|
||||||
|
if (left == null || right == null) return null
|
||||||
|
val split = kotlin.math.abs(right - left)
|
||||||
|
if (split <= 100) return null
|
||||||
|
return SetupTip(
|
||||||
|
"Front springs",
|
||||||
|
"Left front $left lb, right front $right lb. That is a $split lb split. More than about 100 lb was too much on a leaf-spring Camaro.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rearSpringTip(left: Int?, right: Int?, handling: String): SetupTip? {
|
||||||
|
if (left == null || right == null) return null
|
||||||
|
val tight = handling.equals(Handling.Tight.label, ignoreCase = true)
|
||||||
|
val loose = handling.equals(Handling.Loose.label, ignoreCase = true)
|
||||||
|
return when {
|
||||||
|
tight && right >= left + 25 ->
|
||||||
|
SetupTip(
|
||||||
|
"Rear springs",
|
||||||
|
"Right rear is ${right - left} lb stiffer than the left rear, and the car was tight. Ease the right rear.",
|
||||||
|
)
|
||||||
|
loose && left >= right + 25 ->
|
||||||
|
SetupTip(
|
||||||
|
"Rear springs",
|
||||||
|
"Right rear is $right lb and the left rear is $left lb. The softer right rear adds bite on exit and can loosen the car.",
|
||||||
|
)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun leftCamberTip(camber: Double?, sheet: TireSheet?): SetupTip? {
|
||||||
|
if (camber == null || camber >= -0.2) return null
|
||||||
|
if (sheet != null && camberTip(Corner.LF, sheet.heatOf(Corner.LF)) != null) return null
|
||||||
|
return SetupTip(
|
||||||
|
"Left front camber",
|
||||||
|
"Camber is ${formatDegrees(camber)}°. On an oval the left front usually wants positive camber.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rightCamberTip(camber: Double?, sheet: TireSheet?): SetupTip? {
|
||||||
|
if (camber == null) return null
|
||||||
|
if (camber > 0.2) {
|
||||||
|
return SetupTip(
|
||||||
|
"Right front camber",
|
||||||
|
"Camber is ${formatDegrees(camber)}°. On an oval the right front usually needs to be negative.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val heatSaysTooMuch = sheet?.let { camberTip(Corner.RF, it.heatOf(Corner.RF))?.say?.contains("Take some out") } == true
|
||||||
|
return when {
|
||||||
|
camber < -8.0 ->
|
||||||
|
SetupTip(
|
||||||
|
"Right front camber",
|
||||||
|
"Camber is ${formatDegrees(camber)}°, past −8°. That was the deep end of a published street-stock range.",
|
||||||
|
)
|
||||||
|
camber > -6.0 && !heatSaysTooMuch ->
|
||||||
|
SetupTip(
|
||||||
|
"Right front camber",
|
||||||
|
"Camber is ${formatDegrees(camber)}°. A leaf-spring street stock often needed −6° to −8° before the tire temps came in. These hobby stock and thunder car books stop at 2 inches on the right and 1 inch on the left, measured with a square at the top of the wheel. Check the right-front heat before adding more.",
|
||||||
|
)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun casterTip(left: Double?, right: Double?): SetupTip? {
|
||||||
|
if (left == null || right == null || right + 0.2 >= left) return null
|
||||||
|
return SetupTip(
|
||||||
|
"Caster",
|
||||||
|
"Right front is ${formatDegrees(right)}° and left front is ${formatDegrees(left)}°. Oval cars usually want more caster on the right front.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun swayBarTip(turns: Int?, handling: String): SetupTip? {
|
||||||
|
if (turns == null) return null
|
||||||
|
val tight = handling.equals(Handling.Tight.label, ignoreCase = true)
|
||||||
|
val loose = handling.equals(Handling.Loose.label, ignoreCase = true)
|
||||||
|
return when {
|
||||||
|
tight -> SetupTip("Sway bar", "Less bar.")
|
||||||
|
loose -> SetupTip("Sway bar", "More bar.")
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun blockTip(left: Double?, right: Double?, handling: String, where: String): SetupTip? {
|
||||||
|
if (left == null && right == null) return null
|
||||||
|
if (listOfNotNull(left, right).any { it > 3.0 }) {
|
||||||
|
return SetupTip("Blocks", "Over 3 inches cuts forward bite.")
|
||||||
|
}
|
||||||
|
val spot = HandlingSpot.fromLabel(where)
|
||||||
|
val inTheCorner = spot == HandlingSpot.Entry || spot == HandlingSpot.Center
|
||||||
|
if (!inTheCorner) return null
|
||||||
|
val tight = handling.equals(Handling.Tight.label, ignoreCase = true)
|
||||||
|
val loose = handling.equals(Handling.Loose.label, ignoreCase = true)
|
||||||
|
return when {
|
||||||
|
tight -> SetupTip("Blocks", "Less block.")
|
||||||
|
loose -> SetupTip("Blocks", "More block.")
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.racingapp.pitlog.model
|
||||||
|
|
||||||
|
data class Pressures(
|
||||||
|
val lf: Double? = null,
|
||||||
|
val rf: Double? = null,
|
||||||
|
val lr: Double? = null,
|
||||||
|
val rr: Double? = null,
|
||||||
|
) {
|
||||||
|
operator fun get(corner: Corner): Double? = when (corner) {
|
||||||
|
Corner.LF -> lf
|
||||||
|
Corner.RF -> rf
|
||||||
|
Corner.LR -> lr
|
||||||
|
Corner.RR -> rr
|
||||||
|
}
|
||||||
|
|
||||||
|
fun any(): Boolean = listOf(lf, rf, lr, rr).any { it != null }
|
||||||
|
}
|
||||||
|
|
||||||
|
data class HeatReading(
|
||||||
|
val inside: Int? = null,
|
||||||
|
val middle: Int? = null,
|
||||||
|
val outside: Int? = null,
|
||||||
|
) {
|
||||||
|
val average: Double?
|
||||||
|
get() {
|
||||||
|
val values = listOfNotNull(inside, middle, outside)
|
||||||
|
if (values.isEmpty()) return null
|
||||||
|
return values.sum().toDouble() / values.size
|
||||||
|
}
|
||||||
|
|
||||||
|
val spread: Int?
|
||||||
|
get() = if (inside != null && outside != null) outside - inside else null
|
||||||
|
|
||||||
|
fun any(): Boolean = inside != null || middle != null || outside != null
|
||||||
|
}
|
||||||
|
|
||||||
|
data class TireSheet(
|
||||||
|
val cold: Pressures = Pressures(),
|
||||||
|
val hot: Pressures = Pressures(),
|
||||||
|
val heat: Map<Corner, HeatReading> = emptyMap(),
|
||||||
|
) {
|
||||||
|
fun build(corner: Corner): Double? {
|
||||||
|
val before = cold[corner] ?: return null
|
||||||
|
val after = hot[corner] ?: return null
|
||||||
|
return after - before
|
||||||
|
}
|
||||||
|
|
||||||
|
fun heatOf(corner: Corner): HeatReading = heat[corner] ?: HeatReading()
|
||||||
|
|
||||||
|
fun hottest(): Pair<Corner, Double>? =
|
||||||
|
Corner.entries.mapNotNull { corner ->
|
||||||
|
heatOf(corner).average?.let { corner to it }
|
||||||
|
}.maxByOrNull { it.second }
|
||||||
|
|
||||||
|
fun biggestBuild(): Pair<Corner, Double>? =
|
||||||
|
Corner.entries.mapNotNull { corner ->
|
||||||
|
build(corner)?.let { corner to it }
|
||||||
|
}.maxByOrNull { it.second }
|
||||||
|
|
||||||
|
fun rearHotSplit(): Double? {
|
||||||
|
val left = hot[Corner.LR] ?: return null
|
||||||
|
val right = hot[Corner.RR] ?: return null
|
||||||
|
return right - left
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun suspiciousReadings(sheet: TireSheet): List<String> {
|
||||||
|
val messages = mutableListOf<String>()
|
||||||
|
for (corner in Corner.entries) {
|
||||||
|
val cold = sheet.cold[corner]
|
||||||
|
val hot = sheet.hot[corner]
|
||||||
|
if (cold != null && (cold < 4.0 || cold > 50.0)) {
|
||||||
|
messages += "${corner.short} cold pressure ${formatPsi(cold)} psi looks off"
|
||||||
|
}
|
||||||
|
if (hot != null && (hot < 4.0 || hot > 50.0)) {
|
||||||
|
messages += "${corner.short} hot pressure ${formatPsi(hot)} psi looks off"
|
||||||
|
}
|
||||||
|
val reading = sheet.heatOf(corner)
|
||||||
|
checkTemp(messages, corner, "inside", reading.inside)
|
||||||
|
checkTemp(messages, corner, "middle", reading.middle)
|
||||||
|
checkTemp(messages, corner, "outside", reading.outside)
|
||||||
|
}
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkTemp(messages: MutableList<String>, corner: Corner, edge: String, temp: Int?) {
|
||||||
|
if (temp != null && (temp < 50 || temp > 320)) {
|
||||||
|
messages += "${corner.short} $edge temp $temp°F looks off"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package com.racingapp.pitlog.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.ime
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.union
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import com.racingapp.pitlog.model.Corner
|
||||||
|
import com.racingapp.pitlog.model.TireSheet
|
||||||
|
import com.racingapp.pitlog.model.formatTemp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PitScroll(
|
||||||
|
padding: PaddingValues,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 640.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PitActionButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.background) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.windowInsetsPadding(WindowInsets.navigationBars.union(WindowInsets.ime))
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Text(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SectionLabel(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(top = 20.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NumberField(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
onValue: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
decimal: Boolean = true,
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValue,
|
||||||
|
label = { Text(label) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = if (decimal) KeyboardType.Decimal else KeyboardType.Number,
|
||||||
|
),
|
||||||
|
textStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WordsField(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
onValue: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
placeholder: String = "",
|
||||||
|
isError: Boolean = false,
|
||||||
|
supportingText: String? = null,
|
||||||
|
capitalization: KeyboardCapitalization = KeyboardCapitalization.Words,
|
||||||
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValue,
|
||||||
|
label = { Text(label) },
|
||||||
|
placeholder = if (placeholder.isEmpty()) null else ({ Text(placeholder) }),
|
||||||
|
singleLine = true,
|
||||||
|
isError = isError,
|
||||||
|
supportingText = supportingText?.let { message -> { Text(message) } },
|
||||||
|
keyboardOptions = KeyboardOptions(capitalization = capitalization, keyboardType = keyboardType),
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TireHeatLines(sheet: TireSheet) {
|
||||||
|
val corners = Corner.entries.filter { sheet.heatOf(it).any() }
|
||||||
|
if (corners.isEmpty()) return
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth()) {
|
||||||
|
Text(text = "", modifier = Modifier.weight(0.7f))
|
||||||
|
HeatCell("Inside", heading = true)
|
||||||
|
HeatCell("Middle", heading = true)
|
||||||
|
HeatCell("Outside", heading = true)
|
||||||
|
}
|
||||||
|
corners.forEach { corner ->
|
||||||
|
val heat = sheet.heatOf(corner)
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
corner.short,
|
||||||
|
modifier = Modifier.weight(0.7f),
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
HeatCell(formatTemp(heat.inside))
|
||||||
|
HeatCell(formatTemp(heat.middle))
|
||||||
|
HeatCell(formatTemp(heat.outside))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.HeatCell(value: String, heading: Boolean = false) {
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
style = if (heading) MaterialTheme.typography.labelMedium else MaterialTheme.typography.titleMedium,
|
||||||
|
color = if (heading) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FieldRow(content: @Composable RowScope.() -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.racingapp.pitlog.ui
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
private val longDate: DateTimeFormatter =
|
||||||
|
DateTimeFormatter.ofPattern("EEE, MMM d, yyyy", Locale.US)
|
||||||
|
|
||||||
|
private val clock: DateTimeFormatter =
|
||||||
|
DateTimeFormatter.ofPattern("h:mm a", Locale.US)
|
||||||
|
|
||||||
|
fun formatLongDate(epochDay: Long): String =
|
||||||
|
LocalDate.ofEpochDay(epochDay).format(longDate)
|
||||||
|
|
||||||
|
fun formatClock(epochMillis: Long): String =
|
||||||
|
Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault()).format(clock)
|
||||||
|
|
||||||
|
fun LocalDate.toPickerMillis(): Long =
|
||||||
|
atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
|
||||||
|
fun pickerMillisToDate(millis: Long): LocalDate =
|
||||||
|
Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package com.racingapp.pitlog.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import com.racingapp.pitlog.ui.guide.SetupGuideScreen
|
||||||
|
import com.racingapp.pitlog.ui.home.HomeScreen
|
||||||
|
import com.racingapp.pitlog.ui.night.NightDetailScreen
|
||||||
|
import com.racingapp.pitlog.ui.night.NightEditorScreen
|
||||||
|
import com.racingapp.pitlog.ui.night.SetupEditorScreen
|
||||||
|
import com.racingapp.pitlog.ui.run.RunDetailScreen
|
||||||
|
import com.racingapp.pitlog.ui.run.RunEditorScreen
|
||||||
|
import com.racingapp.pitlog.ui.settings.SettingsScreen
|
||||||
|
import com.racingapp.pitlog.ui.setup.CarSetupEditorScreen
|
||||||
|
import com.racingapp.pitlog.ui.setup.CarSetupScreen
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PitNav() {
|
||||||
|
val nav = rememberNavController()
|
||||||
|
val context = LocalContext.current
|
||||||
|
NavHost(navController = nav, startDestination = "home") {
|
||||||
|
composable("home") {
|
||||||
|
HomeScreen(
|
||||||
|
onNewNight = { nav.navigate("editor/night") },
|
||||||
|
onOpenNight = { id -> nav.navigate("night/$id") },
|
||||||
|
onLogRun = { id -> nav.navigate("editor/run/$id") },
|
||||||
|
onSettings = { nav.navigate("settings") },
|
||||||
|
onGuide = { nav.navigate("guide") },
|
||||||
|
onCarSetup = { nav.navigate("car-setup") },
|
||||||
|
onExport = { name, csv -> shareCsv(context, name, csv) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable("settings") {
|
||||||
|
SettingsScreen(onBack = { nav.popBackStack() })
|
||||||
|
}
|
||||||
|
composable("guide") {
|
||||||
|
SetupGuideScreen(onBack = { nav.popBackStack() })
|
||||||
|
}
|
||||||
|
composable("car-setup") {
|
||||||
|
CarSetupScreen(
|
||||||
|
onBack = { nav.popBackStack() },
|
||||||
|
onEditOriginal = { nav.navigate("car-setup/original") },
|
||||||
|
onLogChange = { nav.navigate("car-setup/change") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable("car-setup/original") {
|
||||||
|
CarSetupEditorScreen(loggingChange = false, onBack = { nav.popBackStack() })
|
||||||
|
}
|
||||||
|
composable("car-setup/change") {
|
||||||
|
CarSetupEditorScreen(loggingChange = true, onBack = { nav.popBackStack() })
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = "setup/{nightId}",
|
||||||
|
arguments = listOf(navArgument("nightId") { type = NavType.LongType }),
|
||||||
|
) { entry ->
|
||||||
|
val nightId = entry.arguments?.getLong("nightId") ?: return@composable
|
||||||
|
SetupEditorScreen(nightId = nightId, onBack = { nav.popBackStack() })
|
||||||
|
}
|
||||||
|
composable("editor/night") {
|
||||||
|
NightEditorScreen(
|
||||||
|
nightId = null,
|
||||||
|
onBack = { nav.popBackStack() },
|
||||||
|
onSaved = { id ->
|
||||||
|
nav.navigate("night/$id") {
|
||||||
|
popUpTo("editor/night") { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = "editor/night/{nightId}",
|
||||||
|
arguments = listOf(navArgument("nightId") { type = NavType.LongType }),
|
||||||
|
) { entry ->
|
||||||
|
NightEditorScreen(
|
||||||
|
nightId = entry.arguments?.getLong("nightId"),
|
||||||
|
onBack = { nav.popBackStack() },
|
||||||
|
onSaved = { nav.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = "night/{nightId}",
|
||||||
|
arguments = listOf(navArgument("nightId") { type = NavType.LongType }),
|
||||||
|
) { entry ->
|
||||||
|
val nightId = entry.arguments?.getLong("nightId") ?: return@composable
|
||||||
|
NightDetailScreen(
|
||||||
|
nightId = nightId,
|
||||||
|
onBack = { nav.popBackStack() },
|
||||||
|
onEdit = { nav.navigate("editor/night/$nightId") },
|
||||||
|
onLogRun = { nav.navigate("editor/run/$nightId") },
|
||||||
|
onOpenRun = { runId -> nav.navigate("run/$nightId/$runId") },
|
||||||
|
onDeleted = {
|
||||||
|
nav.popBackStack("home", inclusive = false)
|
||||||
|
},
|
||||||
|
onExport = { name, csv -> shareCsv(context, name, csv) },
|
||||||
|
onSetup = { nav.navigate("setup/$nightId") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = "editor/run/{nightId}",
|
||||||
|
arguments = listOf(navArgument("nightId") { type = NavType.LongType }),
|
||||||
|
) { entry ->
|
||||||
|
val nightId = entry.arguments?.getLong("nightId") ?: return@composable
|
||||||
|
RunEditorScreen(
|
||||||
|
nightId = nightId,
|
||||||
|
runId = null,
|
||||||
|
onBack = { nav.popBackStack() },
|
||||||
|
onSaved = { runId ->
|
||||||
|
nav.navigate("run/$nightId/$runId") {
|
||||||
|
popUpTo("editor/run/$nightId") { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = "editor/run/{nightId}/{runId}",
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("nightId") { type = NavType.LongType },
|
||||||
|
navArgument("runId") { type = NavType.LongType },
|
||||||
|
),
|
||||||
|
) { entry ->
|
||||||
|
val nightId = entry.arguments?.getLong("nightId") ?: return@composable
|
||||||
|
val runId = entry.arguments?.getLong("runId") ?: return@composable
|
||||||
|
RunEditorScreen(
|
||||||
|
nightId = nightId,
|
||||||
|
runId = runId,
|
||||||
|
onBack = { nav.popBackStack() },
|
||||||
|
onSaved = { nav.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = "run/{nightId}/{runId}",
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("nightId") { type = NavType.LongType },
|
||||||
|
navArgument("runId") { type = NavType.LongType },
|
||||||
|
),
|
||||||
|
) { entry ->
|
||||||
|
val nightId = entry.arguments?.getLong("nightId") ?: return@composable
|
||||||
|
val runId = entry.arguments?.getLong("runId") ?: return@composable
|
||||||
|
RunDetailScreen(
|
||||||
|
nightId = nightId,
|
||||||
|
runId = runId,
|
||||||
|
onBack = { nav.popBackStack() },
|
||||||
|
onEdit = { nav.navigate("editor/run/$nightId/$runId") },
|
||||||
|
onGuide = { nav.navigate("guide") },
|
||||||
|
onDeleted = { nav.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.racingapp.pitlog.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewmodel.CreationExtras
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.racingapp.pitlog.data.Repository
|
||||||
|
|
||||||
|
val LocalRepository = staticCompositionLocalOf<Repository> {
|
||||||
|
error("Repository is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
inline fun <reified VM : ViewModel> pitViewModel(
|
||||||
|
crossinline factory: (Repository) -> VM,
|
||||||
|
): VM {
|
||||||
|
val repository = LocalRepository.current
|
||||||
|
return viewModel(
|
||||||
|
factory = object : ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return factory(repository) as T
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.racingapp.pitlog.ui
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
fun shareText(context: Context, text: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val send = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "text/plain"
|
||||||
|
putExtra(Intent.EXTRA_TEXT, text)
|
||||||
|
}
|
||||||
|
context.startActivity(Intent.createChooser(send, "Share car token"))
|
||||||
|
true
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shareCsv(context: Context, fileName: String, csv: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val dir = File(context.cacheDir, "exports").apply { mkdirs() }
|
||||||
|
val file = File(dir, fileName)
|
||||||
|
file.writeText(csv, Charsets.UTF_8)
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.fileprovider",
|
||||||
|
file,
|
||||||
|
)
|
||||||
|
val send = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "text/csv"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, fileName)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
clipData = ClipData.newRawUri(fileName, uri)
|
||||||
|
}
|
||||||
|
context.startActivity(Intent.createChooser(send, "Export tire log"))
|
||||||
|
true
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package com.racingapp.pitlog.ui.guide
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
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.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.racingapp.pitlog.ui.PitScroll
|
||||||
|
import com.racingapp.pitlog.ui.SectionLabel
|
||||||
|
import com.racingapp.pitlog.ui.theme.pitTopBarColors
|
||||||
|
|
||||||
|
private data class Tip(val see: String, val doThis: String)
|
||||||
|
|
||||||
|
private data class Move(val change: String, val why: String)
|
||||||
|
|
||||||
|
private data class HandlingCase(val title: String, val moves: List<Move>)
|
||||||
|
|
||||||
|
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("Both edges hottest", "The tire is folding. Pressure is low. Add air, and stay inside the tire maker's minimum."),
|
||||||
|
Tip("Right inside much hotter", "Too much negative camber for this roll. Take some out. On asphalt the right-side inside should be only about 5–10° hotter than the outside."),
|
||||||
|
Tip("Right outside hotter", "Not enough negative camber. The outside shoulder is doing the work. Add negative camber on that tire."),
|
||||||
|
Tip("Left inside hotter", "The left tire is leaned the wrong way for an oval. Add positive camber."),
|
||||||
|
Tip("Left outside much hotter", "More positive camber than the tire wants. Take some out. The left-side outside should be about 5–10° hotter than the inside, not a huge spread."),
|
||||||
|
)
|
||||||
|
|
||||||
|
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("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("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("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("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("8. Brakes", "Keep the entry neutral. Do not use brake bias to hide a tight or loose car."),
|
||||||
|
Tip("9. Aero last", "On a short track, aero is the last gain, and not worth extra drag."),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val handling = listOf(
|
||||||
|
HandlingCase(
|
||||||
|
"Middle, tight",
|
||||||
|
listOf(
|
||||||
|
Move("Take crossweight out.", "Once the car is square and the geometry is honest, a tight middle wants less crossweight."),
|
||||||
|
Move("Less block.", "A taller block under the leaves increases roll and tightens the entry and the middle. Over 3 inches cuts forward bite. String the rear after a block change, because the blocks and U-bolts shift."),
|
||||||
|
Move("Let the front roll closer to the rear.", "A tight middle often means the rear wants to roll more than the front. Soften the right-front spring, or stiffen the left-front. A stiffer left-front does not work well above about 10 degrees of banking."),
|
||||||
|
Move("Read the left-rear temperature.", "If the left rear is the coolest tire, the rear spring split is too large and the right-rear spring is too stiff."),
|
||||||
|
Move("Less bar.", "Less bar if the car is tight. On a leaf-spring Camaro a bigger bar faded after a few laps. If more bar only helps a few laps, put it back and stiffen the front springs."),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
HandlingCase(
|
||||||
|
"Middle, loose",
|
||||||
|
listOf(
|
||||||
|
Move("Add crossweight.", "Once alignment and balance are right, a loose middle wants more crossweight."),
|
||||||
|
Move("More block.", "A taller block tightens the entry and the middle. Stay at 3 inches or under, or forward bite falls off."),
|
||||||
|
Move("Ease a right-rear spring that is much stiffer than the left rear.", "If the front is rolling more than the rear, load piles onto the right rear."),
|
||||||
|
Move("More bar.", "More bar if the car is loose. The bar has to move freely in the stock saddles."),
|
||||||
|
Move("Do not add stagger to make it turn.", "Extra stagger used to free a tight middle shows up as loose off the corner under power."),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
HandlingCase(
|
||||||
|
"Entry, tight",
|
||||||
|
listOf(
|
||||||
|
Move("Less block.", "More block tightens the entry as well as the middle."),
|
||||||
|
Move("Check right-front camber through the dive.", "If the nose dives hard, the right front loses camber and the push starts. Once it starts, it is hard to drive out of. The left front also loses camber in the dive. It wants about 1/2–1° positive while it is loaded. The right-front camber change through the dive and the roll should stay near zero."),
|
||||||
|
Move("Do not fix the entry with brake bias alone.", "Compare a light brake with a hard brake. If they feel the same, bias is not the problem."),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
HandlingCase(
|
||||||
|
"Entry, loose",
|
||||||
|
listOf(
|
||||||
|
Move("More block.", "More block tightens the entry. Keep each block at 3 inches or under."),
|
||||||
|
Move("Move brake bias forward.", "Too much rear brake loosens the car on a hard entry. A usual asphalt split is about 60–65% front and 35–40% rear, if the book allows an adjuster. The adjuster has to be under the dash or the hood, out of the driver's reach, on the tri-state book."),
|
||||||
|
Move("Take out rear steer to the right.", "If the right rear is farther back than the left rear as the car rolls in, the rear is steering right and the car gets loose."),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
HandlingCase(
|
||||||
|
"Exit, tight",
|
||||||
|
listOf(
|
||||||
|
Move("Add stagger for the radius, then leave it.", "Too little stagger for the corner leaves the car tight getting off. On a spool both rear tires turn the same number of times, and stagger steers the middle when the axles are locked. Size it for the radius and the banking, then stop using it as a handling knob."),
|
||||||
|
Move("A locked rear and an open rear are different.", "On a spool or a welded rear, both tires turn the same number of times the whole corner, so stagger steers the middle. An open rear lets the tires turn at different speeds, so stagger does not steer the middle the same way. Wenatchee allows an open rear. Stateline is spool or welded. These books do not allow a Detroit Locker. A rear that unlocks in the middle and locks on power would be sized for the wider exit radius, which wants less stagger than a spool."),
|
||||||
|
Move("More banking wants a little less stagger.", "The same corner on a bank wants less stagger than it would on flat ground. Radius is still the big piece. About 10 degrees is roughly 1.5% less. About 20 degrees is roughly 6% less."),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
HandlingCase(
|
||||||
|
"Exit, loose",
|
||||||
|
listOf(
|
||||||
|
Move("Take the extra stagger out.", "Stagger past what the radius needs makes the car loose on power."),
|
||||||
|
Move("A softer right rear adds bite only as the car squats.", "A right rear about 25 lb softer than the left rear can add exit bite. On a leaf car that is shackles and blocks, not a wedge bolt. Too much of that split tightens the car off. If 25 lb is too much, try 15."),
|
||||||
|
Move("A little rear steer to the left on power can add bite.", "Rear steer to the right does the opposite and loosens the car on exit. On a leaf car this is mostly the axle square and the spring wrap."),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SetupGuideScreen(onBack: () -> Unit) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Setup") },
|
||||||
|
colors = pitTopBarColors(),
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
PitScroll(padding) {
|
||||||
|
Text(
|
||||||
|
"Leaf-spring Camaro on asphalt. Hobby stock or thunder car. If the rule book disagrees, follow the rule book. Change one thing, write it in the log, and read the tires before the next guess.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
SectionLabel("Heat")
|
||||||
|
Text(
|
||||||
|
"Probe inside, middle, outside right after a full-speed run, before the car sits. 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.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Column(Modifier.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
heatTips.forEach { tip ->
|
||||||
|
TipLine(tip.see, tip.doThis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SectionLabel("How to tell")
|
||||||
|
Text(
|
||||||
|
"Fix the middle before the entry or the exit. A tight car can feel loose: the driver adds steering, the front bites, and the rear snaps when the throttle comes in. If the wheel is turned farther at speed than on a moderate lap, the car is tight. If it is turned less, it is loose. If you are steering right in the middle, bring it in.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
SectionLabel("What to change")
|
||||||
|
Text(
|
||||||
|
"One change, then another run. Skip a move the rule book does not allow.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
handling.forEach { item ->
|
||||||
|
Text(
|
||||||
|
item.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
|
)
|
||||||
|
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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TipLine(title: String, body: String) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(top = 8.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||||
|
Text(body, color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.racingapp.pitlog.ui.guide
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.racingapp.pitlog.model.SetupTip
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SetupTipsCard(
|
||||||
|
tips: List<SetupTip>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
title: String = "From this run",
|
||||||
|
) {
|
||||||
|
if (tips.isEmpty()) return
|
||||||
|
Card(modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
tips.forEach { tip ->
|
||||||
|
Column {
|
||||||
|
Text(tip.about, style = MaterialTheme.typography.titleSmall)
|
||||||
|
Text(tip.say, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
package com.racingapp.pitlog.ui.home
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.MenuBook
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
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.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
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.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.Repository
|
||||||
|
import com.racingapp.pitlog.model.Corner
|
||||||
|
import com.racingapp.pitlog.model.buildCsv
|
||||||
|
import com.racingapp.pitlog.model.formatPsi
|
||||||
|
import com.racingapp.pitlog.model.handlingText
|
||||||
|
import com.racingapp.pitlog.model.toSheet
|
||||||
|
import com.racingapp.pitlog.ui.TireHeatLines
|
||||||
|
import com.racingapp.pitlog.ui.formatClock
|
||||||
|
import com.racingapp.pitlog.ui.formatLongDate
|
||||||
|
import com.racingapp.pitlog.ui.pitViewModel
|
||||||
|
import com.racingapp.pitlog.ui.theme.pitTopBarColors
|
||||||
|
import java.time.LocalDate
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class HomeState(
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val nights: List<NightWithRuns> = emptyList(),
|
||||||
|
val profile: Car? = null,
|
||||||
|
val cars: List<Car> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
class HomeViewModel(private val repository: Repository) : ViewModel() {
|
||||||
|
val state: StateFlow<HomeState> = combine(
|
||||||
|
repository.observeNightsWithRuns(),
|
||||||
|
repository.observeSelectedCar(),
|
||||||
|
repository.observeCars(),
|
||||||
|
) { nights, profile, cars ->
|
||||||
|
HomeState(
|
||||||
|
loading = false,
|
||||||
|
nights = nights.map { night ->
|
||||||
|
night.copy(runs = night.runs.sortedByDescending { it.recordedAt })
|
||||||
|
},
|
||||||
|
profile = profile,
|
||||||
|
cars = cars,
|
||||||
|
)
|
||||||
|
}.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)
|
||||||
|
@Composable
|
||||||
|
fun HomeScreen(
|
||||||
|
onNewNight: () -> Unit,
|
||||||
|
onOpenNight: (Long) -> Unit,
|
||||||
|
onLogRun: (Long) -> Unit,
|
||||||
|
onSettings: () -> Unit,
|
||||||
|
onGuide: () -> Unit,
|
||||||
|
onCarSetup: () -> Unit,
|
||||||
|
onExport: (String, String) -> Boolean,
|
||||||
|
) {
|
||||||
|
val viewModel = pitViewModel { HomeViewModel(it) }
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val snackbar = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val today = remember { LocalDate.now().toEpochDay() }
|
||||||
|
val tonight = state.nights.firstOrNull { it.night.dateEpochDay == today }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbar) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Pit Log") },
|
||||||
|
colors = pitTopBarColors(),
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onGuide) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.MenuBook, contentDescription = "Setup")
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
val runCount = state.nights.sumOf { it.runs.size }
|
||||||
|
if (runCount == 0) {
|
||||||
|
scope.launch { snackbar.showSnackbar("No runs to export yet") }
|
||||||
|
} else {
|
||||||
|
val shared = onExport("pitlog-all.csv", buildCsv(state.nights))
|
||||||
|
if (!shared) {
|
||||||
|
scope.launch { snackbar.showSnackbar("Nothing on this phone can take the file") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Share, contentDescription = "Export all")
|
||||||
|
}
|
||||||
|
IconButton(onClick = onSettings) {
|
||||||
|
Icon(Icons.Filled.Settings, contentDescription = "Car")
|
||||||
|
}
|
||||||
|
IconButton(onClick = onNewNight) {
|
||||||
|
Icon(Icons.Filled.Add, contentDescription = "New race night")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
if (state.loading) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = padding,
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
HomeHeader(
|
||||||
|
profile = state.profile,
|
||||||
|
cars = state.cars,
|
||||||
|
tonight = tonight,
|
||||||
|
onLogRun = onLogRun,
|
||||||
|
onNewNight = onNewNight,
|
||||||
|
onCarSetup = onCarSetup,
|
||||||
|
onSelectCar = viewModel::selectCar,
|
||||||
|
onAddCar = { viewModel.addCar(onSettings) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.nights.isEmpty()) {
|
||||||
|
item { EmptyNights() }
|
||||||
|
} else {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"Race nights",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 20.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(state.nights, key = { it.night.id }) { night ->
|
||||||
|
NightCard(night, onClick = { onOpenNight(night.night.id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HomeHeader(
|
||||||
|
profile: Car?,
|
||||||
|
cars: List<Car>,
|
||||||
|
tonight: NightWithRuns?,
|
||||||
|
onLogRun: (Long) -> Unit,
|
||||||
|
onNewNight: () -> 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)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
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) {
|
||||||
|
Button(
|
||||||
|
onClick = { onLogRun(tonight.night.id) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 16.dp),
|
||||||
|
) {
|
||||||
|
Text("Log a run at ${tonight.night.trackName}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(
|
||||||
|
onClick = onNewNight,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 16.dp),
|
||||||
|
) {
|
||||||
|
Text("New race night")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onCarSetup,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp),
|
||||||
|
) {
|
||||||
|
Text("Car setup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyNights() {
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
"No race nights yet",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Start one before you unload. After every run, log cold pressure, hot pressure, and inside, middle, and outside heat on all four corners.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NightCard(item: NightWithRuns, onClick: () -> Unit) {
|
||||||
|
val night = item.night
|
||||||
|
val latest = item.runs.firstOrNull()
|
||||||
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(night.trackName, style = MaterialTheme.typography.titleLarge)
|
||||||
|
Text(
|
||||||
|
formatLongDate(night.dateEpochDay),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (item.runs.isEmpty()) "No runs yet" else "${item.runs.size} runs",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
if (latest != null) {
|
||||||
|
val sheet = latest.toSheet()
|
||||||
|
Text(
|
||||||
|
"${latest.session} · ${formatClock(latest.recordedAt)}",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
TireHeatLines(sheet)
|
||||||
|
if (sheet.hot.any()) {
|
||||||
|
Text(
|
||||||
|
"Hot " + Corner.entries.joinToString(" ") { "${it.short} ${formatPsi(sheet.hot[it])}" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val feel = handlingText(latest)
|
||||||
|
if (feel.isNotBlank()) {
|
||||||
|
Text(feel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CarMark() {
|
||||||
|
val gold = MaterialTheme.colorScheme.primary
|
||||||
|
val ink = MaterialTheme.colorScheme.onBackground
|
||||||
|
Canvas(Modifier.size(44.dp)) {
|
||||||
|
val tireRadius = CornerRadius(6.dp.toPx(), 6.dp.toPx())
|
||||||
|
fun tire(left: Float, top: Float) {
|
||||||
|
drawRoundRect(
|
||||||
|
color = gold,
|
||||||
|
topLeft = Offset(left, top),
|
||||||
|
size = Size(size.width * 0.28f, size.height * 0.32f),
|
||||||
|
cornerRadius = tireRadius,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
tire(0f, size.height * 0.06f)
|
||||||
|
tire(size.width * 0.72f, size.height * 0.06f)
|
||||||
|
tire(0f, size.height * 0.62f)
|
||||||
|
tire(size.width * 0.72f, size.height * 0.62f)
|
||||||
|
drawRoundRect(
|
||||||
|
color = ink,
|
||||||
|
topLeft = Offset(size.width * 0.30f, size.height * 0.16f),
|
||||||
|
size = Size(size.width * 0.40f, size.height * 0.68f),
|
||||||
|
cornerRadius = CornerRadius(10.dp.toPx(), 10.dp.toPx()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
package com.racingapp.pitlog.ui.night
|
||||||
|
|
||||||
|
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.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
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.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
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.rememberCoroutineScope
|
||||||
|
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.NightWithRuns
|
||||||
|
import com.racingapp.pitlog.data.RaceNight
|
||||||
|
import com.racingapp.pitlog.data.Repository
|
||||||
|
import com.racingapp.pitlog.data.RunLog
|
||||||
|
import com.racingapp.pitlog.model.Corner
|
||||||
|
import com.racingapp.pitlog.model.buildCsv
|
||||||
|
import com.racingapp.pitlog.model.exportFileName
|
||||||
|
import com.racingapp.pitlog.model.chassisSetup
|
||||||
|
import com.racingapp.pitlog.model.chassisTips
|
||||||
|
import com.racingapp.pitlog.model.formatDegrees
|
||||||
|
import com.racingapp.pitlog.model.formatPsi
|
||||||
|
import com.racingapp.pitlog.model.formatStagger
|
||||||
|
import com.racingapp.pitlog.model.handlingText
|
||||||
|
import com.racingapp.pitlog.model.toSheet
|
||||||
|
import com.racingapp.pitlog.ui.PitActionButton
|
||||||
|
import com.racingapp.pitlog.ui.TireHeatLines
|
||||||
|
import com.racingapp.pitlog.ui.guide.SetupTipsCard
|
||||||
|
import com.racingapp.pitlog.ui.formatClock
|
||||||
|
import com.racingapp.pitlog.ui.formatLongDate
|
||||||
|
import com.racingapp.pitlog.ui.pitViewModel
|
||||||
|
import com.racingapp.pitlog.ui.theme.pitTopBarColors
|
||||||
|
import java.time.LocalDate
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class NightDetailState(
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val night: RaceNight? = null,
|
||||||
|
val runs: List<RunLog> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
class NightDetailViewModel(
|
||||||
|
private val repository: Repository,
|
||||||
|
private val nightId: Long,
|
||||||
|
) : ViewModel() {
|
||||||
|
val state: StateFlow<NightDetailState> = combine(
|
||||||
|
repository.observeNight(nightId),
|
||||||
|
repository.observeRuns(nightId),
|
||||||
|
) { night, runs ->
|
||||||
|
NightDetailState(loading = false, night = night, runs = runs)
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), NightDetailState())
|
||||||
|
|
||||||
|
fun delete(onDone: () -> Unit) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.deleteNight(nightId)
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun NightDetailScreen(
|
||||||
|
nightId: Long,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onEdit: () -> Unit,
|
||||||
|
onLogRun: () -> Unit,
|
||||||
|
onOpenRun: (Long) -> Unit,
|
||||||
|
onDeleted: () -> Unit,
|
||||||
|
onExport: (String, String) -> Boolean,
|
||||||
|
onSetup: () -> Unit,
|
||||||
|
) {
|
||||||
|
val viewModel = pitViewModel { NightDetailViewModel(it, nightId) }
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val snackbar = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
|
val night = state.night
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbar) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(night?.trackName ?: "Race night") },
|
||||||
|
colors = pitTopBarColors(),
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (night != null) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
if (state.runs.isEmpty()) {
|
||||||
|
scope.launch { snackbar.showSnackbar("No runs to export yet") }
|
||||||
|
} else {
|
||||||
|
val name = exportFileName(
|
||||||
|
night.trackName,
|
||||||
|
LocalDate.ofEpochDay(night.dateEpochDay),
|
||||||
|
)
|
||||||
|
val shared = onExport(name, buildCsv(listOf(NightWithRuns(night, state.runs))))
|
||||||
|
if (!shared) {
|
||||||
|
scope.launch { snackbar.showSnackbar("Nothing on this phone can take the file") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Share, contentDescription = "Export this night")
|
||||||
|
}
|
||||||
|
IconButton(onClick = onEdit) {
|
||||||
|
Icon(Icons.Filled.Edit, contentDescription = "Edit race night")
|
||||||
|
}
|
||||||
|
IconButton(onClick = { confirmDelete = true }) {
|
||||||
|
Icon(Icons.Filled.Delete, contentDescription = "Delete race night")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
if (night != null) {
|
||||||
|
PitActionButton(text = "Log a run", onClick = onLogRun)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
when {
|
||||||
|
state.loading -> {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
night == null -> {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||||
|
Text("This race night is gone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
LazyColumn(Modifier.fillMaxSize(), contentPadding = padding) {
|
||||||
|
item { NightHeader(night) }
|
||||||
|
item {
|
||||||
|
SetupBlock(
|
||||||
|
night = night,
|
||||||
|
latest = state.runs.firstOrNull(),
|
||||||
|
onSetup = onSetup,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.runs.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"No runs yet. Log pressures and heat when the car comes back in.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(state.runs, key = { it.id }) { run ->
|
||||||
|
RunRow(run, onClick = { onOpenRun(run.id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmDelete) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmDelete = false },
|
||||||
|
title = { Text("Delete this race night?") },
|
||||||
|
text = { Text("The runs logged for this night will be deleted too.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
confirmDelete = false
|
||||||
|
viewModel.delete(onDeleted)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { confirmDelete = false }) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SetupBlock(night: RaceNight, latest: RunLog?, onSetup: () -> Unit) {
|
||||||
|
val setup = night.chassisSetup()
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
|
||||||
|
if (setup.any()) {
|
||||||
|
SetupLine("Cross", night.crossPercent?.let { String.format(java.util.Locale.US, "%.1f%%", it) })
|
||||||
|
SetupLine(
|
||||||
|
"Springs",
|
||||||
|
listOf(
|
||||||
|
night.springLf?.let { "LF $it" },
|
||||||
|
night.springRf?.let { "RF $it" },
|
||||||
|
night.springLr?.let { "LR $it" },
|
||||||
|
night.springRr?.let { "RR $it" },
|
||||||
|
).filterNotNull().joinToString(" ").ifBlank { null },
|
||||||
|
)
|
||||||
|
SetupLine(
|
||||||
|
"Blocks",
|
||||||
|
listOf(
|
||||||
|
night.blockLr?.let { "LR ${String.format(java.util.Locale.US, "%.1f", it)} in" },
|
||||||
|
night.blockRr?.let { "RR ${String.format(java.util.Locale.US, "%.1f", it)} in" },
|
||||||
|
).filterNotNull().joinToString(" ").ifBlank { null },
|
||||||
|
)
|
||||||
|
SetupLine(
|
||||||
|
"Camber",
|
||||||
|
listOf(
|
||||||
|
night.camberLf?.let { "LF ${formatDegrees(it)}°" },
|
||||||
|
night.camberRf?.let { "RF ${formatDegrees(it)}°" },
|
||||||
|
).filterNotNull().joinToString(" ").ifBlank { null },
|
||||||
|
)
|
||||||
|
SetupLine(
|
||||||
|
"Caster",
|
||||||
|
listOf(
|
||||||
|
night.casterLf?.let { "LF ${formatDegrees(it)}°" },
|
||||||
|
night.casterRf?.let { "RF ${formatDegrees(it)}°" },
|
||||||
|
).filterNotNull().joinToString(" ").ifBlank { null },
|
||||||
|
)
|
||||||
|
SetupLine("Sway bar", night.swayBarHole?.let { "Setting $it" })
|
||||||
|
SetupTipsCard(
|
||||||
|
chassisTips(
|
||||||
|
setup,
|
||||||
|
latest?.handling.orEmpty(),
|
||||||
|
latest?.toSheet(),
|
||||||
|
latest?.handlingWhere.orEmpty(),
|
||||||
|
),
|
||||||
|
Modifier.padding(top = 8.dp),
|
||||||
|
title = "From this setup",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(onClick = onSetup) {
|
||||||
|
Text(if (setup.any()) "Edit car setup" else "Log car setup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SetupLine(label: String, value: String?) {
|
||||||
|
if (value.isNullOrBlank()) return
|
||||||
|
Text("$label $value", modifier = Modifier.padding(top = 2.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NightHeader(night: RaceNight) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(formatLongDate(night.dateEpochDay), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
listOf(night.carName, night.raceClass, night.discipline)
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.joinToString(" · "),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
night.airTempF?.let { Text("Air $it°F") }
|
||||||
|
if (night.notes.isNotBlank()) {
|
||||||
|
Text(night.notes, modifier = Modifier.padding(top = 4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RunRow(run: RunLog, onClick: () -> Unit) {
|
||||||
|
val sheet = run.toSheet()
|
||||||
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(run.session, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
formatClock(run.recordedAt),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val details = listOfNotNull(
|
||||||
|
run.laps?.let { "$it laps" },
|
||||||
|
run.finish?.let { "P$it" },
|
||||||
|
run.staggerInches?.let { "Stagger ${formatStagger(it)} in" },
|
||||||
|
run.tireSet.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
if (details.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
details.joinToString(" · "),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TireHeatLines(sheet)
|
||||||
|
if (sheet.hot.any()) {
|
||||||
|
Text(
|
||||||
|
"Hot " + Corner.entries.joinToString(" ") { "${it.short} ${formatPsi(sheet.hot[it])}" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val feel = handlingText(run)
|
||||||
|
if (feel.isNotBlank()) Text(feel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
package com.racingapp.pitlog.ui.night
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DatePicker
|
||||||
|
import androidx.compose.material3.DatePickerDialog
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
|
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.draw.rotate
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
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.RaceNight
|
||||||
|
import com.racingapp.pitlog.data.Repository
|
||||||
|
import com.racingapp.pitlog.model.LocalWeather
|
||||||
|
import com.racingapp.pitlog.model.WeatherUnavailable
|
||||||
|
import com.racingapp.pitlog.model.parseIntField
|
||||||
|
import com.racingapp.pitlog.model.readLocalWeather
|
||||||
|
import com.racingapp.pitlog.model.sanitizeInt
|
||||||
|
import com.racingapp.pitlog.ui.NumberField
|
||||||
|
import com.racingapp.pitlog.ui.PitActionButton
|
||||||
|
import com.racingapp.pitlog.ui.PitScroll
|
||||||
|
import com.racingapp.pitlog.ui.SectionLabel
|
||||||
|
import com.racingapp.pitlog.ui.WordsField
|
||||||
|
import com.racingapp.pitlog.ui.formatLongDate
|
||||||
|
import com.racingapp.pitlog.ui.pickerMillisToDate
|
||||||
|
import com.racingapp.pitlog.ui.pitViewModel
|
||||||
|
import com.racingapp.pitlog.ui.theme.pitTopBarColors
|
||||||
|
import com.racingapp.pitlog.ui.toPickerMillis
|
||||||
|
import java.time.LocalDate
|
||||||
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
|
||||||
|
data class NightForm(
|
||||||
|
val date: LocalDate = LocalDate.now(),
|
||||||
|
val trackName: String = "",
|
||||||
|
val carName: String = DEFAULT_CAR,
|
||||||
|
val raceClass: String = DEFAULT_CLASS,
|
||||||
|
val discipline: String = DEFAULT_DISCIPLINE,
|
||||||
|
val airTemp: String = "",
|
||||||
|
val notes: String = "",
|
||||||
|
) {
|
||||||
|
fun toNight(id: Long, createdAt: Long, kept: RaceNight? = null) = RaceNight(
|
||||||
|
id = id,
|
||||||
|
dateEpochDay = date.toEpochDay(),
|
||||||
|
trackName = trackName.trim(),
|
||||||
|
carName = carName.trim().ifBlank { DEFAULT_CAR },
|
||||||
|
raceClass = raceClass.trim().ifBlank { DEFAULT_CLASS },
|
||||||
|
discipline = discipline.ifBlank { DEFAULT_DISCIPLINE },
|
||||||
|
airTempF = parseIntField(airTemp),
|
||||||
|
notes = notes.trim(),
|
||||||
|
createdAt = createdAt,
|
||||||
|
crossPercent = kept?.crossPercent,
|
||||||
|
springLf = kept?.springLf,
|
||||||
|
springRf = kept?.springRf,
|
||||||
|
springLr = kept?.springLr,
|
||||||
|
springRr = kept?.springRr,
|
||||||
|
camberLf = kept?.camberLf,
|
||||||
|
camberRf = kept?.camberRf,
|
||||||
|
casterLf = kept?.casterLf,
|
||||||
|
casterRf = kept?.casterRf,
|
||||||
|
swayBarHole = kept?.swayBarHole,
|
||||||
|
blockLr = kept?.blockLr,
|
||||||
|
blockRr = kept?.blockRr,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(night: RaceNight) = NightForm(
|
||||||
|
date = LocalDate.ofEpochDay(night.dateEpochDay),
|
||||||
|
trackName = night.trackName,
|
||||||
|
carName = night.carName,
|
||||||
|
raceClass = night.raceClass,
|
||||||
|
discipline = night.discipline,
|
||||||
|
airTemp = night.airTempF?.toString().orEmpty(),
|
||||||
|
notes = night.notes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NightEditorViewModel(
|
||||||
|
private val repository: Repository,
|
||||||
|
private val nightId: Long?,
|
||||||
|
) : ViewModel() {
|
||||||
|
var form by mutableStateOf(NightForm())
|
||||||
|
private set
|
||||||
|
var tracks by mutableStateOf<List<String>>(emptyList())
|
||||||
|
private set
|
||||||
|
var ready by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var missing by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var saving by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var fetchingWeather by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var weatherStatus by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
var error by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
private var createdAt: Long = System.currentTimeMillis()
|
||||||
|
private var existing: RaceNight? = null
|
||||||
|
private var weatherNote: String? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
tracks = repository.observeTrackNames().first()
|
||||||
|
if (nightId == null) {
|
||||||
|
val profile = repository.observeSelectedCar().first()
|
||||||
|
form = NightForm(
|
||||||
|
carName = profile?.carName ?: DEFAULT_CAR,
|
||||||
|
raceClass = profile?.raceClass ?: DEFAULT_CLASS,
|
||||||
|
discipline = profile?.discipline ?: DEFAULT_DISCIPLINE,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val night = repository.getNight(nightId)
|
||||||
|
if (night == null) {
|
||||||
|
missing = true
|
||||||
|
} else {
|
||||||
|
createdAt = night.createdAt
|
||||||
|
existing = night
|
||||||
|
form = NightForm.from(night)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ready = true
|
||||||
|
repository.observeTrackNames().collect { tracks = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(block: (NightForm) -> NightForm) {
|
||||||
|
form = block(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeError() {
|
||||||
|
error = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun weatherDenied() {
|
||||||
|
weatherStatus = "Location is off for this app, so the air temperature stays blank."
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadWeather(context: Context) {
|
||||||
|
if (fetchingWeather) return
|
||||||
|
fetchingWeather = true
|
||||||
|
weatherStatus = null
|
||||||
|
val app = context.applicationContext
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
withTimeout(20_000) { applyWeather(readLocalWeather(app)) }
|
||||||
|
} catch (failure: WeatherUnavailable) {
|
||||||
|
weatherStatus = failure.message
|
||||||
|
} catch (_: TimeoutCancellationException) {
|
||||||
|
weatherStatus = "Couldn't read the weather here. Enter the air temperature by hand."
|
||||||
|
} catch (_: Exception) {
|
||||||
|
weatherStatus = "Couldn't read the weather here. Enter the air temperature by hand."
|
||||||
|
} finally {
|
||||||
|
fetchingWeather = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyWeather(weather: LocalWeather) {
|
||||||
|
val notes = when {
|
||||||
|
form.notes.isBlank() || form.notes == weatherNote -> weather.summary
|
||||||
|
else -> form.notes
|
||||||
|
}
|
||||||
|
weatherNote = weather.summary
|
||||||
|
form = form.copy(airTemp = weather.airF.toString(), notes = notes)
|
||||||
|
weatherStatus = "${weather.summary}. Air ${weather.airF}°F."
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(onDone: (Long) -> Unit) {
|
||||||
|
if (saving || form.trackName.isBlank()) return
|
||||||
|
saving = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val id = repository.saveNight(form.toNight(nightId ?: 0L, createdAt, existing))
|
||||||
|
onDone(id)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
saving = false
|
||||||
|
error = "Couldn't save that race night."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun NightEditorScreen(
|
||||||
|
nightId: Long?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onSaved: (Long) -> Unit,
|
||||||
|
) {
|
||||||
|
val viewModel = pitViewModel { NightEditorViewModel(it, nightId) }
|
||||||
|
val snackbar = remember { SnackbarHostState() }
|
||||||
|
val context = LocalContext.current
|
||||||
|
var showErrors by remember { mutableStateOf(false) }
|
||||||
|
var showDatePicker by remember { mutableStateOf(false) }
|
||||||
|
val locationPermission = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
|
) { grants ->
|
||||||
|
if (grants.values.any { it }) viewModel.loadWeather(context) else viewModel.weatherDenied()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(viewModel.error) {
|
||||||
|
val message = viewModel.error ?: return@LaunchedEffect
|
||||||
|
snackbar.showSnackbar(message)
|
||||||
|
viewModel.consumeError()
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbar) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(if (nightId == null) "New race night" else "Edit race night") },
|
||||||
|
colors = pitTopBarColors(),
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
if (viewModel.ready && !viewModel.missing) {
|
||||||
|
PitActionButton(
|
||||||
|
text = if (viewModel.saving) "Saving…" else "Save race night",
|
||||||
|
onClick = {
|
||||||
|
if (viewModel.form.trackName.isBlank()) {
|
||||||
|
showErrors = true
|
||||||
|
} else {
|
||||||
|
viewModel.save(onSaved)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !viewModel.saving,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
when {
|
||||||
|
!viewModel.ready -> {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModel.missing -> {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||||
|
Text("This race night is gone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val form = viewModel.form
|
||||||
|
PitScroll(padding) {
|
||||||
|
DateField(formatLongDate(form.date.toEpochDay()), onClick = { showDatePicker = true })
|
||||||
|
TrackField(
|
||||||
|
value = form.trackName,
|
||||||
|
tracks = viewModel.tracks,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(trackName = value.take(80)) } },
|
||||||
|
isError = showErrors && form.trackName.isBlank(),
|
||||||
|
supportingText = if (showErrors && form.trackName.isBlank()) "Enter the track" else null,
|
||||||
|
)
|
||||||
|
SectionLabel("Conditions")
|
||||||
|
NumberField(
|
||||||
|
label = "Air °F",
|
||||||
|
value = form.airTemp,
|
||||||
|
onValue = { value ->
|
||||||
|
viewModel.update { it.copy(airTemp = sanitizeInt(value, 3, allowNegative = true)) }
|
||||||
|
},
|
||||||
|
decimal = false,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val allowed = listOf(
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
).any { permission ->
|
||||||
|
ContextCompat.checkSelfPermission(context, permission) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (allowed) {
|
||||||
|
viewModel.loadWeather(context)
|
||||||
|
} else {
|
||||||
|
locationPermission.launch(
|
||||||
|
arrayOf(
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !viewModel.fetchingWeather,
|
||||||
|
) {
|
||||||
|
Text(if (viewModel.fetchingWeather) "Checking weather…" else "Use weather here")
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
viewModel.weatherStatus
|
||||||
|
?: "Fills the air temperature from the weather where you are.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
SectionLabel("Notes")
|
||||||
|
OutlinedTextField(
|
||||||
|
value = form.notes,
|
||||||
|
onValueChange = { value ->
|
||||||
|
if (value.length <= 4000) viewModel.update { it.copy(notes = value) }
|
||||||
|
},
|
||||||
|
label = { Text("Weather, rubber, anything for the night") },
|
||||||
|
minLines = 3,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
SectionLabel("Car on this sheet")
|
||||||
|
WordsField(
|
||||||
|
label = "Car",
|
||||||
|
value = form.carName,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(carName = value.take(80)) } },
|
||||||
|
)
|
||||||
|
WordsField(
|
||||||
|
label = "Class",
|
||||||
|
value = form.raceClass,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(raceClass = value.take(80)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDatePicker) {
|
||||||
|
RaceDatePicker(
|
||||||
|
initial = viewModel.form.date,
|
||||||
|
onDismiss = { showDatePicker = false },
|
||||||
|
onDate = { date ->
|
||||||
|
viewModel.update { it.copy(date = date) }
|
||||||
|
showDatePicker = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun TrackField(
|
||||||
|
value: String,
|
||||||
|
tracks: List<String>,
|
||||||
|
onValue: (String) -> Unit,
|
||||||
|
isError: Boolean,
|
||||||
|
supportingText: String?,
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val open = expanded && tracks.isNotEmpty()
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = open,
|
||||||
|
onExpandedChange = { next -> if (tracks.isNotEmpty()) expanded = next },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValue,
|
||||||
|
label = { Text("Track") },
|
||||||
|
placeholder = { Text("Track name") },
|
||||||
|
singleLine = true,
|
||||||
|
isError = isError,
|
||||||
|
supportingText = supportingText?.let { message -> { Text(message) } },
|
||||||
|
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words),
|
||||||
|
trailingIcon = if (tracks.isEmpty()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
{
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.ArrowDropDown,
|
||||||
|
contentDescription = "Saved tracks",
|
||||||
|
modifier = Modifier.rotate(if (open) 180f else 0f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, enabled = tracks.isNotEmpty())
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = open,
|
||||||
|
onDismissRequest = { expanded = false },
|
||||||
|
) {
|
||||||
|
tracks.forEach { name ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(name) },
|
||||||
|
onClick = {
|
||||||
|
onValue(name)
|
||||||
|
expanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DateField(value: String, onClick: () -> Unit) {
|
||||||
|
Box(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("Date") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun RaceDatePicker(
|
||||||
|
initial: LocalDate,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onDate: (LocalDate) -> Unit,
|
||||||
|
) {
|
||||||
|
val state = rememberDatePickerState(initialSelectedDateMillis = initial.toPickerMillis())
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val millis = state.selectedDateMillis
|
||||||
|
if (millis != null) onDate(pickerMillisToDate(millis)) else onDismiss()
|
||||||
|
},
|
||||||
|
) { Text("OK") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
DatePicker(state = state)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
package com.racingapp.pitlog.ui.night
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
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.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.racingapp.pitlog.data.RaceNight
|
||||||
|
import com.racingapp.pitlog.data.Repository
|
||||||
|
import com.racingapp.pitlog.model.parseDecimal
|
||||||
|
import com.racingapp.pitlog.model.parseIntField
|
||||||
|
import com.racingapp.pitlog.model.sanitizeDecimal
|
||||||
|
import com.racingapp.pitlog.model.sanitizeInt
|
||||||
|
import com.racingapp.pitlog.model.sanitizeSignedDecimal
|
||||||
|
import com.racingapp.pitlog.ui.FieldRow
|
||||||
|
import com.racingapp.pitlog.ui.PitActionButton
|
||||||
|
import com.racingapp.pitlog.ui.NumberField
|
||||||
|
import com.racingapp.pitlog.ui.PitScroll
|
||||||
|
import com.racingapp.pitlog.ui.SectionLabel
|
||||||
|
import com.racingapp.pitlog.ui.pitViewModel
|
||||||
|
import com.racingapp.pitlog.ui.theme.pitTopBarColors
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private data class SetupForm(
|
||||||
|
val cross: String = "",
|
||||||
|
val springLf: String = "",
|
||||||
|
val springRf: String = "",
|
||||||
|
val springLr: String = "",
|
||||||
|
val springRr: String = "",
|
||||||
|
val camberLf: String = "",
|
||||||
|
val camberRf: String = "",
|
||||||
|
val casterLf: String = "",
|
||||||
|
val casterRf: String = "",
|
||||||
|
val swayBarHole: String = "",
|
||||||
|
val blockLr: String = "",
|
||||||
|
val blockRr: String = "",
|
||||||
|
) {
|
||||||
|
fun appliedTo(night: RaceNight) = night.copy(
|
||||||
|
crossPercent = parseDecimal(cross),
|
||||||
|
springLf = parseIntField(springLf),
|
||||||
|
springRf = parseIntField(springRf),
|
||||||
|
springLr = parseIntField(springLr),
|
||||||
|
springRr = parseIntField(springRr),
|
||||||
|
camberLf = parseDecimal(camberLf),
|
||||||
|
camberRf = parseDecimal(camberRf),
|
||||||
|
casterLf = parseDecimal(casterLf),
|
||||||
|
casterRf = parseDecimal(casterRf),
|
||||||
|
swayBarHole = parseIntField(swayBarHole),
|
||||||
|
blockLr = parseDecimal(blockLr),
|
||||||
|
blockRr = parseDecimal(blockRr),
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(night: RaceNight) = SetupForm(
|
||||||
|
cross = night.crossPercent?.let { String.format(Locale.US, "%.1f", it) }.orEmpty(),
|
||||||
|
springLf = night.springLf?.toString().orEmpty(),
|
||||||
|
springRf = night.springRf?.toString().orEmpty(),
|
||||||
|
springLr = night.springLr?.toString().orEmpty(),
|
||||||
|
springRr = night.springRr?.toString().orEmpty(),
|
||||||
|
camberLf = signedField(night.camberLf),
|
||||||
|
camberRf = signedField(night.camberRf),
|
||||||
|
casterLf = signedField(night.casterLf),
|
||||||
|
casterRf = signedField(night.casterRf),
|
||||||
|
swayBarHole = night.swayBarHole?.toString().orEmpty(),
|
||||||
|
blockLr = night.blockLr?.let { String.format(Locale.US, "%.1f", it) }.orEmpty(),
|
||||||
|
blockRr = night.blockRr?.let { String.format(Locale.US, "%.1f", it) }.orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun signedField(value: Double?): String =
|
||||||
|
if (value == null) "" else String.format(Locale.US, "%.1f", value)
|
||||||
|
|
||||||
|
private class SetupEditorViewModel(
|
||||||
|
private val repository: Repository,
|
||||||
|
private val nightId: Long,
|
||||||
|
) : ViewModel() {
|
||||||
|
var form by mutableStateOf(SetupForm())
|
||||||
|
private set
|
||||||
|
var ready by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var missing by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var saving by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var error by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
private var night: RaceNight? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val loaded = repository.getNight(nightId)
|
||||||
|
if (loaded == null) {
|
||||||
|
missing = true
|
||||||
|
} else {
|
||||||
|
night = loaded
|
||||||
|
form = SetupForm.from(loaded)
|
||||||
|
}
|
||||||
|
ready = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(block: (SetupForm) -> SetupForm) {
|
||||||
|
form = block(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeError() {
|
||||||
|
error = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(onDone: () -> Unit) {
|
||||||
|
val current = night ?: return
|
||||||
|
if (saving) return
|
||||||
|
saving = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.saveNight(form.appliedTo(current))
|
||||||
|
onDone()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
saving = false
|
||||||
|
error = "Couldn't save the setup."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SetupEditorScreen(nightId: Long, onBack: () -> Unit) {
|
||||||
|
val viewModel = pitViewModel { SetupEditorViewModel(it, nightId) }
|
||||||
|
val snackbar = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(viewModel.error) {
|
||||||
|
val message = viewModel.error ?: return@LaunchedEffect
|
||||||
|
snackbar.showSnackbar(message)
|
||||||
|
viewModel.consumeError()
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbar) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Car setup") },
|
||||||
|
colors = pitTopBarColors(),
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
if (viewModel.ready && !viewModel.missing) {
|
||||||
|
PitActionButton(
|
||||||
|
text = if (viewModel.saving) "Saving…" else "Save setup",
|
||||||
|
onClick = { viewModel.save(onBack) },
|
||||||
|
enabled = !viewModel.saving,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
when {
|
||||||
|
!viewModel.ready -> {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModel.missing -> {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||||
|
Text("This race night is gone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val form = viewModel.form
|
||||||
|
PitScroll(padding) {
|
||||||
|
Text(
|
||||||
|
"For this night. Positive camber tilts the top of the tire out. Right-front camber is usually negative, and right-front caster is usually higher.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
SectionLabel("Crossweight")
|
||||||
|
NumberField(
|
||||||
|
label = "Cross %",
|
||||||
|
value = form.cross,
|
||||||
|
onValue = { value ->
|
||||||
|
viewModel.update { it.copy(cross = sanitizeDecimal(value, 2, 1)) }
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
SectionLabel("Springs, lb")
|
||||||
|
FieldRow {
|
||||||
|
NumberField(
|
||||||
|
label = "LF",
|
||||||
|
value = form.springLf,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(springLf = sanitizeInt(value, 4)) } },
|
||||||
|
decimal = false,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
NumberField(
|
||||||
|
label = "RF",
|
||||||
|
value = form.springRf,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(springRf = sanitizeInt(value, 4)) } },
|
||||||
|
decimal = false,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FieldRow {
|
||||||
|
NumberField(
|
||||||
|
label = "LR",
|
||||||
|
value = form.springLr,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(springLr = sanitizeInt(value, 4)) } },
|
||||||
|
decimal = false,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
NumberField(
|
||||||
|
label = "RR",
|
||||||
|
value = form.springRr,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(springRr = sanitizeInt(value, 4)) } },
|
||||||
|
decimal = false,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SectionLabel("Blocks, inches")
|
||||||
|
Text(
|
||||||
|
"Under the leaves. More block tightens entry and the middle. Over 3 inches cuts forward bite. String the rear after you move them.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
FieldRow {
|
||||||
|
NumberField(
|
||||||
|
label = "LR",
|
||||||
|
value = form.blockLr,
|
||||||
|
onValue = { value ->
|
||||||
|
viewModel.update { it.copy(blockLr = sanitizeDecimal(value, 1, 1)) }
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
NumberField(
|
||||||
|
label = "RR",
|
||||||
|
value = form.blockRr,
|
||||||
|
onValue = { value ->
|
||||||
|
viewModel.update { it.copy(blockRr = sanitizeDecimal(value, 1, 1)) }
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SectionLabel("Sway bar")
|
||||||
|
Text(
|
||||||
|
"Where the bar is now. Less bar if the car is tight. More bar if it is loose. The bar has to move freely: space the mounts off the frame, or use a bushing about 1/8 inch larger than the bar.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
NumberField(
|
||||||
|
label = "Setting",
|
||||||
|
value = form.swayBarHole,
|
||||||
|
onValue = { value ->
|
||||||
|
viewModel.update { it.copy(swayBarHole = sanitizeInt(value, 2)) }
|
||||||
|
},
|
||||||
|
decimal = false,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
SectionLabel("Camber, degrees")
|
||||||
|
FieldRow {
|
||||||
|
SignedField(
|
||||||
|
label = "LF",
|
||||||
|
value = form.camberLf,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(camberLf = value) } },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
SignedField(
|
||||||
|
label = "RF",
|
||||||
|
value = form.camberRf,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(camberRf = value) } },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SectionLabel("Caster, degrees")
|
||||||
|
FieldRow {
|
||||||
|
SignedField(
|
||||||
|
label = "LF",
|
||||||
|
value = form.casterLf,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(casterLf = value) } },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
SignedField(
|
||||||
|
label = "RF",
|
||||||
|
value = form.casterRf,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(casterRf = value) } },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SignedField(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
onValue: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier) {
|
||||||
|
NumberField(
|
||||||
|
label = label,
|
||||||
|
value = value,
|
||||||
|
onValue = { onValue(sanitizeSignedDecimal(it, 2, 1)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val next = when {
|
||||||
|
value.startsWith("-") -> value.removePrefix("-")
|
||||||
|
value.isEmpty() -> "-"
|
||||||
|
else -> "-$value"
|
||||||
|
}
|
||||||
|
onValue(next)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(if (value.startsWith("-")) "Make positive" else "Make negative")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
package com.racingapp.pitlog.ui.run
|
||||||
|
|
||||||
|
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.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.MenuBook
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
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.RaceNight
|
||||||
|
import com.racingapp.pitlog.data.Repository
|
||||||
|
import com.racingapp.pitlog.data.RunLog
|
||||||
|
import com.racingapp.pitlog.model.Corner
|
||||||
|
import com.racingapp.pitlog.model.formatAverage
|
||||||
|
import com.racingapp.pitlog.model.formatPsi
|
||||||
|
import com.racingapp.pitlog.model.formatSignedPsi
|
||||||
|
import com.racingapp.pitlog.model.formatSpread
|
||||||
|
import com.racingapp.pitlog.model.formatStagger
|
||||||
|
import com.racingapp.pitlog.model.formatTemp
|
||||||
|
import com.racingapp.pitlog.model.handlingText
|
||||||
|
import com.racingapp.pitlog.model.toSheet
|
||||||
|
import com.racingapp.pitlog.model.ChassisSetup
|
||||||
|
import com.racingapp.pitlog.model.chassisSetup
|
||||||
|
import com.racingapp.pitlog.model.chassisTips
|
||||||
|
import com.racingapp.pitlog.model.combineTips
|
||||||
|
import com.racingapp.pitlog.model.setupTips
|
||||||
|
import com.racingapp.pitlog.ui.guide.SetupTipsCard
|
||||||
|
import com.racingapp.pitlog.ui.formatClock
|
||||||
|
import com.racingapp.pitlog.ui.formatLongDate
|
||||||
|
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.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class RunDetailState(
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val night: RaceNight? = null,
|
||||||
|
val run: RunLog? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
class RunDetailViewModel(
|
||||||
|
private val repository: Repository,
|
||||||
|
private val nightId: Long,
|
||||||
|
runId: Long,
|
||||||
|
) : ViewModel() {
|
||||||
|
val state: StateFlow<RunDetailState> = combine(
|
||||||
|
repository.observeNight(nightId),
|
||||||
|
repository.observeRun(runId),
|
||||||
|
) { night, run ->
|
||||||
|
RunDetailState(loading = false, night = night, run = run)
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), RunDetailState())
|
||||||
|
|
||||||
|
fun delete(onDone: () -> Unit) {
|
||||||
|
val id = state.value.run?.id ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.deleteRun(id)
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RunDetailScreen(
|
||||||
|
nightId: Long,
|
||||||
|
runId: Long,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onEdit: () -> Unit,
|
||||||
|
onGuide: () -> Unit,
|
||||||
|
onDeleted: () -> Unit,
|
||||||
|
) {
|
||||||
|
val viewModel = pitViewModel { RunDetailViewModel(it, nightId, runId) }
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
|
val run = state.run
|
||||||
|
val sheet = run?.toSheet()
|
||||||
|
val hottest = sheet?.hottest()?.first
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(run?.session ?: "Run") },
|
||||||
|
colors = pitTopBarColors(),
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (run != null) {
|
||||||
|
IconButton(onClick = onGuide) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.MenuBook, contentDescription = "Setup")
|
||||||
|
}
|
||||||
|
IconButton(onClick = onEdit) {
|
||||||
|
Icon(Icons.Filled.Edit, contentDescription = "Edit run")
|
||||||
|
}
|
||||||
|
IconButton(onClick = { confirmDelete = true }) {
|
||||||
|
Icon(Icons.Filled.Delete, contentDescription = "Delete run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
when {
|
||||||
|
state.loading -> {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run == null || sheet == null -> {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||||
|
Text("That run isn't on this phone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
LazyColumn(Modifier.fillMaxSize(), contentPadding = padding) {
|
||||||
|
item { RunHeader(state.night, run) }
|
||||||
|
item {
|
||||||
|
SetupTipsCard(
|
||||||
|
combineTips(
|
||||||
|
setupTips(sheet, run.handling, run.handlingWhere),
|
||||||
|
chassisTips(
|
||||||
|
state.night?.chassisSetup() ?: ChassisSetup(),
|
||||||
|
run.handling,
|
||||||
|
sheet,
|
||||||
|
run.handlingWhere,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
CornerGrid {
|
||||||
|
Corner.entries.forEach { corner ->
|
||||||
|
CornerResultCard(
|
||||||
|
corner = corner,
|
||||||
|
sheet = sheet,
|
||||||
|
highlight = hottest == corner,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { SummaryCard(sheet) }
|
||||||
|
item {
|
||||||
|
val feel = handlingText(run)
|
||||||
|
if (feel.isNotBlank() || run.notes.isNotBlank()) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
if (feel.isNotBlank()) {
|
||||||
|
Text(feel, style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
if (run.notes.isNotBlank()) Text(run.notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmDelete) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmDelete = false },
|
||||||
|
title = { Text("Delete this run?") },
|
||||||
|
text = { Text("The pressures and heat for this run will be removed.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
confirmDelete = false
|
||||||
|
viewModel.delete(onDeleted)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { confirmDelete = false }) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RunHeader(night: RaceNight?, run: RunLog) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(formatClock(run.recordedAt), style = MaterialTheme.typography.titleMedium)
|
||||||
|
if (night != null) {
|
||||||
|
Text(
|
||||||
|
"${night.trackName} · ${formatLongDate(night.dateEpochDay)}",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val details = listOfNotNull(
|
||||||
|
run.laps?.let { "$it laps" },
|
||||||
|
run.finish?.let { "Finished P$it" },
|
||||||
|
run.staggerInches?.let { "Stagger ${formatStagger(it)} in" },
|
||||||
|
run.tireSet.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
if (details.isNotEmpty()) Text(details.joinToString(" · "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CornerGrid(content: @Composable () -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
content = { content() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CornerResultCard(
|
||||||
|
corner: Corner,
|
||||||
|
sheet: com.racingapp.pitlog.model.TireSheet,
|
||||||
|
highlight: Boolean,
|
||||||
|
) {
|
||||||
|
val heat = sheet.heatOf(corner)
|
||||||
|
val container = if (highlight) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
}
|
||||||
|
val onContainer = if (highlight) {
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
}
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = container),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(corner.label, color = if (highlight) onContainer else MaterialTheme.colorScheme.primary)
|
||||||
|
if (heat.any()) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
TempBit("Inside", formatTemp(heat.inside), onContainer)
|
||||||
|
TempBit("Middle", formatTemp(heat.middle), onContainer)
|
||||||
|
TempBit("Outside", formatTemp(heat.outside), onContainer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Cold ${formatPsi(sheet.cold[corner])} · Hot ${formatPsi(sheet.hot[corner])} · Build ${formatSignedPsi(sheet.build(corner))}",
|
||||||
|
color = onContainer,
|
||||||
|
)
|
||||||
|
if (heat.average != null || heat.spread != null) {
|
||||||
|
Text(
|
||||||
|
"Avg ${formatAverage(heat.average)}°F · Outside − inside ${formatSpread(heat.spread)}",
|
||||||
|
color = onContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TempBit(label: String, value: String, color: androidx.compose.ui.graphics.Color) {
|
||||||
|
Column {
|
||||||
|
Text(label, style = MaterialTheme.typography.labelMedium, color = color.copy(alpha = 0.75f))
|
||||||
|
Text(value, style = MaterialTheme.typography.titleMedium, color = color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SummaryCard(sheet: com.racingapp.pitlog.model.TireSheet) {
|
||||||
|
val hottest = sheet.hottest()
|
||||||
|
val build = sheet.biggestBuild()
|
||||||
|
val split = sheet.rearHotSplit()
|
||||||
|
if (hottest == null && build == null && split == null) return
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
if (hottest != null) {
|
||||||
|
SummaryLine("Hottest", "${hottest.first.short} ${formatAverage(hottest.second)}°F")
|
||||||
|
}
|
||||||
|
if (build != null) {
|
||||||
|
SummaryLine("Biggest build", "${build.first.short} ${formatSignedPsi(build.second)} psi")
|
||||||
|
}
|
||||||
|
if (split != null) {
|
||||||
|
SummaryLine("RR − LR hot", "${formatSignedPsi(split)} psi")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SummaryLine(label: String, value: String) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(value, style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
package com.racingapp.pitlog.ui.run
|
||||||
|
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
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.rememberScrollState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.racingapp.pitlog.data.Repository
|
||||||
|
import com.racingapp.pitlog.data.RunLog
|
||||||
|
import com.racingapp.pitlog.model.Corner
|
||||||
|
import com.racingapp.pitlog.model.Handling
|
||||||
|
import com.racingapp.pitlog.model.HandlingSpot
|
||||||
|
import com.racingapp.pitlog.model.RunForm
|
||||||
|
import com.racingapp.pitlog.model.SessionKind
|
||||||
|
import com.racingapp.pitlog.model.Tread
|
||||||
|
import com.racingapp.pitlog.model.formatPsi
|
||||||
|
import com.racingapp.pitlog.model.hasColdPressure
|
||||||
|
import com.racingapp.pitlog.model.sanitizeDecimal
|
||||||
|
import com.racingapp.pitlog.model.sanitizeInt
|
||||||
|
import com.racingapp.pitlog.model.suspiciousReadings
|
||||||
|
import com.racingapp.pitlog.model.toSheet
|
||||||
|
import com.racingapp.pitlog.model.ChassisSetup
|
||||||
|
import com.racingapp.pitlog.model.chassisSetup
|
||||||
|
import com.racingapp.pitlog.model.chassisTips
|
||||||
|
import com.racingapp.pitlog.model.combineTips
|
||||||
|
import com.racingapp.pitlog.model.setupTips
|
||||||
|
import com.racingapp.pitlog.ui.FieldRow
|
||||||
|
import com.racingapp.pitlog.ui.guide.SetupTipsCard
|
||||||
|
import com.racingapp.pitlog.ui.NumberField
|
||||||
|
import com.racingapp.pitlog.ui.PitActionButton
|
||||||
|
import com.racingapp.pitlog.ui.PitScroll
|
||||||
|
import com.racingapp.pitlog.ui.SectionLabel
|
||||||
|
import com.racingapp.pitlog.ui.formatClock
|
||||||
|
import com.racingapp.pitlog.ui.pitViewModel
|
||||||
|
import com.racingapp.pitlog.ui.theme.pitTopBarColors
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class RunEditorViewModel(
|
||||||
|
private val repository: Repository,
|
||||||
|
private val nightId: Long,
|
||||||
|
private val runId: Long?,
|
||||||
|
) : ViewModel() {
|
||||||
|
var form by mutableStateOf(RunForm())
|
||||||
|
private set
|
||||||
|
var ready by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var missing by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var saving by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var error by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
var lastRun by mutableStateOf<RunLog?>(null)
|
||||||
|
private set
|
||||||
|
var chassis by mutableStateOf(ChassisSetup())
|
||||||
|
private set
|
||||||
|
private var existing: RunLog? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (runId == null) {
|
||||||
|
val previous = repository.latestRun(nightId)
|
||||||
|
lastRun = previous
|
||||||
|
form = RunForm(
|
||||||
|
session = previous?.session?.let(SessionKind::fromLabel) ?: SessionKind.Practice,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val run = repository.getRun(runId)
|
||||||
|
if (run == null || run.nightId != nightId) {
|
||||||
|
missing = true
|
||||||
|
} else {
|
||||||
|
existing = run
|
||||||
|
form = RunForm.from(run)
|
||||||
|
lastRun = repository.previousRun(nightId, run.recordedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chassis = repository.getNight(nightId)?.chassisSetup() ?: ChassisSetup()
|
||||||
|
ready = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(block: (RunForm) -> RunForm) {
|
||||||
|
form = block(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeError() {
|
||||||
|
error = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(onDone: (Long) -> Unit) {
|
||||||
|
if (saving) return
|
||||||
|
saving = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val saved = form.toRunLog(
|
||||||
|
nightId = nightId,
|
||||||
|
id = existing?.id ?: 0L,
|
||||||
|
recordedAt = existing?.recordedAt ?: System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
onDone(repository.saveRun(saved))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
saving = false
|
||||||
|
error = "Couldn't save that run."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RunEditorScreen(
|
||||||
|
nightId: Long,
|
||||||
|
runId: Long?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onSaved: (Long) -> Unit,
|
||||||
|
) {
|
||||||
|
val viewModel = pitViewModel { RunEditorViewModel(it, nightId, runId) }
|
||||||
|
val snackbar = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var warnings by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
|
|
||||||
|
LaunchedEffect(viewModel.error) {
|
||||||
|
val message = viewModel.error ?: return@LaunchedEffect
|
||||||
|
snackbar.showSnackbar(message)
|
||||||
|
viewModel.consumeError()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun attemptSave(force: Boolean) {
|
||||||
|
val form = viewModel.form
|
||||||
|
if (!form.hasContent()) {
|
||||||
|
scope.launch { snackbar.showSnackbar("Enter a pressure, a temp, the laps, or a note.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!force) {
|
||||||
|
val found = suspiciousReadings(form.toSheet())
|
||||||
|
if (found.isNotEmpty()) {
|
||||||
|
warnings = found
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModel.save(onSaved)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbar) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(if (runId == null) "Log a run" else "Edit run") },
|
||||||
|
colors = pitTopBarColors(),
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
if (viewModel.ready && !viewModel.missing) {
|
||||||
|
PitActionButton(
|
||||||
|
text = if (viewModel.saving) "Saving…" else "Save run",
|
||||||
|
onClick = { attemptSave(force = false) },
|
||||||
|
enabled = !viewModel.saving,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
when {
|
||||||
|
!viewModel.ready -> {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModel.missing -> {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||||
|
Text("That run isn't on this phone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val form = viewModel.form
|
||||||
|
PitScroll(padding) {
|
||||||
|
Text(
|
||||||
|
"Cold before the run. Hot when it comes in. Heat is inside, middle, outside.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
SectionLabel("Session")
|
||||||
|
Row(
|
||||||
|
Modifier.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
SessionKind.entries.forEach { session ->
|
||||||
|
FilterChip(
|
||||||
|
selected = form.session == session,
|
||||||
|
onClick = { viewModel.update { it.copy(session = session) } },
|
||||||
|
label = { Text(session.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = form.tireSet,
|
||||||
|
onValueChange = { value -> viewModel.update { it.copy(tireSet = value.take(80)) } },
|
||||||
|
label = { Text("Tire set") },
|
||||||
|
placeholder = { Text("Scuffs, stickers, set B") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
FieldRow {
|
||||||
|
NumberField(
|
||||||
|
label = "Laps",
|
||||||
|
value = form.laps,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(laps = sanitizeInt(value, 3)) } },
|
||||||
|
decimal = false,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
NumberField(
|
||||||
|
label = "Finish",
|
||||||
|
value = form.finish,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(finish = sanitizeInt(value, 2)) } },
|
||||||
|
decimal = false,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
NumberField(
|
||||||
|
label = "Stagger",
|
||||||
|
value = form.stagger,
|
||||||
|
onValue = { value ->
|
||||||
|
viewModel.update { it.copy(stagger = sanitizeDecimal(value, 2, 2)) }
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Stagger is RR circumference minus LR, in inches.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
viewModel.lastRun?.let { previous ->
|
||||||
|
LastRunBanner(
|
||||||
|
run = previous,
|
||||||
|
onCopyCold = { viewModel.update { it.withColdFrom(previous) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SectionLabel("Tires")
|
||||||
|
Corner.entries.forEach { corner ->
|
||||||
|
CornerEntryCard(
|
||||||
|
corner = corner,
|
||||||
|
form = form,
|
||||||
|
onCold = { value -> viewModel.update { it.withCold(corner, sanitizeDecimal(value, 3, 1)) } },
|
||||||
|
onHot = { value -> viewModel.update { it.withHot(corner, sanitizeDecimal(value, 3, 1)) } },
|
||||||
|
onHeat = { tread, value ->
|
||||||
|
viewModel.update { it.withHeat(corner, tread, sanitizeInt(value, 3)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SectionLabel("How it felt")
|
||||||
|
Row(
|
||||||
|
Modifier.padding(top = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Handling.entries.forEach { handling ->
|
||||||
|
FilterChip(
|
||||||
|
selected = form.handling == handling,
|
||||||
|
onClick = {
|
||||||
|
viewModel.update {
|
||||||
|
it.copy(handling = if (it.handling == handling) null else handling)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text(handling.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
Modifier.padding(top = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
HandlingSpot.entries.forEach { spot ->
|
||||||
|
FilterChip(
|
||||||
|
selected = form.handlingWhere == spot,
|
||||||
|
onClick = {
|
||||||
|
viewModel.update {
|
||||||
|
it.copy(handlingWhere = if (it.handlingWhere == spot) null else spot)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text(spot.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val tips = combineTips(
|
||||||
|
setupTips(
|
||||||
|
form.toSheet(),
|
||||||
|
form.handling?.label.orEmpty(),
|
||||||
|
form.handlingWhere?.label.orEmpty(),
|
||||||
|
),
|
||||||
|
chassisTips(
|
||||||
|
viewModel.chassis,
|
||||||
|
form.handling?.label.orEmpty(),
|
||||||
|
form.toSheet(),
|
||||||
|
form.handlingWhere?.label.orEmpty(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (tips.isNotEmpty()) {
|
||||||
|
SetupTipsCard(tips, Modifier.padding(top = 16.dp))
|
||||||
|
}
|
||||||
|
SectionLabel("Notes")
|
||||||
|
OutlinedTextField(
|
||||||
|
value = form.notes,
|
||||||
|
onValueChange = { value ->
|
||||||
|
if (value.length <= 4000) viewModel.update { it.copy(notes = value) }
|
||||||
|
},
|
||||||
|
label = { Text("Notes") },
|
||||||
|
minLines = 3,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warnings.isNotEmpty()) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { warnings = emptyList() },
|
||||||
|
title = { Text("Check these numbers") },
|
||||||
|
text = { Text(warnings.joinToString("\n")) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
warnings = emptyList()
|
||||||
|
attemptSave(force = true)
|
||||||
|
},
|
||||||
|
) { Text("Save anyway") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { warnings = emptyList() }) { Text("Go back") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LastRunBanner(run: RunLog, onCopyCold: () -> Unit) {
|
||||||
|
val sheet = run.toSheet()
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 16.dp),
|
||||||
|
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
"Last run · ${run.session} · ${formatClock(run.recordedAt)}",
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Hot PSI " + Corner.entries.joinToString(" ") { "${it.short} ${formatPsi(sheet.hot[it])}" },
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
if (run.hasColdPressure()) {
|
||||||
|
TextButton(onClick = onCopyCold, modifier = Modifier.padding(top = 4.dp)) {
|
||||||
|
Text("Copy last cold pressures")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CornerEntryCard(
|
||||||
|
corner: Corner,
|
||||||
|
form: RunForm,
|
||||||
|
onCold: (String) -> Unit,
|
||||||
|
onHot: (String) -> Unit,
|
||||||
|
onHeat: (Tread, String) -> Unit,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 10.dp),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
corner.label,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
FieldRow {
|
||||||
|
NumberField(
|
||||||
|
label = "Cold",
|
||||||
|
value = form.cold(corner),
|
||||||
|
onValue = onCold,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
NumberField(
|
||||||
|
label = "Hot",
|
||||||
|
value = form.hot(corner),
|
||||||
|
onValue = onHot,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text("Heat", style = MaterialTheme.typography.labelLarge)
|
||||||
|
FieldRow {
|
||||||
|
Tread.entries.forEach { tread ->
|
||||||
|
NumberField(
|
||||||
|
label = tread.label,
|
||||||
|
value = form.heat(corner, tread),
|
||||||
|
onValue = { onHeat(tread, it) },
|
||||||
|
decimal = false,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
package com.racingapp.pitlog.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
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.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
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.OutlinedButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
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.Repository
|
||||||
|
import com.racingapp.pitlog.ui.PitActionButton
|
||||||
|
import com.racingapp.pitlog.ui.PitScroll
|
||||||
|
import com.racingapp.pitlog.ui.SectionLabel
|
||||||
|
import com.racingapp.pitlog.ui.WordsField
|
||||||
|
import com.racingapp.pitlog.ui.pitViewModel
|
||||||
|
import com.racingapp.pitlog.ui.shareText
|
||||||
|
import com.racingapp.pitlog.ui.theme.pitTopBarColors
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class SettingsViewModel(private val repository: Repository) : ViewModel() {
|
||||||
|
var carId by mutableStateOf(0L)
|
||||||
|
var carName by mutableStateOf(DEFAULT_CAR)
|
||||||
|
var raceClass by mutableStateOf(DEFAULT_CLASS)
|
||||||
|
var discipline by mutableStateOf(DEFAULT_DISCIPLINE)
|
||||||
|
var token by mutableStateOf("")
|
||||||
|
var cars by mutableStateOf<List<Car>>(emptyList())
|
||||||
|
var joinToken by mutableStateOf("")
|
||||||
|
var serverAddress by mutableStateOf("")
|
||||||
|
var automatic by mutableStateOf(true)
|
||||||
|
var syncStatus by mutableStateOf("")
|
||||||
|
var ready by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var saving by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var syncing by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
serverAddress = repository.syncAddress()
|
||||||
|
automatic = repository.syncAutomaticEnabled()
|
||||||
|
syncStatus = repository.syncStatus()
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.observeCars().collect { cars = it }
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.observeSelectedCar().collect { car ->
|
||||||
|
if (car != null && car.id != carId) show(car)
|
||||||
|
ready = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun select(id: Long) {
|
||||||
|
repository.selectCar(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addCar() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
show(repository.addCar())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun join() {
|
||||||
|
if (syncing) return
|
||||||
|
syncing = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
val problem = repository.joinCar(joinToken)
|
||||||
|
if (problem.isNotEmpty()) {
|
||||||
|
syncStatus = problem
|
||||||
|
syncing = false
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
joinToken = ""
|
||||||
|
repository.saveAddress(serverAddress)
|
||||||
|
syncStatus = repository.syncNow()
|
||||||
|
repository.selectedCar()?.let { show(it) }
|
||||||
|
syncing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove() {
|
||||||
|
val id = carId
|
||||||
|
if (cars.size < 2 || id == 0L) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.removeCar(id)
|
||||||
|
carId = 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(onDone: () -> Unit) {
|
||||||
|
if (saving || carId == 0L) return
|
||||||
|
saving = true
|
||||||
|
repository.saveAddress(serverAddress)
|
||||||
|
repository.setSyncAutomatic(automatic)
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.saveCar(
|
||||||
|
Car(
|
||||||
|
id = carId,
|
||||||
|
carName = carName.trim().ifBlank { "New car" },
|
||||||
|
raceClass = raceClass.trim().ifBlank { DEFAULT_CLASS },
|
||||||
|
discipline = discipline.trim().ifBlank { DEFAULT_DISCIPLINE },
|
||||||
|
token = token,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun syncNow() {
|
||||||
|
if (syncing) return
|
||||||
|
repository.saveAddress(serverAddress)
|
||||||
|
repository.setSyncAutomatic(automatic)
|
||||||
|
syncing = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
syncStatus = repository.syncNow()
|
||||||
|
repository.selectedCar()?.let { refreshed ->
|
||||||
|
if (refreshed.id == carId) show(refreshed)
|
||||||
|
}
|
||||||
|
syncing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeAutomatic(enabled: Boolean) {
|
||||||
|
automatic = enabled
|
||||||
|
repository.saveAddress(serverAddress)
|
||||||
|
repository.setSyncAutomatic(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun show(car: Car) {
|
||||||
|
carId = car.id
|
||||||
|
carName = car.carName
|
||||||
|
raceClass = car.raceClass
|
||||||
|
discipline = car.discipline
|
||||||
|
token = car.token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(onBack: () -> Unit) {
|
||||||
|
val viewModel = pitViewModel { SettingsViewModel(it) }
|
||||||
|
val context = LocalContext.current
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Car") },
|
||||||
|
colors = pitTopBarColors(),
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
if (viewModel.ready) {
|
||||||
|
PitActionButton(
|
||||||
|
text = if (viewModel.saving) "Saving…" else "Save",
|
||||||
|
onClick = { viewModel.save(onBack) },
|
||||||
|
enabled = !viewModel.saving,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
if (!viewModel.ready) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PitScroll(padding) {
|
||||||
|
Text(
|
||||||
|
"New race nights start with this car. Pressures are PSI and tire heat is °F.",
|
||||||
|
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(
|
||||||
|
label = "Car",
|
||||||
|
value = viewModel.carName,
|
||||||
|
onValue = { viewModel.carName = it.take(80) },
|
||||||
|
)
|
||||||
|
WordsField(
|
||||||
|
label = "Class",
|
||||||
|
value = viewModel.raceClass,
|
||||||
|
onValue = { viewModel.raceClass = it.take(80) },
|
||||||
|
)
|
||||||
|
WordsField(
|
||||||
|
label = "Type of racing",
|
||||||
|
value = viewModel.discipline,
|
||||||
|
onValue = { viewModel.discipline = it.take(40) },
|
||||||
|
)
|
||||||
|
SectionLabel("This car's token")
|
||||||
|
Text(
|
||||||
|
"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.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
SelectionContainer {
|
||||||
|
Text(
|
||||||
|
viewModel.token.ifBlank { "Save the car and the token shows up here." },
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { shareText(context, viewModel.token) },
|
||||||
|
enabled = viewModel.token.isNotBlank(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 12.dp),
|
||||||
|
) {
|
||||||
|
Text("Share token")
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = viewModel::addCar,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp),
|
||||||
|
) {
|
||||||
|
Text("Add a car")
|
||||||
|
}
|
||||||
|
SectionLabel("Add a car from a token")
|
||||||
|
WordsField(
|
||||||
|
label = "Token from another phone",
|
||||||
|
value = viewModel.joinToken,
|
||||||
|
onValue = { viewModel.joinToken = it.take(200).trim() },
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
keyboardType = KeyboardType.Ascii,
|
||||||
|
)
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = viewModel::join,
|
||||||
|
enabled = !viewModel.syncing && viewModel.joinToken.isNotBlank(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 12.dp),
|
||||||
|
) {
|
||||||
|
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")
|
||||||
|
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.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text("Sync on its own", modifier = Modifier.weight(1f))
|
||||||
|
Switch(
|
||||||
|
checked = viewModel.automatic,
|
||||||
|
onCheckedChange = viewModel::changeAutomatic,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
WordsField(
|
||||||
|
label = "Server",
|
||||||
|
value = viewModel.serverAddress,
|
||||||
|
onValue = { viewModel.serverAddress = it.take(200).trim() },
|
||||||
|
placeholder = "http://192.168.1.20:8787",
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
keyboardType = KeyboardType.Uri,
|
||||||
|
)
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = viewModel::syncNow,
|
||||||
|
enabled = !viewModel.syncing,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(if (viewModel.syncing) "Syncing…" else "Sync now")
|
||||||
|
}
|
||||||
|
if (viewModel.syncStatus.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
viewModel.syncStatus,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
package com.racingapp.pitlog.ui.setup
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
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.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.racingapp.pitlog.data.Repository
|
||||||
|
import com.racingapp.pitlog.data.SetupSheet
|
||||||
|
import com.racingapp.pitlog.model.parseDecimal
|
||||||
|
import com.racingapp.pitlog.model.parseIntField
|
||||||
|
import com.racingapp.pitlog.model.sanitizeDecimal
|
||||||
|
import com.racingapp.pitlog.model.sanitizeInt
|
||||||
|
import com.racingapp.pitlog.model.sanitizeSignedDecimal
|
||||||
|
import com.racingapp.pitlog.model.setupDiffs
|
||||||
|
import com.racingapp.pitlog.ui.FieldRow
|
||||||
|
import com.racingapp.pitlog.ui.NumberField
|
||||||
|
import com.racingapp.pitlog.ui.PitActionButton
|
||||||
|
import com.racingapp.pitlog.ui.PitScroll
|
||||||
|
import com.racingapp.pitlog.ui.SectionLabel
|
||||||
|
import com.racingapp.pitlog.ui.pitViewModel
|
||||||
|
import com.racingapp.pitlog.ui.theme.pitTopBarColors
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private data class CarSetupForm(
|
||||||
|
val cross: String = "",
|
||||||
|
val springLf: String = "",
|
||||||
|
val springRf: String = "",
|
||||||
|
val springLr: String = "",
|
||||||
|
val springRr: String = "",
|
||||||
|
val camberLf: String = "",
|
||||||
|
val camberRf: String = "",
|
||||||
|
val casterLf: String = "",
|
||||||
|
val casterRf: String = "",
|
||||||
|
val swayBarHole: String = "",
|
||||||
|
val blockLr: String = "",
|
||||||
|
val blockRr: String = "",
|
||||||
|
val toeFront: String = "",
|
||||||
|
val toeRear: String = "",
|
||||||
|
val note: String = "",
|
||||||
|
) {
|
||||||
|
fun toSheet(id: Long, recordedAt: Long) = SetupSheet(
|
||||||
|
id = id,
|
||||||
|
recordedAt = recordedAt,
|
||||||
|
note = note.trim(),
|
||||||
|
crossPercent = parseDecimal(cross),
|
||||||
|
springLf = parseIntField(springLf),
|
||||||
|
springRf = parseIntField(springRf),
|
||||||
|
springLr = parseIntField(springLr),
|
||||||
|
springRr = parseIntField(springRr),
|
||||||
|
camberLf = parseDecimal(camberLf),
|
||||||
|
camberRf = parseDecimal(camberRf),
|
||||||
|
casterLf = parseDecimal(casterLf),
|
||||||
|
casterRf = parseDecimal(casterRf),
|
||||||
|
swayBarHole = parseIntField(swayBarHole),
|
||||||
|
blockLr = parseDecimal(blockLr),
|
||||||
|
blockRr = parseDecimal(blockRr),
|
||||||
|
toeFront = parseDecimal(toeFront),
|
||||||
|
toeRear = parseDecimal(toeRear),
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(sheet: SetupSheet, keepNote: Boolean) = CarSetupForm(
|
||||||
|
cross = decimal(sheet.crossPercent, 1),
|
||||||
|
springLf = sheet.springLf?.toString().orEmpty(),
|
||||||
|
springRf = sheet.springRf?.toString().orEmpty(),
|
||||||
|
springLr = sheet.springLr?.toString().orEmpty(),
|
||||||
|
springRr = sheet.springRr?.toString().orEmpty(),
|
||||||
|
camberLf = decimal(sheet.camberLf, 1),
|
||||||
|
camberRf = decimal(sheet.camberRf, 1),
|
||||||
|
casterLf = decimal(sheet.casterLf, 1),
|
||||||
|
casterRf = decimal(sheet.casterRf, 1),
|
||||||
|
swayBarHole = sheet.swayBarHole?.toString().orEmpty(),
|
||||||
|
blockLr = decimal(sheet.blockLr, 1),
|
||||||
|
blockRr = decimal(sheet.blockRr, 1),
|
||||||
|
toeFront = decimal(sheet.toeFront, 3),
|
||||||
|
toeRear = decimal(sheet.toeRear, 3),
|
||||||
|
note = if (keepNote) sheet.note else "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decimal(value: Double?, places: Int): String =
|
||||||
|
if (value == null) "" else String.format(Locale.US, "%.${places}f", value).trimEnd('0').trimEnd('.')
|
||||||
|
|
||||||
|
private class CarSetupEditorViewModel(
|
||||||
|
private val repository: Repository,
|
||||||
|
private val loggingChange: Boolean,
|
||||||
|
) : ViewModel() {
|
||||||
|
var form by mutableStateOf(CarSetupForm())
|
||||||
|
private set
|
||||||
|
var ready by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var missing by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var saving by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var error by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
private var editing: SetupSheet? = null
|
||||||
|
private var previous: SetupSheet? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val sheets = repository.observeSetupSheets().first()
|
||||||
|
if (loggingChange) {
|
||||||
|
val latest = sheets.lastOrNull()
|
||||||
|
if (latest == null) {
|
||||||
|
missing = true
|
||||||
|
} else {
|
||||||
|
previous = latest
|
||||||
|
form = CarSetupForm.from(latest, keepNote = false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
editing = sheets.firstOrNull()
|
||||||
|
editing?.let { form = CarSetupForm.from(it, keepNote = true) }
|
||||||
|
}
|
||||||
|
ready = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(block: (CarSetupForm) -> CarSetupForm) {
|
||||||
|
form = block(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeError() {
|
||||||
|
error = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(onDone: () -> Unit) {
|
||||||
|
if (saving) return
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (loggingChange) {
|
||||||
|
val prior = previous ?: return
|
||||||
|
val next = form.toSheet(0L, now)
|
||||||
|
if (setupDiffs(prior, next).isEmpty() && next.note.isBlank()) {
|
||||||
|
error = "Enter what changed."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.logSetupChange(next)
|
||||||
|
onDone()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
saving = false
|
||||||
|
error = "Couldn't save that change."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
saving = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val existing = editing
|
||||||
|
val sheet = form.toSheet(existing?.id ?: 0L, existing?.recordedAt ?: now)
|
||||||
|
repository.saveOriginalSetup(sheet)
|
||||||
|
onDone()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
saving = false
|
||||||
|
error = "Couldn't save the original setup."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CarSetupEditorScreen(
|
||||||
|
loggingChange: Boolean,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
val viewModel = pitViewModel { CarSetupEditorViewModel(it, loggingChange) }
|
||||||
|
val snackbar = remember { SnackbarHostState() }
|
||||||
|
LaunchedEffect(viewModel.error) {
|
||||||
|
val message = viewModel.error ?: return@LaunchedEffect
|
||||||
|
snackbar.showSnackbar(message)
|
||||||
|
viewModel.consumeError()
|
||||||
|
}
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbar) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(if (loggingChange) "Log a change" else "Original setup") },
|
||||||
|
colors = pitTopBarColors(),
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
if (viewModel.ready && !viewModel.missing) {
|
||||||
|
PitActionButton(
|
||||||
|
text = when {
|
||||||
|
viewModel.saving -> "Saving…"
|
||||||
|
loggingChange -> "Save change"
|
||||||
|
else -> "Save original"
|
||||||
|
},
|
||||||
|
onClick = { viewModel.save(onBack) },
|
||||||
|
enabled = !viewModel.saving,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
when {
|
||||||
|
!viewModel.ready -> {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModel.missing -> {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||||
|
Text("Enter the original setup first")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val form = viewModel.form
|
||||||
|
PitScroll(padding) {
|
||||||
|
Text(
|
||||||
|
if (loggingChange) {
|
||||||
|
"The original stays put. Saving here adds what changed."
|
||||||
|
} else {
|
||||||
|
"This is the setup the car starts from. Fixing it does not add a change."
|
||||||
|
},
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
SectionLabel("Crossweight")
|
||||||
|
NumberField(
|
||||||
|
label = "Cross %",
|
||||||
|
value = form.cross,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(cross = sanitizeDecimal(value, 2, 1)) } },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
SectionLabel("Springs, lb")
|
||||||
|
FieldRow {
|
||||||
|
NumberField(
|
||||||
|
label = "LF",
|
||||||
|
value = form.springLf,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(springLf = sanitizeInt(value, 4)) } },
|
||||||
|
decimal = false,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
NumberField(
|
||||||
|
label = "RF",
|
||||||
|
value = form.springRf,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(springRf = sanitizeInt(value, 4)) } },
|
||||||
|
decimal = false,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FieldRow {
|
||||||
|
NumberField(
|
||||||
|
label = "LR",
|
||||||
|
value = form.springLr,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(springLr = sanitizeInt(value, 4)) } },
|
||||||
|
decimal = false,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
NumberField(
|
||||||
|
label = "RR",
|
||||||
|
value = form.springRr,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(springRr = sanitizeInt(value, 4)) } },
|
||||||
|
decimal = false,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SectionLabel("Blocks, inches")
|
||||||
|
FieldRow {
|
||||||
|
NumberField(
|
||||||
|
label = "LR",
|
||||||
|
value = form.blockLr,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(blockLr = sanitizeDecimal(value, 1, 1)) } },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
NumberField(
|
||||||
|
label = "RR",
|
||||||
|
value = form.blockRr,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(blockRr = sanitizeDecimal(value, 1, 1)) } },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SectionLabel("Sway bar")
|
||||||
|
NumberField(
|
||||||
|
label = "Setting",
|
||||||
|
value = form.swayBarHole,
|
||||||
|
onValue = { value -> viewModel.update { it.copy(swayBarHole = sanitizeInt(value, 2)) } },
|
||||||
|
decimal = false,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
SectionLabel("Camber, degrees")
|
||||||
|
FieldRow {
|
||||||
|
SignedField("LF", form.camberLf, { value -> viewModel.update { it.copy(camberLf = value) } }, Modifier.weight(1f))
|
||||||
|
SignedField("RF", form.camberRf, { value -> viewModel.update { it.copy(camberRf = value) } }, Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
SectionLabel("Caster, degrees")
|
||||||
|
FieldRow {
|
||||||
|
SignedField("LF", form.casterLf, { value -> viewModel.update { it.copy(casterLf = value) } }, Modifier.weight(1f))
|
||||||
|
SignedField("RF", form.casterRf, { value -> viewModel.update { it.copy(casterRf = value) } }, Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
SectionLabel("Toe, inches")
|
||||||
|
Text(
|
||||||
|
"Positive is toe out. Straight is 0. Asphalt front toe is usually 1/16 to 1/8 inch out.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
FieldRow {
|
||||||
|
SignedField("Front", form.toeFront, { value -> viewModel.update { it.copy(toeFront = value) } }, Modifier.weight(1f), 1, 3)
|
||||||
|
SignedField("Rear", form.toeRear, { value -> viewModel.update { it.copy(toeRear = value) } }, Modifier.weight(1f), 1, 3)
|
||||||
|
}
|
||||||
|
SectionLabel(if (loggingChange) "What changed" else "Notes")
|
||||||
|
OutlinedTextField(
|
||||||
|
value = form.note,
|
||||||
|
onValueChange = { value ->
|
||||||
|
if (value.length <= 1000) viewModel.update { it.copy(note = value) }
|
||||||
|
},
|
||||||
|
label = { Text(if (loggingChange) "Spring, toe, anything else" else "Notes") },
|
||||||
|
minLines = 3,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SignedField(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
onValue: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
wholeDigits: Int = 2,
|
||||||
|
fractionDigits: Int = 1,
|
||||||
|
) {
|
||||||
|
Column(modifier) {
|
||||||
|
NumberField(
|
||||||
|
label = label,
|
||||||
|
value = value,
|
||||||
|
onValue = { onValue(sanitizeSignedDecimal(it, wholeDigits, fractionDigits)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val next = when {
|
||||||
|
value.startsWith("-") -> value.removePrefix("-")
|
||||||
|
value.isEmpty() -> "-"
|
||||||
|
else -> "-$value"
|
||||||
|
}
|
||||||
|
onValue(next)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(if (value.startsWith("-")) "Make positive" else "Make negative")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package com.racingapp.pitlog.ui.setup
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.automirrored.filled.ArrowBack
|
||||||
|
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.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.racingapp.pitlog.data.Repository
|
||||||
|
import com.racingapp.pitlog.data.SetupSheet
|
||||||
|
import com.racingapp.pitlog.model.setupDiffs
|
||||||
|
import com.racingapp.pitlog.model.setupLines
|
||||||
|
import com.racingapp.pitlog.ui.PitActionButton
|
||||||
|
import com.racingapp.pitlog.ui.SectionLabel
|
||||||
|
import com.racingapp.pitlog.ui.formatClock
|
||||||
|
import com.racingapp.pitlog.ui.formatLongDate
|
||||||
|
import com.racingapp.pitlog.ui.pitViewModel
|
||||||
|
import com.racingapp.pitlog.ui.theme.pitTopBarColors
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private class CarSetupViewModel(repository: Repository) : ViewModel() {
|
||||||
|
var sheets by mutableStateOf<List<SetupSheet>>(emptyList())
|
||||||
|
private set
|
||||||
|
var ready by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.observeSetupSheets().collect { loaded ->
|
||||||
|
sheets = loaded
|
||||||
|
ready = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CarSetupScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onEditOriginal: () -> Unit,
|
||||||
|
onLogChange: () -> Unit,
|
||||||
|
) {
|
||||||
|
val viewModel = pitViewModel { CarSetupViewModel(it) }
|
||||||
|
val sheets = viewModel.sheets
|
||||||
|
val original = sheets.firstOrNull()
|
||||||
|
val latest = sheets.lastOrNull()
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Car setup") },
|
||||||
|
colors = pitTopBarColors(),
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
if (viewModel.ready) {
|
||||||
|
PitActionButton(
|
||||||
|
text = if (original == null) "Enter the original setup" else "Log a change",
|
||||||
|
onClick = if (original == null) onEditOriginal else onLogChange,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
if (!viewModel.ready) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else if (original == null) {
|
||||||
|
Column(Modifier.padding(padding).padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
"Enter the setup the car is starting from. That sheet stays the original. Later changes, like a spring rate or the toe, are added under it.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val changes = sheets.zipWithNext().reversed()
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = padding,
|
||||||
|
) {
|
||||||
|
if (latest != null && latest.id != original.id) {
|
||||||
|
item {
|
||||||
|
SheetBlock(
|
||||||
|
title = "Where it is now",
|
||||||
|
lines = setupLines(latest),
|
||||||
|
note = "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
SheetBlock(
|
||||||
|
title = "Original",
|
||||||
|
lines = setupLines(original).ifEmpty { listOf("No numbers on the original sheet.") },
|
||||||
|
note = original.note,
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = onEditOriginal,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
|
) {
|
||||||
|
Text("Fix the original")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
SectionLabel("Changes")
|
||||||
|
if (changes.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"No changes yet. The original is still what is on the car.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(changes, key = { it.second.id }) { (before, after) ->
|
||||||
|
ChangeCard(before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SheetBlock(title: String, lines: List<String>, note: String) {
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||||
|
lines.forEach { Text(it) }
|
||||||
|
if (note.isNotBlank()) {
|
||||||
|
Text(note, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChangeCard(before: SetupSheet, after: SetupSheet) {
|
||||||
|
val diffs = setupDiffs(before, after)
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(formatWhen(after.recordedAt), style = MaterialTheme.typography.titleMedium)
|
||||||
|
if (diffs.isEmpty() && after.note.isBlank()) {
|
||||||
|
Text("Saved with no number changes.", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
diffs.forEach { Text(it) }
|
||||||
|
if (after.note.isNotBlank()) {
|
||||||
|
Text(after.note, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatWhen(millis: Long): String {
|
||||||
|
val day = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate().toEpochDay()
|
||||||
|
return "${formatLongDate(day)} · ${formatClock(millis)}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.racingapp.pitlog.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
||||||
|
private val PitColors = darkColorScheme(
|
||||||
|
primary = Color(0xFFE0B15A),
|
||||||
|
onPrimary = Color(0xFF1A1406),
|
||||||
|
primaryContainer = Color(0xFF8A6A2E),
|
||||||
|
onPrimaryContainer = Color(0xFFFFE7B8),
|
||||||
|
secondary = Color(0xFF7EB6D6),
|
||||||
|
onSecondary = Color(0xFF06202C),
|
||||||
|
tertiary = Color(0xFFE36A4C),
|
||||||
|
onTertiary = Color(0xFF2A0C06),
|
||||||
|
background = Color(0xFF101114),
|
||||||
|
onBackground = Color(0xFFF4F1E8),
|
||||||
|
surface = Color(0xFF1B1E25),
|
||||||
|
onSurface = Color(0xFFF4F1E8),
|
||||||
|
surfaceVariant = Color(0xFF262A33),
|
||||||
|
onSurfaceVariant = Color(0xFFB7B2A6),
|
||||||
|
outline = Color(0xFF3A3F4A),
|
||||||
|
error = Color(0xFFE36A4C),
|
||||||
|
onError = Color(0xFF2A0C06),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val PitType = Typography().let { base ->
|
||||||
|
base.copy(
|
||||||
|
headlineMedium = base.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
titleLarge = base.titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun pitTopBarColors() = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onBackground,
|
||||||
|
actionIconContentColor = MaterialTheme.colorScheme.primary,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PitLogTheme(content: @Composable () -> Unit) {
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = PitColors,
|
||||||
|
typography = PitType,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#E0B15A"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#1A1408"
|
||||||
|
android:pathData="M32,26 H40 A4,4 0 0 1 44,30 V44 A4,4 0 0 1 40,48 H32 A4,4 0 0 1 28,44 V30 A4,4 0 0 1 32,26 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#1A1408"
|
||||||
|
android:pathData="M68,26 H76 A4,4 0 0 1 80,30 V44 A4,4 0 0 1 76,48 H68 A4,4 0 0 1 64,44 V30 A4,4 0 0 1 68,26 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#1A1408"
|
||||||
|
android:pathData="M32,60 H40 A4,4 0 0 1 44,64 V78 A4,4 0 0 1 40,82 H32 A4,4 0 0 1 28,78 V64 A4,4 0 0 1 32,60 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#1A1408"
|
||||||
|
android:pathData="M68,60 H76 A4,4 0 0 1 80,64 V78 A4,4 0 0 1 76,82 H68 A4,4 0 0 1 64,78 V64 A4,4 0 0 1 68,60 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#1A1408"
|
||||||
|
android:pathData="M50,34 H58 A6,6 0 0 1 64,40 V70 A6,6 0 0 1 58,76 H50 A6,6 0 0 1 44,70 V40 A6,6 0 0 1 50,34 Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Pit Log</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.PitLog" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">#101114</item>
|
||||||
|
<item name="android:navigationBarColor">#101114</item>
|
||||||
|
<item name="android:windowBackground">#101114</item>
|
||||||
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path name="exports" path="exports/" />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package com.racingapp.pitlog
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.captureToImage
|
||||||
|
import androidx.compose.ui.test.hasText
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performScrollTo
|
||||||
|
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.RaceNight
|
||||||
|
import com.racingapp.pitlog.data.RunLog
|
||||||
|
import java.io.File
|
||||||
|
import java.time.LocalDate
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
class PreviewApp : RacingApp() {
|
||||||
|
override fun onCreate() {
|
||||||
|
deleteDatabase("pitlog.db")
|
||||||
|
super.onCreate()
|
||||||
|
runBlocking(Dispatchers.IO) {
|
||||||
|
val nightId = repository.saveNight(
|
||||||
|
RaceNight(
|
||||||
|
dateEpochDay = LocalDate.now().toEpochDay(),
|
||||||
|
trackName = "Elko Speedway",
|
||||||
|
carName = DEFAULT_CAR,
|
||||||
|
raceClass = DEFAULT_CLASS,
|
||||||
|
discipline = DEFAULT_DISCIPLINE,
|
||||||
|
airTempF = 68,
|
||||||
|
notes = "Rubber coming in. Clear and cooling off.",
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
crossPercent = 53.5,
|
||||||
|
springLf = 950,
|
||||||
|
springRf = 1000,
|
||||||
|
springLr = 200,
|
||||||
|
springRr = 175,
|
||||||
|
camberLf = 2.0,
|
||||||
|
camberRf = -7.0,
|
||||||
|
casterLf = 2.0,
|
||||||
|
casterRf = 4.0,
|
||||||
|
swayBarHole = 4,
|
||||||
|
blockLr = 2.0,
|
||||||
|
blockRr = 2.0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
repository.saveRun(
|
||||||
|
RunLog(
|
||||||
|
nightId = nightId,
|
||||||
|
session = "Heat",
|
||||||
|
tireSet = "Scuffs",
|
||||||
|
laps = 15,
|
||||||
|
finish = 4,
|
||||||
|
staggerInches = 1.25,
|
||||||
|
coldLf = 16.0,
|
||||||
|
coldRf = 22.0,
|
||||||
|
coldLr = 15.0,
|
||||||
|
coldRr = 18.0,
|
||||||
|
hotLf = 20.5,
|
||||||
|
hotRf = 28.0,
|
||||||
|
hotLr = 18.5,
|
||||||
|
hotRr = 23.5,
|
||||||
|
lfInside = 168,
|
||||||
|
lfMiddle = 158,
|
||||||
|
lfOutside = 149,
|
||||||
|
rfInside = 214,
|
||||||
|
rfMiddle = 198,
|
||||||
|
rfOutside = 182,
|
||||||
|
lrInside = 162,
|
||||||
|
lrMiddle = 154,
|
||||||
|
lrOutside = 146,
|
||||||
|
rrInside = 196,
|
||||||
|
rrMiddle = 180,
|
||||||
|
rrOutside = 168,
|
||||||
|
handling = "Loose",
|
||||||
|
handlingWhere = "Exit",
|
||||||
|
notes = "Freed up on exit. RF still the hottest.",
|
||||||
|
recordedAt = System.currentTimeMillis(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Config(sdk = [34], application = PreviewApp::class, qualifiers = "w411dp-h1200dp-420dpi")
|
||||||
|
class PreviewScreenshotTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<MainActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun capturePitLogScreens() {
|
||||||
|
waitForText("Elko Speedway")
|
||||||
|
save("01-home")
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Car setup").performClick()
|
||||||
|
waitForText("starting from")
|
||||||
|
save("10-car-setup-log")
|
||||||
|
composeRule.onNodeWithText("Enter the original setup").performClick()
|
||||||
|
waitForText("Positive is toe out")
|
||||||
|
composeRule.onNodeWithText("Positive is toe out", substring = true).performScrollTo()
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
save("11-original-setup")
|
||||||
|
composeRule.onNodeWithContentDescription("Back").performClick()
|
||||||
|
waitForText("starting from")
|
||||||
|
composeRule.onNodeWithContentDescription("Back").performClick()
|
||||||
|
waitForText("Elko Speedway")
|
||||||
|
|
||||||
|
composeRule.onNodeWithContentDescription("Car").performClick()
|
||||||
|
waitForText("Sync on its own")
|
||||||
|
composeRule.onNodeWithText("Sync on its own").performScrollTo()
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
save("12-sync")
|
||||||
|
composeRule.onNodeWithContentDescription("Back").performClick()
|
||||||
|
waitForText("Elko Speedway")
|
||||||
|
|
||||||
|
composeRule.onNodeWithContentDescription("New race night").performClick()
|
||||||
|
waitForText("Use weather here")
|
||||||
|
save("07-new-night")
|
||||||
|
composeRule.onNodeWithText("Track").performClick()
|
||||||
|
waitForText("Elko Speedway")
|
||||||
|
save("08-saved-tracks")
|
||||||
|
composeRule.onNodeWithContentDescription("Back").performClick()
|
||||||
|
waitForText("Elko Speedway")
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Log a run at Elko Speedway").performClick()
|
||||||
|
waitForText("inside, middle, outside")
|
||||||
|
save("02-log-a-run")
|
||||||
|
|
||||||
|
composeRule.onNodeWithContentDescription("Back").performClick()
|
||||||
|
waitForText("Elko Speedway")
|
||||||
|
composeRule.onNodeWithText("Elko Speedway").performClick()
|
||||||
|
waitForText("Rubber coming in")
|
||||||
|
save("03-race-night")
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Edit car setup").performClick()
|
||||||
|
waitForText("Cross %")
|
||||||
|
save("06-car-setup")
|
||||||
|
composeRule.onNodeWithContentDescription("Back").performClick()
|
||||||
|
waitForText("Rubber coming in")
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Heat").performClick()
|
||||||
|
waitForText("Left front")
|
||||||
|
save("04-run-sheet")
|
||||||
|
|
||||||
|
composeRule.onNodeWithContentDescription("Setup").performClick()
|
||||||
|
waitForText("Middle hottest")
|
||||||
|
save("05-setup")
|
||||||
|
composeRule.onNodeWithText("Middle, tight").performScrollTo()
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
save("09-what-to-change")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForText(text: String) {
|
||||||
|
composeRule.waitUntil(timeoutMillis = 8_000) {
|
||||||
|
composeRule.onAllNodes(hasText(text, substring = true)).fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
private fun save(name: String) {
|
||||||
|
val bitmap = composeRule.onRoot().captureToImage().asAndroidBitmap()
|
||||||
|
val dir = File("D:/Cloudreve/Android Apps/RacingAPP/preview")
|
||||||
|
dir.mkdirs()
|
||||||
|
val file = File(dir, "$name.png")
|
||||||
|
file.outputStream().use { out ->
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||||
|
}
|
||||||
|
println("PREVIEW ${file.absolutePath} ${bitmap.width}x${bitmap.height}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.racingapp.pitlog.data
|
||||||
|
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34])
|
||||||
|
class SyncMigrationTest {
|
||||||
|
@Test
|
||||||
|
fun anExistingNightSurvivesTheSyncColumns() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
|
||||||
|
val name = "sync-migration.db"
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
val file = context.getDatabasePath(name)
|
||||||
|
file.parentFile?.mkdirs()
|
||||||
|
val raw = SQLiteDatabase.openOrCreateDatabase(file, null)
|
||||||
|
raw.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE car_profile (
|
||||||
|
id INTEGER NOT NULL,
|
||||||
|
carName TEXT NOT NULL,
|
||||||
|
raceClass TEXT NOT NULL,
|
||||||
|
discipline TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(id)
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
raw.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE race_nights (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
dateEpochDay INTEGER NOT NULL,
|
||||||
|
trackName TEXT NOT NULL,
|
||||||
|
carName TEXT NOT NULL,
|
||||||
|
raceClass TEXT NOT NULL,
|
||||||
|
discipline TEXT NOT NULL,
|
||||||
|
airTempF INTEGER,
|
||||||
|
notes TEXT NOT NULL,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
crossPercent REAL,
|
||||||
|
springLf INTEGER,
|
||||||
|
springRf INTEGER,
|
||||||
|
springLr INTEGER,
|
||||||
|
springRr INTEGER,
|
||||||
|
camberLf REAL,
|
||||||
|
camberRf REAL,
|
||||||
|
casterLf REAL,
|
||||||
|
casterRf REAL,
|
||||||
|
swayBarHole INTEGER,
|
||||||
|
blockLr REAL,
|
||||||
|
blockRr REAL
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
raw.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE runs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
nightId INTEGER NOT NULL,
|
||||||
|
session TEXT NOT NULL,
|
||||||
|
tireSet TEXT NOT NULL,
|
||||||
|
laps INTEGER,
|
||||||
|
finish INTEGER,
|
||||||
|
staggerInches REAL,
|
||||||
|
coldLf REAL, coldRf REAL, coldLr REAL, coldRr REAL,
|
||||||
|
hotLf REAL, hotRf REAL, hotLr REAL, hotRr REAL,
|
||||||
|
lfInside INTEGER, lfMiddle INTEGER, lfOutside INTEGER,
|
||||||
|
rfInside INTEGER, rfMiddle INTEGER, rfOutside INTEGER,
|
||||||
|
lrInside INTEGER, lrMiddle INTEGER, lrOutside INTEGER,
|
||||||
|
rrInside INTEGER, rrMiddle INTEGER, rrOutside INTEGER,
|
||||||
|
handling TEXT NOT NULL,
|
||||||
|
handlingWhere TEXT NOT NULL,
|
||||||
|
notes TEXT NOT NULL,
|
||||||
|
recordedAt INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(nightId) REFERENCES race_nights(id) ON UPDATE NO ACTION ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
raw.execSQL("CREATE INDEX index_runs_nightId ON runs (nightId)")
|
||||||
|
raw.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE setup_sheets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
recordedAt INTEGER NOT NULL,
|
||||||
|
note TEXT NOT NULL,
|
||||||
|
crossPercent REAL,
|
||||||
|
springLf INTEGER, springRf INTEGER, springLr INTEGER, springRr INTEGER,
|
||||||
|
camberLf REAL, camberRf REAL, casterLf REAL, casterRf REAL,
|
||||||
|
swayBarHole INTEGER, blockLr REAL, blockRr REAL, toeFront REAL, toeRear REAL
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
raw.execSQL("INSERT INTO car_profile (id, carName, raceClass, discipline) VALUES (1, 'Camaro', 'Hobby Stock', 'Oval')")
|
||||||
|
raw.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT INTO race_nights (dateEpochDay, trackName, carName, raceClass, discipline, airTempF, notes, createdAt)
|
||||||
|
VALUES (20000, 'Tri-City Raceway', 'Camaro', 'Hobby Stock', 'Oval', 70, 'Clear', 50)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
raw.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT INTO runs (nightId, session, tireSet, handling, handlingWhere, notes, recordedAt)
|
||||||
|
VALUES (1, 'Heat', 'Scuffs', 'Loose', 'Exit', '', 60)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
raw.version = 7
|
||||||
|
raw.close()
|
||||||
|
|
||||||
|
val database = Room.databaseBuilder(context, AppDatabase::class.java, name)
|
||||||
|
.addMigrations(MIGRATION_7_8, MIGRATION_8_9)
|
||||||
|
.build()
|
||||||
|
runBlocking {
|
||||||
|
val night = database.raceDao().nightById(1)
|
||||||
|
val run = database.raceDao().runById(1)
|
||||||
|
assertEquals("Tri-City Raceway", night?.trackName)
|
||||||
|
assertEquals(70, night?.airTempF)
|
||||||
|
assertTrue(!night?.syncId.isNullOrBlank())
|
||||||
|
assertTrue(night?.pending == true)
|
||||||
|
assertEquals(night?.syncId, run?.nightSyncId)
|
||||||
|
assertEquals("Heat", run?.session)
|
||||||
|
val car = database.raceDao().carById(1)
|
||||||
|
assertEquals("Camaro", car?.carName)
|
||||||
|
assertEquals(1L, car?.updatedAt)
|
||||||
|
assertEquals(car?.id, night?.carId)
|
||||||
|
}
|
||||||
|
database.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.racingapp.pitlog.model
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class LocalWeatherTest {
|
||||||
|
@Test
|
||||||
|
fun parsesAirTemperatureAndSky() {
|
||||||
|
val weather = parseOpenMeteo(
|
||||||
|
"""{"current_units":{"temperature_2m":"°F","weather_code":"wmo code"},"current":{"temperature_2m":67.6,"weather_code":2}}""",
|
||||||
|
)
|
||||||
|
assertEquals(68, weather.airF)
|
||||||
|
assertEquals("Partly cloudy", weather.summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unknownSkyStillKeepsTheTemperature() {
|
||||||
|
val weather = parseOpenMeteo("""{"current":{"temperature_2m":41.2,"weather_code":999}}""")
|
||||||
|
assertEquals(41, weather.airF)
|
||||||
|
assertEquals("Weather", weather.summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.racingapp.pitlog.model
|
||||||
|
|
||||||
|
import com.racingapp.pitlog.data.SetupSheet
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SetupLogTest {
|
||||||
|
@Test
|
||||||
|
fun diffsOnlyTheFieldsThatMoved() {
|
||||||
|
val original = SetupSheet(
|
||||||
|
recordedAt = 1L,
|
||||||
|
springRf = 1000,
|
||||||
|
toeFront = 0.125,
|
||||||
|
swayBarHole = 4,
|
||||||
|
)
|
||||||
|
val later = original.copy(
|
||||||
|
springRf = 1050,
|
||||||
|
toeFront = 0.063,
|
||||||
|
swayBarHole = 3,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
"RF spring 1000 lb → 1050 lb",
|
||||||
|
"Sway bar Setting 4 → Setting 3",
|
||||||
|
"Front toe 0.125 in → 0.063 in",
|
||||||
|
),
|
||||||
|
setupDiffs(original, later),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUnchangedSheetHasNoDiff() {
|
||||||
|
val sheet = SetupSheet(recordedAt = 1L, springLf = 950, camberRf = -7.0)
|
||||||
|
assertTrue(setupDiffs(sheet, sheet.copy(note = "perch tweak")).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun originalLinesSkipBlankFields() {
|
||||||
|
val lines = setupLines(SetupSheet(recordedAt = 1L, crossPercent = 53.5, blockLr = 2.0))
|
||||||
|
assertEquals(listOf("Cross 53.5%", "LR block 2 in"), lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package com.racingapp.pitlog.model
|
||||||
|
|
||||||
|
import com.racingapp.pitlog.data.NightWithRuns
|
||||||
|
import com.racingapp.pitlog.data.RaceNight
|
||||||
|
import com.racingapp.pitlog.data.RunLog
|
||||||
|
import java.time.LocalDate
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class TireSheetTest {
|
||||||
|
@Test
|
||||||
|
fun buildIsHotMinusCold() {
|
||||||
|
val sheet = TireSheet(
|
||||||
|
cold = Pressures(lf = 18.0, rf = 22.0),
|
||||||
|
hot = Pressures(lf = 24.5, rf = 22.0),
|
||||||
|
)
|
||||||
|
assertEquals(6.5, sheet.build(Corner.LF)!!, 0.001)
|
||||||
|
assertEquals(0.0, sheet.build(Corner.RF)!!, 0.001)
|
||||||
|
assertNull(sheet.build(Corner.LR))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun averageSkipsMissingTempsAndSpreadIsOutsideMinusInside() {
|
||||||
|
val reading = HeatReading(inside = 180, middle = null, outside = 160)
|
||||||
|
assertEquals(170.0, reading.average!!, 0.001)
|
||||||
|
assertEquals(-20, reading.spread)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hottestCornerAndRearSplit() {
|
||||||
|
val sheet = TireSheet(
|
||||||
|
hot = Pressures(lr = 20.0, rr = 24.5),
|
||||||
|
heat = mapOf(
|
||||||
|
Corner.LF to HeatReading(150, 150, 150),
|
||||||
|
Corner.RF to HeatReading(200, 190, 180),
|
||||||
|
Corner.LR to HeatReading(140, 140, 140),
|
||||||
|
Corner.RR to HeatReading(160, 160, 160),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(Corner.RF, sheet.hottest()?.first)
|
||||||
|
assertEquals(4.5, sheet.rearHotSplit()!!, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun flagsPressureAndTempThatAreWayOff() {
|
||||||
|
val high = suspiciousReadings(TireSheet(hot = Pressures(rf = 90.0)))
|
||||||
|
assertTrue(high.any { it.contains("RF") })
|
||||||
|
assertTrue(suspiciousReadings(TireSheet(hot = Pressures(rf = 28.0))).isEmpty())
|
||||||
|
val coldTire = suspiciousReadings(
|
||||||
|
TireSheet(heat = mapOf(Corner.LF to HeatReading(inside = 20))),
|
||||||
|
)
|
||||||
|
assertTrue(coldTire.any { it.contains("LF inside") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun heatAndFeelBecomeSetupTips() {
|
||||||
|
val highCrown = TireSheet(heat = mapOf(Corner.RF to HeatReading(180, 200, 175)))
|
||||||
|
assertTrue(setupTips(highCrown).any { it.say.contains("Pressure is high") })
|
||||||
|
|
||||||
|
val low = TireSheet(heat = mapOf(Corner.LF to HeatReading(190, 160, 185)))
|
||||||
|
assertTrue(setupTips(low).any { it.say.contains("Pressure is low") })
|
||||||
|
|
||||||
|
val evenRight = TireSheet(heat = mapOf(Corner.RF to HeatReading(200, 194, 192)))
|
||||||
|
assertTrue(setupTips(evenRight).none { it.about == "Right front" })
|
||||||
|
|
||||||
|
val tooMuchRight = TireSheet(heat = mapOf(Corner.RF to HeatReading(214, 198, 182)))
|
||||||
|
assertTrue(setupTips(tooMuchRight).any { it.say.contains("negative camber") && it.say.contains("Take some out") })
|
||||||
|
|
||||||
|
val leftInside = TireSheet(heat = mapOf(Corner.LF to HeatReading(168, 158, 149)))
|
||||||
|
assertTrue(setupTips(leftInside).any { it.say.contains("Add positive camber") })
|
||||||
|
|
||||||
|
val looseExit = setupTips(TireSheet(), handling = "Loose", handlingWhere = "Exit")
|
||||||
|
assertEquals("Loose on exit", looseExit.single().about)
|
||||||
|
assertTrue(looseExit.single().say.contains("stagger"))
|
||||||
|
|
||||||
|
val looseEntry = setupTips(TireSheet(), handling = "Loose", handlingWhere = "Entry")
|
||||||
|
assertTrue(looseEntry.single().say.contains("brake bias"))
|
||||||
|
assertTrue(looseEntry.none { it.say.contains("rebound") || it.say.contains("compression") })
|
||||||
|
|
||||||
|
val missingEdge = TireSheet(heat = mapOf(Corner.RR to HeatReading(inside = 200)))
|
||||||
|
assertTrue(setupTips(missingEdge).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun chassisNumbersBecomeTips() {
|
||||||
|
val tightHigh = chassisTips(ChassisSetup(crossPercent = 58.0), handling = "Tight")
|
||||||
|
assertTrue(tightHigh.single().say.contains("Take crossweight out"))
|
||||||
|
|
||||||
|
val looseLow = chassisTips(ChassisSetup(crossPercent = 50.0), handling = "Loose")
|
||||||
|
assertTrue(looseLow.single().say.contains("Add crossweight"))
|
||||||
|
|
||||||
|
val alreadyLow = chassisTips(ChassisSetup(crossPercent = 50.0), handling = "Tight")
|
||||||
|
assertTrue(alreadyLow.single().say.contains("Don't take more out"))
|
||||||
|
|
||||||
|
assertTrue(chassisTips(ChassisSetup(crossPercent = 53.5)).isEmpty())
|
||||||
|
|
||||||
|
val split = chassisTips(ChassisSetup(springLf = 900, springRf = 1050))
|
||||||
|
assertTrue(split.single().say.contains("150 lb"))
|
||||||
|
|
||||||
|
assertTrue(chassisTips(ChassisSetup(springLf = 950, springRf = 1000)).isEmpty())
|
||||||
|
|
||||||
|
assertTrue(chassisTips(ChassisSetup(camberRf = 1.0)).single().say.contains("negative"))
|
||||||
|
assertTrue(chassisTips(ChassisSetup(camberRf = -7.0)).isEmpty())
|
||||||
|
assertTrue(chassisTips(ChassisSetup(camberRf = -4.0)).single().say.contains("−6°"))
|
||||||
|
|
||||||
|
val heatSaysStop = TireSheet(heat = mapOf(Corner.RF to HeatReading(214, 198, 182)))
|
||||||
|
assertTrue(chassisTips(ChassisSetup(camberRf = -4.0), sheet = heatSaysStop).isEmpty())
|
||||||
|
|
||||||
|
val caster = chassisTips(ChassisSetup(casterLf = 4.0, casterRf = 2.0))
|
||||||
|
assertTrue(caster.single().say.contains("more caster"))
|
||||||
|
assertTrue(chassisTips(ChassisSetup(casterLf = 2.0, casterRf = 4.0)).isEmpty())
|
||||||
|
|
||||||
|
val softRear = chassisTips(ChassisSetup(springLr = 200, springRr = 175), handling = "Loose")
|
||||||
|
assertTrue(softRear.single().say.contains("softer right rear"))
|
||||||
|
|
||||||
|
val tightBar = chassisTips(ChassisSetup(swayBarHole = 4), handling = "Tight")
|
||||||
|
assertEquals("Less bar.", tightBar.single().say)
|
||||||
|
val looseBar = chassisTips(ChassisSetup(swayBarHole = 4), handling = "Loose")
|
||||||
|
assertEquals("More bar.", looseBar.single().say)
|
||||||
|
assertTrue(chassisTips(ChassisSetup(swayBarHole = 4)).isEmpty())
|
||||||
|
|
||||||
|
val tightBlocks = chassisTips(
|
||||||
|
ChassisSetup(blockLr = 2.0, blockRr = 2.0),
|
||||||
|
handling = "Tight",
|
||||||
|
handlingWhere = "Entry",
|
||||||
|
)
|
||||||
|
assertEquals("Less block.", tightBlocks.single().say)
|
||||||
|
val looseBlocks = chassisTips(
|
||||||
|
ChassisSetup(blockLr = 2.0, blockRr = 2.0),
|
||||||
|
handling = "Loose",
|
||||||
|
handlingWhere = "Center",
|
||||||
|
)
|
||||||
|
assertEquals("More block.", looseBlocks.single().say)
|
||||||
|
assertTrue(
|
||||||
|
chassisTips(
|
||||||
|
ChassisSetup(blockLr = 2.0, blockRr = 2.0),
|
||||||
|
handling = "Loose",
|
||||||
|
handlingWhere = "Exit",
|
||||||
|
).isEmpty(),
|
||||||
|
)
|
||||||
|
val tallBlock = chassisTips(
|
||||||
|
ChassisSetup(blockRr = 3.5),
|
||||||
|
handling = "Loose",
|
||||||
|
handlingWhere = "Entry",
|
||||||
|
)
|
||||||
|
assertTrue(tallBlock.single().say.contains("3 inches"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decimalEntryKeepsATrailingDotUntilTheNumberIsFinished() {
|
||||||
|
assertEquals("18.5", sanitizeDecimal("18.55", 2, 1))
|
||||||
|
assertEquals("100.5", sanitizeDecimal("100.55", 3, 1))
|
||||||
|
assertEquals("8.", sanitizeDecimal("8.", 2, 1))
|
||||||
|
assertEquals("", sanitizeDecimal("psi", 2, 1))
|
||||||
|
assertNull(parseDecimal(""))
|
||||||
|
assertEquals(8.0, parseDecimal("8.")!!, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun csvQuotesCommasAndWritesThePressureBuild() {
|
||||||
|
val night = RaceNight(
|
||||||
|
id = 1,
|
||||||
|
dateEpochDay = LocalDate.of(2026, 10, 17).toEpochDay(),
|
||||||
|
trackName = "Elko",
|
||||||
|
carName = "1976 Chevy Camaro",
|
||||||
|
raceClass = "Hobby Stock",
|
||||||
|
discipline = "Oval",
|
||||||
|
)
|
||||||
|
val run = RunLog(
|
||||||
|
id = 4,
|
||||||
|
nightId = 1,
|
||||||
|
session = "Heat",
|
||||||
|
coldLf = 18.0,
|
||||||
|
hotLf = 24.5,
|
||||||
|
lfInside = 180,
|
||||||
|
lfMiddle = 170,
|
||||||
|
lfOutside = 160,
|
||||||
|
notes = "tight, then freed up",
|
||||||
|
)
|
||||||
|
val csv = buildCsv(listOf(NightWithRuns(night, listOf(run))))
|
||||||
|
assertTrue(csv.startsWith("car,class,discipline"))
|
||||||
|
assertTrue(csv.contains("1976 Chevy Camaro"))
|
||||||
|
assertTrue(csv.contains("\"tight, then freed up\""))
|
||||||
|
assertTrue(csv.contains("18.0"))
|
||||||
|
assertTrue(csv.contains("24.5"))
|
||||||
|
assertTrue(csv.contains("6.5"))
|
||||||
|
assertTrue(csv.contains("-20"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.compose) apply false
|
||||||
|
alias(libs.plugins.ksp) apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep Gradle outputs off the Cloudreve folder. Sync there turns build files into
|
||||||
|
// placeholders and breaks incremental compilation.
|
||||||
|
val localBuildRoot = file("D:/RacingAPP-build")
|
||||||
|
layout.buildDirectory.set(localBuildRoot.resolve("root"))
|
||||||
|
subprojects {
|
||||||
|
layout.buildDirectory.set(localBuildRoot.resolve(name))
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=true
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
android.builtInKotlin=false
|
||||||
|
android.newDsl=false
|
||||||
|
kotlin.incremental=false
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "9.4.1"
|
||||||
|
kotlin = "2.3.21"
|
||||||
|
ksp = "2.3.12"
|
||||||
|
composeBom = "2026.09.00"
|
||||||
|
room = "2.8.5"
|
||||||
|
navigation = "2.10.2"
|
||||||
|
lifecycle = "2.11.0"
|
||||||
|
activity = "1.12.4"
|
||||||
|
core = "1.19.1"
|
||||||
|
coroutines = "1.10.1"
|
||||||
|
work = "2.10.1"
|
||||||
|
junit = "4.13.2"
|
||||||
|
robolectric = "4.16"
|
||||||
|
androidxTestCore = "1.6.1"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", 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" }
|
||||||
|
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" }
|
||||||
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
|
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
|
||||||
|
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# gradlew start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh gradlew
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem gradlew startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
|
setlocal EnableExtensions
|
||||||
|
|
||||||
|
@rem Catch executions from older scripts and ensure they exit cleanly.
|
||||||
|
@rem This can be removed once we can be reasonably confident that few people
|
||||||
|
@rem will be migrating directly to this new wrapper.
|
||||||
|
goto afterSafetyNet
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||||
|
goto exitWithErrorLevel
|
||||||
|
:afterSafetyNet
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
1>&2 echo.
|
||||||
|
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
1>&2 echo.
|
||||||
|
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
1>&2 echo location of your Java installation.
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
goto exitWithErrorLevel
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
1>&2 echo.
|
||||||
|
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
1>&2 echo.
|
||||||
|
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
1>&2 echo location of your Java installation.
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
goto exitWithErrorLevel
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute gradlew
|
||||||
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
|
@rem which allows us to clear the local environment before executing the java command
|
||||||
|
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
|
||||||
|
|
||||||
|
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
|
||||||
|
:exitWithErrorLevel
|
||||||
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 361 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 133 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "RacingAPP"
|
||||||
|
include(":app")
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pit Log sync runs on the Pelican panel. This script does not create a container.
|
||||||
|
echo "Pit Log sync uses the Pelican panel you already have."
|
||||||
|
echo "In the panel, import this egg:"
|
||||||
|
echo "https://git.ny.daprogs.com/RallyBlock/Pit-Log-App-Server-Installer-Proxmox/raw/branch/main/egg-pitlog.yaml"
|
||||||
|
echo "Then create a server from Pit Log sync, give it one port, and start it."
|
||||||
|
echo "The console prints the token. On each phone, open Car and enter http://NODE:PORT plus that token."
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
meta:
|
||||||
|
version: PLCN_v3
|
||||||
|
update_url: https://git.ny.daprogs.com/RallyBlock/Pit-Log-App-Server-Installer-Proxmox/raw/branch/main/egg-pitlog.yaml
|
||||||
|
exported_at: '2026-10-22T20:00:00+00:00'
|
||||||
|
name: Pit Log sync
|
||||||
|
author: pitlog@ny.daprogs.com
|
||||||
|
uuid: 4f8c2e91-6b3a-4d17-9e50-a1c7d8b24f06
|
||||||
|
description: Sync server for the Pit Log phone app. Create this on the existing Pelican panel. The console prints the address port and the token.
|
||||||
|
tags: {}
|
||||||
|
features: {}
|
||||||
|
docker_images:
|
||||||
|
Python 3.13: ghcr.io/pelican-eggs/yolks:python_3.13
|
||||||
|
Python 3.12: ghcr.io/pelican-eggs/yolks:python_3.12
|
||||||
|
file_denylist: {}
|
||||||
|
startup_commands:
|
||||||
|
Default: /usr/local/bin/python /home/container/pitlog_sync.py
|
||||||
|
config:
|
||||||
|
files: {}
|
||||||
|
startup:
|
||||||
|
done:
|
||||||
|
- Pit Log sync listening
|
||||||
|
logs: {}
|
||||||
|
stop: ^C
|
||||||
|
scripts:
|
||||||
|
installation:
|
||||||
|
script: |-
|
||||||
|
#!/bin/bash
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y curl ca-certificates
|
||||||
|
mkdir -p /mnt/server
|
||||||
|
curl -fsSL "https://git.ny.daprogs.com/RallyBlock/Pit-Log-App-Server-Installer-Proxmox/raw/branch/main/pitlog_sync.py" -o /mnt/server/pitlog_sync.py
|
||||||
|
test -s /mnt/server/pitlog_sync.py
|
||||||
|
echo "install complete"
|
||||||
|
exit 0
|
||||||
|
container: python:3.12-slim-bookworm
|
||||||
|
entrypoint: bash
|
||||||
|
variables:
|
||||||
|
- sort: 1
|
||||||
|
name: Token
|
||||||
|
description: Leave this blank. Each car in the app makes its own token, and that is what the phones share.
|
||||||
|
env_variable: PITLOG_TOKEN
|
||||||
|
default_value: ''
|
||||||
|
user_viewable: true
|
||||||
|
user_editable: true
|
||||||
|
rules:
|
||||||
|
- nullable
|
||||||
|
- string
|
||||||
|
- max:128
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Pit Log sync. Runs inside the container the helper script just created.
|
||||||
|
|
||||||
|
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
|
||||||
|
color
|
||||||
|
verb_ip6
|
||||||
|
catch_errors
|
||||||
|
setting_up_container
|
||||||
|
network_check
|
||||||
|
update_os
|
||||||
|
|
||||||
|
PITLOG_BASE="https://git.ny.daprogs.com/RallyBlock/Pit-Log-App-Server-Installer-Proxmox/raw/branch/main"
|
||||||
|
|
||||||
|
msg_info "Installing Pit Log sync"
|
||||||
|
$STD apt-get update
|
||||||
|
$STD apt-get install -y python3 openssl ca-certificates
|
||||||
|
install -d -o root -g root -m 755 /opt/pitlog
|
||||||
|
if ! id pitlog >/dev/null 2>&1; then
|
||||||
|
useradd --system --home-dir /var/lib/pitlog --shell /usr/sbin/nologin pitlog
|
||||||
|
fi
|
||||||
|
install -d -o pitlog -g pitlog -m 750 /var/lib/pitlog /etc/pitlog
|
||||||
|
curl -fsSL "${PITLOG_BASE}/pitlog_sync.py" -o /opt/pitlog/pitlog_sync.py
|
||||||
|
chown root:pitlog /opt/pitlog/pitlog_sync.py
|
||||||
|
chmod 640 /opt/pitlog/pitlog_sync.py
|
||||||
|
if [[ ! -s /etc/pitlog/token ]]; then
|
||||||
|
openssl rand -hex 32 >/etc/pitlog/token
|
||||||
|
fi
|
||||||
|
chown root:pitlog /etc/pitlog/token
|
||||||
|
chmod 640 /etc/pitlog/token
|
||||||
|
cat >/etc/systemd/system/pitlog-sync.service <<'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=Pit Log sync
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=pitlog
|
||||||
|
Group=pitlog
|
||||||
|
Environment=PITLOG_TOKEN_FILE=/etc/pitlog/token
|
||||||
|
Environment=PITLOG_DB=/var/lib/pitlog/sync.db
|
||||||
|
Environment=PITLOG_PORT=8787
|
||||||
|
ExecStart=/usr/bin/python3 /opt/pitlog/pitlog_sync.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable -q --now pitlog-sync.service
|
||||||
|
msg_ok "Installed Pit Log sync"
|
||||||
|
|
||||||
|
motd_ssh
|
||||||
|
customize
|
||||||
|
cleanup_lxc
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install the Pit Log sync service on Debian or Ubuntu.
|
||||||
|
# The Proxmox helper runs this inside the new container.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "$(id -u)" -ne 0 ]]; then
|
||||||
|
echo "Run this as root inside the container."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y python3 openssl ca-certificates
|
||||||
|
|
||||||
|
install -d -o root -g root -m 755 /opt/pitlog
|
||||||
|
if ! id pitlog >/dev/null 2>&1; then
|
||||||
|
useradd --system --home-dir /var/lib/pitlog --shell /usr/sbin/nologin pitlog
|
||||||
|
fi
|
||||||
|
install -d -o pitlog -g pitlog -m 750 /var/lib/pitlog /etc/pitlog
|
||||||
|
|
||||||
|
SOURCE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/pitlog_sync.py"
|
||||||
|
if [[ ! -f "$SOURCE" && -f /tmp/pitlog_sync.py ]]; then
|
||||||
|
SOURCE=/tmp/pitlog_sync.py
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$SOURCE" ]]; then
|
||||||
|
echo "pitlog_sync.py was not next to this script."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
install -o root -g pitlog -m 640 "$SOURCE" /opt/pitlog/pitlog_sync.py
|
||||||
|
|
||||||
|
if [[ ! -s /etc/pitlog/token ]]; then
|
||||||
|
openssl rand -hex 32 > /etc/pitlog/token
|
||||||
|
fi
|
||||||
|
chown root:pitlog /etc/pitlog/token
|
||||||
|
chmod 640 /etc/pitlog/token
|
||||||
|
|
||||||
|
PORT="${PITLOG_PORT:-8787}"
|
||||||
|
cat > /etc/systemd/system/pitlog-sync.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Pit Log sync
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=pitlog
|
||||||
|
Group=pitlog
|
||||||
|
Environment=PITLOG_TOKEN_FILE=/etc/pitlog/token
|
||||||
|
Environment=PITLOG_DB=/var/lib/pitlog/sync.db
|
||||||
|
Environment=PITLOG_PORT=${PORT}
|
||||||
|
ExecStart=/usr/bin/python3 /opt/pitlog/pitlog_sync.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now pitlog-sync.service
|
||||||
|
|
||||||
|
TOKEN="$(cat /etc/pitlog/token)"
|
||||||
|
echo
|
||||||
|
echo "Pit Log sync is running on port ${PORT}."
|
||||||
|
echo "Token: ${TOKEN}"
|
||||||
|
echo "On each phone, open Car and enter this address and the same token."
|
||||||
|
echo "Use http:// when the phone is on the same network. Use https:// if a reverse proxy serves the port."
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pit Log sync server. Phones push their log and pull everyone else's."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
MAX_BODY = 8_000_000
|
||||||
|
|
||||||
|
NIGHT_FIELDS = [
|
||||||
|
"dateEpochDay", "trackName", "carName", "raceClass", "discipline", "airTempF",
|
||||||
|
"notes", "createdAt", "crossPercent", "springLf", "springRf", "springLr", "springRr",
|
||||||
|
"camberLf", "camberRf", "casterLf", "casterRf", "swayBarHole", "blockLr", "blockRr",
|
||||||
|
]
|
||||||
|
RUN_FIELDS = [
|
||||||
|
"session", "tireSet", "laps", "finish", "staggerInches",
|
||||||
|
"coldLf", "coldRf", "coldLr", "coldRr", "hotLf", "hotRf", "hotLr", "hotRr",
|
||||||
|
"lfInside", "lfMiddle", "lfOutside", "rfInside", "rfMiddle", "rfOutside",
|
||||||
|
"lrInside", "lrMiddle", "lrOutside", "rrInside", "rrMiddle", "rrOutside",
|
||||||
|
"handling", "handlingWhere", "notes", "recordedAt",
|
||||||
|
]
|
||||||
|
SHEET_FIELDS = [
|
||||||
|
"recordedAt", "note", "crossPercent", "springLf", "springRf", "springLr", "springRr",
|
||||||
|
"camberLf", "camberRf", "casterLf", "casterRf", "swayBarHole", "blockLr", "blockRr",
|
||||||
|
"toeFront", "toeRear",
|
||||||
|
]
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_db = None
|
||||||
|
_spaces = {}
|
||||||
|
|
||||||
|
|
||||||
|
def db_path():
|
||||||
|
configured = os.environ.get("PITLOG_DB")
|
||||||
|
if configured:
|
||||||
|
return configured
|
||||||
|
home = os.environ.get("HOME", "")
|
||||||
|
if home and os.path.isdir(home) and os.access(home, os.W_OK):
|
||||||
|
return os.path.join(home, "sync.db")
|
||||||
|
return "/var/lib/pitlog/sync.db"
|
||||||
|
|
||||||
|
|
||||||
|
def listen_port():
|
||||||
|
for name in ("PITLOG_PORT", "SERVER_PORT"):
|
||||||
|
value = os.environ.get(name, "").strip()
|
||||||
|
if value:
|
||||||
|
return int(value)
|
||||||
|
return 8787
|
||||||
|
|
||||||
|
|
||||||
|
def token_path():
|
||||||
|
configured = os.environ.get("PITLOG_TOKEN_FILE")
|
||||||
|
if configured:
|
||||||
|
return configured
|
||||||
|
home = os.environ.get("HOME", "")
|
||||||
|
if home:
|
||||||
|
return os.path.join(home, ".pitlog-token")
|
||||||
|
return "/etc/pitlog/token"
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_token():
|
||||||
|
inline = os.environ.get("PITLOG_TOKEN", "").strip().lower()
|
||||||
|
if inline:
|
||||||
|
return inline
|
||||||
|
path = os.environ.get("PITLOG_TOKEN_FILE", "/etc/pitlog/token")
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
|
return handle.read().strip().lower()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def space_path(bearer):
|
||||||
|
root = db_path()
|
||||||
|
legacy = legacy_token()
|
||||||
|
if legacy and bearer == legacy:
|
||||||
|
return root
|
||||||
|
folder = os.path.join(os.path.dirname(root) or ".", "cars")
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
digest = hashlib.sha256(bearer.encode("utf-8")).hexdigest()
|
||||||
|
return os.path.join(folder, digest + ".db")
|
||||||
|
|
||||||
|
|
||||||
|
def open_db(path):
|
||||||
|
folder = os.path.dirname(path)
|
||||||
|
if folder:
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
db = sqlite3.connect(path, check_same_thread=False)
|
||||||
|
db.row_factory = sqlite3.Row
|
||||||
|
db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
db.executescript(SCHEMA)
|
||||||
|
db.commit()
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def space(bearer):
|
||||||
|
path = space_path(bearer)
|
||||||
|
with _lock:
|
||||||
|
db = _spaces.get(path)
|
||||||
|
if db is None:
|
||||||
|
db = open_db(path)
|
||||||
|
_spaces[path] = db
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||||
|
INSERT OR IGNORE INTO meta(key, value) VALUES ('rev', '0');
|
||||||
|
CREATE TABLE IF NOT EXISTS profile (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
carName TEXT NOT NULL,
|
||||||
|
raceClass TEXT NOT NULL,
|
||||||
|
discipline TEXT NOT NULL,
|
||||||
|
updatedAt INTEGER NOT NULL,
|
||||||
|
rev INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS nights (
|
||||||
|
syncId TEXT PRIMARY KEY,
|
||||||
|
updatedAt INTEGER NOT NULL,
|
||||||
|
deleted INTEGER NOT NULL,
|
||||||
|
rev INTEGER NOT NULL,
|
||||||
|
dateEpochDay INTEGER,
|
||||||
|
trackName TEXT,
|
||||||
|
carName TEXT,
|
||||||
|
raceClass TEXT,
|
||||||
|
discipline TEXT,
|
||||||
|
airTempF INTEGER,
|
||||||
|
notes TEXT,
|
||||||
|
createdAt INTEGER,
|
||||||
|
crossPercent REAL,
|
||||||
|
springLf INTEGER,
|
||||||
|
springRf INTEGER,
|
||||||
|
springLr INTEGER,
|
||||||
|
springRr INTEGER,
|
||||||
|
camberLf REAL,
|
||||||
|
camberRf REAL,
|
||||||
|
casterLf REAL,
|
||||||
|
casterRf REAL,
|
||||||
|
swayBarHole INTEGER,
|
||||||
|
blockLr REAL,
|
||||||
|
blockRr REAL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS runs (
|
||||||
|
syncId TEXT PRIMARY KEY,
|
||||||
|
nightSyncId TEXT NOT NULL,
|
||||||
|
updatedAt INTEGER NOT NULL,
|
||||||
|
deleted INTEGER NOT NULL,
|
||||||
|
rev INTEGER NOT NULL,
|
||||||
|
session TEXT,
|
||||||
|
tireSet TEXT,
|
||||||
|
laps INTEGER,
|
||||||
|
finish INTEGER,
|
||||||
|
staggerInches REAL,
|
||||||
|
coldLf REAL, coldRf REAL, coldLr REAL, coldRr REAL,
|
||||||
|
hotLf REAL, hotRf REAL, hotLr REAL, hotRr REAL,
|
||||||
|
lfInside INTEGER, lfMiddle INTEGER, lfOutside INTEGER,
|
||||||
|
rfInside INTEGER, rfMiddle INTEGER, rfOutside INTEGER,
|
||||||
|
lrInside INTEGER, lrMiddle INTEGER, lrOutside INTEGER,
|
||||||
|
rrInside INTEGER, rrMiddle INTEGER, rrOutside INTEGER,
|
||||||
|
handling TEXT,
|
||||||
|
handlingWhere TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
recordedAt INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sheets (
|
||||||
|
syncId TEXT PRIMARY KEY,
|
||||||
|
updatedAt INTEGER NOT NULL,
|
||||||
|
deleted INTEGER NOT NULL,
|
||||||
|
rev INTEGER NOT NULL,
|
||||||
|
recordedAt INTEGER,
|
||||||
|
note TEXT,
|
||||||
|
crossPercent REAL,
|
||||||
|
springLf INTEGER,
|
||||||
|
springRf INTEGER,
|
||||||
|
springLr INTEGER,
|
||||||
|
springRr INTEGER,
|
||||||
|
camberLf REAL,
|
||||||
|
camberRf REAL,
|
||||||
|
casterLf REAL,
|
||||||
|
casterRf REAL,
|
||||||
|
swayBarHole INTEGER,
|
||||||
|
blockLr REAL,
|
||||||
|
blockRr REAL,
|
||||||
|
toeFront REAL,
|
||||||
|
toeRear REAL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
global _db
|
||||||
|
path = db_path()
|
||||||
|
_db = open_db(path)
|
||||||
|
_spaces[path] = _db
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
inline = os.environ.get("PITLOG_TOKEN", "").strip()
|
||||||
|
if inline:
|
||||||
|
return inline
|
||||||
|
path = token_path()
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
|
saved = handle.read().strip()
|
||||||
|
except FileNotFoundError:
|
||||||
|
saved = ""
|
||||||
|
if saved:
|
||||||
|
return saved
|
||||||
|
generated = secrets.token_hex(32)
|
||||||
|
folder = os.path.dirname(path)
|
||||||
|
if folder:
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
with open(path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(generated)
|
||||||
|
return generated
|
||||||
|
|
||||||
|
|
||||||
|
def current_rev(db):
|
||||||
|
return int(db.execute("SELECT value FROM meta WHERE key = 'rev'").fetchone()["value"])
|
||||||
|
|
||||||
|
|
||||||
|
def next_rev(db):
|
||||||
|
db.execute("UPDATE meta SET value = CAST(value AS INTEGER) + 1 WHERE key = 'rev'")
|
||||||
|
return current_rev(db)
|
||||||
|
|
||||||
|
|
||||||
|
def row_dict(row, fields):
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
data = {field: row[field] for field in fields}
|
||||||
|
data["syncId"] = row["syncId"]
|
||||||
|
data["updatedAt"] = row["updatedAt"]
|
||||||
|
data["deleted"] = bool(row["deleted"])
|
||||||
|
if "nightSyncId" in row.keys():
|
||||||
|
data["nightSyncId"] = row["nightSyncId"]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def values_match(existing, incoming, fields):
|
||||||
|
for field in fields:
|
||||||
|
if existing[field] != incoming.get(field):
|
||||||
|
return False
|
||||||
|
return bool(existing["deleted"]) == bool(incoming.get("deleted"))
|
||||||
|
|
||||||
|
|
||||||
|
def store_record(db, table, incoming, fields, extra_columns=""):
|
||||||
|
sync_id = str(incoming.get("syncId") or "").strip()
|
||||||
|
if not sync_id or len(sync_id) > 80:
|
||||||
|
raise ValueError("A record is missing its id.")
|
||||||
|
updated = int(incoming.get("updatedAt") or 0)
|
||||||
|
deleted = 1 if incoming.get("deleted") else 0
|
||||||
|
existing = db.execute(f"SELECT * FROM {table} WHERE syncId = ?", (sync_id,)).fetchone()
|
||||||
|
if existing is not None and int(existing["updatedAt"]) > updated:
|
||||||
|
return False, row_dict(existing, fields)
|
||||||
|
if (
|
||||||
|
existing is not None
|
||||||
|
and int(existing["updatedAt"]) == updated
|
||||||
|
and values_match(existing, incoming, fields)
|
||||||
|
and (extra_columns == "" or existing["nightSyncId"] == incoming.get("nightSyncId"))
|
||||||
|
):
|
||||||
|
return True, None
|
||||||
|
rev = next_rev(db)
|
||||||
|
columns = ["syncId", "updatedAt", "deleted", "rev"]
|
||||||
|
values = [sync_id, updated, deleted, rev]
|
||||||
|
if extra_columns:
|
||||||
|
night_sync = str(incoming.get("nightSyncId") or "").strip()
|
||||||
|
if not night_sync:
|
||||||
|
raise ValueError("A run is missing its race night.")
|
||||||
|
columns.append("nightSyncId")
|
||||||
|
values.append(night_sync)
|
||||||
|
for field in fields:
|
||||||
|
columns.append(field)
|
||||||
|
values.append(incoming.get(field))
|
||||||
|
placeholders = ", ".join("?" for _ in columns)
|
||||||
|
updates = ", ".join(f"{column} = excluded.{column}" for column in columns if column != "syncId")
|
||||||
|
db.execute(
|
||||||
|
f"INSERT INTO {table} ({', '.join(columns)}) VALUES ({placeholders}) "
|
||||||
|
f"ON CONFLICT(syncId) DO UPDATE SET {updates}",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
def profile_row(row):
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"carName": row["carName"],
|
||||||
|
"raceClass": row["raceClass"],
|
||||||
|
"discipline": row["discipline"],
|
||||||
|
"updatedAt": row["updatedAt"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def names_differ(existing, incoming):
|
||||||
|
return (
|
||||||
|
existing["carName"] != incoming.get("carName")
|
||||||
|
or existing["raceClass"] != incoming.get("raceClass")
|
||||||
|
or existing["discipline"] != incoming.get("discipline")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def store_profile(db, incoming):
|
||||||
|
if not isinstance(incoming, dict):
|
||||||
|
return None
|
||||||
|
updated = int(incoming.get("updatedAt") or 0)
|
||||||
|
car_name = str(incoming.get("carName") or "")[:80]
|
||||||
|
race_class = str(incoming.get("raceClass") or "")[:80]
|
||||||
|
discipline = str(incoming.get("discipline") or "")[:40]
|
||||||
|
existing = db.execute("SELECT * FROM profile WHERE id = 1").fetchone()
|
||||||
|
if existing is not None and int(existing["updatedAt"]) > updated:
|
||||||
|
return profile_row(existing)
|
||||||
|
if existing is not None and int(existing["updatedAt"]) == updated and not names_differ(existing, incoming):
|
||||||
|
return None
|
||||||
|
if existing is not None and int(existing["updatedAt"]) == updated and names_differ(existing, incoming):
|
||||||
|
return profile_row(existing)
|
||||||
|
rev = next_rev(db)
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO profile (id, carName, raceClass, discipline, updatedAt, rev)
|
||||||
|
VALUES (1, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
carName = excluded.carName,
|
||||||
|
raceClass = excluded.raceClass,
|
||||||
|
discipline = excluded.discipline,
|
||||||
|
updatedAt = excluded.updatedAt,
|
||||||
|
rev = excluded.rev
|
||||||
|
""",
|
||||||
|
(car_name, race_class, discipline, updated, rev),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def changed_since(db, table, since, fields):
|
||||||
|
rows = db.execute(f"SELECT * FROM {table} WHERE rev > ?", (since,)).fetchall()
|
||||||
|
return [row_dict(row, fields) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def sync_body(payload, db=None):
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("The sync body has to be an object.")
|
||||||
|
since = int(payload.get("rev") or 0)
|
||||||
|
forced = {"nights": [], "runs": [], "sheets": []}
|
||||||
|
accepted = []
|
||||||
|
with _lock:
|
||||||
|
db = db or _db
|
||||||
|
profile_reply = store_profile(db, payload.get("profile"))
|
||||||
|
for table, key, fields, extra in (
|
||||||
|
("nights", "nights", NIGHT_FIELDS, ""),
|
||||||
|
("runs", "runs", RUN_FIELDS, "nightSyncId"),
|
||||||
|
("sheets", "sheets", SHEET_FIELDS, ""),
|
||||||
|
):
|
||||||
|
for item in payload.get(key) or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise ValueError("A sync record was not an object.")
|
||||||
|
ok, winner = store_record(db, table, item, fields, extra)
|
||||||
|
if ok:
|
||||||
|
accepted.append(item.get("syncId"))
|
||||||
|
elif winner is not None:
|
||||||
|
forced[key].append(winner)
|
||||||
|
since_nights = changed_since(db, "nights", since, NIGHT_FIELDS)
|
||||||
|
since_runs = changed_since(db, "runs", since, RUN_FIELDS)
|
||||||
|
since_sheets = changed_since(db, "sheets", since, SHEET_FIELDS)
|
||||||
|
db.commit()
|
||||||
|
rev = current_rev(db)
|
||||||
|
seen = {item["syncId"] for item in since_nights}
|
||||||
|
nights = since_nights + [item for item in forced["nights"] if item["syncId"] not in seen]
|
||||||
|
seen = {item["syncId"] for item in since_runs}
|
||||||
|
runs = since_runs + [item for item in forced["runs"] if item["syncId"] not in seen]
|
||||||
|
seen = {item["syncId"] for item in since_sheets}
|
||||||
|
sheets = since_sheets + [item for item in forced["sheets"] if item["syncId"] not in seen]
|
||||||
|
return {
|
||||||
|
"rev": rev,
|
||||||
|
"accepted": accepted,
|
||||||
|
"profile": profile_reply,
|
||||||
|
"nights": nights,
|
||||||
|
"runs": runs,
|
||||||
|
"sheets": sheets,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
sys.stdout.write("%s - %s\n" % (self.address_string(), fmt % args))
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path.split("?", 1)[0] != "/health":
|
||||||
|
self._send(404, {"error": "Not found."})
|
||||||
|
return
|
||||||
|
self._send(200, {"ok": True})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path.split("?", 1)[0] != "/v1/sync":
|
||||||
|
self._send(404, {"error": "Not found."})
|
||||||
|
return
|
||||||
|
header = self.headers.get("Authorization", "")
|
||||||
|
bearer = header[7:].strip().lower() if header.startswith("Bearer ") else ""
|
||||||
|
if len(bearer) < 16 or any(character not in "0123456789abcdef" for character in bearer):
|
||||||
|
self._send(401, {"error": "The token was refused."})
|
||||||
|
return
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
if length <= 0 or length > MAX_BODY:
|
||||||
|
self._send(400, {"error": "The sync body is empty or too large."})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||||
|
self._send(200, sync_body(payload, space(bearer)))
|
||||||
|
except ValueError as error:
|
||||||
|
self._send(400, {"error": str(error)})
|
||||||
|
except Exception:
|
||||||
|
self._send(500, {"error": "The server couldn't save that sync."})
|
||||||
|
|
||||||
|
def _send(self, status, payload):
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
def serve():
|
||||||
|
connect()
|
||||||
|
port = listen_port()
|
||||||
|
print(f"Pit Log sync listening on {port}", flush=True)
|
||||||
|
print("Each car in the app has its own token.", flush=True)
|
||||||
|
server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
def selftest():
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
handle = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||||
|
handle.close()
|
||||||
|
os.environ["PITLOG_DB"] = handle.name
|
||||||
|
os.environ["PITLOG_TOKEN"] = "test-token"
|
||||||
|
global _db
|
||||||
|
_db = None
|
||||||
|
connect()
|
||||||
|
first = sync_body(
|
||||||
|
{
|
||||||
|
"rev": 0,
|
||||||
|
"profile": {"carName": "1976 Chevy Camaro", "raceClass": "Hobby Stock", "discipline": "Oval", "updatedAt": 1},
|
||||||
|
"nights": [
|
||||||
|
{
|
||||||
|
"syncId": "night-a",
|
||||||
|
"updatedAt": 10,
|
||||||
|
"deleted": False,
|
||||||
|
"dateEpochDay": 20000,
|
||||||
|
"trackName": "Tri-City Raceway",
|
||||||
|
"carName": "1976 Chevy Camaro",
|
||||||
|
"raceClass": "Hobby Stock",
|
||||||
|
"discipline": "Oval",
|
||||||
|
"airTempF": 70,
|
||||||
|
"notes": "",
|
||||||
|
"createdAt": 10,
|
||||||
|
"crossPercent": 53.5,
|
||||||
|
"springLf": 950,
|
||||||
|
"springRf": 1000,
|
||||||
|
"springLr": 200,
|
||||||
|
"springRr": 175,
|
||||||
|
"camberLf": 2.0,
|
||||||
|
"camberRf": -7.0,
|
||||||
|
"casterLf": 2.0,
|
||||||
|
"casterRf": 4.0,
|
||||||
|
"swayBarHole": 4,
|
||||||
|
"blockLr": 2.0,
|
||||||
|
"blockRr": 2.0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"syncId": "run-a",
|
||||||
|
"nightSyncId": "night-a",
|
||||||
|
"updatedAt": 11,
|
||||||
|
"deleted": False,
|
||||||
|
"session": "Heat",
|
||||||
|
"tireSet": "Scuffs",
|
||||||
|
"laps": 15,
|
||||||
|
"finish": 4,
|
||||||
|
"staggerInches": 1.25,
|
||||||
|
"coldLf": 16,
|
||||||
|
"coldRf": 22,
|
||||||
|
"coldLr": 15,
|
||||||
|
"coldRr": 18,
|
||||||
|
"hotLf": 20.5,
|
||||||
|
"hotRf": 28,
|
||||||
|
"hotLr": 18.5,
|
||||||
|
"hotRr": 23.5,
|
||||||
|
"lfInside": 168,
|
||||||
|
"lfMiddle": 158,
|
||||||
|
"lfOutside": 149,
|
||||||
|
"rfInside": 214,
|
||||||
|
"rfMiddle": 198,
|
||||||
|
"rfOutside": 182,
|
||||||
|
"lrInside": 162,
|
||||||
|
"lrMiddle": 154,
|
||||||
|
"lrOutside": 146,
|
||||||
|
"rrInside": 196,
|
||||||
|
"rrMiddle": 180,
|
||||||
|
"rrOutside": 168,
|
||||||
|
"handling": "Loose",
|
||||||
|
"handlingWhere": "Exit",
|
||||||
|
"notes": "",
|
||||||
|
"recordedAt": 11,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sheets": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
second = sync_body({"rev": 0, "profile": {"carName": "1976 Chevy Camaro", "raceClass": "Hobby Stock", "discipline": "Oval", "updatedAt": 0}, "nights": [], "runs": [], "sheets": []})
|
||||||
|
if second["nights"][0]["trackName"] != "Tri-City Raceway":
|
||||||
|
raise SystemExit("second phone did not receive the night")
|
||||||
|
if second["runs"][0]["nightSyncId"] != "night-a":
|
||||||
|
raise SystemExit("second phone did not receive the run")
|
||||||
|
older = sync_body(
|
||||||
|
{
|
||||||
|
"rev": first["rev"],
|
||||||
|
"profile": None,
|
||||||
|
"nights": [{"syncId": "night-a", "updatedAt": 5, "deleted": False, "dateEpochDay": 1, "trackName": "Old", "carName": "x", "raceClass": "x", "discipline": "x", "airTempF": None, "notes": "", "createdAt": 5, "crossPercent": None, "springLf": None, "springRf": None, "springLr": None, "springRr": None, "camberLf": None, "camberRf": None, "casterLf": None, "casterRf": None, "swayBarHole": None, "blockLr": None, "blockRr": None}],
|
||||||
|
"runs": [],
|
||||||
|
"sheets": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if older["nights"][0]["trackName"] != "Tri-City Raceway":
|
||||||
|
raise SystemExit("an older edit replaced the night")
|
||||||
|
print("selftest ok")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == "selftest":
|
||||||
|
raise SystemExit(selftest())
|
||||||
|
serve()
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pit Log sync runs on the Pelican panel. This script does not create a container.
|
||||||
|
echo "Pit Log sync uses the Pelican panel you already have."
|
||||||
|
echo "In the panel, import this egg:"
|
||||||
|
echo "https://git.ny.daprogs.com/RallyBlock/Pit-Log-App-Server-Installer-Proxmox/raw/branch/main/egg-pitlog.yaml"
|
||||||
|
echo "Then create a server from Pit Log sync, give it one port, and start it."
|
||||||
|
echo "The console prints the token. On each phone, open Car and enter http://NODE:PORT plus that token."
|
||||||
|
exit 0
|
||||||