Open on the car list so a car is chosen before its nights.

This commit is contained in:
RallyBlock committed 2026-10-22 21:15:46 -07:00
1 parent 13451a9955
commit 9a4db39b58
6 files changed
+175 -94

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@ Pressures are PSI. Tire heat is °F, read inside, middle, then outside.
## On the phone ## On the phone
The phone starts with no cars. Add one from the home screen, or paste a token from another phone. The app opens on the list of cars. Tap a car to see its nights. Add one from that list, or paste a token from another phone.
The gear icon opens **Car**. A new install already has the server address filled in: The gear icon opens **Car**. A new install already has the server address filled in:
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.racingapp.pitlog" applicationId = "com.racingapp.pitlog"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 3 versionCode = 4
versionName = "1.2" versionName = "1.3"
} }
buildTypes { buildTypes {
@@ -8,6 +8,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.racingapp.pitlog.ui.guide.SetupGuideScreen import com.racingapp.pitlog.ui.guide.SetupGuideScreen
import com.racingapp.pitlog.ui.home.CarsScreen
import com.racingapp.pitlog.ui.home.HomeScreen import com.racingapp.pitlog.ui.home.HomeScreen
import com.racingapp.pitlog.ui.night.NightDetailScreen import com.racingapp.pitlog.ui.night.NightDetailScreen
import com.racingapp.pitlog.ui.night.NightEditorScreen import com.racingapp.pitlog.ui.night.NightEditorScreen
@@ -22,9 +23,22 @@ import com.racingapp.pitlog.ui.setup.CarSetupScreen
fun PitNav() { fun PitNav() {
val nav = rememberNavController() val nav = rememberNavController()
val context = LocalContext.current val context = LocalContext.current
NavHost(navController = nav, startDestination = "home") { NavHost(navController = nav, startDestination = "cars") {
composable("cars") {
CarsScreen(
onOpenCar = {
nav.navigate("home") {
popUpTo("cars")
launchSingleTop = true
}
},
onAddCar = { nav.navigate("settings") },
onSettings = { nav.navigate("settings") },
)
}
composable("home") { composable("home") {
HomeScreen( HomeScreen(
onBack = { nav.popBackStack() },
onNewNight = { nav.navigate("editor/night") }, onNewNight = { nav.navigate("editor/night") },
onOpenNight = { id -> nav.navigate("night/$id") }, onOpenNight = { id -> nav.navigate("night/$id") },
onLogRun = { id -> nav.navigate("editor/run/$id") }, onLogRun = { id -> nav.navigate("editor/run/$id") },
@@ -0,0 +1,145 @@
package com.racingapp.pitlog.ui.home
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.filled.Add
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import com.racingapp.pitlog.data.Car
import com.racingapp.pitlog.data.Repository
import com.racingapp.pitlog.ui.pitViewModel
import com.racingapp.pitlog.ui.theme.pitTopBarColors
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class CarsViewModel(private val repository: Repository) : ViewModel() {
val cars: StateFlow<List<Car>?> = repository.observeCars()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
fun open(id: Long, onOpen: () -> Unit) {
repository.selectCar(id)
onOpen()
}
fun addCar(onDone: () -> Unit) {
viewModelScope.launch {
repository.addCar()
onDone()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CarsScreen(
onOpenCar: () -> Unit,
onAddCar: () -> Unit,
onSettings: () -> Unit,
) {
val viewModel = pitViewModel { CarsViewModel(it) }
val cars by viewModel.cars.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Pit Log") },
colors = pitTopBarColors(),
actions = {
IconButton(onClick = onSettings) {
Icon(Icons.Filled.Settings, contentDescription = "Car")
}
IconButton(onClick = { viewModel.addCar(onAddCar) }) {
Icon(Icons.Filled.Add, contentDescription = "Add a car")
}
},
)
},
) { padding ->
if (cars == null) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (cars!!.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Column(
modifier = Modifier.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("No car yet", style = MaterialTheme.typography.titleLarge)
Text(
"Add a car to start a log. Each car gets its own 8-digit token.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
Button(
onClick = { viewModel.addCar(onAddCar) },
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
) {
Text("Add a car")
}
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = padding,
) {
item {
Text(
"Cars",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 8.dp),
)
}
items(cars.orEmpty(), key = { it.id }) { car ->
Card(
onClick = { viewModel.open(car.id, onOpenCar) },
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 6.dp)
.fillMaxWidth(),
) {
Column(
Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(car.carName, style = MaterialTheme.typography.titleLarge)
Text(
"${car.raceClass} · ${car.discipline}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
@@ -4,7 +4,6 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -13,6 +12,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.automirrored.filled.MenuBook
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
@@ -20,8 +20,6 @@ import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -31,14 +29,11 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
@@ -49,7 +44,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.racingapp.pitlog.data.Car import com.racingapp.pitlog.data.Car
import com.racingapp.pitlog.data.DEFAULT_CAR
import com.racingapp.pitlog.data.DEFAULT_CLASS import com.racingapp.pitlog.data.DEFAULT_CLASS
import com.racingapp.pitlog.data.DEFAULT_DISCIPLINE import com.racingapp.pitlog.data.DEFAULT_DISCIPLINE
import com.racingapp.pitlog.data.NightWithRuns import com.racingapp.pitlog.data.NightWithRuns
@@ -94,19 +88,12 @@ class HomeViewModel(private val repository: Repository) : ViewModel() {
) )
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeState()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeState())
fun selectCar(id: Long) = repository.selectCar(id)
fun addCar(onDone: () -> Unit) {
viewModelScope.launch {
repository.addCar()
onDone()
}
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun HomeScreen( fun HomeScreen(
onBack: () -> Unit,
onNewNight: () -> Unit, onNewNight: () -> Unit,
onOpenNight: (Long) -> Unit, onOpenNight: (Long) -> Unit,
onLogRun: (Long) -> Unit, onLogRun: (Long) -> Unit,
@@ -126,8 +113,13 @@ fun HomeScreen(
snackbarHost = { SnackbarHost(snackbar) }, snackbarHost = { SnackbarHost(snackbar) },
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Pit Log") }, title = { Text(state.profile?.carName?.ifBlank { "Pit Log" } ?: "Pit Log") },
colors = pitTopBarColors(), colors = pitTopBarColors(),
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Cars")
}
},
actions = { actions = {
IconButton(onClick = onGuide) { IconButton(onClick = onGuide) {
Icon(Icons.AutoMirrored.Filled.MenuBook, contentDescription = "Setup") Icon(Icons.AutoMirrored.Filled.MenuBook, contentDescription = "Setup")
@@ -150,15 +142,8 @@ fun HomeScreen(
IconButton(onClick = onSettings) { IconButton(onClick = onSettings) {
Icon(Icons.Filled.Settings, contentDescription = "Car") Icon(Icons.Filled.Settings, contentDescription = "Car")
} }
IconButton( IconButton(onClick = onNewNight) {
onClick = { Icon(Icons.Filled.Add, contentDescription = "New race night")
if (state.cars.isEmpty()) viewModel.addCar(onSettings) else onNewNight()
},
) {
Icon(
Icons.Filled.Add,
contentDescription = if (state.cars.isEmpty()) "Add a car" else "New race night",
)
} }
}, },
) )
@@ -173,19 +158,13 @@ fun HomeScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = padding, contentPadding = padding,
) { ) {
if (state.cars.isEmpty()) {
item { EmptyGarage(onAddCar = { viewModel.addCar(onSettings) }) }
} else {
item { item {
HomeHeader( HomeHeader(
profile = state.profile, profile = state.profile,
cars = state.cars,
tonight = tonight, tonight = tonight,
onLogRun = onLogRun, onLogRun = onLogRun,
onNewNight = onNewNight, onNewNight = onNewNight,
onCarSetup = onCarSetup, onCarSetup = onCarSetup,
onSelectCar = viewModel::selectCar,
onAddCar = { viewModel.addCar(onSettings) },
) )
} }
if (state.nights.isEmpty()) { if (state.nights.isEmpty()) {
@@ -203,7 +182,6 @@ fun HomeScreen(
NightCard(night, onClick = { onOpenNight(night.night.id) }) NightCard(night, onClick = { onOpenNight(night.night.id) })
} }
} }
}
} }
} }
} }
@@ -212,52 +190,17 @@ fun HomeScreen(
@Composable @Composable
private fun HomeHeader( private fun HomeHeader(
profile: Car?, profile: Car?,
cars: List<Car>,
tonight: NightWithRuns?, tonight: NightWithRuns?,
onLogRun: (Long) -> Unit, onLogRun: (Long) -> Unit,
onNewNight: () -> Unit, onNewNight: () -> Unit,
onCarSetup: () -> Unit, onCarSetup: () -> Unit,
onSelectCar: (Long) -> Unit,
onAddCar: () -> Unit,
) { ) {
val car = profile?.carName?.ifBlank { DEFAULT_CAR } ?: DEFAULT_CAR
val raceClass = profile?.raceClass?.ifBlank { DEFAULT_CLASS } ?: DEFAULT_CLASS val raceClass = profile?.raceClass?.ifBlank { DEFAULT_CLASS } ?: DEFAULT_CLASS
val discipline = profile?.discipline?.ifBlank { DEFAULT_DISCIPLINE } ?: DEFAULT_DISCIPLINE val discipline = profile?.discipline?.ifBlank { DEFAULT_DISCIPLINE } ?: DEFAULT_DISCIPLINE
var menu by remember { mutableStateOf(false) }
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
CarMark() CarMark()
Column(Modifier.padding(start = 12.dp)) { 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( Text(
"$raceClass · $discipline", "$raceClass · $discipline",
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -294,29 +237,6 @@ private fun HomeHeader(
} }
} }
@Composable
private fun EmptyGarage(onAddCar: () -> Unit) {
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
Text(
"No car yet",
style = MaterialTheme.typography.titleLarge,
)
Text(
"Add a car to start a log. Each car gets its own 8-digit token.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
Button(
onClick = onAddCar,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
) {
Text("Add a car")
}
}
}
@Composable @Composable
private fun EmptyNights() { private fun EmptyNights() {
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
@@ -112,6 +112,8 @@ class PreviewScreenshotTest {
@Test @Test
fun capturePitLogScreens() { fun capturePitLogScreens() {
waitForText(DEFAULT_CAR)
composeRule.onNodeWithText(DEFAULT_CAR).performClick()
waitForText("Elko Speedway") waitForText("Elko Speedway")
save("01-home") save("01-home")