From 927aa379df94425747668a7299281b0ad73797ae Mon Sep 17 00:00:00 2001 From: "meszaros.martin986@gmail.com" Date: Thu, 30 Jul 2026 09:29:30 +0200 Subject: [PATCH 1/4] Refined GPS tracking, session management, and DAO data types This commit improves the accuracy of drag racing and track session calculations, adds permission safety to GPS providers, and standardizes ID types in DAOs. **Key Changes:** * **`DragTimeCalculation.kt`:** * Improved quarter-mile accuracy by measuring distance relative to the run's start (`runStartDistanceMeters`) rather than the total session distance. * Ensures `isReadyForRun` is reset when tracking is reset. * **`TrackGeometry.kt`:** * Refined line-crossing logic to only register "forward" crossings (`ENTERING` direction). This prevents duplicate lap triggers if a vehicle rolls backward over a start/finish line. * **`PhoneGpsProvider.kt`:** * Added explicit permission checks for `ACCESS_FINE_LOCATION` and `ACCESS_COARSE_LOCATION` before starting location updates to prevent crashes or silent failures. * **`TimeAttackViewModel.kt`:** * Simplified session ID assignment by directly using the return value of `sessionManager.startSession()`. * **DAOs (`RawGPSDataDao.kt`, `TrackCoordinatesDataDAO.kt`):** * Updated `delete` methods to use `Long` for `sessionId` and `trackId` to maintain consistency with the rest of the database schema. --- .../com/example/trackpro/dao/RawGPSDataDao.kt | 2 +- .../trackpro/dao/TrackCoordinatesDataDAO.kt | 2 +- .../calculationClasses/DragTimeCalculation.kt | 8 ++++++-- .../gpsDataManagers/PhoneGpsProvider.kt | 18 +++++++++++++++++- .../timeAttackManagers/TrackGeometry.kt | 8 +++++++- .../trackpro/viewModels/TimeAttackViewModel.kt | 13 +++++-------- 6 files changed, 37 insertions(+), 14 deletions(-) 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/managerClasses/calculationClasses/DragTimeCalculation.kt b/app/src/main/java/com/example/trackpro/managerClasses/calculationClasses/DragTimeCalculation.kt index 9cc4bb9..5b4b3b7 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 @@ -36,6 +36,7 @@ class DragTimeCalculation( private var hasStartedRun: Boolean = false private val gpsPoints = mutableListOf() private var totalDistanceMeters: Float = 0f + private var runStartDistanceMeters: Float = 0f private var maxSpeedRecorded: Float = 0f // Metric tracking @@ -76,6 +77,7 @@ class DragTimeCalculation( if (!hasStartedRun && isReadyForRun && currentSpeed > zeroThreshold) { hasStartedRun = true runStartTime = currentTimeMillis + runStartDistanceMeters = totalDistanceMeters } // Calculate Standing Metrics (Only if a valid run started) @@ -87,8 +89,8 @@ 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) >= 402.336f) { quarterMileTimeResult = elapsed quarterMileSpeedResult = currentSpeed } @@ -145,8 +147,10 @@ class DragTimeCalculation( fun resetRealtimeTracking() { runStartTime = 0L hasStartedRun = false + isReadyForRun = false gpsPoints.clear() totalDistanceMeters = 0f + runStartDistanceMeters = 0f maxSpeedRecorded = 0f time0to60Result = null 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/TrackGeometry.kt b/app/src/main/java/com/example/trackpro/managerClasses/timeAttackManagers/TrackGeometry.kt index d0b95d6..5fc9666 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 @@ -136,7 +136,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/viewModels/TimeAttackViewModel.kt b/app/src/main/java/com/example/trackpro/viewModels/TimeAttackViewModel.kt index da58989..23ed242 100644 --- a/app/src/main/java/com/example/trackpro/viewModels/TimeAttackViewModel.kt +++ b/app/src/main/java/com/example/trackpro/viewModels/TimeAttackViewModel.kt @@ -238,14 +238,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") From 7eb48e2e3c79ff792a8ceeff3e3aca4b17629e1e Mon Sep 17 00:00:00 2001 From: "meszaros.martin986@gmail.com" Date: Thu, 30 Jul 2026 10:17:02 +0200 Subject: [PATCH 2/4] Refactored database schema and enhanced lap time tracking This commit updates the Room database schema to include proper indexing for foreign keys and upgrades the version to 2 with destructive migration enabled. It also implements lap record fetching for tracks and optimizes telemetry data insertion. **Key Changes:** * **Database & Models:** * Updated `ESPDatabase` to version 2 and added `fallbackToDestructiveMigration()`. * Added indices to `sessionid`, `vehicleId`, and `trackId` across all data classes (`RawGPSData`, `SmoothedGPSData`, `LapTimeData`, `SessionData`, etc.) to improve query performance. * Deleted unused default theme files: `Color.kt`, `Theme.kt`, and `Type.kt`. * Deleted `TrackSessionViewModel.kt`. * **UI & Screens:** * **`TrackListView.kt`:** Integrated `LapTimeDataDAO` to fetch and display the actual best lap record for each track instead of a placeholder. * **`CarCreatorScreen.kt`:** Refactored styling to use the custom `TrackProTheme` colors instead of hardcoded hex values. * **`DragScreen.kt`:** Optimized GPS data persistence by using `insertAll(dataBuffer)` instead of individual inserts in a loop. * **`TimeAttackScreen.kt`:** Removed `DisposableEffect` that was managing ESP TCP connections. * **Logic & Calculations:** * **`DragTimeCalculation.kt`:** Added "Half Mile" (804.672m) timing logic and refactored distance calculations to use a single `lastPoint` reference instead of a list of points. * **`TrackGeometry.kt`:** Extracted constants for finish line width and latitude scaling. * **`PostProcessing.kt`:** Removed debug log statements for smoothed speed. * **`LapTimeDataDAO.kt`:** Added `getBestLapForTrack` query to retrieve the fastest completed lap for a specific track across all sessions. * **Configuration:** * Updated `AndroidManifest.xml` to remove `ACCESS_BACKGROUND_LOCATION`. * Updated Compose BOM to `2024.03.00` in `build.gradle.kts`. --- app/build.gradle.kts | 8 +-- app/src/main/AndroidManifest.xml | 4 +- .../example/trackpro/dao/LapTimeDataDAO.kt | 12 ++++ .../trackpro/dataClasses/DerivedData.kt | 4 +- .../trackpro/dataClasses/LapInfoData.kt | 4 +- .../trackpro/dataClasses/LapTimeData.kt | 4 +- .../trackpro/dataClasses/RawGPSData.kt | 4 +- .../trackpro/dataClasses/SessionData.kt | 4 +- .../trackpro/dataClasses/SmoothedGPSData.kt | 4 +- .../dataClasses/TrackCoordinatesData.kt | 4 +- .../trackpro/managerClasses/ESPDatabase.kt | 7 +- .../{jsonReader.kt => JsonReader.kt} | 0 .../trackpro/managerClasses/SessionManager.kt | 2 +- .../calculationClasses/DragTimeCalculation.kt | 24 +++++-- .../calculationClasses/PostProcessing.kt | 3 - .../timeAttackManagers/TrackGeometry.kt | 8 ++- .../screens/listViewScreens/CarListView.kt | 13 ---- .../screens/listViewScreens/TrackListView.kt | 25 ++++--- .../screens/telemetricScreens/DragScreen.kt | 8 +-- .../telemetricScreens/TimeAttackScreen.kt | 10 --- .../vehicleScreens/CarCreatorScreen.kt | 24 +++---- .../com/example/trackpro/ui/theme/Color.kt | 11 --- .../com/example/trackpro/ui/theme/Theme.kt | 70 ------------------- .../com/example/trackpro/ui/theme/Type.kt | 34 --------- .../viewModels/TrackSessionViewModel.kt | 42 ----------- 25 files changed, 92 insertions(+), 241 deletions(-) rename app/src/main/java/com/example/trackpro/managerClasses/{jsonReader.kt => JsonReader.kt} (100%) delete mode 100644 app/src/main/java/com/example/trackpro/ui/theme/Color.kt delete mode 100644 app/src/main/java/com/example/trackpro/ui/theme/Theme.kt delete mode 100644 app/src/main/java/com/example/trackpro/ui/theme/Type.kt delete mode 100644 app/src/main/java/com/example/trackpro/viewModels/TrackSessionViewModel.kt 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/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/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..544fece 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) 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..a9ba697 100644 --- a/app/src/main/java/com/example/trackpro/managerClasses/ESPDatabase.kt +++ b/app/src/main/java/com/example/trackpro/managerClasses/ESPDatabase.kt @@ -43,7 +43,7 @@ import kotlinx.coroutines.launch VehicleInformationData::class, LapTimeData::class, LapInfoData::class -], version = 1, exportSchema = false) +], version = 2, exportSchema = false) abstract class ESPDatabase : RoomDatabase() { abstract fun sessionDataDao(): SessionDataDao abstract fun rawGPSDataDao(): RawGPSDataDao @@ -114,6 +114,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 5b4b3b7..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,12 +29,17 @@ 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 @@ -60,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 @@ -90,10 +95,15 @@ class DragTimeCalculation( if (time0to200Result == null && currentSpeed >= 200f) time0to200Result = elapsed // Quarter Mile (Standing only, measured from the run's start, not the whole recording) - if (quarterMileTimeResult == null && (totalDistanceMeters - runStartDistanceMeters) >= 402.336f) { + 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) --- @@ -148,7 +158,7 @@ class DragTimeCalculation( runStartTime = 0L hasStartedRun = false isReadyForRun = false - gpsPoints.clear() + lastPoint = null totalDistanceMeters = 0f runStartDistanceMeters = 0f maxSpeedRecorded = 0f 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/timeAttackManagers/TrackGeometry.kt b/app/src/main/java/com/example/trackpro/managerClasses/timeAttackManagers/TrackGeometry.kt index 5fc9666..7b9cee6 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) @@ -40,7 +42,7 @@ object TrackGeometry { } * (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( @@ -69,7 +71,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 +89,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, 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..b235283 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 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..1a2e71c 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,6 +149,13 @@ 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( @@ -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/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..15d05c2 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 @@ -179,15 +178,6 @@ fun TimeAttackScreenView( ) } - DisposableEffect(Unit) { - Log.d("TimeAttackScreen", "DisposableEffect - Connecting to ESP") - app.espTcpClient.connect() - - onDispose { - Log.d("TimeAttackScreen", "DisposableEffect - Disconnecting from ESP") - app.espTcpClient.disconnect() - } - } } // ── Portrait ─────────────────────────────────────────────── 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..c9bf613 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 @@ -37,7 +37,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -46,6 +45,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 @@ -81,7 +81,7 @@ fun CarCreationScreen( Box( modifier = Modifier .fillMaxSize() - .background(Color(0xFF121212)) + .background(TrackProTheme.colors.bgDeep) .padding(16.dp) ) { Column( @@ -92,7 +92,7 @@ fun CarCreationScreen( ) { Text( text = "🏎️ Vehicle Setup", - color = Color.White, + color = TrackProTheme.colors.textPrimary, fontSize = 24.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 16.dp) @@ -100,7 +100,7 @@ fun CarCreationScreen( AnimatedVisibility(visible = showSection.value) { Card( - colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)), + colors = CardDefaults.cardColors(containerColor = TrackProTheme.colors.bgCard), shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(10.dp), modifier = Modifier.fillMaxWidth() @@ -121,12 +121,12 @@ 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, TrackProTheme.colors.textPrimary) { selectedEngineType = it } + DropdownMenuField("Drivetrain", jsonOptions.drivetrains, selectedDrivetrain, TrackProTheme.colors.textPrimary) { selectedDrivetrain = it } + DropdownMenuField("Fuel Type", jsonOptions.fuelTypes, selectedFuelType, TrackProTheme.colors.textPrimary) { selectedFuelType = it } + DropdownMenuField("Tire Type", jsonOptions.tireTypes, selectedTireType, TrackProTheme.colors.textPrimary) { selectedTireType = it } + DropdownMenuField("Transmission", jsonOptions.transmissions, selectedTransmission, TrackProTheme.colors.textPrimary) { selectedTransmission = it } + DropdownMenuField("Suspension", jsonOptions.suspensionTypes, selectedSuspensionType, TrackProTheme.colors.textPrimary) { selectedSuspensionType = it } Spacer(modifier = Modifier.height(20.dp)) @@ -162,7 +162,7 @@ fun CarCreationScreen( Toast.makeText(context, "🚀 Vehicle saved successfully!", Toast.LENGTH_SHORT).show() }, modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF00E676)), + colors = ButtonDefaults.buttonColors(containerColor = TrackProTheme.colors.deltaGood), shape = RoundedCornerShape(14.dp) ) { Text("Save Vehicle", fontSize = 16.sp, fontWeight = FontWeight.Bold) @@ -179,7 +179,7 @@ fun CarCreationScreen( fun SectionTitle(title: String) { Text( text = title, - color = Color(0xFFB0BEC5), + color = TrackProTheme.colors.textMuted, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), modifier = Modifier.padding(vertical = 8.dp) ) 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/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 From 1a68c626a2c4387930a73b32285e93fc8c2b70b4 Mon Sep 17 00:00:00 2001 From: "meszaros.martin986@gmail.com" Date: Thu, 30 Jul 2026 11:06:14 +0200 Subject: [PATCH 3/4] Refactored theme handling and UI components to support Material3 integration and dynamic theme switching. **Key Changes:** * **Theme & Styling:** * **`TrackProTheme.kt`:** Added `toMaterialColorScheme` to map custom racing-HUD colors to Material3 `ColorScheme`. Updated `TrackProTheme` to wrap content in a `MaterialTheme` provider. * **`TrackProColors.kt`:** Refined color palettes for both Light and Dark modes, updating hex values for backgrounds, accents, and text for better contrast and visibility. * **`MainActivity.kt`:** Implemented persistent theme selection using `SharedPreferences` and integrated it into the `TrackProTheme` call. * **UI Components:** * **`CustomTextField.kt` & `DropDownMenuField.kt`:** Updated colors to use theme-aware attributes (`textPrimary`, `accentCyan`, `sectorLine`). Added "No tracks found" state to the track dropdown. * **`CarCreatorScreen.kt`:** Redesigned the layout with a new top bar, updated card borders, and consistent typography. Switched hardcoded "Save Vehicle" button styles to theme-driven `deltaGood` colors. * **`SettingsScreen.kt`:** Added an "APPEARANCE" section to toggle between Light and Dark themes. * **List Views:** * **`DragTimesList.kt` & `TimeAttackListView.kt`:** Added empty state placeholders ("NO SESSIONS RECORDED") with specialized typography and spacing when no data is available. * **`CarListView.kt` & `TrackListView.kt`:** Updated `AlertDialog` colors to use `bgCard` and themed text colors instead of hardcoded hex values. * **Refactoring:** * Removed `DragTimesList` activity wrapper in favor of the composable view. * Standardized section headers across screens to use consistent uppercase styling and letter spacing. --- .../java/com/example/trackpro/MainActivity.kt | 13 ++- .../trackpro/extrasForUI/CustomTextField.kt | 13 ++- .../trackpro/extrasForUI/DropDownMenuField.kt | 78 +++++++++++----- .../trackpro/extrasForUI/TrackProTheme.kt | 59 +++++++++++- .../trackpro/screens/SettingsScreen.kt | 45 ++++++++- .../screens/listViewScreens/CarListView.kt | 6 +- .../screens/listViewScreens/DragTimesList.kt | 63 ++++++------- .../listViewScreens/TimeAttackListView.kt | 18 +++- .../screens/listViewScreens/TrackListView.kt | 6 +- .../vehicleScreens/CarCreatorScreen.kt | 91 +++++++++++-------- .../example/trackpro/theme/TrackProColors.kt | 36 ++++---- 11 files changed, 296 insertions(+), 132 deletions(-) diff --git a/app/src/main/java/com/example/trackpro/MainActivity.kt b/app/src/main/java/com/example/trackpro/MainActivity.kt index 66eedb7..9ba9567 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,14 @@ 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 + } + val gpsManager: GpsManager by lazy { GpsManager( espProvider = espTcpClient, @@ -168,7 +178,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/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/screens/SettingsScreen.kt b/app/src/main/java/com/example/trackpro/screens/SettingsScreen.kt index 0361b05..40a1490 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,7 @@ 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() Column( modifier = Modifier @@ -78,7 +79,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 +94,44 @@ fun SettingsScreen(onBack: () -> Unit) { } } - // --- Section: Measurement --- - SettingsSectionHeader(title = "UNITS") + // --- Section: Appearance --- + SettingsSectionHeader(title = "APPEARANCE") SettingsCard { - //SettingsToggleRow(label = "Use Metric Units (km/h)", isActive = true) {} + 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: System --- @@ -129,7 +164,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/listViewScreens/CarListView.kt b/app/src/main/java/com/example/trackpro/screens/listViewScreens/CarListView.kt index b235283..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 @@ -160,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 1a2e71c..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 @@ -160,9 +160,9 @@ fun TrackCard( 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) 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 c9bf613..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 @@ -37,6 +34,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -76,34 +74,41 @@ fun CarCreationScreen( val scrollState = rememberScrollState() - val showSection = remember { mutableStateOf(true) } - Box( modifier = Modifier .fillMaxSize() .background(TrackProTheme.colors.bgDeep) - .padding(16.dp) ) { - Column( - modifier = Modifier - .verticalScroll(scrollState) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "🏎️ Vehicle Setup", - color = TrackProTheme.colors.textPrimary, - fontSize = 24.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 16.dp) - ) - - AnimatedVisibility(visible = showSection.value) { - Card( - colors = CardDefaults.cardColors(containerColor = TrackProTheme.colors.bgCard), - 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, TrackProTheme.colors.textPrimary) { selectedEngineType = it } - DropdownMenuField("Drivetrain", jsonOptions.drivetrains, selectedDrivetrain, TrackProTheme.colors.textPrimary) { selectedDrivetrain = it } - DropdownMenuField("Fuel Type", jsonOptions.fuelTypes, selectedFuelType, TrackProTheme.colors.textPrimary) { selectedFuelType = it } - DropdownMenuField("Tire Type", jsonOptions.tireTypes, selectedTireType, TrackProTheme.colors.textPrimary) { selectedTireType = it } - DropdownMenuField("Transmission", jsonOptions.transmissions, selectedTransmission, TrackProTheme.colors.textPrimary) { selectedTransmission = it } - DropdownMenuField("Suspension", jsonOptions.suspensionTypes, selectedSuspensionType, TrackProTheme.colors.textPrimary) { 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 = TrackProTheme.colors.deltaGood), - shape = RoundedCornerShape(14.dp) + 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, + text = title.uppercase(), color = TrackProTheme.colors.textMuted, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + 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 From 7b22189f573bd7785a2338cac83b642be58ddff9 Mon Sep 17 00:00:00 2001 From: "meszaros.martin986@gmail.com" Date: Thu, 30 Jul 2026 12:22:14 +0200 Subject: [PATCH 4/4] Summary: Added sector split timing and unit selection (Metric/Imperial) This commit introduces comprehensive sector split functionality for circuit timing, adds a global setting for unit preferences, and enhances the drag racing results screen with a map-based GPS trace and heatmap. **Key Changes:** * **Sector Split Timing:** * **`CircuitTimingManager` & `TimeAttackViewModel`:** Now support recording and tracking sector split times. Implemented logic to detect sector gate crossings and calculate deltas against the session's best. * **`TrackBuilderScreen.kt`:** Added a "Mark Sector" button to allow users to define sector points during track creation. * **`TrackGeometry.kt`:** Added `calculateSectorLines` to generate perpendicular gate lines for marked sector points. * **`SectorTimeData`:** Added new DAO and data class to persist sector times in the database (Database version bumped to 3). * **`TimeAttackScreen.kt`:** Added a `SectorSplitsRow` to display real-time sector times and deltas during a session. * **Unit Preferences:** * **`SettingsScreen.kt` & `MainActivity.kt`:** Added a new setting to toggle between Metric (km/h, m) and Imperial (mph, mi) units. * **`UnitFormatter.kt`:** (Internal utility) Integrated across `DragScreenListItem` and `LapDetailScreen` to dynamically format speeds and distances based on user preference. * **Drag Racing Enhancements (`DragScreenListItem.kt`):** * Introduced a toggle between "Chart" and "Map" views. * **`DragSessionMapView`:** Implemented a MapLibre-based view that displays the drag run path with a speed-based heatmap. * Updated the speed trace chart to reflect the selected unit (km/h or mph). * **UI & Performance Improvements:** * **`LapDetailScreen.kt`:** Enhanced the stats panel to include sector time breakdowns and unit-aware speed formatting. * **`SpeedColorUtils.kt`:** Refactored speed-to-color mapping logic into a shared utility. * Updated `TrackCoordinatesData` to include `isSectorPoint` and `sectorIndex` fields. --- .../java/com/example/trackpro/MainActivity.kt | 8 + .../dataClasses/TrackCoordinatesData.kt | 4 +- .../trackpro/managerClasses/ESPDatabase.kt | 8 +- .../timeAttackManagers/CircuitTiming.kt | 70 ++++- .../timeAttackManagers/TrackGeometry.kt | 61 +++-- .../trackpro/screens/SettingsScreen.kt | 41 +++ .../trackpro/screens/TrackBuilderScreen.kt | 51 +++- .../listItems/DragScreenListItem.kt | 244 ++++++++++++++---- .../listItems/LapDetailScreen.kt | 67 ++--- .../telemetricScreens/TimeAttackScreen.kt | 67 ++++- .../viewModels/TimeAttackViewModel.kt | 38 ++- 11 files changed, 547 insertions(+), 112 deletions(-) diff --git a/app/src/main/java/com/example/trackpro/MainActivity.kt b/app/src/main/java/com/example/trackpro/MainActivity.kt index 9ba9567..caba154 100644 --- a/app/src/main/java/com/example/trackpro/MainActivity.kt +++ b/app/src/main/java/com/example/trackpro/MainActivity.kt @@ -122,6 +122,14 @@ class TrackProApp : Application() { 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, 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 544fece..5a8ce7f 100644 --- a/app/src/main/java/com/example/trackpro/dataClasses/TrackCoordinatesData.kt +++ b/app/src/main/java/com/example/trackpro/dataClasses/TrackCoordinatesData.kt @@ -23,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/managerClasses/ESPDatabase.kt b/app/src/main/java/com/example/trackpro/managerClasses/ESPDatabase.kt index a9ba697..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 = 2, 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 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 7b9cee6..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 @@ -28,17 +28,44 @@ 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() @@ -46,17 +73,19 @@ object TrackGeometry { 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 ) ) } 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 40a1490..0ab74dd 100644 --- a/app/src/main/java/com/example/trackpro/screens/SettingsScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/SettingsScreen.kt @@ -37,6 +37,7 @@ fun SettingsScreen(onBack: () -> Unit) { 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 @@ -134,6 +135,46 @@ fun SettingsScreen(onBack: () -> Unit) { } } + // --- Section: Units --- + SettingsSectionHeader(title = "UNITS") + SettingsCard { + 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 --- SettingsSectionHeader(title = "APPLICATION") SettingsCard { 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/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/TimeAttackScreen.kt b/app/src/main/java/com/example/trackpro/screens/telemetricScreens/TimeAttackScreen.kt index 15d05c2..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 @@ -42,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 @@ -94,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 { @@ -161,7 +163,8 @@ fun TimeAttackScreenView( gpsPoints = gpsPoints, driver = driverPos, isConnected = isConnected, - linesToShow = linesToShow + linesToShow = linesToShow, + lapSplits = lapSplits ) else -> TimeAttackPortraitLayout( timingMode = timingMode, @@ -174,7 +177,8 @@ fun TimeAttackScreenView( gpsPoints = gpsPoints, driver = driverPos, isConnected = isConnected, - linesToShow = linesToShow + linesToShow = linesToShow, + lapSplits = lapSplits ) } @@ -193,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" @@ -308,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 ─────────────────────────────────────── @@ -359,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" @@ -440,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)) @@ -547,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/viewModels/TimeAttackViewModel.kt b/app/src/main/java/com/example/trackpro/viewModels/TimeAttackViewModel.kt index 23ed242..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) {