From 62a761cb394be2b999eb994641f0175fb1a6ab04 Mon Sep 17 00:00:00 2001 From: "Thomas Borgogno (personal)" Date: Mon, 24 Aug 2026 12:42:18 +0200 Subject: [PATCH 1/3] feat: introduce Conscious Gate for mindful app pauses --- app/src/main/AndroidManifest.xml | 8 + .../data/repository/SettingsRepository.kt | 67 ++++ .../model/ConsciousGateCountdownStyle.kt | 17 + .../domain/registry/FeatureRegistry.kt | 59 +++ .../services/AppDetectionService.kt | 13 + .../services/handlers/AppFlowHandler.kt | 97 +++++ .../tiles/ScreenOffAccessibilityService.kt | 7 + .../ui/activities/ConsciousGateActivity.kt | 174 +++++++++ .../ui/activities/FeatureSettingsActivity.kt | 13 + .../ui/activities/SettingsActivity.kt | 3 +- .../ui/composables/SetupFeatures.kt | 46 +-- .../consciousgate/ConsciousGateCountdown.kt | 230 +++++++++++ .../ConsciousGateCountdownStylePicker.kt | 96 +++++ .../consciousgate/ConsciousGateIconPicker.kt | 128 ++++++ .../consciousgate/ConsciousGatePauseScreen.kt | 271 +++++++++++++ .../consciousgate/ConsciousGatePreview.kt | 77 ++++ .../consciousgate/ConsciousGateSettingsUI.kt | 364 ++++++++++++++++++ .../display/EssentialsOnDisplaySettingsUI.kt | 6 +- .../com/sameerasw/essentials/utils/AppUtil.kt | 18 + .../essentials/utils/PermissionUtils.kt | 15 + .../essentials/utils/ServiceUtils.kt | 7 +- .../essentials/utils/ui/PermissionUIHelper.kt | 5 +- .../essentials/viewmodels/MainViewModel.kt | 79 ++++ app/src/main/res/drawable/rounded_eco_24.xml | 14 + .../drawable/rounded_health_and_safety_24.xml | 14 + .../res/drawable/rounded_potted_plant_24.xml | 14 + .../drawable/rounded_self_improvement_24.xml | 14 + .../rounded_sentiment_satisfied_24.xml | 14 + .../rounded_sentiment_very_satisfied_24.xml | 14 + app/src/main/res/drawable/rounded_spa_24.xml | 14 + .../rounded_volunteer_activism_24.xml | 14 + .../main/res/drawable/rounded_wb_sunny_24.xml | 14 + app/src/main/res/values/strings.xml | 43 +++ .../res/xml/accessibility_service_config.xml | 3 +- 34 files changed, 1928 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/domain/model/ConsciousGateCountdownStyle.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/activities/ConsciousGateActivity.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateCountdown.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateCountdownStylePicker.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateIconPicker.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePauseScreen.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePreview.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateSettingsUI.kt create mode 100644 app/src/main/res/drawable/rounded_eco_24.xml create mode 100644 app/src/main/res/drawable/rounded_health_and_safety_24.xml create mode 100644 app/src/main/res/drawable/rounded_potted_plant_24.xml create mode 100644 app/src/main/res/drawable/rounded_self_improvement_24.xml create mode 100644 app/src/main/res/drawable/rounded_sentiment_satisfied_24.xml create mode 100644 app/src/main/res/drawable/rounded_sentiment_very_satisfied_24.xml create mode 100644 app/src/main/res/drawable/rounded_spa_24.xml create mode 100644 app/src/main/res/drawable/rounded_volunteer_activism_24.xml create mode 100644 app/src/main/res/drawable/rounded_wb_sunny_24.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9e3ccfce3..703a17e55 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -334,6 +334,14 @@ android:taskAffinity="" android:theme="@style/Theme.Essentials.Translucent" /> + + ] Target apps. + */ + fun saveConsciousGateSelectedApps(apps: List) = saveAppSelection(KEY_CONSCIOUS_GATE_SELECTED_APPS, apps) + + /** + * Executes the update conscious gate app selection operation. + * + * @param packageName [String] Target package name. + * @param enabled [Boolean] Target enabled. + */ + fun updateConsciousGateAppSelection( + packageName: String, + enabled: Boolean, + ) = updateAppSelection(KEY_CONSCIOUS_GATE_SELECTED_APPS, packageName, enabled) + + fun getConsciousGateDelaySeconds(): Int = prefs.getInt(KEY_CONSCIOUS_GATE_DELAY_SECONDS, 5) + + fun setConsciousGateDelaySeconds(seconds: Int) = putInt(KEY_CONSCIOUS_GATE_DELAY_SECONDS, seconds) + + fun getConsciousGateReappearMinutes(): Int = prefs.getInt(KEY_CONSCIOUS_GATE_REAPPEAR_MINUTES, 0) + + fun setConsciousGateReappearMinutes(minutes: Int) = putInt(KEY_CONSCIOUS_GATE_REAPPEAR_MINUTES, minutes) + + fun getConsciousGateIconName(): String = prefs.getString(KEY_CONSCIOUS_GATE_ICON_NAME, "rounded_pause_24") ?: "rounded_pause_24" + + fun setConsciousGateIconName(iconName: String) = putString(KEY_CONSCIOUS_GATE_ICON_NAME, iconName) + + fun getConsciousGateTitle(context: Context = this.context): String = + prefs.getString(KEY_CONSCIOUS_GATE_TITLE, null) ?: context.getString(com.sameerasw.essentials.R.string.conscious_gate_default_title) + + fun setConsciousGateTitle(title: String) = putString(KEY_CONSCIOUS_GATE_TITLE, title) + + fun getConsciousGateMessage(context: Context = this.context): String = + prefs.getString(KEY_CONSCIOUS_GATE_MESSAGE, null) ?: context.getString(com.sameerasw.essentials.R.string.conscious_gate_default_message) + + fun setConsciousGateMessage(message: String) = putString(KEY_CONSCIOUS_GATE_MESSAGE, message) + + fun getConsciousGateCountdownStyle(): ConsciousGateCountdownStyle { + val styleName = + prefs.getString(KEY_CONSCIOUS_GATE_COUNTDOWN_STYLE, ConsciousGateCountdownStyle.CIRCULAR_WAVY.name) + return try { + ConsciousGateCountdownStyle.valueOf(styleName ?: ConsciousGateCountdownStyle.CIRCULAR_WAVY.name) + } catch (e: Exception) { + ConsciousGateCountdownStyle.CIRCULAR_WAVY + } + } + + fun setConsciousGateCountdownStyle(style: ConsciousGateCountdownStyle) = putString(KEY_CONSCIOUS_GATE_COUNTDOWN_STYLE, style.name) + /** * Executes the load freeze selected apps operation. */ diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/ConsciousGateCountdownStyle.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/ConsciousGateCountdownStyle.kt new file mode 100644 index 000000000..a0606168d --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/ConsciousGateCountdownStyle.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Domain Layer Models & Registries + * File: ConsciousGateCountdownStyle.kt + * Description: Domain model and business logic entry for ConsciousGateCountdownStyle.kt. + */ + +package com.sameerasw.essentials.domain.model + +enum class ConsciousGateCountdownStyle { + CIRCULAR_WAVY, + LINEAR_WAVY, + LOADING_BLOB, + BREATHING_DOT, +} diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt index 69b058991..ff8f230ee 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt @@ -1454,6 +1454,65 @@ object FeatureRegistry { enabled: Boolean, ) = viewModel.setAppLockEnabled(enabled, context) }, + object : Feature( + id = "Conscious gate", + title = R.string.feat_conscious_gate_title, + iconRes = R.drawable.rounded_pause_24, + category = R.string.cat_utils, + description = R.string.feat_conscious_gate_desc, + aboutDescription = R.string.about_desc_conscious_gate, + searchableSettings = + listOf( + SearchSetting( + R.string.search_conscious_gate_enable_title, + R.string.search_conscious_gate_enable_desc, + "conscious_gate_enabled", + R.array.keywords_privacy, + ), + SearchSetting( + R.string.search_conscious_gate_pick_title, + R.string.search_conscious_gate_pick_desc, + "conscious_gate_selected_apps", + R.array.keywords_selection, + ), + SearchSetting( + R.string.search_conscious_gate_delay_title, + R.string.search_conscious_gate_delay_desc, + "conscious_gate_delay_seconds", + ), + ), + ) { + override val permissionKeys: List + get() = + if (com.sameerasw.essentials.data.repository + .SettingsRepository( + EssentialsApp.context, + ).getBoolean(com.sameerasw.essentials.data.repository.SettingsRepository.KEY_USE_USAGE_ACCESS) + ) { + listOf("USAGE_STATS", "ACCESSIBILITY") + } else { + listOf("ACCESSIBILITY") + } + + override fun isEnabled(viewModel: MainViewModel) = viewModel.isConsciousGateEnabled.value + + override fun isToggleEnabled( + viewModel: MainViewModel, + context: Context, + ) = ( + if (viewModel.isUseUsageAccess.value) { + viewModel.isUsageStatsPermissionGranted.value + } else { + viewModel.isAccessibilityEnabled.value + } + ) + + override fun onToggle( + viewModel: MainViewModel, + context: Context, + enabled: Boolean, + ) = viewModel.setConsciousGateEnabled(enabled, context) + }, object : Feature( id = "Shut-Up!", title = R.string.feat_shut_up_title, diff --git a/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt b/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt index c8827972b..2ae08663b 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt @@ -58,6 +58,17 @@ class AppDetectionService : Service() { "APP_AUTHENTICATION_FAILED" -> { goHome() } + + "CONSCIOUS_GATE_CONFIRMED" -> { + val packageName = intent.getStringExtra("package_name") + if (packageName != null) { + appFlowHandler.onConsciousGateConfirmed(packageName) + } + } + + "CONSCIOUS_GATE_CLOSED" -> { + goHome() + } } } } @@ -72,6 +83,8 @@ class AppDetectionService : Service() { IntentFilter().apply { addAction("APP_AUTHENTICATED") addAction("APP_AUTHENTICATION_FAILED") + addAction("CONSCIOUS_GATE_CONFIRMED") + addAction("CONSCIOUS_GATE_CLOSED") } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { registerReceiver(authReceiver, filter, RECEIVER_EXPORTED) diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt index 387cee4ac..ca2fde737 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt @@ -113,6 +113,12 @@ class AppFlowHandler( private set private var currentUsageStatsPackage: String? = null + // Conscious Gate State + private var gatingPackage: String? = null + private var lastGateRequestTime: Long = 0 + private val confirmedGatePackages = mutableMapOf() + private val pendingReappearRunnables = mutableMapOf() + // App Automation State private val activeAppAutomationIds = mutableSetOf() @@ -142,11 +148,17 @@ class AppFlowHandler( if (oldPackage != null && oldPackage != packageName) { lastLeaveTimes[oldPackage] = System.currentTimeMillis() checkShutUpRestore(oldPackage, packageName) + confirmedGatePackages.remove(oldPackage) + pendingReappearRunnables.remove(oldPackage)?.let { handler.removeCallbacks(it) } } if (packageName != context.packageName && packageName != lockingPackage) { lockingPackage = null } + if (packageName != context.packageName && packageName != gatingPackage) { + gatingPackage = null + } checkAppLock(packageName) + checkConsciousGate(packageName) checkHighlightNightLight(packageName) checkAppAutomations(packageName) checkGestureBarAutomation(packageName) @@ -235,6 +247,91 @@ class AppFlowHandler( } } + private fun checkConsciousGate(packageName: String) { + val prefs = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) + val isEnabled = prefs.getBoolean("conscious_gate_enabled", false) + if (!isEnabled) return + + if (packageName == context.packageName) { + return + } + + val json = prefs.getString("conscious_gate_selected_apps", null) + val selectedApps: List = + if (json != null) { + try { + Gson().fromJson(json, Array::class.java).toList() + } catch (_: Exception) { + emptyList() + } + } else { + emptyList() + } + + val isGated = selectedApps.find { it.packageName == packageName }?.isEnabled ?: false + if (!isGated) return + + if (confirmedGatePackages.containsKey(packageName)) { + // Already confirmed for this continuous session; the reappear timer (if any) + // is scheduled separately from onConsciousGateConfirmed. + return + } + + val now = System.currentTimeMillis() + if (packageName == gatingPackage && now - lastGateRequestTime < 1500) { + return + } + + gatingPackage = packageName + lastGateRequestTime = now + + val delaySeconds = prefs.getInt("conscious_gate_delay_seconds", 5) + val iconName = prefs.getString("conscious_gate_icon_name", null) ?: "rounded_pause_24" + val title = prefs.getString("conscious_gate_title", null) + val message = prefs.getString("conscious_gate_message", null) + val countdownStyle = prefs.getString("conscious_gate_countdown_style", null) ?: "CIRCULAR_WAVY" + + Log.d("ConsciousGate", "App $packageName is gated and not confirmed. Showing pause screen.") + val intent = + Intent().apply { + component = ComponentName(context, "com.sameerasw.essentials.ui.activities.ConsciousGateActivity") + putExtra("package_to_gate", packageName) + putExtra("delay_seconds", delaySeconds) + putExtra("icon_name", iconName) + title?.let { putExtra("title", it) } + message?.let { putExtra("message", it) } + putExtra("countdown_style", countdownStyle) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION + } + context.startActivity(intent) + } + + fun onConsciousGateConfirmed(packageName: String) { + confirmedGatePackages[packageName] = System.currentTimeMillis() + if (packageName == gatingPackage) { + gatingPackage = null + } + + pendingReappearRunnables.remove(packageName)?.let { handler.removeCallbacks(it) } + + val prefs = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) + val reappearMinutes = prefs.getInt("conscious_gate_reappear_minutes", 0) + if (reappearMinutes > 0) { + val runnable = + Runnable { + pendingReappearRunnables.remove(packageName) + if (currentPackage == packageName) { + confirmedGatePackages.remove(packageName) + checkConsciousGate(packageName) + } else { + confirmedGatePackages.remove(packageName) + } + } + pendingReappearRunnables[packageName] = runnable + handler.postDelayed(runnable, reappearMinutes * 60 * 1000L) + } + } + private fun checkHighlightNightLight(packageName: String) { val prefs = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) val isEnabled = prefs.getBoolean("dynamic_night_light_enabled", false) diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt index 72f1cb7f9..2506a3b63 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt @@ -672,6 +672,13 @@ class ScreenOffAccessibilityService : "APP_AUTHENTICATION_FAILED" -> performGlobalAction(GLOBAL_ACTION_HOME) + "CONSCIOUS_GATE_CONFIRMED" -> + intent + .getStringExtra("package_name") + ?.let { appFlowHandler.onConsciousGateConfirmed(it) } + + "CONSCIOUS_GATE_CLOSED" -> performGlobalAction(GLOBAL_ACTION_HOME) + FlashlightActionReceiver.ACTION_INCREASE, FlashlightActionReceiver.ACTION_DECREASE, FlashlightActionReceiver.ACTION_OFF, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/ConsciousGateActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/ConsciousGateActivity.kt new file mode 100644 index 000000000..6a02c93b7 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/ConsciousGateActivity.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Application Activities + * File: ConsciousGateActivity.kt + * Description: Activity component for ConsciousGateActivity.kt. + */ + +package com.sameerasw.essentials.ui.activities + +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.model.ConsciousGateCountdownStyle +import com.sameerasw.essentials.services.tiles.ScreenOffAccessibilityService +import com.sameerasw.essentials.ui.features.consciousgate.ConsciousGatePauseScreen +import com.sameerasw.essentials.ui.theme.EssentialsTheme +import kotlinx.coroutines.delay + +class ConsciousGateActivity : AppCompatActivity() { + private var packageToGate: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + packageToGate = intent.getStringExtra("package_to_gate") + if (packageToGate == null) { + finish() + return + } + + val appLabel = + try { + val appInfo = packageManager.getApplicationInfo(packageToGate!!, 0) + packageManager.getApplicationLabel(appInfo).toString() + } catch (e: PackageManager.NameNotFoundException) { + packageToGate + } + + val delaySeconds = intent.getIntExtra("delay_seconds", 5).coerceAtLeast(0) + val iconName = intent.getStringExtra("icon_name") ?: "rounded_pause_24" + val title = intent.getStringExtra("title") + val message = intent.getStringExtra("message") + val countdownStyle = + try { + ConsciousGateCountdownStyle.valueOf( + intent.getStringExtra("countdown_style") ?: ConsciousGateCountdownStyle.CIRCULAR_WAVY.name, + ) + } catch (e: Exception) { + ConsciousGateCountdownStyle.CIRCULAR_WAVY + } + + setContent { + EssentialsTheme { + ConsciousGateScreen( + appLabel = appLabel ?: "", + iconName = iconName, + title = title, + message = message, + delaySeconds = delaySeconds, + countdownStyle = countdownStyle, + onClose = ::notifyClosedAndFinish, + onContinue = ::notifyConfirmedAndFinish, + ) + } + } + } + + @Composable + private fun ConsciousGateScreen( + appLabel: String, + iconName: String, + title: String?, + message: String?, + delaySeconds: Int, + countdownStyle: ConsciousGateCountdownStyle, + onClose: () -> Unit, + onContinue: () -> Unit, + ) { + val iconResId = + remember(iconName) { + resources + .getIdentifier(iconName, "drawable", packageName) + .takeIf { it != 0 } ?: R.drawable.rounded_pause_24 + } + + var progress by remember { mutableFloatStateOf(if (delaySeconds <= 0) 1f else 0f) } + + LaunchedEffect(delaySeconds) { + if (delaySeconds <= 0) { + progress = 1f + return@LaunchedEffect + } + val totalMillis = delaySeconds * 1000L + val startTime = System.currentTimeMillis() + while (true) { + val elapsed = System.currentTimeMillis() - startTime + progress = (elapsed.toFloat() / totalMillis).coerceIn(0f, 1f) + if (progress >= 1f) break + delay(16) + } + } + + ConsciousGatePauseScreen( + iconResId = iconResId, + title = title?.takeIf { it.isNotBlank() } ?: stringResource(R.string.conscious_gate_default_title), + message = message?.takeIf { it.isNotBlank() } ?: stringResource(R.string.conscious_gate_default_message), + targetAppLabel = appLabel, + countdownStyle = countdownStyle, + progress = { progress }, + isContinueEnabled = progress >= 1f, + onClose = onClose, + onContinue = onContinue, + ) + } + + private fun notifyConfirmedAndFinish() { + val intent = + Intent("CONSCIOUS_GATE_CONFIRMED").apply { + `package` = packageName + putExtra("package_name", packageToGate) + } + sendBroadcast(intent) + + val accessibilityIntent = + Intent(this, ScreenOffAccessibilityService::class.java).apply { + action = "CONSCIOUS_GATE_CONFIRMED" + putExtra("package_name", packageToGate) + } + startService(accessibilityIntent) + + finishAndTransition() + } + + private fun notifyClosedAndFinish() { + val intent = + Intent("CONSCIOUS_GATE_CLOSED").apply { + `package` = packageName + } + sendBroadcast(intent) + + val serviceIntent = + Intent(this, ScreenOffAccessibilityService::class.java).apply { + action = "CONSCIOUS_GATE_CLOSED" + } + startService(serviceIntent) + + finishAndTransition() + } + + private fun finishAndTransition() { + finish() + if (Build.VERSION.SDK_INT >= 34) { + overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, 0, 0) + } else { + @Suppress("DEPRECATION") + overridePendingTransition(0, 0) + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt index 0b0d02186..d892782eb 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt @@ -349,6 +349,9 @@ class FeatureSettingsActivity : AppCompatActivity() { "App lock" -> !isAccessibilityEnabled || (if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else false) + "Conscious gate" -> + !isAccessibilityEnabled || + (if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else false) "Freeze" -> !com.sameerasw.essentials.utils.ShellUtils.hasPermission( context, @@ -747,6 +750,9 @@ class FeatureSettingsActivity : AppCompatActivity() { "App lock" -> !isAccessibilityEnabled || (if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else false) + "Conscious gate" -> + !isAccessibilityEnabled || + (if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else false) "Freeze" -> !com.sameerasw.essentials.utils.ShellUtils.hasPermission( context, @@ -1001,6 +1007,13 @@ class FeatureSettingsActivity : AppCompatActivity() { ) } + "Conscious gate" -> { + com.sameerasw.essentials.ui.features.consciousgate.ConsciousGateSettingsUI( + viewModel = viewModel, + highlightKey = highlightSetting, + ) + } + "Freeze" -> { FreezeSettingsUI( viewModel = viewModel, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/SettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/SettingsActivity.kt index 748006a01..30b8da4bc 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/SettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/SettingsActivity.kt @@ -978,8 +978,7 @@ fun SettingsContent( actionLabel = if (isAccessibilityEnabled) "Granted" else "Grant Permission", isGranted = isAccessibilityEnabled, onActionClick = { - val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - context.startActivity(intent) + PermissionUtils.openAccessibilitySettings(context) }, ) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt index d4f6b2729..16c390526 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt @@ -98,6 +98,7 @@ import com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet import com.sameerasw.essentials.utils.BiometricSecurityHelper import com.sameerasw.essentials.utils.DeviceUtils import com.sameerasw.essentials.utils.HapticUtil +import com.sameerasw.essentials.utils.PermissionUtils import com.sameerasw.essentials.viewmodels.MainViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -184,7 +185,7 @@ fun SetupFeatures( description = R.string.perm_accessibility_desc_common, dependentFeatures = PermissionRegistry.getFeatures("ACCESSIBILITY"), action = { - context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) + PermissionUtils.openAccessibilitySettings(context) }, isGranted = isAccessibilityEnabled, ), @@ -251,9 +252,7 @@ fun SetupFeatures( dependentFeatures = PermissionRegistry.getFeatures("ACCESSIBILITY"), actionLabel = R.string.perm_action_enable, action = { - val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - context.startActivity(intent) + PermissionUtils.openAccessibilitySettings(context) }, isGranted = isNotificationLightingAccessibilityEnabled, ), @@ -284,9 +283,7 @@ fun SetupFeatures( dependentFeatures = PermissionRegistry.getFeatures("ACCESSIBILITY"), actionLabel = R.string.perm_action_enable, action = { - val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - context.startActivity(intent) + PermissionUtils.openAccessibilitySettings(context) }, isGranted = isAccessibilityEnabled, ), @@ -304,9 +301,7 @@ fun SetupFeatures( dependentFeatures = PermissionRegistry.getFeatures("ACCESSIBILITY"), actionLabel = R.string.perm_action_enable, action = { - val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - context.startActivity(intent) + PermissionUtils.openAccessibilitySettings(context) }, isGranted = isAccessibilityEnabled, ), @@ -434,9 +429,7 @@ fun SetupFeatures( dependentFeatures = PermissionRegistry.getFeatures("ACCESSIBILITY"), actionLabel = R.string.perm_action_enable, action = { - val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(intent) + PermissionUtils.openAccessibilitySettings(context) }, isGranted = isAccessibilityEnabled, ), @@ -483,10 +476,7 @@ fun SetupFeatures( dependentFeatures = PermissionRegistry.getFeatures("ACCESSIBILITY"), actionLabel = R.string.perm_action_enable, action = { - val intent = - Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - context.startActivity(intent) + PermissionUtils.openAccessibilitySettings(context) }, isGranted = isAccessibilityEnabled, ), @@ -526,7 +516,7 @@ fun SetupFeatures( dependentFeatures = PermissionRegistry.getFeatures("ACCESSIBILITY"), actionLabel = R.string.perm_action_grant, action = { - context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) + PermissionUtils.openAccessibilitySettings(context) }, isGranted = isAccessibilityEnabled, ), @@ -583,9 +573,7 @@ fun SetupFeatures( dependentFeatures = PermissionRegistry.getFeatures("ACCESSIBILITY"), actionLabel = R.string.perm_action_enable, action = { - val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - context.startActivity(intent) + PermissionUtils.openAccessibilitySettings(context) }, isGranted = isNotificationLightingAccessibilityEnabled, ), @@ -609,9 +597,7 @@ fun SetupFeatures( dependentFeatures = PermissionRegistry.getFeatures("ACCESSIBILITY"), actionLabel = R.string.perm_action_enable, action = { - val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - context.startActivity(intent) + PermissionUtils.openAccessibilitySettings(context) }, isGranted = isAccessibilityEnabled, ), @@ -639,9 +625,7 @@ fun SetupFeatures( dependentFeatures = PermissionRegistry.getFeatures("ACCESSIBILITY"), actionLabel = R.string.perm_action_enable, action = { - val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - context.startActivity(intent) + PermissionUtils.openAccessibilitySettings(context) }, isGranted = isAccessibilityEnabled, ), @@ -722,9 +706,7 @@ fun SetupFeatures( dependentFeatures = PermissionRegistry.getFeatures("ACCESSIBILITY"), actionLabel = R.string.perm_action_enable, action = { - val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - context.startActivity(intent) + PermissionUtils.openAccessibilitySettings(context) }, isGranted = isAccessibilityEnabled, ), @@ -761,9 +743,7 @@ fun SetupFeatures( dependentFeatures = PermissionRegistry.getFeatures("ACCESSIBILITY"), actionLabel = R.string.perm_action_enable, action = { - val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - context.startActivity(intent) + PermissionUtils.openAccessibilitySettings(context) }, isGranted = isAccessibilityEnabled, ), diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateCountdown.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateCountdown.kt new file mode 100644 index 000000000..f0110c784 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateCountdown.kt @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - Conscious Gate + * File: ConsciousGateCountdown.kt + * Description: Material 3 Expressive countdown treatments shown above the Conscious Gate + * "Continue" button while the user-configured pause delay elapses. + */ + +package com.sameerasw.essentials.ui.features.consciousgate + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.shape.CircleShape +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.ContainedLoadingIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.domain.model.ConsciousGateCountdownStyle + +/** + * Renders a Material 3 Expressive visualization of [progress] (0f = "just shown", 1f = "ready to + * continue") above [content] (the Continue button). + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ConsciousGateCountdown( + style: ConsciousGateCountdownStyle, + progress: () -> Float, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(72.dp) + .padding(bottom = 16.dp), + contentAlignment = Alignment.Center, + ) { + ConsciousGateCountdownIndicator(style = style, progress = progress) + } + + content() + } +} + +/** + * Just the visual indicator for [style] (no Continue button) — reused both inside + * [ConsciousGateCountdown] and as a small live preview in the countdown-style picker. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ConsciousGateCountdownIndicator( + style: ConsciousGateCountdownStyle, + progress: () -> Float, + modifier: Modifier = Modifier, + indicatorColor: Color = MaterialTheme.colorScheme.primary, + trackColor: Color = MaterialTheme.colorScheme.surfaceContainerHighest, +) { + when (style) { + ConsciousGateCountdownStyle.CIRCULAR_WAVY -> + CircularWavyProgressIndicator( + progress = progress, + modifier = modifier, + color = indicatorColor, + trackColor = trackColor, + ) + + ConsciousGateCountdownStyle.LINEAR_WAVY -> + LinearWavyProgressIndicator( + progress = progress, + modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp), + color = indicatorColor, + trackColor = trackColor, + ) + + ConsciousGateCountdownStyle.LOADING_BLOB -> + ContainedLoadingIndicator( + progress = progress, + modifier = modifier, + containerColor = trackColor, + indicatorColor = indicatorColor, + ) + + ConsciousGateCountdownStyle.BREATHING_DOT -> + BreathingDot(modifier = modifier.size(56.dp), color = indicatorColor) + } +} + +/** + * A large (220dp), prominent version of the countdown animation with [iconResId] centered on + * top of it — used as the hero visual for every countdown style except the linear wavy bar, + * which keeps its own compact layout next to the Continue button. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ConsciousGateHeroAnimation( + style: ConsciousGateCountdownStyle, + progress: () -> Float, + iconResId: Int, + modifier: Modifier = Modifier, +) { + val heroScale = HeroSize / IndicatorNativeSize + + Box( + modifier = modifier.size(HeroSize), + contentAlignment = Alignment.Center, + ) { + when (style) { + ConsciousGateCountdownStyle.CIRCULAR_WAVY ->{ + val stroke = Stroke(width = with(LocalDensity.current) { (10f / heroScale).dp.toPx() }, cap = StrokeCap.Round) + CircularWavyProgressIndicator( + progress = progress, + modifier = Modifier.scale(heroScale), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + stroke = stroke, + trackStroke = stroke, + ) + } + + ConsciousGateCountdownStyle.LOADING_BLOB -> + ContainedLoadingIndicator( + progress = progress, + modifier = Modifier.scale(heroScale), + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + indicatorColor = MaterialTheme.colorScheme.primary, + ) + + ConsciousGateCountdownStyle.BREATHING_DOT -> + BreathingDot( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.primary, + ) + + ConsciousGateCountdownStyle.LINEAR_WAVY -> Unit + } + + val iconTint = + when (style) { + // The center stays mostly on the plain page background, so match that surface. + ConsciousGateCountdownStyle.CIRCULAR_WAVY, + ConsciousGateCountdownStyle.LINEAR_WAVY, + -> MaterialTheme.colorScheme.onBackground + // The center sits on top of a primary-colored fill (blob/dot), so use its + // contrasting "on" color instead. + ConsciousGateCountdownStyle.LOADING_BLOB, + ConsciousGateCountdownStyle.BREATHING_DOT, + -> MaterialTheme.colorScheme.onPrimary + } + + Icon( + painter = painterResource(id = iconResId), + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(64.dp), + ) + } +} + +private val HeroSize = 220.dp +private val IndicatorNativeSize = 48.dp + +@Composable +private fun BreathingDot( + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.primaryContainer, +) { + val infiniteTransition = rememberInfiniteTransition(label = "ConsciousGateBreathingDot") + val breath by + infiniteTransition.animateFloat( + initialValue = 0.8f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1400, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "breathScale", + ) + val alpha by + infiniteTransition.animateFloat( + initialValue = 0.35f, + targetValue = 0.7f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1400, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "breathAlpha", + ) + Box( + modifier = + modifier + .graphicsLayer { + scaleX = breath + scaleY = breath + this.alpha = alpha + }.background(color, CircleShape), + ) +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateCountdownStylePicker.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateCountdownStylePicker.kt new file mode 100644 index 000000000..a72905318 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateCountdownStylePicker.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - Conscious Gate + * File: ConsciousGateCountdownStylePicker.kt + * Description: UI component letting the user pick which Material 3 Expressive countdown + * treatment the Conscious Gate pause screen uses, with a small live preview per option. + */ + +package com.sameerasw.essentials.ui.features.consciousgate + +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.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ToggleButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.domain.model.ConsciousGateCountdownStyle +import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.utils.HapticUtil + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ConsciousGateCountdownStylePicker( + selectedStyle: ConsciousGateCountdownStyle, + onStyleSelected: (ConsciousGateCountdownStyle) -> Unit, + modifier: Modifier = Modifier, +) { + val styles = + listOf( + ConsciousGateCountdownStyle.CIRCULAR_WAVY, + ConsciousGateCountdownStyle.LOADING_BLOB, + ConsciousGateCountdownStyle.BREATHING_DOT, + ConsciousGateCountdownStyle.LINEAR_WAVY, + ) + val view = LocalView.current + val selectedIndex = styles.indexOf(selectedStyle).coerceAtLeast(0) + + RoundedCardContainer(modifier = modifier) { + Row( + modifier = + Modifier + .background( + color = MaterialTheme.colorScheme.surfaceBright, + shape = RoundedCornerShape(MaterialTheme.shapes.extraSmall.bottomEnd), + ).padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), + ) { + val itemModifiers = List(styles.size) { Modifier.weight(1f) } + + styles.forEachIndexed { index, style -> + ToggleButton( + checked = selectedIndex == index, + onCheckedChange = { + HapticUtil.performVirtualKeyHaptic(view) + onStyleSelected(style) + }, + modifier = itemModifiers[index].semantics { role = Role.RadioButton }, + shapes = + when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + styles.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + ) { + val isSelected = selectedIndex == index + val previewColor = + if (isSelected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ConsciousGateCountdownIndicator( + style = style, + progress = { 0.6f }, + modifier = Modifier.size(28.dp), + indicatorColor = previewColor, + trackColor = previewColor.copy(alpha = 0.3f), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateIconPicker.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateIconPicker.kt new file mode 100644 index 000000000..ad0342445 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateIconPicker.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - Conscious Gate + * File: ConsciousGateIconPicker.kt + * Description: UI component letting the user pick the icon shown on the Conscious Gate + * pause screen. + */ + +package com.sameerasw.essentials.ui.features.consciousgate + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.carousel.HorizontalMultiBrowseCarousel +import androidx.compose.material3.carousel.rememberCarouselState +import androidx.compose.runtime.Composable +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.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.utils.HapticUtil + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConsciousGateIconPicker( + selectedIconName: String, + onIconSelected: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val icons = + listOf( + "rounded_favorite_24", + "rounded_heart_smile_24", + "rounded_ecg_heart_24", + "rounded_volunteer_activism_24", + "rounded_self_improvement_24", + "rounded_health_and_safety_24", + "rounded_shield_24", + "rounded_wb_sunny_24", + "rounded_nightlight_24", + "rounded_sentiment_satisfied_24", + "rounded_sentiment_very_satisfied_24", + "rounded_spa_24", + "rounded_eco_24", + "rounded_potted_plant_24", + "rounded_pause_24", + "rounded_timer_24", + "rounded_lock_clock_24", + ) + + val carouselState = rememberCarouselState { icons.size } + val context = LocalContext.current + val view = LocalView.current + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.conscious_gate_icon_picker_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + + HorizontalMultiBrowseCarousel( + state = carouselState, + preferredItemWidth = 64.dp, + minSmallItemWidth = 24.dp, + maxSmallItemWidth = 36.dp, + itemSpacing = 6.dp, + contentPadding = PaddingValues(horizontal = 0.dp), + modifier = + Modifier + .fillMaxWidth() + .height(64.dp), + ) { index -> + val iconName = icons[index] + val isSelected = iconName == selectedIconName + val iconResId = + context.resources.getIdentifier(iconName, "drawable", context.packageName) + + Box( + modifier = + Modifier + .fillMaxSize() + .maskClip(MaterialTheme.shapes.medium) + .background( + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + ).clickable { + HapticUtil.performVirtualKeyHaptic(view) + onIconSelected(iconName) + }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = if (iconResId != 0) iconResId else R.drawable.rounded_pause_24), + contentDescription = null, + tint = + if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(24.dp), + ) + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePauseScreen.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePauseScreen.kt new file mode 100644 index 000000000..2256c07a2 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePauseScreen.kt @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - Conscious Gate + * File: ConsciousGatePauseScreen.kt + * Description: Shared full-screen Conscious Gate screen UI, used both by the real + * ConsciousGateActivity and by the live preview shown in the settings screen. + */ + +package com.sameerasw.essentials.ui.features.consciousgate + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +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.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.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.model.ConsciousGateCountdownStyle +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private const val FadeDurationMillis = 400 + +@Composable +fun ConsciousGatePauseScreen( + iconResId: Int, + title: String, + message: String, + targetAppLabel: String, + countdownStyle: ConsciousGateCountdownStyle, + progress: () -> Float, + isContinueEnabled: Boolean, + onClose: () -> Unit, + onContinue: () -> Unit, + modifier: Modifier = Modifier, +) { + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { visible = true } + val alpha by animateFloatAsState( + targetValue = if (visible) 1f else 0f, + animationSpec = tween(durationMillis = FadeDurationMillis), + label = "ConsciousGateFade", + ) + val scope = rememberCoroutineScope() + fun fadeOutThen(action: () -> Unit) { + scope.launch { + visible = false + delay(FadeDurationMillis.toLong()) + action() + } + } + val fadeOutClose = { fadeOutThen(onClose) } + val fadeOutContinue = { fadeOutThen(onContinue) } + + BackHandler(onBack = fadeOutClose) + + Box( + modifier = + modifier + .fillMaxSize() + .graphicsLayer { this.alpha = alpha } + .background(MaterialTheme.colorScheme.background), + ) { + if (countdownStyle == ConsciousGateCountdownStyle.LINEAR_WAVY) { + LinearStyleLayout( + iconResId = iconResId, + title = title, + message = message, + targetAppLabel = targetAppLabel, + progress = progress, + isContinueEnabled = isContinueEnabled, + onClose = fadeOutClose, + onContinue = fadeOutContinue, + ) + } else { + HeroStyleLayout( + iconResId = iconResId, + title = title, + message = message, + targetAppLabel = targetAppLabel, + countdownStyle = countdownStyle, + progress = progress, + isContinueEnabled = isContinueEnabled, + onClose = fadeOutClose, + onContinue = fadeOutContinue, + ) + } + } +} + +/** Unchanged, original layout: small icon badge up top, countdown next to the Continue button. */ +@Composable +private fun LinearStyleLayout( + iconResId: Int, + title: String, + message: String, + targetAppLabel: String, + progress: () -> Float, + isContinueEnabled: Boolean, + onClose: () -> Unit, + onContinue: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 32.dp) + .padding(top = 120.dp, bottom = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + IconBadge(iconResId) + + Spacer(modifier = Modifier.weight(1f)) + + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.weight(1f)) + + ConsciousGateCountdown( + style = ConsciousGateCountdownStyle.LINEAR_WAVY, + progress = progress, + modifier = Modifier.fillMaxWidth(), + ) { + ContinueButton(targetAppLabel, isContinueEnabled, onContinue) + } + + Spacer(modifier = Modifier.height(16.dp)) + + CloseButton(onClose) + } +} + +/** + * Big centered animation (with the icon at its center) up top, title and message between the + * animation and the buttons. Used for every countdown style except the linear wavy bar. + */ +@Composable +private fun HeroStyleLayout( + iconResId: Int, + title: String, + message: String, + targetAppLabel: String, + countdownStyle: ConsciousGateCountdownStyle, + progress: () -> Float, + isContinueEnabled: Boolean, + onClose: () -> Unit, + onContinue: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 32.dp) + .padding(top = 96.dp, bottom = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + ConsciousGateHeroAnimation( + style = countdownStyle, + progress = progress, + iconResId = iconResId, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.weight(1f)) + + ContinueButton(targetAppLabel, isContinueEnabled, onContinue) + + Spacer(modifier = Modifier.height(16.dp)) + + CloseButton(onClose) + } +} + +@Composable +private fun IconBadge(iconResId: Int) { + Icon( + painter = painterResource(id = iconResId), + contentDescription = null, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.size(96.dp), + ) +} + +@Composable +private fun ContinueButton( + targetAppLabel: String, + isContinueEnabled: Boolean, + onContinue: () -> Unit, +) { + OutlinedButton( + onClick = onContinue, + enabled = isContinueEnabled, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(), + ) { + Text(stringResource(R.string.conscious_gate_continue_on_app, targetAppLabel)) + } +} + +@Composable +private fun CloseButton(onClose: () -> Unit) { + Button( + onClick = onClose, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.conscious_gate_close_button)) + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePreview.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePreview.kt new file mode 100644 index 000000000..2e4816a86 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePreview.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - Conscious Gate + * File: ConsciousGatePreview.kt + * Description: Full-screen, live-looping preview of the Conscious Gate pause screen, shown + * as a dialog on top of the settings screen, reusing the real ConsciousGatePauseScreen + * composable. + */ + +package com.sameerasw.essentials.ui.features.consciousgate + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.model.ConsciousGateCountdownStyle +import com.sameerasw.essentials.ui.theme.EssentialsTheme + +@Composable +fun ConsciousGatePreview( + iconName: String, + title: String, + message: String, + targetAppLabel: String, + countdownStyle: ConsciousGateCountdownStyle, + delaySeconds: Int, + onExit: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val iconResId = + remember(iconName) { + context.resources + .getIdentifier(iconName, "drawable", context.packageName) + .takeIf { it != 0 } ?: R.drawable.rounded_pause_24 + } + + val loopSeconds = delaySeconds.coerceIn(1, 30) + val infiniteTransition = rememberInfiniteTransition(label = "ConsciousGatePreviewProgress") + val progress by + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = loopSeconds * 1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "previewProgress", + ) + + EssentialsTheme { + ConsciousGatePauseScreen( + iconResId = iconResId, + title = title, + message = message, + targetAppLabel = targetAppLabel, + countdownStyle = countdownStyle, + progress = { progress }, + isContinueEnabled = progress >= 1f, + onClose = onExit, + onContinue = onExit, + modifier = modifier.fillMaxSize(), + ) + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateSettingsUI.kt new file mode 100644 index 000000000..223e00c77 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateSettingsUI.kt @@ -0,0 +1,364 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - Conscious Gate + * File: ConsciousGateSettingsUI.kt + * Description: Composable UI for configuring Conscious Gate's target applications, pause + * delay, reappear-after-usage timer, countdown style, pause-screen icon/text, and a live + * preview of the resulting pause screen. + */ + +package com.sameerasw.essentials.ui.features.consciousgate + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.sameerasw.essentials.R +import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenuItem +import com.sameerasw.essentials.ui.core.cards.ConfigPickerItem +import com.sameerasw.essentials.ui.core.cards.FeatureCard +import com.sameerasw.essentials.ui.core.cards.IconToggleItem +import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.core.sheets.AppSelectionSheet +import com.sameerasw.essentials.ui.modifiers.highlight +import com.sameerasw.essentials.utils.AppUtil +import com.sameerasw.essentials.viewmodels.MainViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConsciousGateSettingsUI( + viewModel: MainViewModel, + modifier: Modifier = Modifier, + highlightKey: String? = null, +) { + val context = LocalContext.current + var isAppSelectionSheetOpen by remember { mutableStateOf(false) } + var appsReloadTrigger by remember { mutableStateOf(0) } + var selectedAppLabels by remember { mutableStateOf>(emptyList()) } + var isPreviewOpen by remember { mutableStateOf(false) } + + val isConsciousGateEnabled by viewModel.isConsciousGateEnabled + val delaySeconds by viewModel.consciousGateDelaySeconds + val reappearMinutes by viewModel.consciousGateReappearMinutes + val iconName by viewModel.consciousGateIconName + val title by viewModel.consciousGateTitle + val message by viewModel.consciousGateMessage + val countdownStyle by viewModel.consciousGateCountdownStyle + + LaunchedEffect(appsReloadTrigger) { + withContext(Dispatchers.IO) { + val labels = + viewModel + .loadConsciousGateSelectedApps(context) + .filter { it.isEnabled } + .map { AppUtil.getAppLabel(context, it.packageName) } + withContext(Dispatchers.Main) { + selectedAppLabels = labels + } + } + } + + val selectedAppsDescription = + if (selectedAppLabels.isEmpty()) { + stringResource(R.string.conscious_gate_select_apps_desc) + } else { + val shown = selectedAppLabels.take(3) + val extra = selectedAppLabels.size - shown.size + if (extra > 0) { + shown.joinToString(", ") + " " + stringResource(R.string.conscious_gate_selected_apps_more_suffix, extra) + } else { + shown.joinToString(", ") + } + } + + val delaySecondsOptions = listOf(3, 5, 10) + val reappearPresetMinutes = listOf(0, 5, 10, 15) + val reappearPresetLabels = + listOf( + stringResource(R.string.conscious_gate_reappear_off), + stringResource(R.string.conscious_gate_reappear_5min), + stringResource(R.string.conscious_gate_reappear_10min), + stringResource(R.string.conscious_gate_reappear_15min), + ) + val customOptionLabel = stringResource(R.string.conscious_gate_custom_option_label) + + var isDelayCustom by remember { mutableStateOf(delaySeconds !in delaySecondsOptions) } + var isReappearCustom by remember { mutableStateOf(reappearMinutes !in reappearPresetMinutes) } + + val delaySelectedLabel = stringResource(R.string.conscious_gate_delay_seconds_value, delaySeconds) + val reappearSelectedLabel = + if (isReappearCustom) { + stringResource(R.string.conscious_gate_reappear_minutes_value, reappearMinutes) + } else { + reappearPresetMinutes.indexOf(reappearMinutes).let { presetIndex -> + if (presetIndex >= 0) reappearPresetLabels[presetIndex] else stringResource(R.string.conscious_gate_reappear_minutes_value, reappearMinutes) + } + } + + var customDelayText by remember(delaySeconds) { mutableStateOf(delaySeconds.toString()) } + var customReappearText by remember(reappearMinutes) { mutableStateOf(reappearMinutes.toString()) } + + Column( + modifier = + modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(R.string.feat_conscious_gate_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + RoundedCardContainer( + modifier = Modifier, + spacing = 2.dp, + cornerRadius = 24.dp, + ) { + IconToggleItem( + iconRes = R.drawable.rounded_pause_24, + title = stringResource(R.string.conscious_gate_enable_title), + isChecked = isConsciousGateEnabled, + onCheckedChange = { enabled -> viewModel.setConsciousGateEnabled(enabled, context) }, + modifier = Modifier.highlight(highlightKey == "conscious_gate_enabled"), + ) + + FeatureCard( + title = stringResource(R.string.conscious_gate_select_apps_title), + description = selectedAppsDescription, + iconRes = R.drawable.rounded_apps_24, + isEnabled = isConsciousGateEnabled, + showToggle = false, + hasMoreSettings = true, + onToggle = {}, + onClick = { isAppSelectionSheetOpen = true }, + modifier = Modifier.highlight(highlightKey == "conscious_gate_selected_apps"), + ) + + ConfigPickerItem( + title = stringResource(R.string.conscious_gate_delay_title), + description = stringResource(R.string.conscious_gate_delay_desc), + iconRes = R.drawable.rounded_timer_24, + isEnabled = isConsciousGateEnabled, + selectedValue = delaySelectedLabel, + modifier = Modifier.highlight(highlightKey == "conscious_gate_delay_seconds"), + ) { + delaySecondsOptions.forEach { seconds -> + SegmentedDropdownMenuItem( + text = { Text(stringResource(R.string.conscious_gate_delay_seconds_value, seconds)) }, + onClick = { + isDelayCustom = false + viewModel.setConsciousGateDelaySeconds(seconds) + }, + ) + } + SegmentedDropdownMenuItem( + text = { Text(customOptionLabel) }, + onClick = { isDelayCustom = true }, + ) + } + + if (isDelayCustom) { + OutlinedTextField( + value = customDelayText, + onValueChange = { newValue -> + val filtered = newValue.filter { it.isDigit() }.take(3) + customDelayText = filtered + filtered.toIntOrNull()?.let { seconds -> + if (seconds in 1..999) viewModel.setConsciousGateDelaySeconds(seconds) + } + }, + label = { Text(stringResource(R.string.conscious_gate_delay_custom_label)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + enabled = isConsciousGateEnabled, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = MaterialTheme.shapes.large, + ) + } + + ConfigPickerItem( + title = stringResource(R.string.conscious_gate_reappear_title), + description = stringResource(R.string.conscious_gate_reappear_desc), + iconRes = R.drawable.rounded_lock_clock_24, + isEnabled = isConsciousGateEnabled, + selectedValue = reappearSelectedLabel, + modifier = Modifier.highlight(highlightKey == "conscious_gate_reappear_minutes"), + ) { + reappearPresetMinutes.forEachIndexed { index, minutes -> + SegmentedDropdownMenuItem( + text = { Text(reappearPresetLabels[index]) }, + onClick = { + isReappearCustom = false + viewModel.setConsciousGateReappearMinutes(minutes) + }, + ) + } + SegmentedDropdownMenuItem( + text = { Text(customOptionLabel) }, + onClick = { isReappearCustom = true }, + ) + } + + if (isReappearCustom) { + OutlinedTextField( + value = customReappearText, + onValueChange = { newValue -> + val filtered = newValue.filter { it.isDigit() }.take(4) + customReappearText = filtered + filtered.toIntOrNull()?.let { minutes -> + if (minutes in 0..1440) viewModel.setConsciousGateReappearMinutes(minutes) + } + }, + label = { Text(stringResource(R.string.conscious_gate_reappear_custom_label)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + enabled = isConsciousGateEnabled, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = MaterialTheme.shapes.large, + ) + } + } + + Text( + text = stringResource(R.string.conscious_gate_appearance_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 8.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + RoundedCardContainer(modifier = Modifier) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.conscious_gate_countdown_style_title), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + ConsciousGateCountdownStylePicker( + selectedStyle = countdownStyle, + onStyleSelected = { viewModel.setConsciousGateCountdownStyle(it) }, + modifier = Modifier.highlight(highlightKey == "conscious_gate_countdown_style"), + ) + + ConsciousGateIconPicker( + selectedIconName = iconName, + onIconSelected = { viewModel.setConsciousGateIconName(it) }, + ) + + OutlinedTextField( + value = title, + onValueChange = { viewModel.setConsciousGateTitle(it) }, + label = { Text(stringResource(R.string.conscious_gate_title_label)) }, + placeholder = { Text(stringResource(R.string.conscious_gate_default_title)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = MaterialTheme.shapes.large, + ) + + OutlinedTextField( + value = message, + onValueChange = { viewModel.setConsciousGateMessage(it) }, + label = { Text(stringResource(R.string.conscious_gate_message_label)) }, + placeholder = { Text(stringResource(R.string.conscious_gate_default_message)) }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + ) + } + } + + OutlinedButton( + onClick = { isPreviewOpen = true }, + enabled = isConsciousGateEnabled, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.round_play_arrow_24), + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + ) + Text(stringResource(R.string.conscious_gate_preview_button)) + } + + Text( + text = stringResource(R.string.conscious_gate_description), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (isPreviewOpen) { + Dialog( + onDismissRequest = { isPreviewOpen = false }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + ConsciousGatePreview( + iconName = iconName, + title = title.takeIf { it.isNotBlank() } ?: stringResource(R.string.conscious_gate_default_title), + message = message.takeIf { it.isNotBlank() } ?: stringResource(R.string.conscious_gate_default_message), + targetAppLabel = selectedAppLabels.firstOrNull() ?: stringResource(R.string.conscious_gate_preview_placeholder_app), + countdownStyle = countdownStyle, + delaySeconds = delaySeconds, + onExit = { isPreviewOpen = false }, + ) + } + } + + if (isAppSelectionSheetOpen) { + AppSelectionSheet( + onDismissRequest = { + isAppSelectionSheetOpen = false + appsReloadTrigger++ + }, + onLoadApps = { viewModel.loadConsciousGateSelectedApps(it) }, + onSaveApps = { ctx, apps -> viewModel.saveConsciousGateSelectedApps(ctx, apps) }, + onAppToggle = { ctx, pkg, enabled -> + viewModel.updateConsciousGateAppEnabled(ctx, pkg, enabled) + }, + ) + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/EssentialsOnDisplaySettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/EssentialsOnDisplaySettingsUI.kt index a1fb958e7..a39b82c65 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/EssentialsOnDisplaySettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/EssentialsOnDisplaySettingsUI.kt @@ -33,6 +33,7 @@ import com.sameerasw.essentials.ui.core.pickers.AlbumArtModePicker import com.sameerasw.essentials.ui.core.sheets.PermissionItem import com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet import com.sameerasw.essentials.ui.modifiers.highlight +import com.sameerasw.essentials.utils.PermissionUtils import com.sameerasw.essentials.viewmodels.MainViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -63,10 +64,7 @@ fun EssentialsOnDisplaySettingsUI( dependentFeatures = listOf(R.string.feat_essentials_on_display_title), actionLabel = R.string.perm_action_enable, action = { - val intent = - android.content.Intent(android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS) - intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK - context.startActivity(intent) + PermissionUtils.openAccessibilitySettings(context) }, isGranted = isAccessibilityEnabled, ), diff --git a/app/src/main/java/com/sameerasw/essentials/utils/AppUtil.kt b/app/src/main/java/com/sameerasw/essentials/utils/AppUtil.kt index eee17a35d..bead0e93a 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/AppUtil.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/AppUtil.kt @@ -289,6 +289,24 @@ object AppUtil { null } + /** + * Resolves the human-readable label for a single package name. + * + * @param context [Context] Target context. + * @param packageName [String] Target package name. + * @return The app's display label, or the raw package name if it can't be resolved. + */ + fun getAppLabel( + context: Context, + packageName: String, + ): String = + try { + val appInfo = context.packageManager.getApplicationInfo(packageName, 0) + context.packageManager.getApplicationLabel(appInfo).toString() + } catch (e: Exception) { + packageName + } + /** * Checks if the device is currently in Car Mode or projecting Android Auto */ diff --git a/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt b/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt index 4db1790cf..14bf674ad 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt @@ -151,6 +151,21 @@ object PermissionUtils { * @param context [Context] Target context. */ fun openAccessibilitySettings(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + // ACTION_ACCESSIBILITY_DETAILS_SETTINGS / EXTRA_ACCESSIBILITY_COMPONENT_NAME are + // hidden from the public SDK stub (no compile-time constants), but the platform + // still honors these literal action/extra strings from third-party callers on API 31+. + val componentName = ComponentName(context, ScreenOffAccessibilityService::class.java) + val intent = Intent("android.settings.ACCESSIBILITY_DETAILS_SETTINGS") + intent.putExtra("android.provider.extra.ACCESSIBILITY_COMPONENT_NAME", componentName.flattenToString()) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + return + } catch (e: Exception) { + // Fall through to the generic accessibility list below. + } + } try { val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) diff --git a/app/src/main/java/com/sameerasw/essentials/utils/ServiceUtils.kt b/app/src/main/java/com/sameerasw/essentials/utils/ServiceUtils.kt index ed640f3be..1c83fe65e 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/ServiceUtils.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/ServiceUtils.kt @@ -45,6 +45,8 @@ object ServiceUtils { ) { val isAppLockEnabled = settingsRepository.getBoolean(SettingsRepository.KEY_APP_LOCK_ENABLED) + val isConsciousGateEnabled = + settingsRepository.getBoolean(SettingsRepository.KEY_CONSCIOUS_GATE_ENABLED) val isDynamicNightLightEnabled = settingsRepository.getBoolean(SettingsRepository.KEY_DYNAMIC_NIGHT_LIGHT_ENABLED) val isHideGestureBarOnLauncherEnabled = @@ -61,7 +63,10 @@ object ServiceUtils { val hasShutUpApps = shutUpConfigs.any { it.isEnabled } val shouldRun = - (isUseUsageAccess && (isAppLockEnabled || isDynamicNightLightEnabled || isHideGestureBarOnLauncherEnabled || hasAppAutomations)) || + ( + isUseUsageAccess && + (isAppLockEnabled || isConsciousGateEnabled || isDynamicNightLightEnabled || isHideGestureBarOnLauncherEnabled || hasAppAutomations) + ) || hasShutUpApps val intent = Intent(context, AppDetectionService::class.java) diff --git a/app/src/main/java/com/sameerasw/essentials/utils/ui/PermissionUIHelper.kt b/app/src/main/java/com/sameerasw/essentials/utils/ui/PermissionUIHelper.kt index 397b93ab5..44e0fd2eb 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/ui/PermissionUIHelper.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/ui/PermissionUIHelper.kt @@ -21,6 +21,7 @@ import androidx.core.app.ActivityCompat import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.registry.PermissionRegistry import com.sameerasw.essentials.ui.core.sheets.PermissionItem +import com.sameerasw.essentials.utils.PermissionUtils import com.sameerasw.essentials.viewmodels.MainViewModel object PermissionUIHelper { @@ -39,9 +40,7 @@ object PermissionUIHelper { dependentFeatures = PermissionRegistry.getFeatures("ACCESSIBILITY"), actionLabel = if (viewModel.isAccessibilityEnabled.value) R.string.label_enabled else R.string.perm_action_enable, action = { - val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - context.startActivity(intent) + PermissionUtils.openAccessibilitySettings(context) }, isGranted = viewModel.isAccessibilityEnabled.value, ) diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index 1e04ea9eb..5a326e10c 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -251,6 +251,14 @@ class MainViewModel : ViewModel() { val skipPersistentNotifications = mutableStateOf(false) val isAppLockEnabled = mutableStateOf(false) val appLockAutoLockDelayIndex = mutableIntStateOf(0) + val isConsciousGateEnabled = mutableStateOf(false) + val consciousGateDelaySeconds = mutableIntStateOf(5) + val consciousGateReappearMinutes = mutableIntStateOf(0) + val consciousGateIconName = mutableStateOf("rounded_pause_24") + val consciousGateTitle = mutableStateOf("") + val consciousGateMessage = mutableStateOf("") + val consciousGateCountdownStyle = + mutableStateOf(com.sameerasw.essentials.domain.model.ConsciousGateCountdownStyle.CIRCULAR_WAVY) val isUseUsageAccess = mutableStateOf(false) val isFreezeWhenLockedEnabled = mutableStateOf(false) val freezeLockDelayIndex = mutableIntStateOf(1) // Default: 1 minute @@ -1780,6 +1788,14 @@ class MainViewModel : ViewModel() { settingsRepository.getBoolean(SettingsRepository.KEY_APP_LOCK_ENABLED) appLockAutoLockDelayIndex.intValue = settingsRepository.getInt(SettingsRepository.KEY_APP_LOCK_AUTO_LOCK_DELAY_INDEX, 0) + isConsciousGateEnabled.value = + settingsRepository.getBoolean(SettingsRepository.KEY_CONSCIOUS_GATE_ENABLED) + consciousGateDelaySeconds.intValue = settingsRepository.getConsciousGateDelaySeconds() + consciousGateReappearMinutes.intValue = settingsRepository.getConsciousGateReappearMinutes() + consciousGateIconName.value = settingsRepository.getConsciousGateIconName() + consciousGateTitle.value = settingsRepository.getConsciousGateTitle(context) + consciousGateMessage.value = settingsRepository.getConsciousGateMessage(context) + consciousGateCountdownStyle.value = settingsRepository.getConsciousGateCountdownStyle() isFreezeWhenLockedEnabled.value = settingsRepository.getBoolean(SettingsRepository.KEY_FREEZE_WHEN_LOCKED_ENABLED) isFreezeDontFreezeActiveAppsEnabled.value = @@ -4074,6 +4090,51 @@ class MainViewModel : ViewModel() { settingsRepository.putInt(SettingsRepository.KEY_APP_LOCK_AUTO_LOCK_DELAY_INDEX, index) } + /** + * Executes the set conscious gate enabled operation. + * + * @param enabled [Boolean] Target enabled. + * @param context [Context] Target context. + */ + fun setConsciousGateEnabled( + enabled: Boolean, + context: Context, + ) { + isConsciousGateEnabled.value = enabled + settingsRepository.putBoolean(SettingsRepository.KEY_CONSCIOUS_GATE_ENABLED, enabled) + updateAppDetectionService(context) + } + + fun setConsciousGateDelaySeconds(seconds: Int) { + consciousGateDelaySeconds.intValue = seconds + settingsRepository.setConsciousGateDelaySeconds(seconds) + } + + fun setConsciousGateReappearMinutes(minutes: Int) { + consciousGateReappearMinutes.intValue = minutes + settingsRepository.setConsciousGateReappearMinutes(minutes) + } + + fun setConsciousGateIconName(iconName: String) { + consciousGateIconName.value = iconName + settingsRepository.setConsciousGateIconName(iconName) + } + + fun setConsciousGateTitle(title: String) { + consciousGateTitle.value = title + settingsRepository.setConsciousGateTitle(title) + } + + fun setConsciousGateMessage(message: String) { + consciousGateMessage.value = message + settingsRepository.setConsciousGateMessage(message) + } + + fun setConsciousGateCountdownStyle(style: com.sameerasw.essentials.domain.model.ConsciousGateCountdownStyle) { + consciousGateCountdownStyle.value = style + settingsRepository.setConsciousGateCountdownStyle(style) + } + /** * Executes the set use usage access operation. * @@ -5918,6 +5979,24 @@ class MainViewModel : ViewModel() { settingsRepository.updateAppLockAppSelection(packageName, enabled) } + // Conscious Gate App Selection Methods + fun saveConsciousGateSelectedApps( + context: Context, + apps: List, + ) { + settingsRepository.saveConsciousGateSelectedApps(apps) + } + + fun loadConsciousGateSelectedApps(context: Context): List = settingsRepository.loadConsciousGateSelectedApps() + + fun updateConsciousGateAppEnabled( + context: Context, + packageName: String, + enabled: Boolean, + ) { + settingsRepository.updateConsciousGateAppSelection(packageName, enabled) + } + // Freeze App Selection Methods fun saveFreezeSelectedApps( context: Context, diff --git a/app/src/main/res/drawable/rounded_eco_24.xml b/app/src/main/res/drawable/rounded_eco_24.xml new file mode 100644 index 000000000..20b8531e0 --- /dev/null +++ b/app/src/main/res/drawable/rounded_eco_24.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/app/src/main/res/drawable/rounded_health_and_safety_24.xml b/app/src/main/res/drawable/rounded_health_and_safety_24.xml new file mode 100644 index 000000000..438e7bc40 --- /dev/null +++ b/app/src/main/res/drawable/rounded_health_and_safety_24.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/app/src/main/res/drawable/rounded_potted_plant_24.xml b/app/src/main/res/drawable/rounded_potted_plant_24.xml new file mode 100644 index 000000000..594bcf29f --- /dev/null +++ b/app/src/main/res/drawable/rounded_potted_plant_24.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/app/src/main/res/drawable/rounded_self_improvement_24.xml b/app/src/main/res/drawable/rounded_self_improvement_24.xml new file mode 100644 index 000000000..36476318f --- /dev/null +++ b/app/src/main/res/drawable/rounded_self_improvement_24.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/app/src/main/res/drawable/rounded_sentiment_satisfied_24.xml b/app/src/main/res/drawable/rounded_sentiment_satisfied_24.xml new file mode 100644 index 000000000..aaef61421 --- /dev/null +++ b/app/src/main/res/drawable/rounded_sentiment_satisfied_24.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/app/src/main/res/drawable/rounded_sentiment_very_satisfied_24.xml b/app/src/main/res/drawable/rounded_sentiment_very_satisfied_24.xml new file mode 100644 index 000000000..6e7bb9403 --- /dev/null +++ b/app/src/main/res/drawable/rounded_sentiment_very_satisfied_24.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/app/src/main/res/drawable/rounded_spa_24.xml b/app/src/main/res/drawable/rounded_spa_24.xml new file mode 100644 index 000000000..00fbbf337 --- /dev/null +++ b/app/src/main/res/drawable/rounded_spa_24.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/app/src/main/res/drawable/rounded_volunteer_activism_24.xml b/app/src/main/res/drawable/rounded_volunteer_activism_24.xml new file mode 100644 index 000000000..206c29e68 --- /dev/null +++ b/app/src/main/res/drawable/rounded_volunteer_activism_24.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/app/src/main/res/drawable/rounded_wb_sunny_24.xml b/app/src/main/res/drawable/rounded_wb_sunny_24.xml new file mode 100644 index 000000000..52043f1ec --- /dev/null +++ b/app/src/main/res/drawable/rounded_wb_sunny_24.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a50fe6e06..fb6b7a965 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -122,6 +122,40 @@ Use usage access Instead of accessibility (Freeze, App Lock, Dynamic Night Light) + + Enable conscious gate + Select gated apps + Choose which apps show a mindful conscious gate screen before opening + Pause delay + How long Continue stays disabled + %1$d seconds + Reappear after usage + Show the conscious gate screen again after continuous use + Off + 5 minutes + 10 minutes + 15 minutes + 30 minutes + 1 hour + Custom seconds + Custom minutes (0 = off) + %1$d minutes + +%1$d more + Preview + Preview conscious gate screen + a gated app + Custom + Countdown style + Conscious gate screen appearance + Icon + Title + Message + Take a mindful pause + Are you sure you want to open this app right now? + Continue on %1$s + Close + Adds a brief mindful pause before opening apps you\'ve chosen to be more intentional about, such as social media. + Enable Button Remap Use Shizuku or Root @@ -617,6 +651,8 @@ Screen locked security App lock Secure apps with biometrics + Conscious gate + Pause before opening chosen apps Auto lock delay After leaving the app None @@ -769,6 +805,12 @@ Master toggle for app locking Select locked apps Choose which apps require authentication + Enable conscious gate + Master toggle for the mindful conscious gate screen + Select gated apps + Choose which apps show the conscious gate screen + Pause delay + How long Continue stays disabled Pick apps to freeze Choose which apps can be frozen Freeze all apps @@ -954,6 +996,7 @@ Automatically toggle your screen blue light filter based on the foreground app. Enhance security when your device is locked.\n\nRestrict access to some sensitive QS tiles preventing unauthorized network modifications and further preventing them re-attempting to do so by increasing the animation speed to prevent touch spam.\n\nThis feature is not robust and may have flaws such as some tiles which allow toggling directly such as bluetooth or flight mode not being able to be prevented. Secure your apps with a secondary authentication layer.\n\nYour device lock screen authentication method will be used as long as it meets the class 3 biometric security level by Android standards. + Adds a brief mindful conscious gate screen before opening apps you\'ve chosen to be more intentional about, such as social media.\n\nThe Continue button stays disabled for a few seconds so you have a moment to decide, and the pause can optionally reappear if you keep using the app past a set time. Get notified when you get closer to your destination to ensure you never miss the stop.\n\nGo to Google Maps, long press a pin nearby to your destination and make sure it says \"Dropped pin\" (Otherwise the distance calculation might not be accurate), And then share the location to the Essentials app and start tracking. Add Destination Edit Destination diff --git a/app/src/main/res/xml/accessibility_service_config.xml b/app/src/main/res/xml/accessibility_service_config.xml index ea94d4704..9c5bf8b31 100644 --- a/app/src/main/res/xml/accessibility_service_config.xml +++ b/app/src/main/res/xml/accessibility_service_config.xml @@ -1,11 +1,12 @@ From 6301424da2e43b83c18a32c78ca57c6c3757b943 Mon Sep 17 00:00:00 2001 From: "Thomas Borgogno (personal)" Date: Wed, 26 Aug 2026 21:27:08 +0200 Subject: [PATCH 2/3] feat: PR comments, Refactor Conscious Gate UI, animations, and internals --- .../domain/registry/FeatureRegistry.kt | 6 +- .../ui/activities/ConsciousGateActivity.kt | 36 ++--- .../ui/activities/FeatureSettingsActivity.kt | 7 +- .../ConsciousGateCountdownStylePicker.kt | 96 ------------ .../consciousgate/ConsciousGatePauseScreen.kt | 16 +- .../consciousgate/ConsciousGatePreview.kt | 11 +- .../consciousgate/ConsciousGateSettingsUI.kt | 145 +++++++++++------- .../ConsciousGateCountdown.kt | 2 +- .../ConsciousGateCountdownStylePicker.kt | 86 +++++++++++ .../ConsciousGateIconPicker.kt | 38 +---- .../components/ConsciousGateIcons.kt | 45 ++++++ .../components/SettingsRowSurface.kt | 41 +++++ 12 files changed, 305 insertions(+), 224 deletions(-) delete mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateCountdownStylePicker.kt rename app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/{ => components}/ConsciousGateCountdown.kt (99%) create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/ConsciousGateCountdownStylePicker.kt rename app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/{ => components}/ConsciousGateIconPicker.kt (75%) create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/ConsciousGateIcons.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/SettingsRowSurface.kt diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt index f271136e1..9ae7e0584 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt @@ -18,6 +18,7 @@ import com.sameerasw.essentials.domain.model.Feature import com.sameerasw.essentials.domain.model.SearchSetting import com.sameerasw.essentials.ui.activities.PixelSearchbarSettingsActivity import com.sameerasw.essentials.ui.activities.WatermarkActivity +import com.sameerasw.essentials.ui.features.consciousgate.CONSCIOUS_GATE_FEATURE_ID import com.sameerasw.essentials.utils.DeviceUtils import com.sameerasw.essentials.utils.ShellUtils import com.sameerasw.essentials.viewmodels.MainViewModel @@ -1455,12 +1456,13 @@ object FeatureRegistry { ) = viewModel.setAppLockEnabled(enabled, context) }, object : Feature( - id = "Conscious gate", + id = CONSCIOUS_GATE_FEATURE_ID, title = R.string.feat_conscious_gate_title, iconRes = R.drawable.rounded_pause_24, - category = R.string.cat_utils, + category = R.string.cat_interaction, description = R.string.feat_conscious_gate_desc, aboutDescription = R.string.about_desc_conscious_gate, + parentFeatureId = "Input", searchableSettings = listOf( SearchSetting( diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/ConsciousGateActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/ConsciousGateActivity.kt index 6a02c93b7..e926cf4be 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/ConsciousGateActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/ConsciousGateActivity.kt @@ -16,19 +16,19 @@ import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.res.stringResource import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.model.ConsciousGateCountdownStyle import com.sameerasw.essentials.services.tiles.ScreenOffAccessibilityService import com.sameerasw.essentials.ui.features.consciousgate.ConsciousGatePauseScreen +import com.sameerasw.essentials.ui.features.consciousgate.components.ConsciousGateIcons import com.sameerasw.essentials.ui.theme.EssentialsTheme -import kotlinx.coroutines.delay class ConsciousGateActivity : AppCompatActivity() { private var packageToGate: String? = null @@ -91,28 +91,20 @@ class ConsciousGateActivity : AppCompatActivity() { onClose: () -> Unit, onContinue: () -> Unit, ) { - val iconResId = - remember(iconName) { - resources - .getIdentifier(iconName, "drawable", packageName) - .takeIf { it != 0 } ?: R.drawable.rounded_pause_24 - } + val iconResId = remember(iconName) { ConsciousGateIcons.resolve(iconName) } - var progress by remember { mutableFloatStateOf(if (delaySeconds <= 0) 1f else 0f) } + val progressAnimatable = remember { Animatable(if (delaySeconds <= 0) 1f else 0f) } LaunchedEffect(delaySeconds) { if (delaySeconds <= 0) { - progress = 1f + progressAnimatable.snapTo(1f) return@LaunchedEffect } - val totalMillis = delaySeconds * 1000L - val startTime = System.currentTimeMillis() - while (true) { - val elapsed = System.currentTimeMillis() - startTime - progress = (elapsed.toFloat() / totalMillis).coerceIn(0f, 1f) - if (progress >= 1f) break - delay(16) - } + progressAnimatable.snapTo(0f) + progressAnimatable.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = delaySeconds * 1000, easing = LinearEasing), + ) } ConsciousGatePauseScreen( @@ -121,8 +113,8 @@ class ConsciousGateActivity : AppCompatActivity() { message = message?.takeIf { it.isNotBlank() } ?: stringResource(R.string.conscious_gate_default_message), targetAppLabel = appLabel, countdownStyle = countdownStyle, - progress = { progress }, - isContinueEnabled = progress >= 1f, + progress = { progressAnimatable.value }, + isContinueEnabled = progressAnimatable.value >= 1f, onClose = onClose, onContinue = onContinue, ) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt index ef521120d..9a84e73b0 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt @@ -63,6 +63,7 @@ import com.sameerasw.essentials.ui.core.cards.FeatureCard import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet import com.sameerasw.essentials.ui.features.battery.BatteriesSettingsUI +import com.sameerasw.essentials.ui.features.consciousgate.CONSCIOUS_GATE_FEATURE_ID import com.sameerasw.essentials.ui.features.security.AppLockSettingsUI import com.sameerasw.essentials.ui.features.system.AlwaysOnDisplaySettingsUI import com.sameerasw.essentials.ui.features.system.BatteryNotificationSettingsUI @@ -350,7 +351,7 @@ class FeatureSettingsActivity : AppCompatActivity() { "App lock" -> !isAccessibilityEnabled || (if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else false) - "Conscious gate" -> + CONSCIOUS_GATE_FEATURE_ID -> !isAccessibilityEnabled || (if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else false) "Freeze" -> @@ -754,7 +755,7 @@ class FeatureSettingsActivity : AppCompatActivity() { "App lock" -> !isAccessibilityEnabled || (if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else false) - "Conscious gate" -> + CONSCIOUS_GATE_FEATURE_ID -> !isAccessibilityEnabled || (if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else false) "Freeze" -> @@ -1016,7 +1017,7 @@ class FeatureSettingsActivity : AppCompatActivity() { ) } - "Conscious gate" -> { + CONSCIOUS_GATE_FEATURE_ID -> { com.sameerasw.essentials.ui.features.consciousgate.ConsciousGateSettingsUI( viewModel = viewModel, highlightKey = highlightSetting, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateCountdownStylePicker.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateCountdownStylePicker.kt deleted file mode 100644 index a72905318..000000000 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateCountdownStylePicker.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (c) 2026 sameerasw.com - * License: MIT License - * - * Feature Module: UI Feature - Conscious Gate - * File: ConsciousGateCountdownStylePicker.kt - * Description: UI component letting the user pick which Material 3 Expressive countdown - * treatment the Conscious Gate pause screen uses, with a small live preview per option. - */ - -package com.sameerasw.essentials.ui.features.consciousgate - -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.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ButtonGroupDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ToggleButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import com.sameerasw.essentials.domain.model.ConsciousGateCountdownStyle -import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer -import com.sameerasw.essentials.utils.HapticUtil - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -fun ConsciousGateCountdownStylePicker( - selectedStyle: ConsciousGateCountdownStyle, - onStyleSelected: (ConsciousGateCountdownStyle) -> Unit, - modifier: Modifier = Modifier, -) { - val styles = - listOf( - ConsciousGateCountdownStyle.CIRCULAR_WAVY, - ConsciousGateCountdownStyle.LOADING_BLOB, - ConsciousGateCountdownStyle.BREATHING_DOT, - ConsciousGateCountdownStyle.LINEAR_WAVY, - ) - val view = LocalView.current - val selectedIndex = styles.indexOf(selectedStyle).coerceAtLeast(0) - - RoundedCardContainer(modifier = modifier) { - Row( - modifier = - Modifier - .background( - color = MaterialTheme.colorScheme.surfaceBright, - shape = RoundedCornerShape(MaterialTheme.shapes.extraSmall.bottomEnd), - ).padding(10.dp), - horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), - ) { - val itemModifiers = List(styles.size) { Modifier.weight(1f) } - - styles.forEachIndexed { index, style -> - ToggleButton( - checked = selectedIndex == index, - onCheckedChange = { - HapticUtil.performVirtualKeyHaptic(view) - onStyleSelected(style) - }, - modifier = itemModifiers[index].semantics { role = Role.RadioButton }, - shapes = - when (index) { - 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() - styles.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() - else -> ButtonGroupDefaults.connectedMiddleButtonShapes() - }, - ) { - val isSelected = selectedIndex == index - val previewColor = - if (isSelected) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - ConsciousGateCountdownIndicator( - style = style, - progress = { 0.6f }, - modifier = Modifier.size(28.dp), - indicatorColor = previewColor, - trackColor = previewColor.copy(alpha = 0.3f), - ) - } - } - } - } -} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePauseScreen.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePauseScreen.kt index 2256c07a2..6577024e0 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePauseScreen.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePauseScreen.kt @@ -40,12 +40,16 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.model.ConsciousGateCountdownStyle +import com.sameerasw.essentials.ui.features.consciousgate.components.ConsciousGateCountdown +import com.sameerasw.essentials.ui.features.consciousgate.components.ConsciousGateHeroAnimation +import com.sameerasw.essentials.utils.HapticUtil import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -250,8 +254,12 @@ private fun ContinueButton( isContinueEnabled: Boolean, onContinue: () -> Unit, ) { + val view = LocalView.current OutlinedButton( - onClick = onContinue, + onClick = { + HapticUtil.performUIHaptic(view) + onContinue() + }, enabled = isContinueEnabled, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.outlinedButtonColors(), @@ -262,8 +270,12 @@ private fun ContinueButton( @Composable private fun CloseButton(onClose: () -> Unit) { + val view = LocalView.current Button( - onClick = onClose, + onClick = { + HapticUtil.performUIHaptic(view) + onClose() + }, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.conscious_gate_close_button)) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePreview.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePreview.kt index 2e4816a86..66907e0ae 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePreview.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePreview.kt @@ -22,9 +22,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.model.ConsciousGateCountdownStyle +import com.sameerasw.essentials.ui.features.consciousgate.components.ConsciousGateIcons import com.sameerasw.essentials.ui.theme.EssentialsTheme @Composable @@ -38,13 +37,7 @@ fun ConsciousGatePreview( onExit: () -> Unit, modifier: Modifier = Modifier, ) { - val context = LocalContext.current - val iconResId = - remember(iconName) { - context.resources - .getIdentifier(iconName, "drawable", context.packageName) - .takeIf { it != 0 } ?: R.drawable.rounded_pause_24 - } + val iconResId = remember(iconName) { ConsciousGateIcons.resolve(iconName) } val loopSeconds = delaySeconds.coerceIn(1, 30) val infiniteTransition = rememberInfiniteTransition(label = "ConsciousGatePreviewProgress") diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateSettingsUI.kt index 223e00c77..5052a022c 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateSettingsUI.kt @@ -11,15 +11,15 @@ package com.sameerasw.essentials.ui.features.consciousgate +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -30,7 +30,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @@ -43,12 +43,18 @@ import com.sameerasw.essentials.ui.core.cards.FeatureCard import com.sameerasw.essentials.ui.core.cards.IconToggleItem import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.ui.core.sheets.AppSelectionSheet +import com.sameerasw.essentials.ui.features.consciousgate.components.ConsciousGateCountdownStylePicker +import com.sameerasw.essentials.ui.features.consciousgate.components.ConsciousGateIconPicker +import com.sameerasw.essentials.ui.features.consciousgate.components.SettingsRowSurface import com.sameerasw.essentials.ui.modifiers.highlight import com.sameerasw.essentials.utils.AppUtil +import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.viewmodels.MainViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +const val CONSCIOUS_GATE_FEATURE_ID = "Conscious gate" + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ConsciousGateSettingsUI( @@ -57,6 +63,7 @@ fun ConsciousGateSettingsUI( highlightKey: String? = null, ) { val context = LocalContext.current + val view = LocalView.current var isAppSelectionSheetOpen by remember { mutableStateOf(false) } var appsReloadTrigger by remember { mutableStateOf(0) } var selectedAppLabels by remember { mutableStateOf>(emptyList()) } @@ -174,6 +181,7 @@ fun ConsciousGateSettingsUI( SegmentedDropdownMenuItem( text = { Text(stringResource(R.string.conscious_gate_delay_seconds_value, seconds)) }, onClick = { + HapticUtil.performVirtualKeyHaptic(view) isDelayCustom = false viewModel.setConsciousGateDelaySeconds(seconds) }, @@ -181,12 +189,15 @@ fun ConsciousGateSettingsUI( } SegmentedDropdownMenuItem( text = { Text(customOptionLabel) }, - onClick = { isDelayCustom = true }, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + isDelayCustom = true + }, ) } if (isDelayCustom) { - OutlinedTextField( + ConsciousGateTextFieldRow( value = customDelayText, onValueChange = { newValue -> val filtered = newValue.filter { it.isDigit() }.take(3) @@ -195,15 +206,10 @@ fun ConsciousGateSettingsUI( if (seconds in 1..999) viewModel.setConsciousGateDelaySeconds(seconds) } }, - label = { Text(stringResource(R.string.conscious_gate_delay_custom_label)) }, + label = stringResource(R.string.conscious_gate_delay_custom_label), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true, enabled = isConsciousGateEnabled, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - shape = MaterialTheme.shapes.large, ) } @@ -219,6 +225,7 @@ fun ConsciousGateSettingsUI( SegmentedDropdownMenuItem( text = { Text(reappearPresetLabels[index]) }, onClick = { + HapticUtil.performVirtualKeyHaptic(view) isReappearCustom = false viewModel.setConsciousGateReappearMinutes(minutes) }, @@ -226,12 +233,15 @@ fun ConsciousGateSettingsUI( } SegmentedDropdownMenuItem( text = { Text(customOptionLabel) }, - onClick = { isReappearCustom = true }, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + isReappearCustom = true + }, ) } if (isReappearCustom) { - OutlinedTextField( + ConsciousGateTextFieldRow( value = customReappearText, onValueChange = { newValue -> val filtered = newValue.filter { it.isDigit() }.take(4) @@ -240,15 +250,10 @@ fun ConsciousGateSettingsUI( if (minutes in 0..1440) viewModel.setConsciousGateReappearMinutes(minutes) } }, - label = { Text(stringResource(R.string.conscious_gate_reappear_custom_label)) }, + label = stringResource(R.string.conscious_gate_reappear_custom_label), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true, enabled = isConsciousGateEnabled, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - shape = MaterialTheme.shapes.large, ) } } @@ -261,62 +266,54 @@ fun ConsciousGateSettingsUI( ) RoundedCardContainer(modifier = Modifier) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + SettingsRowSurface( + modifier = Modifier.highlight(highlightKey == "conscious_gate_countdown_style"), ) { Text( text = stringResource(R.string.conscious_gate_countdown_style_title), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp), ) ConsciousGateCountdownStylePicker( selectedStyle = countdownStyle, onStyleSelected = { viewModel.setConsciousGateCountdownStyle(it) }, - modifier = Modifier.highlight(highlightKey == "conscious_gate_countdown_style"), + modifier = Modifier.fillMaxWidth(), ) + } - ConsciousGateIconPicker( - selectedIconName = iconName, - onIconSelected = { viewModel.setConsciousGateIconName(it) }, - ) + ConsciousGateIconPicker( + selectedIconName = iconName, + onIconSelected = { viewModel.setConsciousGateIconName(it) }, + modifier = Modifier.fillMaxWidth(), + ) - OutlinedTextField( - value = title, - onValueChange = { viewModel.setConsciousGateTitle(it) }, - label = { Text(stringResource(R.string.conscious_gate_title_label)) }, - placeholder = { Text(stringResource(R.string.conscious_gate_default_title)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = MaterialTheme.shapes.large, - ) + ConsciousGateTextFieldRow( + value = title, + onValueChange = { viewModel.setConsciousGateTitle(it) }, + label = stringResource(R.string.conscious_gate_title_label), + placeholder = stringResource(R.string.conscious_gate_default_title), + singleLine = true, + ) - OutlinedTextField( - value = message, - onValueChange = { viewModel.setConsciousGateMessage(it) }, - label = { Text(stringResource(R.string.conscious_gate_message_label)) }, - placeholder = { Text(stringResource(R.string.conscious_gate_default_message)) }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.large, - ) - } - } + ConsciousGateTextFieldRow( + value = message, + onValueChange = { viewModel.setConsciousGateMessage(it) }, + label = stringResource(R.string.conscious_gate_message_label), + placeholder = stringResource(R.string.conscious_gate_default_message), + ) - OutlinedButton( - onClick = { isPreviewOpen = true }, - enabled = isConsciousGateEnabled, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Icon( - painter = painterResource(id = R.drawable.round_play_arrow_24), - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), + FeatureCard( + title = stringResource(R.string.conscious_gate_preview_button), + description = null, + iconRes = R.drawable.round_play_arrow_24, + isEnabled = isConsciousGateEnabled, + showToggle = false, + hasMoreSettings = true, + onToggle = {}, + onClick = { isPreviewOpen = true }, ) - Text(stringResource(R.string.conscious_gate_preview_button)) } Text( @@ -362,3 +359,33 @@ fun ConsciousGateSettingsUI( } } } + +@Composable +private fun ConsciousGateTextFieldRow( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, + placeholder: String? = null, + enabled: Boolean = true, + singleLine: Boolean = false, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + placeholder = placeholder?.let { { Text(it) } }, + singleLine = singleLine, + enabled = enabled, + keyboardOptions = keyboardOptions, + shape = MaterialTheme.shapes.large, + modifier = + modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceBright, + shape = RoundedCornerShape(MaterialTheme.shapes.extraSmall.bottomEnd), + ).padding(4.dp), + ) +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateCountdown.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/ConsciousGateCountdown.kt similarity index 99% rename from app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateCountdown.kt rename to app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/ConsciousGateCountdown.kt index f0110c784..df1892f78 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateCountdown.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/ConsciousGateCountdown.kt @@ -8,7 +8,7 @@ * "Continue" button while the user-configured pause delay elapses. */ -package com.sameerasw.essentials.ui.features.consciousgate +package com.sameerasw.essentials.ui.features.consciousgate.components import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/ConsciousGateCountdownStylePicker.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/ConsciousGateCountdownStylePicker.kt new file mode 100644 index 000000000..84af4edf6 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/ConsciousGateCountdownStylePicker.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - Conscious Gate + * File: ConsciousGateCountdownStylePicker.kt + * Description: UI component letting the user pick which Material 3 Expressive countdown + * treatment the Conscious Gate pause screen uses, with a small live preview per option. + */ + +package com.sameerasw.essentials.ui.features.consciousgate.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ToggleButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.domain.model.ConsciousGateCountdownStyle +import com.sameerasw.essentials.utils.HapticUtil + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ConsciousGateCountdownStylePicker( + selectedStyle: ConsciousGateCountdownStyle, + onStyleSelected: (ConsciousGateCountdownStyle) -> Unit, + modifier: Modifier = Modifier, +) { + val styles = + listOf( + ConsciousGateCountdownStyle.CIRCULAR_WAVY, + ConsciousGateCountdownStyle.LOADING_BLOB, + ConsciousGateCountdownStyle.BREATHING_DOT, + ConsciousGateCountdownStyle.LINEAR_WAVY, + ) + val view = LocalView.current + val selectedIndex = styles.indexOf(selectedStyle).coerceAtLeast(0) + + Row( + modifier = modifier.padding(horizontal = 10.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), + ) { + val itemModifiers = List(styles.size) { Modifier.weight(1f) } + + styles.forEachIndexed { index, style -> + ToggleButton( + checked = selectedIndex == index, + onCheckedChange = { + HapticUtil.performVirtualKeyHaptic(view) + onStyleSelected(style) + }, + modifier = itemModifiers[index].semantics { role = Role.RadioButton }, + shapes = + when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + styles.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + ) { + val isSelected = selectedIndex == index + val previewColor = + if (isSelected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ConsciousGateCountdownIndicator( + style = style, + progress = { 0.6f }, + modifier = Modifier.size(28.dp), + indicatorColor = previewColor, + trackColor = previewColor.copy(alpha = 0.3f), + ) + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateIconPicker.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/ConsciousGateIconPicker.kt similarity index 75% rename from app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateIconPicker.kt rename to app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/ConsciousGateIconPicker.kt index ad0342445..5d4847665 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateIconPicker.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/ConsciousGateIconPicker.kt @@ -8,12 +8,11 @@ * pause screen. */ -package com.sameerasw.essentials.ui.features.consciousgate +package com.sameerasw.essentials.ui.features.consciousgate.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -29,7 +28,6 @@ import androidx.compose.material3.carousel.rememberCarouselState import androidx.compose.runtime.Composable 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.painterResource import androidx.compose.ui.res.stringResource @@ -44,32 +42,14 @@ fun ConsciousGateIconPicker( onIconSelected: (String) -> Unit, modifier: Modifier = Modifier, ) { - val icons = - listOf( - "rounded_favorite_24", - "rounded_heart_smile_24", - "rounded_ecg_heart_24", - "rounded_volunteer_activism_24", - "rounded_self_improvement_24", - "rounded_health_and_safety_24", - "rounded_shield_24", - "rounded_wb_sunny_24", - "rounded_nightlight_24", - "rounded_sentiment_satisfied_24", - "rounded_sentiment_very_satisfied_24", - "rounded_spa_24", - "rounded_eco_24", - "rounded_potted_plant_24", - "rounded_pause_24", - "rounded_timer_24", - "rounded_lock_clock_24", - ) - + val icons = ConsciousGateIcons.OPTIONS val carouselState = rememberCarouselState { icons.size } - val context = LocalContext.current val view = LocalView.current - Column(modifier = modifier.fillMaxWidth()) { + SettingsRowSurface( + modifier = modifier, + contentPadding = PaddingValues(16.dp), + ) { Text( text = stringResource(R.string.conscious_gate_icon_picker_label), style = MaterialTheme.typography.labelMedium, @@ -89,10 +69,8 @@ fun ConsciousGateIconPicker( .fillMaxWidth() .height(64.dp), ) { index -> - val iconName = icons[index] + val (iconName, iconResId) = icons[index] val isSelected = iconName == selectedIconName - val iconResId = - context.resources.getIdentifier(iconName, "drawable", context.packageName) Box( modifier = @@ -112,7 +90,7 @@ fun ConsciousGateIconPicker( contentAlignment = Alignment.Center, ) { Icon( - painter = painterResource(id = if (iconResId != 0) iconResId else R.drawable.rounded_pause_24), + painter = painterResource(id = iconResId), contentDescription = null, tint = if (isSelected) { diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/ConsciousGateIcons.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/ConsciousGateIcons.kt new file mode 100644 index 000000000..2ece3f36b --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/ConsciousGateIcons.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - Conscious Gate + * File: ConsciousGateIcons.kt + * Description: Static mapping between the icon names persisted for Conscious Gate and their + * explicit R.drawable ids, so drawables stay referenced for R8 resource shrinking instead of + * being looked up by name at runtime. + */ + +package com.sameerasw.essentials.ui.features.consciousgate.components + +import androidx.annotation.DrawableRes +import com.sameerasw.essentials.R + +object ConsciousGateIcons { + const val DEFAULT_ICON_NAME = "rounded_pause_24" + + val OPTIONS: List> = + listOf( + "rounded_favorite_24" to R.drawable.rounded_favorite_24, + "rounded_heart_smile_24" to R.drawable.rounded_heart_smile_24, + "rounded_ecg_heart_24" to R.drawable.rounded_ecg_heart_24, + "rounded_volunteer_activism_24" to R.drawable.rounded_volunteer_activism_24, + "rounded_self_improvement_24" to R.drawable.rounded_self_improvement_24, + "rounded_health_and_safety_24" to R.drawable.rounded_health_and_safety_24, + "rounded_shield_24" to R.drawable.rounded_shield_24, + "rounded_wb_sunny_24" to R.drawable.rounded_wb_sunny_24, + "rounded_nightlight_24" to R.drawable.rounded_nightlight_24, + "rounded_sentiment_satisfied_24" to R.drawable.rounded_sentiment_satisfied_24, + "rounded_sentiment_very_satisfied_24" to R.drawable.rounded_sentiment_very_satisfied_24, + "rounded_spa_24" to R.drawable.rounded_spa_24, + "rounded_eco_24" to R.drawable.rounded_eco_24, + "rounded_potted_plant_24" to R.drawable.rounded_potted_plant_24, + "rounded_pause_24" to R.drawable.rounded_pause_24, + "rounded_timer_24" to R.drawable.rounded_timer_24, + "rounded_lock_clock_24" to R.drawable.rounded_lock_clock_24, + ) + + private val byName = OPTIONS.toMap() + + @DrawableRes + fun resolve(name: String): Int = byName[name] ?: R.drawable.rounded_pause_24 +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/SettingsRowSurface.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/SettingsRowSurface.kt new file mode 100644 index 000000000..f8ba69738 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/SettingsRowSurface.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - Conscious Gate + * File: SettingsRowSurface.kt + * Description: Shared row background (surfaceBright, small rounded corners) used to group + * multiple composables under one continuous surface within a Conscious Gate settings card. + */ + +package com.sameerasw.essentials.ui.features.consciousgate.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun SettingsRowSurface( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = + modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceBright, + shape = RoundedCornerShape(MaterialTheme.shapes.extraSmall.bottomEnd), + ).padding(contentPadding), + content = content, + ) +} From d6f2232a2e7fb829a3541ebe2052e40124296373 Mon Sep 17 00:00:00 2001 From: "Thomas Borgogno (personal)" Date: Wed, 26 Aug 2026 21:37:40 +0200 Subject: [PATCH 3/3] feat: Implement permission gating for Conscious Gate feature --- .../essentials/domain/registry/PermissionRegistry.kt | 2 ++ .../features/consciousgate/ConsciousGateSettingsUI.kt | 11 +++++++++++ app/src/main/res/values/strings.xml | 3 --- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt index 9b0374cee..8c961bd2c 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt @@ -33,6 +33,7 @@ fun initPermissionRegistry() { PermissionRegistry.register("ACCESSIBILITY", R.string.feat_dynamic_night_light_title) PermissionRegistry.register("ACCESSIBILITY", R.string.feat_app_lock_title) PermissionRegistry.register("ACCESSIBILITY", R.string.feat_essentials_on_display_title) + PermissionRegistry.register("ACCESSIBILITY", R.string.feat_conscious_gate_title) // Write secure settings permission PermissionRegistry.register("WRITE_SECURE_SETTINGS", R.string.feat_statusbar_icons_title) @@ -70,6 +71,7 @@ fun initPermissionRegistry() { PermissionRegistry.register("USAGE_STATS", R.string.feat_freeze_title) PermissionRegistry.register("USAGE_STATS", R.string.feat_app_lock_title) PermissionRegistry.register("USAGE_STATS", R.string.feat_dynamic_night_light_title) + PermissionRegistry.register("USAGE_STATS", R.string.feat_conscious_gate_title) PermissionRegistry.register("NOTIFICATION_LISTENER", R.string.feat_freeze_title) // Root permission diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateSettingsUI.kt index 5052a022c..f2d3dc7bf 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateSettingsUI.kt @@ -50,6 +50,7 @@ import com.sameerasw.essentials.ui.modifiers.highlight import com.sameerasw.essentials.utils.AppUtil import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.viewmodels.MainViewModel +import com.sameerasw.essentials.viewmodels.PermissionViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -59,6 +60,9 @@ const val CONSCIOUS_GATE_FEATURE_ID = "Conscious gate" @Composable fun ConsciousGateSettingsUI( viewModel: MainViewModel, + permissionViewModel: PermissionViewModel = + androidx.lifecycle.viewmodel.compose + .viewModel(), modifier: Modifier = Modifier, highlightKey: String? = null, ) { @@ -70,6 +74,11 @@ fun ConsciousGateSettingsUI( var isPreviewOpen by remember { mutableStateOf(false) } val isConsciousGateEnabled by viewModel.isConsciousGateEnabled + val isUseUsageAccess by viewModel.isUseUsageAccess + val isAccessibilityEnabled by permissionViewModel.isAccessibilityEnabled + val isUsageStatsPermissionGranted by viewModel.isUsageStatsPermissionGranted + val canEnableConsciousGate = + if (isUseUsageAccess) isUsageStatsPermissionGranted else isAccessibilityEnabled val delaySeconds by viewModel.consciousGateDelaySeconds val reappearMinutes by viewModel.consciousGateReappearMinutes val iconName by viewModel.consciousGateIconName @@ -154,6 +163,8 @@ fun ConsciousGateSettingsUI( title = stringResource(R.string.conscious_gate_enable_title), isChecked = isConsciousGateEnabled, onCheckedChange = { enabled -> viewModel.setConsciousGateEnabled(enabled, context) }, + enabled = canEnableConsciousGate, + onDisabledClick = {}, modifier = Modifier.highlight(highlightKey == "conscious_gate_enabled"), ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d3aed3f7b..bbb3754b9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -135,13 +135,10 @@ 5 minutes 10 minutes 15 minutes - 30 minutes - 1 hour Custom seconds Custom minutes (0 = off) %1$d minutes +%1$d more - Preview Preview conscious gate screen a gated app Custom