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 ded6f2c..2230351 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 @@ -329,6 +329,16 @@ class MainActivity : ComponentActivity() { bleManager = manager, pinnedControlId = pinnedControl, onTogglePin = { connectionVM.togglePinnedControl(context, it) }, + fingerActions = fingerActions, + onSetFingerAction = { fingers, id -> + connectionVM.setFingerAction(context, fingers, id) + }, + onChangeFingerCount = { from, to -> + connectionVM.changeFingerCount(context, from, to) + }, + onRemoveFingerAction = { fingers -> + connectionVM.removeFingerAction(context, fingers) + }, onBack = { navController.popBackStack() } ) } @@ -356,16 +366,6 @@ class MainActivity : ComponentActivity() { onSportKickdownPercentChange = { connectionVM.updateSportKickdownPercent(context, it) }, - fingerActions = fingerActions, - onSetFingerAction = { fingers, id -> - connectionVM.setFingerAction(context, fingers, id) - }, - onChangeFingerCount = { from, to -> - connectionVM.changeFingerCount(context, from, to) - }, - onRemoveFingerAction = { fingers -> - connectionVM.removeFingerAction(context, fingers) - }, onBack = { navController.popBackStack() } ) } diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/AutomationsScreen.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/AutomationsScreen.kt index 9b9b205..827a273 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/AutomationsScreen.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/AutomationsScreen.kt @@ -21,16 +21,11 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.AcUnit import androidx.compose.material.icons.rounded.Air -import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.ArrowDropDown -import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Speed import androidx.compose.material.icons.rounded.WaterDrop import androidx.compose.material3.AlertDialog -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text @@ -47,35 +42,21 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView -import com.softwiredtech.dashpilot.ui.controls.controlById -import com.softwiredtech.dashpilot.ui.controls.vehicleControls import com.softwiredtech.dashpilot.ui.theme.AccentColor import com.softwiredtech.dashpilot.ui.theme.DarkColors import androidx.compose.ui.res.stringResource import com.softwiredtech.dashpilot.R -// Finger counts that can be bound to an infotainment gesture (matches the -// firmware's MULTI_FINGER_MIN/MAX_FINGERS). -private val FINGER_COUNTS = 3..5 - -// Minutes the keep-climate-on window can run (matches the firmware clamp). +// Firmware clamp. internal val CLIMATE_KEEP_MINUTE_RANGE = 1..60 -// Matches the firmware clamp. +// Firmware clamp. private val SPORT_KICKDOWN_PERCENT_RANGE = 10..95 private const val SPORT_KICKDOWN_PERCENT_STEP = 5 -/** - * Automations screen. Lets the user enable the wiper-off automation and bind - * 3-, 4-, and 5-finger infotainment taps each to a vehicle control. Bindings are - * pushed to the DashKit firmware over BLE (VC_CMD_MULTI_FINGER_ACTION). - * - * @param fingerActions current bindings: finger count -> control id. - */ @Composable fun AutomationsScreen( wiperOffEnabled: Boolean, @@ -90,10 +71,6 @@ fun AutomationsScreen( onSportKickdownChange: (Boolean) -> Unit, sportKickdownPercent: Int, onSportKickdownPercentChange: (Int) -> Unit, - fingerActions: Map, - onSetFingerAction: (fingers: Int, id: String?) -> Unit, - onChangeFingerCount: (from: Int, to: Int) -> Unit, - onRemoveFingerAction: (fingers: Int) -> Unit, onBack: () -> Unit ) { Box( @@ -170,46 +147,12 @@ fun AutomationsScreen( } } else null ) - - Spacer(modifier = Modifier.height(28.dp)) - - SectionLabel(stringResource(R.string.automations_multitouch_title)) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.automations_multitouch_subtitle), - color = DarkColors.TextMuted, - fontSize = 13.sp, - modifier = Modifier.padding(start = 4.dp) - ) - Spacer(modifier = Modifier.height(12.dp)) - - val usedCounts = fingerActions.keys - fingerActions.toSortedMap().forEach { (fingers, controlId) -> - // Allow this row to keep its own count plus any not used elsewhere. - val fingerOptions = FINGER_COUNTS.filter { it == fingers || it !in usedCounts } - FingerActionRow( - fingers = fingers, - controlId = controlId, - fingerOptions = fingerOptions, - onFingerCountChange = { onChangeFingerCount(fingers, it) }, - onActionChange = { onSetFingerAction(fingers, it) }, - onRemove = { onRemoveFingerAction(fingers) } - ) - Spacer(modifier = Modifier.height(12.dp)) - } - - val nextFree = FINGER_COUNTS.firstOrNull { it !in usedCounts } - if (nextFree != null) { - AddTriggerButton( - onClick = { onSetFingerAction(nextFree, vehicleControls.first().id) } - ) - } } } } @Composable -private fun SectionLabel(text: String) { +internal fun SectionLabel(text: String) { Text( text = text, color = DarkColors.TextMuted, @@ -293,124 +236,6 @@ internal fun NumberPickerFooter( } } -@Composable -private fun FingerActionRow( - fingers: Int, - controlId: String, - fingerOptions: List, - onFingerCountChange: (Int) -> Unit, - onActionChange: (String) -> Unit, - onRemove: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(DarkColors.Surface, RoundedCornerShape(16.dp)) - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - DropdownPicker( - selectedLabel = fingers.toString(), - options = fingerOptions, - optionLabel = { it.toString() }, - onSelect = onFingerCountChange - ) - Spacer(modifier = Modifier.size(6.dp)) - Text(text = stringResource(R.string.automations_fingers), color = DarkColors.TextMuted, fontSize = 14.sp) - Spacer(modifier = Modifier.size(10.dp)) - Text(text = stringResource(R.string.automations_action), color = DarkColors.TextMuted, fontSize = 14.sp) - Spacer(modifier = Modifier.size(6.dp)) - DropdownPicker( - selectedLabel = controlById(controlId)?.let { stringResource(it.labelRes()) } ?: controlId, - options = vehicleControls, - optionLabel = { stringResource(it.labelRes()) }, - onSelect = { onActionChange(it.id) }, - modifier = Modifier.weight(1f) - ) - IconButton(onClick = onRemove) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = stringResource(R.string.automations_remove), - tint = DarkColors.TextMuted, - modifier = Modifier.size(20.dp) - ) - } - } -} - -/** A compact dropdown styled to sit inside a [DarkColors.Surface] row. */ -@Composable -private fun DropdownPicker( - selectedLabel: String, - options: List, - optionLabel: @Composable (T) -> String, - onSelect: (T) -> Unit, - modifier: Modifier = Modifier -) { - var expanded by remember { mutableStateOf(false) } - Box(modifier = modifier) { - Row( - modifier = Modifier - .clip(RoundedCornerShape(10.dp)) - .background(DarkColors.Background) - .clickable { expanded = true } - .padding(start = 12.dp, end = 6.dp, top = 10.dp, bottom = 10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = selectedLabel, - color = Color.White, - fontSize = 15.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false) - ) - Icon( - imageVector = Icons.Rounded.ArrowDropDown, - contentDescription = null, - tint = DarkColors.TextMuted, - modifier = Modifier.size(20.dp) - ) - } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - options.forEach { option -> - DropdownMenuItem( - text = { Text(optionLabel(option)) }, - onClick = { - onSelect(option) - expanded = false - } - ) - } - } - } -} - -@Composable -private fun AddTriggerButton(onClick: () -> Unit) { - Row( - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .clickable(onClick = onClick) - .padding(vertical = 8.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Rounded.Add, - contentDescription = stringResource(R.string.automations_add_trigger), - tint = AccentColor, - modifier = Modifier.size(22.dp) - ) - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = stringResource(R.string.automations_add_trigger), - color = AccentColor, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold - ) - } -} - @Composable internal fun AcSwingRow(value: AcSwingSettings, onChange: (AcSwingSettings) -> Unit) { AutomationRow( @@ -449,11 +274,22 @@ private fun ChoiceFooter(label: String, options: List, selected: Int, on Spacer(modifier = Modifier.height(12.dp)) Text(text = label, color = DarkColors.TextMuted, fontSize = 14.sp) Spacer(modifier = Modifier.height(6.dp)) + SegmentedSelector(options = options, selected = selected, onSelect = onSelect) +} + +@Composable +internal fun SegmentedSelector( + options: List, + selected: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, + containerColor: Color = DarkColors.Background +) { Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(10.dp)) - .background(DarkColors.Background) + .background(containerColor) .padding(3.dp), horizontalArrangement = Arrangement.spacedBy(3.dp) ) { diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/ControlScreen.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/ControlScreen.kt index 7af86cd..72d1602 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/ControlScreen.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/ControlScreen.kt @@ -13,6 +13,10 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -25,20 +29,20 @@ import com.softwiredtech.dashpilot.ui.theme.DarkColors import androidx.compose.ui.res.stringResource import com.softwiredtech.dashpilot.R -/** - * Vehicle control screen. Exposes the available DashKit commands as single - * toggle/action buttons. Disabled when there is no active DashKit connection. - * Long-pressing a control pins it to the home screen. - */ @Composable fun ControlScreen( bleManager: DashKitBleManager?, pinnedControlId: String?, onTogglePin: (String) -> Unit, + fingerActions: Map, + onSetFingerAction: (fingers: Int, id: String?) -> Unit, + onChangeFingerCount: (from: Int, to: Int) -> Unit, + onRemoveFingerAction: (fingers: Int) -> Unit, onBack: () -> Unit ) { - // In debug builds the controls behave as if connected (commands are - // simulated) so the flow can be exercised without a DashKit. + var selectedTab by rememberSaveable { mutableIntStateOf(0) } + + // Debug builds simulate commands without a DashKit. val enabled = bleManager != null || BuildConfig.DEBUG Box( @@ -55,40 +59,77 @@ fun ControlScreen( ) { ScreenHeader(title = stringResource(R.string.controls_title), onBack = onBack) - if (!enabled) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.controls_connect_hint), - color = DarkColors.TextMuted, - fontSize = 13.sp, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - } + Spacer(modifier = Modifier.height(16.dp)) + SegmentedSelector( + options = listOf( + stringResource(R.string.controls_tab_controls), + stringResource(R.string.controls_tab_multitouch) + ), + selected = selectedTab, + onSelect = { selectedTab = it }, + containerColor = DarkColors.Surface + ) Spacer(modifier = Modifier.height(24.dp)) - vehicleControls.forEachIndexed { index, action -> - if (index > 0) { - Spacer(modifier = Modifier.height(12.dp)) - } - ControlActionButton( - action = action, - pinned = action.id == pinnedControlId, + if (selectedTab == 0) { + ControlsTab( + bleManager = bleManager, enabled = enabled, - onClick = { action.perform(bleManager) }, - onLongClick = { onTogglePin(action.id) } + pinnedControlId = pinnedControlId, + onTogglePin = onTogglePin + ) + } else { + MultiTouchSection( + fingerActions = fingerActions, + onSetFingerAction = onSetFingerAction, + onChangeFingerCount = onChangeFingerCount, + onRemoveFingerAction = onRemoveFingerAction ) } + } + } +} - Spacer(modifier = Modifier.height(20.dp)) +@Composable +private fun ControlsTab( + bleManager: DashKitBleManager?, + enabled: Boolean, + pinnedControlId: String?, + onTogglePin: (String) -> Unit +) { + Column(modifier = Modifier.fillMaxWidth()) { + if (!enabled) { Text( - text = stringResource(R.string.controls_pin_tip), - color = DarkColors.TextSubtle, - fontSize = 12.sp, + text = stringResource(R.string.controls_connect_hint), + color = DarkColors.TextMuted, + fontSize = 13.sp, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() ) + Spacer(modifier = Modifier.height(16.dp)) + } + + vehicleControls.forEachIndexed { index, action -> + if (index > 0) { + Spacer(modifier = Modifier.height(12.dp)) + } + ControlActionButton( + action = action, + pinned = action.id == pinnedControlId, + enabled = enabled, + onClick = { action.perform(bleManager) }, + onLongClick = { onTogglePin(action.id) } + ) } + + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(R.string.controls_pin_tip), + color = DarkColors.TextSubtle, + fontSize = 12.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) } } 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 26fcb43..bc7e825 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 @@ -72,11 +72,6 @@ import kotlin.math.roundToInt import androidx.compose.ui.res.stringResource import com.softwiredtech.dashpilot.R -/** - * 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. - */ @Composable fun HomeScreen( connectionStatus: ConnectionStatus, @@ -253,8 +248,7 @@ private fun Header( .fillMaxWidth() .height(HeaderHeight) ) { - // Offset by the IconButton's built-in content inset so the gear - // glyph lines up with the tiles' left edge below. + // Offset cancels IconButton's inset so the gear aligns with the tiles. IconButton( onClick = onSettingsClick, modifier = Modifier @@ -351,9 +345,6 @@ private fun PortraitContent( } } -// Mirrors HomeView.swift: widget grid on the left, action tiles and the -// primary buttons on the right, both stretched to the viewport height so the -// screen fills without scrolling unless the content is genuinely taller. @Composable private fun LandscapeContent( minHeight: Dp, @@ -551,8 +542,6 @@ private fun InfoWidget( } } -// Landscape counterpart of ActionButton: icon at the top, label at the -// bottom, sized to match the stat tiles beside it. @Composable private fun ActionTile( label: String, diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/MultiTouchSection.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/MultiTouchSection.kt new file mode 100644 index 0000000..ad06007 --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/MultiTouchSection.kt @@ -0,0 +1,202 @@ +package com.softwiredtech.dashpilot.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.ArrowDropDown +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.softwiredtech.dashpilot.R +import com.softwiredtech.dashpilot.ui.controls.controlById +import com.softwiredtech.dashpilot.ui.controls.vehicleControls +import com.softwiredtech.dashpilot.ui.theme.AccentColor +import com.softwiredtech.dashpilot.ui.theme.DarkColors + +// Firmware MULTI_FINGER_MIN/MAX_FINGERS. +private val FINGER_COUNTS = 3..5 + +@Composable +internal fun MultiTouchSection( + fingerActions: Map, + onSetFingerAction: (fingers: Int, id: String?) -> Unit, + onChangeFingerCount: (from: Int, to: Int) -> Unit, + onRemoveFingerAction: (fingers: Int) -> Unit +) { + Column(modifier = Modifier.fillMaxWidth()) { + SectionLabel(stringResource(R.string.automations_multitouch_title)) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.automations_multitouch_subtitle), + color = DarkColors.TextMuted, + fontSize = 13.sp, + modifier = Modifier.padding(start = 4.dp) + ) + Spacer(modifier = Modifier.height(12.dp)) + + val usedCounts = fingerActions.keys + fingerActions.toSortedMap().forEach { (fingers, controlId) -> + val fingerOptions = FINGER_COUNTS.filter { it == fingers || it !in usedCounts } + FingerActionRow( + fingers = fingers, + controlId = controlId, + fingerOptions = fingerOptions, + onFingerCountChange = { onChangeFingerCount(fingers, it) }, + onActionChange = { onSetFingerAction(fingers, it) }, + onRemove = { onRemoveFingerAction(fingers) } + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + val nextFree = FINGER_COUNTS.firstOrNull { it !in usedCounts } + if (nextFree != null) { + AddTriggerButton( + onClick = { onSetFingerAction(nextFree, vehicleControls.first().id) } + ) + } + } +} + +@Composable +private fun FingerActionRow( + fingers: Int, + controlId: String, + fingerOptions: List, + onFingerCountChange: (Int) -> Unit, + onActionChange: (String) -> Unit, + onRemove: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(DarkColors.Surface, RoundedCornerShape(16.dp)) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + DropdownPicker( + selectedLabel = fingers.toString(), + options = fingerOptions, + optionLabel = { it.toString() }, + onSelect = onFingerCountChange + ) + Spacer(modifier = Modifier.size(6.dp)) + Text(text = stringResource(R.string.automations_fingers), color = DarkColors.TextMuted, fontSize = 14.sp) + Spacer(modifier = Modifier.size(10.dp)) + Text(text = stringResource(R.string.automations_action), color = DarkColors.TextMuted, fontSize = 14.sp) + Spacer(modifier = Modifier.size(6.dp)) + DropdownPicker( + selectedLabel = controlById(controlId)?.let { stringResource(it.labelRes()) } ?: controlId, + options = vehicleControls, + optionLabel = { stringResource(it.labelRes()) }, + onSelect = { onActionChange(it.id) }, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = onRemove) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.automations_remove), + tint = DarkColors.TextMuted, + modifier = Modifier.size(20.dp) + ) + } + } +} + +@Composable +private fun DropdownPicker( + selectedLabel: String, + options: List, + optionLabel: @Composable (T) -> String, + onSelect: (T) -> Unit, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + Box(modifier = modifier) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(DarkColors.Background) + .clickable { expanded = true } + .padding(start = 12.dp, end = 6.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = selectedLabel, + color = Color.White, + fontSize = 15.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + Icon( + imageVector = Icons.Rounded.ArrowDropDown, + contentDescription = null, + tint = DarkColors.TextMuted, + modifier = Modifier.size(20.dp) + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(optionLabel(option)) }, + onClick = { + onSelect(option) + expanded = false + } + ) + } + } + } +} + +@Composable +private fun AddTriggerButton(onClick: () -> Unit) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(vertical = 8.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = stringResource(R.string.automations_add_trigger), + tint = AccentColor, + modifier = Modifier.size(22.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(R.string.automations_add_trigger), + color = AccentColor, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold + ) + } +} diff --git a/dashpilot-android/app/src/main/res/values-de/strings.xml b/dashpilot-android/app/src/main/res/values-de/strings.xml index 6da5391..e30d814 100644 --- a/dashpilot-android/app/src/main/res/values-de/strings.xml +++ b/dashpilot-android/app/src/main/res/values-de/strings.xml @@ -219,6 +219,8 @@ Befehle Verbinde dich mit dem DashKit, um Befehle zu senden Tipp: Halte einen Befehl gedrückt, um ihn auf dem Startbildschirm anzuheften + Befehle + Multi-Touch Angeheftet Akku-Vorheizen: An Akku-Vorheizen: Aus diff --git a/dashpilot-android/app/src/main/res/values-hu/strings.xml b/dashpilot-android/app/src/main/res/values-hu/strings.xml index c7256a4..fe1e4d5 100644 --- a/dashpilot-android/app/src/main/res/values-hu/strings.xml +++ b/dashpilot-android/app/src/main/res/values-hu/strings.xml @@ -218,6 +218,8 @@ Parancsok A parancsok küldéséhez csatlakozz a DashKithez Tipp: nyomj hosszan egy parancsra, hogy a kezdőképernyőre rögzítsd + Parancsok + Többujjas Rögzítve Akku előfűtés: Be Akku előfűtés: Ki diff --git a/dashpilot-android/app/src/main/res/values/strings.xml b/dashpilot-android/app/src/main/res/values/strings.xml index f32b985..fde521f 100644 --- a/dashpilot-android/app/src/main/res/values/strings.xml +++ b/dashpilot-android/app/src/main/res/values/strings.xml @@ -219,6 +219,8 @@ Controls Connect to DashKit to send commands Tip: long-press a control to pin it to the home screen + Controls + Multi-touch Pinned Battery Preheat: On Battery Preheat: Off diff --git a/dashpilot-ios/dashpilot/Localizable.xcstrings b/dashpilot-ios/dashpilot/Localizable.xcstrings index a52cd46..6a309af 100644 --- a/dashpilot-ios/dashpilot/Localizable.xcstrings +++ b/dashpilot-ios/dashpilot/Localizable.xcstrings @@ -1922,6 +1922,22 @@ } } }, + "Multi-touch" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multi-Touch" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Többujjas" + } + } + } + }, "Multi-touch infotainment trigger" : { "localizations" : { "de" : { diff --git a/dashpilot-ios/dashpilot/UI/AutomationsView.swift b/dashpilot-ios/dashpilot/UI/AutomationsView.swift index 88a0074..ed9c8cf 100644 --- a/dashpilot-ios/dashpilot/UI/AutomationsView.swift +++ b/dashpilot-ios/dashpilot/UI/AutomationsView.swift @@ -1,35 +1,12 @@ import SwiftUI -/// Finger counts that can be bound to an infotainment gesture (matches the -/// firmware's MULTI_FINGER_MIN/MAX_FINGERS and Android `FINGER_COUNTS`). -private let fingerCounts = 3...5 - -/// UserDefaults key holding the serialized finger-action bindings. -private let fingerActionsKey = "finger_actions" - -/// Minutes the keep-climate-on window can run (matches the firmware clamp). +// Firmware clamp. let climateKeepMinuteRange = 1...60 -// Matches the firmware clamp. +// Firmware clamp. private let sportKickdownPercentOptions = Array(stride(from: 10, through: 95, by: 5)) -/// A single multi-finger tap binding: `fingerCount` fingers -> vehicle -/// control `controlId`. -struct FingerAction: Identifiable, Equatable { - let id = UUID() - var fingerCount: Int - var controlId: String -} - -/// Automations screen, ported from Android `AutomationsScreen`. Lets the user -/// enable the wiper-off automation and bind 3-, 4-, and 5-finger infotainment -/// taps each to a vehicle control. -/// -/// Edits persist to UserDefaults and are pushed to the DashKit firmware over -/// BLE when a link is up; the ConnectionViewModel re-syncs everything on each -/// connect, so edits made while disconnected are not lost. struct AutomationsView: View { - @Environment(\.dismiss) private var dismiss @Environment(ConnectionViewModel.self) private var connectionVM @@ -38,7 +15,6 @@ struct AutomationsView: View { @AppStorage("climate_keep_minutes") private var climateKeepMinutes: Int = 5 @AppStorage("sport_kickdown_automation") private var sportKickdown: Bool = false @AppStorage("sport_kickdown_percent") private var sportKickdownPercent: Int = 80 - @State private var fingerActions: [FingerAction] = [] @State private var minutesPushTask: Task? @State private var minutesWheelExpanded = false @State private var percentPushTask: Task? @@ -107,37 +83,11 @@ struct AutomationsView: View { ) } } - - Spacer().frame(height: 28) - - SectionLabel("Multi-touch infotainment trigger") - Spacer().frame(height: 4) - Text("Bind 3-, 4-, or 5-finger infotainment taps to a control") - .foregroundColor(.dashTextMuted) - .font(.system(size: 13)) - .padding(.leading, 4) - Spacer().frame(height: 12) - - ForEach($fingerActions) { $action in - FingerActionRow( - action: $action, - fingerOptions: fingerOptions(for: action), - onRemove: { removeAction(action) } - ) - Spacer().frame(height: 12) - } - - if let nextFree = firstFreeFingerCount() { - AddTriggerButton { - addAction(fingerCount: nextFree) - } - } } .padding(DashMetrics.screenPadding) } } - // Child controls consume their own taps, so this only sees taps on - // empty space: tapping outside the minutes wheel collapses it. + // Only sees taps on empty space; collapses open wheels. .contentShape(Rectangle()) .onTapGesture { withAnimation { @@ -146,9 +96,7 @@ struct AutomationsView: View { } } .navigationBarHidden(true) - .onAppear(perform: loadFingerActions) .onChange(of: wiperOff) { _, newValue in - // Persisted by @AppStorage; arm/disarm the firmware immediately. if let manager = connectionVM.bleManager { VehicleControl.sendWiperOff(manager, enabled: newValue) } @@ -162,7 +110,7 @@ struct AutomationsView: View { } } .onChange(of: climateKeepMinutes) { _, newValue in - // Debounced: the wheel fires once per detent while spinning. + // Debounced: the wheel fires once per detent. minutesPushTask?.cancel() minutesPushTask = Task { try? await Task.sleep(for: .milliseconds(400)) @@ -190,99 +138,11 @@ struct AutomationsView: View { } } } - .onChange(of: fingerActions) { oldValue, newValue in - saveFingerActions() - pushChangedBindings(from: oldValue, to: newValue) - } - } - - /// Pushes every finger slot whose binding changed, clearing freed slots - /// with GESTURE_ACTION_NONE (covers add, remove, count and control edits — - /// the same slots Android's setFingerAction/changeFingerCount push). - private func pushChangedBindings(from old: [FingerAction], to new: [FingerAction]) { - guard let manager = connectionVM.bleManager else { return } - let oldByCount = Dictionary(uniqueKeysWithValues: old.map { ($0.fingerCount, $0.controlId) }) - let newByCount = Dictionary(uniqueKeysWithValues: new.map { ($0.fingerCount, $0.controlId) }) - for fingers in fingerCounts { - let oldId = oldByCount[fingers] - let newId = newByCount[fingers] - guard oldId != newId else { continue } - let actionValue = newId.flatMap { controlById($0)?.gestureValue } - ?? VehicleControl.gestureActionNone - VehicleControl.sendFingerAction(manager, fingers: fingers, actionValue: actionValue) - } - } - - // MARK: - Finger action state - - /// Counts this row may pick: its own current value plus any count not used - /// by another row. - private func fingerOptions(for action: FingerAction) -> [Int] { - let used = Set(fingerActions.map(\.fingerCount)) - return fingerCounts.filter { $0 == action.fingerCount || !used.contains($0) } - } - - private func firstFreeFingerCount() -> Int? { - let used = Set(fingerActions.map(\.fingerCount)) - return fingerCounts.first { !used.contains($0) } - } - - private func addAction(fingerCount: Int) { - guard let firstControl = vehicleControls.first else { return } - fingerActions.append(FingerAction(fingerCount: fingerCount, controlId: firstControl.id)) - } - - private func removeAction(_ action: FingerAction) { - fingerActions.removeAll { $0.id == action.id } - } - - // MARK: - Persistence (UserDefaults stub) - - /// Loads bindings from UserDefaults ("3=glovebox;4=frunk" format). - private func loadFingerActions() { - let raw = UserDefaults.standard.string(forKey: fingerActionsKey) ?? "" - fingerActions = Self.parseFingerActions(raw) - } - - /// Persists bindings. Called from `.onChange(of: fingerActions)` so every - /// add/remove/edit saves; the BLE push happens in `pushChangedBindings`. - private func saveFingerActions() { - let raw = Self.serializeFingerActions(fingerActions) - UserDefaults.standard.set(raw, forKey: fingerActionsKey) - } - - /// Parses "3=glovebox;4=frunk" into bindings, dropping malformed entries, - /// counts outside 3...5, unknown control ids, and duplicate counts. - static func parseFingerActions(_ raw: String) -> [FingerAction] { - var seen = Set() - var result: [FingerAction] = [] - for entry in raw.split(separator: ";") { - let parts = entry.split(separator: "=", maxSplits: 1) - guard parts.count == 2, - let fingers = Int(parts[0]), - fingerCounts.contains(fingers), - !seen.contains(fingers), - controlById(String(parts[1])) != nil - else { continue } - seen.insert(fingers) - result.append(FingerAction(fingerCount: fingers, controlId: String(parts[1]))) - } - return result.sorted { $0.fingerCount < $1.fingerCount } } - /// Serializes bindings as "3=glovebox;4=frunk" (sorted by finger count). - static func serializeFingerActions(_ actions: [FingerAction]) -> String { - actions - .sorted { $0.fingerCount < $1.fingerCount } - .map { "\($0.fingerCount)=\($0.controlId)" } - .joined(separator: ";") - } } -// MARK: - Section label - -/// Small muted section heading (Android `SectionLabel`). -private struct SectionLabel: View { +struct SectionLabel: View { let text: LocalizedStringKey init(_ text: LocalizedStringKey) { @@ -297,10 +157,6 @@ private struct SectionLabel: View { } } -// MARK: - Automation row - -/// A `.dashSurface` card with an icon, title/subtitle, a trailing toggle, and -/// an optional footer rendered inside the same card (Android `AutomationRow`). struct AutomationRow: View { let icon: String let title: LocalizedStringKey @@ -371,8 +227,6 @@ extension AutomationRow where Footer == EmptyView { } } -// MARK: - AC swing - struct AcSwingRow: View { @Environment(ConnectionViewModel.self) private var connectionVM @@ -413,37 +267,43 @@ struct ChoiceFooter: View { .foregroundColor(.dashTextMuted) .font(.system(size: 14)) - HStack(spacing: 3) { - ForEach(options.indices, id: \.self) { index in - let isSelected = index == selection - Button { - selection = index - } label: { - Text(options[index]) - .font(.system(size: 14, weight: isSelected ? .semibold : .regular)) - .foregroundColor(isSelected ? .white : .dashTextMuted) - .lineLimit(1) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(isSelected ? Color.dashAccent : Color.clear) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .buttonStyle(.plain) - } - } - .padding(3) - .background(Color.dashBackground) - .clipShape(RoundedRectangle(cornerRadius: DashMetrics.smallCorner)) + SegmentedSelector(options: options, selection: $selection) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 12) } } -// MARK: - Value picker footer +struct SegmentedSelector: View { + let options: [LocalizedStringKey] + @Binding var selection: Int + var background: Color = .dashBackground + + var body: some View { + HStack(spacing: 3) { + ForEach(options.indices, id: \.self) { index in + let isSelected = index == selection + Button { + selection = index + } label: { + Text(options[index]) + .font(.system(size: 14, weight: isSelected ? .semibold : .regular)) + .foregroundColor(isSelected ? .white : .dashTextMuted) + .lineLimit(1) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(isSelected ? Color.dashAccent : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + } + .padding(3) + .background(background) + .clipShape(RoundedRectangle(cornerRadius: DashMetrics.smallCorner)) + } +} -/// "Label N unit" line inside an automation card; tapping the value expands a -/// wheel over `options` (Android `NumberPickerFooter`). struct ValuePickerFooter: View { let label: LocalizedStringKey let unit: String @@ -495,117 +355,6 @@ struct ValuePickerFooter: View { } } -// MARK: - Finger action row - -/// One multi-finger binding row: finger-count picker, control picker, and a -/// remove button (Android `FingerActionRow`). -private struct FingerActionRow: View { - @Binding var action: FingerAction - let fingerOptions: [Int] - let onRemove: () -> Void - - var body: some View { - HStack(spacing: 6) { - DropdownPicker( - selectedLabel: "\(action.fingerCount)", - options: fingerOptions, - optionLabel: { "\($0)" }, - onSelect: { action.fingerCount = $0 } - ) - - Text("fingers") - .foregroundColor(.dashTextMuted) - .font(.system(size: 14)) - .padding(.trailing, 4) - - Text("action:") - .foregroundColor(.dashTextMuted) - .font(.system(size: 14)) - - DropdownPicker( - selectedLabel: controlById(action.controlId)?.label() ?? action.controlId, - options: vehicleControls, - optionLabel: { $0.label() }, - onSelect: { action.controlId = $0.id } - ) - .frame(maxWidth: .infinity, alignment: .leading) - - Button(action: onRemove) { - Image(systemName: "xmark") - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.dashTextMuted) - .frame(width: 32, height: 32) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(Color.dashSurface) - .clipShape(RoundedRectangle(cornerRadius: DashMetrics.corner)) - } -} - -// MARK: - Dropdown picker - -/// A compact `Menu`-based dropdown styled as a `.dashBackground` pill, meant to -/// sit inside a `.dashSurface` row (Android `DropdownPicker`). -private struct DropdownPicker: View { - let selectedLabel: String - let options: [T] - let optionLabel: (T) -> String - let onSelect: (T) -> Void - - var body: some View { - Menu { - ForEach(options.indices, id: \.self) { index in - Button(optionLabel(options[index])) { - onSelect(options[index]) - } - } - } label: { - HStack(spacing: 2) { - Text(selectedLabel) - .foregroundColor(.white) - .font(.system(size: 15)) - .lineLimit(1) - Image(systemName: "chevron.down") - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(.dashTextMuted) - } - .padding(.leading, 12) - .padding(.trailing, 8) - .padding(.vertical, 10) - .background(Color.dashBackground) - .clipShape(RoundedRectangle(cornerRadius: DashMetrics.smallCorner)) - } - } -} - -// MARK: - Add trigger button - -/// Accent-colored "+ Add trigger" button (Android `AddTriggerButton`). -private struct AddTriggerButton: View { - let onTap: () -> Void - - init(onTap: @escaping () -> Void) { - self.onTap = onTap - } - - var body: some View { - Button(action: onTap) { - HStack(spacing: 8) { - Image(systemName: "plus") - .font(.system(size: 18, weight: .semibold)) - .foregroundColor(.dashAccent) - Text("Add trigger") - .foregroundColor(.dashAccent) - .font(.system(size: 15, weight: .semibold)) - } - .padding(.horizontal, 4) - .padding(.vertical, 8) - } - } -} - #Preview { NavigationStack { AutomationsView() diff --git a/dashpilot-ios/dashpilot/UI/ControlsView.swift b/dashpilot-ios/dashpilot/UI/ControlsView.swift index 10803fe..ce0635f 100644 --- a/dashpilot-ios/dashpilot/UI/ControlsView.swift +++ b/dashpilot-ios/dashpilot/UI/ControlsView.swift @@ -1,17 +1,11 @@ import SwiftUI -/// Vehicle control screen. Exposes the available DashKit commands as single -/// toggle/action buttons. Long-pressing a control pins it to the home screen. -/// -/// Ported from Android `ControlScreen` (ControlScreen.kt). Commands are sent -/// over the live DashKit BLE link; without one the buttons are dimmed (in -/// debug builds they stay enabled and simulate, like Android). struct ControlsView: View { - @Environment(\.dismiss) var dismiss @Environment(ConnectionViewModel.self) var connectionVM @AppStorage("pinned_control_id") var pinnedControlId: String = "" + @State private var selectedTab = 0 private var isConnected: Bool { connectionVM.bleManager != nil && connectionVM.connectionStatus == .connected @@ -34,35 +28,19 @@ struct ControlsView: View { VStack(alignment: .leading, spacing: 0) { ScreenHeader(title: "Controls") { dismiss() } - if !isConnected { - Spacer().frame(height: 4) - Text("Connect to DashKit to send commands") - .foregroundColor(.dashTextMuted) - .font(.system(size: 13)) - .frame(maxWidth: .infinity) - .multilineTextAlignment(.center) - } - + Spacer().frame(height: 16) + SegmentedSelector( + options: ["Controls", "Multi-touch"], + selection: $selectedTab, + background: .dashSurface + ) Spacer().frame(height: 24) - VStack(spacing: 12) { - ForEach(vehicleControls) { control in - ControlActionButton( - action: control, - pinned: control.id == pinnedControlId, - enabled: controlsEnabled, - onTap: { control.perform(connectionVM.bleManager) }, - onLongPress: { togglePin(control.id) } - ) - } + if selectedTab == 0 { + controlsTab + } else { + MultiTouchSection() } - - Spacer().frame(height: 20) - Text("Tip: long-press a control to pin it to the home screen") - .foregroundColor(.dashTextSubtle) - .font(.system(size: 12)) - .frame(maxWidth: .infinity) - .multilineTextAlignment(.center) } .padding(DashMetrics.screenPadding) } @@ -70,10 +48,38 @@ struct ControlsView: View { .navigationBarHidden(true) } - // MARK: - Pinning + private var controlsTab: some View { + VStack(spacing: 0) { + if !isConnected { + Text("Connect to DashKit to send commands") + .foregroundColor(.dashTextMuted) + .font(.system(size: 13)) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + Spacer().frame(height: 16) + } + + VStack(spacing: 12) { + ForEach(vehicleControls) { control in + ControlActionButton( + action: control, + pinned: control.id == pinnedControlId, + enabled: controlsEnabled, + onTap: { control.perform(connectionVM.bleManager) }, + onLongPress: { togglePin(control.id) } + ) + } + } + + Spacer().frame(height: 20) + Text("Tip: long-press a control to pin it to the home screen") + .foregroundColor(.dashTextSubtle) + .font(.system(size: 12)) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + } + } - /// Toggles the home-screen pin: clears it when the control is already - /// pinned, otherwise pins this control (replacing any previous pin). private func togglePin(_ id: String) { pinnedControlId = (pinnedControlId == id) ? "" : id } diff --git a/dashpilot-ios/dashpilot/UI/HomeView.swift b/dashpilot-ios/dashpilot/UI/HomeView.swift index 1d7f2ae..545ad86 100644 --- a/dashpilot-ios/dashpilot/UI/HomeView.swift +++ b/dashpilot-ios/dashpilot/UI/HomeView.swift @@ -1,10 +1,6 @@ import SwiftUI -/// Landing screen ported from the Android app's `ConnectedHomeContent` -/// (HomeScreen.kt): vehicle info widget grid + pinned control + primary -/// actions. No Bluetooth yet — widgets show "—" until live data arrives. struct HomeView: View { - @Binding var navigationPath: NavigationPath @Environment(ConnectionViewModel.self) var connectionVM @@ -36,9 +32,6 @@ struct HomeView: View { } .navigationBarHidden(true) .task { - // Each appearance opens its own subscription (with the latest - // state replayed), so navigating away cancels only this view's - // stream — the pipeline keeps feeding the other views. for await state in connectionVM.dashStateStream() { dash = state } @@ -50,8 +43,6 @@ struct HomeView: View { } } - // MARK: - Layouts - private var portraitContent: some View { VStack(spacing: 0) { widgetGrid(fillHeight: false) @@ -92,8 +83,6 @@ struct HomeView: View { .frame(minHeight: minHeight) } - // MARK: - Header - private func header(showDataSource: Bool) -> some View { ZStack { HStack { @@ -118,8 +107,6 @@ struct HomeView: View { } } - // MARK: - Widget grid - private func widgetGrid(fillHeight: Bool) -> some View { VStack(spacing: DashMetrics.gridGap) { HStack(spacing: DashMetrics.gridGap) { @@ -168,8 +155,6 @@ struct HomeView: View { } } - // MARK: - Pinned control - private var pinnedControl: ControlAction? { pinnedControlId.isEmpty ? nil : controlById(pinnedControlId) } @@ -199,8 +184,6 @@ struct HomeView: View { ) } - // MARK: - Actions - private var actionButtons: some View { VStack(spacing: 12) { ActionButton(label: "Automations", icon: "sparkles") { @@ -227,8 +210,6 @@ struct HomeView: View { } } - // MARK: - Formatters (ported from HomeScreen.kt) - private var socText: String { guard let car = dash?.carState else { return "—" } let fullPack = car.fullPackEnergy @@ -260,20 +241,8 @@ struct HomeView: View { } } -// MARK: - Data source menu - -/// Centered "Data source" menu below the actions (port of the Android -/// `DebugDataSourceMenu`): pick a source while idle, disconnect while a -/// session is up. Selecting DashKit retries the BLE link; comma leads to -/// the WiFi connect/setup flow. -/// -/// Kept as its own view on purpose: its body depends only on -/// `connectionStatus`, not on the live dash state. Inside `HomeView`'s body -/// the ~25 Hz dash updates rebuilt the presented `Menu` on every tick, which -/// detached the visible items from their actions — tapping "Disconnect" did -/// nothing. +// Separate view so 25 Hz dash updates don't rebuild the open Menu (taps get dropped). private struct DataSourceMenu: View { - @Binding var navigationPath: NavigationPath var alignment: Alignment = .center @Environment(ConnectionViewModel.self) var connectionVM @@ -327,11 +296,7 @@ private struct DataSourceMenu: View { } } -// MARK: - Info widget - -/// Square-ish stat card: accent icon at top, big value + muted label at bottom. private struct InfoWidget: View { - let icon: String let label: LocalizedStringKey let value: String @@ -371,12 +336,7 @@ private struct InfoWidget: View { } } -// MARK: - Action tile - -/// Landscape counterpart of `ActionButton`: a card with the icon at the top -/// and the label at the bottom, sized to match the stat tiles beside it. private struct ActionTile: View { - let label: LocalizedStringKey let icon: String let action: () -> Void @@ -401,12 +361,7 @@ private struct ActionTile: View { } } -// MARK: - Action button - -/// Full-width primary action button (icon + label). `accent` renders the -/// green accent background; otherwise the standard dark surface. private struct ActionButton: View { - let label: LocalizedStringKey let icon: String var accent: Bool = false diff --git a/dashpilot-ios/dashpilot/UI/MultiTouchSection.swift b/dashpilot-ios/dashpilot/UI/MultiTouchSection.swift new file mode 100644 index 0000000..60557f4 --- /dev/null +++ b/dashpilot-ios/dashpilot/UI/MultiTouchSection.swift @@ -0,0 +1,217 @@ +import SwiftUI + +// Firmware MULTI_FINGER_MIN/MAX_FINGERS. +private let fingerCounts = 3...5 + +private let fingerActionsKey = "finger_actions" + +struct FingerAction: Identifiable, Equatable { + let id = UUID() + var fingerCount: Int + var controlId: String +} + +struct MultiTouchSection: View { + @Environment(ConnectionViewModel.self) private var connectionVM + + @State private var fingerActions: [FingerAction] = [] + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SectionLabel("Multi-touch infotainment trigger") + Spacer().frame(height: 4) + Text("Bind 3-, 4-, or 5-finger infotainment taps to a control") + .foregroundColor(.dashTextMuted) + .font(.system(size: 13)) + .padding(.leading, 4) + Spacer().frame(height: 12) + + ForEach($fingerActions) { $action in + FingerActionRow( + action: $action, + fingerOptions: fingerOptions(for: action), + onRemove: { removeAction(action) } + ) + Spacer().frame(height: 12) + } + + if let nextFree = firstFreeFingerCount() { + AddTriggerButton { + addAction(fingerCount: nextFree) + } + } + } + .onAppear(perform: loadFingerActions) + .onChange(of: fingerActions) { oldValue, newValue in + saveFingerActions() + pushChangedBindings(from: oldValue, to: newValue) + } + } + + private func pushChangedBindings(from old: [FingerAction], to new: [FingerAction]) { + guard let manager = connectionVM.bleManager else { return } + let oldByCount = Dictionary(uniqueKeysWithValues: old.map { ($0.fingerCount, $0.controlId) }) + let newByCount = Dictionary(uniqueKeysWithValues: new.map { ($0.fingerCount, $0.controlId) }) + for fingers in fingerCounts { + let oldId = oldByCount[fingers] + let newId = newByCount[fingers] + guard oldId != newId else { continue } + let actionValue = newId.flatMap { controlById($0)?.gestureValue } + ?? VehicleControl.gestureActionNone + VehicleControl.sendFingerAction(manager, fingers: fingers, actionValue: actionValue) + } + } + + private func fingerOptions(for action: FingerAction) -> [Int] { + let used = Set(fingerActions.map(\.fingerCount)) + return fingerCounts.filter { $0 == action.fingerCount || !used.contains($0) } + } + + private func firstFreeFingerCount() -> Int? { + let used = Set(fingerActions.map(\.fingerCount)) + return fingerCounts.first { !used.contains($0) } + } + + private func addAction(fingerCount: Int) { + guard let firstControl = vehicleControls.first else { return } + fingerActions.append(FingerAction(fingerCount: fingerCount, controlId: firstControl.id)) + } + + private func removeAction(_ action: FingerAction) { + fingerActions.removeAll { $0.id == action.id } + } + + private func loadFingerActions() { + let raw = UserDefaults.standard.string(forKey: fingerActionsKey) ?? "" + fingerActions = Self.parseFingerActions(raw) + } + + private func saveFingerActions() { + let raw = Self.serializeFingerActions(fingerActions) + UserDefaults.standard.set(raw, forKey: fingerActionsKey) + } + + static func parseFingerActions(_ raw: String) -> [FingerAction] { + var seen = Set() + var result: [FingerAction] = [] + for entry in raw.split(separator: ";") { + let parts = entry.split(separator: "=", maxSplits: 1) + guard parts.count == 2, + let fingers = Int(parts[0]), + fingerCounts.contains(fingers), + !seen.contains(fingers), + controlById(String(parts[1])) != nil + else { continue } + seen.insert(fingers) + result.append(FingerAction(fingerCount: fingers, controlId: String(parts[1]))) + } + return result.sorted { $0.fingerCount < $1.fingerCount } + } + + static func serializeFingerActions(_ actions: [FingerAction]) -> String { + actions + .sorted { $0.fingerCount < $1.fingerCount } + .map { "\($0.fingerCount)=\($0.controlId)" } + .joined(separator: ";") + } +} + +private struct FingerActionRow: View { + @Binding var action: FingerAction + let fingerOptions: [Int] + let onRemove: () -> Void + + var body: some View { + HStack(spacing: 6) { + DropdownPicker( + selectedLabel: "\(action.fingerCount)", + options: fingerOptions, + optionLabel: { "\($0)" }, + onSelect: { action.fingerCount = $0 } + ) + + Text("fingers") + .foregroundColor(.dashTextMuted) + .font(.system(size: 14)) + .padding(.trailing, 4) + + Text("action:") + .foregroundColor(.dashTextMuted) + .font(.system(size: 14)) + + DropdownPicker( + selectedLabel: controlById(action.controlId)?.label() ?? action.controlId, + options: vehicleControls, + optionLabel: { $0.label() }, + onSelect: { action.controlId = $0.id } + ) + .frame(maxWidth: .infinity, alignment: .leading) + + Button(action: onRemove) { + Image(systemName: "xmark") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.dashTextMuted) + .frame(width: 32, height: 32) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color.dashSurface) + .clipShape(RoundedRectangle(cornerRadius: DashMetrics.corner)) + } +} + +private struct DropdownPicker: View { + let selectedLabel: String + let options: [T] + let optionLabel: (T) -> String + let onSelect: (T) -> Void + + var body: some View { + Menu { + ForEach(options.indices, id: \.self) { index in + Button(optionLabel(options[index])) { + onSelect(options[index]) + } + } + } label: { + HStack(spacing: 2) { + Text(selectedLabel) + .foregroundColor(.white) + .font(.system(size: 15)) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.dashTextMuted) + } + .padding(.leading, 12) + .padding(.trailing, 8) + .padding(.vertical, 10) + .background(Color.dashBackground) + .clipShape(RoundedRectangle(cornerRadius: DashMetrics.smallCorner)) + } + } +} + +private struct AddTriggerButton: View { + let onTap: () -> Void + + init(onTap: @escaping () -> Void) { + self.onTap = onTap + } + + var body: some View { + Button(action: onTap) { + HStack(spacing: 8) { + Image(systemName: "plus") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.dashAccent) + Text("Add trigger") + .foregroundColor(.dashAccent) + .font(.system(size: 15, weight: .semibold)) + } + .padding(.horizontal, 4) + .padding(.vertical, 8) + } + } +} diff --git a/dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift b/dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift index b692502..c83913d 100644 --- a/dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift +++ b/dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift @@ -153,7 +153,7 @@ final class ConnectionViewModel { func resyncDashKitAutomations() { guard let manager = bleManager else { return } let raw = UserDefaults.standard.string(forKey: "finger_actions") ?? "" - let actions = AutomationsView.parseFingerActions(raw) + let actions = MultiTouchSection.parseFingerActions(raw) let byCount = Dictionary(uniqueKeysWithValues: actions.map { ($0.fingerCount, $0.controlId) }) for fingers in 3...5 { let actionValue = byCount[fingers].flatMap { controlById($0)?.gestureValue }