diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/MainActivity.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/MainActivity.kt index 1595db4..729d8b7 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/MainActivity.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/MainActivity.kt @@ -47,7 +47,7 @@ import com.softwiredtech.dashpilot.navigation.ControlsRoute import com.softwiredtech.dashpilot.navigation.DashboardRoute import com.softwiredtech.dashpilot.navigation.OnboardingRoute import com.softwiredtech.dashpilot.navigation.SettingsRoute -import com.softwiredtech.dashpilot.navigation.SetupRoute +import com.softwiredtech.dashpilot.navigation.HomeRoute import com.softwiredtech.dashpilot.navigation.ThemePickerRoute import com.softwiredtech.dashpilot.ui.AutomationsScreen import com.softwiredtech.dashpilot.ui.ControlScreen @@ -179,7 +179,7 @@ class MainActivity : ComponentActivity() { ?.contains("OnboardingRoute") == true if (!onOnboarding) { navController.navigate(OnboardingRoute) { - popUpTo(SetupRoute) { inclusive = true } + popUpTo(HomeRoute) { inclusive = true } } } } @@ -206,7 +206,7 @@ class MainActivity : ComponentActivity() { Scaffold(modifier = Modifier.fillMaxSize()) { _ -> NavHost( navController = navController, - startDestination = SetupRoute, + startDestination = HomeRoute, enterTransition = { EnterTransition.None }, exitTransition = { ExitTransition.None }, popEnterTransition = { EnterTransition.None }, @@ -217,22 +217,23 @@ class MainActivity : ComponentActivity() { connectionVM = connectionVM, onFinish = { setOnboardingCompleted(context, true) - navController.navigate(SetupRoute) { + navController.navigate(HomeRoute) { popUpTo(OnboardingRoute) { inclusive = true } } }, onSkip = { connectionVM.disconnect() setOnboardingCompleted(context, true) - navController.navigate(SetupRoute) { + navController.navigate(HomeRoute) { popUpTo(OnboardingRoute) { inclusive = true } } } ) } - composable { + composable { val manager by connectionVM.bleManager.collectAsState() val dashStateFlow by connectionVM.dashState.collectAsState() + val activeDataSource by connectionVM.activeDataSource.collectAsState() val toDashboard = { val dashboard = getSelectedDashboard(context) navController.navigate(DashboardRoute( @@ -242,6 +243,7 @@ class MainActivity : ComponentActivity() { } HomeScreen( connectionStatus = connectionStatus, + dataSourceType = activeDataSource, bleManager = manager, dashState = dashStateFlow, pinnedControlId = pinnedControl, @@ -254,7 +256,6 @@ class MainActivity : ComponentActivity() { onDisconnect = { connectionVM.disconnect() }, - onNext = toDashboard, onAutomations = { navController.navigate(AutomationsRoute) }, @@ -306,7 +307,7 @@ class MainActivity : ComponentActivity() { connectionVM.disconnect() setOnboardingCompleted(context, false) navController.navigate(OnboardingRoute) { - popUpTo(SetupRoute) { inclusive = false } + popUpTo(HomeRoute) { inclusive = false } } }, onThemeClick = { diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/navigation/Routes.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/navigation/Routes.kt index 551e215..fd218af 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/navigation/Routes.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/navigation/Routes.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable object OnboardingRoute @Serializable -object SetupRoute +object HomeRoute @Serializable object SettingsRoute diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/DebugDataSourceMenu.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/DebugDataSourceMenu.kt index ee5ff4b..5ffa647 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/DebugDataSourceMenu.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/DebugDataSourceMenu.kt @@ -42,7 +42,6 @@ private val debugSources = buildList { @Composable fun DebugDataSourceMenu( connectionStatus: ConnectionStatus, - onSelectDataSource: (String) -> Unit, onConnect: (serverAddress: String, dataSourceType: String) -> Unit, onDisconnect: () -> Unit ) { @@ -80,7 +79,6 @@ fun DebugDataSourceMenu( if (key == DataSourceType.WEBSOCKET) { showIpDialog = true } else { - onSelectDataSource(key) onConnect("", key) } } @@ -124,7 +122,6 @@ fun DebugDataSourceMenu( onClick = { showIpDialog = false setLastWebsocketAddress(context, address) - onSelectDataSource(DataSourceType.WEBSOCKET) onConnect(address.trim(), DataSourceType.WEBSOCKET) } ) { Text("Connect") } diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/HomeScreen.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/HomeScreen.kt index dbf788b..d75f8a7 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/HomeScreen.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/HomeScreen.kt @@ -21,7 +21,9 @@ import androidx.compose.material.icons.rounded.AutoAwesome import androidx.compose.material.icons.rounded.BatteryChargingFull import androidx.compose.material.icons.rounded.DeviceThermostat import androidx.compose.material.icons.rounded.DirectionsCar +import androidx.compose.material.icons.rounded.Sensors import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.Signpost import androidx.compose.material.icons.rounded.Speed import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.Button @@ -32,9 +34,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -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 @@ -56,68 +55,21 @@ import kotlinx.coroutines.flow.flowOf import kotlin.math.roundToInt /** - * Landing screen. DashKit is the default source and shows the connected home - * content (vehicle info widgets + primary actions) regardless of connection - * state. Selecting any other source switches to the data-source setup flow. + * Landing screen for every data source. The stat grid adapts to what the + * active source can provide: DashKit exposes the vehicle bus (battery, temps, + * odometer), while comma/websocket sessions show the openpilot CAN essentials + * (speed, gear, speed limit, ADAS state). Controls and Automations act on the + * car through DashKit hardware, so they are only enabled on DashKit sessions. */ @Composable fun HomeScreen( connectionStatus: ConnectionStatus, + dataSourceType: String, bleManager: DashKitBleManager?, dashState: Flow?, pinnedControlId: String?, onConnect: (serverAddress: String, dataSourceType: String) -> Unit, onDisconnect: () -> Unit, - onNext: () -> Unit, - onAutomations: () -> Unit, - onControls: () -> Unit, - onDrive: () -> Unit, - onSettingsClick: () -> Unit -) { - var selectedDataSource by rememberSaveable { mutableStateOf(DataSourceType.DASHKIT) } - Column( - modifier = Modifier - .fillMaxSize() - .background(DarkColors.Background) - .systemBarsPadding() - ) { - if (selectedDataSource == DataSourceType.DASHKIT) { - ConnectedHomeContent( - dashState = dashState, - bleManager = bleManager, - connectionStatus = connectionStatus, - pinnedControlId = pinnedControlId, - onConnect = onConnect, - onDisconnect = onDisconnect, - onSelectDataSource = { selectedDataSource = it }, - onAutomations = onAutomations, - onControls = onControls, - onDrive = onDrive, - onSettingsClick = onSettingsClick - ) - } else { - SetupScreen( - connectionStatus = connectionStatus, - selectedDataSource = selectedDataSource, - onSelectDataSource = { selectedDataSource = it }, - onConnect = onConnect, - onDisconnect = onDisconnect, - onNext = onNext, - onSettingsClick = onSettingsClick - ) - } - } -} - -@Composable -private fun ConnectedHomeContent( - dashState: Flow?, - bleManager: DashKitBleManager?, - connectionStatus: ConnectionStatus, - pinnedControlId: String?, - onConnect: (serverAddress: String, dataSourceType: String) -> Unit, - onDisconnect: () -> Unit, - onSelectDataSource: (String) -> Unit, onAutomations: () -> Unit, onControls: () -> Unit, onDrive: () -> Unit, @@ -127,10 +79,13 @@ private fun ConnectedHomeContent( val state by (dashState ?: flowOf(fallback)).collectAsState(initial = fallback) val car = state.carState val useImperial = state.displaySettings.useImperial + val isDashKit = dataSourceType == DataSourceType.DASHKIT Column( modifier = Modifier .fillMaxSize() + .background(DarkColors.Background) + .systemBarsPadding() .padding(horizontal = 24.dp, vertical = 24.dp) ) { Box(modifier = Modifier.fillMaxWidth()) { @@ -160,67 +115,39 @@ private fun ConnectedHomeContent( Spacer(modifier = Modifier.height(8.dp)) - // 2x2 widget grid - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - InfoWidget( - icon = Icons.Rounded.BatteryChargingFull, - label = "Battery", - value = socText(car), - modifier = Modifier.weight(1f) - ) - InfoWidget( - icon = Icons.Rounded.DeviceThermostat, - label = "Battery Temp", - value = batteryTempText(car), - modifier = Modifier.weight(1f) - ) - } - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - InfoWidget( - icon = Icons.Rounded.AcUnit, - label = "AC Temp", - value = tempText(car.acTemp), - modifier = Modifier.weight(1f) - ) - InfoWidget( - icon = Icons.Rounded.Speed, - label = "Odometer", - value = odometerText(car.odometer, useImperial), - modifier = Modifier.weight(1f) - ) + if (isDashKit) { + DashKitStatGrid(car, useImperial) + } else { + CommaStatGrid(car) } Spacer(modifier = Modifier.height(32.dp)) - controlById(pinnedControlId)?.let { action -> - Text( - text = "Pinned", - color = DarkColors.TextMuted, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold - ) - Spacer(modifier = Modifier.height(8.dp)) - ControlActionButton( - action = action, - pinned = true, - enabled = bleManager != null, - onClick = { action.perform(bleManager) }, - onLongClick = null - ) - Spacer(modifier = Modifier.height(24.dp)) + if (isDashKit) { + controlById(pinnedControlId)?.let { action -> + Text( + text = "Pinned", + color = DarkColors.TextMuted, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(8.dp)) + ControlActionButton( + action = action, + pinned = true, + enabled = bleManager != null, + onClick = { action.perform(bleManager) }, + onLongClick = null + ) + Spacer(modifier = Modifier.height(24.dp)) + } } ActionButton( label = "Automations", icon = Icons.Rounded.AutoAwesome, accent = false, + enabled = isDashKit, onClick = onAutomations ) Spacer(modifier = Modifier.height(12.dp)) @@ -228,6 +155,7 @@ private fun ConnectedHomeContent( label = "Controls", icon = Icons.Rounded.Tune, accent = false, + enabled = isDashKit, onClick = onControls ) Spacer(modifier = Modifier.height(12.dp)) @@ -239,11 +167,89 @@ private fun ConnectedHomeContent( ) Spacer(modifier = Modifier.height(12.dp)) Box(modifier = Modifier.align(Alignment.CenterHorizontally)) { - DebugDataSourceMenu(connectionStatus, onSelectDataSource, onConnect, onDisconnect) + DebugDataSourceMenu(connectionStatus, onConnect, onDisconnect) } } } +@Composable +private fun DashKitStatGrid(car: CarState, useImperial: Boolean) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + InfoWidget( + icon = Icons.Rounded.BatteryChargingFull, + label = "Battery", + value = socText(car), + modifier = Modifier.weight(1f) + ) + InfoWidget( + icon = Icons.Rounded.DeviceThermostat, + label = "Battery Temp", + value = batteryTempText(car), + modifier = Modifier.weight(1f) + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + InfoWidget( + icon = Icons.Rounded.AcUnit, + label = "AC Temp", + value = tempText(car.acTemp), + modifier = Modifier.weight(1f) + ) + InfoWidget( + icon = Icons.Rounded.Speed, + label = "Odometer", + value = odometerText(car.odometer, useImperial), + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun CommaStatGrid(car: CarState) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + InfoWidget( + icon = Icons.Rounded.Speed, + label = "Speed", + value = "${car.egoSpeed.roundToInt()}", + modifier = Modifier.weight(1f) + ) + InfoWidget( + icon = Icons.Rounded.DirectionsCar, + label = "Gear", + value = gearText(car.gear), + modifier = Modifier.weight(1f) + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + InfoWidget( + icon = Icons.Rounded.Signpost, + label = "Speed Limit", + value = speedLimitText(car.fusedSpeedLimit), + modifier = Modifier.weight(1f) + ) + InfoWidget( + icon = Icons.Rounded.Sensors, + label = "ADAS", + value = if (car.adasOn || car.selfdriveActive) "On" else "Off", + modifier = Modifier.weight(1f) + ) + } +} + @Composable private fun InfoWidget( icon: ImageVector, @@ -292,14 +298,18 @@ private fun ActionButton( label: String, icon: ImageVector, accent: Boolean, + enabled: Boolean = true, onClick: () -> Unit ) { Button( onClick = onClick, + enabled = enabled, shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors( containerColor = if (accent) AccentColor else DarkColors.Surface, - contentColor = Color.White + contentColor = Color.White, + disabledContainerColor = DarkColors.Surface, + disabledContentColor = DarkColors.TextSubtle ), modifier = Modifier .fillMaxWidth() @@ -342,3 +352,15 @@ private fun odometerText(odometer: Float, useImperial: Boolean): String { val unit = if (useImperial) "mi" else "km" return "${odometer.roundToInt()} $unit" } + +// Matches the gear encoding the dashboards use (Tesla DI_gear). +private fun gearText(gear: Float): String = when (gear.roundToInt()) { + 1 -> "P" + 2 -> "R" + 3 -> "N" + 4 -> "D" + else -> "—" +} + +private fun speedLimitText(limit: Float): String = + if (limit <= 0f) "—" else "${limit.roundToInt()}" diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/SetupScreen.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/SetupScreen.kt deleted file mode 100644 index 2b0dbb6..0000000 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/SetupScreen.kt +++ /dev/null @@ -1,428 +0,0 @@ -package com.softwiredtech.dashpilot.ui - -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -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 -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Bluetooth -import androidx.compose.material.icons.rounded.DirectionsCar -import androidx.compose.material.icons.rounded.Lan -import androidx.compose.material.icons.rounded.PhoneAndroid -import androidx.compose.material.icons.rounded.Settings -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.IconButton -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -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.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.PathEffect -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.softwiredtech.dashpilot.BuildConfig -import com.softwiredtech.dashpilot.R -import com.softwiredtech.dashpilot.datasource.ConnectionStatus -import com.softwiredtech.dashpilot.datasource.DataSourceType -import com.softwiredtech.dashpilot.ui.theme.AccentColor -import com.softwiredtech.dashpilot.ui.theme.DarkColors - -private data class DataSource( - val key: String, - val label: String, - val icon: ImageVector?, -) - -private val dataSources = buildList { - add(DataSource(DataSourceType.COMMA, "comma", null)) - add(DataSource(DataSourceType.DASHKIT, "DashKit", Icons.Rounded.Bluetooth)) - if (BuildConfig.DEBUG) { - add(DataSource(DataSourceType.WEBSOCKET, "WebSocket", Icons.Rounded.Lan)) - } -} - -@Composable -fun SetupScreen( - connectionStatus: ConnectionStatus, - selectedDataSource: String, - onSelectDataSource: (String) -> Unit, - onConnect: (serverAddress: String, dataSourceType: String) -> Unit, - onDisconnect: () -> Unit, - onNext: () -> Unit, - onSettingsClick: () -> Unit = {} -) { - val context = LocalContext.current - val selectedIndex = dataSources.indexOfFirst { it.key == selectedDataSource } - .takeIf { it >= 0 } ?: 0 - val currentSource = dataSources[selectedIndex] - var serverAddress by rememberSaveable { mutableStateOf("") } - - Box( - modifier = Modifier - .fillMaxSize() - .background(DarkColors.Background) - ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 32.dp, vertical = 48.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = stringResource(R.string.app_name), - color = Color.White, - fontSize = 28.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 1.sp - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = if (dataSources.size > 1) stringResource(R.string.setup_select_data_source) else stringResource(R.string.setup_connect_your_comma_device), - color = DarkColors.TextMuted, - fontSize = 14.sp - ) - } - - Spacer(modifier = Modifier.height(32.dp)) - ConnectionVisualization( - connectionStatus = connectionStatus, - currentSource = currentSource, - selectedIndex = selectedIndex, - onSourceSelected = { onSelectDataSource(dataSources[it].key) } - ) - - Spacer(modifier = Modifier.height(32.dp)) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth() - ) { - val showIpField = currentSource.key == DataSourceType.WEBSOCKET || - (currentSource.key == DataSourceType.COMMA && connectionStatus is ConnectionStatus.Error) - OutlinedTextField( - value = serverAddress, - onValueChange = { serverAddress = it }, - label = { Text(stringResource(R.string.connection_device_ip)) }, - singleLine = true, - enabled = showIpField && ( - connectionStatus is ConnectionStatus.Disconnected || - connectionStatus is ConnectionStatus.Error - ), - shape = RoundedCornerShape(12.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedTextColor = Color.White, - focusedTextColor = Color.White, - unfocusedBorderColor = DarkColors.Border, - focusedBorderColor = Color.White, - unfocusedLabelColor = DarkColors.TextMuted, - focusedLabelColor = Color.White, - cursorColor = Color.White, - disabledTextColor = DarkColors.TextMuted, - disabledBorderColor = DarkColors.Border, - disabledLabelColor = DarkColors.TextSubtle - ), - modifier = Modifier - .fillMaxWidth(0.7f) - .alpha(if (showIpField) 1f else 0f) - ) - Spacer(modifier = Modifier.height(16.dp)) - - when (connectionStatus) { - is ConnectionStatus.Disconnected, is ConnectionStatus.Error -> { - if (connectionStatus is ConnectionStatus.Error) { - Text( - text = connectionStatus.message, - color = DarkColors.Error, - fontSize = 14.sp - ) - Spacer(modifier = Modifier.height(12.dp)) - } - Button( - onClick = { - // For comma: pass empty unless the user has overridden via the - // manual field (only shown after a discovery error). The VM takes - // care of seed IP + discovery. - val addressToSend = when (currentSource.key) { - DataSourceType.COMMA -> if (connectionStatus is ConnectionStatus.Error) serverAddress else "" - DataSourceType.WEBSOCKET -> serverAddress - else -> "" - } - onConnect(addressToSend, currentSource.key) - }, - shape = RoundedCornerShape(16.dp), - colors = ButtonDefaults.buttonColors( - containerColor = Color.White, - contentColor = Color.Black - ), - modifier = Modifier - .fillMaxWidth(0.7f) - .height(52.dp) - ) { - Text( - text = stringResource(R.string.setup_connect), - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold - ) - } - } - is ConnectionStatus.Connecting, is ConnectionStatus.Connected -> { - val isConnecting = connectionStatus is ConnectionStatus.Connecting - - OutlinedButton( - onClick = { onDisconnect() }, - shape = RoundedCornerShape(16.dp), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = DarkColors.Error - ), - modifier = Modifier - .fillMaxWidth(0.7f) - .height(52.dp) - ) { - Text( - text = stringResource( - if (isConnecting) R.string.setup_cancel - else R.string.setup_disconnect - ), - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - - Button( - onClick = { onNext() }, - enabled = !isConnecting, - shape = RoundedCornerShape(16.dp), - colors = ButtonDefaults.buttonColors( - containerColor = Color.White, - contentColor = Color.Black, - disabledContainerColor = DarkColors.Disabled, - disabledContentColor = DarkColors.ContentDisabled - ), - modifier = Modifier - .fillMaxWidth(0.7f) - .height(52.dp) - ) { - Text( - text = stringResource( - if (isConnecting) R.string.setup_connecting - else R.string.setup_next - ), - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold - ) - } - } - } - } - } - - IconButton( - onClick = onSettingsClick, - modifier = Modifier - .align(Alignment.TopStart) - .padding(start = 12.dp, top = 40.dp) - ) { - Icon( - imageVector = Icons.Rounded.Settings, - contentDescription = "Settings", - tint = DarkColors.TextMuted, - modifier = Modifier.size(24.dp) - ) - } - } -} - -@Composable -private fun ConnectionVisualization( - connectionStatus: ConnectionStatus, - currentSource: DataSource, - selectedIndex: Int, - onSourceSelected: (Int) -> Unit -) { - val isConnected = connectionStatus is ConnectionStatus.Connected - val isIdle = connectionStatus is ConnectionStatus.Disconnected || - connectionStatus is ConnectionStatus.Error - val dashLineColor = if (isConnected) AccentColor else DarkColors.Disabled - val animating = !isIdle - - val infiniteTransition = rememberInfiniteTransition(label = "dashFlow") - val phase by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 20f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 1000, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "dashPhase" - ) - - val circleBorderModifier = if (isConnected) { - Modifier.border(2.dp, AccentColor, CircleShape) - } else { - Modifier - } - - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .size(80.dp) - .then(circleBorderModifier) - .clip(CircleShape) - .background(DarkColors.Surface), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Rounded.DirectionsCar, - contentDescription = "Vehicle", - tint = Color.White, - modifier = Modifier.size(36.dp) - ) - } - - AnimatedDashedLine(phase = phase, animating = animating, color = dashLineColor) - - if (dataSources.size > 1) { - var expanded by remember { mutableStateOf(false) } - Box { - OutlinedButton( - onClick = { expanded = true }, - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = Color.White - ) - ) { - DataSourceIcon(currentSource, size = 18, tint = Color.White) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = currentSource.label) - } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - dataSources.forEachIndexed { index, source -> - DropdownMenuItem( - text = { Text(source.label) }, - leadingIcon = { DataSourceIcon(source, size = 18, tint = Color.Black) }, - onClick = { - onSourceSelected(index) - expanded = false - } - ) - } - } - } - } else { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(DarkColors.Surface), - contentAlignment = Alignment.Center - ) { - DataSourceIcon(currentSource, size = 24, tint = Color.White) - } - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = currentSource.label, - color = DarkColors.TextMuted, - fontSize = 12.sp - ) - } - } - - AnimatedDashedLine(phase = phase, animating = animating, color = dashLineColor) - - Box( - modifier = Modifier - .size(80.dp) - .then(circleBorderModifier) - .clip(CircleShape) - .background(DarkColors.Surface), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Rounded.PhoneAndroid, - contentDescription = "Phone", - tint = Color.White, - modifier = Modifier.size(36.dp) - ) - } - } -} - -@Composable -private fun DataSourceIcon(source: DataSource, size: Int, tint: Color = Color.Unspecified) { - Icon( - imageVector = source.icon ?: ImageVector.vectorResource(R.drawable.ic_comma), - contentDescription = source.label, - tint = tint, - modifier = Modifier.size(size.dp) - ) -} - -@Composable -private fun AnimatedDashedLine(phase: Float, animating: Boolean, color: Color) { - Canvas( - modifier = Modifier - .width(4.dp) - .height(64.dp) - .padding(vertical = 4.dp) - ) { - drawLine( - color = color, - start = Offset(size.width / 2, 0f), - end = Offset(size.width / 2, size.height), - strokeWidth = 3f, - pathEffect = PathEffect.dashPathEffect( - intervals = floatArrayOf(10f, 10f), - phase = if (animating) -phase else 0f - ) - ) - } -} diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/viewmodel/ConnectionViewModel.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/viewmodel/ConnectionViewModel.kt index 5178d7f..5c6e54a 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/viewmodel/ConnectionViewModel.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/viewmodel/ConnectionViewModel.kt @@ -80,6 +80,12 @@ class ConnectionViewModel(private var networkUtil: NetworkUtil) : ViewModel() { private val _bleManager = MutableStateFlow(null) val bleManager: StateFlow = _bleManager.asStateFlow() + // Data source the current/most recent session runs on. Drives which home + // layout is shown (stat tiles, controls availability). Defaults to DashKit, + // the primary source. + private val _activeDataSource = MutableStateFlow(DataSourceType.DASHKIT) + val activeDataSource = _activeDataSource.asStateFlow() + var blePermissionGate: ((onGranted: () -> Unit) -> Unit)? = null private val _hasAutoNavigatedToDashboard = MutableStateFlow(false) @@ -285,6 +291,7 @@ class ConnectionViewModel(private var networkUtil: NetworkUtil) : ViewModel() { if (userInitiated) userSelectedType = dataSourceType lastDataSourceType = dataSourceType lastServerAddress = manualServerAddress + _activeDataSource.value = dataSourceType var finalServerAddress = manualServerAddress _connectionStatus.value = ConnectionStatus.Connecting diff --git a/dashpilot-android/app/src/main/res/values/strings.xml b/dashpilot-android/app/src/main/res/values/strings.xml index ff4aeb4..85f141b 100644 --- a/dashpilot-android/app/src/main/res/values/strings.xml +++ b/dashpilot-android/app/src/main/res/values/strings.xml @@ -1,13 +1,5 @@ DashPilot - Device IP - Select data source - Connect to your comma device - Connect - Connecting… - Cancel - Disconnect - Next Settings Back Version