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)
+ }
+}