Put Pit Log under version control so the app and sync server can live on git.
This commit is contained in:
commit
1030bac79e
79 files changed
+8463
No files matched your search
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user