diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0a150fc6..8cf076a9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -70,6 +70,7 @@ dependencies { implementation(libs.androidx.ui) implementation(libs.androidx.compose.foundation) implementation(libs.material) + implementation(libs.androidx.material3) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(platform(libs.androidx.compose.bom)) @@ -97,4 +98,7 @@ dependencies { // Google Maps & Location implementation(libs.play.services.location) + + // Kotlin Reflect for dynamic sealed class serialization + implementation(kotlin("reflect")) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9e9328b1..8d2c9a66 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -87,6 +87,14 @@ android:launchMode="singleInstance" android:theme="@style/Theme.Essentials.FullScreenAlarm" /> + + + + @@ -370,6 +378,15 @@ android:exported="false" android:foregroundServiceType="specialUse" /> + + + + = Build.VERSION_CODES.TIRAMISU) { registerReceiver(screenOffReceiver, intentFilter, Context.RECEIVER_EXPORTED) diff --git a/app/src/main/java/com/sameerasw/essentials/MainActivity.kt b/app/src/main/java/com/sameerasw/essentials/MainActivity.kt index 6a993e2f..bee2426f 100644 --- a/app/src/main/java/com/sameerasw/essentials/MainActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/MainActivity.kt @@ -39,7 +39,7 @@ import com.sameerasw.essentials.domain.registry.initPermissionRegistry import com.sameerasw.essentials.ui.components.ReusableTopAppBar import com.sameerasw.essentials.ui.components.DIYFloatingToolbar import com.sameerasw.essentials.ui.composables.SetupFeatures -import com.sameerasw.essentials.ui.composables.ComingSoonDIYScreen +import com.sameerasw.essentials.ui.composables.DIYScreen import com.sameerasw.essentials.ui.theme.EssentialsTheme import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.viewmodels.MainViewModel @@ -279,7 +279,7 @@ class MainActivity : FragmentActivity() { ) } DIYTabs.DIY -> { - ComingSoonDIYScreen( + DIYScreen( modifier = Modifier.padding(innerPadding) ) } diff --git a/app/src/main/java/com/sameerasw/essentials/ShortcutHandlerActivity.kt b/app/src/main/java/com/sameerasw/essentials/ShortcutHandlerActivity.kt index 6c16aef9..e02c9e81 100644 --- a/app/src/main/java/com/sameerasw/essentials/ShortcutHandlerActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ShortcutHandlerActivity.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.LoadingIndicator import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale +import com.sameerasw.essentials.ui.activities.AppFreezingActivity import com.sameerasw.essentials.ui.theme.EssentialsTheme import com.sameerasw.essentials.utils.FreezeManager import kotlinx.coroutines.CoroutineScope diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 3284cdf3..0ad53aae 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -279,7 +279,7 @@ class SettingsRepository(private val context: Context) { fun getAllConfigsAsJsonString(): String { return try { val allConfigs = mutableMapOf>>() - val prefFiles = listOf("essentials_prefs", "caffeinate_prefs", "link_prefs") + val prefFiles = listOf("essentials_prefs", "caffeinate_prefs", "link_prefs", "diy_automations_prefs") prefFiles.forEach { fileName -> val p = context.getSharedPreferences(fileName, Context.MODE_PRIVATE) 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 new file mode 100644 index 00000000..ffeea6f4 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt @@ -0,0 +1,53 @@ +package com.sameerasw.essentials.domain.diy + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.sameerasw.essentials.R + +sealed interface Action { + @get:StringRes + val title: Int + @get:DrawableRes + val icon: Int + val permissions: List + get() = emptyList() + val isConfigurable: Boolean + get() = false + + data object HapticVibration : Action { + override val title: Int = R.string.diy_action_haptic + override val icon: Int = R.drawable.rounded_mobile_vibrate_24 + } + + data object ShowNotification : Action { + override val title: Int = R.string.diy_action_notification + override val icon: Int = R.drawable.rounded_notifications_unread_24 + } + + data object RemoveNotification : Action { + override val title: Int = R.string.diy_action_remove_notification + override val icon: Int = R.drawable.rounded_notifications_off_24 + } + + data object TurnOnFlashlight : Action { + override val title: Int = R.string.diy_action_flashlight_on + override val icon: Int = R.drawable.round_flashlight_on_24 + } + + data object TurnOffFlashlight : Action { + override val title: Int = R.string.diy_action_flashlight_off + override val icon: Int = R.drawable.rounded_flashlight_on_24 + } + + data object ToggleFlashlight : Action { + override val title: Int = R.string.diy_action_flashlight_toggle + override val icon: Int = R.drawable.rounded_flashlight_on_24 + } + + data class DimWallpaper(val dimAmount: Float = 0f) : Action { + override val title: Int = R.string.diy_action_dim_wallpaper + override val icon: Int = R.drawable.rounded_mobile_screensaver_24 + override val permissions: List = listOf("shizuku", "root") + override val isConfigurable: Boolean = true + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/Automation.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/Automation.kt new file mode 100644 index 00000000..94e3c71f --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/Automation.kt @@ -0,0 +1,17 @@ +package com.sameerasw.essentials.domain.diy + +data class Automation( + val id: String, + val type: Type, + val trigger: Trigger? = null, + val state: State? = null, + val actions: List = emptyList(), + val entryAction: Action? = null, + val exitAction: Action? = null, + val isEnabled: Boolean = true +) { + enum class Type { + TRIGGER, + STATE + } +} 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 new file mode 100644 index 00000000..b12d89c6 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/DIYRepository.kt @@ -0,0 +1,114 @@ +package com.sameerasw.essentials.domain.diy + +import android.content.Context +import android.content.SharedPreferences +import com.google.gson.GsonBuilder +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer +import com.google.gson.reflect.TypeToken +import com.sameerasw.essentials.domain.diy.Action +import kotlin.reflect.KClass +import com.sameerasw.essentials.domain.diy.Automation +import com.sameerasw.essentials.domain.diy.State +import com.sameerasw.essentials.domain.diy.Trigger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +object DIYRepository { + private const val PREFS_NAME = "diy_automations_prefs" + private const val KEY_AUTOMATIONS = "saved_automations" + + private val _automations = MutableStateFlow>(emptyList()) + val automations = _automations.asStateFlow() + + private var prefs: SharedPreferences? = null + private val gson = GsonBuilder() + .registerTypeAdapter(Trigger::class.java, SealedAdapter(Trigger::class)) + .registerTypeAdapter(State::class.java, SealedAdapter(State::class)) + .registerTypeAdapter(Action::class.java, SealedAdapter(Action::class)) + .create() + + private class SealedAdapter(private val kClass: KClass) : JsonSerializer, JsonDeserializer { + override fun serialize(src: T, typeOfSrc: java.lang.reflect.Type, context: JsonSerializationContext): JsonElement { + val element = context.serialize(src) + if (element.isJsonObject) { + element.asJsonObject.addProperty("type", src::class.simpleName) + } + return element + } + + override fun deserialize(json: JsonElement, typeOfT: java.lang.reflect.Type, context: JsonDeserializationContext): T? { + val typeName = json.asJsonObject.get("type").asString + val subClass = kClass.sealedSubclasses.firstOrNull { it.simpleName == typeName } + + return if (subClass != null) { + // Determine if it's an object or class + if (subClass.objectInstance != null) { + subClass.objectInstance + } else { + context.deserialize(json, subClass.java) + } + } else { + null + } + } + } + + fun init(context: Context) { + if (prefs != null) return + prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + loadAutomations() + } + + private fun loadAutomations() { + val json = prefs?.getString(KEY_AUTOMATIONS, null) + val loadedList: List = if (json != null) { + try { + val type = object : TypeToken>() {}.type + gson.fromJson(json, type) ?: emptyList() + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + _automations.value = loadedList + } + + private fun saveToPrefs() { + val json = gson.toJson(_automations.value) + prefs?.edit()?.putString(KEY_AUTOMATIONS, json)?.apply() + } + + fun addAutomation(automation: Automation) { + val current = _automations.value.toMutableList() + current.add(automation) + _automations.value = current + saveToPrefs() + } + + fun updateAutomation(automation: Automation) { + val current = _automations.value.toMutableList() + val index = current.indexOfFirst { it.id == automation.id } + if (index != -1) { + current[index] = automation + _automations.value = current + saveToPrefs() + } + } + + fun removeAutomation(id: String) { + val current = _automations.value.toMutableList() + current.removeAll { it.id == id } + _automations.value = current + saveToPrefs() + } + + fun getAutomation(id: String): Automation? { + return _automations.value.find { it.id == id } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/State.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/State.kt new file mode 100644 index 00000000..9b17c51d --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/State.kt @@ -0,0 +1,22 @@ +package com.sameerasw.essentials.domain.diy + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.sameerasw.essentials.R + +sealed interface State { + @get:StringRes + val title: Int + @get:DrawableRes + val icon: Int + + data object Charging : State { + override val title: Int = R.string.diy_state_charging + override val icon: Int = R.drawable.rounded_charger_24 + } + + data object ScreenOn : State { + override val title: Int = R.string.diy_state_screen_on + override val icon: Int = R.drawable.rounded_mobile_text_2_24 + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/Trigger.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/Trigger.kt new file mode 100644 index 00000000..a46a252d --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/Trigger.kt @@ -0,0 +1,38 @@ +package com.sameerasw.essentials.domain.diy + +import androidx.annotation.StringRes +import com.sameerasw.essentials.R + +sealed interface Trigger { + val title: Int + val icon: Int + val permissions: List + get() = emptyList() + val isConfigurable: Boolean + get() = false + + data object ScreenOff : Trigger { + override val title: Int = R.string.diy_trigger_screen_off + override val icon: Int = R.drawable.rounded_mobile_lock_portrait_24 + } + + data object ScreenOn : Trigger { + override val title: Int = R.string.diy_trigger_screen_on + override val icon: Int = R.drawable.rounded_mobile_text_2_24 + } + + data object DeviceUnlock : Trigger { + override val title: Int = R.string.diy_trigger_device_unlock + override val icon: Int = R.drawable.rounded_mobile_unlock_24 + } + + data object ChargerConnected : Trigger { + override val title: Int = R.string.diy_trigger_charger_connected + override val icon: Int = R.drawable.rounded_battery_charging_60_24 + } + + data object ChargerDisconnected : Trigger { + override val title: Int = R.string.diy_trigger_charger_disconnected + override val icon: Int = R.drawable.rounded_battery_android_frame_3_24 + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationManager.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationManager.kt new file mode 100644 index 00000000..6ad80e23 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationManager.kt @@ -0,0 +1,144 @@ +package com.sameerasw.essentials.services.automation + +import android.content.Context +import android.content.Intent +import com.sameerasw.essentials.domain.diy.Automation +import com.sameerasw.essentials.domain.diy.DIYRepository +import com.sameerasw.essentials.domain.diy.Trigger +import com.sameerasw.essentials.domain.diy.State as DIYState +import com.sameerasw.essentials.services.automation.modules.AutomationModule +import com.sameerasw.essentials.services.automation.modules.DisplayModule +import com.sameerasw.essentials.services.automation.modules.PowerModule +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +object AutomationManager { + private val scope = CoroutineScope(Dispatchers.Main) + private var service: AutomationService? = null + private var applicationContext: Context? = null + + // Active modules map: ModuleID -> Module Instance + private val activeModules = ConcurrentHashMap() + + fun init(context: Context) { + applicationContext = context.applicationContext + + // Observe repository + scope.launch { + DIYRepository.automations.collect { automations -> + refreshModules(automations) + } + } + } + + fun onServiceConnected(serviceInstance: AutomationService) { + service = serviceInstance + // Re-apply modules if needed + val automations = DIYRepository.automations.value + refreshModules(automations) + } + + fun onServiceDisconnected(serviceInstance: AutomationService) { + if (service == serviceInstance) { + service = null + stopAllModules() + } + } + + private fun stopAllModules() { + val context = applicationContext ?: return + activeModules.values.forEach { it.stop(context) } + activeModules.clear() + } + + private fun refreshModules(automations: List) { + val context = applicationContext ?: return + val enabledAutomations = automations.filter { it.isEnabled } + + // Determine required modules + val requiredModuleIds = mutableSetOf() + val powerAutomations = mutableListOf() + val displayAutomations = mutableListOf() + + enabledAutomations.forEach { automation -> + when (automation.type) { + Automation.Type.TRIGGER -> { + when (automation.trigger) { + is Trigger.ChargerConnected, is Trigger.ChargerDisconnected -> { + requiredModuleIds.add(PowerModule.ID) + powerAutomations.add(automation) + } + is Trigger.ScreenOn, is Trigger.ScreenOff, is Trigger.DeviceUnlock -> { + requiredModuleIds.add(DisplayModule.ID) + displayAutomations.add(automation) + } + else -> {} + } + } + Automation.Type.STATE -> { + when (automation.state) { + is DIYState.Charging -> { + requiredModuleIds.add(PowerModule.ID) + powerAutomations.add(automation) + } + is DIYState.ScreenOn -> { + requiredModuleIds.add(DisplayModule.ID) + displayAutomations.add(automation) + } + else -> {} + } + } + } + } + + // Service Lifecycle Management + if (requiredModuleIds.isNotEmpty()) { + startService(context) + } else { + stopService(context) + stopAllModules() + return + } + + // Module Management + + // Power Module + if (requiredModuleIds.contains(PowerModule.ID)) { + val module = activeModules.getOrPut(PowerModule.ID) { + PowerModule().also { it.start(context) } + } + module.updateAutomations(powerAutomations) + } else { + activeModules.remove(PowerModule.ID)?.stop(context) + } + + // Display Module + if (requiredModuleIds.contains(DisplayModule.ID)) { + val module = activeModules.getOrPut(DisplayModule.ID) { + DisplayModule().also { it.start(context) } + } + module.updateAutomations(displayAutomations) + } else { + activeModules.remove(DisplayModule.ID)?.stop(context) + } + } + + private fun startService(context: Context) { + if (!AutomationService.isRunning) { + val intent = Intent(context, AutomationService::class.java) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + } + + private fun stopService(context: Context) { + if (AutomationService.isRunning) { + context.stopService(Intent(context, AutomationService::class.java)) + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationService.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationService.kt new file mode 100644 index 00000000..1c6e5c8f --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationService.kt @@ -0,0 +1,63 @@ +package com.sameerasw.essentials.services.automation + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.sameerasw.essentials.R +import com.sameerasw.essentials.services.automation.modules.AutomationModule + +class AutomationService : Service() { + + companion object { + private const val CHANNEL_ID = "automation_service_channel" + private const val NOTIFICATION_ID = 999 + var isRunning = false + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + isRunning = true + createNotificationChannel() + startForeground(NOTIFICATION_ID, createNotification(), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0 + ) + + // Modules will be started by AutomationManager calling onServiceCreated/Updated + AutomationManager.onServiceConnected(this) + } + + override fun onDestroy() { + super.onDestroy() + isRunning = false + AutomationManager.onServiceDisconnected(this) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.automation_service_channel_name), + NotificationManager.IMPORTANCE_LOW + ) + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + } + + private fun createNotification(): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.automation_service_running_title)) + .setContentText(getString(R.string.automation_service_running_desc)) + .setSmallIcon(R.drawable.outline_bubble_chart_24) + .setOngoing(true) + .build() + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/executors/ActionExecutor.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/executors/ActionExecutor.kt new file mode 100644 index 00000000..a257bace --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/automation/executors/ActionExecutor.kt @@ -0,0 +1,8 @@ +package com.sameerasw.essentials.services.automation.executors + +import android.content.Context +import com.sameerasw.essentials.domain.diy.Action + +interface ActionExecutor { + suspend fun execute(context: Context, action: Action) +} 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 new file mode 100644 index 00000000..3f3c496b --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt @@ -0,0 +1,75 @@ +package com.sameerasw.essentials.services.automation.executors + +import android.content.Context +import android.content.Intent +import android.hardware.camera2.CameraManager +import android.os.Build +import com.sameerasw.essentials.domain.diy.Action +import com.sameerasw.essentials.utils.HapticUtil +import android.view.View + +object CombinedActionExecutor { + + suspend fun execute(context: Context, action: Action) { + when (action) { + is Action.HapticVibration -> { + val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val manager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as android.os.VibratorManager + manager.defaultVibrator + } else { + context.getSystemService(Context.VIBRATOR_SERVICE) as android.os.Vibrator + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(android.os.VibrationEffect.createOneShot(50, android.os.VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(50) + } + } + + is Action.TurnOnFlashlight -> toggleFlashlight(context, true) + is Action.TurnOffFlashlight -> toggleFlashlight(context, false) + is Action.ToggleFlashlight -> { + val camManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + try { + val cameraId = camManager.cameraIdList[0] + camManager.registerTorchCallback(object : CameraManager.TorchCallback() { + override fun onTorchModeChanged(cameraId: String, enabled: Boolean) { + super.onTorchModeChanged(cameraId, enabled) + camManager.unregisterTorchCallback(this) + try { + camManager.setTorchMode(cameraId, !enabled) + } catch (e: Exception) { + e.printStackTrace() + } + } + }, null) + + } catch (e: Exception) { + e.printStackTrace() + } + } + + is Action.ShowNotification -> { + // Placeholder + } + is Action.RemoveNotification -> { + // Placeholder + } + is Action.DimWallpaper -> { + com.sameerasw.essentials.utils.ShellUtils.runCommand(context, "cmd wallpaper set-dim-amount ${action.dimAmount}") + } + } + } + + private fun toggleFlashlight(context: Context, on: Boolean) { + val camManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + try { + val cameraId = camManager.cameraIdList[0] + camManager.setTorchMode(cameraId, on) + } catch (e: Exception) { + e.printStackTrace() + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/modules/AutomationModule.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/AutomationModule.kt new file mode 100644 index 00000000..1a276827 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/AutomationModule.kt @@ -0,0 +1,11 @@ +package com.sameerasw.essentials.services.automation.modules + +import android.content.Context +import com.sameerasw.essentials.domain.diy.Automation + +interface AutomationModule { + val id: String + fun start(context: Context) + fun stop(context: Context) + fun updateAutomations(automations: List) +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/modules/DisplayModule.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/DisplayModule.kt new file mode 100644 index 00000000..300db1e0 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/DisplayModule.kt @@ -0,0 +1,90 @@ +package com.sameerasw.essentials.services.automation.modules + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import com.sameerasw.essentials.domain.diy.Automation +import com.sameerasw.essentials.domain.diy.Trigger +import com.sameerasw.essentials.services.automation.executors.CombinedActionExecutor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import com.sameerasw.essentials.domain.diy.State as DIYState + +class DisplayModule : AutomationModule { + companion object { + const val ID = "display_module" + } + + override val id: String = ID + private var automations: List = emptyList() + private val scope = CoroutineScope(Dispatchers.IO) + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Intent.ACTION_SCREEN_ON -> { + handleTrigger(context, Trigger.ScreenOn) + handleStateChange(context, true) + } + Intent.ACTION_SCREEN_OFF -> { + handleTrigger(context, Trigger.ScreenOff) + handleStateChange(context, false) + } + Intent.ACTION_USER_PRESENT -> { + handleTrigger(context, Trigger.DeviceUnlock) + } + } + } + } + + override fun start(context: Context) { + val filter = IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + addAction(Intent.ACTION_USER_PRESENT) + } + context.registerReceiver(receiver, filter) + } + + override fun stop(context: Context) { + try { + context.unregisterReceiver(receiver) + } catch (e: Exception) { + // Ignore + } + } + + override fun updateAutomations(automations: List) { + this.automations = automations + } + + private fun handleTrigger(context: Context, trigger: Trigger) { + scope.launch { + automations.filter { it.type == Automation.Type.TRIGGER && it.trigger == trigger } + .forEach { automation -> + automation.actions.forEach { action -> + CombinedActionExecutor.execute(context, action) + } + } + } + } + + private fun handleStateChange(context: Context, isActive: Boolean) { + scope.launch { + automations.filter { it.type == Automation.Type.STATE } + .forEach { automation -> + if (automation.state is DIYState.ScreenOn) { + if (isActive) { + // Entry + automation.entryAction?.let { CombinedActionExecutor.execute(context, it) } + } else { + // Exit + automation.exitAction?.let { CombinedActionExecutor.execute(context, it) } + } + } + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/modules/PowerModule.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/PowerModule.kt new file mode 100644 index 00000000..e0d0200c --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/PowerModule.kt @@ -0,0 +1,108 @@ +package com.sameerasw.essentials.services.automation.modules + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.BatteryManager +import com.sameerasw.essentials.domain.diy.Automation +import com.sameerasw.essentials.domain.diy.Trigger +import com.sameerasw.essentials.services.automation.executors.CombinedActionExecutor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import com.sameerasw.essentials.domain.diy.State as DIYState + +class PowerModule : AutomationModule { + companion object { + const val ID = "power_module" + } + + override val id: String = ID + private var automations: List = emptyList() + private val scope = CoroutineScope(Dispatchers.IO) + + // State tracking + private var isCharging = false + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Intent.ACTION_POWER_CONNECTED -> { + if (!isCharging) { + isCharging = true + handleTrigger(context, Trigger.ChargerConnected) + handleStateChange(context, true) + } + } + Intent.ACTION_POWER_DISCONNECTED -> { + if (isCharging) { + isCharging = false + handleTrigger(context, Trigger.ChargerDisconnected) + handleStateChange(context, false) + } + } + } + } + } + + override fun start(context: Context) { + val filter = IntentFilter().apply { + addAction(Intent.ACTION_POWER_CONNECTED) + addAction(Intent.ACTION_POWER_DISCONNECTED) + } + context.registerReceiver(receiver, filter) + + // Initial check + val batteryStatus: Intent? = IntentFilter(Intent.ACTION_BATTERY_CHANGED).let { ifilter -> + context.registerReceiver(null, ifilter) + } + val status: Int = batteryStatus?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1 + isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || + status == BatteryManager.BATTERY_STATUS_FULL + + if (isCharging) { + handleStateChange(context, true) + } + } + + override fun stop(context: Context) { + try { + context.unregisterReceiver(receiver) + } catch (e: Exception) { + // Ignore if not registered + } + } + + override fun updateAutomations(automations: List) { + this.automations = automations + } + + private fun handleTrigger(context: Context, trigger: Trigger) { + scope.launch { + automations.filter { it.type == Automation.Type.TRIGGER && it.trigger == trigger } + .forEach { automation -> + automation.actions.forEach { action -> + CombinedActionExecutor.execute(context, action) + } + } + } + } + + private fun handleStateChange(context: Context, isActive: Boolean) { + scope.launch { + automations.filter { it.type == Automation.Type.STATE } + .forEach { automation -> + if (automation.state is DIYState.Charging) { + if (isActive) { + // Entry + automation.entryAction?.let { CombinedActionExecutor.execute(context, it) } + } else { + // Exit + automation.exitAction?.let { CombinedActionExecutor.execute(context, it) } + } + } + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/AppFreezingTileService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/AppFreezingTileService.kt index 651508b0..ea741c17 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/AppFreezingTileService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/AppFreezingTileService.kt @@ -4,7 +4,7 @@ import android.app.PendingIntent import android.content.Intent import android.os.Build import android.service.quicksettings.Tile -import com.sameerasw.essentials.AppFreezingActivity +import com.sameerasw.essentials.ui.activities.AppFreezingActivity class AppFreezingTileService : BaseTileService() { diff --git a/app/src/main/java/com/sameerasw/essentials/AppFreezingActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/AppFreezingActivity.kt similarity index 96% rename from app/src/main/java/com/sameerasw/essentials/AppFreezingActivity.kt rename to app/src/main/java/com/sameerasw/essentials/ui/activities/AppFreezingActivity.kt index 8c509a7d..086f2382 100644 --- a/app/src/main/java/com/sameerasw/essentials/AppFreezingActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/AppFreezingActivity.kt @@ -1,15 +1,13 @@ -package com.sameerasw.essentials +package com.sameerasw.essentials.ui.activities -import android.content.Context import android.content.Intent +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.SystemBarStyle import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.* import androidx.compose.foundation.ExperimentalFoundationApi @@ -30,7 +28,6 @@ import androidx.compose.material3.ToggleFloatingActionButtonDefaults.animateIcon import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView @@ -40,9 +37,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.animation.* -import androidx.compose.animation.core.* -import androidx.core.graphics.drawable.toBitmap import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -58,11 +52,13 @@ import com.sameerasw.essentials.viewmodels.MainViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import com.sameerasw.essentials.FeatureSettingsActivity +import com.sameerasw.essentials.R import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -@OptIn(ExperimentalMaterial3Api::class, androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) class AppFreezingActivity : ComponentActivity() { @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { @@ -70,7 +66,7 @@ class AppFreezingActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { window.isNavigationBarContrastEnforced = false } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt new file mode 100644 index 00000000..2be44c17 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt @@ -0,0 +1,507 @@ +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.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.carousel.HorizontalMultiBrowseCarousel +import androidx.compose.material3.carousel.rememberCarouselState +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.ui.components.ReusableTopAppBar +import com.sameerasw.essentials.ui.theme.EssentialsTheme +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.IconButton +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableIntStateOf +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.stringResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import com.sameerasw.essentials.R +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.Trigger +import com.sameerasw.essentials.domain.diy.State as DIYState +import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenu +import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenuItem +import com.sameerasw.essentials.ui.components.pickers.SegmentedPicker +import com.sameerasw.essentials.ui.components.sheets.DimWallpaperSettingsSheet +import com.sameerasw.essentials.utils.HapticUtil + +class AutomationEditorActivity : ComponentActivity() { + + companion object { + private const val EXTRA_AUTOMATION_ID = "automation_id" + private const val EXTRA_AUTOMATION_TYPE = "automation_type" + + fun createIntent(context: Context, automationId: String): Intent { + return Intent(context, AutomationEditorActivity::class.java).apply { + putExtra(EXTRA_AUTOMATION_ID, automationId) + } + } + + fun createIntent(context: Context, type: Automation.Type): Intent { + return Intent(context, AutomationEditorActivity::class.java).apply { + putExtra(EXTRA_AUTOMATION_TYPE, type.name) + } + } + } + + @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // Init repository + DIYRepository.init(applicationContext) + + val automationId = intent.getStringExtra(EXTRA_AUTOMATION_ID) + val automationTypeStr = intent.getStringExtra(EXTRA_AUTOMATION_TYPE) + + val existingAutomation = if (automationId != null) DIYRepository.getAutomation(automationId) else null + val isEditMode = existingAutomation != null + + val automationType = if (isEditMode) { + existingAutomation?.type ?: Automation.Type.TRIGGER + } else { + try { + Automation.Type.valueOf(automationTypeStr ?: Automation.Type.TRIGGER.name) + } catch (e: Exception) { + Automation.Type.TRIGGER + } + } + + val titleRes = if (isEditMode) R.string.diy_editor_edit_title else R.string.diy_editor_new_title + + setContent { + EssentialsTheme { + val view = LocalView.current + var carouselState = rememberCarouselState { 2 } // 0: Trigger/State, 1: Actions + + // State for selections + // Initialize with existing data or defaults + var selectedTrigger by remember { mutableStateOf(existingAutomation?.trigger) } + var selectedState by remember { mutableStateOf(existingAutomation?.state) } + + // Actions + // For Trigger type + var selectedAction by remember { mutableStateOf(existingAutomation?.actions?.firstOrNull()) } + + // For State type + var selectedInAction by remember { mutableStateOf(existingAutomation?.entryAction) } + var selectedOutAction by remember { mutableStateOf(existingAutomation?.exitAction) } + + // Tab for State Actions + var selectedActionTab by remember { mutableIntStateOf(0) } // 0: In, 1: Out + + // Menu State + var showMenu by remember { mutableStateOf(false) } + + // Config Sheets + var showDimSettings by remember { mutableStateOf(false) } + var configAction by remember { mutableStateOf(null) } + + // Validation + val isValid = when (automationType) { + Automation.Type.TRIGGER -> selectedTrigger != null && selectedAction != null + Automation.Type.STATE -> selectedState != null && (selectedInAction != null || selectedOutAction != null) + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + topBar = { + ReusableTopAppBar( + title = titleRes, + hasBack = true, + isSmall = true, + onBackClick = { finish() }, + actions = { + if (isEditMode) { + IconButton(onClick = { showMenu = true }) { + Icon( + painter = painterResource(R.drawable.rounded_more_vert_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface + ) + } + + SegmentedDropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + SegmentedDropdownMenuItem( + text = { Text(stringResource(R.string.action_delete)) }, + onClick = { + showMenu = false + DIYRepository.removeAutomation(existingAutomation!!.id) + finish() + }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.rounded_delete_24), + contentDescription = null + ) + } + ) + } + } + } + ) + } + ) { innerPadding -> + val configuration = LocalConfiguration.current + val screenWidth = configuration.screenWidthDp.dp + + Column(modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + HorizontalMultiBrowseCarousel( + state = carouselState, + preferredItemWidth = screenWidth, + itemSpacing = 4.dp, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 18.dp) + ) { index -> + Box( + modifier = Modifier + .fillMaxSize() + .maskClip(MaterialTheme.shapes.extraLarge) + .background(MaterialTheme.colorScheme.background) + ) { + if (index == 0) { + // PAGE 0: Trigger or State Picker + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(if (automationType == Automation.Type.TRIGGER) R.string.diy_select_trigger else R.string.diy_select_state), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 12.dp) + ) + + RoundedCardContainer(spacing = 2.dp) { + if (automationType == Automation.Type.TRIGGER) { + val triggers = listOf( + Trigger.ScreenOff, + Trigger.ScreenOn, + Trigger.DeviceUnlock, + Trigger.ChargerConnected, + Trigger.ChargerDisconnected + ) + triggers.forEach { trigger -> + EditorActionItem( + title = stringResource(trigger.title), + iconRes = trigger.icon, + isSelected = selectedTrigger == trigger, + isConfigurable = trigger.isConfigurable, + onClick = { selectedTrigger = trigger }, + onSettingsClick = { + // Handle trigger settings if needed later + } + ) + } + } else { + val states = listOf( + DIYState.Charging, + DIYState.ScreenOn + ) + states.forEach { state -> + EditorActionItem( + title = stringResource(state.title), + iconRes = state.icon, + isSelected = selectedState == state, + onClick = { selectedState = state } + ) + } + } + } + } + } else { + // PAGE 1: Action Picker + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(R.string.diy_select_action), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 12.dp) + ) + + if (automationType == Automation.Type.STATE) { + // Tabs for In/Out + val options = listOf( + stringResource(R.string.diy_in_action_label), + stringResource(R.string.diy_out_action_label) + ) + SegmentedPicker( + items = options, + selectedItem = options[selectedActionTab], + onItemSelected = { + HapticUtil.performUIHaptic(view) + selectedActionTab = options.indexOf(it) + }, + labelProvider = { it }, + modifier = Modifier.fillMaxWidth(), + cornerShape = MaterialTheme.shapes.extraExtraLarge.bottomEnd + ) + } + + RoundedCardContainer(spacing = 2.dp) { + val actions = listOf( + Action.TurnOnFlashlight, + Action.TurnOffFlashlight, + Action.ToggleFlashlight, + Action.HapticVibration, + Action.DimWallpaper() + ) + + val currentSelection = when(automationType) { + Automation.Type.TRIGGER -> selectedAction + Automation.Type.STATE -> if (selectedActionTab == 0) selectedInAction else selectedOutAction + } + + // None option + EditorActionItem( + title = stringResource(R.string.action_none), + iconRes = R.drawable.rounded_do_not_disturb_on_24, + isSelected = currentSelection == null, + onClick = { + when(automationType) { + Automation.Type.TRIGGER -> selectedAction = null + Automation.Type.STATE -> { + if (selectedActionTab == 0) selectedInAction = null + else selectedOutAction = null + } + } + } + ) + + actions.forEach { action -> + // Check if the current selection matches this action type and update 'action' with the selected values if so + // This ensures the loop variable 'action' doesn't overwrite the configured values in 'currentSelection' + val resolvedAction = if (currentSelection != null && currentSelection::class == action::class) currentSelection else action + + EditorActionItem( + title = stringResource(resolvedAction.title), + iconRes = resolvedAction.icon, + isSelected = currentSelection != null && currentSelection::class == resolvedAction::class, + isConfigurable = resolvedAction.isConfigurable, + onClick = { + when(automationType) { + Automation.Type.TRIGGER -> selectedAction = resolvedAction + Automation.Type.STATE -> { + if (selectedActionTab == 0) selectedInAction = resolvedAction + else selectedOutAction = resolvedAction + } + } + }, + onSettingsClick = { + if (resolvedAction is Action.DimWallpaper) { + configAction = resolvedAction + showDimSettings = true + } + } + ) + } + } + } + } + } + } + + if (showDimSettings && configAction != null) { + DimWallpaperSettingsSheet( + initialAction = configAction!!, + onDismiss = { showDimSettings = false }, + onSave = { newAction -> + showDimSettings = false + // Update the selection with configured action + when(automationType) { + Automation.Type.TRIGGER -> selectedAction = newAction + Automation.Type.STATE -> { + if (selectedActionTab == 0) selectedInAction = newAction + else selectedOutAction = newAction + } + } + configAction = null + } + ) + } + + // Bottom Actions + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + finish() + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceBright, + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_close_24), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text(stringResource(R.string.action_cancel)) + } + + Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + // Save logic + if (automationType == Automation.Type.TRIGGER) { + val newAutomation = Automation( + id = if (isEditMode) existingAutomation!!.id else java.util.UUID.randomUUID().toString(), + type = Automation.Type.TRIGGER, + trigger = selectedTrigger, + actions = listOfNotNull(selectedAction) + ) + if (isEditMode) DIYRepository.updateAutomation(newAutomation) else DIYRepository.addAutomation(newAutomation) + } else { + val newAutomation = Automation( + id = if (isEditMode) existingAutomation!!.id else java.util.UUID.randomUUID().toString(), + type = Automation.Type.STATE, + state = selectedState, + entryAction = selectedInAction, + exitAction = selectedOutAction + ) + if (isEditMode) DIYRepository.updateAutomation(newAutomation) else DIYRepository.addAutomation(newAutomation) + } + finish() + }, + modifier = Modifier.weight(1f), + enabled = isValid + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_check_24), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text(stringResource(R.string.action_save)) + } + } + } + } + } + } + } +} + +@Composable +fun EditorActionItem( + title: String, + iconRes: Int, + isSelected: Boolean, + isConfigurable: Boolean = false, + onClick: () -> Unit, + onSettingsClick: () -> Unit = {}, + modifier: Modifier = Modifier +) { + val view = LocalView.current + Row( + modifier = modifier + .fillMaxWidth() + .clickable { + HapticUtil.performUIHaptic(view) + onClick() + } + .background( + color = MaterialTheme.colorScheme.surfaceBright, + shape = RoundedCornerShape(MaterialTheme.shapes.extraSmall.bottomEnd) + ) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + RadioButton( + selected = isSelected, + onClick = onClick + ) + + Icon( + painter = painterResource(id = iconRes), + contentDescription = title, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + color = if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (isSelected && isConfigurable) { + IconButton(onClick = onSettingsClick) { + Icon( + painter = painterResource(id = R.drawable.rounded_settings_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/ReusableTopAppBar.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/ReusableTopAppBar.kt index 80c1b9e6..055fc628 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/ReusableTopAppBar.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/ReusableTopAppBar.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -50,20 +51,14 @@ fun ReusableTopAppBar( scrollBehavior: TopAppBarScrollBehavior? = null, subtitle: Any? = null, // Can be Int or String isBeta: Boolean = false, + backIconRes: Int = R.drawable.rounded_arrow_back_24, + isSmall: Boolean = false, actions: @Composable RowScope.() -> Unit = {} ) { val collapsedFraction = scrollBehavior?.state?.collapsedFraction ?: 0f collapsedFraction > 0.5f - LargeFlexibleTopAppBar( - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ), - modifier = Modifier.padding(horizontal = 8.dp), - expandedHeight = if (subtitle != null) 200.dp else 160.dp, - collapsedHeight = 64.dp, - - title = { + val titleContent: @Composable () -> Unit = { val resolvedTitle = when (title) { is Int -> stringResource(id = title) is String -> title @@ -138,8 +133,9 @@ fun ReusableTopAppBar( } } } - }, - navigationIcon = { + } + + val navigationIconContent: @Composable () -> Unit = { if (hasBack) { val view = LocalView.current IconButton( @@ -153,14 +149,15 @@ fun ReusableTopAppBar( modifier = Modifier.size(48.dp) ) { Icon( - painter = painterResource(id = R.drawable.rounded_arrow_back_24), + painter = painterResource(id = backIconRes), contentDescription = stringResource(R.string.action_back), modifier = Modifier.size(32.dp) ) } } - }, - actions = { + } + + val actionsContent: @Composable RowScope.() -> Unit = { actions() if (hasHelp) { @@ -239,7 +236,31 @@ fun ReusableTopAppBar( ) } } - }, - scrollBehavior = scrollBehavior - ) + } + + if (isSmall) { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ), + modifier = Modifier.padding(horizontal = 8.dp), + title = titleContent, + navigationIcon = navigationIconContent, + actions = actionsContent, + scrollBehavior = scrollBehavior + ) + } else { + LargeFlexibleTopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ), + modifier = Modifier.padding(horizontal = 8.dp), + expandedHeight = if (subtitle != null) 200.dp else 160.dp, + collapsedHeight = 64.dp, + title = titleContent, + navigationIcon = navigationIconContent, + actions = actionsContent, + scrollBehavior = scrollBehavior + ) + } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/diy/AutomationItem.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/diy/AutomationItem.kt new file mode 100644 index 00000000..c2aebb8f --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/diy/AutomationItem.kt @@ -0,0 +1,308 @@ +package com.sameerasw.essentials.ui.components.diy + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.diy.Action +import com.sameerasw.essentials.domain.diy.Automation +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MenuDefaults +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.Color +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.DpOffset +import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer +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 AutomationItem( + automation: Automation, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + onDelete: () -> Unit = {}, + onToggle: () -> Unit = {} +) { + + 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 + } + ) + .alpha(if (automation.isEnabled) 1f else 0.5f) + ) { + Box { + // Dropdown Menu + // Dropdown Menu + SegmentedDropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + offset = DpOffset(0.dp, 0.dp), + ) { + val toggleText = if (automation.isEnabled) stringResource(R.string.action_disable) else stringResource(R.string.action_enable) + val toggleIcon = if (automation.isEnabled) R.drawable.rounded_close_24 else R.drawable.rounded_check_24 + + SegmentedDropdownMenuItem( + text = { Text(toggleText) }, + onClick = { + showMenu = false + onToggle() + }, + leadingIcon = { + Icon( + painter = painterResource(toggleIcon), + contentDescription = null + ) + } + ) + SegmentedDropdownMenuItem( + text = { Text(stringResource(R.string.action_edit)) }, + onClick = { + showMenu = false + onClick() + }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.rounded_edit_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(8.dp) + .height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically + ) { + RoundedCardContainer( + cornerRadius = 18.dp, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) { + val icon = + if (automation.type == Automation.Type.TRIGGER) automation.trigger?.icon else automation.state?.icon + val title = + if (automation.type == Automation.Type.TRIGGER) automation.trigger?.title else automation.state?.title + + if (icon != null && title != null) { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxSize() + ) { + Row( + modifier = Modifier + .padding(12.dp) + .fillMaxHeight(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = icon), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(id = title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + + + if (automation.type == Automation.Type.TRIGGER) { + // Separator Icon + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .padding(horizontal = 3.dp) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.rounded_arrow_forward_24), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } else { + Column( + modifier = Modifier.fillMaxHeight(), + verticalArrangement = Arrangement.SpaceAround, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .padding(horizontal = 3.dp) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.rounded_arrow_forward_24), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .padding(horizontal = 3.dp) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.rounded_arrow_back_24), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + + // Right Side: Actions (Weight 1 to fill space) + + RoundedCardContainer( + cornerRadius = 18.dp, + modifier = Modifier + .weight(1f), + ) { + if (automation.type == Automation.Type.TRIGGER) { + automation.actions.forEach { action -> + ActionItem(action = action) + } + } else { + // State Actions (In/Out) + // In Action (Top) + automation.entryAction?.let { action -> + ActionItem(action = action) + } + + // Out Action (Bottom) + automation.exitAction?.let { action -> + ActionItem(action = action) + } + } + } + } + } + } +} + +@Composable +fun ActionItem( + action: Action, + modifier: Modifier = Modifier +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(4.dp), + modifier = modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = action.icon), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(id = action.title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/menus/SegmentedMenu.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/menus/SegmentedMenu.kt new file mode 100644 index 00000000..5ea57e20 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/menus/SegmentedMenu.kt @@ -0,0 +1,71 @@ +package com.sameerasw.essentials.ui.components.menus + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.MenuItemColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer + +@Composable +fun SegmentedDropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + offset: DpOffset = DpOffset(0.dp, 0.dp), + content: @Composable ColumnScope.() -> Unit +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + modifier = modifier, + offset = offset, + containerColor = Color.Transparent, + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + RoundedCardContainer( + cornerRadius = 16.dp, + spacing = 2.dp, + content = content + ) + } +} + +@Composable +fun SegmentedDropdownMenuItem( + text: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + enabled: Boolean = true, + colors: MenuItemColors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.onSurface, + leadingIconColor = MaterialTheme.colorScheme.onSurface, + trailingIconColor = MaterialTheme.colorScheme.onSurface, + disabledTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + disabledLeadingIconColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ) +) { + DropdownMenuItem( + text = text, + onClick = onClick, + modifier = modifier + .clip(MaterialTheme.shapes.extraSmall) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + enabled = enabled, + colors = colors + ) +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/SegmentedPicker.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/SegmentedPicker.kt index f95741b4..7e4c3a4a 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/SegmentedPicker.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/SegmentedPicker.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ButtonGroupDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -24,14 +26,15 @@ fun SegmentedPicker( selectedItem: T, onItemSelected: (T) -> Unit, labelProvider: (T) -> String, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + cornerShape: CornerSize = MaterialTheme.shapes.extraSmall.bottomEnd, ) { val view = androidx.compose.ui.platform.LocalView.current Row( modifier = modifier .background( color = MaterialTheme.colorScheme.surfaceBright, - shape = RoundedCornerShape(MaterialTheme.shapes.extraSmall.bottomEnd) + shape = RoundedCornerShape(cornerShape) ) .padding(10.dp), horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/DimWallpaperSettingsSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/DimWallpaperSettingsSheet.kt new file mode 100644 index 00000000..6ce364fa --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/DimWallpaperSettingsSheet.kt @@ -0,0 +1,170 @@ +package com.sameerasw.essentials.ui.components.sheets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +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.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.diy.Action +import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer +import com.sameerasw.essentials.utils.HapticUtil + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DimWallpaperSettingsSheet( + initialAction: Action.DimWallpaper, + onDismiss: () -> Unit, + onSave: (Action.DimWallpaper) -> Unit +) { + val view = LocalView.current + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + var dimAmount by remember { mutableFloatStateOf(initialAction.dimAmount) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainer, + dragHandle = null + ) { + Column( + modifier = Modifier + .padding(24.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Text( + text = stringResource(R.string.diy_action_dim_wallpaper), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + // Permissions Info + RoundedCardContainer { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + painter = painterResource(R.drawable.rounded_info_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(R.string.diy_dim_wallpaper_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Permission Icons + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Shizuku Icon + Icon( + painter = painterResource(R.drawable.rounded_adb_24), + contentDescription = "Shizuku", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + // Root Icon + Icon( + painter = painterResource(R.drawable.rounded_numbers_24), + contentDescription = "Root", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Slider + Column { + Text( + text = "Dim Amount: ${(dimAmount * 100).toInt()}%", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Slider( + value = dimAmount, + onValueChange = { + dimAmount = it + HapticUtil.performUIHaptic(view) + }, + valueRange = 0f..1f + ) + } + + // Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onDismiss() + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceBright, + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_close_24), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text(stringResource(R.string.action_cancel)) + } + + Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onSave(initialAction.copy(dimAmount = dimAmount)) + }, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_check_24), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text(stringResource(R.string.action_save)) + } + } + Spacer(modifier = Modifier.size(16.dp)) + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/NewAutomationSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/NewAutomationSheet.kt new file mode 100644 index 00000000..656e5930 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/NewAutomationSheet.kt @@ -0,0 +1,145 @@ +package com.sameerasw.essentials.ui.components.sheets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedIconButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.diy.Automation +import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewAutomationSheet( + onDismiss: () -> Unit, + onOptionSelected: (Automation.Type) -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurface + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp, start = 24.dp, end = 24.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 18.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.diy_editor_new_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + + OutlinedIconButton( + onClick = { /* TODO: Implement import */ }, + enabled = false + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_download_24), + contentDescription = "Import Automation" + ) + } + } + + RoundedCardContainer { + // Trigger Option + AutomationTypeOption( + title = stringResource(R.string.diy_create_trigger_title), + description = stringResource(R.string.diy_create_trigger_desc), + iconRes = R.drawable.rounded_bolt_24, + onClick = { onOptionSelected(Automation.Type.TRIGGER) } + ) + + // State Option + AutomationTypeOption( + title = stringResource(R.string.diy_create_state_title), + description = stringResource(R.string.diy_create_state_desc), + iconRes = R.drawable.rounded_toggle_on_24, + onClick = { onOptionSelected(Automation.Type.STATE) } + ) + } + } + } +} + +@Composable +private fun AutomationTypeOption( + title: String, + description: String, + iconRes: Int, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(4.dp), + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.rounded_chevron_right_24), + contentDescription = null, + modifier = Modifier.padding(end = 4.dp).size(24.dp), + ) + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/ComingSoonDIYScreen.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/ComingSoonDIYScreen.kt deleted file mode 100644 index cc0a7020..00000000 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/ComingSoonDIYScreen.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.sameerasw.essentials.ui.composables - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.sameerasw.essentials.R - -@Composable -fun ComingSoonDIYScreen( - modifier: Modifier = Modifier -) { - Column( - modifier = modifier - .fillMaxSize() - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - painter = painterResource(id = R.drawable.rounded_experiment_24), - contentDescription = null, - modifier = Modifier.size(80.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) - ) - - Spacer(modifier = Modifier.height(24.dp)) - - Text( - text = stringResource(R.string.coming_soon_title), - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = stringResource(R.string.coming_soon_desc), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(32.dp)) - - Text( - text = stringResource(R.string.coming_soon_label), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ) - } -} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/DIYScreen.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/DIYScreen.kt new file mode 100644 index 00000000..acb5d3a2 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/DIYScreen.kt @@ -0,0 +1,123 @@ +package com.sameerasw.essentials.ui.composables + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.LocalFocusManager +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.sameerasw.essentials.ui.components.diy.AutomationItem +import com.sameerasw.essentials.viewmodels.DIYViewModel + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.sameerasw.essentials.R +import com.sameerasw.essentials.ui.activities.AutomationEditorActivity +import com.sameerasw.essentials.ui.components.sheets.NewAutomationSheet + +@Composable +fun DIYScreen( + modifier: Modifier = Modifier, + viewModel: DIYViewModel = viewModel() +) { + val context = LocalContext.current + val automations by viewModel.automations.collectAsState() + val focusManager = LocalFocusManager.current + + var showNewAutomationSheet by remember { mutableStateOf(false) } + + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures(onTap = { focusManager.clearFocus() }) + } + .padding(24.dp), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.Start + ) { + if (automations.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No automations yet" + ) + } + } else { + LazyColumn( + modifier = Modifier + .clip(RoundedCornerShape(24.dp)), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + items(automations) { automation -> + AutomationItem( + automation = automation, + onClick = { + context.startActivity(AutomationEditorActivity.createIntent(context, automation.id)) + }, + onDelete = { + viewModel.deleteAutomation(automation.id) + }, + onToggle = { + viewModel.toggleAutomation(automation.id) + } + ) + } + } + } + } + + // FAB + FloatingActionButton( + onClick = { showNewAutomationSheet = true }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(bottom = 32.dp, end = 32.dp), + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_add_24), + contentDescription = stringResource(R.string.diy_editor_new_title) + ) + } + } + + if (showNewAutomationSheet) { + NewAutomationSheet( + onDismiss = { showNewAutomationSheet = false }, + onOptionSelected = { type -> + showNewAutomationSheet = false + context.startActivity(AutomationEditorActivity.createIntent(context, type)) + } + ) + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/DIYViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/DIYViewModel.kt new file mode 100644 index 00000000..323b8d84 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/DIYViewModel.kt @@ -0,0 +1,35 @@ +package com.sameerasw.essentials.viewmodels + +import com.sameerasw.essentials.domain.diy.DIYRepository +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.sameerasw.essentials.domain.diy.Automation +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn + +class DIYViewModel(application: Application) : AndroidViewModel(application) { + private val repository = DIYRepository + + init { + repository.init(application) + } + + val automations: StateFlow> = repository.automations + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + + fun deleteAutomation(id: String) { + repository.removeAutomation(id) + } + + fun toggleAutomation(id: String) { + repository.getAutomation(id)?.let { automation -> + repository.updateAutomation(automation.copy(isEnabled = !automation.isEnabled)) + } + } +} diff --git a/app/src/main/res/drawable/outline_bubble_chart_24.xml b/app/src/main/res/drawable/outline_bubble_chart_24.xml new file mode 100644 index 00000000..f733c97e --- /dev/null +++ b/app/src/main/res/drawable/outline_bubble_chart_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_arrows_outward_24.xml b/app/src/main/res/drawable/rounded_arrows_outward_24.xml new file mode 100644 index 00000000..aedd03af --- /dev/null +++ b/app/src/main/res/drawable/rounded_arrows_outward_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_battery_android_frame_3_24.xml b/app/src/main/res/drawable/rounded_battery_android_frame_3_24.xml new file mode 100644 index 00000000..5857b3ba --- /dev/null +++ b/app/src/main/res/drawable/rounded_battery_android_frame_3_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_battery_charging_60_24.xml b/app/src/main/res/drawable/rounded_battery_charging_60_24.xml new file mode 100644 index 00000000..afe2b22b --- /dev/null +++ b/app/src/main/res/drawable/rounded_battery_charging_60_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_bolt_24.xml b/app/src/main/res/drawable/rounded_bolt_24.xml new file mode 100644 index 00000000..3d7c205e --- /dev/null +++ b/app/src/main/res/drawable/rounded_bolt_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_check_24.xml b/app/src/main/res/drawable/rounded_check_24.xml new file mode 100644 index 00000000..eb2564df --- /dev/null +++ b/app/src/main/res/drawable/rounded_check_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_compare_arrows_24.xml b/app/src/main/res/drawable/rounded_compare_arrows_24.xml new file mode 100644 index 00000000..4bee4974 --- /dev/null +++ b/app/src/main/res/drawable/rounded_compare_arrows_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/rounded_download_24.xml b/app/src/main/res/drawable/rounded_download_24.xml new file mode 100644 index 00000000..786601a9 --- /dev/null +++ b/app/src/main/res/drawable/rounded_download_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/rounded_mobile_cancel_24.xml b/app/src/main/res/drawable/rounded_mobile_cancel_24.xml new file mode 100644 index 00000000..fbe44d51 --- /dev/null +++ b/app/src/main/res/drawable/rounded_mobile_cancel_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_mobile_screensaver_24.xml b/app/src/main/res/drawable/rounded_mobile_screensaver_24.xml new file mode 100644 index 00000000..4344fcb5 --- /dev/null +++ b/app/src/main/res/drawable/rounded_mobile_screensaver_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_mobile_unlock_24.xml b/app/src/main/res/drawable/rounded_mobile_unlock_24.xml new file mode 100644 index 00000000..6cb81fdc --- /dev/null +++ b/app/src/main/res/drawable/rounded_mobile_unlock_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_toggle_on_24.xml b/app/src/main/res/drawable/rounded_toggle_on_24.xml new file mode 100644 index 00000000..13e6dd3b --- /dev/null +++ b/app/src/main/res/drawable/rounded_toggle_on_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 76baa3ef..c805a70d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -263,6 +263,14 @@ Light up for notifications Sound mode tile QS tile to toggle sound mode + + + Trigger Automation + Schedule an action to trigger on an observation + State Automation + Schedule an action to execute based on the state of a condition in and out + New Automation + Edit Automation Link actions Handle links with multiple apps Snooze system notifications @@ -411,6 +419,44 @@ Do It Yourself Something experimental is brewing here. Stay tuned for automations and advanced mods. Coming Soon + Trigger + State + Action + In + Out + Automation + Screen Off + Screen On + Device Unlock + Charger Connected + Charger Disconnected + + Charging + Screen On + + Vibrate + Show Notification + Remove Notification + Turn On Flashlight + Turn Off Flashlight + Toggle Flashlight + Dim Wallpaper + This action requires Shizuku or Root to adjust system wallpaper dimming. + Select Trigger + Select State + Select Action + In Action + Out Action + Cancel + Save + Edit + Delete + Enable + Disable + + Automation Service + Automations Active + Monitoring system events for your automations Sameera Wijerathna diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml index cc4d807c..77b3812c 100644 --- a/app/src/main/res/xml/shortcuts.xml +++ b/app/src/main/res/xml/shortcuts.xml @@ -9,7 +9,7 @@ + android:targetClass="com.sameerasw.essentials.ui.activities.AppFreezingActivity" /> diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ee5dedc0..d269d688 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ ui = "1.10.0" foundation = "1.10.0" playServicesLocation = "21.3.0" material = "1.13.0" +material3 = "1.4.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -40,6 +41,7 @@ androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" } play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }