diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2c46738c9..f92a4b8d5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -368,6 +368,12 @@ android:label="@string/diy_editor_new_title" android:theme="@style/Theme.Essentials" /> + + { Icon( painter = painterResource(id = R.drawable.rounded_add_24), - contentDescription = stringResource(R.string.diy_editor_new_title), + contentDescription = + stringResource( + if (isDiyMacrosShown) { + R.string.diy_new_macro + } else { + R.string.diy_editor_new_title + }, + ), ) } } @@ -908,6 +916,9 @@ class MainActivity : AppCompatActivity() { onNewAutomationClick = { showNewAutomationSheet = true }, + onShowMacrosChange = { + isDiyMacrosShown = it + }, ) } } diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt index fc5d67bcd..139bf6a04 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt @@ -168,6 +168,14 @@ sealed interface Action { override val isConfigurable: Boolean get() = true } + @Keep + data class RunMacro( + @SerializedName("macroId") val macroId: String = "", + ) : Action { + override val title: Int get() = R.string.diy_action_run_macro + override val icon: Int get() = R.drawable.rounded_bolt_24 + } + @Keep data object TurnOffLowPower : Action { override val title: Int = R.string.diy_action_low_power_off diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/ActionRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/ActionRegistry.kt index 46a57cb85..82246bf37 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/diy/ActionRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/ActionRegistry.kt @@ -142,4 +142,10 @@ object ActionRegistry { ), ), ) + getCategories(sdkInt, screenOnOnly = true) + + // Saved macros as RunMacro actions, or null when there are none + fun getMacroCategory(): ActionCategory? = + DIYRepository.macros.value + .takeIf { it.isNotEmpty() } + ?.let { macros -> ActionCategory(R.string.diy_category_macros, macros.map { Action.RunMacro(it.id) }) } } diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/DIYRepository.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/DIYRepository.kt index 8fdb2a0e9..ed3e0fc56 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/diy/DIYRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/DIYRepository.kt @@ -17,6 +17,7 @@ import com.google.gson.JsonDeserializer import com.google.gson.JsonElement import com.google.gson.JsonSerializationContext import com.google.gson.JsonSerializer +import com.google.gson.reflect.TypeToken import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlin.reflect.KClass @@ -24,10 +25,14 @@ import kotlin.reflect.KClass object DIYRepository { private const val PREFS_NAME = "diy_automations_prefs" private const val KEY_AUTOMATIONS = "saved_automations" + private const val KEY_MACROS = "saved_macros" private val _automations = MutableStateFlow>(emptyList()) val automations = _automations.asStateFlow() + private val _macros = MutableStateFlow>(emptyList()) + val macros = _macros.asStateFlow() + private var prefs: SharedPreferences? = null private val gson = GsonBuilder() @@ -80,6 +85,7 @@ object DIYRepository { if (prefs != null) return prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) reloadAutomations() + reloadMacros() } fun reloadAutomations() { @@ -133,6 +139,56 @@ object DIYRepository { fun getAutomation(id: String): Automation? = _automations.value.find { it.id == id } + fun reloadMacros() { + val json = prefs?.getString(KEY_MACROS, null) + _macros.value = if (json != null) macrosFromJson(json) else emptyList() + } + + private fun saveMacrosToPrefs() { + val json = macrosToJson(_macros.value) + prefs?.edit()?.putString(KEY_MACROS, json)?.apply() + } + + fun addMacro(macro: Macro) { + val current = _macros.value.toMutableList() + current.add(macro) + _macros.value = current + saveMacrosToPrefs() + } + + fun updateMacro(macro: Macro) { + val current = _macros.value.toMutableList() + val index = current.indexOfFirst { it.id == macro.id } + if (index != -1) { + current[index] = macro + _macros.value = current + saveMacrosToPrefs() + } + } + + fun removeMacro(id: String) { + val current = _macros.value.toMutableList() + current.removeAll { it.id == id } + _macros.value = current + saveMacrosToPrefs() + } + + fun getMacro(id: String): Macro? = _macros.value.find { it.id == id } + + internal fun macrosToJson(macros: List): String = gson.toJson(macros) + + internal fun macrosFromJson(json: String): List = + try { + val type = object : TypeToken>() {}.type + val list: List = gson.fromJson(json, type) ?: emptyList() + // Gson puts nulls in the list for unknown types despite the non-null type + list.filterNotNull().map { macro -> + macro.copy(actions = macro.actionList.filterNotNull()) + } + } catch (_: Exception) { + emptyList() + } + private fun updateActionShortcutLauncherState() { val context = appContext ?: return val showLauncher = diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/Macro.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/Macro.kt new file mode 100644 index 000000000..4ca171c50 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/Macro.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Domain Layer Models & Registries + * File: Macro.kt + * Description: Domain model and business logic entry for Macro.kt. + */ + +package com.sameerasw.essentials.domain.diy + +import android.content.Context +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName +import com.sameerasw.essentials.R + +@Keep +data class Macro( + @SerializedName("id") val id: String, + @SerializedName("name") val name: String, + @SerializedName("actions") val actions: List = emptyList(), +) { + // Gson leaves missing lists null despite the default + @Suppress("USELESS_ELVIS") + val actionList: List get() = actions ?: emptyList() +} + +// RunMacro shows the macro's name; every other action shows its title +fun Action.displayName(context: Context): String = + if (this is Action.RunMacro) { + DIYRepository.getMacro(macroId)?.name ?: context.getString(R.string.diy_macro_deleted_label) + } else { + context.getString(title) + } + +// Macros match only the same macro; other actions match by type so their saved settings carry over +fun Action.matches(other: Action?): Boolean = if (this is Action.RunMacro) this == other else other != null && this::class == other::class diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt index 45dea2875..8b6b8f5f0 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt @@ -41,6 +41,7 @@ import com.sameerasw.essentials.domain.controller.HilightController import com.sameerasw.essentials.domain.controller.HotspotController import com.sameerasw.essentials.domain.controller.ZenModeController import com.sameerasw.essentials.domain.diy.Action +import com.sameerasw.essentials.domain.diy.DIYRepository import com.sameerasw.essentials.domain.model.DashConfig import com.sameerasw.essentials.domain.model.NotificationLightingStyle import com.sameerasw.essentials.domain.model.RippleConfig @@ -173,6 +174,7 @@ object CombinedActionExecutor { is Action.TurnOffCaffeinate -> CaffeinateController.cancelAll(context) is Action.Wait -> delay(action.seconds * 1000L) is Action.SendNotification -> AutomationNotificationPoster.post(context, action) + is Action.RunMacro -> runMacro(context, action) is Action.TurnOffLowPower -> setLowPowerMode(context, false) is Action.SetChargingMode -> com.sameerasw.essentials.utils.battery.ChargingModeUtil @@ -1035,6 +1037,20 @@ object CombinedActionExecutor { } } + private suspend fun runMacro( + context: Context, + action: Action.RunMacro, + ) { + DIYRepository.init(context) + val macro = DIYRepository.getMacro(action.macroId) + if (macro == null) { + AutomationFailureNotifier.notifyFailure(context, action, context.getString(R.string.diy_macro_not_found)) + return + } + // Macros can't contain macros; skipping them rules out loops from edited JSON + executeAll(context, macro.actionList.filterNot { it is Action.RunMacro }) + } + private fun toggleFlashlight( context: Context, on: Boolean, diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/LockscreenShortcutsHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/LockscreenShortcutsHandler.kt index ed064dab7..d236e0ad5 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/LockscreenShortcutsHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/LockscreenShortcutsHandler.kt @@ -63,6 +63,7 @@ import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.sameerasw.essentials.R import com.sameerasw.essentials.data.repository.SettingsRepository import com.sameerasw.essentials.domain.diy.Action +import com.sameerasw.essentials.domain.diy.displayName import com.sameerasw.essentials.domain.model.LockscreenShortcutSide import com.sameerasw.essentials.domain.model.SystemShortcutsState import com.sameerasw.essentials.island.service.OverlayLifecycleOwner @@ -259,7 +260,7 @@ class LockscreenShortcutsHandler( else -> null } val label = - service.getString(R.string.lockscreen_shortcut_content_desc, app?.label ?: service.getString(action.title)) + service.getString(R.string.lockscreen_shortcut_content_desc, app?.label ?: action.displayName(service)) container.addView( ComposeView(service).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/MacroEditorActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/MacroEditorActivity.kt new file mode 100644 index 000000000..5e615d192 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/MacroEditorActivity.kt @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Application Activities + * File: MacroEditorActivity.kt + * Description: Activity component for MacroEditorActivity.kt. + */ + +package com.sameerasw.essentials.ui.activities + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.LaunchedEffect +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.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.lifecycle.viewmodel.compose.viewModel +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.diy.ActionRegistry +import com.sameerasw.essentials.domain.diy.DIYRepository +import com.sameerasw.essentials.domain.diy.Macro +import com.sameerasw.essentials.ui.components.EssentialsFloatingToolbar +import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.features.system.ActionSequenceEditor +import com.sameerasw.essentials.ui.theme.EssentialsTheme +import com.sameerasw.essentials.utils.HapticUtil +import com.sameerasw.essentials.viewmodels.MainViewModel +import java.util.UUID + +class MacroEditorActivity : ComponentActivity() { + companion object { + private const val EXTRA_MACRO_ID = "macro_id" + + fun createIntent( + context: Context, + macroId: String?, + ): Intent = + Intent(context, MacroEditorActivity::class.java).apply { + putExtra(EXTRA_MACRO_ID, macroId) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + DIYRepository.init(applicationContext) + + val macroId = intent.getStringExtra(EXTRA_MACRO_ID) + val existing = macroId?.let { DIYRepository.getMacro(it) } + val isEditMode = existing != null + + setContent { + val viewModel: MainViewModel = viewModel() + val context = LocalContext.current + val view = LocalView.current + + LaunchedEffect(Unit) { + viewModel.check(context) + } + + val isPitchBlackThemeEnabled by viewModel.isPitchBlackThemeEnabled + EssentialsTheme(pitchBlackTheme = isPitchBlackThemeEnabled) { + var name by rememberSaveable { mutableStateOf(existing?.name.orEmpty()) } + var actions by remember { mutableStateOf(existing?.actionList.orEmpty()) } + var showDiscardDialog by remember { mutableStateOf(false) } + + val handleBackClick = { + showDiscardDialog = true + } + + BackHandler { + handleBackClick() + } + + if (showDiscardDialog) { + AlertDialog( + onDismissRequest = { showDiscardDialog = false }, + title = { Text(stringResource(R.string.diy_discard_warning_title)) }, + text = { Text(stringResource(R.string.diy_discard_warning_desc)) }, + confirmButton = { + TextButton( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + showDiscardDialog = false + finish() + }, + ) { + Text( + text = stringResource(R.string.translation_discard), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + showDiscardDialog = false + }, + ) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + val isValid = name.isNotBlank() && actions.isNotEmpty() + + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ) { _ -> + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding( + start = 16.dp, + end = 16.dp, + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding(), + ), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + RoundedCardContainer { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.diy_macro_name)) }, + singleLine = true, + ) + } + + ActionSequenceEditor( + viewModel = viewModel, + actions = actions, + onActionsChange = { actions = it }, + screenOnOnly = false, + listKey = "macro", + emptyText = stringResource(R.string.diy_no_actions), + categories = remember { ActionRegistry.getCategories() }, + showMacros = false, + ) + + Spacer( + modifier = + Modifier.height( + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 80.dp, + ), + ) + } + + EssentialsFloatingToolbar( + title = + stringResource( + if (isEditMode) R.string.diy_edit_macro else R.string.diy_new_macro, + ), + onBackClick = handleBackClick, + fabAction = + if (isValid) { + { + val macro = + Macro( + id = existing?.id ?: UUID.randomUUID().toString(), + name = name.trim(), + actions = actions, + ) + if (isEditMode) { + DIYRepository.updateMacro(macro) + } else { + DIYRepository.addMacro(macro) + } + finish() + } + } else { + null + }, + fabIconRes = R.drawable.rounded_check_24, + fabContentDescription = stringResource(R.string.action_save), + modifier = + Modifier + .align(Alignment.BottomCenter) + .zIndex(1f), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/automation/DIYScreen.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/DIYScreen.kt index caa0f72d5..a559a399d 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/automation/DIYScreen.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/DIYScreen.kt @@ -9,12 +9,23 @@ package com.sameerasw.essentials.ui.composables +import android.content.Context import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement @@ -22,13 +33,20 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size 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.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon @@ -41,22 +59,29 @@ import androidx.compose.runtime.collectAsState 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.clip import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import android.content.Context -import androidx.compose.foundation.lazy.rememberLazyListState import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.diy.Automation +import com.sameerasw.essentials.domain.diy.Macro import com.sameerasw.essentials.ui.activities.AutomationEditorActivity +import com.sameerasw.essentials.ui.activities.MacroEditorActivity import com.sameerasw.essentials.ui.components.diy.AutomationItem +import com.sameerasw.essentials.ui.components.diy.MacroItem import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.ui.core.sheets.NewAutomationSheet import com.sameerasw.essentials.ui.modifiers.scrollMotionBlur @@ -71,11 +96,19 @@ fun DIYScreen( showNewAutomationSheet: Boolean = false, onDismissNewAutomationSheet: () -> Unit = {}, onNewAutomationClick: (() -> Unit)? = null, + onShowMacrosChange: (Boolean) -> Unit = {}, ) { val context = LocalContext.current + val view = LocalView.current val automations by viewModel.automations.collectAsState() + val macros by viewModel.macros.collectAsState() val focusManager = LocalFocusManager.current + var showMacros by rememberSaveable { mutableStateOf(false) } + + // MainActivity labels its + button for the section shown here + LaunchedEffect(showMacros) { onShowMacrosChange(showMacros) } + var showGenAIPill by remember { mutableStateOf(false) } val genAIState by viewModel.genAIState.collectAsState() @@ -106,186 +139,230 @@ fun DIYScreen( verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.Start, ) { - if (automations.isEmpty()) { - val view = androidx.compose.ui.platform.LocalView.current - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = "No automations yet", - ) - if (onNewAutomationClick != null) { - Spacer(modifier = Modifier.height(16.dp)) - androidx.compose.material3.Button( - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - onNewAutomationClick() - }, - ) { - Text(stringResource(R.string.action_new_automation)) - } - } - } - } - } else { - val (enabledAutomations, disabledAutomations) = - remember(automations) { - automations.partition { it.isEnabled } - } - val isMotionBlurEnabled = - remember(context) { - context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) - .getBoolean("motion_blur", false) - } - val lazyListState = rememberLazyListState() - val view = androidx.compose.ui.platform.LocalView.current + Spacer(modifier = Modifier.height(contentPadding.calculateTopPadding())) + DIYSectionSwitch( + showMacros = showMacros, + onShowMacrosChange = { + exitSelection() + showMacros = it + }, + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = 16.dp), + ) - @Composable - fun AutomationRow(automation: Automation) { - AutomationItem( - automation = automation, - onClick = { - context.startActivity( - AutomationEditorActivity.createIntent( - context, - automation.id, - ), - ) - }, - onDelete = { - viewModel.deleteAutomation(automation.id) + AnimatedContent( + targetState = showMacros, + label = "diy_section", + modifier = Modifier.fillMaxSize(), + transitionSpec = { + val direction = if (targetState) 1 else -1 + val slideSpring = + spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = IntOffset.VisibilityThreshold, + ) + (slideInHorizontally(slideSpring) { width -> direction * width / 3 } + fadeIn()) + .togetherWith( + slideOutHorizontally(slideSpring) { width -> -direction * width / 3 } + fadeOut(), + ).using(SizeTransform(clip = false)) + }, + ) { isMacros -> + if (isMacros) { + MacroList( + macros = macros, + contentPadding = contentPadding, + onNewMacro = { + context.startActivity(MacroEditorActivity.createIntent(context, null)) }, - onToggle = { - viewModel.toggleAutomation(automation.id) + onOpen = { macro -> + context.startActivity(MacroEditorActivity.createIntent(context, macro.id)) }, - onTest = { - viewModel.testAutomation(automation) + onTest = { macro -> + viewModel.testMacro(macro) }, - selectionMode = selectionMode, - selected = automation.id in selectedIds, - onSelectedChange = { checked -> - selectionMode = true - selectedIds = - if (checked) selectedIds + automation.id else selectedIds - automation.id + onDelete = { macro -> + viewModel.deleteMacro(macro.id) }, ) - } - - LazyColumn( - state = lazyListState, - modifier = - Modifier - .fillMaxSize() - .scrollMotionBlur(lazyListState, enabled = isMotionBlurEnabled), - verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = - PaddingValues( - bottom = contentPadding.calculateBottomPadding(), - start = 16.dp, - end = 16.dp, - ), - ) { - item { - Spacer(modifier = Modifier.height(contentPadding.calculateTopPadding())) - } - item { - AnimatedVisibility( - visible = selectionMode, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), + } else if (automations.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, ) { - val selected = automations.filter { it.id in selectedIds } - val hasEnabled = selected.any { it.isEnabled } - val hasDisabled = selected.any { !it.isEnabled } - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = stringResource(R.string.diy_selected_count, selected.size), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 16.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + Text( + text = "No automations yet", + ) + if (onNewAutomationClick != null) { + Spacer(modifier = Modifier.height(16.dp)) + androidx.compose.material3.Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onNewAutomationClick() + }, ) { - SelectionActionButton( - icon = R.drawable.rounded_close_24, - text = stringResource(R.string.action_cancel), - outlined = true, - modifier = Modifier.weight(1f), + Text(stringResource(R.string.action_new_automation)) + } + } + } + } + } else { + val (enabledAutomations, disabledAutomations) = + remember(automations) { + automations.partition { it.isEnabled } + } + val isMotionBlurEnabled = + remember(context) { + context + .getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) + .getBoolean("motion_blur", false) + } + val lazyListState = rememberLazyListState() + + @Composable + fun AutomationRow(automation: Automation) { + AutomationItem( + automation = automation, + onClick = { + context.startActivity( + AutomationEditorActivity.createIntent( + context, + automation.id, + ), + ) + }, + onDelete = { + viewModel.deleteAutomation(automation.id) + }, + onToggle = { + viewModel.toggleAutomation(automation.id) + }, + onTest = { + viewModel.testAutomation(automation) + }, + selectionMode = selectionMode, + selected = automation.id in selectedIds, + onSelectedChange = { checked -> + selectionMode = true + selectedIds = + if (checked) selectedIds + automation.id else selectedIds - automation.id + }, + ) + } + + LazyColumn( + state = lazyListState, + modifier = + Modifier + .fillMaxSize() + .scrollMotionBlur(lazyListState, enabled = isMotionBlurEnabled), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = + PaddingValues( + bottom = contentPadding.calculateBottomPadding(), + start = 16.dp, + end = 16.dp, + ), + ) { + item { + AnimatedVisibility( + visible = selectionMode, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + val selected = automations.filter { it.id in selectedIds } + val hasEnabled = selected.any { it.isEnabled } + val hasDisabled = selected.any { !it.isEnabled } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.diy_selected_count, selected.size), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - HapticUtil.performVirtualKeyHaptic(view) - exitSelection() - } - if (hasEnabled) { SelectionActionButton( - icon = R.drawable.rounded_do_not_disturb_on_24, - text = stringResource(R.string.action_disable), + icon = R.drawable.rounded_close_24, + text = stringResource(R.string.action_cancel), + outlined = true, modifier = Modifier.weight(1f), ) { HapticUtil.performVirtualKeyHaptic(view) - viewModel.setAutomationsEnabled(selectedIds, false) + exitSelection() + } + if (hasEnabled) { + SelectionActionButton( + icon = R.drawable.rounded_do_not_disturb_on_24, + text = stringResource(R.string.action_disable), + modifier = Modifier.weight(1f), + ) { + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setAutomationsEnabled(selectedIds, false) + } + } + if (hasDisabled) { + SelectionActionButton( + icon = R.drawable.rounded_check_24, + text = stringResource(R.string.action_enable), + modifier = Modifier.weight(1f), + ) { + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setAutomationsEnabled(selectedIds, true) + } } - } - if (hasDisabled) { SelectionActionButton( - icon = R.drawable.rounded_check_24, - text = stringResource(R.string.action_enable), + icon = R.drawable.rounded_delete_24, + text = stringResource(R.string.action_delete), + enabled = selected.isNotEmpty(), modifier = Modifier.weight(1f), ) { HapticUtil.performVirtualKeyHaptic(view) - viewModel.setAutomationsEnabled(selectedIds, true) + viewModel.deleteAutomations(selectedIds) + exitSelection() } } - SelectionActionButton( - icon = R.drawable.rounded_delete_24, - text = stringResource(R.string.action_delete), - enabled = selected.isNotEmpty(), - modifier = Modifier.weight(1f), - ) { - HapticUtil.performVirtualKeyHaptic(view) - viewModel.deleteAutomations(selectedIds) - exitSelection() - } } } } - } - if (enabledAutomations.isNotEmpty()) { - item { - Text( - text = stringResource(R.string.label_enabled), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 16.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - item { - RoundedCardContainer { - enabledAutomations.forEach { automation -> - AutomationRow(automation) + if (enabledAutomations.isNotEmpty()) { + item { + Text( + text = stringResource(R.string.label_enabled), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + item { + RoundedCardContainer { + enabledAutomations.forEach { automation -> + AutomationRow(automation) + } } } } - } - if (disabledAutomations.isNotEmpty()) { - item { - Text( - text = stringResource(R.string.label_disabled), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 16.dp, top = 8.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - item { - RoundedCardContainer { - disabledAutomations.forEach { automation -> - AutomationRow(automation) + if (disabledAutomations.isNotEmpty()) { + item { + Text( + text = stringResource(R.string.label_disabled), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp, top = 8.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + item { + RoundedCardContainer { + disabledAutomations.forEach { automation -> + AutomationRow(automation) + } } } } @@ -294,7 +371,15 @@ fun DIYScreen( } } - if (showNewAutomationSheet) { + if (showNewAutomationSheet && showMacros) { + // The FAB in MainActivity always requests a new automation; redirect to the macro editor when in the Macros tab + LaunchedEffect(showNewAutomationSheet) { + context.startActivity(MacroEditorActivity.createIntent(context, null)) + onDismissNewAutomationSheet() + } + } + + if (showNewAutomationSheet && !showMacros) { NewAutomationSheet( onDismiss = onDismissNewAutomationSheet, onOptionSelected = { type -> @@ -388,3 +473,157 @@ private fun SelectionActionButton( ) { content() } } } + +@Composable +private fun MacroList( + macros: List, + contentPadding: PaddingValues, + onNewMacro: () -> Unit, + onOpen: (Macro) -> Unit, + onTest: (Macro) -> Unit, + onDelete: (Macro) -> Unit, +) { + if (macros.isEmpty()) { + val view = LocalView.current + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 32.dp), + ) { + Text( + text = stringResource(R.string.diy_no_macros), + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onNewMacro() + }, + ) { + Text(stringResource(R.string.diy_new_macro)) + } + } + } + } else { + val context = LocalContext.current + val isMotionBlurEnabled = + remember(context) { + context + .getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) + .getBoolean("motion_blur", false) + } + val macrosLazyListState = rememberLazyListState() + + LazyColumn( + state = macrosLazyListState, + modifier = + Modifier + .fillMaxSize() + .scrollMotionBlur(macrosLazyListState, enabled = isMotionBlurEnabled), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = + PaddingValues( + bottom = contentPadding.calculateBottomPadding(), + start = 16.dp, + end = 16.dp, + ), + ) { + items(macros, key = { it.id }) { macro -> + MacroItem( + macro = macro, + onClick = { + onOpen(macro) + }, + onRun = { + onTest(macro) + }, + onDelete = { + onDelete(macro) + }, + ) + } + } + } +} + +@Composable +private fun DIYSectionSwitch( + showMacros: Boolean, + onShowMacrosChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val view = LocalView.current + val tabs = + listOf( + Triple(false, R.drawable.rounded_robot_2_24, R.string.diy_tab_automations), + Triple(true, R.drawable.rounded_bolt_24, R.string.diy_tab_macros), + ) + + Row( + modifier = + modifier + .background(MaterialTheme.colorScheme.primary, CircleShape) + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + tabs.forEach { (isMacrosTab, iconRes, labelRes) -> + val isSelected = showMacros == isMacrosTab + Row( + modifier = + Modifier + .height(48.dp) + .widthIn(min = 48.dp) + .clip(CircleShape) + .background( + if (isSelected) { + MaterialTheme.colorScheme.background + } else { + MaterialTheme.colorScheme.primary + }, + ).selectable( + selected = isSelected, + role = Role.Tab, + onClick = { + if (!isSelected) { + HapticUtil.performUIHaptic(view) + onShowMacrosChange(isMacrosTab) + } + }, + ).animateContentSize( + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow, + ), + ).padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = stringResource(id = labelRes), + tint = + if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.background + }, + modifier = Modifier.size(24.dp), + ) + if (isSelected) { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(id = labelRes), + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/automation/components/AutomationItem.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/components/AutomationItem.kt index e52f6031c..ffd4ff2b8 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/automation/components/AutomationItem.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/components/AutomationItem.kt @@ -60,6 +60,7 @@ import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.diy.Action +import com.sameerasw.essentials.domain.diy.displayName import com.sameerasw.essentials.domain.diy.Automation import com.sameerasw.essentials.domain.diy.Trigger import com.sameerasw.essentials.domain.diy.State as DIYState @@ -548,6 +549,15 @@ fun ActionItem( overflow = TextOverflow.Ellipsis, ) } + if (action is Action.RunMacro) { + Text( + text = action.displayName(context), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } if (moreCount > 0) { Spacer(modifier = Modifier.width(8.dp)) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/automation/components/MacroItem.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/components/MacroItem.kt new file mode 100644 index 000000000..03f2c49c3 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/components/MacroItem.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - Automation + * File: MacroItem.kt + * Description: UI component and settings composable for Automation feature domain. + */ + +package com.sameerasw.essentials.ui.components.diy + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.diy.Macro +import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenu +import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenuItem +import com.sameerasw.essentials.utils.HapticUtil + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MacroItem( + macro: Macro, + onClick: () -> Unit = {}, + onRun: () -> Unit = {}, + onDelete: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + val view = LocalView.current + var showMenu by remember { mutableStateOf(false) } + + Card( + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceBright, + ), + shape = MaterialTheme.shapes.extraSmall, + modifier = + modifier.combinedClickable( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onClick() + }, + onLongClick = { + HapticUtil.performVirtualKeyHaptic(view) + showMenu = true + }, + ), + ) { + Box { + SegmentedDropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + offset = DpOffset(0.dp, 0.dp), + ) { + SegmentedDropdownMenuItem( + text = { Text(stringResource(R.string.action_test)) }, + onClick = { + showMenu = false + onRun() + }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.rounded_play_arrow_24), + contentDescription = null, + ) + }, + ) + SegmentedDropdownMenuItem( + text = { Text(stringResource(R.string.action_delete)) }, + onClick = { + showMenu = false + onDelete() + }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.rounded_delete_24), + contentDescription = null, + ) + }, + ) + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_bolt_24), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = macro.name, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val actionCount = macro.actionList.size + Text( + text = pluralStringResource(R.plurals.diy_macro_action_count, actionCount, actionCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt index cb85156c1..3c482f5d0 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt @@ -45,6 +45,8 @@ import com.sameerasw.essentials.R import com.sameerasw.essentials.data.repository.SettingsRepository import com.sameerasw.essentials.domain.diy.Action import com.sameerasw.essentials.domain.diy.ActionRegistry +import com.sameerasw.essentials.domain.diy.displayName +import com.sameerasw.essentials.domain.diy.matches import com.sameerasw.essentials.domain.model.AppSelection import com.sameerasw.essentials.ui.components.CategoryExpandableSection import com.sameerasw.essentials.ui.components.sliders.ConfigSliderItem @@ -612,7 +614,7 @@ fun DuoSettingsUI( IconToggleItem( iconRes = R.drawable.rounded_pan_tool_alt_24, title = stringResource(R.string.duo_action_tap_title), - description = tapAction?.let { stringResource(it.title) } ?: stringResource(R.string.duo_action_none), + description = tapAction?.displayName(context) ?: stringResource(R.string.duo_action_none), showToggle = false, enabled = !tapForBriefActive, onClick = { @@ -643,7 +645,7 @@ fun DuoSettingsUI( IconToggleItem( iconRes = R.drawable.rounded_touch_app_24, title = stringResource(R.string.duo_action_double_tap_title), - description = doubleTapAction?.let { stringResource(it.title) } ?: stringResource(R.string.duo_action_none), + description = doubleTapAction?.displayName(context) ?: stringResource(R.string.duo_action_none), showToggle = false, onClick = { HapticUtil.performVirtualKeyHaptic(view) @@ -656,7 +658,7 @@ fun DuoSettingsUI( IconToggleItem( iconRes = R.drawable.rounded_front_hand_24, title = stringResource(R.string.duo_action_long_press_title), - description = longPressAction?.let { stringResource(it.title) } ?: stringResource(R.string.duo_action_none), + description = longPressAction?.displayName(context) ?: stringResource(R.string.duo_action_none), showToggle = false, onClick = { HapticUtil.performVirtualKeyHaptic(view) @@ -669,7 +671,7 @@ fun DuoSettingsUI( IconToggleItem( iconRes = R.drawable.rounded_south_24, title = stringResource(R.string.duo_action_swipe_down_title), - description = swipeDownAction?.let { stringResource(it.title) } ?: stringResource(R.string.duo_action_none), + description = swipeDownAction?.displayName(context) ?: stringResource(R.string.duo_action_none), showToggle = false, onClick = { HapticUtil.performVirtualKeyHaptic(view) @@ -841,13 +843,13 @@ fun DuoSettingsUI( } val actionCategories = remember { - ActionRegistry.getCategories() + ActionRegistry.getCategories() + listOfNotNull(ActionRegistry.getMacroCategory()) } var expandedActionCategory by remember { mutableStateOf( actionCategories.firstOrNull { category -> - category.actions.any { currentAction != null && it::class == currentAction::class } + category.actions.any { it.matches(currentAction) } }?.titleRes ?: actionCategories.firstOrNull()?.titleRes ) } @@ -864,9 +866,9 @@ fun DuoSettingsUI( ) { category.actions.forEach { action -> val resolvedAction = - if (currentAction != null && currentAction::class == action::class) currentAction else action + currentAction?.takeIf { action.matches(it) } ?: action val isSelected = - currentAction != null && currentAction::class == resolvedAction::class + action.matches(currentAction) val missing = getMissingPermissionsHelper(resolvedAction) fun showMissingPermissionSheet() { @@ -876,7 +878,7 @@ fun DuoSettingsUI( } RemapActionItem( - title = stringResource(resolvedAction.title), + title = resolvedAction.displayName(context), iconRes = resolvedAction.icon, isSelected = isSelected, hasSettings = resolvedAction.isConfigurable || diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt index 9f643d314..a00a8ccbf 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.key import androidx.compose.material3.IconButton import sh.calvin.reorderable.ReorderableColumn import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.domain.diy.displayName import com.sameerasw.essentials.island.model.IslandPriorityEntries import com.sameerasw.essentials.island.remote.ProviderDiscovery import com.sameerasw.essentials.island.remote.ProviderPriorities @@ -584,8 +585,7 @@ fun IslandSettingsUI( description = if (viewModel.isIslandBriefEnabled.value) { stringResource(R.string.island_brief_title) } else { - viewModel.islandLongPressAction.value?.let { stringResource(it.title) } - ?: stringResource(R.string.duo_action_none) + viewModel.islandLongPressAction.value?.displayName(context) ?: stringResource(R.string.duo_action_none) }, showToggle = false, enabled = !viewModel.isIslandBriefEnabled.value, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/StatusGlanceSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/StatusGlanceSettingsUI.kt index fd15787df..eb31949bc 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/StatusGlanceSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/StatusGlanceSettingsUI.kt @@ -45,6 +45,8 @@ import com.sameerasw.essentials.R import com.sameerasw.essentials.data.repository.SettingsRepository import com.sameerasw.essentials.domain.diy.Action import com.sameerasw.essentials.domain.diy.ActionRegistry +import com.sameerasw.essentials.domain.diy.displayName +import com.sameerasw.essentials.domain.diy.matches import com.sameerasw.essentials.domain.model.AppPermission import com.sameerasw.essentials.domain.model.AppSelection import com.sameerasw.essentials.ui.components.CategoryExpandableSection @@ -408,7 +410,7 @@ fun StatusGlanceSettingsUI( ) { val tapAction = viewModel.statusGlanceTapAction.value val tapDescription = if (tapAction != null) { - "${stringResource(R.string.status_glance_action_tap_media_desc)} • ${stringResource(tapAction.title)}" + "${stringResource(R.string.status_glance_action_tap_media_desc)} • ${tapAction.displayName(context)}" } else { stringResource(R.string.status_glance_action_tap_media_desc) } @@ -428,7 +430,7 @@ fun StatusGlanceSettingsUI( IconToggleItem( iconRes = R.drawable.rounded_touch_app_24, title = stringResource(R.string.status_glance_action_double_tap_title), - description = doubleTapAction?.let { stringResource(it.title) } ?: stringResource(R.string.status_glance_action_none), + description = doubleTapAction?.displayName(context) ?: stringResource(R.string.status_glance_action_none), showToggle = false, onClick = { HapticUtil.performVirtualKeyHaptic(view) @@ -441,7 +443,7 @@ fun StatusGlanceSettingsUI( IconToggleItem( iconRes = R.drawable.rounded_front_hand_24, title = stringResource(R.string.status_glance_action_long_press_title), - description = longPressAction?.let { stringResource(it.title) } ?: stringResource(R.string.status_glance_action_none), + description = longPressAction?.displayName(context) ?: stringResource(R.string.status_glance_action_none), showToggle = false, onClick = { HapticUtil.performVirtualKeyHaptic(view) @@ -609,13 +611,13 @@ fun StatusGlanceSettingsUI( } val actionCategories = remember { - ActionRegistry.getCategories(screenOnOnly = false) + ActionRegistry.getCategories(screenOnOnly = false) + listOfNotNull(ActionRegistry.getMacroCategory()) } var expandedActionCategory by remember { mutableStateOf( actionCategories.firstOrNull { category -> - category.actions.any { currentAction != null && it::class == currentAction::class } + category.actions.any { it.matches(currentAction) } }?.titleRes ?: actionCategories.firstOrNull()?.titleRes ) } @@ -632,9 +634,9 @@ fun StatusGlanceSettingsUI( ) { category.actions.forEach { action -> val resolvedAction = - if (currentAction != null && currentAction::class == action::class) currentAction else action + currentAction?.takeIf { action.matches(it) } ?: action val isSelected = - currentAction != null && currentAction::class == resolvedAction::class + action.matches(currentAction) val missing = getMissingPermissionsHelper(resolvedAction) fun showMissingPermissionSheet() { @@ -644,7 +646,7 @@ fun StatusGlanceSettingsUI( } RemapActionItem( - title = stringResource(resolvedAction.title), + title = resolvedAction.displayName(context), iconRes = resolvedAction.icon, isSelected = isSelected, hasSettings = resolvedAction.isConfigurable || diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/actions/GestureActionPickerSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/actions/GestureActionPickerSheet.kt index 6993ed999..c86272dc8 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/actions/GestureActionPickerSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/actions/GestureActionPickerSheet.kt @@ -28,6 +28,8 @@ import com.sameerasw.essentials.R import com.sameerasw.essentials.data.repository.SettingsRepository import com.sameerasw.essentials.domain.diy.Action import com.sameerasw.essentials.domain.diy.ActionRegistry +import com.sameerasw.essentials.domain.diy.displayName +import com.sameerasw.essentials.domain.diy.matches import com.sameerasw.essentials.domain.model.AppSelection import com.sameerasw.essentials.ui.components.CategoryExpandableSection import com.sameerasw.essentials.ui.core.cards.IconToggleItem @@ -153,13 +155,13 @@ fun GestureActionPickerSheet( } val actionCategories = remember { - ActionRegistry.getCategories() + ActionRegistry.getCategories() + listOfNotNull(ActionRegistry.getMacroCategory()) } var expandedActionCategory by remember { mutableStateOf( actionCategories.firstOrNull { category -> - category.actions.any { currentAction != null && it::class == currentAction::class } + category.actions.any { it.matches(currentAction) } }?.titleRes ?: actionCategories.firstOrNull()?.titleRes ) } @@ -176,9 +178,9 @@ fun GestureActionPickerSheet( ) { category.actions.forEach { action -> val resolvedAction = - if (currentAction != null && currentAction::class == action::class) currentAction else action + currentAction?.takeIf { action.matches(it) } ?: action val isSelected = - currentAction != null && currentAction::class == resolvedAction::class + action.matches(currentAction) val missing = getMissingPermissionsHelper(resolvedAction) fun showMissingPermissionSheet() { @@ -188,7 +190,7 @@ fun GestureActionPickerSheet( } RemapActionItem( - title = stringResource(resolvedAction.title), + title = resolvedAction.displayName(context), iconRes = resolvedAction.icon, isSelected = isSelected, hasSettings = resolvedAction.isConfigurable || diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ActionSequenceEditor.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ActionSequenceEditor.kt index 01a4a9164..d0468595d 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ActionSequenceEditor.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ActionSequenceEditor.kt @@ -47,6 +47,7 @@ import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.diy.Action import com.sameerasw.essentials.ui.core.pickers.SegmentedPicker import com.sameerasw.essentials.domain.diy.ActionRegistry +import com.sameerasw.essentials.domain.diy.displayName import com.sameerasw.essentials.domain.model.AppSelection import com.sameerasw.essentials.ui.components.CategoryExpandableSection import com.sameerasw.essentials.ui.core.cards.IconToggleItem @@ -86,6 +87,7 @@ fun ActionSequenceEditor( modifier: Modifier = Modifier, maxActions: Int = Int.MAX_VALUE, categories: List? = null, + showMacros: Boolean = true, ) { val context = LocalContext.current val view = LocalView.current @@ -323,6 +325,7 @@ fun ActionSequenceEditor( RemapAddActionSheet( screenOnOnly = screenOnOnly, categories = categories, + showMacros = showMacros, onDismiss = { showAddActionSheet = false }, onActionPicked = { action -> showAddActionSheet = false @@ -682,12 +685,14 @@ private fun RemapSequenceItem( onSettingsClick: () -> Unit, onRemove: () -> Unit, ) { + val context = LocalContext.current val view = LocalView.current val title = stringResource(action.title).let { baseTitle -> when { action is Action.Wait -> "$baseTitle · ${formatWait(action.seconds)}" action is Action.SendNotification && action.notificationTitle.isNotBlank() -> "$baseTitle · ${action.notificationTitle}" + action is Action.RunMacro -> "$baseTitle · ${action.displayName(context)}" else -> baseTitle } } @@ -811,12 +816,18 @@ private fun RemapAddActionItem( private fun RemapAddActionSheet( screenOnOnly: Boolean, categories: List?, + showMacros: Boolean = true, onDismiss: () -> Unit, onActionPicked: (Action) -> Unit, ) { + val context = LocalContext.current val view = LocalView.current val actionCategories = - remember(screenOnOnly, categories) { categories ?: ActionRegistry.getCategories(screenOnOnly = screenOnOnly) } + remember(screenOnOnly, categories, showMacros) { + val base = categories ?: ActionRegistry.getCategories(screenOnOnly = screenOnOnly) + val macroCategory = if (showMacros) ActionRegistry.getMacroCategory() else null + if (macroCategory != null) base + macroCategory else base + } var expandedActionCategory by remember(screenOnOnly) { mutableStateOf(actionCategories.firstOrNull()?.titleRes) } @@ -868,7 +879,7 @@ private fun RemapAddActionSheet( tint = MaterialTheme.colorScheme.primary, ) Text( - text = stringResource(action.title), + text = action.displayName(context), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f), ) diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/DIYViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/DIYViewModel.kt index 1936ef01c..62eacd140 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/DIYViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/DIYViewModel.kt @@ -17,6 +17,7 @@ import androidx.lifecycle.viewModelScope import com.sameerasw.essentials.domain.diy.Action import com.sameerasw.essentials.domain.diy.Automation import com.sameerasw.essentials.domain.diy.DIYRepository +import com.sameerasw.essentials.domain.diy.Macro import com.sameerasw.essentials.domain.diy.State import com.sameerasw.essentials.domain.diy.Trigger import com.sameerasw.essentials.domain.genai.AutomationSuggestion @@ -63,6 +64,14 @@ class DIYViewModel( initialValue = emptyList(), ) + val macros: StateFlow> = + repository.macros + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList(), + ) + /** * Executes the delete automation operation. * @@ -72,6 +81,15 @@ class DIYViewModel( repository.removeAutomation(id) } + /** + * Executes the delete macro operation. + * + * @param id [String] Target id. + */ + fun deleteMacro(id: String) { + repository.removeMacro(id) + } + /** * Executes the toggle automation operation. * @@ -115,6 +133,18 @@ class DIYViewModel( } } + /** + * Runs the macro's action(s) immediately. + * + * @param macro [Macro] Target macro. + */ + fun testMacro(macro: Macro) { + val context = getApplication().applicationContext + viewModelScope.launch { + CombinedActionExecutor.executeAll(context, macro.actionList) + } + } + /** * Executes the request gen ai suggestion operation. * diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f97275599..ab98150c5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1460,6 +1460,20 @@ Title Message Label + Run macro + The macro was deleted + Automations + Macros + Macros + New macro + Edit macro + Name + No macros yet. A macro is a list of actions you can reuse anywhere. + Deleted macro + + %d action + %d actions + Turn Off Low Power Mode Set Charging Mode Trigger notification lighting diff --git a/app/src/test/java/com/sameerasw/essentials/domain/MacroJsonTest.kt b/app/src/test/java/com/sameerasw/essentials/domain/MacroJsonTest.kt new file mode 100644 index 000000000..c0ee42152 --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/domain/MacroJsonTest.kt @@ -0,0 +1,62 @@ +package com.sameerasw.essentials.domain + +import com.sameerasw.essentials.domain.diy.Action +import com.sameerasw.essentials.domain.diy.ActionGsonAdapter +import com.sameerasw.essentials.domain.diy.DIYRepository +import com.sameerasw.essentials.domain.diy.Macro +import org.junit.Assert.assertEquals +import org.junit.Test + +class MacroJsonTest { + @Test + fun runMacroInListRoundTripsThroughActionGsonAdapter() { + val actions = listOf(Action.RunMacro("abc")) + assertEquals(actions, ActionGsonAdapter.listFromJson(ActionGsonAdapter.listToJson(actions))) + } + + @Test + fun typeOnlyLoadsAsDefaultEmptyId() { + val json = """[{"type":"RunMacro"}]""" + assertEquals(listOf(Action.RunMacro("")), ActionGsonAdapter.listFromJson(json)) + } + + @Test + fun twoMacrosRoundTripThroughDIYRepository() { + val macros = + listOf( + Macro( + id = "macro-1", + name = "Turn on light and wait", + actions = listOf(Action.TurnOnFlashlight, Action.Wait(3)), + ), + Macro( + id = "macro-2", + name = "Turn off light", + actions = listOf(Action.TurnOffFlashlight), + ), + ) + val json = DIYRepository.macrosToJson(macros) + assertEquals(macros, DIYRepository.macrosFromJson(json)) + } + + @Test + fun invalidJsonGivesEmptyList() { + assertEquals(emptyList(), DIYRepository.macrosFromJson("not json")) + } + + @Test + fun unknownActionTypesAreSkippedInsideMacro() { + val json = + """[{"id":"m1","name":"Test","actions":[{"type":"RemovedAction"},{"type":"ToggleFlashlight"}]}]""" + val loaded = DIYRepository.macrosFromJson(json) + val expected = + listOf( + Macro( + id = "m1", + name = "Test", + actions = listOf(Action.ToggleFlashlight), + ), + ) + assertEquals(expected, loaded) + } +}