diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 54850b5..7c683d0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,7 +12,7 @@ android { defaultConfig { applicationId = "com.example.trackpro" minSdk = 26 - targetSdk = 34 + targetSdk = 34 // intentionally behind compileSdk 35, not yet verified against Android 15 behavior changes versionCode = 1 versionName = "1.1" @@ -54,7 +54,7 @@ dependencies { implementation("androidx.core:core-ktx:1.10.1") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1") implementation("androidx.activity:activity-compose:1.7.0") - implementation(platform("androidx.compose:compose-bom:2023.08.00")) + implementation(platform("androidx.compose:compose-bom:2024.03.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") @@ -68,7 +68,7 @@ dependencies { testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") - androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00")) + androidTestImplementation(platform("androidx.compose:compose-bom:2024.03.00")) androidTestImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") @@ -101,8 +101,6 @@ dependencies { implementation ("com.google.accompanist:accompanist-pager:0.31.1-alpha") implementation ("com.google.accompanist:accompanist-pager-indicators:0.31.1-alpha") - implementation(platform("androidx.compose:compose-bom:2024.03.00")) // Update to latest BOM - implementation ("com.google.code.gson:gson:2.10.1") //MAP diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1509f18..e2d6409 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,11 +34,9 @@ - + - - \ No newline at end of file diff --git a/app/src/main/java/com/example/trackpro/MainActivity.kt b/app/src/main/java/com/example/trackpro/MainActivity.kt index 66eedb7..caba154 100644 --- a/app/src/main/java/com/example/trackpro/MainActivity.kt +++ b/app/src/main/java/com/example/trackpro/MainActivity.kt @@ -42,6 +42,8 @@ import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.Text import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -112,6 +114,22 @@ class TrackProApp : Application() { val useExternalGps = MutableStateFlow(true) + private val themePrefs by lazy { getSharedPreferences("theme_prefs", MODE_PRIVATE) } + val useDarkTheme by lazy { MutableStateFlow(themePrefs.getBoolean("dark_theme", true)) } + + fun setDarkTheme(enabled: Boolean) { + themePrefs.edit().putBoolean("dark_theme", enabled).apply() + useDarkTheme.value = enabled + } + + private val unitPrefs by lazy { getSharedPreferences("unit_prefs", MODE_PRIVATE) } + val useMetricUnits by lazy { MutableStateFlow(unitPrefs.getBoolean("metric_units", true)) } + + fun setMetricUnits(enabled: Boolean) { + unitPrefs.edit().putBoolean("metric_units", enabled).apply() + useMetricUnits.value = enabled + } + val gpsManager: GpsManager by lazy { GpsManager( espProvider = espTcpClient, @@ -168,7 +186,8 @@ class MainActivity : ComponentActivity() { setContent { - TrackProTheme { + val useDarkTheme by (application as TrackProApp).useDarkTheme.collectAsState() + TrackProTheme(darkTheme = useDarkTheme) { val navController = rememberNavController() NavHost(navController = navController, startDestination = "main") { composable("main") { diff --git a/app/src/main/java/com/example/trackpro/dao/LapTimeDataDAO.kt b/app/src/main/java/com/example/trackpro/dao/LapTimeDataDAO.kt index bee6385..63dfeff 100644 --- a/app/src/main/java/com/example/trackpro/dao/LapTimeDataDAO.kt +++ b/app/src/main/java/com/example/trackpro/dao/LapTimeDataDAO.kt @@ -32,6 +32,18 @@ interface LapTimeDataDAO { @Query("SELECT * FROM lap_time_data WHERE sessionid = :sessionId ORDER BY laptime ASC LIMIT 1") suspend fun getBestLapForSession(sessionId: Long): LapTimeData? + // Get best completed lap across all sessions recorded on a track + @Query(""" + SELECT lap_time_data.* FROM lap_time_data + INNER JOIN session_data ON lap_time_data.sessionid = session_data.id + WHERE session_data.trackId = :trackId + AND lap_time_data.laptime != 'IN PROGRESS' + AND lap_time_data.laptime != 'INVALID' + ORDER BY lap_time_data.laptime ASC + LIMIT 1 + """) + suspend fun getBestLapForTrack(trackId: Long): LapTimeData? + // Get total number of laps in a session @Query("SELECT COUNT(*) FROM lap_time_data WHERE sessionid = :sessionId") suspend fun getLapCountForSession(sessionId: Long): Int diff --git a/app/src/main/java/com/example/trackpro/dao/RawGPSDataDao.kt b/app/src/main/java/com/example/trackpro/dao/RawGPSDataDao.kt index b295b15..530ba36 100644 --- a/app/src/main/java/com/example/trackpro/dao/RawGPSDataDao.kt +++ b/app/src/main/java/com/example/trackpro/dao/RawGPSDataDao.kt @@ -18,5 +18,5 @@ interface RawGPSDataDao { suspend fun getGPSDataBySession(sessionId: Long?): List @Query("DELETE FROM raw_gps_data WHERE sessionId = :sessionId") - suspend fun deleteGPSDataBySession(sessionId: Int) + suspend fun deleteGPSDataBySession(sessionId: Long) } diff --git a/app/src/main/java/com/example/trackpro/dao/TrackCoordinatesDataDAO.kt b/app/src/main/java/com/example/trackpro/dao/TrackCoordinatesDataDAO.kt index 4de6151..55683f6 100644 --- a/app/src/main/java/com/example/trackpro/dao/TrackCoordinatesDataDAO.kt +++ b/app/src/main/java/com/example/trackpro/dao/TrackCoordinatesDataDAO.kt @@ -24,5 +24,5 @@ interface TrackCoordinatesDataDAO { //OR // IF the user filters the coordinates (if the full track is complete) @Query("DELETE FROM track_coordinates_data WHERE trackId = :trackId") - suspend fun deleteTrackCoordinates(trackId: Int) + suspend fun deleteTrackCoordinates(trackId: Long) } \ No newline at end of file diff --git a/app/src/main/java/com/example/trackpro/dataClasses/DerivedData.kt b/app/src/main/java/com/example/trackpro/dataClasses/DerivedData.kt index f10804d..fc4b8a7 100644 --- a/app/src/main/java/com/example/trackpro/dataClasses/DerivedData.kt +++ b/app/src/main/java/com/example/trackpro/dataClasses/DerivedData.kt @@ -1,6 +1,7 @@ package com.example.trackpro.dataClasses import androidx.room.Entity +import androidx.room.Index import androidx.room.PrimaryKey @@ -10,7 +11,8 @@ import androidx.room.PrimaryKey parentColumns = ["id"], childColumns = ["sessionid"], onDelete = androidx.room.ForeignKey.CASCADE -)]) +)], + indices = [Index("sessionid")]) data class DerivedData( @PrimaryKey(autoGenerate = true) val id: Int = 0, diff --git a/app/src/main/java/com/example/trackpro/dataClasses/LapInfoData.kt b/app/src/main/java/com/example/trackpro/dataClasses/LapInfoData.kt index 17bfa35..37ea7a4 100644 --- a/app/src/main/java/com/example/trackpro/dataClasses/LapInfoData.kt +++ b/app/src/main/java/com/example/trackpro/dataClasses/LapInfoData.kt @@ -2,6 +2,7 @@ package com.example.trackpro.dataClasses import androidx.room.Entity import androidx.room.ForeignKey +import androidx.room.Index import androidx.room.PrimaryKey @Entity( @@ -11,7 +12,8 @@ import androidx.room.PrimaryKey parentColumns = ["id"], childColumns = ["lapid"], onDelete = ForeignKey.CASCADE - )] + )], + indices = [Index("lapid")] ) data class LapInfoData( @PrimaryKey(autoGenerate = true) val id: Long = 0, diff --git a/app/src/main/java/com/example/trackpro/dataClasses/LapTimeData.kt b/app/src/main/java/com/example/trackpro/dataClasses/LapTimeData.kt index a1ce81e..29e9b8c 100644 --- a/app/src/main/java/com/example/trackpro/dataClasses/LapTimeData.kt +++ b/app/src/main/java/com/example/trackpro/dataClasses/LapTimeData.kt @@ -2,6 +2,7 @@ package com.example.trackpro.dataClasses import androidx.room.Entity import androidx.room.ForeignKey +import androidx.room.Index import androidx.room.PrimaryKey @Entity( @@ -11,7 +12,8 @@ import androidx.room.PrimaryKey parentColumns = ["id"], childColumns = ["sessionid"], onDelete = ForeignKey.CASCADE - )] + )], + indices = [Index("sessionid")] ) data class LapTimeData ( diff --git a/app/src/main/java/com/example/trackpro/dataClasses/RawGPSData.kt b/app/src/main/java/com/example/trackpro/dataClasses/RawGPSData.kt index d049991..1a11584 100644 --- a/app/src/main/java/com/example/trackpro/dataClasses/RawGPSData.kt +++ b/app/src/main/java/com/example/trackpro/dataClasses/RawGPSData.kt @@ -3,6 +3,7 @@ package com.example.trackpro.dataClasses import androidx.room.Entity import androidx.room.ForeignKey +import androidx.room.Index import androidx.room.PrimaryKey @Entity( @@ -12,7 +13,8 @@ import androidx.room.PrimaryKey parentColumns = ["id"], childColumns = ["sessionid"], onDelete = ForeignKey.CASCADE - )] + )], + indices = [Index("sessionid")] ) data class RawGPSData( diff --git a/app/src/main/java/com/example/trackpro/dataClasses/SessionData.kt b/app/src/main/java/com/example/trackpro/dataClasses/SessionData.kt index 7bd747c..9306166 100644 --- a/app/src/main/java/com/example/trackpro/dataClasses/SessionData.kt +++ b/app/src/main/java/com/example/trackpro/dataClasses/SessionData.kt @@ -1,6 +1,7 @@ package com.example.trackpro.dataClasses import androidx.room.Entity +import androidx.room.Index import androidx.room.PrimaryKey @Entity( @@ -10,7 +11,8 @@ import androidx.room.PrimaryKey parentColumns = ["vehicleId"], childColumns = ["vehicleId"], onDelete = androidx.room.ForeignKey.CASCADE - )] + )], + indices = [Index("vehicleId"), Index("trackId")] ) data class SessionData( diff --git a/app/src/main/java/com/example/trackpro/dataClasses/SmoothedGPSData.kt b/app/src/main/java/com/example/trackpro/dataClasses/SmoothedGPSData.kt index 9b28a72..b015725 100644 --- a/app/src/main/java/com/example/trackpro/dataClasses/SmoothedGPSData.kt +++ b/app/src/main/java/com/example/trackpro/dataClasses/SmoothedGPSData.kt @@ -3,6 +3,7 @@ package com.example.trackpro.dataClasses import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.ForeignKey +import androidx.room.Index @Entity( tableName = "smoothed_gps_data" , @@ -11,7 +12,8 @@ import androidx.room.ForeignKey parentColumns = ["id"], childColumns = ["sessionid"], onDelete = ForeignKey.CASCADE - )] + )], + indices = [Index("sessionid")] ) data class SmoothedGPSData ( diff --git a/app/src/main/java/com/example/trackpro/dataClasses/TrackCoordinatesData.kt b/app/src/main/java/com/example/trackpro/dataClasses/TrackCoordinatesData.kt index c8a6608..5a8ce7f 100644 --- a/app/src/main/java/com/example/trackpro/dataClasses/TrackCoordinatesData.kt +++ b/app/src/main/java/com/example/trackpro/dataClasses/TrackCoordinatesData.kt @@ -2,6 +2,7 @@ package com.example.trackpro.dataClasses import androidx.room.Entity import androidx.room.ForeignKey +import androidx.room.Index import androidx.room.PrimaryKey @@ -12,7 +13,8 @@ import androidx.room.PrimaryKey parentColumns = ["trackId"], childColumns = ["trackId"], onDelete = ForeignKey.CASCADE - )] + )], + indices = [Index("trackId")] ) data class TrackCoordinatesData( @PrimaryKey(autoGenerate = true) @@ -21,5 +23,7 @@ data class TrackCoordinatesData( val latitude: Double, val longitude: Double, val altitude: Double?, - val isStartPoint: Boolean = false + val isStartPoint: Boolean = false, + val isSectorPoint: Boolean = false, + val sectorIndex: Int? = null ) diff --git a/app/src/main/java/com/example/trackpro/extrasForUI/CustomTextField.kt b/app/src/main/java/com/example/trackpro/extrasForUI/CustomTextField.kt index 5daae37..7b4d733 100644 --- a/app/src/main/java/com/example/trackpro/extrasForUI/CustomTextField.kt +++ b/app/src/main/java/com/example/trackpro/extrasForUI/CustomTextField.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.input.KeyboardType @@ -20,17 +19,25 @@ fun CustomTextField( OutlinedTextField( value = value, onValueChange = onValueChange, - label = { Text(label, color = Color.White) }, + label = { Text(label, color = TrackProTheme.colors.textMuted) }, keyboardOptions = if (isNumber) KeyboardOptions(keyboardType = KeyboardType.Number) else KeyboardOptions.Default, leadingIcon = if (leadingIcon != null) { { Icon( imageVector = leadingIcon, contentDescription = null, - tint = Color.LightGray + tint = TrackProTheme.colors.textMuted ) } } else null, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = TrackProTheme.colors.textPrimary, + unfocusedTextColor = TrackProTheme.colors.textPrimary, + focusedBorderColor = TrackProTheme.colors.accentCyan, + unfocusedBorderColor = TrackProTheme.colors.sectorLine, + focusedLabelColor = TrackProTheme.colors.accentCyan, + cursorColor = TrackProTheme.colors.accentCyan + ), modifier = Modifier.fillMaxWidth() ) } diff --git a/app/src/main/java/com/example/trackpro/extrasForUI/DropDownMenuField.kt b/app/src/main/java/com/example/trackpro/extrasForUI/DropDownMenuField.kt index 2ee3171..504cc5c 100644 --- a/app/src/main/java/com/example/trackpro/extrasForUI/DropDownMenuField.kt +++ b/app/src/main/java/com/example/trackpro/extrasForUI/DropDownMenuField.kt @@ -33,25 +33,33 @@ fun DropdownMenuFieldMulti(label: String, options: List, selectedOp value = selectedText, onValueChange = {}, readOnly = true, - label = { Text(label) }, + label = { Text(label, color = TrackProTheme.colors.textMuted) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color(0xFF007BFF), - unfocusedBorderColor = Color.Gray + focusedTextColor = TrackProTheme.colors.textPrimary, + unfocusedTextColor = TrackProTheme.colors.textPrimary, + focusedBorderColor = TrackProTheme.colors.accentCyan, + unfocusedBorderColor = TrackProTheme.colors.sectorLine ), modifier = Modifier .fillMaxWidth() ) - ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.background(TrackProTheme.colors.bgElevated) + ) { options.forEach { option -> Log.d("trackpro", "ID:" + option.vehicleId) - DropdownMenuItem(text = { Text(option.manufacturerAndModel) }, onClick = { - selectedText = option.manufacturerAndModel - expanded = false - onOptionSelected(option.vehicleId) - }) + DropdownMenuItem( + text = { Text(option.manufacturerAndModel, color = TrackProTheme.colors.textPrimary) }, + onClick = { + selectedText = option.manufacturerAndModel + expanded = false + onOptionSelected(option.vehicleId) + } + ) } } } @@ -73,25 +81,39 @@ fun TrackDropdownMenu( value = selectedText, onValueChange = {}, readOnly = true, - label = { Text(label) }, + label = { Text(label, color = TrackProTheme.colors.textMuted) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color(0xFF007BFF), - unfocusedBorderColor = Color.Gray + focusedTextColor = TrackProTheme.colors.textPrimary, + unfocusedTextColor = TrackProTheme.colors.textPrimary, + focusedBorderColor = TrackProTheme.colors.accentCyan, + unfocusedBorderColor = TrackProTheme.colors.sectorLine ), modifier = Modifier.fillMaxWidth() ) - ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - tracks.forEach { track -> + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.background(TrackProTheme.colors.bgElevated) + ) { + if (tracks.isEmpty()) { DropdownMenuItem( - text = { Text(track.trackName) }, - onClick = { - selectedText = track.trackName - expanded = false - onTrackSelected(track.trackId) - } + text = { Text("No tracks found", color = TrackProTheme.colors.textMuted) }, + onClick = { expanded = false }, + enabled = false ) + } else { + tracks.forEach { track -> + DropdownMenuItem( + text = { Text(track.trackName, color = TrackProTheme.colors.textPrimary) }, + onClick = { + selectedText = track.trackName + expanded = false + onTrackSelected(track.trackId) + } + ) + } } } } @@ -105,7 +127,7 @@ fun DropdownMenuField( label: String, options: List, selectedOption: String, - textColor: Color = Color.White, + textColor: Color = TrackProTheme.colors.textPrimary, onOptionSelected: (String) -> Unit ) { var expanded by remember { mutableStateOf(false) } @@ -115,10 +137,16 @@ fun DropdownMenuField( value = selectedOption, onValueChange = {}, readOnly = true, - label = { Text(label, color = textColor) }, + label = { Text(label, color = TrackProTheme.colors.textMuted) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = textColor, + unfocusedTextColor = textColor, + focusedBorderColor = TrackProTheme.colors.accentCyan, + unfocusedBorderColor = TrackProTheme.colors.sectorLine + ), modifier = Modifier .fillMaxWidth() ) @@ -126,11 +154,11 @@ fun DropdownMenuField( ExposedDropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, - modifier = Modifier.background(Color(0xFF2C2C2C)) + modifier = Modifier.background(TrackProTheme.colors.bgElevated) ) { options.forEach { option -> DropdownMenuItem( - text = { Text(option, color = Color.White) }, + text = { Text(option, color = TrackProTheme.colors.textPrimary) }, onClick = { onOptionSelected(option) expanded = false diff --git a/app/src/main/java/com/example/trackpro/extrasForUI/TrackProTheme.kt b/app/src/main/java/com/example/trackpro/extrasForUI/TrackProTheme.kt index 7375c5a..835197b 100644 --- a/app/src/main/java/com/example/trackpro/extrasForUI/TrackProTheme.kt +++ b/app/src/main/java/com/example/trackpro/extrasForUI/TrackProTheme.kt @@ -5,6 +5,10 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color import com.example.trackpro.theme.DarkTrackProColors import com.example.trackpro.theme.LightTrackProColors import com.example.trackpro.theme.TrackProColorScheme @@ -19,6 +23,51 @@ object TrackProTheme { get() = LocalTrackProColors.current } +/** + * Maps our racing-HUD palette onto Material3's ColorScheme so that stock Material3 + * components (OutlinedTextField, DropdownMenu, AlertDialog, etc.) that don't explicitly + * override every color still fall back to something themed instead of Material3's + * default light/purple scheme. + */ +private fun TrackProColorScheme.toMaterialColorScheme(dark: Boolean): androidx.compose.material3.ColorScheme = + if (dark) { + darkColorScheme( + primary = accentCyan, + onPrimary = Color.Black, + secondary = accentBlue, + onSecondary = Color.Black, + tertiary = accentAmber, + onTertiary = Color.Black, + background = bgDeep, + onBackground = textPrimary, + surface = bgCard, + onSurface = textPrimary, + surfaceVariant = bgElevated, + onSurfaceVariant = textMuted, + outline = sectorLine, + error = deltaBad, + onError = Color.White + ) + } else { + lightColorScheme( + primary = accentCyan, + onPrimary = Color.Black, + secondary = accentBlue, + onSecondary = Color.Black, + tertiary = accentAmber, + onTertiary = Color.Black, + background = bgDeep, + onBackground = textPrimary, + surface = bgCard, + onSurface = textPrimary, + surfaceVariant = bgElevated, + onSurfaceVariant = textMuted, + outline = sectorLine, + error = deltaBad, + onError = Color.White + ) + } + @Composable fun TrackProTheme( darkTheme: Boolean = isSystemInDarkTheme(), @@ -27,7 +76,11 @@ fun TrackProTheme( val colorScheme = if (darkTheme) DarkTrackProColors else LightTrackProColors CompositionLocalProvider( - LocalTrackProColors provides colorScheme, - content = content - ) + LocalTrackProColors provides colorScheme + ) { + MaterialTheme( + colorScheme = colorScheme.toMaterialColorScheme(darkTheme), + content = content + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/trackpro/managerClasses/ESPDatabase.kt b/app/src/main/java/com/example/trackpro/managerClasses/ESPDatabase.kt index 515e8f1..ebfb96b 100644 --- a/app/src/main/java/com/example/trackpro/managerClasses/ESPDatabase.kt +++ b/app/src/main/java/com/example/trackpro/managerClasses/ESPDatabase.kt @@ -10,6 +10,7 @@ import com.example.trackpro.dao.DerivedDataDao import com.example.trackpro.dao.LapInfoDataDAO import com.example.trackpro.dao.LapTimeDataDAO import com.example.trackpro.dao.RawGPSDataDao +import com.example.trackpro.dao.SectorTimeDataDAO import com.example.trackpro.dao.SessionDataDao import com.example.trackpro.dao.SmoothedGPSDataDAO import com.example.trackpro.dao.TrackCoordinatesDataDAO @@ -19,6 +20,7 @@ import com.example.trackpro.dataClasses.DerivedData import com.example.trackpro.dataClasses.LapInfoData import com.example.trackpro.dataClasses.LapTimeData import com.example.trackpro.dataClasses.RawGPSData +import com.example.trackpro.dataClasses.SectorTimeData import com.example.trackpro.dataClasses.SessionData import com.example.trackpro.dataClasses.SmoothedGPSData import com.example.trackpro.dataClasses.TrackCoordinatesData @@ -42,8 +44,9 @@ import kotlinx.coroutines.launch TrackCoordinatesData::class, VehicleInformationData::class, LapTimeData::class, - LapInfoData::class -], version = 1, exportSchema = false) + LapInfoData::class, + SectorTimeData::class +], version = 3, exportSchema = false) abstract class ESPDatabase : RoomDatabase() { abstract fun sessionDataDao(): SessionDataDao abstract fun rawGPSDataDao(): RawGPSDataDao @@ -54,6 +57,7 @@ abstract class ESPDatabase : RoomDatabase() { abstract fun vehicleInformationDAO(): VehicleInformationDAO abstract fun lapTimeDataDAO(): LapTimeDataDAO abstract fun lapInfoDataDAO(): LapInfoDataDAO + abstract fun sectorTimeDataDAO(): SectorTimeDataDAO companion object { @Volatile @@ -114,6 +118,11 @@ abstract class ESPDatabase : RoomDatabase() { } }) + // No migrations exist yet; without this, any future (or this) schema + // change throws IllegalStateException on every existing install instead + // of recovering. Replace with real Migration objects once the schema + // needs to be preserved across upgrades. + .fallbackToDestructiveMigration() .build() INSTANCE = instance diff --git a/app/src/main/java/com/example/trackpro/managerClasses/jsonReader.kt b/app/src/main/java/com/example/trackpro/managerClasses/JsonReader.kt similarity index 100% rename from app/src/main/java/com/example/trackpro/managerClasses/jsonReader.kt rename to app/src/main/java/com/example/trackpro/managerClasses/JsonReader.kt diff --git a/app/src/main/java/com/example/trackpro/managerClasses/SessionManager.kt b/app/src/main/java/com/example/trackpro/managerClasses/SessionManager.kt index e5c6725..2f0d9dc 100644 --- a/app/src/main/java/com/example/trackpro/managerClasses/SessionManager.kt +++ b/app/src/main/java/com/example/trackpro/managerClasses/SessionManager.kt @@ -21,7 +21,7 @@ class SessionManager private constructor( trackId = trackId, ) currentSessionId = sessionDataDao.insertSession(session) // Insert session - Log.e("SessionManager", "Inserted session with ID: $currentSessionId") + Log.d("SessionManager", "Inserted session with ID: $currentSessionId") return currentSessionId ?: throw Exception("Failed to insert session") } diff --git a/app/src/main/java/com/example/trackpro/managerClasses/calculationClasses/DragTimeCalculation.kt b/app/src/main/java/com/example/trackpro/managerClasses/calculationClasses/DragTimeCalculation.kt index 9cc4bb9..525e6f4 100644 --- a/app/src/main/java/com/example/trackpro/managerClasses/calculationClasses/DragTimeCalculation.kt +++ b/app/src/main/java/com/example/trackpro/managerClasses/calculationClasses/DragTimeCalculation.kt @@ -29,13 +29,19 @@ class DragTimeCalculation( val session: Long? = null, private val database: ESPDatabase ) { + companion object { + private const val QUARTER_MILE_METERS = 402.336f + private const val HALF_MILE_METERS = 804.672f + } + private val zeroThreshold = 2.0f // Speed threshold to consider as "zero" (km/h) // Real-time tracking state private var runStartTime: Long = 0L private var hasStartedRun: Boolean = false - private val gpsPoints = mutableListOf() + private var lastPoint: LatLonOffset? = null private var totalDistanceMeters: Float = 0f + private var runStartDistanceMeters: Float = 0f private var maxSpeedRecorded: Float = 0f // Metric tracking @@ -59,12 +65,12 @@ class DragTimeCalculation( // --- 1. GLOBAL STATS --- if (currentSpeed > maxSpeedRecorded) maxSpeedRecorded = currentSpeed - gpsPoints.add(LatLonOffset(gpsData.latitude, gpsData.longitude)) - if (gpsPoints.size >= 2) { - val last = gpsPoints[gpsPoints.size - 2] - val dist = haversineDistance(last.lat, last.lon, gpsData.latitude, gpsData.longitude) + val currentPoint = LatLonOffset(gpsData.latitude, gpsData.longitude) + lastPoint?.let { last -> + val dist = haversineDistance(last.lat, last.lon, currentPoint.lat, currentPoint.lon) totalDistanceMeters += dist.toFloat() } + lastPoint = currentPoint // --- 2. STANDING START LOGIC --- // If we are below threshold, we are "Ready" to perform a 0-X run @@ -76,6 +82,7 @@ class DragTimeCalculation( if (!hasStartedRun && isReadyForRun && currentSpeed > zeroThreshold) { hasStartedRun = true runStartTime = currentTimeMillis + runStartDistanceMeters = totalDistanceMeters } // Calculate Standing Metrics (Only if a valid run started) @@ -87,11 +94,16 @@ class DragTimeCalculation( if (time0to160Result == null && currentSpeed >= 160f) time0to160Result = elapsed if (time0to200Result == null && currentSpeed >= 200f) time0to200Result = elapsed - // Quarter Mile (Standing only) - if (quarterMileTimeResult == null && totalDistanceMeters >= 402.336f) { + // Quarter Mile (Standing only, measured from the run's start, not the whole recording) + if (quarterMileTimeResult == null && (totalDistanceMeters - runStartDistanceMeters) >= QUARTER_MILE_METERS) { quarterMileTimeResult = elapsed quarterMileSpeedResult = currentSpeed } + + // Half Mile (Standing only, measured from the run's start) + if (halfMileTimeResult == null && (totalDistanceMeters - runStartDistanceMeters) >= HALF_MILE_METERS) { + halfMileTimeResult = elapsed + } } // --- 3. ROLLING METRICS LOGIC (Always Active) --- @@ -145,8 +157,10 @@ class DragTimeCalculation( fun resetRealtimeTracking() { runStartTime = 0L hasStartedRun = false - gpsPoints.clear() + isReadyForRun = false + lastPoint = null totalDistanceMeters = 0f + runStartDistanceMeters = 0f maxSpeedRecorded = 0f time0to60Result = null diff --git a/app/src/main/java/com/example/trackpro/managerClasses/calculationClasses/PostProcessing.kt b/app/src/main/java/com/example/trackpro/managerClasses/calculationClasses/PostProcessing.kt index 60cea01..ea0b883 100644 --- a/app/src/main/java/com/example/trackpro/managerClasses/calculationClasses/PostProcessing.kt +++ b/app/src/main/java/com/example/trackpro/managerClasses/calculationClasses/PostProcessing.kt @@ -56,9 +56,6 @@ class PostProcessing(val database: ESPDatabase) { val smoothedAlt = altWindow.filterNotNull().averageOrNull() ?: gpsData.altitude val smoothedSpeed = speedWindow.filterNotNull().averageOrNull() ?: gpsData.speed - // Debug log - Log.d("PostProcessing", "Timestamp: ${gpsData.timestamp}, Smoothed Speed: $smoothedSpeed") - // Add smoothed result to the list smoothed.add( SmoothedGPSData( diff --git a/app/src/main/java/com/example/trackpro/managerClasses/gpsDataManagers/PhoneGpsProvider.kt b/app/src/main/java/com/example/trackpro/managerClasses/gpsDataManagers/PhoneGpsProvider.kt index 14eebfd..202cbb5 100644 --- a/app/src/main/java/com/example/trackpro/managerClasses/gpsDataManagers/PhoneGpsProvider.kt +++ b/app/src/main/java/com/example/trackpro/managerClasses/gpsDataManagers/PhoneGpsProvider.kt @@ -1,8 +1,12 @@ package com.example.trackpro.managerClasses.gpsDataManagers +import android.Manifest import android.annotation.SuppressLint import android.content.Context +import android.content.pm.PackageManager import android.os.Looper +import android.util.Log +import androidx.core.content.ContextCompat import com.example.trackpro.dataClasses.RawGPSData import com.example.trackpro.models.GpsProvider import com.google.android.gms.location.LocationCallback @@ -14,7 +18,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow class PhoneGpsProvider( - context: Context, + private val context: Context, ) : GpsProvider { private val client = LocationServices.getFusedLocationProviderClient(context) @@ -41,6 +45,18 @@ class PhoneGpsProvider( @SuppressLint("MissingPermission") override fun start() { + val hasFineLocation = ContextCompat.checkSelfPermission( + context, Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + val hasCoarseLocation = ContextCompat.checkSelfPermission( + context, Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + if (!hasFineLocation && !hasCoarseLocation) { + Log.w("PhoneGpsProvider", "Location permission not granted, cannot start phone GPS") + _isConnected.value = false + return + } + val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 100).build() client.requestLocationUpdates(request, callback, Looper.getMainLooper()) _isConnected.value = true diff --git a/app/src/main/java/com/example/trackpro/managerClasses/timeAttackManagers/CircuitTiming.kt b/app/src/main/java/com/example/trackpro/managerClasses/timeAttackManagers/CircuitTiming.kt index ebb19c0..2fe5b3f 100644 --- a/app/src/main/java/com/example/trackpro/managerClasses/timeAttackManagers/CircuitTiming.kt +++ b/app/src/main/java/com/example/trackpro/managerClasses/timeAttackManagers/CircuitTiming.kt @@ -4,15 +4,30 @@ import android.os.SystemClock import com.example.trackpro.dataClasses.TrackCoordinatesData import kotlinx.coroutines.channels.Channel import com.example.trackpro.dataClasses.RawGPSData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** One completed sector split for the lap currently in progress. deltaMs is vs. this session's best for that sector, or null if this is the first time it's been recorded. */ +data class SectorSplit(val sectorIndex: Int, val splitMs: Long, val deltaMs: Long?) class CircuitTimingManager( - private val finishLine: List + private val finishLine: List, + private val sectorLines: List> = emptyList() ) : TimingManager() { private var lapStartTime = SystemClock.elapsedRealtime() private var lastCrossTime = 0L + private var lastSplitTime = lapStartTime private var bestLapSeconds = Double.POSITIVE_INFINITY private var hasStarted = false + private var currentSectorIndex = 0 + private val bestSectorMs = mutableMapOf() + val lapCompletedChannel = Channel(Channel.UNLIMITED) + val sectorCompletedChannel = Channel(Channel.UNLIMITED) + + private val _currentLapSplits = MutableStateFlow>(emptyList()) + val currentLapSplits: StateFlow> = _currentLapSplits.asStateFlow() override fun handleGpsUpdate( prev: RawGPSData?, @@ -20,23 +35,51 @@ class CircuitTimingManager( ) { val now = SystemClock.elapsedRealtime() prev?.let { prevData -> - TrackGeometry.checkLineCrossing(prevData, current, finishLine)?.let { crossing -> - if (crossing.isValid && now - lastCrossTime > 5000) { - if (!hasStarted) { - hasStarted = true - lastCrossTime = now - lapStartTime = now - return - } + val finishCrossing = TrackGeometry.checkLineCrossing(prevData, current, finishLine) + var finishHandled = false + if (finishCrossing != null && finishCrossing.isValid && now - lastCrossTime > 5000) { + finishHandled = true + if (!hasStarted) { + hasStarted = true + lastCrossTime = now + lapStartTime = now + lastSplitTime = now + currentSectorIndex = 0 + _currentLapSplits.value = emptyList() + } else { val lapMs = now - lapStartTime updateTimes(lapMs) lastCrossTime = now lapStartTime = now + lastSplitTime = now + currentSectorIndex = 0 + _currentLapSplits.value = emptyList() _eventCount.value += 1 lapCompletedChannel.trySend(lapMs) } } + + // Only look for the next expected sector gate while a lap is in progress, and + // only if the finish line didn't just fire on this same update. + if (!finishHandled && hasStarted && currentSectorIndex < sectorLines.size) { + val gate = sectorLines[currentSectorIndex] + val sectorCrossing = TrackGeometry.checkLineCrossing(prevData, current, gate) + if (sectorCrossing != null && sectorCrossing.isValid && now - lastCrossTime > 5000) { + val splitMs = now - lastSplitTime + val best = bestSectorMs[currentSectorIndex] + val deltaMs = best?.let { splitMs - it } + if (best == null || splitMs < best) bestSectorMs[currentSectorIndex] = splitMs + + val split = SectorSplit(currentSectorIndex, splitMs, deltaMs) + _currentLapSplits.value = _currentLapSplits.value + split + sectorCompletedChannel.trySend(split) + + lastCrossTime = now + lastSplitTime = now + currentSectorIndex += 1 + } + } } _currentTime.value = formatTime(now - lapStartTime) } @@ -55,13 +98,20 @@ class CircuitTimingManager( override fun reset() { lapStartTime = SystemClock.elapsedRealtime() lastCrossTime = 0L + lastSplitTime = lapStartTime hasStarted = false + currentSectorIndex = 0 + bestSectorMs.clear() + _currentLapSplits.value = emptyList() _stintStart.value = lapStartTime _eventCount.value = 0 } override fun startNewEvent() { lapStartTime = SystemClock.elapsedRealtime() + lastSplitTime = lapStartTime + currentSectorIndex = 0 + _currentLapSplits.value = emptyList() _currentTime.value = formatTime(0) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/trackpro/managerClasses/timeAttackManagers/TrackGeometry.kt b/app/src/main/java/com/example/trackpro/managerClasses/timeAttackManagers/TrackGeometry.kt index d0b95d6..fbdae49 100644 --- a/app/src/main/java/com/example/trackpro/managerClasses/timeAttackManagers/TrackGeometry.kt +++ b/app/src/main/java/com/example/trackpro/managerClasses/timeAttackManagers/TrackGeometry.kt @@ -7,6 +7,8 @@ import kotlin.math.sqrt object TrackGeometry { private const val TAG = "TrackGeometry" + private const val FINISH_LINE_WIDTH_METERS = 12.0 + private const val METERS_PER_DEGREE_LATITUDE = 111320.0 data class Vector(val x: Double, val y: Double) { operator fun plus(v: Vector) = Vector(x + v.x, y + v.y) @@ -26,35 +28,64 @@ object TrackGeometry { return if (track.size >= 2) listOf(track[0], track[1]) else track } + return buildGateLine(track, startPoint, idBase = -1) ?: run { + Log.w(TAG, "Not enough points near start") + if (track.size >= 2) listOf(track[0], track[1]) else track + } + } + + /** + * Computes a perpendicular gate line (2 points) through each point on the track that was + * marked as a sector split during track building, ordered by sectorIndex. Tracks with no + * marked sector points (e.g. the bundled seed tracks) simply return an empty list. + */ + fun calculateSectorLines(track: List): List> { + val sectorPoints = track.filter { it.isSectorPoint } + .sortedBy { it.sectorIndex ?: Int.MAX_VALUE } + + return sectorPoints.mapIndexedNotNull { index, point -> + buildGateLine(track, point, idBase = -100 - (index * 2L)) + } + } + + /** + * Builds a perpendicular gate line through [atPoint], oriented to the track's local + * direction of travel around it (same construction as the finish/sector lines), or null + * if there aren't enough nearby points to establish a direction. + */ + private fun buildGateLine( + track: List, + atPoint: TrackCoordinatesData, + idBase: Long + ): List? { val nearbyPoints = track.filter { - it.id in (startPoint.id - 5)..(startPoint.id + 5) && it.id != startPoint.id + it.id in (atPoint.id - 5)..(atPoint.id + 5) && it.id != atPoint.id }.take(10) - if (nearbyPoints.isEmpty()) { - Log.w(TAG, "Not enough points near start") - return if (track.size >= 2) listOf(track[0], track[1]) else track - } + if (nearbyPoints.isEmpty()) return null val avgDirection = nearbyPoints.fold(Vector(0.0, 0.0)) { acc, point -> - acc + Vector(point.longitude - startPoint.longitude, point.latitude - startPoint.latitude) + acc + Vector(point.longitude - atPoint.longitude, point.latitude - atPoint.latitude) } * (1.0 / nearbyPoints.size) val perpendicular = Vector(-avgDirection.y, avgDirection.x).normalized() - val lineLength = 12.0 / 111320.0 + val lineLength = FINISH_LINE_WIDTH_METERS / METERS_PER_DEGREE_LATITUDE val scaledPerpendicular = perpendicular * lineLength return listOf( - startPoint.copy( - id = -1, - latitude = startPoint.latitude - scaledPerpendicular.y, - longitude = startPoint.longitude - scaledPerpendicular.x, - isStartPoint = false + atPoint.copy( + id = idBase, + latitude = atPoint.latitude - scaledPerpendicular.y, + longitude = atPoint.longitude - scaledPerpendicular.x, + isStartPoint = false, + isSectorPoint = false ), - startPoint.copy( - id = -2, - latitude = startPoint.latitude + scaledPerpendicular.y, - longitude = startPoint.longitude + scaledPerpendicular.x, - isStartPoint = false + atPoint.copy( + id = idBase - 1, + latitude = atPoint.latitude + scaledPerpendicular.y, + longitude = atPoint.longitude + scaledPerpendicular.x, + isStartPoint = false, + isSectorPoint = false ) ) } @@ -69,7 +100,7 @@ object TrackGeometry { val startPoint = track.first() val startDirection = calculateDirection(startPoint, track[1]) val startPerpendicular = Vector(-startDirection.y, startDirection.x).normalized() - val startLineLength = 12.0 / 111320.0 + val startLineLength = FINISH_LINE_WIDTH_METERS / METERS_PER_DEGREE_LATITUDE val startLine = listOf( startPoint.copy( id = -10, @@ -87,7 +118,7 @@ object TrackGeometry { val finishPoint = track.last() val finishDirection = calculateDirection(track[track.size - 2], finishPoint) val finishPerpendicular = Vector(-finishDirection.y, finishDirection.x).normalized() - val finishLineLength = 12.0 / 111320.0 + val finishLineLength = FINISH_LINE_WIDTH_METERS / METERS_PER_DEGREE_LATITUDE val finishLine = listOf( finishPoint.copy( id = -20, @@ -136,7 +167,13 @@ object TrackGeometry { } return intersection?.let { - CrossingResult(true, determineDirection(prevPos, currPos, lineStart, lineEnd)) + val direction = determineDirection(prevPos, currPos, lineStart, lineEnd) + // Lines are built with the perpendicular rotated from the track's forward + // direction of travel, which makes ENTERING correspond to a crossing in that + // forward direction. Only forward crossings should count, otherwise a car + // overshooting the line and rolling back across it would register a second + // (bogus) crossing. + CrossingResult(direction == CrossingDirection.ENTERING, direction) } } diff --git a/app/src/main/java/com/example/trackpro/screens/SettingsScreen.kt b/app/src/main/java/com/example/trackpro/screens/SettingsScreen.kt index 0361b05..0ab74dd 100644 --- a/app/src/main/java/com/example/trackpro/screens/SettingsScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/SettingsScreen.kt @@ -36,6 +36,8 @@ fun SettingsScreen(onBack: () -> Unit) { val context = LocalContext.current val app = context.applicationContext as TrackProApp val useExternal by app.useExternalGps.collectAsState() + val useDarkTheme by app.useDarkTheme.collectAsState() + val useMetric by app.useMetricUnits.collectAsState() Column( modifier = Modifier @@ -78,7 +80,7 @@ fun SettingsScreen(onBack: () -> Unit) { Button( onClick = { app.useExternalGps.value = !useExternal }, colors = ButtonDefaults.buttonColors( - containerColor = if (useExternal) TrackProTheme.colors.accentCyan else Color(0xFF1E2530) + containerColor = if (useExternal) TrackProTheme.colors.accentCyan else TrackProTheme.colors.bgElevated ), shape = RoundedCornerShape(4.dp), modifier = Modifier.height(36.dp) @@ -93,10 +95,84 @@ fun SettingsScreen(onBack: () -> Unit) { } } - // --- Section: Measurement --- + // --- Section: Appearance --- + SettingsSectionHeader(title = "APPEARANCE") + SettingsCard { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "THEME", + color = TrackProTheme.colors.textPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = if (useDarkTheme) "Dark" else "Light", + color = TrackProTheme.colors.accentCyan, + fontSize = 12.sp + ) + } + + Button( + onClick = { app.setDarkTheme(!useDarkTheme) }, + colors = ButtonDefaults.buttonColors( + containerColor = TrackProTheme.colors.accentCyan + ), + shape = RoundedCornerShape(4.dp), + modifier = Modifier.height(36.dp) + ) { + Text( + text = if (useDarkTheme) "USE LIGHT" else "USE DARK", + color = Color.Black, + fontSize = 10.sp, + fontWeight = FontWeight.ExtraBold + ) + } + } + } + + // --- Section: Units --- SettingsSectionHeader(title = "UNITS") SettingsCard { - //SettingsToggleRow(label = "Use Metric Units (km/h)", isActive = true) {} + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "SPEED & DISTANCE", + color = TrackProTheme.colors.textPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = if (useMetric) "Metric (km/h, km)" else "Imperial (mph, mi)", + color = TrackProTheme.colors.accentCyan, + fontSize = 12.sp + ) + } + + Button( + onClick = { app.setMetricUnits(!useMetric) }, + colors = ButtonDefaults.buttonColors( + containerColor = TrackProTheme.colors.accentCyan + ), + shape = RoundedCornerShape(4.dp), + modifier = Modifier.height(36.dp) + ) { + Text( + text = if (useMetric) "USE MPH" else "USE KM/H", + color = Color.Black, + fontSize = 10.sp, + fontWeight = FontWeight.ExtraBold + ) + } + } } // --- Section: System --- @@ -129,7 +205,7 @@ private fun SettingsCard(content: @Composable () -> Unit) { modifier = Modifier .fillMaxWidth() .background(TrackProTheme.colors.bgCard, RoundedCornerShape(8.dp)) - .border(1.dp, Color(0xFF1E2530), RoundedCornerShape(8.dp)) + .border(1.dp, TrackProTheme.colors.sectorLine, RoundedCornerShape(8.dp)) .padding(16.dp) ) { content() diff --git a/app/src/main/java/com/example/trackpro/screens/TrackBuilderScreen.kt b/app/src/main/java/com/example/trackpro/screens/TrackBuilderScreen.kt index 9554709..981eea3 100644 --- a/app/src/main/java/com/example/trackpro/screens/TrackBuilderScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/TrackBuilderScreen.kt @@ -81,6 +81,14 @@ fun TrackBuilderScreen( // Track Points val gpsPointsList = remember { mutableStateListOf() } + var sectorCount by remember { mutableIntStateOf(0) } + + fun markSector() { + if (gpsPointsList.isEmpty()) return + val lastIndex = gpsPointsList.size - 1 + gpsPointsList[lastIndex] = gpsPointsList[lastIndex].copy(isSectorPoint = true, sectorIndex = sectorCount) + sectorCount += 1 + } // UI State var showInfoDialog by remember { mutableStateOf(false) } @@ -121,6 +129,7 @@ fun TrackBuilderScreen( } else { coroutineScope.launch { gpsPointsList.clear() // Clear old preview + sectorCount = 0 trackID = startTrackBuilder(database, trackName, countryName, trackMode) isLiveRecording = true } @@ -152,6 +161,13 @@ fun TrackBuilderScreen( canSave = gpsPointsList.size > 1 && trackName.isNotEmpty() ) } + + Spacer(modifier = Modifier.height(12.dp)) + MarkSectorButton( + count = sectorCount, + enabled = gpsPointsList.isNotEmpty() && (builderType == 1 || isLiveRecording), + onClick = { markSector() } + ) } // Map/Preview Area @@ -188,8 +204,9 @@ fun MapLibreBuilderView( onMapTap: (LatLng) -> Unit ) { var mapLibreMap by remember { mutableStateOf(null) } + val sectorMarkCount = points.count { it.isSectorPoint } - LaunchedEffect(points.size) { + LaunchedEffect(points.size, sectorMarkCount) { mapLibreMap?.let { map -> map.clear() @@ -221,6 +238,16 @@ fun MapLibreBuilderView( .title(if (trackMode == "Circuit") "LAP COMPLETE" else "FINISH LINE") ) } + + // Sector markers + points.filter { it.isSectorPoint } + .sortedBy { it.sectorIndex ?: Int.MAX_VALUE } + .forEach { sectorPoint -> + map.addMarker(MarkerOptions() + .position(LatLng(sectorPoint.latitude, sectorPoint.longitude)) + .title("S${(sectorPoint.sectorIndex ?: 0) + 1}") + ) + } } } } @@ -268,6 +295,28 @@ private fun TrackInfoCard(name: String, country: String, mode: String, onClick: } } +@Composable +private fun MarkSectorButton(count: Int, enabled: Boolean, onClick: () -> Unit) { + Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier.fillMaxWidth().height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = TrackProTheme.colors.accentBlue, + disabledContainerColor = Color(0xFF1E2530) + ), + shape = RoundedCornerShape(8.dp) + ) { + Text( + "MARK SECTOR ${count + 1}", + color = if (enabled) Color.Black else TrackProTheme.colors.textMuted, + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + letterSpacing = 1.sp + ) + } +} + @Composable private fun ModeToggle(selected: Int, onSelect: (Int) -> Unit) { Row(modifier = Modifier.fillMaxWidth().background(TrackProTheme.colors.bgCard, RoundedCornerShape(8.dp)).padding(4.dp)) { diff --git a/app/src/main/java/com/example/trackpro/screens/listViewScreens/CarListView.kt b/app/src/main/java/com/example/trackpro/screens/listViewScreens/CarListView.kt index fa00435..534090c 100644 --- a/app/src/main/java/com/example/trackpro/screens/listViewScreens/CarListView.kt +++ b/app/src/main/java/com/example/trackpro/screens/listViewScreens/CarListView.kt @@ -1,9 +1,6 @@ package com.example.trackpro.screens.listViewScreens import android.content.Context -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -52,16 +49,6 @@ import com.example.trackpro.viewModels.VehicleFULLViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -class CarListView : ComponentActivity() -{ - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent{ - - } - } -} - @Composable fun CarListScreen(navController: NavController, viewModel: VehicleFULLViewModel) { val context = LocalContext.current @@ -173,9 +160,9 @@ fun VehicleCard( if (showDeleteDialog) { AlertDialog( onDismissRequest = { showDeleteDialog = false }, - containerColor = Color(0xFF0E1117), - titleContentColor = Color(0xFFF0F2F5), - textContentColor = Color(0xFF6B7280), + containerColor = TrackProTheme.colors.bgCard, + titleContentColor = TrackProTheme.colors.textPrimary, + textContentColor = TrackProTheme.colors.textMuted, confirmButton = { TextButton(onClick = { onDelete(vehicle) diff --git a/app/src/main/java/com/example/trackpro/screens/listViewScreens/DragTimesList.kt b/app/src/main/java/com/example/trackpro/screens/listViewScreens/DragTimesList.kt index 065f3b9..d47efbf 100644 --- a/app/src/main/java/com/example/trackpro/screens/listViewScreens/DragTimesList.kt +++ b/app/src/main/java/com/example/trackpro/screens/listViewScreens/DragTimesList.kt @@ -1,8 +1,5 @@ package com.example.trackpro.screens.listViewScreens -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -15,6 +12,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -35,30 +33,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController -import androidx.navigation.compose.rememberNavController import com.example.trackpro.extrasForUI.TrackProTheme import com.example.trackpro.managerClasses.utilities.DateFormatterUtil import com.example.trackpro.models.DragSessionWithVehicle import com.example.trackpro.viewModels.DragSessionViewModel -import com.example.trackpro.viewModels.DragSessionViewModelFactory import java.util.Date -class DragTimesList : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - val navController = rememberNavController() - val viewModel: DragSessionViewModel = viewModel( - factory = DragSessionViewModelFactory(this) - ) - - DragTimesListView(viewModel = viewModel, navController = navController) - } - } -} - @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun DragTimesListView( @@ -107,20 +88,40 @@ fun DragTimesListView( } - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - groupedSessions.forEach { (groupKey, sessions) -> - item(key = groupKey) { - ExpandableSessionGroup( - groupTitle = groupKey, - sessions = sessions, - navController = navController + if (dragSessions.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "NO SESSIONS RECORDED", + color = TrackProTheme.colors.textMuted, + fontSize = 14.sp, + letterSpacing = 3.sp, + fontWeight = FontWeight.Black + ) + Spacer(Modifier.height(8.dp)) + Text( + "Run a drag session to see it here", + color = TrackProTheme.colors.textMuted.copy(alpha = 0.5f), + fontSize = 12.sp ) } } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + groupedSessions.forEach { (groupKey, sessions) -> + item(key = groupKey) { + ExpandableSessionGroup( + groupTitle = groupKey, + sessions = sessions, + navController = navController + ) + } + } + } } } } diff --git a/app/src/main/java/com/example/trackpro/screens/listViewScreens/TimeAttackListView.kt b/app/src/main/java/com/example/trackpro/screens/listViewScreens/TimeAttackListView.kt index d2e08f0..309b607 100644 --- a/app/src/main/java/com/example/trackpro/screens/listViewScreens/TimeAttackListView.kt +++ b/app/src/main/java/com/example/trackpro/screens/listViewScreens/TimeAttackListView.kt @@ -76,7 +76,23 @@ fun TimeAttackListViewScreen( } if (trackSessions.isEmpty()) { - // ... Empty State ... + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "NO SESSIONS RECORDED", + color = TrackProTheme.colors.textMuted, + fontSize = 14.sp, + letterSpacing = 3.sp, + fontWeight = FontWeight.Black + ) + Spacer(Modifier.height(8.dp)) + Text( + "Run a track session to see it here", + color = TrackProTheme.colors.textMuted.copy(alpha = 0.5f), + fontSize = 12.sp + ) + } + } } else { LazyColumn( modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/com/example/trackpro/screens/listViewScreens/TrackListView.kt b/app/src/main/java/com/example/trackpro/screens/listViewScreens/TrackListView.kt index b28305e..4cc8a63 100644 --- a/app/src/main/java/com/example/trackpro/screens/listViewScreens/TrackListView.kt +++ b/app/src/main/java/com/example/trackpro/screens/listViewScreens/TrackListView.kt @@ -1,8 +1,5 @@ package com.example.trackpro.screens.listViewScreens -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -28,6 +25,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -49,14 +47,7 @@ import com.example.trackpro.managerClasses.ESPDatabase import com.example.trackpro.viewModels.TrackViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch - -class TrackListView : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - } - } -} +import kotlinx.coroutines.withContext @Composable fun TrackListScreen(navController: NavController, viewModel: TrackViewModel) { @@ -122,6 +113,7 @@ fun TrackListScreen(navController: NavController, viewModel: TrackViewModel) { TrackCard( track = track, navController = navController, + database = database, bgCard = TrackProTheme.colors.bgCard, bgElevated = TrackProTheme.colors.bgElevated, accentAmber = TrackProTheme.colors.accentAmber, @@ -146,6 +138,7 @@ fun TrackListScreen(navController: NavController, viewModel: TrackViewModel) { fun TrackCard( track: TrackMainData, navController: NavController, + database: ESPDatabase, bgCard: Color, bgElevated: Color, accentAmber: Color, @@ -156,13 +149,20 @@ fun TrackCard( onDelete: (TrackMainData) -> Unit ) { var showDeleteDialog by remember { mutableStateOf(false) } + var bestLapTime by remember(track.trackId) { mutableStateOf(null) } + + LaunchedEffect(track.trackId) { + bestLapTime = withContext(Dispatchers.IO) { + database.lapTimeDataDAO().getBestLapForTrack(track.trackId)?.laptime + } + } if (showDeleteDialog) { AlertDialog( onDismissRequest = { showDeleteDialog = false }, - containerColor = Color(0xFF0E1117), - titleContentColor = Color(0xFFF0F2F5), - textContentColor = Color(0xFF6B7280), + containerColor = TrackProTheme.colors.bgCard, + titleContentColor = TrackProTheme.colors.textPrimary, + textContentColor = TrackProTheme.colors.textMuted, confirmButton = { TextButton(onClick = { onDelete(track); showDeleteDialog = false }) { Text("DELETE", color = accentRed, fontWeight = FontWeight.Black) @@ -288,8 +288,7 @@ fun TrackCard( Text("LAP RECORD", color = textMuted, fontSize = 9.sp, letterSpacing = 1.sp, fontWeight = FontWeight.Bold) Text( - // TODO: wire up actual lap record from DB - text = "—", + text = bestLapTime ?: "—", color = textPrimary, fontSize = 16.sp, fontWeight = FontWeight.Black diff --git a/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/DragScreenListItem.kt b/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/DragScreenListItem.kt index 48d32c8..b417015 100644 --- a/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/DragScreenListItem.kt +++ b/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/DragScreenListItem.kt @@ -11,10 +11,12 @@ 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.shape.RoundedCornerShape import androidx.compose.material.Divider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableStateOf @@ -35,6 +37,9 @@ import com.example.trackpro.dataClasses.RawGPSData import com.example.trackpro.managerClasses.ESPDatabase import com.example.trackpro.dataClasses.convertToLatLonOffsetList import com.example.trackpro.extrasForUI.TrackProTheme +import com.example.trackpro.TrackProApp +import com.example.trackpro.managerClasses.utilities.SpeedColorUtils +import com.example.trackpro.managerClasses.utilities.UnitFormatter import com.example.trackpro.screens.telemetricScreens.DragMetricCard import com.example.trackpro.screens.telemetricScreens.DragMetricDisplay import com.github.mikephil.charting.charts.LineChart @@ -45,6 +50,19 @@ import com.github.mikephil.charting.data.LineDataSet import com.github.mikephil.charting.formatter.ValueFormatter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.maplibre.android.MapLibre +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.geometry.LatLngBounds +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.MapView +import org.maplibre.android.maps.Style +import org.maplibre.android.style.expressions.Expression +import org.maplibre.android.style.layers.CircleLayer +import org.maplibre.android.style.layers.LineLayer +import org.maplibre.android.style.layers.Property +import org.maplibre.android.style.layers.PropertyFactory +import org.maplibre.android.style.sources.GeoJsonSource import java.util.concurrent.TimeUnit import kotlin.math.* @@ -63,10 +81,13 @@ private fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Doub @Composable fun GraphScreen(onBack: () -> Unit, sessionId: Long) { val context = LocalContext.current + val app = context.applicationContext as TrackProApp + val useMetric by app.useMetricUnits.collectAsState() val database = remember { ESPDatabase.getInstance(context) } var coordinates by remember { mutableStateOf(emptyList()) } val dragTimeClass = remember { DragTimeCalculation(sessionId, database) } var totalDist by remember { mutableDoubleStateOf(-1.0) } + var showMap by remember { mutableStateOf(false) } // X = cumulative meters or seconds, Y = speed (km/h) val dataPointsMeters = remember { mutableListOf() } @@ -202,11 +223,7 @@ fun GraphScreen(onBack: () -> Unit, sessionId: Long) { } CompactStat( label = "DISTANCE", - value = when { - totalDist <= 0 -> "—" - totalDist >= 1000 -> String.format("%.2f km", totalDist / 1000.0) - else -> String.format("%.0f m", totalDist) - }, + value = if (totalDist <= 0) "—" else UnitFormatter.formatDistance(totalDist, useMetric), textMuted = TrackProTheme.colors.textMuted, valueColor = TrackProTheme.colors.textPrimary ) @@ -218,9 +235,9 @@ fun GraphScreen(onBack: () -> Unit, sessionId: Long) { modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - DragMetricCard(DragMetricDisplay("TOP SPEED", if (maxSpeed > 0) String.format("%.0f", maxSpeed) else "—", "KM/H", maxSpeed > 0), modifier = Modifier.weight(1f)) - DragMetricCard(DragMetricDisplay("AVG SPEED", if (avgSpeed > 0) String.format("%.0f", avgSpeed) else "—", "KM/H", avgSpeed > 0), modifier = Modifier.weight(1f)) - DragMetricCard(DragMetricDisplay("MAX ACCEL", if (maxAcceleration > 0) String.format("%.1f", maxAcceleration) else "—", "KM/H/S", maxAcceleration > 0), modifier = Modifier.weight(1f)) + DragMetricCard(DragMetricDisplay("TOP SPEED", if (maxSpeed > 0) UnitFormatter.formatSpeed(maxSpeed, useMetric) else "—", UnitFormatter.speedUnitLabel(useMetric), maxSpeed > 0), modifier = Modifier.weight(1f)) + DragMetricCard(DragMetricDisplay("AVG SPEED", if (avgSpeed > 0) UnitFormatter.formatSpeed(avgSpeed, useMetric) else "—", UnitFormatter.speedUnitLabel(useMetric), avgSpeed > 0), modifier = Modifier.weight(1f)) + DragMetricCard(DragMetricDisplay("MAX ACCEL", if (maxAcceleration > 0) String.format("%.1f", UnitFormatter.convertSpeed(maxAcceleration, useMetric)) else "—", "${UnitFormatter.speedUnitLabel(useMetric)}/S", maxAcceleration > 0), modifier = Modifier.weight(1f)) } Row( @@ -244,7 +261,7 @@ fun GraphScreen(onBack: () -> Unit, sessionId: Long) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { DragMetricCard(DragMetricDisplay("100-200", formatMetric(metrics.time100to200), "SEC", metrics.time100to200 != null), modifier = Modifier.weight(1f)) DragMetricCard(DragMetricDisplay("¼ MILE", formatMetric(metrics.quarterMileTime), "SEC", metrics.quarterMileTime != null), modifier = Modifier.weight(1f)) - DragMetricCard(DragMetricDisplay("TRAP SPD", metrics.quarterMileSpeed?.toInt()?.toString() ?: "—", "KM/H", metrics.quarterMileSpeed != null), modifier = Modifier.weight(1f)) + DragMetricCard(DragMetricDisplay("TRAP SPD", metrics.quarterMileSpeed?.let { UnitFormatter.formatSpeed(it, useMetric) } ?: "—", UnitFormatter.speedUnitLabel(useMetric), metrics.quarterMileSpeed != null), modifier = Modifier.weight(1f)) } } @@ -260,7 +277,7 @@ fun GraphScreen(onBack: () -> Unit, sessionId: Long) { verticalAlignment = Alignment.CenterVertically ) { Text( - text = "SPEED TRACE", + text = if (showMap) "GPS TRACE" else "SPEED TRACE", color = TrackProTheme.colors.textMuted, fontSize = 9.sp, letterSpacing = 2.sp, @@ -270,15 +287,36 @@ fun GraphScreen(onBack: () -> Unit, sessionId: Long) { horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { - listOf(true to "METER", false to "SEC").forEach { (isMeters, label) -> - val active = xAxisInMeters == isMeters + if (!showMap) { + listOf(true to "METER", false to "SEC").forEach { (isMeters, label) -> + val active = xAxisInMeters == isMeters + Box( + modifier = Modifier + .background( + if (active) TrackProTheme.colors.accentCyan else TrackProTheme.colors.sectorLine, + RoundedCornerShape(3.dp) + ) + .clickable { xAxisInMeters = isMeters } + .padding(horizontal = 10.dp, vertical = 3.dp) + ) { + Text( + text = label, + color = if (active) Color.Black else TrackProTheme.colors.textMuted, + fontSize = 9.sp, + fontWeight = FontWeight.Black, + ) + } + } + } + listOf(false to "CHART", true to "MAP").forEach { (isMap, label) -> + val active = showMap == isMap Box( modifier = Modifier .background( - if (active) TrackProTheme.colors.accentCyan else TrackProTheme.colors.sectorLine, - androidx.compose.foundation.shape.RoundedCornerShape(3.dp) + if (active) TrackProTheme.colors.accentAmber else TrackProTheme.colors.sectorLine, + RoundedCornerShape(3.dp) ) - .clickable { xAxisInMeters = isMeters } + .clickable { showMap = isMap } .padding(horizontal = 10.dp, vertical = 3.dp) ) { Text( @@ -299,43 +337,159 @@ fun GraphScreen(onBack: () -> Unit, sessionId: Long) { .fillMaxSize() .background(TrackProTheme.colors.bgCard) ) { - AndroidView( - factory = { ctx -> - LineChart(ctx).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT + if (showMap) { + if (coordinates.isNotEmpty()) { + DragSessionMapView(gpsData = coordinates, modifier = Modifier.fillMaxSize()) + } else { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + "NO GPS DATA", + color = TrackProTheme.colors.textMuted, + fontSize = 12.sp, + letterSpacing = 2.sp ) - setupChartStyle() - } - }, - update = { chart -> - val dataSet = LineDataSet(dataPoints, "Speed").apply { - setDrawValues(false) - setDrawCircles(false) - lineWidth = 2f - color = android.graphics.Color.parseColor("#E8001C") - setDrawFilled(true) - fillColor = android.graphics.Color.parseColor("#E8001C") - fillAlpha = 40 } - chart.data = LineData(dataSet) - chart.xAxis.valueFormatter = object : ValueFormatter() { - override fun getFormattedValue(value: Float): String { - return if (xAxisInMeters) "${value.toInt()}m" else "${value.toInt()}s" + } + } else { + AndroidView( + factory = { ctx -> + LineChart(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + setupChartStyle() } - } - chart.notifyDataSetChanged() - chart.invalidate() - }, - modifier = Modifier - .fillMaxSize() - .padding(8.dp) - ) + }, + update = { chart -> + val convertedPoints = dataPoints.map { Entry(it.x, UnitFormatter.convertSpeed(it.y, useMetric)) } + val dataSet = LineDataSet(convertedPoints, "Speed").apply { + setDrawValues(false) + setDrawCircles(false) + lineWidth = 2f + color = android.graphics.Color.parseColor("#E8001C") + setDrawFilled(true) + fillColor = android.graphics.Color.parseColor("#E8001C") + fillAlpha = 40 + } + chart.data = LineData(dataSet) + chart.xAxis.valueFormatter = object : ValueFormatter() { + override fun getFormattedValue(value: Float): String { + return if (xAxisInMeters) "${value.toInt()}m" else "${value.toInt()}s" + } + } + chart.notifyDataSetChanged() + chart.invalidate() + }, + modifier = Modifier + .fillMaxSize() + .padding(8.dp) + ) + } } } } +@Composable +fun DragSessionMapView( + gpsData: List, + modifier: Modifier = Modifier +) { + val mapRef = remember { mutableStateOf(null) } + val styleRef = remember { mutableStateOf(null) } + + LaunchedEffect(gpsData) { + val map = mapRef.value ?: return@LaunchedEffect + val style = styleRef.value ?: return@LaunchedEffect + drawDragSpeedHeatmap(style, gpsData) + if (gpsData.isNotEmpty()) fitCameraToDragGps(map, gpsData) + } + + AndroidView( + factory = { ctx -> + MapLibre.getInstance(ctx) + MapView(ctx).also { mv -> + mv.onCreate(null) + mv.getMapAsync { map -> + mapRef.value = map + map.uiSettings.setAllGesturesEnabled(true) + map.setStyle("https://tiles.openfreemap.org/styles/dark") { style -> + styleRef.value = style + drawDragSpeedHeatmap(style, gpsData) + if (gpsData.isNotEmpty()) fitCameraToDragGps(map, gpsData) + } + } + } + }, + modifier = modifier + ) +} + +private fun drawDragSpeedHeatmap(style: Style, gps: List) { + listOf("drag-heat-layer", "drag-start-layer", "drag-end-layer").forEach { id -> + style.getLayer(id)?.let { style.removeLayer(it) } + } + listOf("drag-heat-src", "drag-start-src", "drag-end-src").forEach { id -> + style.getSource(id)?.let { style.removeSource(it) } + } + + if (gps.size < 2) return + + val speeds = gps.mapNotNull { it.speed } + val minSpd = speeds.minOrNull() ?: 0f + val maxSpd = speeds.maxOrNull() ?: 1f + + val features = mutableListOf() + for (i in 0 until gps.size - 1) { + val p0 = gps[i]; val p1 = gps[i + 1] + val spd = ((p0.speed ?: minSpd) + (p1.speed ?: minSpd)) / 2f + val t = if (maxSpd > minSpd) (spd - minSpd) / (maxSpd - minSpd) else 0f + val hex = SpeedColorUtils.speedToHex(t) + features.add( + """{"type":"Feature","geometry":{"type":"LineString","coordinates":[[${p0.longitude},${p0.latitude}],[${p1.longitude},${p1.latitude}]]},"properties":{"color":"$hex"}}""" + ) + } + + val geojson = """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}""" + style.addSource(GeoJsonSource("drag-heat-src", geojson)) + style.addLayer(LineLayer("drag-heat-layer", "drag-heat-src").apply { + setProperties( + PropertyFactory.lineColor(Expression.get("color")), + PropertyFactory.lineWidth(3f), + PropertyFactory.lineCap(Property.LINE_CAP_ROUND), + PropertyFactory.lineJoin(Property.LINE_JOIN_ROUND) + ) + }) + + val start = gps.first() + val end = gps.last() + style.addSource(GeoJsonSource("drag-start-src", """{"type":"Feature","geometry":{"type":"Point","coordinates":[${start.longitude},${start.latitude}]}}""")) + style.addLayer(CircleLayer("drag-start-layer", "drag-start-src").apply { + setProperties( + PropertyFactory.circleColor("#00E676"), + PropertyFactory.circleRadius(6f), + PropertyFactory.circleStrokeColor("#0E1117"), + PropertyFactory.circleStrokeWidth(1.5f) + ) + }) + style.addSource(GeoJsonSource("drag-end-src", """{"type":"Feature","geometry":{"type":"Point","coordinates":[${end.longitude},${end.latitude}]}}""")) + style.addLayer(CircleLayer("drag-end-layer", "drag-end-src").apply { + setProperties( + PropertyFactory.circleColor("#FF1744"), + PropertyFactory.circleRadius(6f), + PropertyFactory.circleStrokeColor("#0E1117"), + PropertyFactory.circleStrokeWidth(1.5f) + ) + }) +} + +private fun fitCameraToDragGps(map: MapLibreMap, gps: List) { + if (gps.isEmpty()) return + val bb = LatLngBounds.Builder() + gps.forEach { bb.include(LatLng(it.latitude, it.longitude)) } + map.easeCamera(CameraUpdateFactory.newLatLngBounds(bb.build(), 80), 800) +} + private fun LineChart.setupChartStyle() { xAxis.apply { position = XAxis.XAxisPosition.BOTTOM diff --git a/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/LapDetailScreen.kt b/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/LapDetailScreen.kt index 66e5b7d..04e785b 100644 --- a/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/LapDetailScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/LapDetailScreen.kt @@ -35,8 +35,12 @@ import androidx.navigation.compose.rememberNavController import androidx.room.Room import com.example.trackpro.dataClasses.LapInfoData import com.example.trackpro.dataClasses.LapTimeData +import com.example.trackpro.dataClasses.SectorTimeData import com.example.trackpro.extrasForUI.TrackProTheme +import com.example.trackpro.TrackProApp import com.example.trackpro.managerClasses.ESPDatabase +import com.example.trackpro.managerClasses.utilities.SpeedColorUtils +import com.example.trackpro.managerClasses.utilities.UnitFormatter import com.example.trackpro.managerClasses.utilities.toLapTimeMillis import com.example.trackpro.managerClasses.utilities.toLapTimeString import kotlinx.coroutines.Dispatchers @@ -94,12 +98,17 @@ fun LapDetailScreen( sessionId: Long, primaryLapId: Long ) { + val app = LocalContext.current.applicationContext as TrackProApp + val useMetric by app.useMetricUnits.collectAsState() + // ── State ────────────────────────────────────────────── var allSessionLaps by remember { mutableStateOf>(emptyList()) } var primaryLap by remember { mutableStateOf(null) } var primaryGps by remember { mutableStateOf>(emptyList()) } + var primarySectors by remember { mutableStateOf>(emptyList()) } var compareLap by remember { mutableStateOf(null) } var compareGps by remember { mutableStateOf>(emptyList()) } + var compareSectors by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } var heatmapMode by remember { mutableStateOf(HeatmapMode.SPEED) } var showLapPicker by remember { mutableStateOf(false) } @@ -113,6 +122,9 @@ fun LapDetailScreen( primaryGps = primaryLap?.let { database.lapInfoDataDAO().getLapData(it.id) } ?: emptyList() + primarySectors = primaryLap?.let { + database.sectorTimeDataDAO().getSectorTimesForLap(it.id) + } ?: emptyList() withContext(Dispatchers.Main) { isLoading = false } } } @@ -122,8 +134,9 @@ fun LapDetailScreen( compareLap?.let { cl -> withContext(Dispatchers.IO) { compareGps = database.lapInfoDataDAO().getLapData(cl.id) + compareSectors = database.sectorTimeDataDAO().getSectorTimesForLap(cl.id) } - } ?: run { compareGps = emptyList() } + } ?: run { compareGps = emptyList(); compareSectors = emptyList() } } // ── Derived stats ────────────────────────────────────── @@ -198,7 +211,7 @@ fun LapDetailScreen( ) } Text( - "⚡ ${String.format("%.0f", primaryTopSpeed)} km/h", + "⚡ ${UnitFormatter.formatSpeed(primaryTopSpeed, useMetric)} ${UnitFormatter.speedUnitLabel(useMetric)}", color = Color.Black, fontSize = 10.sp, fontWeight = FontWeight.Bold @@ -310,11 +323,14 @@ fun LapDetailScreen( StatsPanel( primaryLap = lap, primaryGps = primaryGps, + primarySectors = primarySectors, compareLap = compareLap, compareGps = compareGps, + compareSectors = compareSectors, primaryTopSpeed = primaryTopSpeed, compareTopSpeed = compareTopSpeed, deltaMs = deltaMs, + useMetric = useMetric, onDismiss = { showStatsPanel = false } ) } @@ -474,7 +490,7 @@ private fun drawSpeedHeatmap( val spd = ((pt0.spd ?: minSpd) + (pt1.spd ?: minSpd)) / 2f val t = if (maxSpd > minSpd) (spd - minSpd) / (maxSpd - minSpd) else 0f - val hex = speedToHex(t) + val hex = SpeedColorUtils.speedToHex(t) features.add(""" { @@ -548,31 +564,6 @@ private fun drawEndpointDot(style: Style, pt: LapInfoData, id: String, color: St }) } -/** t in 0..1 → blue (slow) → green → yellow → red (fast) */ -private fun speedToHex(t: Float): String { - val clamped = t.coerceIn(0f, 1f) - val r: Int; val g: Int; val b: Int - when { - clamped < 0.25f -> { - val s = clamped / 0.25f - r = 0; g = (s * 180).toInt(); b = 255 - } - clamped < 0.5f -> { - val s = (clamped - 0.25f) / 0.25f - r = 0; g = (180 + s * 75).toInt(); b = (255 * (1 - s)).toInt() - } - clamped < 0.75f -> { - val s = (clamped - 0.5f) / 0.25f - r = (s * 255).toInt(); g = 255; b = 0 - } - else -> { - val s = (clamped - 0.75f) / 0.25f - r = 255; g = (255 * (1 - s)).toInt(); b = 0 - } - } - return String.format("#%02X%02X%02X", r.coerceIn(0,255), g.coerceIn(0,255), b.coerceIn(0,255)) -} - private fun fitCameraToGps(map: org.maplibre.android.maps.MapLibreMap, gps: List) { val valid = gps.filter { it.lat != null && it.lon != null } if (valid.isEmpty()) return @@ -595,11 +586,14 @@ private fun androidColorFrom(color: Color): Int = private fun StatsPanel( primaryLap: LapTimeData, primaryGps: List, + primarySectors: List, compareLap: LapTimeData?, compareGps: List, + compareSectors: List, primaryTopSpeed: Float, compareTopSpeed: Float, deltaMs: Long, + useMetric: Boolean, onDismiss: () -> Unit ) { val primaryAvgSpd = primaryGps.mapNotNull { it.spd }.let { @@ -680,14 +674,23 @@ private fun StatsPanel( HorizontalDivider(color = TrackProTheme.colors.sectorLine) + val speedUnit = UnitFormatter.speedUnitLabel(useMetric) val rows = buildList { add(Triple("LAP TIME", primaryLap.laptime, compareLap?.laptime ?: "—")) - add(Triple("TOP SPEED", "${String.format("%.0f", primaryTopSpeed)} km/h", - if (compareLap != null) "${String.format("%.0f", compareTopSpeed)} km/h" else "—")) - add(Triple("AVG SPEED", "${String.format("%.1f", primaryAvgSpd)} km/h", - if (compareLap != null) "${String.format("%.1f", compareAvgSpd)} km/h" else "—")) + add(Triple("TOP SPEED", "${UnitFormatter.formatSpeed(primaryTopSpeed, useMetric)} $speedUnit", + if (compareLap != null) "${UnitFormatter.formatSpeed(compareTopSpeed, useMetric)} $speedUnit" else "—")) + add(Triple("AVG SPEED", "${UnitFormatter.formatSpeedPrecise(primaryAvgSpd.toDouble(), useMetric)} $speedUnit", + if (compareLap != null) "${UnitFormatter.formatSpeedPrecise(compareAvgSpd.toDouble(), useMetric)} $speedUnit" else "—")) add(Triple("GPS POINTS", "${primaryGps.size}", if (compareLap != null) "${compareGps.size}" else "—")) + primarySectors.sortedBy { it.sectorIndex }.forEach { sector -> + val compareSplit = compareSectors.find { it.sectorIndex == sector.sectorIndex } + add(Triple( + "SECTOR ${sector.sectorIndex + 1}", + String.format("%.2fs", sector.splitTimeMs / 1000.0), + if (compareLap != null) compareSplit?.let { String.format("%.2fs", it.splitTimeMs / 1000.0) } ?: "—" else "—" + )) + } if (compareLap != null) { val sign = if (deltaMs > 0) "+" else "" add(Triple("DELTA", "${sign}${deltaMs.toLapTimeString()}", "")) diff --git a/app/src/main/java/com/example/trackpro/screens/telemetricScreens/DragScreen.kt b/app/src/main/java/com/example/trackpro/screens/telemetricScreens/DragScreen.kt index 3fe22e9..f41d002 100644 --- a/app/src/main/java/com/example/trackpro/screens/telemetricScreens/DragScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/telemetricScreens/DragScreen.kt @@ -111,9 +111,7 @@ fun DragRaceScreen( if (isSessionActive) { scope.launch(Dispatchers.IO) { sessionManager.endSession() - dataBuffer.forEach { gps -> - database.rawGPSDataDao().insert(gps) - } + database.rawGPSDataDao().insertAll(dataBuffer) } } } @@ -561,9 +559,7 @@ fun DragRaceScreen( sessionManager.endSession() // Save buffered GPS data - dataBuffer.forEach { gps -> - database.rawGPSDataDao().insert(gps) - } + database.rawGPSDataDao().insertAll(dataBuffer) dataBuffer.clear() } } diff --git a/app/src/main/java/com/example/trackpro/screens/telemetricScreens/TimeAttackScreen.kt b/app/src/main/java/com/example/trackpro/screens/telemetricScreens/TimeAttackScreen.kt index c49c7cc..ba744ff 100644 --- a/app/src/main/java/com/example/trackpro/screens/telemetricScreens/TimeAttackScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/telemetricScreens/TimeAttackScreen.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -43,6 +42,7 @@ import com.example.trackpro.TrackProApp import com.example.trackpro.dataClasses.TrackCoordinatesData import com.example.trackpro.dataClasses.LatLonOffset import com.example.trackpro.extrasForUI.TrackProTheme +import com.example.trackpro.managerClasses.timeAttackManagers.SectorSplit import com.example.trackpro.managerClasses.timeAttackManagers.TimingMode import com.example.trackpro.viewModels.TimeAttackViewModel import kotlinx.coroutines.Dispatchers @@ -95,6 +95,7 @@ fun TimeAttackScreenView( val startLine by vm.startLine.collectAsState() val driver by vm.driverPosition.collectAsState() val timingMode by vm.timingMode.collectAsState() + val lapSplits by vm.currentLapSplits.collectAsState() val linesToShow by remember(timingMode, startLine, finishLine) { derivedStateOf { @@ -162,7 +163,8 @@ fun TimeAttackScreenView( gpsPoints = gpsPoints, driver = driverPos, isConnected = isConnected, - linesToShow = linesToShow + linesToShow = linesToShow, + lapSplits = lapSplits ) else -> TimeAttackPortraitLayout( timingMode = timingMode, @@ -175,19 +177,11 @@ fun TimeAttackScreenView( gpsPoints = gpsPoints, driver = driverPos, isConnected = isConnected, - linesToShow = linesToShow + linesToShow = linesToShow, + lapSplits = lapSplits ) } - DisposableEffect(Unit) { - Log.d("TimeAttackScreen", "DisposableEffect - Connecting to ESP") - app.espTcpClient.connect() - - onDispose { - Log.d("TimeAttackScreen", "DisposableEffect - Disconnecting from ESP") - app.espTcpClient.disconnect() - } - } } // ── Portrait ─────────────────────────────────────────────── @@ -203,7 +197,8 @@ fun TimeAttackPortraitLayout( gpsPoints: List, driver: LatLonOffset, isConnected: Boolean, - linesToShow : List + linesToShow : List, + lapSplits: List = emptyList() ) { val deltaColor = if (delta <= 0) TrackProTheme.colors.deltaGood else TrackProTheme.colors.deltaBad val eventName = if (timingMode is TimingMode.Circuit) "LAP" else "RUN" @@ -318,6 +313,11 @@ fun TimeAttackPortraitLayout( StintTimerCell(stintStart = stintStart) } + if (lapSplits.isNotEmpty()) { + HorizontalDivider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) + SectorSplitsRow(splits = lapSplits) + } + HorizontalDivider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) // ── Map ─────────────────────────────────────── @@ -369,7 +369,8 @@ fun TimeAttackLandscapeLayout( gpsPoints: List, driver: LatLonOffset, isConnected: Boolean, - linesToShow: List + linesToShow: List, + lapSplits: List = emptyList() ) { val deltaColor = if (delta <= 0) TrackProTheme.colors.deltaGood else TrackProTheme.colors.deltaBad val eventName = if (timingMode is TimingMode.Circuit) "LAP" else "RUN" @@ -450,6 +451,11 @@ fun TimeAttackLandscapeLayout( LapTimeCell(label = "LAST", value = lastTime, valueColor = TrackProTheme.colors.textPrimary) } + if (lapSplits.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + SectorSplitsRow(splits = lapSplits) + } + Spacer(Modifier.height(16.dp)) HorizontalDivider(color = TrackProTheme.colors.sectorLine) Spacer(Modifier.height(16.dp)) @@ -557,6 +563,49 @@ private fun StintTimerCell(stintStart: Long) { } } +@Composable +private fun SectorSplitsRow(splits: List) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(18.dp) + ) { + splits.forEach { split -> + val deltaColor = when { + split.deltaMs == null -> TrackProTheme.colors.textPrimary + split.deltaMs <= 0 -> TrackProTheme.colors.deltaGood + else -> TrackProTheme.colors.deltaBad + } + Column { + Text( + "S${split.sectorIndex + 1}", + color = TrackProTheme.colors.textMuted, + fontSize = 9.sp, + letterSpacing = 1.sp, + fontWeight = FontWeight.Bold + ) + Text( + String.format("%.2fs", split.splitMs / 1000.0), + color = deltaColor, + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + if (split.deltaMs != null) { + val deltaSeconds = split.deltaMs / 1000.0 + val sign = if (deltaSeconds > 0) "+" else "" + Text( + "$sign${String.format("%.2f", deltaSeconds)}", + color = deltaColor, + fontSize = 9.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + } +} + @Composable private fun SectorDivider() { Box( diff --git a/app/src/main/java/com/example/trackpro/screens/vehicleScreens/CarCreatorScreen.kt b/app/src/main/java/com/example/trackpro/screens/vehicleScreens/CarCreatorScreen.kt index 8c87a54..43544f3 100644 --- a/app/src/main/java/com/example/trackpro/screens/vehicleScreens/CarCreatorScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/vehicleScreens/CarCreatorScreen.kt @@ -1,8 +1,8 @@ package com.example.trackpro.screens.vehicleScreens import android.widget.Toast -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -25,9 +25,6 @@ import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Timer import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -46,6 +43,7 @@ import com.example.trackpro.dataClasses.VehicleInformationData import com.example.trackpro.managerClasses.ESPDatabase import com.example.trackpro.extrasForUI.CustomTextField import com.example.trackpro.extrasForUI.DropdownMenuField +import com.example.trackpro.extrasForUI.TrackProTheme import com.example.trackpro.managerClasses.JsonReader.loadJsonOptions import kotlinx.coroutines.launch @@ -76,34 +74,41 @@ fun CarCreationScreen( val scrollState = rememberScrollState() - val showSection = remember { mutableStateOf(true) } - Box( modifier = Modifier .fillMaxSize() - .background(Color(0xFF121212)) - .padding(16.dp) + .background(TrackProTheme.colors.bgDeep) ) { - Column( - modifier = Modifier - .verticalScroll(scrollState) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "🏎️ Vehicle Setup", - color = Color.White, - fontSize = 24.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 16.dp) - ) - - AnimatedVisibility(visible = showSection.value) { - Card( - colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)), - shape = RoundedCornerShape(20.dp), - elevation = CardDefaults.cardElevation(10.dp), - modifier = Modifier.fillMaxWidth() + Column(modifier = Modifier.fillMaxSize()) { + + // ── Top bar ─────────────────────────────────── + Box( + modifier = Modifier + .fillMaxWidth() + .background(TrackProTheme.colors.accentAmber) + .padding(horizontal = 20.dp, vertical = 6.dp) + ) { + Text( + text = "● VEHICLE SETUP", + color = Color.Black, + fontSize = 11.sp, + fontWeight = FontWeight.Black, + letterSpacing = 3.sp + ) + } + + Column( + modifier = Modifier + .verticalScroll(scrollState) + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(TrackProTheme.colors.bgCard, RoundedCornerShape(12.dp)) + .border(1.dp, TrackProTheme.colors.sectorLine, RoundedCornerShape(12.dp)) ) { Column(modifier = Modifier.padding(20.dp)) { @@ -121,19 +126,19 @@ fun CarCreationScreen( CustomTextField("Fuel Capacity (L)", fuelCapacity, true, Icons.Default.LocalGasStation) { fuelCapacity = it } SectionTitle("Configuration") - DropdownMenuField("Engine Type", jsonOptions.engineTypes, selectedEngineType, Color.White) { selectedEngineType = it } - DropdownMenuField("Drivetrain", jsonOptions.drivetrains, selectedDrivetrain, Color.White) { selectedDrivetrain = it } - DropdownMenuField("Fuel Type", jsonOptions.fuelTypes, selectedFuelType, Color.White) { selectedFuelType = it } - DropdownMenuField("Tire Type", jsonOptions.tireTypes, selectedTireType, Color.White) { selectedTireType = it } - DropdownMenuField("Transmission", jsonOptions.transmissions, selectedTransmission, Color.White) { selectedTransmission = it } - DropdownMenuField("Suspension", jsonOptions.suspensionTypes, selectedSuspensionType, Color.White) { selectedSuspensionType = it } + DropdownMenuField("Engine Type", jsonOptions.engineTypes, selectedEngineType) { selectedEngineType = it } + DropdownMenuField("Drivetrain", jsonOptions.drivetrains, selectedDrivetrain) { selectedDrivetrain = it } + DropdownMenuField("Fuel Type", jsonOptions.fuelTypes, selectedFuelType) { selectedFuelType = it } + DropdownMenuField("Tire Type", jsonOptions.tireTypes, selectedTireType) { selectedTireType = it } + DropdownMenuField("Transmission", jsonOptions.transmissions, selectedTransmission) { selectedTransmission = it } + DropdownMenuField("Suspension", jsonOptions.suspensionTypes, selectedSuspensionType) { selectedSuspensionType = it } Spacer(modifier = Modifier.height(20.dp)) Button( onClick = { if (manufacturer.isBlank() || model.isBlank() || year.isBlank()) { - Toast.makeText(context, "⚠️ Fill in required fields.", Toast.LENGTH_SHORT).show() + Toast.makeText(context, "Fill in required fields.", Toast.LENGTH_SHORT).show() return@Button } @@ -159,13 +164,19 @@ fun CarCreationScreen( database.vehicleInformationDAO().insertVehicle(vehicle) } - Toast.makeText(context, "🚀 Vehicle saved successfully!", Toast.LENGTH_SHORT).show() + Toast.makeText(context, "Vehicle saved successfully.", Toast.LENGTH_SHORT).show() }, modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF00E676)), - shape = RoundedCornerShape(14.dp) + colors = ButtonDefaults.buttonColors(containerColor = TrackProTheme.colors.deltaGood), + shape = RoundedCornerShape(8.dp) ) { - Text("Save Vehicle", fontSize = 16.sp, fontWeight = FontWeight.Bold) + Text( + "SAVE VEHICLE", + color = Color.Black, + fontSize = 13.sp, + fontWeight = FontWeight.Black, + letterSpacing = 1.sp + ) } } } @@ -178,9 +189,11 @@ fun CarCreationScreen( @Composable fun SectionTitle(title: String) { Text( - text = title, - color = Color(0xFFB0BEC5), - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + text = title.uppercase(), + color = TrackProTheme.colors.textMuted, + fontSize = 10.sp, + letterSpacing = 2.sp, + fontWeight = FontWeight.Bold, modifier = Modifier.padding(vertical = 8.dp) ) } diff --git a/app/src/main/java/com/example/trackpro/theme/TrackProColors.kt b/app/src/main/java/com/example/trackpro/theme/TrackProColors.kt index 7c4f355..a2c5db8 100644 --- a/app/src/main/java/com/example/trackpro/theme/TrackProColors.kt +++ b/app/src/main/java/com/example/trackpro/theme/TrackProColors.kt @@ -18,29 +18,29 @@ data class TrackProColorScheme( val DarkTrackProColors = TrackProColorScheme( - bgDeep = Color(0xFF0F1219), - bgCard = Color(0xFF161B26), - bgElevated = Color(0xFF1F2635), - accentCyan = Color(0xFF00B4D8), - accentBlue = Color(0xFF6C7AEC), - accentAmber = Color(0xFFF7A23B), - textPrimary = Color(0xFFF1F3F7), - textMuted = Color(0xFF8A94A6), - deltaGood = Color(0xFF22C55E), - deltaBad = Color(0xFFEF4444), - sectorLine = Color(0xFF2E374A) + bgDeep = Color(0xFF0A0C11), + bgCard = Color(0xFF151924), + bgElevated = Color(0xFF212739), + accentCyan = Color(0xFF22D3EE), + accentBlue = Color(0xFF818CF8), + accentAmber = Color(0xFFFBBF24), + textPrimary = Color(0xFFF3F5F9), + textMuted = Color(0xFF9AA5B8), + deltaGood = Color(0xFF4ADE80), + deltaBad = Color(0xFFF87171), + sectorLine = Color(0xFF3A4358) ) val LightTrackProColors = TrackProColorScheme( - bgDeep = Color(0xFFF4F6F9), + bgDeep = Color(0xFFEEF1F6), bgCard = Color(0xFFFFFFFF), - bgElevated = Color(0xFFEAEFF5), - accentCyan = Color(0xFF0077B6), - accentBlue = Color(0xFF4A56E2), + bgElevated = Color(0xFFE2E8F1), + accentCyan = Color(0xFF0891B2), + accentBlue = Color(0xFF6366F1), accentAmber = Color(0xFFD97706), - textPrimary = Color(0xFF111827), - textMuted = Color(0xFF6B7280), + textPrimary = Color(0xFF0F172A), + textMuted = Color(0xFF64748B), deltaGood = Color(0xFF16A34A), deltaBad = Color(0xFFDC2626), - sectorLine = Color(0xFFE5E7EB) + sectorLine = Color(0xFFD8DEE9) ) \ No newline at end of file diff --git a/app/src/main/java/com/example/trackpro/ui/theme/Color.kt b/app/src/main/java/com/example/trackpro/ui/theme/Color.kt deleted file mode 100644 index ec8f0a4..0000000 --- a/app/src/main/java/com/example/trackpro/ui/theme/Color.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.example.trackpro.ui.theme - -import androidx.compose.ui.graphics.Color - -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) - -val Blue40 = Color(0x001A5ADA) -val BlueGrey40 = Color(0xFF424C65) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/example/trackpro/ui/theme/Theme.kt b/app/src/main/java/com/example/trackpro/ui/theme/Theme.kt deleted file mode 100644 index d82f7cb..0000000 --- a/app/src/main/java/com/example/trackpro/ui/theme/Theme.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.example.trackpro.ui.theme - -import android.app.Activity -import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.core.view.WindowCompat - -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 -) - -private val LightColorScheme = lightColorScheme( - primary = Blue40, - secondary = BlueGrey40, - tertiary = Pink40 - - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ -) - -@Composable -fun TrackProTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, - content: @Composable () -> Unit -) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - - darkTheme -> DarkColorScheme - else -> LightColorScheme - } - val view = LocalView.current - if (!view.isInEditMode) { - SideEffect { - val window = (view.context as Activity).window - window.statusBarColor = colorScheme.primary.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme - } - } - - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/example/trackpro/ui/theme/Type.kt b/app/src/main/java/com/example/trackpro/ui/theme/Type.kt deleted file mode 100644 index e612f45..0000000 --- a/app/src/main/java/com/example/trackpro/ui/theme/Type.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.example.trackpro.ui.theme - -import androidx.compose.material3.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp - -// Set of Material typography styles to start with -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) - /* Other default text styles to override - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp - ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) - */ -) \ No newline at end of file diff --git a/app/src/main/java/com/example/trackpro/viewModels/TimeAttackViewModel.kt b/app/src/main/java/com/example/trackpro/viewModels/TimeAttackViewModel.kt index da58989..2daf0f0 100644 --- a/app/src/main/java/com/example/trackpro/viewModels/TimeAttackViewModel.kt +++ b/app/src/main/java/com/example/trackpro/viewModels/TimeAttackViewModel.kt @@ -9,10 +9,12 @@ import com.example.trackpro.TrackProApp import com.example.trackpro.dataClasses.LapInfoData import com.example.trackpro.dataClasses.LapTimeData import com.example.trackpro.dataClasses.RawGPSData +import com.example.trackpro.dataClasses.SectorTimeData import com.example.trackpro.dataClasses.TrackCoordinatesData import com.example.trackpro.dataClasses.LatLonOffset import com.example.trackpro.managerClasses.gpsDataManagers.ESPTcpClient import com.example.trackpro.managerClasses.timeAttackManagers.CircuitTimingManager +import com.example.trackpro.managerClasses.timeAttackManagers.SectorSplit import com.example.trackpro.managerClasses.timeAttackManagers.SprintTimingManager import com.example.trackpro.managerClasses.timeAttackManagers.TimingManager import com.example.trackpro.managerClasses.timeAttackManagers.TimingMode @@ -51,6 +53,7 @@ class TimeAttackViewModel( private val _fullTrack = MutableStateFlow>(emptyList()) private val _startLine = MutableStateFlow>(emptyList()) private val _finishLine = MutableStateFlow>(emptyList()) + private val _sectorLines = MutableStateFlow>>(emptyList()) // Session state private var _sessionId: Long = -1 @@ -64,6 +67,13 @@ class TimeAttackViewModel( val fullTrack: StateFlow> = _fullTrack.asStateFlow() val startLine: StateFlow> = _startLine.asStateFlow() val finishLine: StateFlow> = _finishLine.asStateFlow() + val sectorLines: StateFlow>> = _sectorLines.asStateFlow() + + // Sector splits for the lap currently in progress (Circuit mode only; empty for Sprint + // or tracks with no marked sector points). + val currentLapSplits: StateFlow> + get() = (timingManager as? CircuitTimingManager)?.currentLapSplits + ?: MutableStateFlow>(emptyList()).asStateFlow() // Expose timing state val currentTime: StateFlow @@ -106,13 +116,19 @@ class TimeAttackViewModel( TimingMode.Circuit -> { _finishLine.value = calculateFinishLine(coords) _startLine.value = emptyList() - val manager = CircuitTimingManager(_finishLine.value) + _sectorLines.value = TrackGeometry.calculateSectorLines(coords) + val manager = CircuitTimingManager(_finishLine.value, _sectorLines.value) timingManager = manager viewModelScope.launch { manager.lapCompletedChannel.consumeAsFlow().collect { lapMs -> handleCompletedLap(lapMs) } } + viewModelScope.launch { + manager.sectorCompletedChannel.consumeAsFlow().collect { split -> + handleCompletedSector(split) + } + } } TimingMode.Sprint -> { val (start, finish) = TrackGeometry.calculateSprintLines(coords) @@ -203,6 +219,26 @@ class TimeAttackViewModel( } } + private fun handleCompletedSector(split: SectorSplit) { + viewModelScope.launch(Dispatchers.IO) { + if (_lapId == -1L) { + Log.w("TimeAttack", "Cannot record sector split: no active lap") + return@launch + } + try { + database.sectorTimeDataDAO().insert( + SectorTimeData( + lapid = _lapId, + sectorIndex = split.sectorIndex, + splitTimeMs = split.splitMs + ) + ) + } catch (e: Exception) { + Log.e("TimeAttack", "Error recording sector split: ${e.message}", e) + } + } + } + private fun handleCompletedSprint(sprintMs: Long) { viewModelScope.launch(Dispatchers.IO) { if (_sessionId == -1L || _lapId == -1L) { @@ -238,14 +274,11 @@ class TimeAttackViewModel( val eventType = "${track.trackName} - $todayFormatted" - _sessionId = run { - sessionManager.startSession( - eventType = eventType, - vehicleId = vehicleId, - trackId = trackId - ) - sessionManager.getCurrentSessionId()!! - } + _sessionId = sessionManager.startSession( + eventType = eventType, + vehicleId = vehicleId, + trackId = trackId + ) Log.d("TimeAttack", "Session created/resumed: $_sessionId") diff --git a/app/src/main/java/com/example/trackpro/viewModels/TrackSessionViewModel.kt b/app/src/main/java/com/example/trackpro/viewModels/TrackSessionViewModel.kt deleted file mode 100644 index 49130c1..0000000 --- a/app/src/main/java/com/example/trackpro/viewModels/TrackSessionViewModel.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.example.trackpro.viewModels - -import android.content.Context -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import com.example.trackpro.managerClasses.ESPDatabase -import com.example.trackpro.models.DragSessionWithVehicle -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch - -class TrackSessionViewModel(private val database: ESPDatabase) : ViewModel() { - private val _trackSessions = MutableStateFlow>(emptyList()) - val trackSessions = _trackSessions.asStateFlow() - - init { - fetchTrackSessions() - } - - private fun fetchTrackSessions() { - viewModelScope.launch { - // Only fetch sessions where trackId is NOT null and NOT -1 (track sessions) - database.sessionDataDao().getAllTrackSessionsWithVehicles() - .collect { _trackSessions.value = it } - } - } - - fun deleteSession(session: DragSessionWithVehicle) { - viewModelScope.launch { - database.sessionDataDao().deleteSessionById(session.sessionId) - } - } -} - -class TrackSessionViewModelFactory(private val context: Context) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - @Suppress("UNCHECKED_CAST") - return TrackSessionViewModel(ESPDatabase.getInstance(context.applicationContext)) as T - } -} \ No newline at end of file