diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 09632fb21..b1c31fb9c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -335,6 +335,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 93d9a9af0..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 @@ -1454,6 +1455,66 @@ object FeatureRegistry { enabled: Boolean, ) = viewModel.setAppLockEnabled(enabled, context) }, + object : Feature( + id = CONSCIOUS_GATE_FEATURE_ID, + title = R.string.feat_conscious_gate_title, + iconRes = R.drawable.rounded_pause_24, + category = R.string.cat_interaction, + description = R.string.feat_conscious_gate_desc, + aboutDescription = R.string.about_desc_conscious_gate, + parentFeatureId = "Input", + 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/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/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..e926cf4be --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/ConsciousGateActivity.kt @@ -0,0 +1,166 @@ +/* + * 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.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.remember +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 + +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) { ConsciousGateIcons.resolve(iconName) } + + val progressAnimatable = remember { Animatable(if (delaySeconds <= 0) 1f else 0f) } + + LaunchedEffect(delaySeconds) { + if (delaySeconds <= 0) { + progressAnimatable.snapTo(1f) + return@LaunchedEffect + } + progressAnimatable.snapTo(0f) + progressAnimatable.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = delaySeconds * 1000, easing = LinearEasing), + ) + } + + 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 = { progressAnimatable.value }, + isContinueEnabled = progressAnimatable.value >= 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 a4e3bfe92..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,6 +351,9 @@ class FeatureSettingsActivity : AppCompatActivity() { "App lock" -> !isAccessibilityEnabled || (if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else false) + CONSCIOUS_GATE_FEATURE_ID -> + !isAccessibilityEnabled || + (if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else false) "Freeze" -> !com.sameerasw.essentials.utils.ShellUtils.hasPermission( context, @@ -751,6 +755,9 @@ class FeatureSettingsActivity : AppCompatActivity() { "App lock" -> !isAccessibilityEnabled || (if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else false) + CONSCIOUS_GATE_FEATURE_ID -> + !isAccessibilityEnabled || + (if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else false) "Freeze" -> !com.sameerasw.essentials.utils.ShellUtils.hasPermission( context, @@ -1010,6 +1017,13 @@ class FeatureSettingsActivity : AppCompatActivity() { ) } + CONSCIOUS_GATE_FEATURE_ID -> { + 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/ConsciousGatePauseScreen.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePauseScreen.kt new file mode 100644 index 000000000..6577024e0 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePauseScreen.kt @@ -0,0 +1,283 @@ +/* + * 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.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 + +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, +) { + val view = LocalView.current + OutlinedButton( + onClick = { + HapticUtil.performUIHaptic(view) + 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) { + val view = LocalView.current + Button( + 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 new file mode 100644 index 000000000..66907e0ae --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePreview.kt @@ -0,0 +1,70 @@ +/* + * 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 com.sameerasw.essentials.domain.model.ConsciousGateCountdownStyle +import com.sameerasw.essentials.ui.features.consciousgate.components.ConsciousGateIcons +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 iconResId = remember(iconName) { ConsciousGateIcons.resolve(iconName) } + + 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..f2d3dc7bf --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGateSettingsUI.kt @@ -0,0 +1,402 @@ +/* + * 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.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.MaterialTheme +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.platform.LocalView +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.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 com.sameerasw.essentials.viewmodels.PermissionViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +const val CONSCIOUS_GATE_FEATURE_ID = "Conscious gate" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConsciousGateSettingsUI( + viewModel: MainViewModel, + permissionViewModel: PermissionViewModel = + androidx.lifecycle.viewmodel.compose + .viewModel(), + modifier: Modifier = Modifier, + 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()) } + 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 + 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) }, + enabled = canEnableConsciousGate, + onDisabledClick = {}, + 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 = { + HapticUtil.performVirtualKeyHaptic(view) + isDelayCustom = false + viewModel.setConsciousGateDelaySeconds(seconds) + }, + ) + } + SegmentedDropdownMenuItem( + text = { Text(customOptionLabel) }, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + isDelayCustom = true + }, + ) + } + + if (isDelayCustom) { + ConsciousGateTextFieldRow( + 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 = stringResource(R.string.conscious_gate_delay_custom_label), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + enabled = isConsciousGateEnabled, + ) + } + + 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 = { + HapticUtil.performVirtualKeyHaptic(view) + isReappearCustom = false + viewModel.setConsciousGateReappearMinutes(minutes) + }, + ) + } + SegmentedDropdownMenuItem( + text = { Text(customOptionLabel) }, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + isReappearCustom = true + }, + ) + } + + if (isReappearCustom) { + ConsciousGateTextFieldRow( + 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 = stringResource(R.string.conscious_gate_reappear_custom_label), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + enabled = isConsciousGateEnabled, + ) + } + } + + 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) { + 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.fillMaxWidth(), + ) + } + + ConsciousGateIconPicker( + selectedIconName = iconName, + onIconSelected = { viewModel.setConsciousGateIconName(it) }, + modifier = Modifier.fillMaxWidth(), + ) + + 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, + ) + + ConsciousGateTextFieldRow( + value = message, + onValueChange = { viewModel.setConsciousGateMessage(it) }, + label = stringResource(R.string.conscious_gate_message_label), + placeholder = stringResource(R.string.conscious_gate_default_message), + ) + + 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( + 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) + }, + ) + } + } +} + +@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/components/ConsciousGateCountdown.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/ConsciousGateCountdown.kt new file mode 100644 index 000000000..df1892f78 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/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.components + +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/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/components/ConsciousGateIconPicker.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/ConsciousGateIconPicker.kt new file mode 100644 index 000000000..5d4847665 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/components/ConsciousGateIconPicker.kt @@ -0,0 +1,106 @@ +/* + * 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.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +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.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 = ConsciousGateIcons.OPTIONS + val carouselState = rememberCarouselState { icons.size } + val view = LocalView.current + + SettingsRowSurface( + modifier = modifier, + contentPadding = PaddingValues(16.dp), + ) { + 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, iconResId) = icons[index] + val isSelected = iconName == selectedIconName + + 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 = iconResId), + 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/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, + ) +} 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 fae5a840b..60330be3e 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 94d785f5c..342798117 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 bf8513d6d..9f105352c 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. * @@ -5930,6 +5991,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 c71f3b434..bbb3754b9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -122,6 +122,37 @@ 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 + Custom seconds + Custom minutes (0 = off) + %1$d minutes + +%1$d more + 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 +648,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 +802,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 +993,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 @@