diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 07f0b0505..4c589f3e1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,8 @@
+
+
@@ -695,6 +697,14 @@
+
+
+
+
+
+
,
- val imageUrls: List = emptyList()
+ val imageUrls: List = emptyList(),
+ val localImagePaths: List = emptyList(),
+ val lastUpdated: Long = System.currentTimeMillis()
)
diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt
index 1aac598d9..32313248e 100644
--- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt
+++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt
@@ -8,6 +8,7 @@ import com.sameerasw.essentials.domain.model.AppSelection
import com.sameerasw.essentials.domain.model.NotificationLightingColorMode
import com.sameerasw.essentials.domain.model.NotificationLightingSide
import com.sameerasw.essentials.domain.model.NotificationLightingStyle
+import com.sameerasw.essentials.domain.model.DnsPreset
import com.sameerasw.essentials.domain.model.TrackedRepo
import com.sameerasw.essentials.domain.model.github.GitHubUser
import com.sameerasw.essentials.utils.RootUtils
@@ -100,6 +101,7 @@ class SettingsRepository(private val context: Context) {
const val KEY_FREEZE_AUTO_EXCLUDED_APPS = "freeze_auto_excluded_apps"
const val KEY_FREEZE_SELECTED_APPS = "freeze_selected_apps"
const val KEY_FREEZE_DONT_FREEZE_ACTIVE_APPS = "freeze_dont_freeze_active_apps"
+ const val KEY_FREEZE_MODE = "freeze_mode"
const val KEY_DEVELOPER_MODE_ENABLED = "developer_mode_enabled"
const val KEY_HAPTIC_FEEDBACK_TYPE = "haptic_feedback_type"
@@ -120,6 +122,7 @@ class SettingsRepository(private val context: Context) {
const val KEY_KEYBOARD_PITCH_BLACK = "keyboard_pitch_black"
const val KEY_KEYBOARD_CLIPBOARD_ENABLED = "keyboard_clipboard_enabled"
const val KEY_KEYBOARD_LONG_PRESS_SYMBOLS = "keyboard_long_press_symbols"
+ const val KEY_KEYBOARD_ACCENTED_CHARACTERS = "keyboard_accented_characters"
// Essentials-AirSync Bridge
const val KEY_AIRSYNC_CONNECTION_ENABLED = "airsync_connection_enabled"
@@ -166,6 +169,7 @@ class SettingsRepository(private val context: Context) {
const val KEY_USE_BLUR = "use_blur"
const val KEY_SENTRY_REPORT_MODE = "sentry_report_mode"
const val KEY_ONBOARDING_COMPLETED = "onboarding_completed"
+ const val KEY_PRIVATE_DNS_PRESETS = "private_dns_presets"
}
// Observe changes
@@ -285,6 +289,8 @@ class SettingsRepository(private val context: Context) {
putString(KEY_FREEZE_AUTO_EXCLUDED_APPS, json)
}
+ fun getFreezeMode(): Int = getInt(KEY_FREEZE_MODE, 0)
+
fun getHapticFeedbackType(): HapticFeedbackType {
val typeName = prefs.getString(KEY_HAPTIC_FEEDBACK_TYPE, HapticFeedbackType.SUBTLE.name)
return try {
@@ -717,6 +723,9 @@ class SettingsRepository(private val context: Context) {
fun isUserDictionaryEnabled(): Boolean = getBoolean(KEY_USER_DICTIONARY_ENABLED, false)
fun setUserDictionaryEnabled(enabled: Boolean) = putBoolean(KEY_USER_DICTIONARY_ENABLED, enabled)
+ fun isAccentedCharactersEnabled(): Boolean = getBoolean(KEY_KEYBOARD_ACCENTED_CHARACTERS, false)
+ fun setAccentedCharactersEnabled(enabled: Boolean) = putBoolean(KEY_KEYBOARD_ACCENTED_CHARACTERS, enabled)
+
fun isBatteryNotificationEnabled(): Boolean = getBoolean(KEY_BATTERY_NOTIFICATION_ENABLED, false)
fun setBatteryNotificationEnabled(enabled: Boolean) = putBoolean(KEY_BATTERY_NOTIFICATION_ENABLED, enabled)
@@ -866,5 +875,37 @@ class SettingsRepository(private val context: Context) {
e.printStackTrace()
}
}
+
+ fun getPrivateDnsPresets(): List {
+ val json = prefs.getString(KEY_PRIVATE_DNS_PRESETS, null)
+ return if (json != null) {
+ try {
+ gson.fromJson(json, Array::class.java).toList()
+ } catch (e: Exception) {
+ getDefaultDnsPresets()
+ }
+ } else {
+ getDefaultDnsPresets().also { savePrivateDnsPresets(it) }
+ }
+ }
+
+ private fun getDefaultDnsPresets(): List {
+ return listOf(
+ DnsPreset(name = context.getString(com.sameerasw.essentials.R.string.dns_preset_adguard), hostname = "dns.adguard.com", isDefault = true),
+ DnsPreset(name = context.getString(com.sameerasw.essentials.R.string.dns_preset_google), hostname = "dns.google", isDefault = true),
+ DnsPreset(name = context.getString(com.sameerasw.essentials.R.string.dns_preset_cloudflare), hostname = "1dot1dot1dot1.cloudflare-dns.com", isDefault = true),
+ DnsPreset(name = context.getString(com.sameerasw.essentials.R.string.dns_preset_quad9), hostname = "dns.quad9.net", isDefault = true),
+ DnsPreset(name = context.getString(com.sameerasw.essentials.R.string.dns_preset_cleanbrowsing), hostname = "adult-filter-dns.cleanbrowsing.org", isDefault = true)
+ )
+ }
+
+ fun savePrivateDnsPresets(presets: List) {
+ val json = gson.toJson(presets)
+ putString(KEY_PRIVATE_DNS_PRESETS, json)
+ }
+
+ fun resetPrivateDnsPresets() {
+ savePrivateDnsPresets(getDefaultDnsPresets())
+ }
}
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt
index 6f7f1bbbf..22e28fee7 100644
--- a/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt
+++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt
@@ -79,4 +79,14 @@ sealed interface Action {
override val permissions: List = listOf("notification_policy")
override val isConfigurable: Boolean = true
}
+
+ data object TurnOnLowPower : Action {
+ override val title: Int = R.string.diy_action_low_power_on
+ override val icon: Int = R.drawable.rounded_battery_android_frame_shield_24
+ }
+
+ data object TurnOffLowPower : Action {
+ override val title: Int = R.string.diy_action_low_power_off
+ override val icon: Int = R.drawable.rounded_battery_android_frame_shield_24
+ }
}
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/State.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/State.kt
index 4093e0bc1..6a1140c0c 100644
--- a/app/src/main/java/com/sameerasw/essentials/domain/diy/State.kt
+++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/State.kt
@@ -20,4 +20,15 @@ sealed interface State {
override val title: Int = R.string.diy_state_screen_on
override val icon: Int = R.drawable.rounded_mobile_text_2_24
}
+
+ data class TimePeriod(
+ val startHour: Int = 0,
+ val startMinute: Int = 0,
+ val endHour: Int = 0,
+ val endMinute: Int = 0,
+ val days: Set = emptySet()
+ ) : State {
+ override val title: Int = R.string.diy_state_time_period
+ override val icon: Int = R.drawable.rounded_timelapse_24
+ }
}
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/Trigger.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/Trigger.kt
index 866219279..a6205ae73 100644
--- a/app/src/main/java/com/sameerasw/essentials/domain/diy/Trigger.kt
+++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/Trigger.kt
@@ -34,4 +34,14 @@ sealed interface Trigger {
override val title: Int = R.string.diy_trigger_charger_disconnected
override val icon: Int = R.drawable.rounded_battery_android_frame_3_24
}
+
+ data class Schedule(
+ val hour: Int = 0,
+ val minute: Int = 0,
+ val days: Set = emptySet()
+ ) : Trigger {
+ override val title: Int = R.string.diy_trigger_schedule
+ override val icon: Int = R.drawable.rounded_nest_clock_farsight_analog_24
+ override val isConfigurable: Boolean = true
+ }
}
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/DnsPreset.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/DnsPreset.kt
new file mode 100644
index 000000000..9f05839c1
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/domain/model/DnsPreset.kt
@@ -0,0 +1,8 @@
+package com.sameerasw.essentials.domain.model
+
+data class DnsPreset(
+ val id: String = java.util.UUID.randomUUID().toString(),
+ val name: String,
+ val hostname: String,
+ val isDefault: Boolean = false
+)
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/FreezeMode.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/FreezeMode.kt
new file mode 100644
index 000000000..7b0878e14
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/domain/model/FreezeMode.kt
@@ -0,0 +1,10 @@
+package com.sameerasw.essentials.domain.model
+
+enum class FreezeMode(val value: Int) {
+ FREEZE(0),
+ SUSPEND(1);
+
+ companion object {
+ fun fromInt(value: Int) = entries.find { it.value == value } ?: FREEZE
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/ime/EssentialsInputMethodService.kt b/app/src/main/java/com/sameerasw/essentials/ime/EssentialsInputMethodService.kt
index 8ce43b36e..472fc6702 100644
--- a/app/src/main/java/com/sameerasw/essentials/ime/EssentialsInputMethodService.kt
+++ b/app/src/main/java/com/sameerasw/essentials/ime/EssentialsInputMethodService.kt
@@ -267,6 +267,15 @@ class EssentialsInputMethodService : InputMethodService(), LifecycleOwner, ViewM
)
}
+ var isAccentedCharactersEnabled by remember {
+ mutableStateOf(
+ prefs.getBoolean(
+ SettingsRepository.KEY_KEYBOARD_ACCENTED_CHARACTERS,
+ false
+ )
+ )
+ }
+
// Observe SharedPreferences changes
DisposableEffect(prefs) {
val listener =
@@ -377,6 +386,12 @@ class EssentialsInputMethodService : InputMethodService(), LifecycleOwner, ViewM
false
)
}
+ SettingsRepository.KEY_KEYBOARD_ACCENTED_CHARACTERS -> {
+ isAccentedCharactersEnabled = sharedPreferences.getBoolean(
+ SettingsRepository.KEY_KEYBOARD_ACCENTED_CHARACTERS,
+ false
+ )
+ }
}
}
prefs.registerOnSharedPreferenceChangeListener(listener)
@@ -404,6 +419,7 @@ class EssentialsInputMethodService : InputMethodService(), LifecycleOwner, ViewM
functionsPadding = functionsPadding.dp,
isClipboardEnabled = isKeyboardClipboardEnabled,
isLongPressSymbolsEnabled = isLongPressSymbolsEnabled,
+ isAccentedCharactersEnabled = isAccentedCharactersEnabled,
suggestions = suggestions,
clipboardHistory = _clipboardHistory.collectAsState().value,
onOpened = resetTrigger,
diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationManager.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationManager.kt
index cd21718b0..e37c9f95a 100644
--- a/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationManager.kt
+++ b/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationManager.kt
@@ -8,6 +8,7 @@ import com.sameerasw.essentials.domain.diy.Trigger
import com.sameerasw.essentials.services.automation.modules.AutomationModule
import com.sameerasw.essentials.services.automation.modules.DisplayModule
import com.sameerasw.essentials.services.automation.modules.PowerModule
+import com.sameerasw.essentials.services.automation.modules.TimeModule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -61,6 +62,7 @@ object AutomationManager {
val requiredModuleIds = mutableSetOf()
val powerAutomations = mutableListOf()
val displayAutomations = mutableListOf()
+ val timeAutomations = mutableListOf()
enabledAutomations.forEach { automation ->
when (automation.type) {
@@ -76,6 +78,11 @@ object AutomationManager {
displayAutomations.add(automation)
}
+ is Trigger.Schedule -> {
+ requiredModuleIds.add(TimeModule.ID)
+ timeAutomations.add(automation)
+ }
+
else -> {}
}
}
@@ -92,6 +99,11 @@ object AutomationManager {
displayAutomations.add(automation)
}
+ is DIYState.TimePeriod -> {
+ requiredModuleIds.add(TimeModule.ID)
+ timeAutomations.add(automation)
+ }
+
else -> {}
}
}
@@ -132,6 +144,16 @@ object AutomationManager {
} else {
activeModules.remove(DisplayModule.ID)?.stop(context)
}
+
+ // Time Module
+ if (requiredModuleIds.contains(TimeModule.ID)) {
+ val module = activeModules.getOrPut(TimeModule.ID) {
+ TimeModule().also { it.start(context) }
+ }
+ module.updateAutomations(timeAutomations)
+ } else {
+ activeModules.remove(TimeModule.ID)?.stop(context)
+ }
}
private fun startService(context: Context) {
diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt
index a748351d1..fbfc60405 100644
--- a/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt
+++ b/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt
@@ -8,8 +8,11 @@ import com.sameerasw.essentials.domain.diy.Action
object CombinedActionExecutor {
- suspend fun execute(context: Context, action: Action) {
- when (action) {
+ suspend fun execute(context: Context, action: com.sameerasw.essentials.domain.diy.Action) {
+ kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
+ when (action) {
+ is Action.TurnOnLowPower -> setLowPowerMode(context, true)
+ is Action.TurnOffLowPower -> setLowPowerMode(context, false)
is Action.HapticVibration -> {
val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val manager =
@@ -90,7 +93,7 @@ object CombinedActionExecutor {
} catch (refE: Exception) {
null
}
- } ?: return
+ } ?: return@withContext
effectsBuilder.setShouldDisplayGrayscale(action.grayscale)
.setShouldSuppressAmbientDisplay(action.suppressAmbient)
@@ -189,6 +192,7 @@ object CombinedActionExecutor {
e.printStackTrace()
}
}
+ }
}
}
@@ -201,4 +205,13 @@ object CombinedActionExecutor {
e.printStackTrace()
}
}
+
+ private fun setLowPowerMode(context: Context, on: Boolean) {
+ val value = if (on) 1 else 0
+ try {
+ android.provider.Settings.Global.putInt(context.contentResolver, "low_power", value)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
}
diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/modules/PowerModule.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/PowerModule.kt
index 4fc1e0c97..14c2e66b6 100644
--- a/app/src/main/java/com/sameerasw/essentials/services/automation/modules/PowerModule.kt
+++ b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/PowerModule.kt
@@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
+import android.os.PowerManager
import com.sameerasw.essentials.domain.diy.Automation
import com.sameerasw.essentials.domain.diy.Trigger
import com.sameerasw.essentials.services.automation.executors.CombinedActionExecutor
diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/modules/TimeModule.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/TimeModule.kt
new file mode 100644
index 000000000..b09ce8d71
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/TimeModule.kt
@@ -0,0 +1,207 @@
+package com.sameerasw.essentials.services.automation.modules
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.util.Log
+import com.sameerasw.essentials.domain.diy.Automation
+import com.sameerasw.essentials.domain.diy.Trigger
+import com.sameerasw.essentials.services.automation.executors.CombinedActionExecutor
+import com.sameerasw.essentials.services.automation.receivers.TimeAutomationReceiver
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import java.util.*
+import com.sameerasw.essentials.domain.diy.State as DIYState
+
+class TimeModule : AutomationModule {
+ companion object {
+ const val ID = "time_module"
+ }
+
+ override val id: String = ID
+ private var automations: List = emptyList()
+ private val scope = CoroutineScope(Dispatchers.IO)
+ private var appContext: Context? = null
+
+ override fun start(context: Context) {
+ appContext = context.applicationContext
+ // We'll run initial check only after we get automations
+ }
+
+ override fun stop(context: Context) {
+ appContext?.let { cancelAllAlarms(it) }
+ appContext = null
+ }
+
+ override fun updateAutomations(automations: List) {
+ this.automations = automations
+ appContext?.let {
+ checkCurrentStates(it)
+ scheduleAllAlarms(it)
+ }
+ }
+
+ private fun scheduleAllAlarms(context: Context) {
+ cancelAllAlarms(context)
+
+ automations.forEach { automation ->
+ when (automation.type) {
+ Automation.Type.TRIGGER -> {
+ (automation.trigger as? Trigger.Schedule)?.let { schedule ->
+ scheduleAlarm(context, automation.id, schedule.hour, schedule.minute, schedule.days, true)
+ }
+ }
+ Automation.Type.STATE -> {
+ (automation.state as? DIYState.TimePeriod)?.let { period ->
+ scheduleAlarm(context, automation.id, period.startHour, period.startMinute, period.days, true)
+ scheduleAlarm(context, automation.id, period.endHour, period.endMinute, period.days, false)
+ }
+ }
+ else -> {}
+ }
+ }
+ }
+
+ private fun scheduleAlarm(context: Context, id: String, hour: Int, minute: Int, days: Set, isEntry: Boolean) {
+ val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ val intent = Intent(context, TimeAutomationReceiver::class.java).apply {
+ action = TimeAutomationReceiver.ACTION_TRIGGER
+ putExtra(TimeAutomationReceiver.EXTRA_AUTOMATION_ID, id)
+ putExtra(TimeAutomationReceiver.EXTRA_IS_ENTRY, isEntry)
+ }
+
+ val requestCode = (id + isEntry.toString()).hashCode()
+ val pendingIntent = PendingIntent.getBroadcast(
+ context,
+ requestCode,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val calendar = calculateNextOccurrence(hour, minute, days)
+ android.util.Log.d(ID, "Scheduling alarm for automation $id (entry=$isEntry) at ${calendar.time}")
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ try {
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
+ if (alarmManager.canScheduleExactAlarms()) {
+ alarmManager.setExactAndAllowWhileIdle(
+ AlarmManager.RTC_WAKEUP,
+ calendar.timeInMillis,
+ pendingIntent
+ )
+ } else {
+ // Fallback to inexact
+ alarmManager.setAndAllowWhileIdle(
+ AlarmManager.RTC_WAKEUP,
+ calendar.timeInMillis,
+ pendingIntent
+ )
+ }
+ } else {
+ alarmManager.setExactAndAllowWhileIdle(
+ AlarmManager.RTC_WAKEUP,
+ calendar.timeInMillis,
+ pendingIntent
+ )
+ }
+ } catch (e: SecurityException) {
+ alarmManager.setAndAllowWhileIdle(
+ AlarmManager.RTC_WAKEUP,
+ calendar.timeInMillis,
+ pendingIntent
+ )
+ }
+} else {
+ alarmManager.setExact(AlarmManager.RTC_WAKEUP, calendar.timeInMillis, pendingIntent)
+ }
+ }
+
+ private fun cancelAllAlarms(context: Context) {
+ val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ automations.forEach { automation ->
+ val intent = Intent(context, TimeAutomationReceiver::class.java).apply {
+ action = TimeAutomationReceiver.ACTION_TRIGGER
+ }
+
+ val rc1 = (automation.id + "true").hashCode()
+ PendingIntent.getBroadcast(context, rc1, intent, PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE)?.let {
+ alarmManager.cancel(it)
+ it.cancel()
+ }
+
+ val rc2 = (automation.id + "false").hashCode()
+ PendingIntent.getBroadcast(context, rc2, intent, PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE)?.let {
+ alarmManager.cancel(it)
+ it.cancel()
+ }
+ }
+ }
+
+ private fun calculateNextOccurrence(hour: Int, minute: Int, days: Set): Calendar {
+ val now = Calendar.getInstance()
+ val target = Calendar.getInstance().apply {
+ set(Calendar.HOUR_OF_DAY, hour)
+ set(Calendar.MINUTE, minute)
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ }
+
+ if (target.before(now)) {
+ target.add(Calendar.DAY_OF_YEAR, 1)
+ }
+
+ if (days.isNotEmpty()) {
+ while (!days.contains(target.get(Calendar.DAY_OF_WEEK))) {
+ target.add(Calendar.DAY_OF_YEAR, 1)
+ }
+ }
+
+ return target
+ }
+
+ private val activeStateAutomations = mutableSetOf()
+
+ private fun checkCurrentStates(context: Context) {
+ scope.launch {
+ val now = Calendar.getInstance()
+ val currentHour = now.get(Calendar.HOUR_OF_DAY)
+ val currentMinute = now.get(Calendar.MINUTE)
+ val currentDay = now.get(Calendar.DAY_OF_WEEK)
+
+ android.util.Log.d(ID, "Checking current states at $currentHour:$currentMinute on day $currentDay")
+
+ automations.filter { it.type == Automation.Type.STATE && it.isEnabled }
+ .forEach { automation ->
+ (automation.state as? DIYState.TimePeriod)?.let { period ->
+ if (period.days.isEmpty() || period.days.contains(currentDay)) {
+ val startTime = period.startHour * 60 + period.startMinute
+ val endTime = period.endHour * 60 + period.endMinute
+ val currentTime = currentHour * 60 + currentMinute
+
+ val isActive = if (startTime < endTime) {
+ currentTime in startTime until endTime
+ } else {
+ currentTime >= startTime || currentTime < endTime
+ }
+
+ val wasActive = activeStateAutomations.contains(automation.id)
+
+ if (isActive && !wasActive) {
+ Log.d(ID, "State ${automation.id} became active. Executing entry actions.")
+ activeStateAutomations.add(automation.id)
+ automation.entryAction?.let { CombinedActionExecutor.execute(context, it) }
+ } else if (!isActive && wasActive) {
+ Log.d(ID, "State ${automation.id} became inactive. Executing exit actions.")
+ activeStateAutomations.remove(automation.id)
+ automation.exitAction?.let { CombinedActionExecutor.execute(context, it) }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/receivers/TimeAutomationReceiver.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/receivers/TimeAutomationReceiver.kt
new file mode 100644
index 000000000..192ad404e
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/services/automation/receivers/TimeAutomationReceiver.kt
@@ -0,0 +1,56 @@
+package com.sameerasw.essentials.services.automation.receivers
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.sameerasw.essentials.domain.diy.Automation
+import com.sameerasw.essentials.domain.diy.DIYRepository
+import com.sameerasw.essentials.services.automation.executors.CombinedActionExecutor
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class TimeAutomationReceiver : BroadcastReceiver() {
+ companion object {
+ const val ACTION_TRIGGER = "com.sameerasw.essentials.ACTION_TIME_AUTOMATION_TRIGGER"
+ const val EXTRA_AUTOMATION_ID = "automation_id"
+ const val EXTRA_IS_ENTRY = "is_entry"
+ }
+
+ private val scope = CoroutineScope(Dispatchers.IO)
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != ACTION_TRIGGER) return
+
+ val automationId = intent.getStringExtra(EXTRA_AUTOMATION_ID) ?: return
+ val isEntry = intent.getBooleanExtra(EXTRA_IS_ENTRY, true)
+
+ val pendingResult = goAsync()
+ scope.launch {
+ try {
+ DIYRepository.init(context)
+ val automation = DIYRepository.getAutomation(automationId) ?: return@launch
+ if (!automation.isEnabled) {
+ return@launch
+ }
+
+ when (automation.type) {
+ Automation.Type.TRIGGER -> {
+ automation.actions.forEach { action ->
+ CombinedActionExecutor.execute(context, action)
+ }
+ }
+ Automation.Type.STATE -> {
+ val action = if (isEntry) automation.entryAction else automation.exitAction
+ action?.let { CombinedActionExecutor.execute(context, it) }
+ }
+ else -> {}
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/PrivateDnsTileService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/PrivateDnsTileService.kt
index 978bb83e9..936f108b4 100644
--- a/app/src/main/java/com/sameerasw/essentials/services/tiles/PrivateDnsTileService.kt
+++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/PrivateDnsTileService.kt
@@ -37,9 +37,19 @@ class PrivateDnsTileService : BaseTileService() {
override fun getTileLabel(): String = getString(R.string.tile_private_dns)
override fun getTileSubtitle(): String {
- return when (getPrivateDnsMode()) {
+ val mode = getPrivateDnsMode()
+ return when (mode) {
MODE_AUTO -> getString(R.string.tile_private_dns_auto)
- MODE_HOSTNAME -> getPrivateDnsHostname() ?: getString(R.string.feat_qs_tiles_title)
+ MODE_HOSTNAME -> {
+ val hostname = getPrivateDnsHostname()
+ if (!hostname.isNullOrEmpty()) {
+ val settingsRepository = com.sameerasw.essentials.data.repository.SettingsRepository(this)
+ val preset = settingsRepository.getPrivateDnsPresets().find { it.hostname == hostname }
+ preset?.name ?: hostname
+ } else {
+ getString(R.string.feat_qs_tiles_title)
+ }
+ }
else -> getString(R.string.tile_private_dns_off)
}
}
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt
index e36b67ac5..74e28bdcd 100644
--- a/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt
+++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt
@@ -226,6 +226,7 @@ class AutomationEditorActivity : ComponentActivity() {
var showDimSettings by remember { mutableStateOf(false) }
var showDeviceEffectsSettings by remember { mutableStateOf(false) }
var showSoundModeSettings by remember { mutableStateOf(false) }
+ var showTimeSettings by remember { mutableStateOf(false) }
var configAction by remember { mutableStateOf(null) } // Generic config action
// Validation
@@ -456,7 +457,12 @@ class AutomationEditorActivity : ComponentActivity() {
Trigger.ScreenOn,
Trigger.DeviceUnlock,
Trigger.ChargerConnected,
- Trigger.ChargerDisconnected
+ Trigger.ChargerDisconnected,
+ Trigger.Schedule(
+ hour = (selectedTrigger as? Trigger.Schedule)?.hour ?: 0,
+ minute = (selectedTrigger as? Trigger.Schedule)?.minute ?: 0,
+ days = (selectedTrigger as? Trigger.Schedule)?.days ?: emptySet()
+ )
)
triggers.forEach { trigger ->
EditorActionItem(
@@ -466,21 +472,36 @@ class AutomationEditorActivity : ComponentActivity() {
isConfigurable = trigger.isConfigurable,
onClick = { selectedTrigger = trigger },
onSettingsClick = {
- // Handle trigger settings if needed later
+ if (trigger is Trigger.Schedule) {
+ showTimeSettings = true
+ }
}
)
}
} else {
val states = listOf(
DIYState.Charging,
- DIYState.ScreenOn
+ DIYState.ScreenOn,
+ DIYState.TimePeriod(
+ startHour = (selectedState as? DIYState.TimePeriod)?.startHour ?: 0,
+ startMinute = (selectedState as? DIYState.TimePeriod)?.startMinute ?: 0,
+ endHour = (selectedState as? DIYState.TimePeriod)?.endHour ?: 0,
+ endMinute = (selectedState as? DIYState.TimePeriod)?.endMinute ?: 0,
+ days = (selectedState as? DIYState.TimePeriod)?.days ?: emptySet()
+ )
)
states.forEach { state ->
EditorActionItem(
title = stringResource(state.title),
iconRes = state.icon,
isSelected = selectedState == state,
- onClick = { selectedState = state }
+ onClick = { selectedState = state },
+ isConfigurable = state is DIYState.TimePeriod,
+ onSettingsClick = {
+ if (state is DIYState.TimePeriod) {
+ showTimeSettings = true
+ }
+ }
)
}
}
@@ -530,7 +551,9 @@ class AutomationEditorActivity : ComponentActivity() {
Action.ToggleFlashlight,
Action.HapticVibration,
Action.DimWallpaper(),
- Action.SoundMode()
+ Action.SoundMode(),
+ Action.TurnOnLowPower,
+ Action.TurnOffLowPower
)
// Only show Device Effects on Android 15+
actions.add(Action.DeviceEffects())
@@ -615,6 +638,22 @@ class AutomationEditorActivity : ComponentActivity() {
}
}
+ if (showTimeSettings) {
+ com.sameerasw.essentials.ui.components.sheets.TimeSelectionSheet(
+ initialTrigger = selectedTrigger as? Trigger.Schedule,
+ initialState = selectedState as? DIYState.TimePeriod,
+ onDismiss = { showTimeSettings = false },
+ onSaveTrigger = {
+ selectedTrigger = it
+ showTimeSettings = false
+ },
+ onSaveState = {
+ selectedState = it
+ showTimeSettings = false
+ }
+ )
+ }
+
if (showDimSettings && configAction is Action.DimWallpaper) {
DimWallpaperSettingsSheet(
initialAction = configAction as Action.DimWallpaper,
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/PrivateDnsSettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/PrivateDnsSettingsActivity.kt
index 8f2a00957..c96a4e281 100644
--- a/app/src/main/java/com/sameerasw/essentials/ui/activities/PrivateDnsSettingsActivity.kt
+++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/PrivateDnsSettingsActivity.kt
@@ -52,6 +52,11 @@ import com.sameerasw.essentials.R
import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer
import com.sameerasw.essentials.ui.theme.EssentialsTheme
import com.sameerasw.essentials.utils.HapticUtil
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.AlertDialog
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.width
+import com.sameerasw.essentials.domain.model.DnsPreset
class PrivateDnsSettingsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -77,11 +82,14 @@ class PrivateDnsSettingsActivity : ComponentActivity() {
fun PrivateDnsSettingsOverlay(onDismiss: () -> Unit) {
val context = LocalContext.current
val view = LocalView.current
+ val viewModel: com.sameerasw.essentials.viewmodels.MainViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val PRIVATE_DNS_MODE = "private_dns_mode"
val PRIVATE_DNS_SPECIFIER = "private_dns_specifier"
+ var showAddDialog by remember { mutableStateOf(false) }
+
val currentMode = remember {
Settings.Global.getString(context.contentResolver, PRIVATE_DNS_MODE) ?: "off"
}
@@ -177,30 +185,81 @@ fun PrivateDnsSettingsOverlay(onDismiss: () -> Unit) {
)
}
- Text(
- text = stringResource(R.string.private_dns_presets_title),
- style = MaterialTheme.typography.labelLarge,
- modifier = Modifier.padding(horizontal = 16.dp),
- color = MaterialTheme.colorScheme.primary
- )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(R.string.private_dns_presets_title),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedButton(
+ onClick = {
+ (viewModel as? com.sameerasw.essentials.viewmodels.MainViewModel)?.resetDnsPresets()
+ HapticUtil.performUIHaptic(view)
+ },
+ modifier = Modifier.height(32.dp),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp, vertical = 0.dp),
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.dns_preset_reset_action),
+ style = MaterialTheme.typography.labelMedium
+ )
+ }
+ Button(
+ onClick = {
+ showAddDialog = true
+ HapticUtil.performUIHaptic(view)
+ },
+ modifier = Modifier.height(32.dp),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp, vertical = 0.dp),
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_add_24),
+ contentDescription = null,
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = stringResource(R.string.action_add),
+ style = MaterialTheme.typography.labelMedium
+ )
+ }
+ }
+ }
- RoundedCardContainer {
- val presets = listOf(
- Pair(R.string.dns_preset_adguard, R.string.dns_preset_adguard_hostname),
- Pair(R.string.dns_preset_google, R.string.dns_preset_google_hostname),
- Pair(R.string.dns_preset_cloudflare, R.string.dns_preset_cloudflare_hostname),
- Pair(R.string.dns_preset_quad9, R.string.dns_preset_quad9_hostname),
- Pair(R.string.dns_preset_cleanbrowsing, R.string.dns_preset_cleanbrowsing_hostname)
+ if (showAddDialog) {
+ AddDnsPresetDialog(
+ onDismiss = { showAddDialog = false },
+ onConfirm = { name, host ->
+ (viewModel as? com.sameerasw.essentials.viewmodels.MainViewModel)?.addDnsPreset(name, host)
+ showAddDialog = false
+ HapticUtil.performUIHaptic(view)
+ }
)
+ }
- presets.forEach { (nameRes, hostRes) ->
- val host = stringResource(hostRes)
+ RoundedCardContainer {
+ val presets = (viewModel as? com.sameerasw.essentials.viewmodels.MainViewModel)?.dnsPresets ?: emptyList()
+
+ presets.forEach { preset ->
DnsPresetItem(
- name = stringResource(nameRes),
- hostname = host,
- isSelected = customHostname == host,
+ name = preset.name,
+ hostname = preset.hostname,
+ isSelected = customHostname == preset.hostname,
onClick = {
- customHostname = host
+ customHostname = preset.hostname
+ HapticUtil.performUIHaptic(view)
+ },
+ onDelete = {
+ (viewModel as? com.sameerasw.essentials.viewmodels.MainViewModel)?.removeDnsPreset(preset)
HapticUtil.performUIHaptic(view)
}
)
@@ -291,30 +350,104 @@ fun DnsPresetItem(
name: String,
hostname: String,
isSelected: Boolean,
- onClick: () -> Unit
+ onClick: () -> Unit,
+ onDelete: () -> Unit
) {
Card(
- modifier = Modifier.fillMaxWidth().clickable { onClick() },
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onClick() },
shape = MaterialTheme.shapes.extraSmall,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceBright
)
) {
- Column(
+ Row(
modifier = Modifier
- .padding(16.dp)
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
) {
- Text(
- text = name,
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.Medium,
- color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
- )
- Text(
- text = hostname,
- style = MaterialTheme.typography.bodySmall,
- color = if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant
- )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = name,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = hostname,
+ style = MaterialTheme.typography.bodySmall,
+ color = if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (isSelected) {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_check_24),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ }
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_delete_24),
+ contentDescription = stringResource(R.string.dns_preset_delete_content_description),
+ tint = MaterialTheme.colorScheme.error.copy(alpha = 0.6f),
+ modifier = Modifier
+ .size(20.dp)
+ .clickable { onDelete() }
+ )
+ }
}
}
}
+
+@Composable
+fun AddDnsPresetDialog(
+ onDismiss: () -> Unit,
+ onConfirm: (String, String) -> Unit
+) {
+ var name by remember { mutableStateOf("") }
+ var hostname by remember { mutableStateOf("") }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.dns_preset_add_title)) },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it },
+ label = { Text(stringResource(R.string.dns_preset_name_label)) },
+ singleLine = true,
+ shape = RoundedCornerShape(12.dp)
+ )
+ OutlinedTextField(
+ value = hostname,
+ onValueChange = { hostname = it },
+ label = { Text(stringResource(R.string.private_dns_hostname_label)) },
+ singleLine = true,
+ shape = RoundedCornerShape(12.dp)
+ )
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = { if (name.isNotBlank() && hostname.isNotBlank()) onConfirm(name, hostname) },
+ enabled = name.isNotBlank() && hostname.isNotBlank()
+ ) {
+ Text(stringResource(R.string.action_add))
+ }
+ },
+ dismissButton = {
+ OutlinedButton(onClick = onDismiss) {
+ Text(stringResource(R.string.action_cancel))
+ }
+ },
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ shape = RoundedCornerShape(28.dp)
+ )
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/YourAndroidActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/YourAndroidActivity.kt
index cb32b5136..6f5bff2f4 100644
--- a/app/src/main/java/com/sameerasw/essentials/ui/activities/YourAndroidActivity.kt
+++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/YourAndroidActivity.kt
@@ -80,17 +80,36 @@ class YourAndroidViewModel : ViewModel() {
private val _isSpecsLoading = MutableStateFlow(true)
val isSpecsLoading = _isSpecsLoading.asStateFlow()
+ private val _isRefreshing = MutableStateFlow(false)
+ val isRefreshing = _isRefreshing.asStateFlow()
+
var hasRunStartupAnimation = false
- fun loadDeviceSpecs(deviceInfo: DeviceInfo) {
- if (_deviceSpecs.value != null) {
+ fun loadDeviceSpecs(context: android.content.Context, deviceInfo: com.sameerasw.essentials.utils.DeviceInfo, forceRefresh: Boolean = false) {
+ if (!forceRefresh && _deviceSpecs.value != null) {
_isSpecsLoading.value = false
return
}
viewModelScope.launch {
- _isSpecsLoading.value = true
- val specs = withContext(Dispatchers.IO) {
+ if (forceRefresh) {
+ _isRefreshing.value = true
+ } else {
+ _isSpecsLoading.value = true
+
+ // Try to load from cache first
+ val cached = withContext(Dispatchers.IO) {
+ com.sameerasw.essentials.utils.DeviceSpecsCache.getCachedSpecs(context)
+ }
+
+ if (cached != null) {
+ _deviceSpecs.value = cached
+ _isSpecsLoading.value = false
+ return@launch
+ }
+ }
+
+ val fetchedSpecs = withContext(Dispatchers.IO) {
val manufacturer = deviceInfo.manufacturer
val model = deviceInfo.model
val deviceName = deviceInfo.deviceName
@@ -105,30 +124,49 @@ class YourAndroidViewModel : ViewModel() {
} else {
queries.add("$manufacturer $model")
}
+
+ // 2. User-defined device name (often it's the marketing name like "Galaxy S21 FE 5G")
+ if (deviceName.isNotBlank() && !queries.contains(deviceName)) {
+ queries.add(deviceName)
+ }
+
+ // 3. Handle model numbers by stripping common prefixes (SM-, Redmi, Mi, POCO, etc.)
+ val prefixes = listOf("SM-", "Redmi ", "Mi ", "POCO ")
+ for (prefix in prefixes) {
+ if (model.startsWith(prefix, ignoreCase = true)) {
+ val stripped = model.substring(prefix.length).trim()
+ if (stripped.isNotBlank() && !queries.contains(stripped)) {
+ queries.add(stripped)
+ queries.add("$manufacturer $stripped")
+ }
+ }
+ }
- // 2. Model number directly if it's different from marketing name
+ // 4. Model number directly if it's different from marketing name
if (!queries.contains(model)) {
queries.add(model)
}
- // 3. User-defined device name (sometimes it's the marketing name)
- if (deviceName.isNotBlank() && !queries.contains(deviceName)) {
- queries.add(deviceName)
- }
-
- // 4. Device codename (e.g., "shiba", "a51")
+ // 5. Device codename (e.g., "shiba", "a51", "r9q")
if (deviceCodename.isNotBlank() && !queries.contains(deviceCodename)) {
queries.add(deviceCodename)
}
- GSMArenaService.fetchSpecs(
+ com.sameerasw.essentials.utils.GSMArenaService.fetchSpecs(
preferredName = manufacturer,
preferredModel = model,
queries = queries.toTypedArray()
)
}
- _deviceSpecs.value = specs
+
+ if (fetchedSpecs != null) {
+ // Download and cache images
+ val specsWithImages = com.sameerasw.essentials.utils.DeviceSpecsCache.downloadImages(context, fetchedSpecs)
+ _deviceSpecs.value = specsWithImages
+ }
+
_isSpecsLoading.value = false
+ _isRefreshing.value = false
}
}
}
@@ -150,6 +188,8 @@ class YourAndroidActivity : ComponentActivity() {
val viewModel: YourAndroidViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
val deviceSpecs by viewModel.deviceSpecs.collectAsState()
val isSpecsLoading by viewModel.isSpecsLoading.collectAsState()
+ val isRefreshing by viewModel.isRefreshing.collectAsState()
+
val context = androidx.compose.ui.platform.LocalContext.current
val deviceInfo = remember { DeviceUtils.getDeviceInfo(context) }
var showHelpSheet by remember { mutableStateOf(false) }
@@ -172,7 +212,7 @@ class YourAndroidActivity : ComponentActivity() {
LaunchedEffect(Unit) {
mainViewModel.check(context)
- viewModel.loadDeviceSpecs(deviceInfo)
+ viewModel.loadDeviceSpecs(context, deviceInfo)
}
EssentialsTheme(pitchBlackTheme = isPitchBlackThemeEnabled) {
@@ -194,14 +234,22 @@ class YourAndroidActivity : ComponentActivity() {
} else Modifier
)
) {
- YourAndroidContent(
- deviceInfo = deviceInfo,
- deviceSpecs = deviceSpecs,
- isSpecsLoading = isSpecsLoading,
- hasRunStartupAnimation = viewModel.hasRunStartupAnimation,
- onAnimationRun = { viewModel.hasRunStartupAnimation = true },
+ androidx.compose.material3.pulltorefresh.PullToRefreshBox(
+ isRefreshing = isRefreshing,
+ onRefresh = {
+ viewModel.loadDeviceSpecs(context, deviceInfo, forceRefresh = true)
+ },
modifier = Modifier.fillMaxSize()
- )
+ ) {
+ YourAndroidContent(
+ deviceInfo = deviceInfo,
+ deviceSpecs = deviceSpecs,
+ isSpecsLoading = isSpecsLoading,
+ hasRunStartupAnimation = viewModel.hasRunStartupAnimation,
+ onAnimationRun = { viewModel.hasRunStartupAnimation = true },
+ modifier = Modifier.fillMaxSize()
+ )
+ }
EssentialsFloatingToolbar(
title = stringResource(R.string.tab_your_android),
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/DeviceHeroCard.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/DeviceHeroCard.kt
index 6cb2789c1..32f08e696 100644
--- a/app/src/main/java/com/sameerasw/essentials/ui/components/DeviceHeroCard.kt
+++ b/app/src/main/java/com/sameerasw/essentials/ui/components/DeviceHeroCard.kt
@@ -95,10 +95,17 @@ fun DeviceHeroCard(
.fillMaxWidth(0.85f)
)
} else {
- // real image from gsmarena
+ // real image from gsmarena (or local cache)
val imageIndex = if (showIllustration) page - 1 else page
+ val imageModel = if (deviceSpecs?.localImagePaths?.isNotEmpty() == true &&
+ deviceSpecs.localImagePaths.size > imageIndex) {
+ deviceSpecs.localImagePaths[imageIndex]
+ } else {
+ imageUrls[imageIndex]
+ }
+
AsyncImage(
- model = imageUrls[imageIndex],
+ model = imageModel,
contentDescription = "Device Image",
contentScale = ContentScale.Fit,
modifier = Modifier
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/MultiSegmentedPicker.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/MultiSegmentedPicker.kt
index de03a5305..026ef9dee 100644
--- a/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/MultiSegmentedPicker.kt
+++ b/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/MultiSegmentedPicker.kt
@@ -26,7 +26,9 @@ fun MultiSegmentedPicker(
selectedItems: Set,
onItemsSelected: (Set) -> Unit,
labelProvider: (T) -> String,
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
+ shape: androidx.compose.ui.graphics.Shape = RoundedCornerShape(24.dp),
+ allowEmpty: Boolean = true
) {
val view = LocalView.current
@@ -34,7 +36,7 @@ fun MultiSegmentedPicker(
modifier = modifier
.background(
color = MaterialTheme.colorScheme.surfaceBright,
- shape = RoundedCornerShape(MaterialTheme.shapes.extraSmall.bottomEnd)
+ shape = shape
)
.padding(10.dp),
horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
@@ -51,7 +53,7 @@ fun MultiSegmentedPicker(
val newSelection = if (checked) {
selectedItems + item
} else {
- if (selectedItems.size > 1) selectedItems - item else selectedItems
+ if (allowEmpty || selectedItems.size > 1) selectedItems - item else selectedItems
}
onItemsSelected(newSelection)
},
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/TimeSelectionSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/TimeSelectionSheet.kt
new file mode 100644
index 000000000..899b9bf66
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/TimeSelectionSheet.kt
@@ -0,0 +1,264 @@
+package com.sameerasw.essentials.ui.components.sheets
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.sameerasw.essentials.R
+import com.sameerasw.essentials.domain.diy.State as DIYState
+import com.sameerasw.essentials.domain.diy.Trigger
+import com.sameerasw.essentials.ui.components.pickers.MultiSegmentedPicker
+import com.sameerasw.essentials.utils.HapticUtil
+import java.util.*
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TimeSelectionSheet(
+ initialTrigger: Trigger.Schedule? = null,
+ initialState: DIYState.TimePeriod? = null,
+ onDismiss: () -> Unit,
+ onSaveTrigger: (Trigger.Schedule) -> Unit = {},
+ onSaveState: (DIYState.TimePeriod) -> Unit = {}
+) {
+ val context = androidx.compose.ui.platform.LocalContext.current
+ val view = LocalView.current
+ val isRange = initialState != null
+ val is24Hour = android.text.format.DateFormat.is24HourFormat(context)
+
+ val startPickerState = rememberTimePickerState(
+ initialHour = initialTrigger?.hour ?: initialState?.startHour ?: 0,
+ initialMinute = initialTrigger?.minute ?: initialState?.startMinute ?: 0,
+ is24Hour = is24Hour
+ )
+ val endPickerState = rememberTimePickerState(
+ initialHour = initialState?.endHour ?: 0,
+ initialMinute = initialState?.endMinute ?: 0,
+ is24Hour = is24Hour
+ )
+ var selectedDays by remember { mutableStateOf(initialTrigger?.days ?: initialState?.days ?: emptySet()) }
+
+ var showingEndPicker by remember { mutableStateOf(false) }
+
+ LaunchedEffect(startPickerState.hour, startPickerState.minute) {
+ HapticUtil.performSliderHaptic(view)
+ }
+ LaunchedEffect(endPickerState.hour, endPickerState.minute) {
+ if (showingEndPicker) HapticUtil.performSliderHaptic(view)
+ }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ dragHandle = null
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(24.dp)
+ .fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = stringResource(if (isRange) R.string.diy_time_range_selection_title else R.string.diy_time_selection_title),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.align(Alignment.Start)
+ )
+
+ if (isRange) {
+ // Range display with Start/End tabs
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ TimeDisplayCard(
+ label = stringResource(R.string.diy_start_time_label),
+ hour = startPickerState.hour,
+ minute = startPickerState.minute,
+ isSelected = !showingEndPicker,
+ modifier = Modifier.weight(1f),
+ onClick = { showingEndPicker = false }
+ )
+ TimeDisplayCard(
+ label = stringResource(R.string.diy_end_time_label),
+ hour = endPickerState.hour,
+ minute = endPickerState.minute,
+ isSelected = showingEndPicker,
+ modifier = Modifier.weight(1f),
+ onClick = { showingEndPicker = true }
+ )
+ }
+ }
+
+ // Time Picker
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ color = MaterialTheme.colorScheme.surfaceBright,
+ shape = RoundedCornerShape(28.dp)
+ )
+ .padding(vertical = 16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ TimePicker(
+ state = if (showingEndPicker) endPickerState else startPickerState
+ )
+ }
+
+ // Days Selection
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = stringResource(R.string.diy_repeat_days_label),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ val days = listOf(
+ Calendar.MONDAY, Calendar.TUESDAY, Calendar.WEDNESDAY,
+ Calendar.THURSDAY, Calendar.FRIDAY, Calendar.SATURDAY, Calendar.SUNDAY
+ )
+ val dayLabels = mapOf(
+ Calendar.SUNDAY to "S",
+ Calendar.MONDAY to "M",
+ Calendar.TUESDAY to "T",
+ Calendar.WEDNESDAY to "W",
+ Calendar.THURSDAY to "T",
+ Calendar.FRIDAY to "F",
+ Calendar.SATURDAY to "S"
+ )
+
+ MultiSegmentedPicker(
+ items = days,
+ selectedItems = selectedDays,
+ onItemsSelected = { selectedDays = it },
+ labelProvider = { dayLabels[it]!! },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+
+ // Buttons
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Button(
+ onClick = {
+ HapticUtil.performVirtualKeyHaptic(view)
+ onDismiss()
+ },
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.surfaceBright,
+ contentColor = MaterialTheme.colorScheme.onSurface
+ )
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_close_24),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(stringResource(R.string.action_cancel))
+ }
+
+ Button(
+ onClick = {
+ HapticUtil.performVirtualKeyHaptic(view)
+ if (isRange) {
+ onSaveState(
+ DIYState.TimePeriod(
+ startHour = startPickerState.hour,
+ startMinute = startPickerState.minute,
+ endHour = endPickerState.hour,
+ endMinute = endPickerState.minute,
+ days = selectedDays
+ )
+ )
+ } else {
+ onSaveTrigger(
+ Trigger.Schedule(
+ hour = startPickerState.hour,
+ minute = startPickerState.minute,
+ days = selectedDays
+ )
+ )
+ }
+ },
+ modifier = Modifier.weight(1f)
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_check_24),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(stringResource(R.string.action_save))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TimeDisplayCard(
+ label: String,
+ hour: Int,
+ minute: Int,
+ isSelected: Boolean,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit
+) {
+ val context = androidx.compose.ui.platform.LocalContext.current
+ val view = LocalView.current
+
+ val formattedTime = remember(hour, minute) {
+ val calendar = Calendar.getInstance().apply {
+ set(Calendar.HOUR_OF_DAY, hour)
+ set(Calendar.MINUTE, minute)
+ }
+ android.text.format.DateFormat.getTimeFormat(context).format(calendar.time)
+ }
+
+ Surface(
+ onClick = {
+ HapticUtil.performUIHaptic(view)
+ onClick()
+ },
+ modifier = modifier.fillMaxWidth(),
+ color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceBright,
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelMedium,
+ color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = formattedTime,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/FreezeSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/FreezeSettingsUI.kt
index ae5357e05..825affa4c 100644
--- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/FreezeSettingsUI.kt
+++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/FreezeSettingsUI.kt
@@ -40,10 +40,12 @@ 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.domain.model.FreezeMode
import com.sameerasw.essentials.ui.components.cards.AppToggleItem
import com.sameerasw.essentials.ui.components.cards.FeatureCard
import com.sameerasw.essentials.ui.components.cards.IconToggleItem
import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer
+import com.sameerasw.essentials.ui.components.pickers.SegmentedPicker
import com.sameerasw.essentials.ui.components.sheets.AppSelectionSheet
import com.sameerasw.essentials.ui.components.sheets.PermissionsBottomSheet
import com.sameerasw.essentials.ui.modifiers.highlight
@@ -63,6 +65,7 @@ fun FreezeSettingsUI(
val view = LocalView.current
var isAppSelectionSheetOpen by remember { mutableStateOf(false) }
var showPermissionSheet by remember { mutableStateOf(false) }
+ var showModeWarningResult by remember { mutableStateOf(false) }
var permissionsToRequest by remember { mutableStateOf>(emptyList()) }
val isShizukuAvailable by viewModel.isShizukuAvailable
@@ -277,6 +280,48 @@ fun FreezeSettingsUI(
)
}
+ Text(
+ text = stringResource(R.string.freeze_mode_title),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 8.dp),
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ RoundedCardContainer{
+ SegmentedPicker(
+ items = FreezeMode.entries,
+ selectedItem = FreezeMode.fromInt(viewModel.freezeMode.intValue),
+ onItemSelected = { mode ->
+ if (viewModel.freezeMode.intValue != mode.value) {
+ if (viewModel.anyAppsCurrentlyFrozen(context)) {
+ showModeWarningResult = true
+ } else {
+ HapticUtil.performVirtualKeyHaptic(view)
+ viewModel.setFreezeMode(mode.value, context)
+ }
+ }
+ },
+ labelProvider = { mode ->
+ when (mode) {
+ FreezeMode.FREEZE -> context.getString(R.string.freeze_mode_freeze)
+ FreezeMode.SUSPEND -> context.getString(R.string.freeze_mode_suspend)
+ }
+ },
+ iconProvider = { mode ->
+ Icon(
+ painter = painterResource(
+ id = when (mode) {
+ FreezeMode.FREEZE -> R.drawable.rounded_mode_cool_24
+ FreezeMode.SUSPEND -> R.drawable.rounded_pause_24
+ }
+ ),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ },
+ )
+ }
+
Text(
text = stringResource(R.string.settings_section_automation),
style = MaterialTheme.typography.titleMedium,
@@ -484,5 +529,18 @@ fun FreezeSettingsUI(
showPermissionSheet = false
}
}
+
+ if (showModeWarningResult) {
+ androidx.compose.material3.AlertDialog(
+ onDismissRequest = { showModeWarningResult = false },
+ confirmButton = {
+ androidx.compose.material3.TextButton(onClick = { showModeWarningResult = false }) {
+ Text(stringResource(id = R.string.action_ok))
+ }
+ },
+ title = { Text(stringResource(id = R.string.warning_title)) },
+ text = { Text(stringResource(id = R.string.freeze_mode_warning_desc)) }
+ )
+ }
}
}
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/KeyboardSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/KeyboardSettingsUI.kt
index 789537263..41db7d279 100644
--- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/KeyboardSettingsUI.kt
+++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/KeyboardSettingsUI.kt
@@ -284,12 +284,21 @@ fun KeyboardSettingsUI(
IconToggleItem(
iconRes = R.drawable.rounded_keyboard_24,
- title = "Long press for symbols",
+ title = stringResource(R.string.label_keyboard_long_press_symbols),
isChecked = viewModel.isLongPressSymbolsEnabled.value,
onCheckedChange = { viewModel.setLongPressSymbolsEnabled(it, context) },
modifier = Modifier.highlight(highlightSetting == "keyboard_long_press_symbols")
)
+ IconToggleItem(
+ iconRes = R.drawable.rounded_keyboard_24,
+ title = stringResource(R.string.label_keyboard_accented_characters),
+ isChecked = viewModel.isAccentedCharactersEnabled.value,
+ onCheckedChange = { viewModel.setAccentedCharactersEnabled(it, context) },
+ enabled = viewModel.isLongPressSymbolsEnabled.value,
+ modifier = Modifier.highlight(highlightSetting == "keyboard_accented_characters")
+ )
+
IconToggleItem(
iconRes = R.drawable.rounded_book_2_24,
title = "User Dictionary (Learn words)",
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/ime/KeyboardInputView.kt b/app/src/main/java/com/sameerasw/essentials/ui/ime/KeyboardInputView.kt
index 5d9cd7745..f3b3a6b59 100644
--- a/app/src/main/java/com/sameerasw/essentials/ui/ime/KeyboardInputView.kt
+++ b/app/src/main/java/com/sameerasw/essentials/ui/ime/KeyboardInputView.kt
@@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -52,13 +53,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
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.BiasAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
+import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
@@ -66,6 +71,7 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.platform.LocalDensity
@@ -80,6 +86,9 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupProperties
+import androidx.compose.ui.zIndex
import com.sameerasw.essentials.R
import com.sameerasw.essentials.data.repository.SettingsRepository
import com.sameerasw.essentials.ime.EssentialsInputMethodService
@@ -89,6 +98,73 @@ import com.sameerasw.essentials.utils.HapticUtil
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
+private val KeyAccentMap = mapOf(
+ "a" to listOf("à", "á", "â", "ä", "æ", "ã", "å", "ā"),
+ "e" to listOf("è", "é", "ê", "ë", "ē", "ė", "ę"),
+ "i" to listOf("ì", "í", "î", "ï", "ī", "į"),
+ "o" to listOf("ò", "ó", "ô", "ö", "ø", "õ", "œ", "ō"),
+ "u" to listOf("ù", "ú", "û", "ü", "ū"),
+ "n" to listOf("ñ", "ń"),
+ "s" to listOf("ś", "š", "ß"),
+ "z" to listOf("ź", "ż", "ž"),
+ "c" to listOf("ç", "ć", "č"),
+ "l" to listOf("ł"),
+ "y" to listOf("ÿ"),
+ "A" to listOf("À", "Á", "Â", "Ä", "Æ", "Ã", "Å", "Ā"),
+ "E" to listOf("È", "É", "Ê", "Ë", "Ē", "Ė", "Ę"),
+ "I" to listOf("Ì", "Í", "Î", "Ï", "Ī", "Į"),
+ "O" to listOf("Ò", "Ó", "Ô", "Ö", "Ø", "Õ", "Œ", "Ō"),
+ "U" to listOf("Ù", "Ú", "Û", "Ü", "Ū"),
+ "S" to listOf("Ś", "Š"),
+ "N" to listOf("Ñ", "Ń"),
+ "Z" to listOf("Ž", "Ź", "Ż"),
+ "C" to listOf("Ç", "Ć", "Č"),
+ "L" to listOf("Ł"),
+ "Y" to listOf("Ÿ")
+)
+
+@Composable
+fun AccentedKeysPopup(
+ variants: List,
+ selectedIndex: Int,
+ keyRoundness: Dp,
+ modifier: Modifier = Modifier
+) {
+ androidx.compose.material3.Surface(
+ modifier = modifier.zIndex(100f),
+ color = MaterialTheme.colorScheme.surfaceContainerHighest,
+ shape = RoundedCornerShape(keyRoundness),
+ shadowElevation = 8.dp,
+ tonalElevation = 4.dp
+ ) {
+ Row(
+ modifier = Modifier.padding(4.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ variants.forEachIndexed { index, char ->
+ val isSelected = index == selectedIndex
+ Box(
+ modifier = Modifier
+ .size(44.dp)
+ .background(
+ color = if (isSelected) MaterialTheme.colorScheme.primary else androidx.compose.ui.graphics.Color.Transparent,
+ shape = RoundedCornerShape(keyRoundness / 2)
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = char,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+ }
+ }
+}
+
enum class ShiftState {
OFF,
@@ -295,6 +371,7 @@ fun KeyboardInputView(
onCursorMove: (Int, Boolean, Boolean) -> Unit = { keyCode, _, _ -> onKeyPress(keyCode) },
onCursorDrag: (Boolean) -> Unit = {},
isLongPressSymbolsEnabled: Boolean = false,
+ isAccentedCharactersEnabled: Boolean = false,
onOpened: Int = 0,
canDelete: () -> Boolean = { true }
) {
@@ -308,18 +385,31 @@ fun KeyboardInputView(
var isEmojiMode by remember { mutableStateOf(false) }
var isSuggestionsCollapsed by remember { mutableStateOf(false) }
var currentWord by remember { mutableStateOf("") }
-
+
// Track if Shift was used for selection
var isSelectionPerformed by remember { mutableStateOf(false) }
// Track if Symbols was used for word jump
var isWordJumpPerformed by remember { mutableStateOf(false) }
+ // Accented Characters State
+ var longPressKey by remember { mutableStateOf(null) }
+ var longPressVariants by remember { mutableStateOf>(emptyList()) }
+ var selectedAccentIndex by remember { mutableIntStateOf(0) }
+ var initialAccentIndex by remember { mutableIntStateOf(0) }
+ var longPressXRatio by remember { mutableFloatStateOf(0.5f) }
+ var longPressYRatio by remember { mutableFloatStateOf(0.5f) }
+
val emojiCandidates = remember(currentWord) {
if (currentWord.length >= 3) {
EmojiData.allEmojis
.filter { it.name.contains(currentWord, ignoreCase = true) }
.take(5)
- .map { Suggestion(it.emoji, SuggestionType.Prediction) } // Wrap emojis as Suggestions
+ .map {
+ Suggestion(
+ it.emoji,
+ SuggestionType.Prediction
+ )
+ } // Wrap emojis as Suggestions
} else {
emptyList()
}
@@ -328,7 +418,9 @@ fun KeyboardInputView(
val mergedSuggestions = remember(suggestions, emojiCandidates) {
if (emojiCandidates.isNotEmpty() && suggestions.isNotEmpty()) {
// Priority: Text 1 -> Emoji 1 -> Remaining Text -> Remaining Emojis
- listOf(suggestions[0]) + emojiCandidates.take(1) + suggestions.drop(1) + emojiCandidates.drop(1)
+ listOf(suggestions[0]) + emojiCandidates.take(1) + suggestions.drop(1) + emojiCandidates.drop(
+ 1
+ )
} else {
emojiCandidates + suggestions
}
@@ -365,6 +457,11 @@ fun KeyboardInputView(
)
val totalHeight = animatedTotalHeight
+ val animatedBlurRadius by animateDpAsState(
+ targetValue = if (longPressKey != null) 8.dp else 0.dp,
+ label = "blur"
+ )
+
// Pre-load Emoji data on startup (Background thread)
LaunchedEffect(Unit) {
EmojiData.load(view.context, scope)
@@ -411,7 +508,7 @@ fun KeyboardInputView(
val row1Symbols = remember { listOf("~", "\\", "|", "^", "%", "=", "<", ">", "[", "]") }
val row2Symbols = remember { listOf("@", "#", "$", "_", "&", "-", "+", "(", ")", "/") }
val row3Symbols = remember { listOf("*", "\"", "'", ":", ";", "!", "?") }
-
+
// Long Press Symbols Mapping
val row1LongPress = remember { listOf("%", "\\", "|", "=", "[", "]", "<", ">", "{", "}") }
val row2LongPress = remember { listOf("@", "#", "$", "_", "&", "-", "+", "(", ")") }
@@ -434,501 +531,541 @@ fun KeyboardInputView(
if (keyboardShape == 2) keyRoundness else 0.dp
}
- Column(
+ Box(
modifier = Modifier
.fillMaxWidth()
.height(totalHeight)
- .clip(containerShape)
- .background(MaterialTheme.colorScheme.surfaceContainer)
- .pointerInput(Unit) {
- detectVerticalDragGestures { change, dragAmount ->
- if (!isEmojiMode && dragAmount < -20f) { // Swipe up
- isEmojiMode = true
- isClipboardMode = false
- performHeavyHaptic()
- }
- }
- }
- .padding(
- bottom = if (isEmojiMode) 0.dp else bottomPadding,
- start = 6.dp,
- end = 6.dp,
- top = 6.dp + extraTopPadding
- ),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(4.dp)
) {
- val FunctionRow: @Composable (Modifier) -> Unit = { modifier ->
- val hasSuggestions = mergedSuggestions.isNotEmpty()
- val showSuggestions = hasSuggestions && !isEmojiMode && !isSuggestionsCollapsed
-
- val rotation by animateFloatAsState(
- targetValue = if (showSuggestions) 45f else 0f,
- animationSpec = spring(
- dampingRatio = Spring.DampingRatioLowBouncy,
- stiffness = Spring.StiffnessMedium
- ),
- label = "controlIconRotation"
- )
-
- AnimatedContent(
- targetState = showSuggestions,
- transitionSpec = {
- val springSpec = spring(
- dampingRatio = Spring.DampingRatioLowBouncy,
- stiffness = Spring.StiffnessMedium
- )
- if (targetState) {
- // Expand
- (fadeIn() + slideInHorizontally(animationSpec = springSpec) { it })
- .togetherWith(fadeOut() + slideOutHorizontally(animationSpec = springSpec) { -it })
- } else {
- // Collapse
- (fadeIn() + slideInHorizontally(animationSpec = springSpec) { -it })
- .togetherWith(fadeOut() + slideOutHorizontally(animationSpec = springSpec) { it })
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .clip(containerShape)
+ .background(MaterialTheme.colorScheme.surfaceContainer)
+ .pointerInput(Unit) {
+ detectVerticalDragGestures { change, dragAmount ->
+ if (!isEmojiMode && dragAmount < -20f) { // Swipe up
+ isEmojiMode = true
+ isClipboardMode = false
+ performHeavyHaptic()
+ }
}
- },
- label = "FunctionRowTransition",
- modifier = modifier
- ) { targetShowSuggestions ->
- if (targetShowSuggestions) {
- val collapseInteraction = remember { MutableInteractionSource() }
- val carouselState = rememberCarouselState { mergedSuggestions.count() }
-
- Row(
- modifier = Modifier.fillMaxSize(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(4.dp)
- ) {
- val nestedScrollConnection = remember {
- object : NestedScrollConnection {
- var accumulatedScroll = 0f
- val threshold = 70f
-
- override fun onPreScroll(
- available: Offset,
- source: NestedScrollSource
- ): Offset {
- if (source == NestedScrollSource.UserInput) {
- accumulatedScroll += available.x
- if (kotlin.math.abs(accumulatedScroll) >= threshold) {
- performScrollHaptic()
- accumulatedScroll = 0f
- }
- }
- return Offset.Zero
+ }
+ .padding(
+ bottom = if (isEmojiMode) 0.dp else bottomPadding,
+ start = 6.dp,
+ end = 6.dp,
+ top = 6.dp + extraTopPadding
+ )
+ .pointerInput(longPressKey) {
+ if (longPressKey == null) return@pointerInput
+ awaitPointerEventScope {
+ var baselineX: Float? = null
+ while (longPressKey != null) {
+ val event = awaitPointerEvent(PointerEventPass.Initial)
+ val change = event.changes.firstOrNull()
+
+ if (change == null || change.changedToUp() || !change.pressed) {
+ // Commit selection and dismiss
+ val selectedChar =
+ longPressVariants.getOrNull(selectedAccentIndex)
+ if (selectedChar != null) {
+ handleType(selectedChar)
+ performHeavyHaptic()
+ }
+ longPressKey = null
+ break
+ } else {
+ if (baselineX == null) {
+ baselineX = change.position.x
+ }
+
+ // Slide selection - relative movement from start
+ val deltaX = change.position.x - (baselineX ?: change.position.x)
+ val stepWidthPx = with(density) { 40.dp.toPx() }
+
+ val index = ((deltaX / stepWidthPx) + initialAccentIndex).toInt()
+ .coerceIn(0, longPressVariants.size - 1)
+
+ if (index != selectedAccentIndex) {
+ selectedAccentIndex = index
+ performLightHaptic()
}
}
}
+ }
+ },
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ val FunctionRow: @Composable (Modifier) -> Unit = { modifier ->
+ val hasSuggestions = mergedSuggestions.isNotEmpty()
+ val showSuggestions = hasSuggestions && !isEmojiMode && !isSuggestionsCollapsed
+
+ val rotation by animateFloatAsState(
+ targetValue = if (showSuggestions) 45f else 0f,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioLowBouncy,
+ stiffness = Spring.StiffnessMedium
+ ),
+ label = "controlIconRotation"
+ )
- HorizontalMultiBrowseCarousel(
- state = carouselState,
- modifier = Modifier
- .weight(1f)
- .fillMaxHeight()
- .nestedScroll(nestedScrollConnection),
- preferredItemWidth = 150.dp,
- itemSpacing = 4.dp,
- minSmallItemWidth = 10.dp,
- maxSmallItemWidth = 20.dp,
- contentPadding = PaddingValues(start = functionsPadding)
- ) { i ->
- val suggestion = mergedSuggestions[i]
- val isLearned = suggestion.type == SuggestionType.Learned
- val suggInteraction = remember { MutableInteractionSource() }
- val animatedRadius by animateDpAsState(
- targetValue = keyRoundness,
- label = "cornerRadius"
- )
-
- KeyButton(
- onClick = {
- onSuggestionClick(suggestion)
- // If it's an emoji (single char usually, or check length), don't add space if app does,
- // but we just pass it out. Let's reset currentWord if it's a COMMIT.
- currentWord = ""
- },
- onPress = { performLightHaptic() },
- interactionSource = suggInteraction,
- containerColor = if (isLearned) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.7f),
- contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
- shape = RoundedCornerShape(animatedRadius),
- modifier = Modifier
- .fillMaxHeight()
- .fillMaxWidth()
- .maskClip(RoundedCornerShape(animatedRadius))
- ) {
- Text(
- text = suggestion.text,
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = FontWeight.Bold,
- fontFamily = CustomFontFamily,
- maxLines = 1
- )
- }
- }
-
- // Collapse Button (Far Right)
- val isCollapsePressed by collapseInteraction.collectIsPressedAsState()
- val collapseRadius by animateDpAsState(
- targetValue = if (isCollapsePressed) 4.dp else keyRoundness,
- label = "collapseRadius"
+ AnimatedContent(
+ targetState = showSuggestions,
+ transitionSpec = {
+ val springSpec = spring(
+ dampingRatio = Spring.DampingRatioLowBouncy,
+ stiffness = Spring.StiffnessMedium
)
+ if (targetState) {
+ // Expand
+ (fadeIn() + slideInHorizontally(animationSpec = springSpec) { it })
+ .togetherWith(fadeOut() + slideOutHorizontally(animationSpec = springSpec) { -it })
+ } else {
+ // Collapse
+ (fadeIn() + slideInHorizontally(animationSpec = springSpec) { -it })
+ .togetherWith(fadeOut() + slideOutHorizontally(animationSpec = springSpec) { it })
+ }
+ },
+ label = "FunctionRowTransition",
+ modifier = modifier
+ ) { targetShowSuggestions ->
+ if (targetShowSuggestions) {
+ val collapseInteraction = remember { MutableInteractionSource() }
+ val carouselState = rememberCarouselState { mergedSuggestions.count() }
- KeyButton(
- onClick = {
- isSuggestionsCollapsed = true
- performLightHaptic()
- },
- onPress = { performLightHaptic() },
- interactionSource = collapseInteraction,
- containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
- contentColor = MaterialTheme.colorScheme.onSurface,
- shape = RoundedCornerShape(collapseRadius),
- modifier = Modifier
- .fillMaxHeight()
- .width(50.dp)
- .padding(end = functionsPadding)
+ Row(
+ modifier = Modifier.fillMaxSize(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
- Icon(
- painter = painterResource(id = R.drawable.rounded_add_24),
- contentDescription = "Collapse Suggestions",
- modifier = Modifier
- .size(18.dp)
- .graphicsLayer { rotationZ = rotation }
- )
- }
- }
- } else {
- ButtonGroup(
- modifier = Modifier
- .fillMaxWidth()
- .fillMaxHeight()
- .padding(horizontal = functionsPadding),
- horizontalArrangement = Arrangement.spacedBy(4.dp),
- content = {
- val functions = remember(isClipboardEnabled, isEmojiMode, isSuggestionsCollapsed, hasSuggestions) {
- val list = mutableListOf(
- R.drawable.ic_emoji to "Emoji",
- if (isEmojiMode) R.drawable.rounded_backspace_24 to "Backspace"
- else R.drawable.ic_undo to "Undo"
- )
- if (isClipboardEnabled) {
- list.add(1, R.drawable.ic_clipboard to "Clipboard")
- }
- // Add Expand button if collapsed and suggestions exist
- if (isSuggestionsCollapsed && hasSuggestions && !isEmojiMode) {
- list.add(R.drawable.rounded_add_24 to "Expand")
+ val nestedScrollConnection = remember {
+ object : NestedScrollConnection {
+ var accumulatedScroll = 0f
+ val threshold = 70f
+
+ override fun onPreScroll(
+ available: Offset,
+ source: NestedScrollSource
+ ): Offset {
+ if (source == NestedScrollSource.UserInput) {
+ accumulatedScroll += available.x
+ if (kotlin.math.abs(accumulatedScroll) >= threshold) {
+ performScrollHaptic()
+ accumulatedScroll = 0f
+ }
+ }
+ return Offset.Zero
+ }
}
- list
}
- functions.forEach { (iconRes, desc) ->
- val fnInteraction = remember { MutableInteractionSource() }
- val isPressed by fnInteraction.collectIsPressedAsState()
+ HorizontalMultiBrowseCarousel(
+ state = carouselState,
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .nestedScroll(nestedScrollConnection),
+ preferredItemWidth = 150.dp,
+ itemSpacing = 4.dp,
+ minSmallItemWidth = 10.dp,
+ maxSmallItemWidth = 20.dp,
+ contentPadding = PaddingValues(start = functionsPadding)
+ ) { i ->
+ val suggestion = mergedSuggestions[i]
+ val isLearned = suggestion.type == SuggestionType.Learned
+ val suggInteraction = remember { MutableInteractionSource() }
val animatedRadius by animateDpAsState(
- targetValue = if (isPressed) 4.dp else keyRoundness,
+ targetValue = keyRoundness,
label = "cornerRadius"
)
KeyButton(
onClick = {
- if (desc == "Clipboard") {
- isClipboardMode = !isClipboardMode
- if (isClipboardMode) isEmojiMode = false
- } else if (desc == "Undo") {
- onUndoClick()
- } else if (desc == "Emoji") {
- isEmojiMode = !isEmojiMode
- if (isEmojiMode) isClipboardMode = false
- } else if (desc == "Backspace") {
- onKeyPress(android.view.KeyEvent.KEYCODE_DEL)
- } else if (desc == "Expand") {
- isSuggestionsCollapsed = false
- }
- },
- onRepeat = {
- if (desc == "Backspace") {
- onKeyPress(android.view.KeyEvent.KEYCODE_DEL)
- performLightHaptic()
- }
- },
- canRepeat = {
- if (desc == "Backspace") canDelete() else true
+ onSuggestionClick(suggestion)
+ // If it's an emoji (single char usually, or check length), don't add space if app does,
+ // but we just pass it out. Let's reset currentWord if it's a COMMIT.
+ currentWord = ""
},
onPress = { performLightHaptic() },
- interactionSource = fnInteraction,
- containerColor = if ((desc == "Clipboard" && isClipboardMode) || (desc == "Emoji" && isEmojiMode)) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
- contentColor = if ((desc == "Clipboard" && isClipboardMode) || (desc == "Emoji" && isEmojiMode)) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
+ interactionSource = suggInteraction,
+ containerColor = if (isLearned) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.secondaryContainer.copy(
+ alpha = 0.7f
+ ),
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
shape = RoundedCornerShape(animatedRadius),
- modifier = if (desc == "Expand") {
- Modifier.width(50.dp).fillMaxHeight()
- } else {
- Modifier.weight(1.3f).fillMaxHeight()
- }
+ modifier = Modifier
+ .fillMaxHeight()
+ .fillMaxWidth()
+ .maskClip(RoundedCornerShape(animatedRadius))
) {
- Icon(
- painter = painterResource(id = iconRes),
- contentDescription = desc,
- modifier = Modifier
- .size(if (desc == "Expand") 18.dp else 20.dp)
- .then(
- if (desc == "Expand") {
- Modifier.graphicsLayer { rotationZ = rotation }
- } else {
- Modifier
- }
- )
+ Text(
+ text = suggestion.text,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Bold,
+ fontFamily = CustomFontFamily,
+ maxLines = 1
)
}
}
- }
- )
- }
- }
- }
-
- if (!isFunctionsBottom) {
- FunctionRow(
- Modifier
- .height(48.dp)
- .fillMaxWidth()
- )
- }
-
- val currentMode = when {
- isEmojiMode -> 1
- isClipboardMode && isClipboardEnabled -> 2
- else -> 0
- }
- AnimatedContent(
- targetState = currentMode,
- transitionSpec = {
- (fadeIn(animationSpec = spring(stiffness = Spring.StiffnessMedium)) togetherWith
- fadeOut(animationSpec = spring(stiffness = Spring.StiffnessMedium)))
- },
- modifier = Modifier
- .fillMaxWidth()
- .weight(5f),
- label = "KeyboardModeAnimation"
- ) { mode ->
- when (mode) {
- 2 -> {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .clip(RoundedCornerShape(keyRoundness))
- .background(MaterialTheme.colorScheme.surfaceContainerLow)
- .padding(8.dp)
- ) {
- if (clipboardHistory.isEmpty()) {
- Text(
- text = "Clipboard is empty",
- modifier = Modifier.align(Alignment.Center),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ // Collapse Button (Far Right)
+ val isCollapsePressed by collapseInteraction.collectIsPressedAsState()
+ val collapseRadius by animateDpAsState(
+ targetValue = if (isCollapsePressed) 4.dp else keyRoundness,
+ label = "collapseRadius"
)
- } else {
- LazyColumn(
- verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier.fillMaxSize()
- ) {
- items(clipboardHistory, key = { it }) { clipText ->
- val dismissState = androidx.compose.material3.rememberSwipeToDismissBoxState(
- confirmValueChange = { value ->
- if (value == androidx.compose.material3.SwipeToDismissBoxValue.StartToEnd ||
- value == androidx.compose.material3.SwipeToDismissBoxValue.EndToStart) {
- onDeleteClipboardItem(clipText)
- performHeavyHaptic()
- true
- } else false
- }
- )
- androidx.compose.material3.SwipeToDismissBox(
- state = dismissState,
- backgroundContent = {
- val color by animateColorAsState(
- when (dismissState.targetValue) {
- androidx.compose.material3.SwipeToDismissBoxValue.Settled -> MaterialTheme.colorScheme.surfaceContainerLow
- else -> MaterialTheme.colorScheme.errorContainer
- }, label = "dismissBackground"
- )
- Box(
- Modifier
- .fillMaxSize()
- .clip(RoundedCornerShape(keyRoundness))
- .background(color)
- .padding(horizontal = 16.dp),
- contentAlignment = if (dismissState.dismissDirection == androidx.compose.material3.SwipeToDismissBoxValue.StartToEnd)
- Alignment.CenterStart else Alignment.CenterEnd
- ) {
- Icon(
- painter = painterResource(R.drawable.rounded_delete_24),
- contentDescription = "Delete",
- tint = MaterialTheme.colorScheme.onErrorContainer
- )
- }
- },
- content = {
- ClipboardItem(
- text = clipText,
- shape = RoundedCornerShape(keyRoundness),
- onClick = {
- onPasteClick(clipText)
- isClipboardMode = false
- },
- modifier = Modifier.fillMaxWidth()
- )
- }
- )
- }
+ KeyButton(
+ onClick = {
+ isSuggestionsCollapsed = true
+ performLightHaptic()
+ },
+ onPress = { performLightHaptic() },
+ interactionSource = collapseInteraction,
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ shape = RoundedCornerShape(collapseRadius),
+ modifier = Modifier
+ .fillMaxHeight()
+ .width(50.dp)
+ .padding(end = functionsPadding)
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_add_24),
+ contentDescription = "Collapse Suggestions",
+ modifier = Modifier
+ .size(18.dp)
+ .graphicsLayer { rotationZ = rotation }
+ )
}
}
- }
- }
- 1 -> {
- EmojiPicker(
- modifier = Modifier.fillMaxSize(),
- keyRoundness = keyRoundness,
- isHapticsEnabled = isHapticsEnabled,
- hapticStrength = hapticStrength,
- onEmojiSelected = { emoji ->
- handleType(emoji)
- },
- onSwipeDownToExit = {
- if (isEmojiMode) {
- isEmojiMode = false
- performHeavyHaptic()
- }
- },
- bottomContentPadding = bottomPadding
- )
- }
- else -> {
- Column(
- modifier = Modifier.fillMaxSize(),
- verticalArrangement = Arrangement.spacedBy(4.dp)
- ) {
- // Dedicated Number Row
+ } else {
ButtonGroup(
modifier = Modifier
- .weight(1f)
- .fillMaxWidth(),
+ .fillMaxWidth()
+ .fillMaxHeight()
+ .padding(horizontal = functionsPadding),
horizontalArrangement = Arrangement.spacedBy(4.dp),
content = {
- numberRow.forEach { char ->
- key(char) {
- val numInteraction = remember { MutableInteractionSource() }
- val isPressed by numInteraction.collectIsPressedAsState()
- KeyButton(
- onClick = { handleType(char) },
- onPress = { performLightHaptic() },
- interactionSource = numInteraction,
- containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
- contentColor = MaterialTheme.colorScheme.onSurface,
- shape = RoundedCornerShape(keyRoundness),
- modifier = Modifier
- .weight(1f)
- .fillMaxHeight()
- ) {
- Text(
- text = char,
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Medium,
- fontFamily = CustomFontFamily
- )
+ val functions = remember(
+ isClipboardEnabled,
+ isEmojiMode,
+ isSuggestionsCollapsed,
+ hasSuggestions
+ ) {
+ val list = mutableListOf(
+ R.drawable.ic_emoji to "Emoji",
+ if (isEmojiMode) R.drawable.rounded_backspace_24 to "Backspace"
+ else R.drawable.ic_undo to "Undo"
+ )
+ if (isClipboardEnabled) {
+ list.add(1, R.drawable.ic_clipboard to "Clipboard")
+ }
+ // Add Expand button if collapsed and suggestions exist
+ if (isSuggestionsCollapsed && hasSuggestions && !isEmojiMode) {
+ list.add(R.drawable.rounded_add_24 to "Expand")
+ }
+ list
+ }
+
+ functions.forEach { (iconRes, desc) ->
+ val fnInteraction = remember { MutableInteractionSource() }
+ val isPressed by fnInteraction.collectIsPressedAsState()
+ val animatedRadius by animateDpAsState(
+ targetValue = if (isPressed) 4.dp else keyRoundness,
+ label = "cornerRadius"
+ )
+
+ KeyButton(
+ onClick = {
+ if (desc == "Clipboard") {
+ isClipboardMode = !isClipboardMode
+ if (isClipboardMode) isEmojiMode = false
+ } else if (desc == "Undo") {
+ onUndoClick()
+ } else if (desc == "Emoji") {
+ isEmojiMode = !isEmojiMode
+ if (isEmojiMode) isClipboardMode = false
+ } else if (desc == "Backspace") {
+ onKeyPress(android.view.KeyEvent.KEYCODE_DEL)
+ } else if (desc == "Expand") {
+ isSuggestionsCollapsed = false
+ }
+ },
+ onRepeat = {
+ if (desc == "Backspace") {
+ onKeyPress(android.view.KeyEvent.KEYCODE_DEL)
+ performLightHaptic()
+ }
+ },
+ canRepeat = {
+ if (desc == "Backspace") canDelete() else true
+ },
+ onPress = { performLightHaptic() },
+ interactionSource = fnInteraction,
+ containerColor = if ((desc == "Clipboard" && isClipboardMode) || (desc == "Emoji" && isEmojiMode)) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
+ contentColor = if ((desc == "Clipboard" && isClipboardMode) || (desc == "Emoji" && isEmojiMode)) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
+ shape = RoundedCornerShape(animatedRadius),
+ modifier = if (desc == "Expand") {
+ Modifier.width(50.dp).fillMaxHeight()
+ } else {
+ Modifier.weight(1.3f).fillMaxHeight()
}
+ ) {
+ Icon(
+ painter = painterResource(id = iconRes),
+ contentDescription = desc,
+ modifier = Modifier
+ .size(if (desc == "Expand") 18.dp else 20.dp)
+ .then(
+ if (desc == "Expand") {
+ Modifier.graphicsLayer {
+ rotationZ = rotation
+ }
+ } else {
+ Modifier
+ }
+ )
+ )
}
}
}
)
+ }
+ }
+ }
- // Row 1
- ButtonGroup(
+ if (!isFunctionsBottom) {
+ FunctionRow(
+ Modifier
+ .height(48.dp)
+ .fillMaxWidth()
+ )
+ }
+
+ val currentMode = when {
+ isEmojiMode -> 1
+ isClipboardMode && isClipboardEnabled -> 2
+ else -> 0
+ }
+
+ AnimatedContent(
+ targetState = currentMode,
+ transitionSpec = {
+ (fadeIn(animationSpec = spring(stiffness = Spring.StiffnessMedium)) togetherWith
+ fadeOut(animationSpec = spring(stiffness = Spring.StiffnessMedium)))
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(5f),
+ label = "KeyboardModeAnimation"
+ ) { mode ->
+ when (mode) {
+ 2 -> {
+ Box(
modifier = Modifier
- .weight(1f)
- .fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(4.dp),
- content = {
- currentRow1.forEachIndexed { index, char ->
- key(char) {
- val displayLabel =
- if (shiftState != ShiftState.OFF && !isSymbols) char.uppercase() else char
- val row1Interaction = remember { MutableInteractionSource() }
- val isPressed by row1Interaction.collectIsPressedAsState()
- val animatedRadius by animateDpAsState(
- targetValue = if (isPressed) 4.dp else keyRoundness,
- label = "cornerRadius"
- )
-
- val secondary = if (!isSymbols && isLongPressSymbolsEnabled && index < row1LongPress.size) row1LongPress[index] else null
-
- KeyButton(
- onClick = {
- handleType(displayLabel)
- if (shiftState == ShiftState.ON) shiftState = ShiftState.OFF
- },
- onPress = { performLightHaptic() },
- onLongClick = if (secondary != null) { { handleType(secondary) } } else null,
- secondaryText = secondary,
- interactionSource = row1Interaction,
- containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
- contentColor = MaterialTheme.colorScheme.onSurface,
- shape = RoundedCornerShape(animatedRadius),
- modifier = Modifier
- .weight(1f)
- .fillMaxHeight()
- ) {
- Text(
- text = displayLabel,
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Medium,
- color = MaterialTheme.colorScheme.onSurface,
- fontFamily = CustomFontFamily
+ .fillMaxSize()
+ .clip(RoundedCornerShape(keyRoundness))
+ .background(MaterialTheme.colorScheme.surfaceContainerLow)
+ .padding(8.dp)
+ ) {
+ if (clipboardHistory.isEmpty()) {
+ Text(
+ text = "Clipboard is empty",
+ modifier = Modifier.align(Alignment.Center),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ } else {
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxSize()
+ ) {
+ items(clipboardHistory, key = { it }) { clipText ->
+ val dismissState =
+ androidx.compose.material3.rememberSwipeToDismissBoxState(
+ confirmValueChange = { value ->
+ if (value == androidx.compose.material3.SwipeToDismissBoxValue.StartToEnd ||
+ value == androidx.compose.material3.SwipeToDismissBoxValue.EndToStart
+ ) {
+ onDeleteClipboardItem(clipText)
+ performHeavyHaptic()
+ true
+ } else false
+ }
)
- }
+
+ androidx.compose.material3.SwipeToDismissBox(
+ state = dismissState,
+ backgroundContent = {
+ val color by animateColorAsState(
+ when (dismissState.targetValue) {
+ androidx.compose.material3.SwipeToDismissBoxValue.Settled -> MaterialTheme.colorScheme.surfaceContainerLow
+ else -> MaterialTheme.colorScheme.errorContainer
+ }, label = "dismissBackground"
+ )
+ Box(
+ Modifier
+ .fillMaxSize()
+ .clip(RoundedCornerShape(keyRoundness))
+ .background(color)
+ .padding(horizontal = 16.dp),
+ contentAlignment = if (dismissState.dismissDirection == androidx.compose.material3.SwipeToDismissBoxValue.StartToEnd)
+ Alignment.CenterStart else Alignment.CenterEnd
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.rounded_delete_24),
+ contentDescription = "Delete",
+ tint = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ },
+ content = {
+ ClipboardItem(
+ text = clipText,
+ shape = RoundedCornerShape(keyRoundness),
+ onClick = {
+ onPasteClick(clipText)
+ isClipboardMode = false
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ )
}
}
}
+ }
+ }
+
+ 1 -> {
+ EmojiPicker(
+ modifier = Modifier.fillMaxSize(),
+ keyRoundness = keyRoundness,
+ isHapticsEnabled = isHapticsEnabled,
+ hapticStrength = hapticStrength,
+ onEmojiSelected = { emoji ->
+ handleType(emoji)
+ },
+ onSwipeDownToExit = {
+ if (isEmojiMode) {
+ isEmojiMode = false
+ performHeavyHaptic()
+ }
+ },
+ bottomContentPadding = bottomPadding
)
+ }
- // Row 2
- Row(
- modifier = Modifier
- .weight(1f)
- .fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(4.dp)
+ else -> {
+ Column(
+ modifier = Modifier.fillMaxSize()
+ .blur(animatedBlurRadius),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
) {
- if (!isSymbols) Spacer(modifier = Modifier.weight(0.5f))
+ // Dedicated Number Row
+ ButtonGroup(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ content = {
+ numberRow.forEach { char ->
+ key(char) {
+ val numInteraction =
+ remember { MutableInteractionSource() }
+ val isPressed by numInteraction.collectIsPressedAsState()
+ KeyButton(
+ onClick = { handleType(char) },
+ onPress = { performLightHaptic() },
+ interactionSource = numInteraction,
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ shape = RoundedCornerShape(keyRoundness),
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ ) {
+ Text(
+ text = char,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Medium,
+ fontFamily = CustomFontFamily
+ )
+ }
+ }
+ }
+ }
+ )
+ // Row 1
ButtonGroup(
- modifier = Modifier.weight(currentRow2.size.toFloat()),
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
content = {
- currentRow2.forEachIndexed { index, char ->
+ currentRow1.forEachIndexed { index, char ->
key(char) {
val displayLabel =
if (shiftState != ShiftState.OFF && !isSymbols) char.uppercase() else char
- val row2Interaction = remember { MutableInteractionSource() }
- val isPressed by row2Interaction.collectIsPressedAsState()
+ val row1Interaction =
+ remember { MutableInteractionSource() }
+ val isPressed by row1Interaction.collectIsPressedAsState()
val animatedRadius by animateDpAsState(
targetValue = if (isPressed) 4.dp else keyRoundness,
label = "cornerRadius"
)
-
- val secondary = if (!isSymbols && isLongPressSymbolsEnabled && index < row2LongPress.size) row2LongPress[index] else null
-
+
+ val secondary =
+ if (!isSymbols && isLongPressSymbolsEnabled && index < row1LongPress.size) row1LongPress[index] else null
+
KeyButton(
onClick = {
handleType(displayLabel)
- if (shiftState == ShiftState.ON) shiftState = ShiftState.OFF
+ if (shiftState == ShiftState.ON) shiftState =
+ ShiftState.OFF
},
onPress = { performLightHaptic() },
- onLongClick = if (secondary != null) { { handleType(secondary) } } else null,
+ onLongClick = {
+ if (isLongPressSymbolsEnabled) {
+ val accents =
+ if (isAccentedCharactersEnabled) KeyAccentMap[char]
+ ?: emptyList() else emptyList()
+ val variants = mutableListOf()
+ val xRatio = (index + 0.5f) / 10f
+ var startIndex = 0
+
+ if (xRatio < 0.35f) {
+ if (secondary != null) variants.add(secondary)
+ variants.addAll(accents)
+ startIndex = 0
+ } else if (xRatio > 0.65f) {
+ variants.addAll(accents)
+ if (secondary != null) variants.add(secondary)
+ startIndex = variants.size - 1
+ } else {
+ val half = accents.size / 2
+ variants.addAll(accents.take(half))
+ if (secondary != null) variants.add(secondary)
+ startIndex = variants.size - 1
+ variants.addAll(accents.drop(half))
+ }
+
+
+ if (variants.isNotEmpty()) {
+ longPressKey = char
+ longPressVariants = variants
+ initialAccentIndex = startIndex
+ selectedAccentIndex = startIndex
+ longPressXRatio = xRatio
+ longPressYRatio = 0.2f // Row 1
+ }
+ }
+ },
secondaryText = secondary,
- interactionSource = row2Interaction,
+ interactionSource = row1Interaction,
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.onSurface,
shape = RoundedCornerShape(animatedRadius),
@@ -949,522 +1086,719 @@ fun KeyboardInputView(
}
)
- if (!isSymbols) Spacer(modifier = Modifier.weight(0.5f))
- }
+ // Row 2
+ Row(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ if (!isSymbols) Spacer(modifier = Modifier.weight(0.5f))
+
+ ButtonGroup(
+ modifier = Modifier.weight(currentRow2.size.toFloat()),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ content = {
+ currentRow2.forEachIndexed { index, char ->
+ key(char) {
+ val displayLabel =
+ if (shiftState != ShiftState.OFF && !isSymbols) char.uppercase() else char
+ val row2Interaction =
+ remember { MutableInteractionSource() }
+ val isPressed by row2Interaction.collectIsPressedAsState()
+ val animatedRadius by animateDpAsState(
+ targetValue = if (isPressed) 4.dp else keyRoundness,
+ label = "cornerRadius"
+ )
- // Row 3 (with Shift/Backspace logic)
- ButtonGroup(
- modifier = Modifier
- .weight(1f)
- .fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(4.dp),
- content = {
- // Shift Key
- val shiftInteraction = remember { MutableInteractionSource() }
- val isPressed by shiftInteraction.collectIsPressedAsState()
-
- var shiftPressTime by remember { androidx.compose.runtime.mutableLongStateOf(0L) }
- var wasShiftOffAtDown by remember { mutableStateOf(false) }
-
- // Reset Shift on release if used for selection
- LaunchedEffect(isPressed) {
- if (!isPressed) {
- if (isSelectionPerformed && wasShiftOffAtDown) {
- shiftState = ShiftState.OFF
- }
- }
- }
+ val secondary =
+ if (!isSymbols && isLongPressSymbolsEnabled && index < row2LongPress.size) row2LongPress[index] else null
+
+ KeyButton(
+ onClick = {
+ handleType(displayLabel)
+ if (shiftState == ShiftState.ON) shiftState =
+ ShiftState.OFF
+ },
+ onPress = { performLightHaptic() },
+ onLongClick = {
+ if (isLongPressSymbolsEnabled) {
+ val accents =
+ if (isAccentedCharactersEnabled) KeyAccentMap[char]
+ ?: emptyList() else emptyList()
+ val variants = mutableListOf()
+ val xRatio = (index + 0.5f) / currentRow2.size.toFloat()
+ var startIndex = 0
+
+ if (xRatio < 0.35f) {
+ if (secondary != null) variants.add(secondary)
+ variants.addAll(accents)
+ startIndex = 0
+ } else if (xRatio > 0.65f) {
+ variants.addAll(accents)
+ if (secondary != null) variants.add(secondary)
+ startIndex = variants.size - 1
+ } else {
+ val half = accents.size / 2
+ variants.addAll(accents.take(half))
+ if (secondary != null) variants.add(secondary)
+ startIndex = variants.size - 1
+ variants.addAll(accents.drop(half))
+ }
- val animatedRadius by animateDpAsState(
- targetValue = if (isPressed) 4.dp else keyRoundness,
- label = "cornerRadius"
+ if (variants.isNotEmpty()) {
+ longPressKey = char
+ longPressVariants = variants
+ initialAccentIndex = startIndex
+ selectedAccentIndex = startIndex
+ longPressXRatio = xRatio
+ longPressYRatio = 0.4f // Row 2
+ }
+ }
+ },
+ secondaryText = secondary,
+ interactionSource = row2Interaction,
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ shape = RoundedCornerShape(animatedRadius),
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ ) {
+ Text(
+ text = displayLabel,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontFamily = CustomFontFamily
+ )
+ }
+ }
+ }
+ }
)
- KeyButton(
- onClick = {
- if (!isSymbols) {
- val pressDuration = System.currentTimeMillis() - shiftPressTime
-
- if (pressDuration < 250) {
- if (wasShiftOffAtDown) {
+ if (!isSymbols) Spacer(modifier = Modifier.weight(0.5f))
+ }
+
+ // Row 3 (with Shift/Backspace logic)
+ ButtonGroup(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ content = {
+ // Shift Key
+ val shiftInteraction = remember { MutableInteractionSource() }
+ val isPressed by shiftInteraction.collectIsPressedAsState()
+
+ var shiftPressTime by remember {
+ androidx.compose.runtime.mutableLongStateOf(
+ 0L
+ )
+ }
+ var wasShiftOffAtDown by remember { mutableStateOf(false) }
+
+ // Reset Shift on release if used for selection
+ LaunchedEffect(isPressed) {
+ if (!isPressed) {
+ if (isSelectionPerformed && wasShiftOffAtDown) {
+ shiftState = ShiftState.OFF
+ }
+ }
+ }
+
+ val animatedRadius by animateDpAsState(
+ targetValue = if (isPressed) 4.dp else keyRoundness,
+ label = "cornerRadius"
+ )
+
+ KeyButton(
+ onClick = {
+ if (!isSymbols) {
+ val pressDuration =
+ System.currentTimeMillis() - shiftPressTime
+
+ if (pressDuration < 250) {
+ if (wasShiftOffAtDown) {
+ } else {
+ shiftState = ShiftState.OFF
+ }
} else {
- shiftState = ShiftState.OFF
+ if (wasShiftOffAtDown) {
+ shiftState = ShiftState.OFF
+ }
}
- } else {
+ }
+ },
+ onPress = {
+ performLightHaptic()
+ if (!isSymbols) {
+ shiftPressTime = System.currentTimeMillis()
+ wasShiftOffAtDown = (shiftState == ShiftState.OFF)
+ isSelectionPerformed =
+ false // Reset selection tracker
if (wasShiftOffAtDown) {
- shiftState = ShiftState.OFF
+ shiftState = ShiftState.ON
}
}
- }
- },
- onPress = {
- performLightHaptic()
- if (!isSymbols) {
- shiftPressTime = System.currentTimeMillis()
- wasShiftOffAtDown = (shiftState == ShiftState.OFF)
- isSelectionPerformed = false // Reset selection tracker
- if (wasShiftOffAtDown) {
- shiftState = ShiftState.ON
+ },
+ onLongClick = {
+ if (!isSymbols && !isSelectionPerformed) {
+ performHeavyHaptic()
+ shiftState = ShiftState.LOCKED
}
- }
- },
- onLongClick = {
- if (!isSymbols && !isSelectionPerformed) {
- performHeavyHaptic()
- shiftState = ShiftState.LOCKED
- }
- },
- interactionSource = shiftInteraction,
- containerColor = if (isSymbols) {
- MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.5f)
- } else if (shiftState != ShiftState.OFF) {
- MaterialTheme.colorScheme.primary
- } else {
- MaterialTheme.colorScheme.primaryContainer
- },
- contentColor = if (isSymbols) {
- MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
- } else if (shiftState != ShiftState.OFF) {
- MaterialTheme.colorScheme.onPrimary
- } else {
- MaterialTheme.colorScheme.onPrimaryContainer
- },
- shape = RoundedCornerShape(animatedRadius),
- modifier = Modifier
- .weight(1.5f)
- .fillMaxHeight()
- ) {
- Icon(
- painter = painterResource(id = R.drawable.key_shift),
- contentDescription = "Shift",
- modifier = Modifier.size(24.dp),
- tint = if (isSymbols) {
+ },
+ interactionSource = shiftInteraction,
+ containerColor = if (isSymbols) {
+ MaterialTheme.colorScheme.surfaceContainerHighest.copy(
+ alpha = 0.5f
+ )
+ } else if (shiftState != ShiftState.OFF) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.primaryContainer
+ },
+ contentColor = if (isSymbols) {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
} else if (shiftState != ShiftState.OFF) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onPrimaryContainer
- }
- )
- }
-
- currentRow3.forEachIndexed { index, char ->
- key(char) {
- val displayLabel =
- if (shiftState != ShiftState.OFF && !isSymbols) char.uppercase() else char
- val row3Interaction = remember { MutableInteractionSource() }
- val isPressed by row3Interaction.collectIsPressedAsState()
- val animatedRadius by animateDpAsState(
- targetValue = if (isPressed) 4.dp else keyRoundness,
- label = "cornerRadius"
+ },
+ shape = RoundedCornerShape(animatedRadius),
+ modifier = Modifier
+ .weight(1.5f)
+ .fillMaxHeight()
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.key_shift),
+ contentDescription = "Shift",
+ modifier = Modifier.size(24.dp),
+ tint = if (isSymbols) {
+ MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
+ } else if (shiftState != ShiftState.OFF) {
+ MaterialTheme.colorScheme.onPrimary
+ } else {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ }
)
-
- val secondary = if (!isSymbols && isLongPressSymbolsEnabled && index < row3LongPress.size) row3LongPress[index] else null
+ }
- KeyButton(
- onClick = {
- handleType(displayLabel)
- if (shiftState == ShiftState.ON) shiftState = ShiftState.OFF
- },
- onPress = { performLightHaptic() },
- onLongClick = if (secondary != null) { { handleType(secondary) } } else null,
- secondaryText = secondary,
- interactionSource = row3Interaction,
- containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
- contentColor = MaterialTheme.colorScheme.onSurface,
- shape = RoundedCornerShape(animatedRadius),
- modifier = Modifier
- .weight(1f)
- .fillMaxHeight()
- ) {
- Text(
- text = displayLabel,
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Medium,
- color = MaterialTheme.colorScheme.onSurface,
- fontFamily = CustomFontFamily
+ currentRow3.forEachIndexed { index, char ->
+ key(char) {
+ val displayLabel =
+ if (shiftState != ShiftState.OFF && !isSymbols) char.uppercase() else char
+ val row3Interaction =
+ remember { MutableInteractionSource() }
+ val isPressed by row3Interaction.collectIsPressedAsState()
+ val animatedRadius by animateDpAsState(
+ targetValue = if (isPressed) 4.dp else keyRoundness,
+ label = "cornerRadius"
)
+
+ val secondary =
+ if (!isSymbols && isLongPressSymbolsEnabled && index < row3LongPress.size) row3LongPress[index] else null
+
+ KeyButton(
+ onClick = {
+ handleType(displayLabel)
+ if (shiftState == ShiftState.ON) shiftState =
+ ShiftState.OFF
+ },
+ onPress = { performLightHaptic() },
+ onLongClick = {
+ if (isLongPressSymbolsEnabled) {
+ val accents =
+ if (isAccentedCharactersEnabled) KeyAccentMap[char]
+ ?: emptyList() else emptyList()
+ val variants = mutableListOf()
+ val xRatio = (index + 2.0f) / 10.5f
+ var startIndex = 0
+
+ if (xRatio < 0.35f) {
+ if (secondary != null) variants.add(secondary)
+ variants.addAll(accents)
+ startIndex = 0
+ } else if (xRatio > 0.65f) {
+ variants.addAll(accents)
+ if (secondary != null) variants.add(secondary)
+ startIndex = variants.size - 1
+ } else {
+ val half = accents.size / 2
+ variants.addAll(accents.take(half))
+ if (secondary != null) variants.add(secondary)
+ startIndex = variants.size - 1
+ variants.addAll(accents.drop(half))
+ }
+
+ if (variants.isNotEmpty()) {
+ longPressKey = char
+ longPressVariants = variants
+ initialAccentIndex = startIndex
+ selectedAccentIndex = startIndex
+ longPressXRatio = xRatio
+ longPressYRatio = 0.6f // Row 3
+ }
+ }
+ },
+ secondaryText = secondary,
+ interactionSource = row3Interaction,
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ shape = RoundedCornerShape(animatedRadius),
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ ) {
+ Text(
+ text = displayLabel,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontFamily = CustomFontFamily
+ )
+ }
}
}
- }
- // Backspace Key
- val backspaceInteraction = remember { MutableInteractionSource() }
- val isPressedDel by backspaceInteraction.collectIsPressedAsState()
- val animatedRadiusDel by animateDpAsState(
- targetValue = if (isPressedDel) 4.dp else keyRoundness,
- label = "cornerRadius"
- )
- var delAccumulatedDx by remember { mutableStateOf(0f) }
- var isDraggingDel by remember { mutableStateOf(false) }
- val delSweepThreshold = 25f
+ // Backspace Key
+ val backspaceInteraction =
+ remember { MutableInteractionSource() }
+ val isPressedDel by backspaceInteraction.collectIsPressedAsState()
+ val animatedRadiusDel by animateDpAsState(
+ targetValue = if (isPressedDel) 4.dp else keyRoundness,
+ label = "cornerRadius"
+ )
+ var delAccumulatedDx by remember { mutableStateOf(0f) }
+ var isDraggingDel by remember { mutableStateOf(false) }
+ val delSweepThreshold = 25f
- val animatedColorDel by animateColorAsState(
- targetValue = if (isPressedDel) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.primaryContainer,
- label = "DelColor"
- )
- val animatedContentColorDel by animateColorAsState(
- targetValue = if (isPressedDel) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onPrimaryContainer,
- label = "DelContentColor"
- )
+ val animatedColorDel by animateColorAsState(
+ targetValue = if (isPressedDel) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.primaryContainer,
+ label = "DelColor"
+ )
+ val animatedContentColorDel by animateColorAsState(
+ targetValue = if (isPressedDel) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onPrimaryContainer,
+ label = "DelContentColor"
+ )
- Box(
- modifier = Modifier
- .weight(1.5f)
- .fillMaxHeight()
- .bounceClick(backspaceInteraction)
- .clip(RoundedCornerShape(animatedRadiusDel))
- .pointerInput(Unit) {
- detectHorizontalDragGestures(
- onDragStart = {
- delAccumulatedDx = 0f
- isDraggingDel = true
- },
- onDragEnd = { isDraggingDel = false },
- onDragCancel = { isDraggingDel = false },
- onHorizontalDrag = { change, dragAmount ->
- change.consume()
- delAccumulatedDx += dragAmount
- // Moving left (negative dx) for delete
- if (delAccumulatedDx <= -delSweepThreshold) {
- val steps =
- (kotlin.math.abs(delAccumulatedDx) / delSweepThreshold).toInt()
- repeat(steps) {
- performLightHaptic()
- handleKeyPress(KeyEvent.KEYCODE_DEL)
+ Box(
+ modifier = Modifier
+ .weight(1.5f)
+ .fillMaxHeight()
+ .bounceClick(backspaceInteraction)
+ .clip(RoundedCornerShape(animatedRadiusDel))
+ .pointerInput(Unit) {
+ detectHorizontalDragGestures(
+ onDragStart = {
+ delAccumulatedDx = 0f
+ isDraggingDel = true
+ },
+ onDragEnd = { isDraggingDel = false },
+ onDragCancel = { isDraggingDel = false },
+ onHorizontalDrag = { change, dragAmount ->
+ change.consume()
+ delAccumulatedDx += dragAmount
+ // Moving left (negative dx) for delete
+ if (delAccumulatedDx <= -delSweepThreshold) {
+ val steps =
+ (kotlin.math.abs(delAccumulatedDx) / delSweepThreshold).toInt()
+ repeat(steps) {
+ performLightHaptic()
+ handleKeyPress(KeyEvent.KEYCODE_DEL)
+ }
+ delAccumulatedDx %= delSweepThreshold
}
- delAccumulatedDx %= delSweepThreshold
}
- }
- )
- }
- .pointerInput(Unit) {
- detectTapGestures(
- onPress = { offset ->
- val press = PressInteraction.Press(offset)
- performLightHaptic()
- scope.launch { backspaceInteraction.emit(press) }
-
- var isReleased = false
- val repeatJob = scope.launch {
- delay(500)
- while (!isReleased && !isDraggingDel) {
- if (canDelete()) {
- handleKeyPress(KeyEvent.KEYCODE_DEL)
- performLightHaptic()
- delay(50)
- } else {
- break
- }
- }
- }
-
- try {
- if (tryAwaitRelease()) {
- isReleased = true
- repeatJob.cancel()
- scope.launch {
- backspaceInteraction.emit(
- PressInteraction.Release(press)
- )
+ )
+ }
+ .pointerInput(Unit) {
+ detectTapGestures(
+ onPress = { offset ->
+ val press = PressInteraction.Press(offset)
+ performLightHaptic()
+ scope.launch {
+ backspaceInteraction.emit(
+ press
+ )
+ }
+
+ var isReleased = false
+ val repeatJob = scope.launch {
+ delay(500)
+ while (!isReleased && !isDraggingDel) {
+ if (canDelete()) {
+ handleKeyPress(KeyEvent.KEYCODE_DEL)
+ performLightHaptic()
+ delay(50)
+ } else {
+ break
+ }
}
- if (!isDraggingDel) {
- handleKeyPress(KeyEvent.KEYCODE_DEL)
+ }
+
+ try {
+ if (tryAwaitRelease()) {
+ isReleased = true
+ repeatJob.cancel()
+ scope.launch {
+ backspaceInteraction.emit(
+ PressInteraction.Release(
+ press
+ )
+ )
+ }
+ if (!isDraggingDel) {
+ handleKeyPress(KeyEvent.KEYCODE_DEL)
+ }
+ } else {
+ isReleased = true
+ repeatJob.cancel()
+ scope.launch {
+ backspaceInteraction.emit(
+ PressInteraction.Cancel(
+ press
+ )
+ )
+ }
}
- } else {
+ } catch (e: Exception) {
isReleased = true
repeatJob.cancel()
- scope.launch {
- backspaceInteraction.emit(
- PressInteraction.Cancel(press)
- )
- }
}
- } catch (e: Exception) {
- isReleased = true
- repeatJob.cancel()
}
- }
- )
- }
- .background(animatedColorDel),
- contentAlignment = Alignment.Center
- ) {
- Icon(
- painter = painterResource(id = R.drawable.rounded_backspace_24),
- contentDescription = "Backspace",
- modifier = Modifier.size(24.dp),
- tint = animatedContentColorDel
- )
+ )
+ }
+ .background(animatedColorDel),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_backspace_24),
+ contentDescription = "Backspace",
+ modifier = Modifier.size(24.dp),
+ tint = animatedContentColorDel
+ )
+ }
}
- }
- )
+ )
- // Row 4 (Sym, Space, Return)
- ButtonGroup(
- modifier = Modifier
- .weight(1f)
- .fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(4.dp),
- content = {
- // Symbols Toggle
- val symInteraction = remember { MutableInteractionSource() }
- val isPressedSym by symInteraction.collectIsPressedAsState()
- var symPressTime by remember { androidx.compose.runtime.mutableLongStateOf(0L) }
- var wasSymOffAtDown by remember { mutableStateOf(false) }
-
- // Reset Symbols on release if used for modifier
- LaunchedEffect(isPressedSym) {
- if (!isPressedSym) {
- if (isWordJumpPerformed && wasSymOffAtDown) {
- isSymbols = false
- }
+ // Row 4 (Sym, Space, Return)
+ ButtonGroup(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ content = {
+ // Symbols Toggle
+ val symInteraction = remember { MutableInteractionSource() }
+ val isPressedSym by symInteraction.collectIsPressedAsState()
+ var symPressTime by remember {
+ androidx.compose.runtime.mutableLongStateOf(
+ 0L
+ )
}
- }
+ var wasSymOffAtDown by remember { mutableStateOf(false) }
- val animatedRadiusSym by animateDpAsState(
- targetValue = if (isPressedSym) 4.dp else keyRoundness,
- label = "cornerRadius"
- )
- KeyButton(
- onClick = {
- val pressDuration = System.currentTimeMillis() - symPressTime
- if (isWordJumpPerformed) {
- if (wasSymOffAtDown) isSymbols = false
- return@KeyButton
- }
- if (pressDuration < 250) {
- if (!wasSymOffAtDown) {
- // Was ON at start. Tap means toggle OFF.
- isSymbols = false
+ // Reset Symbols on release if used for modifier
+ LaunchedEffect(isPressedSym) {
+ if (!isPressedSym) {
+ if (isWordJumpPerformed && wasSymOffAtDown) {
+ isSymbols = false
}
- // Else: Was OFF at start. onPress turned it ON. Tap means "Commit" (keep it ON). Do nothing.
- } else {
- if (wasSymOffAtDown) isSymbols = false
- }
- },
- onPress = {
- performLightHaptic()
- symPressTime = System.currentTimeMillis()
- wasSymOffAtDown = !isSymbols
- isWordJumpPerformed = false
- if (wasSymOffAtDown) {
- isSymbols = true
}
- },
- interactionSource = symInteraction,
- containerColor = MaterialTheme.colorScheme.primaryContainer,
- contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
- shape = RoundedCornerShape(animatedRadiusSym),
- modifier = Modifier
- .weight(1.2f)
- .fillMaxHeight()
- ) {
- Text(
- text = stringResource(if (isSymbols) R.string.label_kbd_abc else R.string.label_kbd_symbols),
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Medium,
- fontFamily = CustomFontFamily
+ }
+
+ val animatedRadiusSym by animateDpAsState(
+ targetValue = if (isPressedSym) 4.dp else keyRoundness,
+ label = "cornerRadius"
)
- }
+ KeyButton(
+ onClick = {
+ val pressDuration =
+ System.currentTimeMillis() - symPressTime
+ if (isWordJumpPerformed) {
+ if (wasSymOffAtDown) isSymbols = false
+ return@KeyButton
+ }
+ if (pressDuration < 250) {
+ if (!wasSymOffAtDown) {
+ // Was ON at start. Tap means toggle OFF.
+ isSymbols = false
+ }
+ // Else: Was OFF at start. onPress turned it ON. Tap means "Commit" (keep it ON). Do nothing.
+ } else {
+ if (wasSymOffAtDown) isSymbols = false
+ }
+ },
+ onPress = {
+ performLightHaptic()
+ symPressTime = System.currentTimeMillis()
+ wasSymOffAtDown = !isSymbols
+ isWordJumpPerformed = false
+ if (wasSymOffAtDown) {
+ isSymbols = true
+ }
+ },
+ interactionSource = symInteraction,
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ shape = RoundedCornerShape(animatedRadiusSym),
+ modifier = Modifier
+ .weight(1.2f)
+ .fillMaxHeight()
+ ) {
+ Text(
+ text = stringResource(if (isSymbols) R.string.label_kbd_abc else R.string.label_kbd_symbols),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Medium,
+ fontFamily = CustomFontFamily
+ )
+ }
- // Comma Key
- val commaInteraction = remember { MutableInteractionSource() }
- val isPressedComma by commaInteraction.collectIsPressedAsState()
- val animatedRadiusComma by animateDpAsState(
- targetValue = if (isPressedComma) 4.dp else keyRoundness,
- label = "cornerRadius"
- )
- KeyButton(
- onClick = { handleType(",") },
- onPress = { performLightHaptic() },
- interactionSource = commaInteraction,
- containerColor = MaterialTheme.colorScheme.primaryContainer,
- contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
- shape = RoundedCornerShape(animatedRadiusComma),
- modifier = Modifier
- .weight(0.7f)
- .fillMaxHeight()
- ) {
- Text(
- text = ",",
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Medium,
- fontFamily = CustomFontFamily
+ // Comma Key
+ val commaInteraction = remember { MutableInteractionSource() }
+ val isPressedComma by commaInteraction.collectIsPressedAsState()
+ val animatedRadiusComma by animateDpAsState(
+ targetValue = if (isPressedComma) 4.dp else keyRoundness,
+ label = "cornerRadius"
)
- }
+ KeyButton(
+ onClick = { handleType(",") },
+ onPress = { performLightHaptic() },
+ interactionSource = commaInteraction,
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ shape = RoundedCornerShape(animatedRadiusComma),
+ modifier = Modifier
+ .weight(0.7f)
+ .fillMaxHeight()
+ ) {
+ Text(
+ text = ",",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Medium,
+ fontFamily = CustomFontFamily
+ )
+ }
- // Space
- val spaceInteraction = remember { MutableInteractionSource() }
- val isPressedSpace by spaceInteraction.collectIsPressedAsState()
- val animatedRadiusSpace by animateDpAsState(
- targetValue = if (isPressedSpace) 4.dp else keyRoundness,
- label = "cornerRadius"
- )
- var accumulatedDx by remember { mutableStateOf(0f) }
- val sweepThreshold = 25f // pixels per cursor move
+ // Space
+ val spaceInteraction = remember { MutableInteractionSource() }
+ val isPressedSpace by spaceInteraction.collectIsPressedAsState()
+ val animatedRadiusSpace by animateDpAsState(
+ targetValue = if (isPressedSpace) 4.dp else keyRoundness,
+ label = "cornerRadius"
+ )
+ var accumulatedDx by remember { mutableStateOf(0f) }
+ val sweepThreshold = 25f // pixels per cursor move
- val animatedColorSpace by animateColorAsState(
- targetValue = if (isPressedSpace) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
- label = "SpaceColor"
- )
+ val animatedColorSpace by animateColorAsState(
+ targetValue = if (isPressedSpace) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
+ label = "SpaceColor"
+ )
- Box(
- modifier = Modifier
- .weight(3f)
- .fillMaxHeight()
- .bounceClick(spaceInteraction)
- .clip(RoundedCornerShape(animatedRadiusSpace))
- .pointerInput(Unit) {
- val viewConfig = viewConfiguration
- awaitEachGesture {
- val down = awaitFirstDown(requireUnconsumed = false)
- val press = PressInteraction.Press(down.position)
- scope.launch { spaceInteraction.emit(press) }
- performLightHaptic()
+ Box(
+ modifier = Modifier
+ .weight(3f)
+ .fillMaxHeight()
+ .bounceClick(spaceInteraction)
+ .clip(RoundedCornerShape(animatedRadiusSpace))
+ .pointerInput(Unit) {
+ val viewConfig = viewConfiguration
+ awaitEachGesture {
+ val down =
+ awaitFirstDown(requireUnconsumed = false)
+ val press =
+ PressInteraction.Press(down.position)
+ scope.launch { spaceInteraction.emit(press) }
+ performLightHaptic()
- var isDragStarted = false
- var totalDx = 0f
- var cursorAccumulator = 0f
-
- // Increased slop for spacebar to prevent accidental cursor moves
- val customSlop = viewConfig.touchSlop * 2.5f
-
- var upOrCancel: PointerInputChange? = null
-
- while (true) {
- val event = awaitPointerEvent()
- val change = event.changes.find { it.id == down.id }
-
- if (change == null || change.isConsumed) {
- break
- }
+ var isDragStarted = false
+ var totalDx = 0f
+ var cursorAccumulator = 0f
- if (change.changedToUp()) {
- upOrCancel = change
- break
- }
+ // Increased slop for spacebar to prevent accidental cursor moves
+ val customSlop = viewConfig.touchSlop * 2.5f
- if (change.positionChange() != Offset.Zero) {
- totalDx += (change.position.x - change.previousPosition.x)
- val dxFromOrigin = totalDx
-
- if (!isDragStarted) {
- if (kotlin.math.abs(dxFromOrigin) > customSlop) {
- isDragStarted = true
- onCursorDrag(true)
- cursorAccumulator = 0f
- }
+ var upOrCancel: PointerInputChange? = null
+
+ while (true) {
+ val event = awaitPointerEvent()
+ val change =
+ event.changes.find { it.id == down.id }
+
+ if (change == null || change.isConsumed) {
+ break
}
-
- if (isDragStarted) {
- change.consume()
- cursorAccumulator += (change.position.x - change.previousPosition.x)
-
- val absDx = kotlin.math.abs(cursorAccumulator)
- if (absDx >= sweepThreshold) {
- val steps = (absDx / sweepThreshold).toInt()
- val keycode = if (cursorAccumulator > 0) KeyEvent.KEYCODE_DPAD_RIGHT else KeyEvent.KEYCODE_DPAD_LEFT
- repeat(steps) {
- performLightHaptic()
- // Use Shift state to decide if we are selecting text
- val isSelection = shiftState != ShiftState.OFF
- if (isSelection) {
- isSelectionPerformed = true
- }
- // Use Symbols press state to decide if we are jumping words
- val isWordJump = isPressedSym
- if (isWordJump) {
- isWordJumpPerformed = true
+
+ if (change.changedToUp()) {
+ upOrCancel = change
+ break
+ }
+
+ if (change.positionChange() != Offset.Zero) {
+ totalDx += (change.position.x - change.previousPosition.x)
+ val dxFromOrigin = totalDx
+
+ if (!isDragStarted) {
+ if (kotlin.math.abs(dxFromOrigin) > customSlop) {
+ isDragStarted = true
+ onCursorDrag(true)
+ cursorAccumulator = 0f
+ }
+ }
+
+ if (isDragStarted) {
+ change.consume()
+ cursorAccumulator += (change.position.x - change.previousPosition.x)
+
+ val absDx = kotlin.math.abs(
+ cursorAccumulator
+ )
+ if (absDx >= sweepThreshold) {
+ val steps =
+ (absDx / sweepThreshold).toInt()
+ val keycode =
+ if (cursorAccumulator > 0) KeyEvent.KEYCODE_DPAD_RIGHT else KeyEvent.KEYCODE_DPAD_LEFT
+ repeat(steps) {
+ performLightHaptic()
+ // Use Shift state to decide if we are selecting text
+ val isSelection =
+ shiftState != ShiftState.OFF
+ if (isSelection) {
+ isSelectionPerformed =
+ true
+ }
+ // Use Symbols press state to decide if we are jumping words
+ val isWordJump =
+ isPressedSym
+ if (isWordJump) {
+ isWordJumpPerformed =
+ true
+ }
+ onCursorMove(
+ keycode,
+ isSelection,
+ isWordJump
+ )
}
- onCursorMove(keycode, isSelection, isWordJump)
+ cursorAccumulator %= sweepThreshold
}
- cursorAccumulator %= sweepThreshold
}
}
}
- }
- if (upOrCancel != null && !isDragStarted) {
- handleType(" ")
- scope.launch { spaceInteraction.emit(PressInteraction.Release(press)) }
- } else {
- if (isDragStarted) {
- onCursorDrag(false)
- }
- scope.launch { spaceInteraction.emit(PressInteraction.Cancel(press)) }
+ if (upOrCancel != null && !isDragStarted) {
+ handleType(" ")
+ scope.launch {
+ spaceInteraction.emit(
+ PressInteraction.Release(press)
+ )
+ }
+ } else {
+ if (isDragStarted) {
+ onCursorDrag(false)
+ }
+ scope.launch {
+ spaceInteraction.emit(
+ PressInteraction.Cancel(press)
+ )
+ }
+ }
}
}
- }
- .background(animatedColorSpace),
- contentAlignment = Alignment.Center
- ) {
- // Empty space
- }
+ .background(animatedColorSpace),
+ contentAlignment = Alignment.Center
+ ) {
+ // Empty space
+ }
- // Dot Key
- val dotInteraction = remember { MutableInteractionSource() }
- val isPressedDot by dotInteraction.collectIsPressedAsState()
- val animatedRadiusDot by animateDpAsState(
- targetValue = if (isPressedDot) 4.dp else keyRoundness,
- label = "cornerRadius"
- )
- KeyButton(
- onClick = { handleType(".") },
- onPress = { performLightHaptic() },
- interactionSource = dotInteraction,
- containerColor = MaterialTheme.colorScheme.primaryContainer,
- contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
- shape = RoundedCornerShape(animatedRadiusDot),
- modifier = Modifier
- .weight(0.7f)
- .fillMaxHeight()
- ) {
- Text(
- text = ".",
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Medium,
- fontFamily = CustomFontFamily
+ // Dot Key
+ val dotInteraction = remember { MutableInteractionSource() }
+ val isPressedDot by dotInteraction.collectIsPressedAsState()
+ val animatedRadiusDot by animateDpAsState(
+ targetValue = if (isPressedDot) 4.dp else keyRoundness,
+ label = "cornerRadius"
)
- }
+ KeyButton(
+ onClick = { handleType(".") },
+ onPress = { performLightHaptic() },
+ interactionSource = dotInteraction,
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ shape = RoundedCornerShape(animatedRadiusDot),
+ modifier = Modifier
+ .weight(0.7f)
+ .fillMaxHeight()
+ ) {
+ Text(
+ text = ".",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Medium,
+ fontFamily = CustomFontFamily
+ )
+ }
- // Return
- val returnInteraction = remember { MutableInteractionSource() }
- val isPressedReturn by returnInteraction.collectIsPressedAsState()
- val animatedRadiusReturn by animateDpAsState(
- targetValue = if (isPressedReturn) 4.dp else keyRoundness,
- label = "cornerRadius"
- )
- KeyButton(
- onClick = { handleKeyPress(KeyEvent.KEYCODE_ENTER) },
- onPress = { performLightHaptic() },
- interactionSource = returnInteraction,
- containerColor = MaterialTheme.colorScheme.primaryContainer,
- contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
- shape = RoundedCornerShape(animatedRadiusReturn),
- modifier = Modifier
- .weight(1.5f)
- .fillMaxHeight()
- ) {
- Icon(
- painter = painterResource(id = R.drawable.rounded_keyboard_return_24),
- contentDescription = "Return",
- modifier = Modifier.size(24.dp)
+ // Return
+ val returnInteraction = remember { MutableInteractionSource() }
+ val isPressedReturn by returnInteraction.collectIsPressedAsState()
+ val animatedRadiusReturn by animateDpAsState(
+ targetValue = if (isPressedReturn) 4.dp else keyRoundness,
+ label = "cornerRadius"
)
+ KeyButton(
+ onClick = { handleKeyPress(KeyEvent.KEYCODE_ENTER) },
+ onPress = { performLightHaptic() },
+ interactionSource = returnInteraction,
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ shape = RoundedCornerShape(animatedRadiusReturn),
+ modifier = Modifier
+ .weight(1.5f)
+ .fillMaxHeight()
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_keyboard_return_24),
+ contentDescription = "Return",
+ modifier = Modifier.size(24.dp)
+ )
+ }
}
- }
- )
+ )
+ }
}
}
}
+
+ if (isFunctionsBottom) {
+ FunctionRow(
+ Modifier
+ .height(48.dp)
+ .fillMaxWidth()
+ )
+ }
}
- if (isFunctionsBottom) {
- FunctionRow(
- Modifier
- .height(48.dp)
- .fillMaxWidth()
- )
+ // Accented Popup Overlay
+ if (longPressKey != null && longPressVariants.isNotEmpty()) {
+ val density = LocalDensity.current
+ Popup(
+ alignment = BiasAlignment(-1f + 2f * longPressXRatio, -1f + 2f * longPressYRatio),
+ offset = IntOffset(0, with(density) { (-20).dp.roundToPx() }),
+ properties = PopupProperties(
+ focusable = false,
+ dismissOnClickOutside = false,
+ dismissOnBackPress = true,
+ clippingEnabled = false,
+ excludeFromSystemGesture = true
+ )
+ ) {
+ AccentedKeysPopup(
+ variants = longPressVariants,
+ selectedIndex = selectedAccentIndex,
+ keyRoundness = keyRoundness
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/sameerasw/essentials/utils/DeviceSpecsCache.kt b/app/src/main/java/com/sameerasw/essentials/utils/DeviceSpecsCache.kt
new file mode 100644
index 000000000..0df4c1e87
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/utils/DeviceSpecsCache.kt
@@ -0,0 +1,97 @@
+package com.sameerasw.essentials.utils
+
+import android.content.Context
+import com.google.gson.Gson
+import com.sameerasw.essentials.data.model.DeviceSpecs
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+import java.net.URL
+
+/**
+ * Utility for caching device specifications and images locally to avoid redundant network requests.
+ */
+object DeviceSpecsCache {
+ private const val SPECS_FILE = "device_specs_cache.json"
+ private const val IMAGES_DIR = "device_info_images"
+ private val gson = Gson()
+
+ /**
+ * Retrieves the cached device specifications if available.
+ */
+ fun getCachedSpecs(context: Context): DeviceSpecs? {
+ return try {
+ val file = File(context.filesDir, SPECS_FILE)
+ if (!file.exists()) return null
+ val json = file.readText()
+ gson.fromJson(json, DeviceSpecs::class.java)
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ /**
+ * Saves the device specifications to local storage.
+ */
+ fun saveSpecs(context: Context, specs: DeviceSpecs) {
+ try {
+ val json = gson.toJson(specs)
+ File(context.filesDir, SPECS_FILE).writeText(json)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+
+ /**
+ * Clears all cached device data.
+ */
+ fun clearCache(context: Context) {
+ try {
+ File(context.filesDir, SPECS_FILE).delete()
+ val imagesDir = File(context.filesDir, IMAGES_DIR)
+ if (imagesDir.exists()) {
+ imagesDir.deleteRecursively()
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+
+ /**
+ * Downloads and saves device images locally.
+ * Returns a copy of [specs] with the [DeviceSpecs.localImagePaths] populated.
+ */
+ suspend fun downloadImages(context: Context, specs: DeviceSpecs): DeviceSpecs = withContext(Dispatchers.IO) {
+ val dir = File(context.filesDir, IMAGES_DIR)
+ if (!dir.exists()) dir.mkdirs()
+
+ val localPaths = mutableListOf()
+ specs.imageUrls.forEachIndexed { index, url ->
+ try {
+ // Use a stable filename based on index and extension
+ val extension = url.substringAfterLast(".", "jpg").split("?").first()
+ val fileName = "device_image_${index}.${extension}"
+ val file = File(dir, fileName)
+
+ // Only download if it doesn't already exist or if it's the first image (often updated)
+ if (!file.exists()) {
+ URL(url).openStream().use { input ->
+ FileOutputStream(file).use { output ->
+ input.copyTo(output)
+ }
+ }
+ }
+ localPaths.add(file.absolutePath)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ // If download fails, we don't add to localPaths,
+ // UI will fall back to imageUrls
+ }
+ }
+
+ val updatedSpecs = specs.copy(localImagePaths = localPaths)
+ saveSpecs(context, updatedSpecs)
+ updatedSpecs
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/utils/FreezeManager.kt b/app/src/main/java/com/sameerasw/essentials/utils/FreezeManager.kt
index 5c48d0262..fa2a4b3d4 100644
--- a/app/src/main/java/com/sameerasw/essentials/utils/FreezeManager.kt
+++ b/app/src/main/java/com/sameerasw/essentials/utils/FreezeManager.kt
@@ -1,6 +1,13 @@
package com.sameerasw.essentials.utils
import android.content.Context
+import android.os.Build
+import android.os.IBinder
+import android.os.PersistableBundle
+import org.lsposed.hiddenapibypass.HiddenApiBypass
+import rikka.shizuku.Shizuku
+import rikka.shizuku.ShizukuBinderWrapper
+import rikka.shizuku.SystemServiceHelper
object FreezeManager {
private const val TAG = "FreezeManager"
@@ -13,23 +20,37 @@ object FreezeManager {
private const val COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED = 4
/**
- * Freeze an application using Shizuku.
- * Sets state to COMPONENT_ENABLED_STATE_DISABLED_USER (3).
+ * Freeze an application using Shizuku or Root.
+ * Uses either 'pm disable-user' or 'pm suspend' based on configuration.
*/
fun freezeApp(context: Context, packageName: String): Boolean {
- return setApplicationEnabledSetting(
- context,
- packageName,
- COMPONENT_ENABLED_STATE_DISABLED_USER
- )
+ val prefs = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE)
+ val mode = prefs.getInt("freeze_mode", 0) // 0: FREEZE, 1: SUSPEND
+
+ return if (mode == 1) {
+ suspendApp(context, packageName)
+ } else {
+ setApplicationEnabledSetting(
+ context,
+ packageName,
+ COMPONENT_ENABLED_STATE_DISABLED_USER
+ )
+ }
}
/**
- * Unfreeze an application using Shizuku.
- * Sets state to COMPONENT_ENABLED_STATE_ENABLED (1).
+ * Unfreeze an application using Shizuku or Root.
+ * Uses either 'pm enable' or 'pm unsuspend' based on configuration.
*/
fun unfreezeApp(context: Context, packageName: String): Boolean {
- return setApplicationEnabledSetting(context, packageName, COMPONENT_ENABLED_STATE_ENABLED)
+ val prefs = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE)
+ val mode = prefs.getInt("freeze_mode", 0)
+
+ return if (mode == 1) {
+ unsuspendApp(context, packageName)
+ } else {
+ setApplicationEnabledSetting(context, packageName, COMPONENT_ENABLED_STATE_ENABLED)
+ }
}
/**
@@ -45,7 +66,10 @@ object FreezeManager {
val gson = com.google.gson.Gson()
try {
val apps: List =
- gson.fromJson(json, Array::class.java).toList()
+ gson.fromJson(
+ json,
+ Array::class.java
+ ).toList()
val excludedSet: Set = if (excludedJson != null) {
gson.fromJson(excludedJson, Array::class.java).toSet()
} else emptySet()
@@ -144,7 +168,10 @@ object FreezeManager {
val gson = com.google.gson.Gson()
try {
val apps: List =
- gson.fromJson(json, Array::class.java).toList()
+ gson.fromJson(
+ json,
+ Array::class.java
+ ).toList()
apps.forEach { app ->
freezeApp(context, app.packageName)
}
@@ -166,7 +193,10 @@ object FreezeManager {
val gson = com.google.gson.Gson()
try {
val apps: List =
- gson.fromJson(json, Array::class.java).toList()
+ gson.fromJson(
+ json,
+ Array::class.java
+ ).toList()
val excludedSet: Set = if (excludedJson != null) {
gson.fromJson(excludedJson, Array::class.java).toSet()
} else emptySet()
@@ -192,7 +222,10 @@ object FreezeManager {
val gson = com.google.gson.Gson()
try {
val apps: List =
- gson.fromJson(json, Array::class.java).toList()
+ gson.fromJson(
+ json,
+ Array::class.java
+ ).toList()
apps.forEach { app ->
unfreezeApp(context, app.packageName)
}
@@ -203,25 +236,154 @@ object FreezeManager {
}
/**
- * Check if an application is currently frozen/disabled.
+ * Check if an application is currently frozen/disabled/suspended.
*/
fun isAppFrozen(context: Context, packageName: String): Boolean {
return try {
val state = context.packageManager.getApplicationEnabledSetting(packageName)
- state == COMPONENT_ENABLED_STATE_DISABLED_USER || state == COMPONENT_ENABLED_STATE_DISABLED
+ val isSuspended = context.packageManager.isPackageSuspended(packageName)
+ state == COMPONENT_ENABLED_STATE_DISABLED_USER || state == COMPONENT_ENABLED_STATE_DISABLED || isSuspended
} catch (e: Exception) {
false
}
}
+ private fun suspendApp(context: Context, packageName: String): Boolean {
+ if (ShizukuUtils.isShizukuAvailable() && ShizukuUtils.hasPermission()) {
+ if (setAppSuspendedWithShizuku(packageName, true)) return true
+ }
+
+ if (!ShellUtils.hasPermission(context)) return false
+ return try {
+ ShellUtils.runCommand(context, "pm suspend --user 0 $packageName")
+ true
+ } catch (e: Exception) {
+ e.printStackTrace()
+ false
+ }
+ }
+
+ private fun unsuspendApp(context: Context, packageName: String): Boolean {
+ if (ShizukuUtils.isShizukuAvailable() && ShizukuUtils.hasPermission()) {
+ if (setAppSuspendedWithShizuku(packageName, false)) return true
+ }
+
+ if (!ShellUtils.hasPermission(context)) return false
+ return try {
+ ShellUtils.runCommand(context, "pm unsuspend --user 0 $packageName")
+ true
+ } catch (e: Exception) {
+ e.printStackTrace()
+ false
+ }
+ }
+
+ private fun setAppSuspendedWithShizuku(packageName: String, suspended: Boolean): Boolean {
+ return try {
+ if (suspended) forceStopAppWithShizuku(packageName)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ setAppRestrictedWithShizuku(packageName, suspended)
+ }
+
+ val pm = getService("package", "android.content.pm.IPackageManager\$Stub") ?: return false
+ val callerPackage = getSuspenderPackage()
+ val userId = getUserId()
+
+ val dialogInfo = if (suspended) {
+ val builderClass = Class.forName("android.content.pm.SuspendDialogInfo\$Builder")
+ val builder = HiddenApiBypass.newInstance(builderClass)
+ HiddenApiBypass.invoke(builderClass, builder, "setNeutralButtonAction", 1 /*BUTTON_ACTION_UNSUSPEND*/)
+ HiddenApiBypass.invoke(builderClass, builder, "build")
+ } else null
+
+ fun callSetPackagesSuspended(version: Int): Array<*>? {
+ return try {
+ when (version) {
+ 0 -> HiddenApiBypass.invoke(
+ pm.javaClass, pm, "setPackagesSuspendedAsUser",
+ arrayOf(packageName), suspended, null, null, dialogInfo, 0, callerPackage, userId, userId
+ ) as? Array<*>
+ 1 -> HiddenApiBypass.invoke(
+ pm.javaClass, pm, "setPackagesSuspendedAsUser",
+ arrayOf(packageName), suspended, null, null, dialogInfo, callerPackage, userId
+ ) as? Array<*>
+ 2 -> HiddenApiBypass.invoke(
+ pm.javaClass, pm, "setPackagesSuspendedAsUser",
+ arrayOf(packageName), suspended, null, null, null, callerPackage, userId
+ ) as? Array<*>
+ else -> pm.javaClass.getMethod("setPackagesSuspendedAsUser", Array::class.java, Boolean::class.javaPrimitiveType, Int::class.javaPrimitiveType)
+ .invoke(pm, arrayOf(packageName), suspended, userId) as? Array<*>
+ }
+ } catch (_: Exception) { null }
+ }
+
+ val result = when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> callSetPackagesSuspended(0) ?: callSetPackagesSuspended(1)
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> callSetPackagesSuspended(1) ?: callSetPackagesSuspended(2)
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> callSetPackagesSuspended(2)
+ else -> callSetPackagesSuspended(3)
+ }
+
+ result?.isEmpty() ?: false
+ } catch (e: Exception) {
+ e.printStackTrace()
+ false
+ }
+ }
+
+ private fun forceStopAppWithShizuku(packageName: String) {
+ val am = getService(Context.ACTIVITY_SERVICE, "android.app.IActivityManager\$Stub") ?: return
+ try {
+ HiddenApiBypass.invoke(am.javaClass, am, "forceStopPackage", packageName, getUserId())
+ } catch (e: Exception) { e.printStackTrace() }
+ }
+
+ private fun setAppRestrictedWithShizuku(packageName: String, restricted: Boolean) {
+ val appops = getService(Context.APP_OPS_SERVICE, "com.android.internal.app.IAppOpsService\$Stub") ?: return
+ try {
+ val appOpsManagerClass = Class.forName("android.app.AppOpsManager")
+ val op = HiddenApiBypass.invoke(appOpsManagerClass, null, "strOpToOp", "android:run_any_in_background") as Int
+ val uid = getPackageUid(packageName)
+ if (uid != -1) {
+ val mode = if (restricted) 1 /*MODE_IGNORED*/ else 0 /*MODE_ALLOWED*/
+ HiddenApiBypass.invoke(appops.javaClass, appops, "setMode", op, uid, packageName, mode)
+ }
+ } catch (e: Exception) { e.printStackTrace() }
+ }
+
+ private fun getPackageUid(packageName: String): Int {
+ val pm = getService("package", "android.content.pm.IPackageManager\$Stub") ?: return -1
+ return try {
+ HiddenApiBypass.invoke(pm.javaClass, pm, "getPackageUid", packageName, 0, 0) as Int
+ } catch (e: Exception) {
+ e.printStackTrace()
+ -1
+ }
+ }
+
+ private fun getService(serviceName: String, stubClassName: String): Any? {
+ return try {
+ val binder = SystemServiceHelper.getSystemService(serviceName) ?: return null
+ val stubClass = Class.forName(stubClassName)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ HiddenApiBypass.invoke(stubClass, null, "asInterface", ShizukuBinderWrapper(binder))
+ } else {
+ stubClass.getMethod("asInterface", IBinder::class.java).invoke(null, ShizukuBinderWrapper(binder))
+ }
+ } catch (_: Exception) { null }
+ }
+
+ private fun getSuspenderPackage(): String =
+ if (Shizuku.getUid() == 0) "com.sameerasw.essentials" else "com.android.shell"
+
+ private fun getUserId(): Int = android.os.Process.myUserHandle().hashCode()
+
private fun setApplicationEnabledSetting(
context: Context,
packageName: String,
newState: Int
): Boolean {
- if (!ShellUtils.hasPermission(context)) {
- return false
- }
+ if (!ShellUtils.hasPermission(context)) return false
val cmd = when (newState) {
COMPONENT_ENABLED_STATE_DISABLED_USER -> "pm disable-user --user 0 $packageName"
diff --git a/app/src/main/java/com/sameerasw/essentials/utils/GSMArenaService.kt b/app/src/main/java/com/sameerasw/essentials/utils/GSMArenaService.kt
index f3d3ae3e2..75db61b10 100644
--- a/app/src/main/java/com/sameerasw/essentials/utils/GSMArenaService.kt
+++ b/app/src/main/java/com/sameerasw/essentials/utils/GSMArenaService.kt
@@ -30,12 +30,23 @@ object GSMArenaService {
val formattedQuery = query.replace(" ", "+")
val searchUrl = "$BASE_URL/results.php3?sQuickSearch=yes&sName=$formattedQuery"
- val searchDoc: Document = Jsoup.connect(searchUrl)
+ var searchDoc: Document = Jsoup.connect(searchUrl)
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
.timeout(30000)
.get()
- val results = searchDoc.select(".makers li")
+ var results = searchDoc.select(".makers li")
+
+ // Fallback for model numbers (SM-G990B etc) which often don't show up in quick search
+ if (results.isEmpty()) {
+ val freeSearchUrl = "$BASE_URL/results.php3?sFreeSearch=yes&sFreeText=$formattedQuery"
+ searchDoc = Jsoup.connect(freeSearchUrl)
+ .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
+ .timeout(30000)
+ .get()
+ results = searchDoc.select(".makers li")
+ }
+
if (results.isEmpty()) return null
val bestMatchingElement = results.firstOrNull { element ->
@@ -138,7 +149,7 @@ object GSMArenaService {
val prefName = preferredName.lowercase()
val prefModel = preferredModel.lowercase()
- val variants = listOf("pro", "max", "plus", "xl", "ultra", "fold", "flip", "power", "neo", "gt", "lite", "ace", "prime", "edge")
+ val variants = listOf("pro", "max", "plus", "xl", "ultra", "fold", "flip", "power", "neo", "gt", "lite", "ace", "prime", "edge", "fe")
for (variant in variants) {
if (found.contains(variant) && !prefName.contains(variant) && !prefModel.contains(variant)) {
return false
diff --git a/app/src/main/java/com/sameerasw/essentials/utils/ShizukuUtils.kt b/app/src/main/java/com/sameerasw/essentials/utils/ShizukuUtils.kt
index d0e67d494..c88f41dc0 100644
--- a/app/src/main/java/com/sameerasw/essentials/utils/ShizukuUtils.kt
+++ b/app/src/main/java/com/sameerasw/essentials/utils/ShizukuUtils.kt
@@ -77,4 +77,28 @@ object ShizukuUtils {
false
}
}
+
+ fun getSystemBinder(name: String): IBinder? {
+ if (!hasPermission() || !isBinderAlive) return null
+
+ val service = moe.shizuku.server.IShizukuService.Stub.asInterface(binder)
+ return try {
+ // Try known method names for Shizuku v13
+ val method = service.javaClass.methods.find { it.name == "getSystemBinder" || it.name == "getService" }
+ if (method != null) {
+ if (method.parameterCount == 1) {
+ method.invoke(service, name) as? IBinder
+ } else if (method.parameterCount == 2) {
+ method.invoke(service, name, null) as? IBinder
+ } else {
+ null
+ }
+ } else {
+ null
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ null
+ }
+ }
}
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 548989505..835f9b88d 100644
--- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt
+++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt
@@ -2,6 +2,7 @@ package com.sameerasw.essentials.viewmodels
import android.Manifest
import android.app.Activity
+import com.sameerasw.essentials.domain.model.DnsPreset
import android.app.ActivityManager
import android.app.admin.DevicePolicyManager
import android.content.BroadcastReceiver
@@ -118,6 +119,7 @@ class MainViewModel : ViewModel() {
val isAutoAccessibilityEnabled = mutableStateOf(false)
val isNotificationGlanceSameAsLightingEnabled = mutableStateOf(true)
val isOnboardingCompleted = mutableStateOf(true) // Default to true so it doesn't flash on first check if not loaded
+ val dnsPresets = mutableStateListOf()
data class CalendarAccount(
@@ -154,6 +156,7 @@ class MainViewModel : ViewModel() {
val isFreezePickedAppsLoading = mutableStateOf(false)
val freezeAutoExcludedApps = mutableStateOf>(emptySet())
val isFreezeDontFreezeActiveAppsEnabled = mutableStateOf(false)
+ val freezeMode = mutableIntStateOf(0)
// Search state
val searchQuery = mutableStateOf("")
@@ -199,6 +202,7 @@ class MainViewModel : ViewModel() {
val userDictionaryWords = mutableStateOf