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 f5d81b7..003a84b 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 @@ -159,6 +159,8 @@ class MainActivity : ComponentActivity() { val connectionStatus by connectionVM.connectionStatus.collectAsState() val pinnedControl by connectionVM.pinnedControl.collectAsState() val wiperOffAutomation by connectionVM.wiperOffAutomation.collectAsState() + val climateKeepAutomation by connectionVM.climateKeepAutomation.collectAsState() + val climateKeepMinutes by connectionVM.climateKeepMinutes.collectAsState() val fingerActions by connectionVM.fingerActions.collectAsState() val navBackStackEntry by navController.currentBackStackEntryAsState() val onDashboard = navBackStackEntry?.destination?.route @@ -282,6 +284,14 @@ class MainActivity : ComponentActivity() { onWiperOffChange = { connectionVM.updateWiperOffAutomation(context, it) }, + climateKeepEnabled = climateKeepAutomation, + onClimateKeepChange = { + connectionVM.updateClimateKeepAutomation(context, it) + }, + climateKeepMinutes = climateKeepMinutes, + onClimateKeepMinutesChange = { + connectionVM.updateClimateKeepMinutes(context, it) + }, fingerActions = fingerActions, onSetFingerAction = { fingers, id -> connectionVM.setFingerAction(context, fingers, id) diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/VehicleControl.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/VehicleControl.kt index 52f5da7..7296ccd 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/VehicleControl.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/VehicleControl.kt @@ -76,6 +76,16 @@ object VehicleControl { // the firmware drops the link after 60 s of silence. Value is ignored. const val CMD_PING: Int = 0x45 + // --- Keep climate on after leaving the car (UI_hvacRequest 0x2F3) --- + // Arms/disarms the firmware's climate-keep automation. 1=enable, 0=disable. + // The firmware persists it in NVS (climate_keep module). + const val CMD_CLIMATE_KEEP_ENABLE: Int = 0x46 + + // --- Climate-keep duration --- + // Minutes the climate-keep automation runs after the driver leaves. + // The firmware clamps to 1..60 and persists it in NVS. + const val CMD_CLIMATE_KEEP_DURATION: Int = 0x47 + /** Bind (or clear, with actionValue 0) an N-finger tap to a control action. */ fun sendFingerAction(manager: DashKitBleManager, fingers: Int, actionValue: Int): Boolean = send(manager, CMD_MULTI_FINGER_ACTION, (fingers shl 8) or (actionValue and 0xFF)) @@ -84,6 +94,14 @@ object VehicleControl { fun sendWiperOff(manager: DashKitBleManager, enabled: Boolean): Boolean = send(manager, CMD_WIPER_OFF_ENABLE, if (enabled) 1 else 0) + /** Enable or disable the firmware's keep-climate-on automation. */ + fun sendClimateKeep(manager: DashKitBleManager, enabled: Boolean): Boolean = + send(manager, CMD_CLIMATE_KEEP_ENABLE, if (enabled) 1 else 0) + + /** Set how many minutes the keep-climate-on automation runs. */ + fun sendClimateKeepDuration(manager: DashKitBleManager, minutes: Int): Boolean = + send(manager, CMD_CLIMATE_KEEP_DURATION, minutes) + /** Ask the DashKit to open a pairing window for one new device. */ fun sendEnterPairing(manager: DashKitBleManager): Boolean { val ok = send(manager, CMD_ENTER_PAIRING, 1) diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datamodel/dash/DashboardPrefs.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datamodel/dash/DashboardPrefs.kt index 36f19bc..df9bb96 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datamodel/dash/DashboardPrefs.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datamodel/dash/DashboardPrefs.kt @@ -22,11 +22,15 @@ const val PREF_PINNED_CONTROL = "pinned_control" // Automations const val PREF_WIPER_OFF_AUTOMATION = "wiper_off_automation" +const val PREF_CLIMATE_KEEP_AUTOMATION = "climate_keep_automation" +const val PREF_CLIMATE_KEEP_MINUTES = "climate_keep_minutes" // Map of finger count (3..5) -> control id, serialized as "3=glovebox;4=frunk". const val PREF_FINGER_ACTIONS = "finger_actions" // Legacy single three-finger binding, migrated into PREF_FINGER_ACTIONS. const val PREF_THREE_FINGER_ACTION = "three_finger_action" const val DEFAULT_WIPER_OFF_AUTOMATION = false +const val DEFAULT_CLIMATE_KEEP_AUTOMATION = false +const val DEFAULT_CLIMATE_KEEP_MINUTES = 5 // Display settings defaults const val DEFAULT_SHOW_PHONE_BATTERY = true @@ -111,6 +115,24 @@ fun setWiperOffAutomation(context: Context, value: Boolean) { .edit { putBoolean(PREF_WIPER_OFF_AUTOMATION, value) } } +fun getClimateKeepAutomation(context: Context): Boolean = + context.getSharedPreferences(DASH_PREFS_NAME, Context.MODE_PRIVATE) + .getBoolean(PREF_CLIMATE_KEEP_AUTOMATION, DEFAULT_CLIMATE_KEEP_AUTOMATION) + +fun setClimateKeepAutomation(context: Context, value: Boolean) { + context.getSharedPreferences(DASH_PREFS_NAME, Context.MODE_PRIVATE) + .edit { putBoolean(PREF_CLIMATE_KEEP_AUTOMATION, value) } +} + +fun getClimateKeepMinutes(context: Context): Int = + context.getSharedPreferences(DASH_PREFS_NAME, Context.MODE_PRIVATE) + .getInt(PREF_CLIMATE_KEEP_MINUTES, DEFAULT_CLIMATE_KEEP_MINUTES) + +fun setClimateKeepMinutes(context: Context, value: Int) { + context.getSharedPreferences(DASH_PREFS_NAME, Context.MODE_PRIVATE) + .edit { putInt(PREF_CLIMATE_KEEP_MINUTES, value) } +} + /** * Returns the bindings of finger count (3..5) -> control id. Performs a one-time * migration of the legacy single three-finger binding into the new map. 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 7b39fd9..549fc7d 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 @@ -1,5 +1,6 @@ package com.softwiredtech.dashpilot.ui +import android.widget.NumberPicker import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -16,10 +17,12 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AcUnit 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.WaterDrop +import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon @@ -27,8 +30,10 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -41,6 +46,7 @@ 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 @@ -50,6 +56,9 @@ import com.softwiredtech.dashpilot.ui.theme.DarkColors // 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). +private val CLIMATE_KEEP_MINUTE_RANGE = 1..60 + /** * 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 @@ -61,6 +70,10 @@ private val FINGER_COUNTS = 3..5 fun AutomationsScreen( wiperOffEnabled: Boolean, onWiperOffChange: (Boolean) -> Unit, + climateKeepEnabled: Boolean, + onClimateKeepChange: (Boolean) -> Unit, + climateKeepMinutes: Int, + onClimateKeepMinutesChange: (Int) -> Unit, fingerActions: Map, onSetFingerAction: (fingers: Int, id: String?) -> Unit, onChangeFingerCount: (from: Int, to: Int) -> Unit, @@ -95,6 +108,26 @@ fun AutomationsScreen( Spacer(modifier = Modifier.height(28.dp)) + SectionLabel("Climate") + Spacer(modifier = Modifier.height(8.dp)) + AutomationRow( + icon = Icons.Rounded.AcUnit, + title = "Keep climate on", + subtitle = "Keep the climate on when you leave the car. The automation stops after the set time, or when you return to the car.", + checked = climateKeepEnabled, + onToggle = { onClimateKeepChange(!climateKeepEnabled) }, + extraContent = if (climateKeepEnabled) { + { + ClimateKeepDurationFooter( + minutes = climateKeepMinutes, + onMinutesChange = onClimateKeepMinutesChange + ) + } + } else null + ) + + Spacer(modifier = Modifier.height(28.dp)) + SectionLabel("Multi-touch infotainment trigger") Spacer(modifier = Modifier.height(4.dp)) Text( @@ -141,6 +174,74 @@ private fun SectionLabel(text: String) { ) } +@Composable +private fun ClimateKeepDurationFooter( + minutes: Int, + onMinutesChange: (Int) -> Unit +) { + var showPicker by remember { mutableStateOf(false) } + + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Stop after", + color = DarkColors.TextMuted, + fontSize = 14.sp, + modifier = Modifier.weight(1f) + ) + Row( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(DarkColors.Background) + .clickable { showPicker = true } + .padding(start = 12.dp, end = 6.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "$minutes min", color = Color.White, fontSize = 15.sp) + Icon( + imageVector = Icons.Rounded.ArrowDropDown, + contentDescription = null, + tint = DarkColors.TextMuted, + modifier = Modifier.size(20.dp) + ) + } + } + + if (showPicker) { + var pending by remember { mutableIntStateOf(minutes) } + AlertDialog( + onDismissRequest = { showPicker = false }, + title = { Text("Stop after") }, + text = { + AndroidView( + factory = { ctx -> + NumberPicker(ctx).apply { + minValue = CLIMATE_KEEP_MINUTE_RANGE.first + maxValue = CLIMATE_KEEP_MINUTE_RANGE.last + wrapSelectorWheel = false + value = minutes + setOnValueChangedListener { _, _, value -> pending = value } + } + }, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton(onClick = { + showPicker = false + onMinutesChange(pending) + }) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = { showPicker = false }) { Text("Cancel") } + } + ) + } +} + @Composable private fun FingerActionRow( fingers: Int, @@ -265,58 +366,65 @@ private fun AutomationRow( title: String, subtitle: String?, checked: Boolean, - onToggle: () -> Unit + onToggle: () -> Unit, + extraContent: (@Composable () -> Unit)? = null ) { - Row( + Column( modifier = Modifier .fillMaxWidth() .background(DarkColors.Surface, RoundedCornerShape(16.dp)) - .clickable(onClick = onToggle) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically + .padding(horizontal = 16.dp, vertical = 12.dp) ) { - Box( + Row( modifier = Modifier - .size(36.dp) - .background( - if (checked) AccentColor.copy(alpha = 0.16f) - else Color.White.copy(alpha = 0.08f), - RoundedCornerShape(10.dp) - ), - contentAlignment = Alignment.Center + .fillMaxWidth() + .clickable(onClick = onToggle), + verticalAlignment = Alignment.CenterVertically ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = if (checked) AccentColor else Color.White, - modifier = Modifier.size(20.dp) - ) - } - Spacer(modifier = Modifier.size(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - color = Color.White, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold - ) - if (subtitle != null) { + Box( + modifier = Modifier + .size(36.dp) + .background( + if (checked) AccentColor.copy(alpha = 0.16f) + else Color.White.copy(alpha = 0.08f), + RoundedCornerShape(10.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = if (checked) AccentColor else Color.White, + modifier = Modifier.size(20.dp) + ) + } + Spacer(modifier = Modifier.size(12.dp)) + Column(modifier = Modifier.weight(1f)) { Text( - text = subtitle, - color = DarkColors.TextMuted, - fontSize = 13.sp + text = title, + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold ) + if (subtitle != null) { + Text( + text = subtitle, + color = DarkColors.TextMuted, + fontSize = 13.sp + ) + } } - } - Switch( - checked = checked, - onCheckedChange = { onToggle() }, - colors = SwitchDefaults.colors( - checkedThumbColor = Color.White, - checkedTrackColor = AccentColor, - uncheckedThumbColor = Color.White, - uncheckedTrackColor = DarkColors.Disabled + Switch( + checked = checked, + onCheckedChange = { onToggle() }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = AccentColor, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = DarkColors.Disabled + ) ) - ) + } + extraContent?.invoke() } } 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 5975450..3c62e5e 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 @@ -42,6 +42,11 @@ import com.softwiredtech.dashpilot.datamodel.dash.getFingerActions import com.softwiredtech.dashpilot.datamodel.dash.setFingerActions import com.softwiredtech.dashpilot.datamodel.dash.getWiperOffAutomation import com.softwiredtech.dashpilot.datamodel.dash.setWiperOffAutomation +import com.softwiredtech.dashpilot.datamodel.dash.DEFAULT_CLIMATE_KEEP_MINUTES +import com.softwiredtech.dashpilot.datamodel.dash.getClimateKeepAutomation +import com.softwiredtech.dashpilot.datamodel.dash.setClimateKeepAutomation +import com.softwiredtech.dashpilot.datamodel.dash.getClimateKeepMinutes +import com.softwiredtech.dashpilot.datamodel.dash.setClimateKeepMinutes import com.softwiredtech.dashpilot.ui.controls.controlById import com.softwiredtech.dashpilot.datasource.DataSourceType import com.softwiredtech.dashpilot.datasource.CommaDataSource @@ -105,11 +110,19 @@ class ConnectionViewModel(private var networkUtil: NetworkUtil) : ViewModel() { private val _wiperOffAutomation = MutableStateFlow(false) val wiperOffAutomation = _wiperOffAutomation.asStateFlow() + private val _climateKeepAutomation = MutableStateFlow(false) + val climateKeepAutomation = _climateKeepAutomation.asStateFlow() + + private val _climateKeepMinutes = MutableStateFlow(DEFAULT_CLIMATE_KEEP_MINUTES) + val climateKeepMinutes = _climateKeepMinutes.asStateFlow() + private val _fingerActions = MutableStateFlow>(emptyMap()) val fingerActions = _fingerActions.asStateFlow() fun loadAutomations(context: Context) { _wiperOffAutomation.value = getWiperOffAutomation(context) + _climateKeepAutomation.value = getClimateKeepAutomation(context) + _climateKeepMinutes.value = getClimateKeepMinutes(context) _fingerActions.value = getFingerActions(context) } @@ -123,6 +136,25 @@ class ConnectionViewModel(private var networkUtil: NetworkUtil) : ViewModel() { _bleManager.value?.let { VehicleControl.sendWiperOff(it, enabled) } } + fun updateClimateKeepAutomation(context: Context, value: Boolean) { + setClimateKeepAutomation(context, value) + _climateKeepAutomation.value = value + _bleManager.value?.let { VehicleControl.sendClimateKeep(it, value) } + } + + private var climateKeepMinutesPush: Job? = null + + fun updateClimateKeepMinutes(context: Context, value: Int) { + setClimateKeepMinutes(context, value) + _climateKeepMinutes.value = value + // Debounced: the wheel picker fires once per detent while spinning. + climateKeepMinutesPush?.cancel() + climateKeepMinutesPush = viewModelScope.launch { + delay(400) + _bleManager.value?.let { VehicleControl.sendClimateKeepDuration(it, value) } + } + } + fun setFingerAction(context: Context, fingers: Int, id: String?) { val next = _fingerActions.value.toMutableMap() if (id.isNullOrBlank()) next.remove(fingers) else next[fingers] = id @@ -379,6 +411,14 @@ class ConnectionViewModel(private var networkUtil: NetworkUtil) : ViewModel() { val wiperOff = getWiperOffAutomation(context) _wiperOffAutomation.value = wiperOff VehicleControl.sendWiperOff(mgr, wiperOff) + + // Re-sync the climate-keep automation the same way. + val climateKeep = getClimateKeepAutomation(context) + _climateKeepAutomation.value = climateKeep + VehicleControl.sendClimateKeep(mgr, climateKeep) + val climateKeepMin = getClimateKeepMinutes(context) + _climateKeepMinutes.value = climateKeepMin + VehicleControl.sendClimateKeepDuration(mgr, climateKeepMin) } } } diff --git a/dashpilot-ios/dashpilot/BLE/VehicleControl.swift b/dashpilot-ios/dashpilot/BLE/VehicleControl.swift index 23ea2d7..bc99507 100644 --- a/dashpilot-ios/dashpilot/BLE/VehicleControl.swift +++ b/dashpilot-ios/dashpilot/BLE/VehicleControl.swift @@ -62,6 +62,16 @@ enum VehicleControl { // the firmware drops the link after 60 s of silence. Value is ignored. static let cmdPing = 0x45 + // --- Keep climate on after leaving the car (UI_hvacRequest 0x2F3) --- + // Arms/disarms the firmware's climate-keep automation. 1=enable, + // 0=disable. The firmware persists it in NVS (climate_keep module). + static let cmdClimateKeepEnable = 0x46 + + // --- Climate-keep duration --- + // Minutes the climate-keep automation runs after the driver leaves. + // The firmware clamps to 1..60 and persists it in NVS. + static let cmdClimateKeepDuration = 0x47 + /// Bind (or clear, with actionValue 0) an N-finger tap to a control action. @discardableResult static func sendFingerAction(_ manager: DashKitBleManager, fingers: Int, actionValue: Int) -> Bool { @@ -103,6 +113,18 @@ enum VehicleControl { send(manager, opcode: cmdPing, value: 1) } + /// Enable or disable the firmware's keep-climate-on automation. + @discardableResult + static func sendClimateKeep(_ manager: DashKitBleManager, enabled: Bool) -> Bool { + send(manager, opcode: cmdClimateKeepEnable, value: enabled ? 1 : 0) + } + + /// Set how many minutes the keep-climate-on automation runs. + @discardableResult + static func sendClimateKeepDuration(_ manager: DashKitBleManager, minutes: Int) -> Bool { + send(manager, opcode: cmdClimateKeepDuration, value: minutes) + } + /// Write a control command to the DashKit. Returns true if the write was /// dispatched (not necessarily acknowledged). No-op returning false when /// the link is down or the control characteristic is unavailable. diff --git a/dashpilot-ios/dashpilot/UI/AutomationsView.swift b/dashpilot-ios/dashpilot/UI/AutomationsView.swift index 461d8ac..d6aeeaa 100644 --- a/dashpilot-ios/dashpilot/UI/AutomationsView.swift +++ b/dashpilot-ios/dashpilot/UI/AutomationsView.swift @@ -7,6 +7,9 @@ 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). +private let climateKeepMinuteRange = 1...60 + /// A single multi-finger tap binding: `fingerCount` fingers -> vehicle /// control `controlId`. struct FingerAction: Identifiable, Equatable { @@ -28,7 +31,11 @@ struct AutomationsView: View { @Environment(ConnectionViewModel.self) private var connectionVM @AppStorage("wiper_off_automation") private var wiperOff: Bool = false + @AppStorage("climate_keep_automation") private var climateKeep: Bool = false + @AppStorage("climate_keep_minutes") private var climateKeepMinutes: Int = 5 @State private var fingerActions: [FingerAction] = [] + @State private var minutesPushTask: Task? + @State private var minutesWheelExpanded = false var body: some View { ZStack { @@ -52,6 +59,24 @@ struct AutomationsView: View { Spacer().frame(height: 28) + SectionLabel("Climate") + Spacer().frame(height: 8) + AutomationRow( + icon: "fanblades.fill", + title: "Keep climate on", + subtitle: "Keep the climate on when you leave the car. The automation stops after the set time, or when you return to the car.", + isOn: $climateKeep + ) { + if climateKeep { + ClimateKeepDurationFooter( + minutes: $climateKeepMinutes, + expanded: $minutesWheelExpanded + ) + } + } + + 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") @@ -78,6 +103,12 @@ struct AutomationsView: View { .padding(DashMetrics.screenPadding) } } + // Child controls consume their own taps, so this only sees taps on + // empty space: tapping outside the minutes wheel collapses it. + .contentShape(Rectangle()) + .onTapGesture { + withAnimation { minutesWheelExpanded = false } + } .navigationBarHidden(true) .onAppear(perform: loadFingerActions) .onChange(of: wiperOff) { _, newValue in @@ -86,6 +117,25 @@ struct AutomationsView: View { VehicleControl.sendWiperOff(manager, enabled: newValue) } } + .onChange(of: climateKeep) { _, newValue in + if !newValue { + minutesWheelExpanded = false + } + if let manager = connectionVM.bleManager { + VehicleControl.sendClimateKeep(manager, enabled: newValue) + } + } + .onChange(of: climateKeepMinutes) { _, newValue in + // Debounced: the wheel fires once per detent while spinning. + minutesPushTask?.cancel() + minutesPushTask = Task { + try? await Task.sleep(for: .milliseconds(400)) + guard !Task.isCancelled else { return } + if let manager = connectionVM.bleManager { + VehicleControl.sendClimateKeepDuration(manager, minutes: newValue) + } + } + } .onChange(of: fingerActions) { oldValue, newValue in saveFingerActions() pushChangedBindings(from: oldValue, to: newValue) @@ -195,45 +245,122 @@ private struct SectionLabel: View { // MARK: - Automation row -/// A `.dashSurface` card with an icon, title/subtitle, and a trailing toggle -/// (Android `AutomationRow`). -private struct AutomationRow: View { +/// A `.dashSurface` card with an icon, title/subtitle, a trailing toggle, and +/// an optional footer rendered inside the same card (Android `AutomationRow`). +private struct AutomationRow: View { let icon: String let title: String let subtitle: String? @Binding var isOn: Bool + let footer: Footer + + init( + icon: String, + title: String, + subtitle: String?, + isOn: Binding, + @ViewBuilder footer: () -> Footer + ) { + self.icon = icon + self.title = title + self.subtitle = subtitle + self._isOn = isOn + self.footer = footer() + } var body: some View { - HStack(spacing: 12) { - IconChip( - systemName: icon, - tint: isOn ? .dashAccent : .white, - background: isOn ? Color.dashAccent.opacity(0.16) : Color.white.opacity(0.08) - ) - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .foregroundColor(.white) - .font(.system(size: 16, weight: .semibold)) - if let subtitle { - Text(subtitle) - .foregroundColor(.dashTextMuted) - .font(.system(size: 13)) + VStack(spacing: 0) { + HStack(spacing: 12) { + IconChip( + systemName: icon, + tint: isOn ? .dashAccent : .white, + background: isOn ? Color.dashAccent.opacity(0.16) : Color.white.opacity(0.08) + ) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .foregroundColor(.white) + .font(.system(size: 16, weight: .semibold)) + if let subtitle { + Text(subtitle) + .foregroundColor(.dashTextMuted) + .font(.system(size: 13)) + } } + .frame(maxWidth: .infinity, alignment: .leading) + + Toggle("", isOn: $isOn) + .labelsHidden() + .tint(.dashAccent) + } + .contentShape(Rectangle()) + .onTapGesture { + isOn.toggle() } - .frame(maxWidth: .infinity, alignment: .leading) - Toggle("", isOn: $isOn) - .labelsHidden() - .tint(.dashAccent) + footer } .padding(.horizontal, 16) .padding(.vertical, 12) .background(Color.dashSurface) .clipShape(RoundedRectangle(cornerRadius: DashMetrics.corner)) - .contentShape(RoundedRectangle(cornerRadius: DashMetrics.corner)) - .onTapGesture { - isOn.toggle() + } +} + +extension AutomationRow where Footer == EmptyView { + init(icon: String, title: String, subtitle: String?, isOn: Binding) { + self.init(icon: icon, title: title, subtitle: subtitle, isOn: isOn) { EmptyView() } + } +} + +// MARK: - Climate keep duration footer + +/// "Stop after N min" line inside the keep-climate-on card; tapping the value +/// expands a minutes wheel (Android `ClimateKeepDurationFooter`). +private struct ClimateKeepDurationFooter: View { + @Binding var minutes: Int + @Binding var expanded: Bool + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Stop after") + .foregroundColor(.dashTextMuted) + .font(.system(size: 14)) + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + withAnimation { expanded.toggle() } + } label: { + HStack(spacing: 2) { + Text("\(minutes) min") + .foregroundColor(.white) + .font(.system(size: 15)) + Image(systemName: "chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.dashTextMuted) + .rotationEffect(.degrees(expanded ? 180 : 0)) + } + .padding(.leading, 12) + .padding(.trailing, 8) + .padding(.vertical, 8) + .background(Color.dashBackground) + .clipShape(RoundedRectangle(cornerRadius: DashMetrics.smallCorner)) + } + } + .padding(.top, 10) + + if expanded { + Picker("", selection: $minutes) { + ForEach(climateKeepMinuteRange, id: \.self) { value in + Text("\(value) min").tag(value) + } + } + .pickerStyle(.wheel) + .frame(height: 120) + .frame(maxWidth: .infinity) + .environment(\.colorScheme, .dark) + } } } } diff --git a/dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift b/dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift index 95583cc..c87e1f6 100644 --- a/dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift +++ b/dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift @@ -159,6 +159,10 @@ final class ConnectionViewModel { } let wiperOff = UserDefaults.standard.bool(forKey: "wiper_off_automation") VehicleControl.sendWiperOff(manager, enabled: wiperOff) + let climateKeep = UserDefaults.standard.bool(forKey: "climate_keep_automation") + VehicleControl.sendClimateKeep(manager, enabled: climateKeep) + let climateKeepMinutes = UserDefaults.standard.integer(forKey: "climate_keep_minutes") + VehicleControl.sendClimateKeepDuration(manager, minutes: climateKeepMinutes > 0 ? climateKeepMinutes : 5) } // MARK: - Comma (WiFi)