diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e2d6409..5788ed7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -38,5 +38,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/trackpro/MainActivity.kt b/app/src/main/java/com/example/trackpro/MainActivity.kt index f870530..b70d201 100644 --- a/app/src/main/java/com/example/trackpro/MainActivity.kt +++ b/app/src/main/java/com/example/trackpro/MainActivity.kt @@ -3,6 +3,7 @@ package com.example.trackpro import com.example.trackpro.extrasForUI.TrackProTheme import android.Manifest import android.app.Application +import android.os.Build import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity @@ -62,10 +63,12 @@ import androidx.navigation.navArgument import com.example.trackpro.managerClasses.ESPDatabase import com.example.trackpro.managerClasses.TrackSeeder import com.example.trackpro.managerClasses.gpsDataManagers.ESPTcpClient +import com.example.trackpro.managerClasses.gpsDataManagers.BluetoothClassicClient import com.example.trackpro.managerClasses.JsonReader import com.example.trackpro.managerClasses.SessionManager import com.example.trackpro.managerClasses.gpsDataManagers.GpsManager import com.example.trackpro.managerClasses.gpsDataManagers.PhoneGpsProvider +import com.example.trackpro.models.GpsProviderType import com.example.trackpro.screens.vehicleScreens.CarCreationScreen import com.example.trackpro.screens.telemetricScreens.DragRaceScreen import com.example.trackpro.screens.ESPConnectionTestScreen @@ -114,11 +117,76 @@ class TrackProApp : Application() { ESPTcpClient(serverAddress = config.first, port = config.second) } + // Lets Settings redirect the WiFi connection to a test simulator (e.g. + // esp32_simulator.py on a dev machine) instead of the real ESP32's fixed + // AP address, without editing config.json and rebuilding. Port always + // comes from config.json (the simulator listens on the same 4210 the + // firmware does) - only the host is swappable. + private val espTargetPrefs by lazy { getSharedPreferences("esp_target_prefs", MODE_PRIVATE) } + val useTestServer by lazy { MutableStateFlow(espTargetPrefs.getBoolean("use_test_server", false)) } + val testServerAddress by lazy { MutableStateFlow(espTargetPrefs.getString("test_server_address", "") ?: "") } + + fun setUseTestServer(enabled: Boolean) { + espTargetPrefs.edit().putBoolean("use_test_server", enabled).apply() + useTestServer.value = enabled + applyEspTarget() + } + + fun setTestServerAddress(address: String) { + espTargetPrefs.edit().putString("test_server_address", address).apply() + testServerAddress.value = address + if (useTestServer.value) applyEspTarget() + } + + private fun applyEspTarget() { + val (realIp, port) = JsonReader.loadConfig(this) + val target = if (useTestServer.value && testServerAddress.value.isNotBlank()) { + testServerAddress.value + } else { + realIp + } + espTcpClient.updateTarget(target, port) + } + + val bluetoothClassicClient: BluetoothClassicClient by lazy { + BluetoothClassicClient(this) + } + val phoneGpsProvider: PhoneGpsProvider by lazy { PhoneGpsProvider(this) } - val useExternalGps = MutableStateFlow(true) + // Persisted like useDarkTheme/useMetricUnits below (unlike the old useExternalGps, + // which reset to WiFi every launch) — avoids surprising the user mid-track-day. + private val gpsSourcePrefs by lazy { getSharedPreferences("gps_source_prefs", MODE_PRIVATE) } + val gpsSource by lazy { + val stored = gpsSourcePrefs.getString("source", GpsProviderType.WIFI.name) + val initial = runCatching { GpsProviderType.valueOf(stored ?: GpsProviderType.WIFI.name) } + .getOrDefault(GpsProviderType.WIFI) + MutableStateFlow(initial) + } + + fun setGpsSource(source: GpsProviderType) { + gpsSourcePrefs.edit().putString("source", source.name).apply() + gpsSource.value = source + } + + private val ratePrefs by lazy { getSharedPreferences("gps_rate_prefs", MODE_PRIVATE) } + val selectedRateHz by lazy { MutableStateFlow(ratePrefs.getInt("rate_hz", 10)) } + + fun setRateHz(hz: Int) { + ratePrefs.edit().putInt("rate_hz", hz).apply() + selectedRateHz.value = hz + gpsManager.sendCommandToActive("RATE:$hz\n") + } + + private val btDevicePrefs by lazy { getSharedPreferences("bluetooth_prefs", MODE_PRIVATE) } + val selectedBtDeviceMac by lazy { MutableStateFlow(btDevicePrefs.getString("device_mac", null)) } + + fun setSelectedBtDevice(mac: String) { + btDevicePrefs.edit().putString("device_mac", mac).apply() + selectedBtDeviceMac.value = mac + } private val themePrefs by lazy { getSharedPreferences("theme_prefs", MODE_PRIVATE) } val useDarkTheme by lazy { MutableStateFlow(themePrefs.getBoolean("dark_theme", true)) } @@ -138,15 +206,21 @@ class TrackProApp : Application() { val gpsManager: GpsManager by lazy { GpsManager( - espProvider = espTcpClient, + wifiProvider = espTcpClient, + bluetoothProvider = bluetoothClassicClient, phoneProvider = phoneGpsProvider, - useExternalGps = useExternalGps + gpsSource = gpsSource, + selectedRateHz = selectedRateHz ) } override fun onCreate() { super.onCreate() MapLibre.getInstance(this) + // Apply a persisted test-server redirect (if any) before the first + // connection attempt, so a restart doesn't briefly dial the real ESP32 + // before switching over. + applyEspTarget() // Start the active provider immediately at app launch gpsManager.startActiveProvider() @@ -176,6 +250,23 @@ class MainActivity : ComponentActivity() { } } + // Requested contextually (only when the user opens the Bluetooth device + // picker in Settings), unlike the eager location request above — Bluetooth + // is opt-in/rare, location is core to the app on every launch. + private val bluetoothPermissionRequest = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + if (permissions[Manifest.permission.BLUETOOTH_CONNECT] != true) { + Log.w("Permissions", "Bluetooth permission denied — Bluetooth GPS source unavailable") + } + } + + private fun requestBluetoothPermissionIfNeeded() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + bluetoothPermissionRequest.launch(arrayOf(Manifest.permission.BLUETOOTH_CONNECT)) + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -220,7 +311,7 @@ class MainActivity : ComponentActivity() { DragRaceScreen(database, sessionManager, vehicleFULLViewModel) } composable("esptest") { - ESPConnectionTestScreen() + ESPConnectionTestScreen(onNavigateToSettings = { navController.navigate("settings") }) } composable( "track/{trackId}", @@ -286,7 +377,10 @@ class MainActivity : ComponentActivity() { ) } composable(route = "settings") { - SettingsScreen(onBack = { navController.popBackStack() }) + SettingsScreen( + onBack = { navController.popBackStack() }, + onRequestBluetoothPermission = { requestBluetoothPermissionIfNeeded() } + ) } // In your NavHost setup composable("lap_detail/{sessionId}/{lapId}") { backStackEntry -> @@ -337,7 +431,7 @@ fun MainScreen( Box( modifier = Modifier .size(6.dp) - .background(TrackProTheme.colors.accentCyan, CircleShape) + .background(TrackProTheme.colors.accent, CircleShape) ) Spacer(Modifier.width(10.dp)) Column { @@ -361,13 +455,13 @@ fun MainScreen( DrawerItem( icon = Icons.Default.RocketLaunch, label = "Drag Sessions", - tint = TrackProTheme.colors.accentCyan, + tint = TrackProTheme.colors.accentMuted, onClick = { onNavigateToDragTimesList(); scope.launch { drawerState.close() } } ) DrawerItem( icon = Icons.Default.FlagCircle, label = "Track Sessions", - tint = TrackProTheme.colors.accentBlue, + tint = TrackProTheme.colors.accentMuted, onClick = { onNavigateToTimeAttackListView(); scope.launch { drawerState.close() } } ) } @@ -382,13 +476,13 @@ fun MainScreen( DrawerItem( icon = Icons.Default.Timelapse, label = "My Tracks", - tint = TrackProTheme.colors.accentAmber, + tint = TrackProTheme.colors.accentMuted, onClick = { onNavigateToTrackListScreen(); scope.launch { drawerState.close() } } ) DrawerItem( icon = Icons.Default.CarRepair, label = "My Vehicles", - tint = TrackProTheme.colors.accentAmber, + tint = TrackProTheme.colors.accentMuted, onClick = { onNavigateToVehicleList(); scope.launch { drawerState.close() } } ) } @@ -463,7 +557,7 @@ fun MainScreen( modifier = Modifier .width(28.dp) .height(2.dp) - .background(TrackProTheme.colors.accentCyan) + .background(TrackProTheme.colors.accent) ) Spacer(Modifier.height(10.dp)) Text( @@ -496,7 +590,7 @@ fun MainScreen( icon = Icons.Default.RocketLaunch, title = "DRAG TIMING", subtitle = "0–100 · ¼ mile · speed trace", - accentColor = TrackProTheme.colors.accentCyan, + accentColor = TrackProTheme.colors.accentMuted, onClick = onNavigateToDragRace, fullWidth = true ) @@ -505,7 +599,7 @@ fun MainScreen( icon = Icons.Default.FlagCircle, title = "LAP TIMING", subtitle = "Circuit & sprint · live delta · best lap", - accentColor = TrackProTheme.colors.accentBlue, + accentColor = TrackProTheme.colors.accentMuted, onClick = onNavigateToTrackVehicleSelector, fullWidth = true ) @@ -526,7 +620,7 @@ fun MainScreen( icon = Icons.Default.CarRepair, title = "ADD VEHICLES", subtitle = "Create your own vehicles", - accentColor = TrackProTheme.colors.accentAmber, + accentColor = TrackProTheme.colors.accentMuted, onClick = onNavigateToVehicleCreatorScreen, halfWidth = true ) @@ -536,7 +630,7 @@ fun MainScreen( icon = Icons.Default.Timelapse, title = "TRACK\nBUILDER", subtitle = "Define tracks", - accentColor = TrackProTheme.colors.accentAmber, + accentColor = TrackProTheme.colors.accentMuted, onClick = onNavigateToTrackBuilder, halfWidth = true ) 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 1a11584..cc9a8ce 100644 --- a/app/src/main/java/com/example/trackpro/dataClasses/RawGPSData.kt +++ b/app/src/main/java/com/example/trackpro/dataClasses/RawGPSData.kt @@ -25,6 +25,7 @@ data class RawGPSData( val altitude: Double?, val timestamp: Long, val speed: Float?, - val fixQuality: Int? + val fixQuality: Int?, + val valid: Boolean? = null ) diff --git a/app/src/main/java/com/example/trackpro/extrasForUI/CustomTextField.kt b/app/src/main/java/com/example/trackpro/extrasForUI/CustomTextField.kt index 79871e9..46adf9c 100644 --- a/app/src/main/java/com/example/trackpro/extrasForUI/CustomTextField.kt +++ b/app/src/main/java/com/example/trackpro/extrasForUI/CustomTextField.kt @@ -16,7 +16,7 @@ fun CustomTextField( value: String, isNumber: Boolean = false, leadingIcon: ImageVector? = null, - accent: Color = TrackProTheme.colors.accentCyan, + accent: Color = TrackProTheme.colors.accent, onValueChange: (String) -> Unit ) { OutlinedTextField( 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 07a7788..82b2167 100644 --- a/app/src/main/java/com/example/trackpro/extrasForUI/DropDownMenuField.kt +++ b/app/src/main/java/com/example/trackpro/extrasForUI/DropDownMenuField.kt @@ -36,7 +36,7 @@ fun AppDropdownField( itemLabel: (T) -> String, onSelect: (T) -> Unit, modifier: Modifier = Modifier, - accent: Color = TrackProTheme.colors.accentCyan, + accent: Color = TrackProTheme.colors.accent, emptyMessage: String = "No options available" ) { var expanded by remember { mutableStateOf(false) } @@ -103,7 +103,7 @@ fun DropdownMenuFieldMulti(label: String, options: List, selectedOp colors = OutlinedTextFieldDefaults.colors( focusedTextColor = TrackProTheme.colors.textPrimary, unfocusedTextColor = TrackProTheme.colors.textPrimary, - focusedBorderColor = TrackProTheme.colors.accentCyan, + focusedBorderColor = TrackProTheme.colors.accent, unfocusedBorderColor = TrackProTheme.colors.sectorLine ), modifier = Modifier @@ -151,7 +151,7 @@ fun TrackDropdownMenu( colors = OutlinedTextFieldDefaults.colors( focusedTextColor = TrackProTheme.colors.textPrimary, unfocusedTextColor = TrackProTheme.colors.textPrimary, - focusedBorderColor = TrackProTheme.colors.accentCyan, + focusedBorderColor = TrackProTheme.colors.accent, unfocusedBorderColor = TrackProTheme.colors.sectorLine ), modifier = Modifier.fillMaxWidth() 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 835197b..9d94e47 100644 --- a/app/src/main/java/com/example/trackpro/extrasForUI/TrackProTheme.kt +++ b/app/src/main/java/com/example/trackpro/extrasForUI/TrackProTheme.kt @@ -32,12 +32,15 @@ object TrackProTheme { 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, + // primary/secondary/tertiary intentionally all map to the same accent - the + // palette has exactly one. Material needs three slots filled; that is not a + // reason to invent two more brand colors. + primary = accent, + onPrimary = onAccent, + secondary = accent, + onSecondary = onAccent, + tertiary = accent, + onTertiary = onAccent, background = bgDeep, onBackground = textPrimary, surface = bgCard, @@ -46,16 +49,16 @@ private fun TrackProColorScheme.toMaterialColorScheme(dark: Boolean): androidx.c onSurfaceVariant = textMuted, outline = sectorLine, error = deltaBad, - onError = Color.White + onError = Color.Black ) } else { lightColorScheme( - primary = accentCyan, - onPrimary = Color.Black, - secondary = accentBlue, - onSecondary = Color.Black, - tertiary = accentAmber, - onTertiary = Color.Black, + primary = accent, + onPrimary = onAccent, + secondary = accent, + onSecondary = onAccent, + tertiary = accent, + onTertiary = onAccent, background = bgDeep, onBackground = textPrimary, surface = bgCard, 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 900e7a2..269c30c 100644 --- a/app/src/main/java/com/example/trackpro/managerClasses/ESPDatabase.kt +++ b/app/src/main/java/com/example/trackpro/managerClasses/ESPDatabase.kt @@ -37,7 +37,7 @@ import com.example.trackpro.dataClasses.VehicleInformationData LapTimeData::class, LapInfoData::class, SectorTimeData::class -], version = 3, exportSchema = false) +], version = 4, exportSchema = false) abstract class ESPDatabase : RoomDatabase() { abstract fun sessionDataDao(): SessionDataDao abstract fun rawGPSDataDao(): RawGPSDataDao diff --git a/app/src/main/java/com/example/trackpro/managerClasses/gpsDataManagers/ESPTcpClient.kt b/app/src/main/java/com/example/trackpro/managerClasses/gpsDataManagers/ESPTcpClient.kt index 90b354a..03349a1 100644 --- a/app/src/main/java/com/example/trackpro/managerClasses/gpsDataManagers/ESPTcpClient.kt +++ b/app/src/main/java/com/example/trackpro/managerClasses/gpsDataManagers/ESPTcpClient.kt @@ -2,7 +2,7 @@ package com.example.trackpro.managerClasses.gpsDataManagers import android.util.Log import com.example.trackpro.dataClasses.RawGPSData -import com.example.trackpro.models.GpsProvider +import com.example.trackpro.models.CommandableGpsProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -10,24 +10,31 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import java.io.ByteArrayOutputStream -import java.io.InputStream +import java.io.OutputStream import java.net.InetSocketAddress import java.net.Socket import java.util.concurrent.atomic.AtomicBoolean class ESPTcpClient( - private val serverAddress: String, - private val port: Int -) : GpsProvider { + serverAddress: String, + port: Int +) : CommandableGpsProvider { + // Mutable (not the constructor params directly) so updateTarget() can + // redirect a live singleton - e.g. switching between the real ESP32 and + // a test simulator from Settings - without restarting the app. + @Volatile private var serverAddress: String = serverAddress + @Volatile private var port: Int = port + // --- Observables (Singletons use these instead of callbacks) --- private val _connectionStatus = MutableStateFlow(false) override val connectionStatus: StateFlow = _connectionStatus.asStateFlow() private val _gpsFlow = MutableStateFlow(null) override val gpsFlow: StateFlow = _gpsFlow.asStateFlow() + private val _confirmedRateHz = MutableStateFlow(null) + override val confirmedRateHz: StateFlow = _confirmedRateHz.asStateFlow() override fun start() = connect() @@ -38,17 +45,26 @@ class ESPTcpClient( private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var socket: Socket? = null private var running = AtomicBoolean(false) - - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - coerceInputValues = true - } + @Volatile private var outputStream: OutputStream? = null + private val writeMutex = Mutex() private val bufferPool = BufferPool(512, 10) // --- Core Methods --- + // Redirects future connections to a different host/port. If currently + // connected, reconnects immediately to the new target instead of waiting + // for the next manual start(). + fun updateTarget(address: String, newPort: Int) { + val changed = serverAddress != address || port != newPort + serverAddress = address + port = newPort + if (changed && running.get()) { + disconnect() + connect() + } + } + fun connect() { if (running.getAndSet(true)) return @@ -59,6 +75,7 @@ class ESPTcpClient( socket?.connect(InetSocketAddress(serverAddress, port), 5000) val inputStream = socket?.getInputStream() ?: throw Exception("Failed to get input stream") + outputStream = socket?.getOutputStream() _connectionStatus.value = true val delimiter = "\n".toByteArray() @@ -86,37 +103,54 @@ class ESPTcpClient( } } - private suspend fun processChunk(buffer: ByteArray, length: Int) { - val message = buffer.decodeToString(0, length).trim() - if (message.isNotEmpty()) { - withContext(Dispatchers.Default) { + // Sends a command (e.g. "RATE:20\n") to the ESP32. Dispatches onto this + // client's own IO scope so callers (Compose onClick handlers) never block, + // and a Mutex serializes concurrent writers against each other - reading + // and writing the same socket concurrently is safe by contract and needs + // no lock between them. + override fun sendCommand(command: String) { + val out = outputStream ?: return + scope.launch { + writeMutex.withLock { try { - val raw = json.decodeFromString(message) - val parsed = RawGPSData( - sessionid = 0L, - latitude = raw.latitude, - longitude = raw.longitude, - altitude = raw.altitude, - speed = raw.speed, - fixQuality = raw.satellites, - // Stamped on receipt rather than the ESP32-reported timestamp string: - // elapsed-time math (0-60, quarter mile, etc.) needs consistent - // relative precision between samples, and the module's own timestamp - // has no guaranteed sub-second resolution. - timestamp = System.currentTimeMillis() - ) - _gpsFlow.value = parsed // Use .value instead of .emit() + out.write(command.toByteArray(Charsets.US_ASCII)) + out.flush() } catch (e: Exception) { - Log.e("ESPTcpClient", "JSON Parse Error: ${e.message} for input: $message") + Log.e("ESPTcpClient", "sendCommand failed: ${e.message}") } } } } + private suspend fun processChunk(buffer: ByteArray, length: Int) { + val message = buffer.decodeToString(0, length).trim() + if (message.isEmpty()) return + + val ackedHz = parseRateAck(message) + if (ackedHz != null) { + _confirmedRateHz.value = ackedHz + return + } + if (message == "RATE_ERR") { + Log.w("ESPTcpClient", "GPS module rejected rate change") + return + } + + withContext(Dispatchers.Default) { + try { + val raw = gpsJsonParser.decodeFromString(message) + _gpsFlow.value = raw.toEntity() // Use .value instead of .emit() + } catch (e: Exception) { + Log.e("ESPTcpClient", "JSON Parse Error: ${e.message} for input: $message") + } + } + } + fun disconnect() { running.set(false) runCatching { socket?.close() } + outputStream = null _connectionStatus.value = false } @@ -127,77 +161,8 @@ class ESPTcpClient( Log.e("ESPTcpClient", "Error closing socket: ${e.message}") } socket = null + outputStream = null running.set(false) _connectionStatus.value = false } - - // --- Helper Classes --- - - @Serializable - private data class RawGPSDataRaw( - val latitude: Double, - val longitude: Double, - val altitude: Double = 0.0, - val speed: Float = 0f, - val satellites: Int = 0, - val timestamp: String - ) - - class DelimitedInputStreamReader( - private val input: InputStream, - private val delimiter: ByteArray - ) { - private val buffer = ByteArrayOutputStream() - - fun read(target: ByteArray): Int { - try { - while (true) { - val byte = input.read() - if (byte == -1) return -1 - - buffer.write(byte) - - if (endsWithDelimiter()) { - val fullData = buffer.toByteArray() - val length = fullData.size - delimiter.size - - // Ensure we don't overflow the target buffer - val finalSize = if (length > target.size) target.size else length - System.arraycopy(fullData, 0, target, 0, finalSize) - - buffer.reset() - return finalSize - } - - // Emergency flush if buffer gets too large (corrupt stream protection) - if (buffer.size() > 2048) buffer.reset() - } - } catch (_: Exception) { - return -1 - } - } - - private fun endsWithDelimiter(): Boolean { - val data = buffer.toByteArray() - if (data.size < delimiter.size) return false - for (i in delimiter.indices) { - if (data[data.size - delimiter.size + i] != delimiter[i]) return false - } - return true - } - } - - class BufferPool(private val bufferSize: Int, poolSize: Int) { - private val pool = ArrayDeque(poolSize).apply { - repeat(poolSize) { add(ByteArray(bufferSize)) } - } - - @Synchronized - fun obtain(): ByteArray = pool.removeFirstOrNull() ?: ByteArray(bufferSize) - - @Synchronized - fun recycle(buffer: ByteArray) { - if (pool.size < 10) pool.addLast(buffer) - } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/trackpro/managerClasses/gpsDataManagers/GpsManager.kt b/app/src/main/java/com/example/trackpro/managerClasses/gpsDataManagers/GpsManager.kt index f845c09..d1a2d3d 100644 --- a/app/src/main/java/com/example/trackpro/managerClasses/gpsDataManagers/GpsManager.kt +++ b/app/src/main/java/com/example/trackpro/managerClasses/gpsDataManagers/GpsManager.kt @@ -1,56 +1,89 @@ package com.example.trackpro.managerClasses.gpsDataManagers +import com.example.trackpro.models.CommandableGpsProvider +import com.example.trackpro.models.GpsProvider +import com.example.trackpro.models.GpsProviderType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch class GpsManager( - private val espProvider: ESPTcpClient, + private val wifiProvider: ESPTcpClient, + private val bluetoothProvider: BluetoothClassicClient, private val phoneProvider: PhoneGpsProvider, - private val useExternalGps: StateFlow + private val gpsSource: StateFlow, + private val selectedRateHz: StateFlow ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - @OptIn(ExperimentalCoroutinesApi::class) - val activeGpsFlow = useExternalGps.flatMapLatest { isExternal -> - if (isExternal) espProvider.gpsFlow else phoneProvider.gpsFlow + private fun providerFor(source: GpsProviderType): GpsProvider = when (source) { + GpsProviderType.WIFI -> wifiProvider + GpsProviderType.BLUETOOTH -> bluetoothProvider + GpsProviderType.PHONE_GPS -> phoneProvider } + private val allProviders: List = listOf(wifiProvider, bluetoothProvider, phoneProvider) + + @OptIn(ExperimentalCoroutinesApi::class) + val activeGpsFlow = gpsSource.flatMapLatest { providerFor(it).gpsFlow } + + @OptIn(ExperimentalCoroutinesApi::class) + val connectionStatus = gpsSource.flatMapLatest { providerFor(it).connectionStatus } + + // ESP32-confirmed rate for the active provider; null for phone GPS (not + // commandable) or before any RATE_OK reply has been seen yet. @OptIn(ExperimentalCoroutinesApi::class) - val connectionStatus = useExternalGps.flatMapLatest { isExternal -> - if (isExternal) espProvider.connectionStatus else phoneProvider.connectionStatus + val confirmedRateHz = gpsSource.flatMapLatest { source -> + (providerFor(source) as? CommandableGpsProvider)?.confirmedRateHz + ?: MutableStateFlow(null) + } + + fun sendCommandToActive(cmd: String) { + (providerFor(gpsSource.value) as? CommandableGpsProvider)?.sendCommand(cmd) } init { - // React to toggle changes — stop old, start new + // React to source changes — stop everything else, start the selected one scope.launch { - useExternalGps.collect { isExternal -> - if (isExternal) { - phoneProvider.stop() - espProvider.start() - } else { - espProvider.stop() - phoneProvider.start() - } + gpsSource.collect { selected -> + val active = providerFor(selected) + allProviders.filter { it !== active }.forEach { it.stop() } + active.start() } } + + // Re-assert the desired rate whenever the active provider becomes + // connected — covers both a fresh connection and switching source onto + // an already-live provider. distinctUntilChanged() MUST precede + // filter{it}: when switching sources the flattened sequence is + // true(old)->false(new starting)->true(new connected); filtering first + // would hide the intervening false, so distinctUntilChanged would then + // see true,true back-to-back and swallow the second reconnect's re-send. + scope.launch { + connectionStatus + .distinctUntilChanged() + .filter { it } + .collect { sendCommandToActive("RATE:${selectedRateHz.value}\n") } + } } fun startActiveProvider() { - if (useExternalGps.value) espProvider.start() else phoneProvider.start() + providerFor(gpsSource.value).start() } fun stopActiveProvider() { - espProvider.stop() - phoneProvider.stop() + allProviders.forEach { it.stop() } } fun cancel() { scope.cancel() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/trackpro/screens/ESPConnectionTestScreen.kt b/app/src/main/java/com/example/trackpro/screens/ESPConnectionTestScreen.kt index b63c7d7..b460ca7 100644 --- a/app/src/main/java/com/example/trackpro/screens/ESPConnectionTestScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/ESPConnectionTestScreen.kt @@ -1,5 +1,6 @@ package com.example.trackpro.screens +import android.annotation.SuppressLint import android.graphics.Typeface import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState @@ -49,24 +50,37 @@ import com.example.trackpro.theme.Spacing import com.example.trackpro.theme.TrackProType import com.example.trackpro.managerClasses.JsonReader import com.example.trackpro.managerClasses.utilities.UnitFormatter +import com.example.trackpro.models.GpsProviderType import kotlin.math.cos import kotlin.math.sin +@SuppressLint("MissingPermission") @Composable -fun ESPConnectionTestScreen() { +fun ESPConnectionTestScreen(onNavigateToSettings: () -> Unit) { val context = LocalContext.current val app = context.applicationContext as TrackProApp // 1. Unified State Collection - val isConnected by app.gpsManager.connectionStatus.collectAsState(initial = false) - val gpsData by app.gpsManager.activeGpsFlow.collectAsState(initial = null) - val useExternal by app.useExternalGps.collectAsState() - val useMetric by app.useMetricUnits.collectAsState() + val isConnected by app.gpsManager.connectionStatus.collectAsState(initial = false) + val gpsData by app.gpsManager.activeGpsFlow.collectAsState(initial = null) + val gpsSource by app.gpsSource.collectAsState() + val selectedRateHz by app.selectedRateHz.collectAsState() + val confirmedRateHz by app.gpsManager.confirmedRateHz.collectAsState(initial = null) + val selectedBtDeviceMac by app.selectedBtDeviceMac.collectAsState() + val useTestServer by app.useTestServer.collectAsState() + val testServerAddress by app.testServerAddress.collectAsState() + val useMetric by app.useMetricUnits.collectAsState() // 2. Configuration for display val config = remember { JsonReader.loadConfig(context) } - val ip = config.first + val ip = if (useTestServer && testServerAddress.isNotBlank()) testServerAddress else config.first val port = config.second + val pairedDeviceLabel = remember(selectedBtDeviceMac) { + app.bluetoothClassicClient.getBondedDevices() + .find { it.address == selectedBtDeviceMac } + ?.let { it.name ?: it.address } + ?: "None selected" + } // 3. Derived UI values val speed = gpsData?.speed ?: 0f @@ -81,14 +95,18 @@ fun ESPConnectionTestScreen() { Column(modifier = Modifier.fillMaxSize()) { AppTopBar( - title = if (useExternal) "ESP32 Mode" else "Phone GPS Mode", - accent = if (isConnected) TrackProTheme.colors.accentBlue else TrackProTheme.colors.accentCyan, + title = when (gpsSource) { + GpsProviderType.WIFI -> "ESP32 (WiFi) Mode" + GpsProviderType.BLUETOOTH -> "ESP32 (Bluetooth) Mode" + GpsProviderType.PHONE_GPS -> "Phone GPS Mode" + }, + accent = if (isConnected) TrackProTheme.colors.accent else TrackProTheme.colors.textFaint, trailing = { Text( - text = "Switch", + text = "Change", style = TrackProType.label, - color = TrackProTheme.colors.accentCyan, - modifier = Modifier.clickable { app.useExternalGps.value = !useExternal } + color = TrackProTheme.colors.accent, + modifier = Modifier.clickable { onNavigateToSettings() } ) } ) @@ -132,7 +150,11 @@ fun ESPConnectionTestScreen() { ) { StatCell( label = "Source", - value = if (useExternal) "ESP32" else "Internal", + value = when (gpsSource) { + GpsProviderType.WIFI -> "ESP32 (WiFi)" + GpsProviderType.BLUETOOTH -> "ESP32 (BT)" + GpsProviderType.PHONE_GPS -> "Internal" + }, size = StatCellSize.Small, horizontalAlignment = Alignment.CenterHorizontally ) @@ -140,7 +162,7 @@ fun ESPConnectionTestScreen() { StatCell( label = "Status", value = if (isConnected) "Live" else "Offline", - valueColor = if (isConnected) TrackProTheme.colors.accentBlue else TrackProTheme.colors.accentCyan, + valueColor = if (isConnected) TrackProTheme.colors.deltaGood else TrackProTheme.colors.textMuted, size = StatCellSize.Small, horizontalAlignment = Alignment.CenterHorizontally ) @@ -148,7 +170,7 @@ fun ESPConnectionTestScreen() { StatCell( label = "Fix", value = if (fix) "OK" else "Wait", - valueColor = if (fix) TrackProTheme.colors.accentBlue else TrackProTheme.colors.accentAmber, + valueColor = if (fix) TrackProTheme.colors.deltaGood else TrackProTheme.colors.textMuted, size = StatCellSize.Small, horizontalAlignment = Alignment.CenterHorizontally ) @@ -166,8 +188,15 @@ fun ESPConnectionTestScreen() { .padding(horizontal = Spacing.lg, vertical = Spacing.md), verticalArrangement = Arrangement.spacedBy(Spacing.md) ) { - if (useExternal) { - TelemetryRow("Remote IP", "$ip:$port", TrackProTheme.colors.textPrimary, TrackProTheme.colors.textMuted) + when (gpsSource) { + GpsProviderType.WIFI -> TelemetryRow( + if (useTestServer) "Remote IP (Test)" else "Remote IP", + "$ip:$port", + if (useTestServer) TrackProTheme.colors.accent else TrackProTheme.colors.textPrimary, + TrackProTheme.colors.textMuted + ) + GpsProviderType.BLUETOOTH -> TelemetryRow("Paired Device", pairedDeviceLabel, TrackProTheme.colors.textPrimary, TrackProTheme.colors.textMuted) + GpsProviderType.PHONE_GPS -> {} } TelemetryRow("Latitude", gpsData?.latitude?.let { String.format("%.6f°", it) } ?: "—", TrackProTheme.colors.textPrimary, TrackProTheme.colors.textMuted) TelemetryRow("Longitude", gpsData?.longitude?.let { String.format("%.6f°", it) } ?: "—", TrackProTheme.colors.textPrimary, TrackProTheme.colors.textMuted) @@ -175,8 +204,12 @@ fun ESPConnectionTestScreen() { TelemetryRow( "Refresh", - if (useExternal) "20-25 Hz" else "1-5 Hz", - if (useExternal) TrackProTheme.colors.accentBlue else TrackProTheme.colors.accentAmber, + when { + gpsSource == GpsProviderType.PHONE_GPS -> "1-5 Hz" + confirmedRateHz != null -> "$confirmedRateHz Hz" + else -> "$selectedRateHz Hz (pending)" + }, + TrackProTheme.colors.textPrimary, TrackProTheme.colors.textMuted ) } @@ -194,7 +227,7 @@ fun ESPConnectionTestScreen() { ) { Text( text = gpsData?.toString() ?: "Awaiting data stream...", - color = if (gpsData != null) TrackProTheme.colors.accentBlue else TrackProTheme.colors.textMuted, + color = if (gpsData != null) TrackProTheme.colors.accent else TrackProTheme.colors.textMuted, fontSize = 10.sp, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, lineHeight = 16.sp 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 b68c345..74d1759 100644 --- a/app/src/main/java/com/example/trackpro/screens/SettingsScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/SettingsScreen.kt @@ -1,5 +1,7 @@ package com.example.trackpro.screens +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -11,6 +13,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -20,20 +24,28 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.example.trackpro.TrackProApp import com.example.trackpro.components.AppCard import com.example.trackpro.components.AppTopBar import com.example.trackpro.components.SectionLabel import com.example.trackpro.components.ToggleChip +import com.example.trackpro.extrasForUI.AppDropdownField import com.example.trackpro.extrasForUI.TrackProTheme +import com.example.trackpro.models.GpsProviderType import com.example.trackpro.theme.Spacing import com.example.trackpro.theme.TrackProType @Composable -fun SettingsScreen(onBack: () -> Unit) { +fun SettingsScreen(onBack: () -> Unit, onRequestBluetoothPermission: () -> Unit) { val context = LocalContext.current val app = context.applicationContext as TrackProApp - val useExternal by app.useExternalGps.collectAsState() + val gpsSource by app.gpsSource.collectAsState() + val selectedRateHz by app.selectedRateHz.collectAsState() + val confirmedRateHz by app.gpsManager.confirmedRateHz.collectAsState(initial = null) + val selectedBtDeviceMac by app.selectedBtDeviceMac.collectAsState() + val useTestServer by app.useTestServer.collectAsState() + val testServerAddress by app.testServerAddress.collectAsState() val useDarkTheme by app.useDarkTheme.collectAsState() val useMetric by app.useMetricUnits.collectAsState() @@ -53,14 +65,39 @@ fun SettingsScreen(onBack: () -> Unit) { // --- Section: Hardware & GPS --- SectionLabel("Hardware & Sensors") AppCard { - SettingsToggleRow( - label = "GPS Source", - valueText = if (useExternal) "External ESP32 Module" else "Internal Phone GPS", - valueColor = if (useExternal) TrackProTheme.colors.accentCyan else TrackProTheme.colors.textMuted, - buttonText = if (useExternal) "Use Phone" else "Use ESP32", - isActive = useExternal, - onClick = { app.useExternalGps.value = !useExternal } - ) + Column(verticalArrangement = Arrangement.spacedBy(Spacing.md)) { + GpsSourceRow( + selected = gpsSource, + onSelect = { source -> + app.setGpsSource(source) + if (source == GpsProviderType.BLUETOOTH) onRequestBluetoothPermission() + } + ) + + if (gpsSource == GpsProviderType.BLUETOOTH) { + BluetoothDeviceRow( + devices = app.bluetoothClassicClient.getBondedDevices(), + selectedMac = selectedBtDeviceMac, + hasPermission = app.bluetoothClassicClient.hasBluetoothPermission(), + onSelect = { device -> app.setSelectedBtDevice(device.address) } + ) + } + + if (gpsSource == GpsProviderType.WIFI) { + EspTargetRow( + useTestServer = useTestServer, + testServerAddress = testServerAddress, + onToggle = { app.setUseTestServer(it) }, + onAddressChange = { app.setTestServerAddress(it) } + ) + } + + GpsRateRow( + selectedHz = selectedRateHz, + confirmedHz = confirmedRateHz, + onSelect = { hz -> app.setRateHz(hz) } + ) + } } // --- Section: Appearance --- @@ -69,7 +106,7 @@ fun SettingsScreen(onBack: () -> Unit) { SettingsToggleRow( label = "Theme", valueText = if (useDarkTheme) "Dark" else "Light", - valueColor = TrackProTheme.colors.accentCyan, + valueColor = TrackProTheme.colors.textMuted, buttonText = if (useDarkTheme) "Use Light" else "Use Dark", isActive = true, onClick = { app.setDarkTheme(!useDarkTheme) } @@ -82,7 +119,7 @@ fun SettingsScreen(onBack: () -> Unit) { SettingsToggleRow( label = "Speed & Distance", valueText = if (useMetric) "Metric (km/h, km)" else "Imperial (mph, mi)", - valueColor = TrackProTheme.colors.accentCyan, + valueColor = TrackProTheme.colors.textMuted, buttonText = if (useMetric) "Use mph" else "Use km/h", isActive = true, onClick = { app.setMetricUnits(!useMetric) } @@ -104,6 +141,140 @@ fun SettingsScreen(onBack: () -> Unit) { } } +@Composable +private fun GpsSourceRow(selected: GpsProviderType, onSelect: (GpsProviderType) -> Unit) { + Column { + Text("GPS SOURCE", style = TrackProType.label, color = TrackProTheme.colors.textPrimary) + Spacer(Modifier.height(6.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + GpsProviderType.values().forEach { source -> + ToggleChip( + text = gpsSourceLabel(source), + selected = selected == source, + onClick = { onSelect(source) }, + modifier = Modifier.weight(1f) + ) + } + } + } +} + +private fun gpsSourceLabel(source: GpsProviderType): String = when (source) { + GpsProviderType.WIFI -> "WiFi" + GpsProviderType.BLUETOOTH -> "Bluetooth" + GpsProviderType.PHONE_GPS -> "Phone" +} + +@SuppressLint("MissingPermission") +@Composable +private fun BluetoothDeviceRow( + devices: List, + selectedMac: String?, + hasPermission: Boolean, + onSelect: (BluetoothDevice) -> Unit +) { + val selectedLabel = devices.find { it.address == selectedMac }?.let { it.name ?: it.address } + ?: "Select a paired device" + AppDropdownField( + label = "Bluetooth Device", + items = devices, + selectedLabel = selectedLabel, + itemLabel = { it.name ?: it.address }, + onSelect = onSelect, + emptyMessage = if (hasPermission) { + "No paired devices — pair the ESP32 in Android Bluetooth settings first" + } else { + "Bluetooth permission needed — tap WiFi then Bluetooth again to re-prompt" + } + ) +} + +@Composable +private fun EspTargetRow( + useTestServer: Boolean, + testServerAddress: String, + onToggle: (Boolean) -> Unit, + onAddressChange: (String) -> Unit +) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text("ESP TARGET", style = TrackProType.label, color = TrackProTheme.colors.textPrimary) + Spacer(Modifier.height(2.dp)) + Text( + text = if (useTestServer) "Test Simulator" else "Real Device (192.168.4.1)", + style = TrackProType.body, + color = TrackProTheme.colors.textMuted + ) + } + ToggleChip( + text = if (useTestServer) "Use Real Device" else "Use Test Simulator", + selected = useTestServer, + onClick = { onToggle(!useTestServer) }, + accent = TrackProTheme.colors.accent + ) + } + + if (useTestServer) { + Spacer(Modifier.height(Spacing.sm)) + OutlinedTextField( + value = testServerAddress, + onValueChange = onAddressChange, + label = { Text("Simulator IP Address", color = TrackProTheme.colors.textMuted) }, + placeholder = { Text("e.g. 192.168.1.50", color = TrackProTheme.colors.textFaint) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = TrackProTheme.colors.textPrimary, + unfocusedTextColor = TrackProTheme.colors.textPrimary, + focusedBorderColor = TrackProTheme.colors.accent, + unfocusedBorderColor = TrackProTheme.colors.sectorLine + ) + ) + } + } +} + +@Composable +private fun GpsRateRow(selectedHz: Int, confirmedHz: Int?, onSelect: (Int) -> Unit) { + Column { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text("GPS RATE", style = TrackProType.label, color = TrackProTheme.colors.textPrimary) + if (confirmedHz != null) { + Text( + text = if (confirmedHz == selectedHz) "· confirmed" else "· device at ${confirmedHz}Hz", + style = TrackProType.body.copy(fontSize = 10.sp), + // A mismatch between requested and confirmed rate is a real problem + // worth flagging, so this is one of the few places color is earned. + color = if (confirmedHz == selectedHz) TrackProTheme.colors.deltaGood + else TrackProTheme.colors.deltaBad + ) + } + } + Spacer(Modifier.height(6.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + listOf(5, 10, 20, 25).forEach { hz -> + ToggleChip( + text = "$hz Hz", + selected = selectedHz == hz, + onClick = { onSelect(hz) }, + modifier = Modifier.weight(1f) + ) + } + } + } +} + /** * A labeled setting with a value line on the left and a single toggle action on the * right. The three toggles on this screen (GPS source, theme, units) all used to @@ -132,7 +303,7 @@ private fun SettingsToggleRow( text = buttonText, selected = isActive, onClick = onClick, - accent = TrackProTheme.colors.accentCyan + accent = TrackProTheme.colors.accent ) } } 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 4069e0a..ae185a4 100644 --- a/app/src/main/java/com/example/trackpro/screens/TrackBuilderScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/TrackBuilderScreen.kt @@ -115,7 +115,7 @@ fun TrackBuilderScreen( Box(modifier = Modifier.fillMaxSize().background(TrackProTheme.colors.bgDeep)) { Column(modifier = Modifier.fillMaxSize()) { - AppTopBar(title = "Track Builder", accent = TrackProTheme.colors.accentAmber, onBack = onBack) + AppTopBar(title = "Track Builder", accent = TrackProTheme.colors.accent, onBack = onBack) Column(modifier = Modifier.padding(Spacing.md)) { TrackInfoCard(trackName, countryName, trackMode) { showInfoDialog = true } @@ -301,7 +301,7 @@ private fun TrackInfoCard(name: String, country: String, mode: String, onClick: style = TrackProType.titleMedium, color = TrackProTheme.colors.textPrimary ) - Text("Mode: ${mode.uppercase()}", style = TrackProType.body.copy(fontSize = 12.sp), color = TrackProTheme.colors.accentCyan) + Text("Mode: ${mode.uppercase()}", style = TrackProType.body.copy(fontSize = 12.sp), color = TrackProTheme.colors.accent) } PrimaryButton( text = "Edit", @@ -320,7 +320,7 @@ private fun MarkSectorButton(count: Int, enabled: Boolean, onClick: () -> Unit) text = "Mark Sector ${count + 1}", onClick = onClick, enabled = enabled, - accent = TrackProTheme.colors.accentBlue, + accent = TrackProTheme.colors.accent, modifier = Modifier.fillMaxWidth().height(48.dp) ) } @@ -415,7 +415,7 @@ private fun ManualControls(onUndo: () -> Unit, onSave: () -> Unit, canSave: Bool text = "Save Track", onClick = onSave, enabled = canSave, - accent = TrackProTheme.colors.accentCyan, + accent = TrackProTheme.colors.accent, modifier = Modifier.weight(1f).height(56.dp) ) } @@ -461,7 +461,7 @@ fun TrackInfoAlert( }, confirmButton = { TextButton(onClick = { onConfirm(name, country, mode) }) { - Text("DONE", color = TrackProTheme.colors.accentCyan, fontWeight = FontWeight.Bold) + Text("DONE", color = TrackProTheme.colors.accent, fontWeight = FontWeight.Bold) } }, dismissButton = { diff --git a/app/src/main/java/com/example/trackpro/screens/TrackScreen.kt b/app/src/main/java/com/example/trackpro/screens/TrackScreen.kt index bdc36ba..eba2eb8 100644 --- a/app/src/main/java/com/example/trackpro/screens/TrackScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/TrackScreen.kt @@ -105,7 +105,7 @@ fun TrackView(database: ESPDatabase, trackId: Long) { ) { Column(modifier = Modifier.fillMaxSize()) { - AppTopBar(title = "Track Overview", accent = TrackProTheme.colors.accentBlue) + AppTopBar(title = "Track Overview", accent = TrackProTheme.colors.accent) // ── Track info card ─────────────────────────────── Column( @@ -190,7 +190,7 @@ fun TrackView(database: ESPDatabase, trackId: Long) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { CircularProgressIndicator( - color = TrackProTheme.colors.accentBlue, + color = TrackProTheme.colors.accent, modifier = Modifier.size(32.dp), strokeWidth = 2.dp ) @@ -234,7 +234,7 @@ private fun SectorSlicerCard( Text( text = if (sectorCount > 0) "$sectorCount marked" else "None marked", style = TrackProType.body.copy(fontSize = 11.sp), - color = if (sectorCount > 0) TrackProTheme.colors.accentBlue else TrackProTheme.colors.textMuted + color = if (sectorCount > 0) TrackProTheme.colors.accent else TrackProTheme.colors.textMuted ) } @@ -247,7 +247,7 @@ private fun SectorSlicerCard( text = "$n", selected = sectorCount == n, onClick = { onSlice(n) }, - accent = TrackProTheme.colors.accentBlue, + accent = TrackProTheme.colors.accent, modifier = Modifier.weight(1f) ) } diff --git a/app/src/main/java/com/example/trackpro/screens/TrackVehicleSelectorScreen.kt b/app/src/main/java/com/example/trackpro/screens/TrackVehicleSelectorScreen.kt index 9b83fff..17f41ee 100644 --- a/app/src/main/java/com/example/trackpro/screens/TrackVehicleSelectorScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/TrackVehicleSelectorScreen.kt @@ -65,7 +65,7 @@ fun TrackVehicleSelectorScreen( Column(modifier = Modifier.fillMaxSize()) { AppTopBar( title = "Session Setup", - accent = TrackProTheme.colors.accentCyan, + accent = TrackProTheme.colors.accent, onBack = { navController.popBackStack() } ) @@ -111,7 +111,7 @@ fun TrackVehicleSelectorScreen( selectedVehicleName = vehicles.find { it.vehicleId == id }?.manufacturerAndModel ?: "" // Adjust 'name' to your vehicle field } } else { - Text("No vehicles found in garage", style = TrackProType.body.copy(fontSize = 12.sp), color = TrackProTheme.colors.accentCyan) + Text("No vehicles found in garage", style = TrackProType.body.copy(fontSize = 12.sp), color = TrackProTheme.colors.accent) } } @@ -124,7 +124,7 @@ fun TrackVehicleSelectorScreen( text = "Start Time Attack", onClick = { navController.navigate("timeattack/$selectedVehicleId/$selectedTrackId") }, enabled = canStart, - accent = TrackProTheme.colors.accentCyan, + accent = TrackProTheme.colors.accent, modifier = Modifier.fillMaxWidth().height(56.dp) ) } @@ -141,12 +141,12 @@ fun SelectionCard( ) { AppCard( modifier = Modifier.fillMaxWidth(), - borderColor = if (isSet) TrackProTheme.colors.accentCyan.copy(alpha = 0.5f) else TrackProTheme.colors.sectorLine + borderColor = if (isSet) TrackProTheme.colors.accent.copy(alpha = 0.5f) else TrackProTheme.colors.sectorLine ) { Text( label.uppercase(), style = TrackProType.label, - color = if (isSet) TrackProTheme.colors.accentCyan else TrackProTheme.colors.textMuted + color = if (isSet) TrackProTheme.colors.accent else TrackProTheme.colors.textMuted ) Text(title, style = TrackProType.titleMedium, color = TrackProTheme.colors.textPrimary, modifier = Modifier.padding(vertical = 4.dp)) Spacer(modifier = Modifier.height(Spacing.sm)) 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 56ef5e7..67eb35b 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 @@ -69,7 +69,7 @@ fun CarListScreen(navController: NavController, viewModel: VehicleFULLViewModel) AppTopBar( title = "My Vehicles", - accent = TrackProTheme.colors.accentAmber, + accent = TrackProTheme.colors.accent, trailing = { Text( text = "${vehicles.size} cars", @@ -96,7 +96,7 @@ fun CarListScreen(navController: NavController, viewModel: VehicleFULLViewModel) navController = navController, bgCard = TrackProTheme.colors.bgCard, bgElevated = TrackProTheme.colors.bgElevated, - accentAmber = TrackProTheme.colors.accentAmber, + accent = TrackProTheme.colors.accent, dangerColor = TrackProTheme.colors.danger, textPrimary = TrackProTheme.colors.textPrimary, textMuted = TrackProTheme.colors.textMuted, @@ -121,7 +121,7 @@ fun VehicleCard( navController: NavController, bgCard: Color, bgElevated: Color, - accentAmber: Color, + accent: Color, dangerColor: Color, textPrimary: Color, textMuted: Color, @@ -168,7 +168,7 @@ fun VehicleCard( modifier = Modifier .fillMaxWidth() .background( - accentAmber.copy(alpha = 0.12f), + TrackProTheme.colors.bgElevated, RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp) ) .padding(horizontal = Spacing.md, vertical = Spacing.sm) @@ -184,13 +184,13 @@ fun VehicleCard( ) { Box( modifier = Modifier - .background(accentAmber.copy(alpha = 0.15f), TrackProShapes.badge) + .background(bgCard, TrackProShapes.badge) .padding(horizontal = 6.dp, vertical = 2.dp) ) { Text( text = vehicle.fuelType.uppercase(), style = TrackProType.label.copy(fontSize = 9.sp), - color = accentAmber + color = textMuted ) } Text( @@ -251,15 +251,15 @@ fun VehicleCard( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - StatCell(label = "Power", value = "${vehicle.horsepower}", unit = "hp", valueColor = accentAmber, horizontalAlignment = Alignment.CenterHorizontally) + StatCell(label = "Power", value = "${vehicle.horsepower}", unit = "hp", horizontalAlignment = Alignment.CenterHorizontally) StatCellDivider() - StatCell(label = "Torque", value = vehicle.torque?.toString() ?: "—", unit = "Nm", valueColor = accentAmber, horizontalAlignment = Alignment.CenterHorizontally) + StatCell(label = "Torque", value = vehicle.torque?.toString() ?: "—", unit = "Nm", horizontalAlignment = Alignment.CenterHorizontally) StatCellDivider() - StatCell(label = "Weight", value = "${vehicle.weight}", unit = "kg", valueColor = accentAmber, horizontalAlignment = Alignment.CenterHorizontally) + StatCell(label = "Weight", value = "${vehicle.weight}", unit = "kg", horizontalAlignment = Alignment.CenterHorizontally) Text( text = "View", style = TrackProType.label, - color = accentAmber.copy(alpha = 0.8f) + color = accent ) } } 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 8f0e8dd..3351027 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 @@ -66,7 +66,7 @@ fun DragTimesListView( Column(modifier = Modifier.fillMaxSize()) { AppTopBar( title = "Drag Records", - accent = TrackProTheme.colors.accentCyan, + accent = TrackProTheme.colors.accent, trailing = { Text( "${dragSessions.size} sessions", @@ -105,7 +105,7 @@ fun ExpandableSessionGroup( navController: NavController ) { ExpandableGroup( - accent = TrackProTheme.colors.accentCyan, + accent = TrackProTheme.colors.accent, header = { Column(modifier = Modifier.weight(1f)) { Text(groupTitle, style = TrackProType.titleMedium.copy(fontSize = 13.sp), color = TrackProTheme.colors.textPrimary) @@ -132,7 +132,7 @@ fun ExpandableSessionGroup( ) { Row(verticalAlignment = Alignment.CenterVertically) { Box( - Modifier.size(6.dp).background(TrackProTheme.colors.accentCyan, RoundedCornerShape(100)) + Modifier.size(6.dp).background(TrackProTheme.colors.accentMuted, RoundedCornerShape(100)) ) Spacer(Modifier.width(Spacing.sm)) Text( 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 c8d5ef8..4739d16 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 @@ -66,7 +66,7 @@ fun TimeAttackListViewScreen( Column(modifier = Modifier.fillMaxSize()) { AppTopBar( title = "Track Records", - accent = TrackProTheme.colors.accentCyan, + accent = TrackProTheme.colors.accent, trailing = { Text("${trackSessions.size} sessions", style = TrackProType.label, color = TrackProTheme.colors.textMuted) } @@ -107,7 +107,7 @@ fun ExpandableTrackGroup( navController: NavController ) { ExpandableGroup( - accent = TrackProTheme.colors.accentCyan, + accent = TrackProTheme.colors.accent, header = { Column(modifier = Modifier.weight(1f)) { Text(trackName, style = TrackProType.titleMedium.copy(fontSize = 14.sp), color = TrackProTheme.colors.textPrimary) @@ -136,7 +136,7 @@ fun ExpandableTrackGroup( ) { Row(verticalAlignment = Alignment.CenterVertically) { // Vertical "Pillar" accent - Box(modifier = Modifier.width(2.dp).height(24.dp).background(TrackProTheme.colors.accentCyan, RoundedCornerShape(2.dp))) + Box(modifier = Modifier.width(2.dp).height(24.dp).background(TrackProTheme.colors.accentMuted, RoundedCornerShape(2.dp))) Spacer(Modifier.width(Spacing.sm)) Column { Text( @@ -152,7 +152,7 @@ fun ExpandableTrackGroup( } } - Text("Telemetry", style = TrackProType.label, color = TrackProTheme.colors.accentCyan) + Text("Telemetry", style = TrackProType.label, color = TrackProTheme.colors.textMuted) } } } 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 8293131..897238f 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 @@ -72,7 +72,7 @@ fun TrackListScreen(navController: NavController, viewModel: TrackViewModel) { AppTopBar( title = "My Tracks", - accent = TrackProTheme.colors.accentAmber, + accent = TrackProTheme.colors.accent, trailing = { Text( text = "${tracks.size} tracks", @@ -98,7 +98,7 @@ fun TrackListScreen(navController: NavController, viewModel: TrackViewModel) { useMetric = useMetric, bgCard = TrackProTheme.colors.bgCard, bgElevated = TrackProTheme.colors.bgElevated, - accentAmber = TrackProTheme.colors.accentAmber, + accent = TrackProTheme.colors.accent, dangerColor = TrackProTheme.colors.danger, textPrimary = TrackProTheme.colors.textPrimary, textMuted = TrackProTheme.colors.textMuted, @@ -124,7 +124,7 @@ fun TrackCard( useMetric: Boolean, bgCard: Color, bgElevated: Color, - accentAmber: Color, + accent: Color, dangerColor: Color, textPrimary: Color, textMuted: Color, @@ -174,7 +174,7 @@ fun TrackCard( modifier = Modifier .fillMaxWidth() .background( - accentAmber.copy(alpha = 0.12f), + TrackProTheme.colors.bgElevated, RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp) ) .padding(horizontal = Spacing.md, vertical = Spacing.sm) @@ -190,13 +190,13 @@ fun TrackCard( ) { Box( modifier = Modifier - .background(accentAmber.copy(alpha = 0.15f), TrackProShapes.badge) + .background(bgCard, TrackProShapes.badge) .padding(horizontal = 6.dp, vertical = 2.dp) ) { Text( text = track.type.uppercase(), style = TrackProType.label.copy(fontSize = 9.sp), - color = accentAmber + color = textMuted ) } Text( @@ -252,8 +252,7 @@ fun TrackCard( StatCell( label = "Length", // totalLength is stored in km; formatDistance takes meters. - value = track.totalLength?.let { UnitFormatter.formatDistance(it * 1000.0, useMetric) } ?: "?", - valueColor = accentAmber + value = track.totalLength?.let { UnitFormatter.formatDistance(it * 1000.0, useMetric) } ?: "?" ) StatCell( label = "Lap Record", @@ -263,7 +262,7 @@ fun TrackCard( Text( text = "View", style = TrackProType.label, - color = accentAmber.copy(alpha = 0.8f) + color = accent ) } } diff --git a/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/CarViewScreen.kt b/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/CarViewScreen.kt index 281a5df..e1e350b 100644 --- a/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/CarViewScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/CarViewScreen.kt @@ -69,7 +69,7 @@ fun CarViewScreen(vehicleId: Long) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { CircularProgressIndicator( - color = TrackProTheme.colors.accentAmber, + color = TrackProTheme.colors.accent, modifier = Modifier.size(36.dp), strokeWidth = 2.dp ) @@ -81,7 +81,7 @@ fun CarViewScreen(vehicleId: Long) { val vehicle = vehicleInfo!! Column(modifier = Modifier.fillMaxSize()) { - AppTopBar(title = "Vehicle Profile", accent = TrackProTheme.colors.accentAmber) + AppTopBar(title = "Vehicle Profile", accent = TrackProTheme.colors.accent) LazyColumn(modifier = Modifier.fillMaxSize()) { @@ -117,9 +117,9 @@ fun CarViewScreen(vehicleId: Long) { .padding(horizontal = Spacing.lg, vertical = Spacing.md), horizontalArrangement = Arrangement.SpaceBetween ) { - StatCell(label = "Power", value = "${vehicle.horsepower}", unit = "hp", valueColor = TrackProTheme.colors.accentAmber, size = StatCellSize.Large, horizontalAlignment = Alignment.CenterHorizontally) - StatCell(label = "Torque", value = vehicle.torque?.toString() ?: "—", unit = "Nm", valueColor = TrackProTheme.colors.accentAmber, size = StatCellSize.Large, horizontalAlignment = Alignment.CenterHorizontally) - StatCell(label = "Weight", value = "${vehicle.weight}", unit = "kg", valueColor = TrackProTheme.colors.accentAmber, size = StatCellSize.Large, horizontalAlignment = Alignment.CenterHorizontally) + StatCell(label = "Power", value = "${vehicle.horsepower}", unit = "hp", size = StatCellSize.Large, horizontalAlignment = Alignment.CenterHorizontally) + StatCell(label = "Torque", value = vehicle.torque?.toString() ?: "—", unit = "Nm", size = StatCellSize.Large, horizontalAlignment = Alignment.CenterHorizontally) + StatCell(label = "Weight", value = "${vehicle.weight}", unit = "kg", size = StatCellSize.Large, horizontalAlignment = Alignment.CenterHorizontally) } HorizontalDivider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) } @@ -137,7 +137,6 @@ fun CarViewScreen(vehicleId: Long) { label = "Top Speed", value = vehicle.topSpeed?.let { UnitFormatter.formatSpeed(it, useMetric) } ?: "—", unit = UnitFormatter.speedUnitLabel(useMetric).lowercase(), - valueColor = TrackProTheme.colors.accentAmber, size = StatCellSize.Large, horizontalAlignment = Alignment.CenterHorizontally ) @@ -145,14 +144,12 @@ fun CarViewScreen(vehicleId: Long) { label = "0–100", value = vehicle.acceleration?.toString() ?: "—", unit = "sec", - valueColor = TrackProTheme.colors.accentAmber, size = StatCellSize.Large, horizontalAlignment = Alignment.CenterHorizontally ) StatCell( label = "Drivetrain", value = vehicle.drivetrain, - valueColor = TrackProTheme.colors.accentAmber, size = StatCellSize.Large, horizontalAlignment = Alignment.CenterHorizontally ) 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 5c51967..957eeca 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 @@ -24,7 +24,6 @@ import androidx.compose.runtime.remember 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.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -210,7 +209,7 @@ fun GraphScreen(onBack: () -> Unit, sessionId: Long) { .fillMaxSize() .background(TrackProTheme.colors.bgDeep) ) { - AppTopBar(title = "Session Overview", accent = TrackProTheme.colors.accentCyan, onBack = onBack) + AppTopBar(title = "Session Overview", accent = TrackProTheme.colors.accent, onBack = onBack) // ── Compact stats panel ─────────────────────────────── Column( @@ -298,7 +297,7 @@ fun GraphScreen(onBack: () -> Unit, sessionId: Long) { Box( modifier = Modifier .background( - if (active) TrackProTheme.colors.accentCyan else TrackProTheme.colors.sectorLine, + if (active) TrackProTheme.colors.accent else TrackProTheme.colors.sectorLine, RoundedCornerShape(3.dp) ) .clickable { xAxisInMeters = isMeters } @@ -307,7 +306,7 @@ fun GraphScreen(onBack: () -> Unit, sessionId: Long) { Text( text = label, style = TrackProType.body.copy(fontSize = 9.sp), - color = if (active) Color.Black else TrackProTheme.colors.textMuted + color = if (active) TrackProTheme.colors.onAccent else TrackProTheme.colors.textMuted ) } } @@ -317,7 +316,7 @@ fun GraphScreen(onBack: () -> Unit, sessionId: Long) { Box( modifier = Modifier .background( - if (active) TrackProTheme.colors.accentAmber else TrackProTheme.colors.sectorLine, + if (active) TrackProTheme.colors.accent else TrackProTheme.colors.sectorLine, RoundedCornerShape(3.dp) ) .clickable { showMap = isMap } @@ -326,7 +325,7 @@ fun GraphScreen(onBack: () -> Unit, sessionId: Long) { Text( text = label, style = TrackProType.body.copy(fontSize = 9.sp), - color = if (active) Color.Black else TrackProTheme.colors.textMuted + color = if (active) TrackProTheme.colors.onAccent else TrackProTheme.colors.textMuted ) } } 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 c9e85a1..ad2355f 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 @@ -29,6 +29,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.graphics.toColorInt import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController import androidx.room.Room @@ -181,7 +182,7 @@ fun LapDetailScreen( ) { AppTopBar( title = "Lap ${lap.lapnumber} · ${lap.laptime}", - accent = TrackProTheme.colors.accentBlue, + accent = TrackProTheme.colors.accent, onBack = { navController.popBackStack() }, trailing = { Text( @@ -209,7 +210,7 @@ fun LapDetailScreen( modifier = Modifier .clip(RoundedCornerShape(8.dp)) .background( - if (selected) TrackProTheme.colors.accentBlue + if (selected) TrackProTheme.colors.accent else Color.Transparent ) .clickable { heatmapMode = mode } @@ -219,7 +220,7 @@ fun LapDetailScreen( Text( mode.label, style = TrackProType.label, - color = if (selected) Color.Black else TrackProTheme.colors.textMuted + color = if (selected) TrackProTheme.colors.onAccent else TrackProTheme.colors.textMuted ) } } @@ -313,14 +314,14 @@ fun LapDetailScreen( // Stats toggle ActionButton( label = if (showStatsPanel) "Hide Stats" else "Stats", - color = TrackProTheme.colors.accentBlue, + color = TrackProTheme.colors.accent, modifier = Modifier.weight(1f) ) { showStatsPanel = !showStatsPanel } // Compare toggle ActionButton( label = if (compareLap != null) "Comparing" else "Compare", - color = if (compareLap != null) COMPARE_COLOR else TrackProTheme.colors.accentAmber, + color = if (compareLap != null) COMPARE_COLOR else TrackProTheme.colors.accent, modifier = Modifier.weight(1f) ) { showLapPicker = true } } @@ -349,11 +350,13 @@ fun LapDetailScreen( // ── Heatmap Map View ─────────────────────────────────────── -// Dedicated (non-theme) colors: these draw MapLibre GPS traces from plain Kotlin -// functions, which run outside any @Composable context and so can't read -// TrackProTheme.colors. Chosen to read clearly against a dark map tile. -private val PRIMARY_COLOR = Color(0xFF7B84D6) -private val COMPARE_COLOR = Color(0xFF4FB6C9) +// These draw MapLibre GPS traces from plain Kotlin functions, which run outside any +// @Composable context and so can't read TrackProTheme.colors. Sourced from +// DataVizColors so the two series stay in step with the rest of the palette - this is +// the one sanctioned place in the app with two distinguishable hues, because telling +// two overlaid lap traces apart is a real data problem. +private val PRIMARY_COLOR = Color(DataVizColors.seriesPrimary.toColorInt()) +private val COMPARE_COLOR = Color(DataVizColors.seriesCompare.toColorInt()) @Composable fun LapHeatmapMapView( @@ -526,7 +529,7 @@ private fun drawEndpointDot(style: Style, pt: LapInfoData, id: String, color: St setProperties( PropertyFactory.circleColor(color), PropertyFactory.circleRadius(5f), - PropertyFactory.circleStrokeColor("#0E1117"), + PropertyFactory.circleStrokeColor(DataVizColors.darkOutline), PropertyFactory.circleStrokeWidth(1.5f) ) }) @@ -750,7 +753,7 @@ private fun LapPickerSheet( .background( when { isSelected -> COMPARE_COLOR.copy(alpha = 0.15f) - isBest -> TrackProTheme.colors.accentBlue.copy(alpha = 0.05f) + isBest -> TrackProTheme.colors.accent.copy(alpha = 0.05f) else -> Color.Transparent } ) @@ -758,7 +761,7 @@ private fun LapPickerSheet( width = 1.dp, color = when { isSelected -> COMPARE_COLOR.copy(alpha = 0.5f) - isBest -> TrackProTheme.colors.accentBlue.copy(alpha = 0.2f) + isBest -> TrackProTheme.colors.accent.copy(alpha = 0.2f) else -> Color.Transparent }, shape = RoundedCornerShape(8.dp) @@ -775,15 +778,15 @@ private fun LapPickerSheet( Text( String.format("%02d", lap.lapnumber), style = TrackProType.statValue.copy(fontSize = 16.sp), - color = if (isBest) TrackProTheme.colors.accentBlue else TrackProTheme.colors.textPrimary + color = if (isBest) TrackProTheme.colors.accent else TrackProTheme.colors.textPrimary ) if (isBest) { Box( Modifier - .background(TrackProTheme.colors.accentBlue.copy(alpha = 0.15f), TrackProShapes.badge) + .background(TrackProTheme.colors.accent.copy(alpha = 0.15f), TrackProShapes.badge) .padding(horizontal = 5.dp, vertical = 2.dp) ) { - Text("Best", style = TrackProType.label.copy(fontSize = 7.sp), color = TrackProTheme.colors.accentBlue) + Text("Best", style = TrackProType.label.copy(fontSize = 7.sp), color = TrackProTheme.colors.accent) } } } @@ -817,7 +820,7 @@ private fun LoadingView() { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { CircularProgressIndicator( - color = TrackProTheme.colors.accentBlue, + color = TrackProTheme.colors.accent, modifier = Modifier.size(32.dp), strokeWidth = 2.dp ) diff --git a/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/TimeAttackListItem.kt b/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/TimeAttackListItem.kt index 8a0e4d6..0b2d796 100644 --- a/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/TimeAttackListItem.kt +++ b/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/TimeAttackListItem.kt @@ -125,7 +125,7 @@ fun TimeAttackListItemScreen( if (isLoading) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator(color = TrackProTheme.colors.accentBlue, + CircularProgressIndicator(color = TrackProTheme.colors.accent, modifier = Modifier.size(36.dp), strokeWidth = 2.dp) Spacer(Modifier.height(12.dp)) Text("Loading session", style = TrackProType.label, color = TrackProTheme.colors.textFaint) @@ -175,7 +175,7 @@ fun TimeAttackListItemScreen( val trendColor = when { trend.contains("IMPROVING") -> TrackProTheme.colors.deltaGood trend.contains("FADING") -> TrackProTheme.colors.deltaBad - else -> TrackProTheme.colors.accentAmber + else -> TrackProTheme.colors.textMuted } LazyColumn( @@ -186,7 +186,7 @@ fun TimeAttackListItemScreen( item { AppTopBar( title = "Session Detail", - accent = TrackProTheme.colors.accentBlue, + accent = TrackProTheme.colors.accent, trailing = { Text("${lapTimes.size} laps", style = TrackProType.label, color = TrackProTheme.colors.textMuted) } @@ -342,7 +342,6 @@ fun TimeAttackListItemScreen( bgElevated = TrackProTheme.colors.bgElevated, goodColor = TrackProTheme.colors.deltaGood, badColor = TrackProTheme.colors.deltaBad, - accentAmber = TrackProTheme.colors.accentAmber, textPrimary = TrackProTheme.colors.textPrimary, textMuted = TrackProTheme.colors.textMuted, sectorLine = TrackProTheme.colors.sectorLine @@ -369,7 +368,6 @@ private fun LapRow( bgElevated: Color, goodColor: Color, badColor: Color, - accentAmber: Color, textPrimary: Color, textMuted: Color, sectorLine: Color 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 c647cc2..c44ed94 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 @@ -36,7 +36,6 @@ import androidx.compose.runtime.saveable.rememberSaveable 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.style.TextOverflow import androidx.compose.ui.unit.dp @@ -155,7 +154,7 @@ fun DragRaceScreen( // 1. TOP STATUS BAR AppTopBar( title = "Drag Mode", - accent = TrackProTheme.colors.accentCyan, + accent = TrackProTheme.colors.accent, trailing = { Row( verticalAlignment = Alignment.CenterVertically, @@ -165,7 +164,7 @@ fun DragRaceScreen( Text( elapsedTime, style = TrackProType.statValue.copy(fontSize = 13.sp), - color = TrackProTheme.colors.accentCyan + color = TrackProTheme.colors.accent ) } Text( @@ -280,7 +279,7 @@ fun DragRaceScreen( .fillMaxWidth() .padding(horizontal = Spacing.md), padding = Spacing.lg, - borderColor = if (isSessionActive) TrackProTheme.colors.accentCyan.copy(alpha = 0.5f) + borderColor = if (isSessionActive) TrackProTheme.colors.accent.copy(alpha = 0.5f) else TrackProTheme.colors.sectorLine ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -297,7 +296,7 @@ fun DragRaceScreen( Text( gpsData?.speed?.let { UnitFormatter.formatSpeed(it, useMetric) } ?: "0", style = TrackProType.displayNumeric, - color = if (isSessionActive) TrackProTheme.colors.accentCyan + color = if (isSessionActive) TrackProTheme.colors.accent else TrackProTheme.colors.textPrimary ) Text( @@ -505,8 +504,8 @@ fun DragRaceScreen( } }, enabled = selectedVehicle != null, - accent = if (isSessionActive) TrackProTheme.colors.bgElevated else TrackProTheme.colors.accentCyan, - contentColor = if (isSessionActive) TrackProTheme.colors.accentCyan else null, + accent = if (isSessionActive) TrackProTheme.colors.bgElevated else TrackProTheme.colors.accent, + contentColor = if (isSessionActive) TrackProTheme.colors.accent else null, modifier = Modifier.weight(1f).height(56.dp) ) } @@ -518,15 +517,16 @@ fun DragMetricCard( metric: DragMetricDisplay, modifier: Modifier = Modifier ) { + // "Achieved" is signalled by a tinted border plus a small accent dot, and the value + // brightening from dim to full. An earlier version also washed the whole tile in + // accent and colored the label - four cues on eleven tiles at once, which is what + // made this grid read as noisy. AppCard( modifier = modifier, - borderColor = if (metric.achieved) TrackProTheme.colors.accentCyan.copy(alpha = 0.6f) else TrackProTheme.colors.sectorLine + borderColor = if (metric.achieved) TrackProTheme.colors.accent.copy(alpha = 0.45f) + else TrackProTheme.colors.sectorLine ) { - Column( - modifier = Modifier.background( - if (metric.achieved) TrackProTheme.colors.accentCyan.copy(alpha = 0.08f) else Color.Transparent - ) - ) { + Column { Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -535,13 +535,13 @@ fun DragMetricCard( Text( metric.label.uppercase(), style = TrackProType.label, - color = if (metric.achieved) TrackProTheme.colors.accentCyan else TrackProTheme.colors.textFaint + color = if (metric.achieved) TrackProTheme.colors.textMuted else TrackProTheme.colors.textFaint ) if (metric.achieved) { Box( Modifier - .size(6.dp) - .background(TrackProTheme.colors.accentCyan, CircleShape) + .size(5.dp) + .background(TrackProTheme.colors.accent, CircleShape) ) } } 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 7788764..9d6dc62 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 @@ -216,7 +216,7 @@ fun TimeAttackPortraitLayout( ) { val deltaColor = if (delta <= 0) TrackProTheme.colors.deltaGood else TrackProTheme.colors.deltaBad val eventName = if (timingMode is TimingMode.Circuit) "LAP" else "RUN" - val modeColor = if (timingMode is TimingMode.Circuit) TrackProTheme.colors.accentCyan else TrackProTheme.colors.accentAmber + val modeColor = TrackProTheme.colors.accent val modeLabel = if (timingMode is TimingMode.Circuit) "CIRCUIT" else "SPRINT" Box( @@ -359,7 +359,7 @@ fun TimeAttackLandscapeLayout( ) { val deltaColor = if (delta <= 0) TrackProTheme.colors.deltaGood else TrackProTheme.colors.deltaBad val eventName = if (timingMode is TimingMode.Circuit) "LAP" else "RUN" - val modeColor = if (timingMode is TimingMode.Circuit) TrackProTheme.colors.accentCyan else TrackProTheme.colors.accentAmber + val modeColor = TrackProTheme.colors.accent val modeLabel = if (timingMode is TimingMode.Circuit) "CIRCUIT" else "SPRINT" Row( 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 88b6450..2c9a2c4 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 @@ -83,7 +83,7 @@ fun CarCreationScreen( ) { Column(modifier = Modifier.fillMaxSize()) { - AppTopBar(title = "Vehicle Setup", accent = TrackProTheme.colors.accentAmber) + AppTopBar(title = "Vehicle Setup", accent = TrackProTheme.colors.accent) Column( modifier = Modifier @@ -95,28 +95,28 @@ fun CarCreationScreen( AppCard(modifier = Modifier.fillMaxWidth(), padding = 20.dp) { SectionLabel("Basic Info (Required)", modifier = Modifier.padding(vertical = Spacing.sm)) - CustomTextField("Manufacturer", manufacturer, leadingIcon = Icons.Default.Business, accent = TrackProTheme.colors.accentAmber) { manufacturer = it } - CustomTextField("Model", model, leadingIcon = Icons.Default.DirectionsCar, accent = TrackProTheme.colors.accentAmber) { model = it } - CustomTextField("Year", year, leadingIcon = Icons.Default.Event, accent = TrackProTheme.colors.accentAmber) { year = it } + CustomTextField("Manufacturer", manufacturer, leadingIcon = Icons.Default.Business) { manufacturer = it } + CustomTextField("Model", model, leadingIcon = Icons.Default.DirectionsCar) { model = it } + CustomTextField("Year", year, leadingIcon = Icons.Default.Event) { year = it } SectionLabel("Performance", modifier = Modifier.padding(vertical = Spacing.sm)) - CustomTextField("Horsepower", horsepower, true, Icons.Default.FlashOn, accent = TrackProTheme.colors.accentAmber) { horsepower = it } - CustomTextField("Torque (Nm)", torque, true, Icons.Default.Settings, accent = TrackProTheme.colors.accentAmber) { torque = it } - CustomTextField("Weight (kg)", weight, true, Icons.Default.FitnessCenter, accent = TrackProTheme.colors.accentAmber) { weight = it } - CustomTextField("Top Speed (${UnitFormatter.speedUnitLabel(useMetric)})", topSpeed, true, Icons.Default.Speed, accent = TrackProTheme.colors.accentAmber) { topSpeed = it } + CustomTextField("Horsepower", horsepower, true, Icons.Default.FlashOn) { horsepower = it } + CustomTextField("Torque (Nm)", torque, true, Icons.Default.Settings) { torque = it } + CustomTextField("Weight (kg)", weight, true, Icons.Default.FitnessCenter) { weight = it } + CustomTextField("Top Speed (${UnitFormatter.speedUnitLabel(useMetric)})", topSpeed, true, Icons.Default.Speed) { topSpeed = it } CustomTextField( if (useMetric) "0-100 KM/H (s)" else "0-60 MPH (s)", - acceleration, true, Icons.Default.Timer, accent = TrackProTheme.colors.accentAmber + acceleration, true, Icons.Default.Timer ) { acceleration = it } - CustomTextField("Fuel Capacity (L)", fuelCapacity, true, Icons.Default.LocalGasStation, accent = TrackProTheme.colors.accentAmber) { fuelCapacity = it } + CustomTextField("Fuel Capacity (L)", fuelCapacity, true, Icons.Default.LocalGasStation) { fuelCapacity = it } SectionLabel("Configuration", modifier = Modifier.padding(vertical = Spacing.sm)) - AppDropdownField("Engine Type", jsonOptions.engineTypes, selectedEngineType, { it }, { selectedEngineType = it }, accent = TrackProTheme.colors.accentAmber) - AppDropdownField("Drivetrain", jsonOptions.drivetrains, selectedDrivetrain, { it }, { selectedDrivetrain = it }, accent = TrackProTheme.colors.accentAmber) - AppDropdownField("Fuel Type", jsonOptions.fuelTypes, selectedFuelType, { it }, { selectedFuelType = it }, accent = TrackProTheme.colors.accentAmber) - AppDropdownField("Tire Type", jsonOptions.tireTypes, selectedTireType, { it }, { selectedTireType = it }, accent = TrackProTheme.colors.accentAmber) - AppDropdownField("Transmission", jsonOptions.transmissions, selectedTransmission, { it }, { selectedTransmission = it }, accent = TrackProTheme.colors.accentAmber) - AppDropdownField("Suspension", jsonOptions.suspensionTypes, selectedSuspensionType, { it }, { selectedSuspensionType = it }, accent = TrackProTheme.colors.accentAmber) + AppDropdownField("Engine Type", jsonOptions.engineTypes, selectedEngineType, { it }, { selectedEngineType = it }) + AppDropdownField("Drivetrain", jsonOptions.drivetrains, selectedDrivetrain, { it }, { selectedDrivetrain = it }) + AppDropdownField("Fuel Type", jsonOptions.fuelTypes, selectedFuelType, { it }, { selectedFuelType = it }) + AppDropdownField("Tire Type", jsonOptions.tireTypes, selectedTireType, { it }, { selectedTireType = it }) + AppDropdownField("Transmission", jsonOptions.transmissions, selectedTransmission, { it }, { selectedTransmission = it }) + AppDropdownField("Suspension", jsonOptions.suspensionTypes, selectedSuspensionType, { it }, { selectedSuspensionType = it }) Spacer(modifier = Modifier.height(Spacing.md)) 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 939bc8a..2e1a351 100644 --- a/app/src/main/java/com/example/trackpro/theme/TrackProColors.kt +++ b/app/src/main/java/com/example/trackpro/theme/TrackProColors.kt @@ -6,9 +6,11 @@ data class TrackProColorScheme( val bgDeep: Color, val bgCard: Color, val bgElevated: Color, - val accentCyan: Color, - val accentBlue: Color, - val accentAmber: Color, + val accent: Color, + /** Foreground for content sitting on top of an [accent] fill (selected chips, CTAs). */ + val onAccent: Color, + /** The quieter accent - structural marks that repeat, where [accent] would shout. */ + val accentMuted: Color, val textPrimary: Color, val textMuted: Color, val textFaint: Color, @@ -19,39 +21,73 @@ data class TrackProColorScheme( ) /** - * Accents are desaturated on purpose and are meant to stay small (icons, hairline - * underlines, badges, dots) — never a full-bleed fill. bgDeep/bgCard/bgElevated carry the - * visual weight instead. deltaGood/deltaBad/danger stay closer to full saturation since - * they're functional signal colors (faster/slower, destructive), not decoration. + * Built on the supplied 5-color palette: + * + * #16262E darkest navy -> bgDeep + * #2E4756 dark slate -> bgElevated / chart grid + * #3C7A89 teal -> accentMuted, map + chart lines + * #9FA2B2 cool grey -> textMuted + * #FEEA00 yellow -> accent + * + * Five colors can't cover a whole UI's tonal needs, so bgCard, sectorLine and the text + * tiers are interpolated *within* that ramp rather than invented - bgCard sits between + * #16262E and #2E4756, sectorLine just above #2E4756, and textPrimary is #9FA2B2 lifted + * toward white so it clears 11:1 on the deep background. + * + * Color hierarchy, in order of loudness: + * + * 1. [accent] (#FEEA00) - active / selected / primary action only. It hits 12.6:1 on the + * background, so it carries enormous weight; used sparingly it reads as a signal lamp, + * used everywhere it reads as a highlighter. + * 2. [accentMuted] (#3C7A89) - the structural marks that repeat per row or per card: + * icon chips, left accent bars, map and chart lines. Same family, a fraction of the + * impact. This is a loudness hierarchy, not the per-section color rotation an earlier + * revision had - don't reintroduce that. + * 3. [deltaGood]/[deltaBad] - faster/slower, and nothing else. + * + * The palette has no green or red, but a lap timer's delta has to be readable at a glance + * at speed and green/red is the one convention drivers already know. They're tuned to sit + * with the palette (the green leans teal, the red leans toward the yellow's warmth) rather + * than taken off the shelf. + * + * Neutral, always: surfaces, top bars, card headers, form fields, and static spec values. */ val DarkTrackProColors = TrackProColorScheme( - bgDeep = Color(0xFF0B0D11), - bgCard = Color(0xFF12151A), - bgElevated = Color(0xFF1A1E26), - accentCyan = Color(0xFF4FB6C9), - accentBlue = Color(0xFF7B84D6), - accentAmber = Color(0xFFD6A44A), - textPrimary = Color(0xFFF0F2F6), - textMuted = Color(0xFF8B93A3), - textFaint = Color(0xFF545C6B), - deltaGood = Color(0xFF3ECC7A), - deltaBad = Color(0xFFEF5B54), - sectorLine = Color(0xFF232830), - danger = Color(0xFFEF5B54) + bgDeep = Color(0xFF16262E), + bgCard = Color(0xFF1D3038), + bgElevated = Color(0xFF2E4756), + accent = Color(0xFFFEEA00), + onAccent = Color(0xFF16262E), + accentMuted = Color(0xFF3C7A89), + textPrimary = Color(0xFFDCDEE6), + textMuted = Color(0xFF9FA2B2), + textFaint = Color(0xFF6E7385), + deltaGood = Color(0xFF2FBF71), + deltaBad = Color(0xFFEF4E3A), + sectorLine = Color(0xFF3A5563), + danger = Color(0xFFEF4E3A) ) +/** + * The supplied palette is inherently dark, so light mode is a derived inversion: the + * darkest navy becomes the text color and the teal becomes the accent. + * + * Yellow is deliberately *not* the light-mode accent. [accent] is used as text as well as + * fill (link hints, the top-bar dot), and #FEEA00 on white is ~1.2:1 - illegible. Teal + * darkened to #2C6B7A clears 5.9:1 and keeps the palette's family. + */ val LightTrackProColors = TrackProColorScheme( - bgDeep = Color(0xFFF0F2F5), + bgDeep = Color(0xFFEDEFF2), bgCard = Color(0xFFFFFFFF), - bgElevated = Color(0xFFEDF0F4), - accentCyan = Color(0xFF3E93A6), - accentBlue = Color(0xFF6169C7), - accentAmber = Color(0xFFB9812E), - textPrimary = Color(0xFF12151A), - textMuted = Color(0xFF667085), - textFaint = Color(0xFF98A2B3), - deltaGood = Color(0xFF1C9B57), - deltaBad = Color(0xFFD3453D), - sectorLine = Color(0xFFE2E5EA), - danger = Color(0xFFD3453D) -) \ No newline at end of file + bgElevated = Color(0xFFE3E7EB), + accent = Color(0xFF2C6B7A), + onAccent = Color(0xFFFFFFFF), + accentMuted = Color(0xFF3C7A89), + textPrimary = Color(0xFF16262E), + textMuted = Color(0xFF4F5F6C), + textFaint = Color(0xFF8A95A1), + deltaGood = Color(0xFF158A4E), + deltaBad = Color(0xFFC93A28), + sectorLine = Color(0xFFC8D0D8), + danger = Color(0xFFC93A28) +)