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 ee88227cb4..c9841e635c 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 @@ -816,6 +816,17 @@ class SettingsRepository( const val KEY_SIM_NAMES_APPLY_ON_BOOT = "sim_names_apply_on_boot" const val KEY_POWER_SAVING_APPLY_ON_BOOT = "power_saving_apply_on_boot" const val KEY_UNSPLASH_ACCESS_KEY = "unsplash_access_key" + + // Snippets Universal + const val KEY_SNIPPETS_UNIVERSAL_ENABLED = "snippets_universal_enabled" + const val KEY_SNIPPETS_UNIVERSAL_AUTO_EXPAND = "snippets_universal_auto_expand" + const val KEY_SNIPPETS_UNIVERSAL_FLOATING_PILL = "snippets_universal_floating_pill" + const val KEY_SNIPPETS_SUGGESTION_DISPLAY_MODE = "snippets_suggestion_display_mode" + const val SNIPPETS_DISPLAY_NONE = "none" + const val SNIPPETS_DISPLAY_FLOATING_PILL = "pill" + const val SNIPPETS_DISPLAY_DYNAMIC_ISLAND = "island" + const val SNIPPETS_DISPLAY_DUO = "duo" + const val SNIPPETS_DISPLAY_BOTH = "both" const val KEY_EXTERNAL_CONTROL_TOKEN = "external_control_token" } @@ -4569,6 +4580,35 @@ class SettingsRepository( fun getStatusGlanceLongPressAction(): Action? = getRemapAction(KEY_STATUS_GLANCE_LONG_PRESS_ACTION) fun setStatusGlanceLongPressAction(action: Action?) = setRemapAction(KEY_STATUS_GLANCE_LONG_PRESS_ACTION, action) + fun isSnippetsUniversalEnabled(): Boolean = getBoolean(KEY_SNIPPETS_UNIVERSAL_ENABLED, false) + fun setSnippetsUniversalEnabled(enabled: Boolean) = putBoolean(KEY_SNIPPETS_UNIVERSAL_ENABLED, enabled) + + fun isSnippetsUniversalAutoExpandEnabled(): Boolean = getBoolean(KEY_SNIPPETS_UNIVERSAL_AUTO_EXPAND, true) + fun setSnippetsUniversalAutoExpandEnabled(enabled: Boolean) = putBoolean(KEY_SNIPPETS_UNIVERSAL_AUTO_EXPAND, enabled) + + fun isSnippetsUniversalFloatingPillEnabled(): Boolean = getBoolean(KEY_SNIPPETS_UNIVERSAL_FLOATING_PILL, true) + fun setSnippetsUniversalFloatingPillEnabled(enabled: Boolean) = putBoolean(KEY_SNIPPETS_UNIVERSAL_FLOATING_PILL, enabled) + + fun getRecommendedSnippetsSuggestionDisplayMode(): String { + val islandOn = isIslandEnabled() + val duoOn = isDuoEnabled() + return when { + islandOn && duoOn -> SNIPPETS_DISPLAY_BOTH + islandOn -> SNIPPETS_DISPLAY_DYNAMIC_ISLAND + duoOn -> SNIPPETS_DISPLAY_DUO + else -> SNIPPETS_DISPLAY_FLOATING_PILL + } + } + + fun getSnippetsSuggestionDisplayMode(): String { + val stored = getString(KEY_SNIPPETS_SUGGESTION_DISPLAY_MODE, null) + if (stored != null) return stored + return getRecommendedSnippetsSuggestionDisplayMode() + } + + fun setSnippetsSuggestionDisplayMode(mode: String) = + putString(KEY_SNIPPETS_SUGGESTION_DISPLAY_MODE, mode) + // Home screen shortcuts only carry an id; the action itself never leaves the app fun getPinnedAction(id: String): Action? = getRemapAction(KEY_PINNED_ACTION_PREFIX + id) 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 6240cf8863..8f33bcaad5 100644 --- a/app/src/main/java/com/sameerasw/essentials/ime/EssentialsInputMethodService.kt +++ b/app/src/main/java/com/sameerasw/essentials/ime/EssentialsInputMethodService.kt @@ -39,6 +39,8 @@ import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.ime.snippets.SnippetExpander +import com.sameerasw.essentials.ime.snippets.SnippetRepository import com.sameerasw.essentials.ui.ime.KeyboardInputView import com.sameerasw.essentials.ui.theme.EssentialsTheme import kotlinx.coroutines.Job @@ -462,12 +464,49 @@ class EssentialsInputMethodService : undoRedoManager.recordDelete(lastWord) ic.deleteSurroundingText(lastWord.length, 0) } - undoRedoManager.recordInsert(word + " ") - ic.commitText(word + " ", 1) + val insertText = + if (suggestion.snippet != null) { + SnippetExpander.expand( + suggestion.snippet.content, + this@EssentialsInputMethodService, + ) + } else { + word + " " + } + undoRedoManager.recordInsert(insertText) + ic.commitText(insertText, 1) suggestionEngine.clearSuggestions() } }, onType = { text -> + if (text == " ") { + val ic = currentInputConnection + if (ic != null) { + val textBefore = ic.getTextBeforeCursor(50, 0)?.toString() ?: "" + val lastWord = textBefore.split(Regex("\\s+")).lastOrNull() ?: "" + if (lastWord.isNotEmpty()) { + val matching = + SnippetRepository.getInstance(this@EssentialsInputMethodService) + .snippets.value.firstOrNull { + it.autoExpandOnSpace && + it.keyword.equals(lastWord, ignoreCase = true) + } + if (matching != null) { + undoRedoManager.recordDelete(lastWord) + ic.deleteSurroundingText(lastWord.length, 0) + val expanded = + SnippetExpander.expand( + matching.content, + this@EssentialsInputMethodService, + ) + undoRedoManager.recordInsert(expanded + " ") + ic.commitText(expanded + " ", 1) + suggestionEngine.clearSuggestions() + return@KeyboardInputView + } + } + } + } undoRedoManager.recordInsert(text) currentInputConnection?.commitText(text, 1) if (isUserDictionaryEnabled && text.length == 1 && !text[0].isLetterOrDigit()) { @@ -715,7 +754,7 @@ class EssentialsInputMethodService : val textBefore = ic.getTextBeforeCursor(50, 0)?.toString() if (!textBefore.isNullOrEmpty()) { val lastWord = textBefore.split(Regex("\\s+")).lastOrNull() ?: "" - if (lastWord.isNotEmpty() && lastWord.all { it.isLetter() }) { + if (lastWord.isNotEmpty()) { lifecycleScope.launch(kotlinx.coroutines.Dispatchers.Default) { suggestionEngine.lookup(lastWord) } diff --git a/app/src/main/java/com/sameerasw/essentials/ime/SuggestionEngine.kt b/app/src/main/java/com/sameerasw/essentials/ime/SuggestionEngine.kt index 9da571fdbf..2687c3e7cd 100644 --- a/app/src/main/java/com/sameerasw/essentials/ime/SuggestionEngine.kt +++ b/app/src/main/java/com/sameerasw/essentials/ime/SuggestionEngine.kt @@ -35,17 +35,21 @@ enum class SuggestionType { Prediction, Correction, Learned, + Snippet, } @androidx.annotation.Keep data class Suggestion( val text: String, val type: SuggestionType, + val snippet: com.sameerasw.essentials.ime.snippets.Snippet? = null, ) class SuggestionEngine( private val context: Context, ) : SpellCheckerSession.SpellCheckerSessionListener { + private val snippetRepository = + com.sameerasw.essentials.ime.snippets.SnippetRepository.getInstance(context) private val userDictFile = File(context.filesDir, "user_dict.txt") private val userWords = mutableMapOf() @@ -63,6 +67,7 @@ class SuggestionEngine( // Current word being looked up to handle sync/async merging private var currentWord: String = "" private var currentSymSpellSuggestions: List = emptyList() + private var currentMatchingSnippets: List = emptyList() fun initialize(scope: CoroutineScope) { // Init Android Session (Main Thread) @@ -194,10 +199,22 @@ class SuggestionEngine( return@withContext } - // 1. SymSpell (Fast, Predictive) + // 0. Snippets (Instant, Highest Priority) + val matchingSnippets = + snippetRepository.findMatching(word).map { snippet -> + Suggestion( + text = snippet.title.ifBlank { snippet.keyword }, + type = SuggestionType.Snippet, + snippet = snippet, + ) + } + + currentMatchingSnippets = matchingSnippets + + // 1. SymSpell (Fast, Predictive - only for letter words) // Run on default dispatcher but wait for result to show immediately val symResults = - if (isSymSpellReady) { + if (isSymSpellReady && word.all { it.isLetter() }) { withContext(Dispatchers.Default) { try { // Max edit distance 2.0 for fuzzy @@ -208,7 +225,7 @@ class SuggestionEngine( val type = if (userWords.containsKey(term)) SuggestionType.Learned else SuggestionType.Prediction Suggestion(term, type) - }?.distinctBy { it.text } + }?.distinctBy { it.text.lowercase() } ?.take(6) ?: emptyList() } catch (e: Exception) { emptyList() @@ -219,12 +236,12 @@ class SuggestionEngine( } currentSymSpellSuggestions = symResults - // Update immediately with SymSpell results (Native usually takes longer) - _suggestions.value = symResults + val uniqueSymResults = symResults.distinctBy { it.text.lowercase() } + _suggestions.value = matchingSnippets + uniqueSymResults - // 2. Android (Corrective, Slower) + // 2. Android (Corrective, Slower - only for letter words) val s = session - if (s != null) { + if (s != null && word.all { it.isLetter() }) { try { // Request suggestions. The callback onGetSuggestions will merge results @Suppress("DEPRECATION") @@ -236,15 +253,9 @@ class SuggestionEngine( } override fun onGetSuggestions(results: Array?) { - // Runs on binder thread usually, switch to Main logic if needed, but StateFlow is thread safe. - // We want to merge with currentSymSpellSuggestions - - if (results.isNullOrEmpty()) return + if (results.isNullOrEmpty() || currentWord.isBlank()) return val info = results[0] - // info.cookie or sequence is implementation dependent, assuming it matches 'currentWord' roughly - // Ideally we check validity but for simplicity we merge. - val androidSuggestions = mutableListOf() val count = info.suggestionsCount if (count > 0) { @@ -253,14 +264,14 @@ class SuggestionEngine( } } - // Merge: SymSpell (Prediction) + Android (Correction) - // Deduplicate + // Merge: Snippets + SymSpell (Prediction) + Android (Correction) + // Deduplicate dictionary suggestions without dropping snippets val androidSuggestionsList = androidSuggestions.map { Suggestion(it, SuggestionType.Correction) } - val merged = + val uniqueDictSuggestions = (currentSymSpellSuggestions + androidSuggestionsList) - .distinctBy { it.text } - .take(8) + .distinctBy { it.text.lowercase() } + val merged = (currentMatchingSnippets + uniqueDictSuggestions).take(8) _suggestions.value = merged } @@ -270,6 +281,8 @@ class SuggestionEngine( fun clearSuggestions() { _suggestions.value = emptyList() currentSymSpellSuggestions = emptyList() + currentMatchingSnippets = emptyList() + currentWord = "" } fun getUserWords() = userWords.toMap() diff --git a/app/src/main/java/com/sameerasw/essentials/ime/snippets/Snippet.kt b/app/src/main/java/com/sameerasw/essentials/ime/snippets/Snippet.kt new file mode 100644 index 0000000000..6bc3edf3b9 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ime/snippets/Snippet.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: IME - Snippets + * File: Snippet.kt + * Description: Data model representing a text replacement snippet. + */ + +package com.sameerasw.essentials.ime.snippets + +import androidx.annotation.Keep +import java.util.UUID + +@Keep +data class Snippet( + val id: String = UUID.randomUUID().toString(), + val title: String, + val keyword: String, + val content: String, + val autoExpandOnSpace: Boolean = true, + val createdAt: Long = System.currentTimeMillis(), +) diff --git a/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetExpander.kt b/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetExpander.kt new file mode 100644 index 0000000000..278fbffa86 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetExpander.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: IME - Snippets + * File: SnippetExpander.kt + * Description: Evaluates dynamic placeholder tags inside snippets. + */ + +package com.sameerasw.essentials.ime.snippets + +import android.content.ClipboardManager +import android.content.Context +import java.time.LocalDate +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.util.Locale + +object SnippetExpander { + private val DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.getDefault()) + private val TIME_FORMAT = DateTimeFormatter.ofPattern("h:mm a", Locale.getDefault()) + + fun expand(template: String, context: Context?): String { + var result = template + if (result.contains("{date}", ignoreCase = true)) { + result = result.replace("{date}", LocalDate.now().format(DATE_FORMAT), ignoreCase = true) + } + if (result.contains("{time}", ignoreCase = true)) { + result = result.replace("{time}", LocalTime.now().format(TIME_FORMAT), ignoreCase = true) + } + if (result.contains("{clipboard}", ignoreCase = true)) { + val clipText = context?.let { getClipboardText(it) } ?: "" + result = result.replace("{clipboard}", clipText, ignoreCase = true) + } + return result + } + + private fun getClipboardText(context: Context): String { + return try { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + val clip = cm?.primaryClip + if (clip != null && clip.itemCount > 0) { + clip.getItemAt(0)?.text?.toString().orEmpty() + } else { + "" + } + } catch (_: Exception) { + "" + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt b/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt new file mode 100644 index 0000000000..6a2f10b286 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: IME - Snippets + * File: SnippetRepository.kt + * Description: Repository for persisting and managing custom snippets. + */ + +package com.sameerasw.essentials.ime.snippets + +import android.content.Context +import android.util.AtomicFile +import android.util.Log +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.io.File +import java.io.FileOutputStream +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +class SnippetRepository private constructor(context: Context) { + private val appContext = context.applicationContext + private val scope = CoroutineScope(Dispatchers.IO) + private val gson = Gson() + private val storageFile = File(appContext.filesDir, "snippets.json") + private val atomicFile = AtomicFile(storageFile) + private val fileMutex = Mutex() + + private val _snippets = MutableStateFlow>(DEFAULT_SNIPPETS) + val snippets: StateFlow> = _snippets.asStateFlow() + + init { + loadSnippets() + } + + private fun loadSnippets() { + scope.launch { + try { + if (!storageFile.exists()) { + saveToFile(DEFAULT_SNIPPETS) + _snippets.value = DEFAULT_SNIPPETS + return@launch + } + + val json = atomicFile.openRead().bufferedReader(Charsets.UTF_8).use { it.readText() } + val type = object : TypeToken>() {}.type + val loaded: List? = gson.fromJson(json, type) + if (!loaded.isNullOrEmpty()) { + _snippets.value = loaded + } else if (loaded != null) { + _snippets.value = emptyList() + } else { + Log.w(TAG, "Snippets file read returned null; preserving memory state") + } + } catch (e: Exception) { + Log.e(TAG, "Error loading snippets", e) + } + } + } + + private suspend fun saveToFile(items: List) = withContext(Dispatchers.IO) { + fileMutex.withLock { + var fos: FileOutputStream? = null + try { + val json = gson.toJson(items) + fos = atomicFile.startWrite() + fos.write(json.toByteArray(Charsets.UTF_8)) + atomicFile.finishWrite(fos) + } catch (e: Exception) { + Log.e(TAG, "Error saving snippets", e) + fos?.let { atomicFile.failWrite(it) } + } + } + } + + fun addSnippet(snippet: Snippet) { + val updated = _snippets.updateAndGet { current -> current + snippet } + scope.launch { saveToFile(updated) } + } + + fun updateSnippet(snippet: Snippet) { + val updated = _snippets.updateAndGet { current -> + current.map { if (it.id == snippet.id) snippet else it } + } + scope.launch { saveToFile(updated) } + } + + fun deleteSnippet(id: String) { + val updated = _snippets.updateAndGet { current -> + current.filterNot { it.id == id } + } + scope.launch { saveToFile(updated) } + } + + fun findMatching(word: String): List { + val query = word.trim() + if (query.isEmpty()) return emptyList() + + val isTriggerPrefix = query.startsWith("!") || query.startsWith("/") || query.startsWith(":") || query.startsWith(";") + return _snippets.value.filter { snippet -> + snippet.keyword.equals(query, ignoreCase = true) || + (isTriggerPrefix && snippet.keyword.startsWith(query, ignoreCase = true)) + } + } + + companion object { + private const val TAG = "SnippetRepository" + + val DEFAULT_SNIPPETS = listOf( + Snippet( + title = "Current Date", + keyword = "!date", + content = "{date}", + ), + Snippet( + title = "Current Time", + keyword = "!time", + content = "{time}", + ), + Snippet( + title = "Shrug", + keyword = "!shrug", + content = "¯\\_(ツ)_/¯", + ), + ) + + @Volatile + private var INSTANCE: SnippetRepository? = null + + fun getInstance(context: Context): SnippetRepository { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: SnippetRepository(context).also { INSTANCE = it } + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt b/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt index db598498bb..b196a1ff93 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt @@ -75,7 +75,7 @@ class ExpandedContent(val content: @Composable (IslandExpandedScope) -> Unit) class InteractionOverrides( val onTap: (() -> Boolean)? = null, - val onLongPress: (() -> Unit)? = null, + val onLongPress: (() -> Any)? = null, val onExpandedTap: (() -> Boolean)? = null, val onLineTap: (() -> Boolean)? = null, ) { diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetExpanded.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetExpanded.kt new file mode 100644 index 0000000000..42afe84718 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetExpanded.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Dynamic Island - Plugins + * File: SnippetExpanded.kt + * Description: Expanded card view for snippet suggestions in Dynamic Island. + */ + +package com.sameerasw.essentials.island.plugins.snippets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.ime.snippets.Snippet +import com.sameerasw.essentials.island.model.IslandExpandedScope +import com.sameerasw.essentials.island.ui.IslandTextStyles +import com.sameerasw.essentials.island.ui.components.CameraRow +import com.sameerasw.essentials.island.ui.components.ConnectedButtonRow +import com.sameerasw.essentials.island.ui.components.ConnectedItem +import com.sameerasw.essentials.island.ui.components.ConnectedTextLabel +import com.sameerasw.essentials.island.ui.components.IslandIcon +import com.sameerasw.essentials.island.ui.components.MarqueeText +import com.sameerasw.essentials.island.ui.components.accentGlow +import com.sameerasw.essentials.island.ui.components.cameraClearance + +@Composable +fun SnippetExpanded( + snippet: Snippet, + scope: IslandExpandedScope, + onInsert: () -> Unit, + onCopy: () -> Unit, + onDismiss: () -> Unit, +) { + val spec = scope.spec + val sidePadding = spec.expandedPadding + spec.expandedCorner * 0.35f + val accent = MaterialTheme.colorScheme.primary + + Box(propagateMinConstraints = true) { + Box(Modifier.matchParentSize().accentGlow(accent, true, scope.cameraClearance)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(spec.expandedOutset), + ) { + Spacer(Modifier.height(spec.expandedTopPadding)) + scope.CameraRow( + horizontalPadding = spec.cameraGap + spec.expandedCorner * 0.35f, + start = { + Box(Modifier.padding(horizontal = 4.dp)) { + IslandIcon( + res = R.drawable.rounded_text_snippet_24, + size = 20.dp, + tint = accent, + ) + } + val displayTitle = snippet.title.ifBlank { snippet.keyword } + MarqueeText( + text = displayTitle, + style = IslandTextStyles.title, + modifier = Modifier.weight(1f), + ) + }, + end = { + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(Color.White.copy(alpha = 0.12f)) + .padding(horizontal = 8.dp, vertical = 4.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = snippet.keyword, + style = IslandTextStyles.compact.copy( + fontSize = 11.sp, + color = accent, + fontWeight = FontWeight.Bold, + ), + ) + } + }, + ) + + Spacer(Modifier.height(8.dp)) + + // Body preview of snippet content + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = sidePadding) + .clip(RoundedCornerShape(14.dp)) + .background(Color.White.copy(alpha = 0.08f)) + .padding(horizontal = 14.dp, vertical = 10.dp), + ) { + Text( + text = snippet.content, + style = IslandTextStyles.body.copy( + color = Color.White, + fontSize = 14.sp, + lineHeight = 19.sp, + ), + maxLines = 6, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(Modifier.height(14.dp)) + + // Action row: Insert, Copy, Dismiss + ConnectedButtonRow( + height = 36.dp, + modifier = Modifier.padding(horizontal = sidePadding), + container = Color.White.copy(alpha = 0.2f), + items = listOf( + ConnectedItem(onInsert) { + ConnectedTextLabel(stringResource(R.string.snippets_action_insert)) + }, + ConnectedItem(onCopy) { + ConnectedTextLabel(stringResource(R.string.snippets_action_copy)) + }, + ConnectedItem(onDismiss) { + ConnectedTextLabel(stringResource(R.string.action_dismiss)) + }, + ), + ) + + Spacer(Modifier.height(spec.expandedBottomPadding)) + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetIslandPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetIslandPlugin.kt new file mode 100644 index 0000000000..71a87d247d --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetIslandPlugin.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Dynamic Island - Plugins + * File: SnippetIslandPlugin.kt + * Description: Dynamic Island plugin showing snippet suggestions and peek banners for external keyboards. + */ + +package com.sameerasw.essentials.island.plugins.snippets + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.ime.snippets.Snippet +import com.sameerasw.essentials.island.model.CompactCell +import com.sameerasw.essentials.island.model.CompactPlacement +import com.sameerasw.essentials.island.model.ExpandedContent +import com.sameerasw.essentials.island.model.InteractionOverrides +import com.sameerasw.essentials.island.model.IslandItem +import com.sameerasw.essentials.island.model.IslandPriority +import com.sameerasw.essentials.island.model.LineContent +import com.sameerasw.essentials.island.model.PluginRequest +import com.sameerasw.essentials.island.plugins.BaseIslandPlugin +import com.sameerasw.essentials.island.ui.components.IslandIcon +import com.sameerasw.essentials.island.ui.components.RollingText + +class SnippetIslandPlugin : BaseIslandPlugin() { + companion object { + const val ITEM_KEY = "snippet_suggestion" + private const val PEEK_DURATION_MS = 4500L + } + + override val id = "snippets" + + private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper()) + private val autoDismissRunnable = Runnable { hideSnippet() } + + private var activeSnippet: Snippet? = null + private var onExpandAction: (() -> Unit)? = null + + fun showSnippet(snippet: Snippet, onExpand: () -> Unit) { + activeSnippet = snippet + onExpandAction = onExpand + render() + ctx?.request(PluginRequest.Peek(ITEM_KEY, PEEK_DURATION_MS)) + mainHandler.removeCallbacks(autoDismissRunnable) + mainHandler.postDelayed(autoDismissRunnable, PEEK_DURATION_MS) + } + + fun hideSnippet() { + mainHandler.removeCallbacks(autoDismissRunnable) + if (activeSnippet == null) return + activeSnippet = null + onExpandAction = null + publish(emptyList()) + ctx?.request(PluginRequest.Collapse(ITEM_KEY)) + } + + override fun onStop() { + mainHandler.removeCallbacks(autoDismissRunnable) + activeSnippet = null + onExpandAction = null + } + + private fun copyToClipboard(text: String) { + try { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + val clip = ClipData.newPlainText("Snippet", text) + cm?.setPrimaryClip(clip) + } catch (_: Exception) { + } + } + + private fun render() { + val snippet = activeSnippet + if (snippet == null) { + publish(emptyList()) + return + } + + val item = IslandItem( + key = ITEM_KEY, + priority = IslandPriority.NOTIFICATION - 1, + placement = CompactPlacement.Dynamic, + compact = listOf( + CompactCell("snippet.icon") { + IslandIcon( + res = R.drawable.rounded_text_snippet_24, + size = 18.dp, + tint = MaterialTheme.colorScheme.primary, + ) + }, + CompactCell("snippet.keyword") { + RollingText(snippet.keyword) + }, + ), + line = LineContent( + icon = { + IslandIcon( + res = R.drawable.rounded_text_snippet_24, + tint = MaterialTheme.colorScheme.primary, + ) + }, + start = snippet.keyword, + end = snippet.title.ifEmpty { snippet.content.take(24) }, + ), + expanded = ExpandedContent { scope -> + SnippetExpanded( + snippet = snippet, + scope = scope, + onInsert = { + onExpandAction?.invoke() + hideSnippet() + }, + onCopy = { + val expanded = com.sameerasw.essentials.ime.snippets.SnippetExpander.expand(snippet.content, context) + copyToClipboard(expanded) + Toast.makeText(context, R.string.snippets_action_copied, Toast.LENGTH_SHORT).show() + hideSnippet() + }, + onDismiss = { + hideSnippet() + }, + ) + }, + interactions = InteractionOverrides( + onTap = { + onExpandAction?.invoke() + hideSnippet() + true + }, + onLongPress = { + mainHandler.removeCallbacks(autoDismissRunnable) + ctx?.request(PluginRequest.Expand(ITEM_KEY)) + true + }, + ), + dismissible = true, + onDismiss = { hideSnippet() }, + ) + publish(listOf(item)) + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt b/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt index c50b5149f8..794bc8bc8c 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt @@ -5,6 +5,7 @@ import com.sameerasw.essentials.island.plugins.alarm.AlarmPlugin import com.sameerasw.essentials.island.plugins.timer.TimerPlugin import com.sameerasw.essentials.island.remote.ProviderTrustStore import com.sameerasw.essentials.island.plugins.call.CallPlugin +import com.sameerasw.essentials.island.plugins.snippets.SnippetIslandPlugin import android.accessibilityservice.AccessibilityService import android.app.KeyguardManager import android.content.BroadcastReceiver @@ -118,7 +119,7 @@ class IslandCoordinator( }, anchorProvider = { geometry?.anchor ?: CameraAnchor.Center }, ) - + private val snippetPlugin = SnippetIslandPlugin() private val latestItems = HashMap>() private var userPriorities: Map = emptyMap() @@ -157,6 +158,8 @@ class IslandCoordinator( HotspotPlugin(), DevicesPlugin(), BriefPlugin(), + BatteryAlertsPlugin(), + snippetPlugin, ) private var scope: CoroutineScope? = null @@ -393,6 +396,14 @@ class IslandCoordinator( } } + fun showSnippet(snippet: com.sameerasw.essentials.ime.snippets.Snippet, onExpand: () -> Unit) { + mainHandler.post { snippetPlugin.showSnippet(snippet, onExpand) } + } + + fun hideSnippet() { + mainHandler.post { snippetPlugin.hideSnippet() } + } + fun restart() { mainHandler.post { stop() diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt index 5c315b16d9..0d0cc9bf91 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt @@ -374,6 +374,35 @@ class DuoOverlayHandler( var openBrief: (() -> Unit)? = null + private val snippetAutoDismissRunnable = Runnable { hideSnippetSuggestion() } + + fun showSnippetSuggestion(snippet: com.sameerasw.essentials.ime.snippets.Snippet, onExpand: () -> Unit) { + mainHandler.post { + mainHandler.removeCallbacks(snippetAutoDismissRunnable) + ensureTouchAnchor(isSnippet = true) + duoTouchHandler?.onSnippetTapAction = { + mainHandler.removeCallbacks(snippetAutoDismissRunnable) + onExpand() + restoreTouchAnchor() + } + val titleText = snippet.title.ifBlank { snippet.content.take(18) } + val displayKeyword = if (snippet.keyword.startsWith("!")) snippet.keyword else "!${snippet.keyword}" + overlayView?.setInteractiveSnippet(keyword = displayKeyword, title = titleText) + mainHandler.postDelayed(snippetAutoDismissRunnable, 5000L) + } + } + + fun hideSnippetSuggestion() { + mainHandler.post { + mainHandler.removeCallbacks(snippetAutoDismissRunnable) + duoTouchHandler?.onSnippetTapAction = null + if (overlayView?.isSnippetActive == true) { + overlayView?.resetInteractiveState(animate = true) + } + restoreTouchAnchor() + } + } + fun init() { windowManager = service.getSystemService(AccessibilityService.WINDOW_SERVICE) as? WindowManager updateState() @@ -1200,6 +1229,66 @@ class DuoOverlayHandler( + private fun ensureTouchAnchor(isSnippet: Boolean = false) { + val wm = windowManager ?: return + val density = service.resources.displayMetrics.density + if (duoTouchHandler == null) { + duoTouchHandler = DuoTouchHandler(service) + } + duoTouchHandler?.apply { + this.overlayView = this@DuoOverlayHandler.overlayView + this.cameraCenterX = currentCenterX + this.cameraCenterY = currentCenterY + this.cameraRadiusPx = currentCameraRadiusPx + this.ringRadiusScale = settingsRepository.getDuoRingRadius() + } + + if (touchAnchorView == null) { + touchAnchorView = View(service).apply { + setOnTouchListener { _, event -> + duoTouchHandler?.onTouchEvent(event) ?: false + } + } + } + + val displayWidth = service.resources.displayMetrics.widthPixels + val snippetActive = isSnippet || overlayView?.isSnippetActive == true + val diameter = (((currentCameraRadiusPx + 20f * density) * 2 * settingsRepository.getDuoRingRadius()).toInt()).coerceAtLeast((44f * density).toInt()) + val touchWidth = if (snippetActive) maxOf(diameter, (280f * density).toInt()) else diameter + val touchHeight = if (snippetActive) diameter + (72f * density).toInt() else diameter + + @Suppress("DEPRECATION") + val touchParams = WindowManager.LayoutParams( + touchWidth, + touchHeight, + WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or + WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD, + PixelFormat.TRANSLUCENT + ).apply { + gravity = Gravity.TOP or Gravity.START + x = ((currentCenterX - touchWidth / 2f).toInt()).coerceIn(0, (displayWidth - touchWidth).coerceAtLeast(0)) + y = ((currentCenterY - diameter / 2f).toInt()).coerceAtLeast(0) + } + + if (!isTouchAnchorAdded) { + try { + wm.addView(touchAnchorView, touchParams) + isTouchAnchorAdded = true + } catch (e: Exception) { + Log.e("DuoOverlayHandler", "Failed to add Duo touch anchor for snippet", e) + } + } else { + try { + wm.updateViewLayout(touchAnchorView, touchParams) + } catch (_: Exception) {} + } + } + private fun restoreTouchAnchor() { val wm = windowManager ?: return val anchor = touchAnchorView ?: return @@ -1223,6 +1312,7 @@ class DuoOverlayHandler( val density = service.resources.displayMetrics.density val diameter = (((currentCameraRadiusPx + 20f * density) * 2 * settingsRepository.getDuoRingRadius()).toInt()).coerceAtLeast((44f * density).toInt()) + @Suppress("DEPRECATION") val touchParams = WindowManager.LayoutParams( diameter, diameter, diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoTouchHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoTouchHandler.kt index 8146206fcb..68e4df401b 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoTouchHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoTouchHandler.kt @@ -49,6 +49,8 @@ class DuoTouchHandler( var cameraRadiusPx: Float = 36f var ringRadiusScale: Float = 1.0f var openBrief: (() -> Unit)? = null + var onSnippetTapAction: (() -> Unit)? = null + private var wasDownInSnippetPill: Boolean = false private var downX: Float = 0f private var downY: Float = 0f @@ -112,7 +114,10 @@ class DuoTouchHandler( MotionEvent.ACTION_DOWN -> { val effectiveRadius = (cameraRadiusPx + 20f * density) * ringRadiusScale val dist = hypot(x - cameraCenterX, y - cameraCenterY) - if (dist > effectiveRadius) { + val isSnippet = overlayView?.isSnippetActive == true && onSnippetTapAction != null + val inSnippetPill = isSnippet && overlayView?.snippetPillRect?.contains(x, y) == true + wasDownInSnippetPill = inSnippetPill + if (dist > effectiveRadius && !inSnippetPill) { isTouchActiveInCutout = false return false } @@ -133,6 +138,10 @@ class DuoTouchHandler( overlayView?.triggerTapAnimation() + if (inSnippetPill) { + return true + } + val doubleTapAction = settingsRepository.getDuoDoubleTapAction() val isSecondTapInWindow = (downTime - lastTapTime) < doubleTapTimeoutMs && hypot(x - lastTapX, y - lastTapY) < touchSlopPx * 1.5f @@ -229,7 +238,27 @@ class DuoTouchHandler( isSoundModeTriggered || isVolumeOrBrightnessAdjusted + if (wasDownInSnippetPill) { + wasDownInSnippetPill = false + val snippetTap = onSnippetTapAction + if (snippetTap != null && overlayView?.isSnippetActive == true) { + if (totalDist < touchSlopPx * 2f && elapsed < 650L) { + onSnippetTapAction = null + overlayView?.resetInteractiveState(animate = true) + HapticUtil.performHapticForService(service, HapticFeedbackType.CLICK) + snippetTap() + isTouchActiveInCutout = false + return true + } + } + } + if (!didPerformAnyGesture && totalDist < touchSlopPx && elapsed < 350L) { + if (overlayView?.isSnippetActive == true) { + onSnippetTapAction = null + overlayView?.resetInteractiveState(animate = true) + } + val doubleTapAction = settingsRepository.getDuoDoubleTapAction() val tapAction = if (settingsRepository.isDuoTapForBriefActive() && openBrief != null) null else settingsRepository.getDuoTapAction() val briefTap = openBrief?.takeIf { settingsRepository.isDuoTapForBriefActive() } diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt new file mode 100644 index 0000000000..349afabc82 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt @@ -0,0 +1,554 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Background Services & Receivers - Handlers + * File: SnippetAccessibilityHandler.kt + * Description: Accessibility service handler providing universal snippet expansion and floating pills across external keyboards. + */ + +package com.sameerasw.essentials.services.handlers + +import android.accessibilityservice.AccessibilityService +import android.content.Context +import android.graphics.PixelFormat +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.util.Log +import android.view.Gravity +import android.view.MotionEvent +import android.view.WindowManager +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import android.widget.FrameLayout +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.ime.snippets.Snippet +import com.sameerasw.essentials.ime.snippets.SnippetExpander +import com.sameerasw.essentials.ime.snippets.SnippetRepository +import com.sameerasw.essentials.island.service.IslandCoordinator +import com.sameerasw.essentials.island.service.OverlayLifecycleOwner +import com.sameerasw.essentials.ui.ime.snippets.SnippetFloatingPillView +import com.sameerasw.essentials.ui.theme.EssentialsTheme +import com.sameerasw.essentials.utils.HapticUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow + +class SnippetAccessibilityHandler( + private val service: AccessibilityService, + private val scope: CoroutineScope, + var islandCoordinator: IslandCoordinator? = null, + var duoOverlayHandler: DuoOverlayHandler? = null, +) { + companion object { + private const val TAG = "SnippetA11yHandler" + private const val AUTO_DISMISS_TIMEOUT_MS = 6000L + private const val REPLACEMENT_DEBOUNCE_MS = 300L + private const val DEFAULT_KEYBOARD_HEIGHT_DP = 290 + private const val FALLBACK_IME_PADDING_DP = 72 + private const val MIN_INPUT_BOUNDS_TOP_DP = 80 + private const val FLOATING_PILL_OFFSET_DP = 12 + } + + private val windowManager = service.getSystemService(Context.WINDOW_SERVICE) as WindowManager + private val settingsRepository = SettingsRepository(service) + private val snippetRepository = SnippetRepository.getInstance(service) + private val mainHandler = Handler(Looper.getMainLooper()) + + private var isUniversalEnabled = settingsRepository.isSnippetsUniversalEnabled() + private var isAutoExpandEnabled = settingsRepository.isSnippetsUniversalAutoExpandEnabled() + private var isFloatingPillEnabled = settingsRepository.isSnippetsUniversalFloatingPillEnabled() + + private val currentMatchedSnippet = MutableStateFlow(null) + private var activeNode: AccessibilityNodeInfo? = null + private var lastInputBounds: android.graphics.Rect? = null + private var lastTypedWord: String = "" + private var lastKnownText: String = "" + + private var overlayView: FrameLayout? = null + private var lifecycleOwner: OverlayLifecycleOwner? = null + + private val dismissRunnable = Runnable { hideFloatingPill() } + + @Volatile + private var isPerformingReplacement = false + + fun updateSettings() { + isUniversalEnabled = settingsRepository.isSnippetsUniversalEnabled() + isAutoExpandEnabled = settingsRepository.isSnippetsUniversalAutoExpandEnabled() + isFloatingPillEnabled = settingsRepository.isSnippetsUniversalFloatingPillEnabled() + if (!isUniversalEnabled) { + dismissAllSuggestions() + } + } + + private var lastImeCheckTime = 0L + private var cachedIsEssentialsIme = false + + private fun isEssentialsImeActive(): Boolean { + val now = System.currentTimeMillis() + if (now - lastImeCheckTime < 2000L) { + return cachedIsEssentialsIme + } + lastImeCheckTime = now + cachedIsEssentialsIme = try { + val currentIme = Settings.Secure.getString( + service.contentResolver, + Settings.Secure.DEFAULT_INPUT_METHOD, + ).orEmpty() + currentIme.startsWith(service.packageName) + } catch (_: Exception) { + false + } + return cachedIsEssentialsIme + } + + fun onTextChanged(event: AccessibilityEvent) { + if (!isUniversalEnabled || isPerformingReplacement) return + + val eventPkg = event.packageName?.toString().orEmpty() + if (eventPkg == service.packageName) return + + val sourceNode = event.source ?: return + if (event.isPassword || sourceNode.isPassword || isPasswordField(sourceNode)) return + + val hasSetTextAction = (sourceNode.actions and AccessibilityNodeInfo.ACTION_SET_TEXT) != 0 || + sourceNode.actionList.any { it.id == AccessibilityNodeInfo.ACTION_SET_TEXT } + val isTextClass = sourceNode.className?.let { name -> + name.contains("EditText", ignoreCase = true) || + name.contains("AutoComplete", ignoreCase = true) || + name.contains("Search", ignoreCase = true) + } == true + + val canEdit = sourceNode.isEditable || hasSetTextAction || isTextClass + if (!canEdit) return + + activeNode?.recycle() + activeNode = AccessibilityNodeInfo.obtain(sourceNode) + val bounds = android.graphics.Rect() + sourceNode.getBoundsInScreen(bounds) + if (bounds.height() > 0 && bounds.top > 0) { + lastInputBounds = bounds + } + + val textList = event.text + val fullText = + if (textList.isNotEmpty()) { + textList.joinToString("") + } else { + sourceNode.text?.toString().orEmpty() + } + + lastKnownText = fullText + + if (fullText.isEmpty()) { + dismissAllSuggestions() + return + } + + // 1. Space-based instant auto-expand (skip if Essentials IME is typing, to prevent double expansion) + if (fullText.endsWith(" ") && isAutoExpandEnabled && !isEssentialsImeActive()) { + val textBeforeSpace = fullText.dropLast(1) + val candidateWord = textBeforeSpace.takeLastWhile { !it.isWhitespace() } + + if (candidateWord.isNotBlank()) { + val matchingSnippet = + snippetRepository.snippets.value.firstOrNull { snippet -> + snippet.keyword.equals(candidateWord, ignoreCase = true) + } + + if (matchingSnippet != null && matchingSnippet.autoExpandOnSpace) { + val expanded = SnippetExpander.expand(matchingSnippet.content, service) + val success = replaceKeywordInNode( + node = sourceNode, + targetWord = "$candidateWord ", + replacement = "$expanded ", + currentText = fullText, + ) + if (success) { + dismissAllSuggestions() + return + } + } + } + } + + // 2. Suggestion trigger (Pill, Island, Duo, or Both) + val currentWord = fullText.takeLastWhile { !it.isWhitespace() } + lastTypedWord = currentWord + + if (currentWord.isNotBlank()) { + val matches = snippetRepository.findMatching(currentWord) + if (matches.isNotEmpty()) { + showSuggestion(matches.first()) + } else { + dismissAllSuggestions() + } + } else { + dismissAllSuggestions() + } + } + + private fun replaceKeywordInNode( + node: AccessibilityNodeInfo, + targetWord: String, + replacement: String, + currentText: String = "", + ): Boolean { + isPerformingReplacement = true + try { + val fullText = node.text?.toString().takeIf { !it.isNullOrEmpty() } + ?: currentText.takeIf { it.isNotEmpty() } + ?: lastKnownText.takeIf { it.isNotEmpty() } + ?: return false + + val cursor = node.textSelectionEnd + // Verify cursor is at the end of targetWord to prevent replacing arbitrary occurrences + val targetIndex = + if (cursor in targetWord.length..fullText.length && + fullText.substring(cursor - targetWord.length, cursor).equals(targetWord, ignoreCase = true) + ) { + cursor - targetWord.length + } else if (fullText.endsWith(targetWord, ignoreCase = true)) { + fullText.length - targetWord.length + } else if (fullText.trimEnd().endsWith(targetWord.trimEnd(), ignoreCase = true)) { + val trimmed = fullText.trimEnd() + trimmed.length - targetWord.trimEnd().length + } else { + return false + } + + val actualWordEnd = + if (fullText.trimEnd().endsWith(targetWord.trimEnd(), ignoreCase = true)) { + fullText.length + } else { + (targetIndex + targetWord.length).coerceAtMost(fullText.length) + } + + val newText = + fullText.substring(0, targetIndex) + + replacement + + fullText.substring(actualWordEnd) + + val args = + Bundle().apply { + putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, newText) + } + val success = node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args) + if (!success) return false + + val newCursorPos = (targetIndex + replacement.length).coerceIn(0, newText.length) + val cursorArgs = + Bundle().apply { + putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, newCursorPos) + putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, newCursorPos) + } + node.performAction(AccessibilityNodeInfo.ACTION_SET_SELECTION, cursorArgs) + return true + } finally { + mainHandler.postDelayed({ isPerformingReplacement = false }, REPLACEMENT_DEBOUNCE_MS) + } + } + + private fun isPasswordField(node: AccessibilityNodeInfo): Boolean { + val inputType = node.inputType + val variation = inputType and android.text.InputType.TYPE_MASK_VARIATION + return variation == android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD || + variation == android.text.InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD || + variation == android.text.InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD || + (inputType and android.text.InputType.TYPE_MASK_CLASS == android.text.InputType.TYPE_CLASS_NUMBER && + variation == android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD) + } + + private fun calculateImeOffset(): Int { + val density = service.resources.displayMetrics.density + val displayHeight = service.resources.displayMetrics.heightPixels + var keyboardHeight = (DEFAULT_KEYBOARD_HEIGHT_DP * density).toInt() + + try { + val currentWindows = service.windows + if (!currentWindows.isNullOrEmpty()) { + val imeWindow = + currentWindows.firstOrNull { + it.type == android.view.accessibility.AccessibilityWindowInfo.TYPE_INPUT_METHOD + } + if (imeWindow != null) { + val imeBounds = android.graphics.Rect() + imeWindow.getBoundsInScreen(imeBounds) + if (imeBounds.height() > 0 && imeBounds.top < displayHeight) { + keyboardHeight = (displayHeight - imeBounds.top).coerceAtLeast(0) + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error calculating IME window bounds", e) + } + + val bounds = lastInputBounds + if (bounds != null && bounds.height() > 0 && bounds.top > 0 && bounds.top < displayHeight) { + val nodeTopFromBottom = displayHeight - bounds.top + if (nodeTopFromBottom >= keyboardHeight && bounds.top > (MIN_INPUT_BOUNDS_TOP_DP * density)) { + return nodeTopFromBottom + (FLOATING_PILL_OFFSET_DP * density).toInt() + } + } + + return keyboardHeight + (FALLBACK_IME_PADDING_DP * density).toInt() + } + + private fun expandSnippet(snippet: Snippet) { + overlayView?.let { HapticUtil.performUIHaptic(it) } + val targetNode = activeNode?.also { + try { it.refresh() } catch (_: Exception) {} + } ?: service.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) + + if (targetNode != null) { + val fullText = targetNode.text?.toString().takeIf { !it.isNullOrEmpty() } ?: lastKnownText + val cursor = targetNode.textSelectionEnd + val textBeforeCursor = if (cursor in 0..fullText.length) fullText.substring(0, cursor) else fullText + val candidateWord = textBeforeCursor.takeLastWhile { !it.isWhitespace() } + val targetWord = if (candidateWord.isNotBlank() && snippet.keyword.startsWith(candidateWord, ignoreCase = true)) { + candidateWord + } else { + lastTypedWord.ifBlank { snippet.keyword } + } + + val expanded = SnippetExpander.expand(snippet.content, service) + replaceKeywordInNode( + node = targetNode, + targetWord = targetWord, + replacement = "$expanded ", + currentText = fullText, + ) + } + dismissAllSuggestions() + } + + private fun showSuggestion(snippet: Snippet) { + val mode = settingsRepository.getSnippetsSuggestionDisplayMode() + val onExpand = { expandSnippet(snippet) } + + when (mode) { + SettingsRepository.SNIPPETS_DISPLAY_NONE -> { + hideFloatingPill() + islandCoordinator?.hideSnippet() + duoOverlayHandler?.hideSnippetSuggestion() + } + SettingsRepository.SNIPPETS_DISPLAY_FLOATING_PILL -> { + islandCoordinator?.hideSnippet() + duoOverlayHandler?.hideSnippetSuggestion() + showFloatingPill(snippet, onExpand) + } + SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> { + hideFloatingPill() + duoOverlayHandler?.hideSnippetSuggestion() + if (settingsRepository.isIslandEnabled()) { + islandCoordinator?.showSnippet(snippet, onExpand) + } else { + showFloatingPill(snippet, onExpand) + } + } + SettingsRepository.SNIPPETS_DISPLAY_DUO -> { + hideFloatingPill() + islandCoordinator?.hideSnippet() + if (settingsRepository.isDuoEnabled()) { + duoOverlayHandler?.showSnippetSuggestion(snippet, onExpand) + } else { + showFloatingPill(snippet, onExpand) + } + } + SettingsRepository.SNIPPETS_DISPLAY_BOTH -> { + hideFloatingPill() + val islandOn = settingsRepository.isIslandEnabled() + val duoOn = settingsRepository.isDuoEnabled() + if (islandOn) { + islandCoordinator?.showSnippet(snippet, onExpand) + } + if (duoOn) { + duoOverlayHandler?.showSnippetSuggestion(snippet, onExpand) + } + if (!islandOn && !duoOn) { + showFloatingPill(snippet, onExpand) + } + } + else -> { + islandCoordinator?.hideSnippet() + duoOverlayHandler?.hideSnippetSuggestion() + showFloatingPill(snippet, onExpand) + } + } + } + + fun dismissAllSuggestions() { + hideFloatingPill() + islandCoordinator?.hideSnippet() + duoOverlayHandler?.hideSnippetSuggestion() + activeNode?.recycle() + activeNode = null + lastTypedWord = "" + lastKnownText = "" + } + + private fun showFloatingPill(snippet: Snippet, onExpand: () -> Unit) { + mainHandler.post { + currentMatchedSnippet.value = snippet + mainHandler.removeCallbacks(dismissRunnable) + mainHandler.postDelayed(dismissRunnable, AUTO_DISMISS_TIMEOUT_MS) + + if (overlayView != null) { + try { + val frame = overlayView ?: return@post + val lp = frame.layoutParams as? WindowManager.LayoutParams + if (lp != null) { + val newY = calculateImeOffset() + if (lp.y != newY) { + lp.y = newY + windowManager.updateViewLayout(frame, lp) + } + } + } catch (_: Exception) { + } + return@post + } + + val lifecycle = OverlayLifecycleOwner().also { it.onCreate() } + lifecycleOwner = lifecycle + + val composeView = + ComposeView(service).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) + setContent { + EssentialsTheme { + val currentSnippet = currentMatchedSnippet.collectAsState().value + if (currentSnippet != null) { + SnippetFloatingPillView( + snippet = currentSnippet, + onExpand = { expandSnippet(currentSnippet) }, + onDismiss = { dismissAllSuggestions() }, + ) + } + } + } + } + + val frame = + FrameLayout(service).apply { + setViewTreeLifecycleOwner(lifecycle) + setViewTreeSavedStateRegistryOwner(lifecycle) + setViewTreeViewModelStoreOwner(lifecycle) + addView(composeView) + } + + val params = + WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or + WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH, + PixelFormat.TRANSLUCENT, + ).apply { + gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL + y = calculateImeOffset() + } + + val density = service.resources.displayMetrics.density + var initialY = 0 + var initialTouchY = 0f + + frame.setOnTouchListener { _, event -> + when (event.action) { + MotionEvent.ACTION_OUTSIDE -> { + dismissAllSuggestions() + true + } + MotionEvent.ACTION_DOWN -> { + val lp = frame.layoutParams as? WindowManager.LayoutParams + initialY = lp?.y ?: 0 + initialTouchY = event.rawY + false + } + MotionEvent.ACTION_MOVE -> { + val dy = (initialTouchY - event.rawY).toInt() + if (kotlin.math.abs(dy) > (8 * density).toInt()) { + val lp = frame.layoutParams as? WindowManager.LayoutParams + if (lp != null) { + lp.y = (initialY + dy).coerceAtLeast(0) + try { + windowManager.updateViewLayout(frame, lp) + } catch (_: Exception) {} + } + true + } else { + false + } + } + else -> false + } + } + + overlayView = frame + try { + windowManager.addView(frame, params) + } catch (e: Exception) { + Log.e(TAG, "Failed to display snippet floating pill overlay", e) + overlayView = null + } + } + } + + fun onNonImeWindowChanged() { + if (!isImePresent()) { + dismissAllSuggestions() + } + } + + private fun isImePresent(): Boolean { + return try { + val currentWindows = service.windows + if (currentWindows.isNullOrEmpty()) return false + val displayHeight = service.resources.displayMetrics.heightPixels + val imeWindow = + currentWindows.firstOrNull { + it.type == android.view.accessibility.AccessibilityWindowInfo.TYPE_INPUT_METHOD + } + if (imeWindow != null) { + val bounds = android.graphics.Rect() + imeWindow.getBoundsInScreen(bounds) + bounds.height() > 0 && bounds.top < displayHeight + } else { + false + } + } catch (_: Exception) { + false + } + } + + fun hideFloatingPill() { + mainHandler.post { + mainHandler.removeCallbacks(dismissRunnable) + currentMatchedSnippet.value = null + val view = overlayView ?: return@post + overlayView = null + try { + windowManager.removeView(view) + } catch (_: Exception) { + } + lifecycleOwner?.onDestroy() + lifecycleOwner = null + } + } + + fun destroy() { + dismissAllSuggestions() + activeNode?.recycle() + activeNode = null + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt index 2f0ef76e36..8f4ccf3727 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt @@ -90,6 +90,7 @@ class ScreenOffAccessibilityService : private lateinit var duoOverlayHandler: DuoOverlayHandler lateinit var islandOverlayHandler: IslandCoordinator private lateinit var statusGlanceHandler: StatusGlanceHandler + private lateinit var snippetAccessibilityHandler: com.sameerasw.essentials.services.handlers.SnippetAccessibilityHandler private lateinit var screenshotShareCleanupHandler: ScreenshotShareCleanupHandler private lateinit var screenshotAutoCleanHandler: ScreenshotAutoCleanHandler private lateinit var lockscreenShortcutsHandler: LockscreenShortcutsHandler @@ -302,6 +303,11 @@ class ScreenOffAccessibilityService : key == SettingsRepository.KEY_STATUS_GLANCE_HIDE_WHEN_LOCKED ) { statusGlanceHandler.updateState() + } else if (key?.startsWith("snippets_") == true) { + if (::snippetAccessibilityHandler.isInitialized) { + snippetAccessibilityHandler.updateSettings() + updateSnippetEventRegistration() + } } else if (key == SettingsRepository.KEY_LOCKSCREEN_SHORTCUTS_ENABLED || key == SettingsRepository.KEY_LOCKSCREEN_SHORTCUT_LEFT_ACTIONS || key == SettingsRepository.KEY_LOCKSCREEN_SHORTCUT_RIGHT_ACTIONS @@ -342,6 +348,13 @@ class ScreenOffAccessibilityService : } duoOverlayHandler.openBrief = { islandOverlayHandler.openBrief() } statusGlanceHandler = StatusGlanceHandler(this) + snippetAccessibilityHandler = + com.sameerasw.essentials.services.handlers.SnippetAccessibilityHandler( + service = this, + scope = serviceScope, + islandCoordinator = islandOverlayHandler, + duoOverlayHandler = duoOverlayHandler, + ) lockscreenShortcutsHandler = LockscreenShortcutsHandler(this, flashlightHandler) appVolumeOverlayHandler = AppVolumeOverlayHandler(this) screenshotShareCleanupHandler = ScreenshotShareCleanupHandler(this, serviceScope) @@ -520,6 +533,7 @@ class ScreenOffAccessibilityService : duoOverlayHandler.restart() statusGlanceHandler.restart() islandOverlayHandler.restart() + updateSnippetEventRegistration() } private fun updateOmniOverlay() { @@ -563,6 +577,9 @@ class ScreenOffAccessibilityService : duoOverlayHandler.destroy() islandOverlayHandler.onDestroy() statusGlanceHandler.destroy() + if (::snippetAccessibilityHandler.isInitialized) { + snippetAccessibilityHandler.destroy() + } lockscreenShortcutsHandler.onDestroy() appVolumeOverlayHandler.onDestroy() statusBarIconHandler.unregister() @@ -603,9 +620,21 @@ class ScreenOffAccessibilityService : screenshotShareCleanupHandler.onViewClicked(event, appFlowHandler.currentPackage) } + if (event.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED) { + if (::snippetAccessibilityHandler.isInitialized) { + snippetAccessibilityHandler.onTextChanged(event) + } + return + } + if (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED || event.eventType == AccessibilityEvent.TYPE_WINDOWS_CHANGED ) { + val eventPkg = event.packageName?.toString().orEmpty() + val isImeEvent = isKeyboardPackage(eventPkg) + if (!isImeEvent && ::snippetAccessibilityHandler.isInitialized) { + snippetAccessibilityHandler.onNonImeWindowChanged() + } checkFullscreenState() checkStatusBarExpansion() freezeHandler.removeCallbacks(shadeRecheckRunnable) @@ -679,6 +708,9 @@ class ScreenOffAccessibilityService : statusGlanceHandler.setShadeExpanded(expanded) islandOverlayHandler.setShadeExpanded(expanded) duoOverlayHandler.setShadeExpanded(expanded) + if (expanded && ::snippetAccessibilityHandler.isInitialized) { + snippetAccessibilityHandler.hideFloatingPill() + } } private fun isShadeWindowVisible(): Boolean { @@ -716,12 +748,38 @@ class ScreenOffAccessibilityService : duoOverlayHandler.setFullscreen(isFullscreen) statusGlanceHandler.setFullscreen(isFullscreen) islandOverlayHandler.setFullscreen(isFullscreen) + if (isFullscreen && ::snippetAccessibilityHandler.isInitialized) { + snippetAccessibilityHandler.hideFloatingPill() + } } } } catch (_: Exception) {} } } + private fun isKeyboardPackage(pkg: String): Boolean { + if (pkg.isEmpty()) return false + if (pkg == packageName) return true + val imm = getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager + return imm?.inputMethodList?.any { it.packageName == pkg } == true + } + + private fun updateSnippetEventRegistration() { + val info = serviceInfo ?: return + val isSnippetsEnabled = SettingsRepository(this).isSnippetsUniversalEnabled() + val currentEvents = info.eventTypes + val desiredEvents = + if (isSnippetsEnabled) { + currentEvents or AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED + } else { + currentEvents and AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED.inv() + } + if (currentEvents != desiredEvents) { + info.eventTypes = desiredEvents + serviceInfo = info + } + } + override fun onInterrupt() { if (::volumeStepsHandler.isInitialized) volumeStepsHandler.stop() } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt new file mode 100644 index 0000000000..6b9e2a8ec2 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt @@ -0,0 +1,629 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - Apps (IME Snippets) + * File: SnippetsBottomSheet.kt + * Description: BottomSheet UI for managing, adding, editing, and deleting Raycast-style text snippets + * adhering to Essentials' core design system (EssentialsBottomSheet, RoundedCardContainer, IconToggleItem). + */ + +package com.sameerasw.essentials.ui.features.apps.sheets + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.ime.snippets.Snippet +import com.sameerasw.essentials.ime.snippets.SnippetRepository +import com.sameerasw.essentials.ui.core.cards.IconToggleItem +import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.core.sheets.EssentialsBottomSheet +import com.sameerasw.essentials.utils.HapticUtil + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SnippetsBottomSheet( + onDismissRequest: () -> Unit, +) { + val context = LocalContext.current + val view = LocalView.current + val repository = remember { SnippetRepository.getInstance(context) } + val snippets by repository.snippets.collectAsState() + + var searchQuery by remember { mutableStateOf("") } + var editingSnippet by remember { mutableStateOf(null) } + var isCreatingNew by remember { mutableStateOf(false) } + var deletingSnippet by remember { mutableStateOf(null) } + + val filteredSnippets = + remember(snippets, searchQuery) { + val query = searchQuery.trim() + if (query.isEmpty()) { + snippets + } else { + snippets.filter { + it.keyword.contains(query, ignoreCase = true) || + it.title.contains(query, ignoreCase = true) || + it.content.contains(query, ignoreCase = true) + } + } + } + + // Delete Confirmation Dialog + deletingSnippet?.let { target -> + AlertDialog( + onDismissRequest = { deletingSnippet = null }, + title = { Text(stringResource(R.string.snippets_delete)) }, + text = { Text(stringResource(R.string.snippets_delete_confirm)) }, + confirmButton = { + TextButton( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + repository.deleteSnippet(target.id) + deletingSnippet = null + }, + ) { + Text(stringResource(R.string.snippets_delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { deletingSnippet = null }) { + Text(stringResource(R.string.snippets_cancel)) + } + }, + ) + } + + // Add / Edit Bottom Sheet + if (isCreatingNew || editingSnippet != null) { + val initial = editingSnippet + SnippetEditSheet( + snippet = initial, + onDismissRequest = { + isCreatingNew = false + editingSnippet = null + }, + onSave = { savedSnippet -> + HapticUtil.performVirtualKeyHaptic(view) + if (initial == null) { + repository.addSnippet(savedSnippet) + } else { + repository.updateSnippet(savedSnippet) + } + isCreatingNew = false + editingSnippet = null + }, + ) + } + + EssentialsBottomSheet( + onDismissRequest = onDismissRequest, + ) { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Header Title + item(key = "header_title") { + Text( + text = stringResource(R.string.snippets_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + } + + // Top Action: Add Snippet Card (Matches UserDictionaryBottomSheet pattern) + item(key = "add_snippet_action") { + RoundedCardContainer(spacing = 2.dp) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceBright) + .clickable { + HapticUtil.performVirtualKeyHaptic(view) + isCreatingNew = true + } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.rounded_add_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(16.dp)) + Column { + Text( + text = stringResource(R.string.snippets_add), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(R.string.desc_keyboard_snippets), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + // Search Bar + item(key = "search_bar") { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(stringResource(R.string.snippets_search_hint)) }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.rounded_search_24), + contentDescription = "Search", + modifier = Modifier.size(20.dp), + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon( + painter = painterResource(R.drawable.rounded_close_24), + contentDescription = "Clear", + modifier = Modifier.size(20.dp), + ) + } + } + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceBright, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceBright, + ), + ) + } + + // Snippets List + if (filteredSnippets.isEmpty()) { + item(key = "empty_state") { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.snippets_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + item(key = "snippets_group") { + RoundedCardContainer(spacing = 2.dp) { + filteredSnippets.forEach { snippet -> + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceBright) + .clickable { + HapticUtil.performVirtualKeyHaptic(view) + editingSnippet = snippet + } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Keyword Chip + Box( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.tertiaryContainer) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = snippet.keyword, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + + Text( + text = snippet.title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Text( + text = snippet.content, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + if (snippet.autoExpandOnSpace) { + Text( + text = "⚡ " + stringResource(R.string.snippets_auto_expand), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + editingSnippet = snippet + }, + ) { + Icon( + painter = painterResource(R.drawable.rounded_edit_24), + contentDescription = stringResource(R.string.snippets_edit), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + IconButton( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + deletingSnippet = snippet + }, + ) { + Icon( + painter = painterResource(R.drawable.rounded_delete_24), + contentDescription = stringResource(R.string.snippets_delete), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + } + } + } + } + } + } + } + + item(key = "bottom_spacer") { + Spacer(Modifier.height(8.dp)) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SnippetEditSheet( + snippet: Snippet?, + onDismissRequest: () -> Unit, + onSave: (Snippet) -> Unit, +) { + val view = LocalView.current + var title by remember { mutableStateOf(snippet?.title.orEmpty()) } + var keyword by remember { mutableStateOf(snippet?.keyword.orEmpty()) } + var content by remember { mutableStateOf(snippet?.content.orEmpty()) } + var autoExpandOnSpace by remember { mutableStateOf(snippet?.autoExpandOnSpace ?: true) } + + val isValid = keyword.isNotBlank() && content.isNotBlank() + + EssentialsBottomSheet( + onDismissRequest = onDismissRequest, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = + if (snippet == null) { + stringResource(R.string.snippets_add) + } else { + stringResource(R.string.snippets_edit) + }, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text(stringResource(R.string.snippets_snippet_title)) }, + placeholder = { Text("e.g. My Email") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) + + OutlinedTextField( + value = keyword, + onValueChange = { keyword = it }, + label = { Text(stringResource(R.string.snippets_keyword)) }, + placeholder = { Text("e.g. !email") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) + + OutlinedTextField( + value = content, + onValueChange = { content = it }, + label = { Text(stringResource(R.string.snippets_content)) }, + placeholder = { Text("Expanded snippet text") }, + minLines = 3, + maxLines = 6, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) + + // Variable Insert Chips + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = stringResource(R.string.snippets_quick_insert), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + val variables = listOf("{date}", "{time}", "{clipboard}") + variables.forEach { placeholder -> + AssistChip( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + content += placeholder + }, + label = { Text(placeholder) }, + colors = + AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + shape = RoundedCornerShape(8.dp), + ) + } + } + } + + // Auto-expand Toggle using standard RoundedCardContainer + IconToggleItem + RoundedCardContainer(spacing = 2.dp) { + IconToggleItem( + iconRes = R.drawable.rounded_bolt_24, + title = stringResource(R.string.snippets_auto_expand), + description = stringResource(R.string.snippets_auto_expand_desc), + isChecked = autoExpandOnSpace, + onCheckedChange = { autoExpandOnSpace = it }, + ) + } + + // Save Button + Button( + onClick = { + if (isValid) { + HapticUtil.performVirtualKeyHaptic(view) + val result = + snippet?.copy( + title = title.ifBlank { keyword }, + keyword = keyword.trim(), + content = content, + autoExpandOnSpace = autoExpandOnSpace, + ) ?: Snippet( + title = title.ifBlank { keyword }, + keyword = keyword.trim(), + content = content, + autoExpandOnSpace = autoExpandOnSpace, + ) + onSave(result) + } + }, + enabled = isValid, + modifier = + Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(16.dp), + ) { + Text( + text = stringResource(R.string.snippets_save), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + + Spacer(Modifier.height(8.dp)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SnippetDisplayStyleSheet( + currentMode: String, + onModeSelected: (String) -> Unit, + onDismissRequest: () -> Unit, +) { + val view = LocalView.current + val context = LocalContext.current + val recommendedMode = remember { SettingsRepository(context).getRecommendedSnippetsSuggestionDisplayMode() } + + EssentialsBottomSheet(onDismissRequest = onDismissRequest) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column { + Text( + text = stringResource(R.string.snippets_display_style_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(R.string.snippets_display_style_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + RoundedCardContainer(spacing = 2.dp) { + val options = + listOf( + Triple( + SettingsRepository.SNIPPETS_DISPLAY_NONE, + Pair( + stringResource(R.string.snippets_display_none_title), + stringResource(R.string.snippets_display_none_desc), + ), + R.drawable.rounded_visibility_off_24, + ), + Triple( + SettingsRepository.SNIPPETS_DISPLAY_FLOATING_PILL, + Pair( + stringResource(R.string.snippets_display_pill_title), + stringResource(R.string.snippets_display_pill_desc), + ), + R.drawable.rounded_keyboard_24, + ), + Triple( + SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND, + Pair( + stringResource(R.string.snippets_display_island_title), + stringResource(R.string.snippets_display_island_desc), + ), + R.drawable.rounded_bolt_24, + ), + Triple( + SettingsRepository.SNIPPETS_DISPLAY_DUO, + Pair( + stringResource(R.string.snippets_display_duo_title), + stringResource(R.string.snippets_display_duo_desc), + ), + R.drawable.rounded_circle_24, + ), + Triple( + SettingsRepository.SNIPPETS_DISPLAY_BOTH, + Pair( + stringResource(R.string.snippets_display_both_title), + stringResource(R.string.snippets_display_both_desc), + ), + R.drawable.rounded_auto_awesome_24, + ), + ) + + options.forEach { (mode, textPair, iconRes) -> + val isSelected = currentMode == mode + val isRecommended = mode == recommendedMode + val title = if (isRecommended) "${textPair.first} (${stringResource(R.string.snippets_recommended_tag)})" else textPair.first + IconToggleItem( + iconRes = iconRes, + title = title, + description = textPair.second, + isChecked = isSelected, + showToggle = false, + trailingContent = + if (isSelected) { + { + Icon( + painter = painterResource(R.drawable.rounded_check_24), + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + } + } else { + null + }, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onModeSelected(mode) + onDismissRequest() + }, + onCheckedChange = { + HapticUtil.performVirtualKeyHaptic(view) + onModeSelected(mode) + onDismissRequest() + }, + ) + } + } + + Spacer(Modifier.height(16.dp)) + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt index cb85156c1f..7817dd9dd4 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt @@ -66,6 +66,7 @@ import com.sameerasw.essentials.ui.core.sheets.SometimesEssentialsSettingsSheet import com.sameerasw.essentials.ui.core.sheets.SoundModeSettingsSheet import com.sameerasw.essentials.ui.features.audio.sheets.SetModeSettingsSheet import com.sameerasw.essentials.ui.features.apps.sheets.KeyboardSelectionSheet +import com.sameerasw.essentials.ui.features.apps.sheets.SnippetDisplayStyleSheet import com.sameerasw.essentials.ui.features.audio.sheets.SetVolumeSettingsSheet import com.sameerasw.essentials.ui.features.display.sheets.DuoBatteryOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.DuoBatteryPercentageOptionsBottomSheet @@ -100,6 +101,8 @@ fun DuoSettingsUI( var showNetworkOptionsSheet by remember { mutableStateOf(false) } var showTimeOptionsSheet by remember { mutableStateOf(false) } var showMediaAppSelectionSheet by remember { mutableStateOf(false) } + var showSnippetStyleSheet by remember { mutableStateOf(false) } + var snippetDisplayMode by remember { mutableStateOf(SettingsRepository(context).getSnippetsSuggestionDisplayMode()) } var pickingActionForGesture by remember { mutableStateOf(null) } var activeConfigGesture by remember { mutableStateOf(null) } @@ -481,6 +484,45 @@ fun DuoSettingsUI( }, modifier = Modifier.highlight(highlightSetting == "duo_show_flashlight"), ) + + val isSnippetsDuoActive = snippetDisplayMode == SettingsRepository.SNIPPETS_DISPLAY_DUO || + snippetDisplayMode == SettingsRepository.SNIPPETS_DISPLAY_BOTH + IconToggleItem( + iconRes = R.drawable.rounded_text_snippet_24, + title = stringResource(R.string.label_keyboard_snippets), + description = when (snippetDisplayMode) { + SettingsRepository.SNIPPETS_DISPLAY_NONE -> stringResource(R.string.snippets_display_none_title) + SettingsRepository.SNIPPETS_DISPLAY_DUO -> stringResource(R.string.snippets_display_duo_title) + SettingsRepository.SNIPPETS_DISPLAY_BOTH -> stringResource(R.string.snippets_display_both_title) + SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> stringResource(R.string.snippets_display_island_title) + else -> stringResource(R.string.snippets_display_pill_title) + }, + isChecked = isSnippetsDuoActive, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + val repo = SettingsRepository(context) + if (checked) { + repo.setSnippetsUniversalEnabled(true) + val newMode = if (snippetDisplayMode == SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND) { + SettingsRepository.SNIPPETS_DISPLAY_BOTH + } else { + SettingsRepository.SNIPPETS_DISPLAY_DUO + } + repo.setSnippetsSuggestionDisplayMode(newMode) + snippetDisplayMode = newMode + } else { + val newMode = if (snippetDisplayMode == SettingsRepository.SNIPPETS_DISPLAY_BOTH) { + SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND + } else { + SettingsRepository.SNIPPETS_DISPLAY_FLOATING_PILL + } + repo.setSnippetsSuggestionDisplayMode(newMode) + snippetDisplayMode = newMode + } + }, + onSettingsClick = { showSnippetStyleSheet = true }, + modifier = Modifier.highlight(highlightSetting == "duo_show_snippets"), + ) } Text( @@ -1247,4 +1289,15 @@ fun DuoSettingsUI( context = context, ) } + + if (showSnippetStyleSheet) { + SnippetDisplayStyleSheet( + currentMode = snippetDisplayMode, + onModeSelected = { newMode -> + snippetDisplayMode = newMode + SettingsRepository(context).setSnippetsSuggestionDisplayMode(newMode) + }, + onDismissRequest = { showSnippetStyleSheet = false }, + ) + } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt index 9f643d3141..744c255ac2 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt @@ -68,6 +68,7 @@ import com.sameerasw.essentials.ui.core.cards.IconToggleItem import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.ui.core.sheets.AppSelectionSheet import com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet +import com.sameerasw.essentials.ui.features.apps.sheets.SnippetDisplayStyleSheet import com.sameerasw.essentials.ui.features.consciousgate.CONSCIOUS_GATE_FEATURE_ID import com.sameerasw.essentials.ui.features.display.actions.GestureActionPickerSheet import com.sameerasw.essentials.ui.features.display.actions.HorizontalSlideModeSheet @@ -128,6 +129,8 @@ fun IslandSettingsUI( var showBriefOptionsSheet by remember { mutableStateOf(highlightSetting == "island_brief_show_alarm") } var pickingGesture by remember { mutableStateOf(null) } var showSlideModeSheet by remember { mutableStateOf(false) } + var showSnippetStyleSheet by remember { mutableStateOf(false) } + var snippetDisplayMode by remember { mutableStateOf(SettingsRepository(context).getSnippetsSuggestionDisplayMode()) } if (requestingPermissionsFor != null) { val (titleRes, permKeys) = requestingPermissionsFor!! @@ -239,6 +242,45 @@ fun IslandSettingsUI( modifier = Modifier.fillMaxWidth(), ) } + + val isSnippetsIslandActive = snippetDisplayMode == SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND || + snippetDisplayMode == SettingsRepository.SNIPPETS_DISPLAY_BOTH + IconToggleItem( + iconRes = R.drawable.rounded_text_snippet_24, + title = stringResource(R.string.label_keyboard_snippets), + description = when (snippetDisplayMode) { + SettingsRepository.SNIPPETS_DISPLAY_NONE -> stringResource(R.string.snippets_display_none_title) + SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> stringResource(R.string.snippets_display_island_title) + SettingsRepository.SNIPPETS_DISPLAY_BOTH -> stringResource(R.string.snippets_display_both_title) + SettingsRepository.SNIPPETS_DISPLAY_DUO -> stringResource(R.string.snippets_display_duo_title) + else -> stringResource(R.string.snippets_display_pill_title) + }, + isChecked = isSnippetsIslandActive, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + val repo = SettingsRepository(context) + if (checked) { + repo.setSnippetsUniversalEnabled(true) + val newMode = if (snippetDisplayMode == SettingsRepository.SNIPPETS_DISPLAY_DUO) { + SettingsRepository.SNIPPETS_DISPLAY_BOTH + } else { + SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND + } + repo.setSnippetsSuggestionDisplayMode(newMode) + snippetDisplayMode = newMode + } else { + val newMode = if (snippetDisplayMode == SettingsRepository.SNIPPETS_DISPLAY_BOTH) { + SettingsRepository.SNIPPETS_DISPLAY_DUO + } else { + SettingsRepository.SNIPPETS_DISPLAY_FLOATING_PILL + } + repo.setSnippetsSuggestionDisplayMode(newMode) + snippetDisplayMode = newMode + } + }, + onSettingsClick = { showSnippetStyleSheet = true }, + modifier = Modifier.highlight(highlightSetting == "island_show_snippets"), + ) } RoundedCardContainer( @@ -870,4 +912,15 @@ fun IslandSettingsUI( allowIconEdit = true, ) } + + if (showSnippetStyleSheet) { + SnippetDisplayStyleSheet( + currentMode = snippetDisplayMode, + onModeSelected = { newMode -> + snippetDisplayMode = newMode + SettingsRepository(context).setSnippetsSuggestionDisplayMode(newMode) + }, + onDismissRequest = { showSnippetStyleSheet = false }, + ) + } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt index a5ca7ad2e4..143bfc7b9f 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt @@ -59,6 +59,18 @@ fun KeyboardSettingsUI( var text by remember { mutableStateOf("") } val isKeyboardEnabled by viewModel.isKeyboardEnabled val isKeyboardSelected by viewModel.isKeyboardSelected + val isAccessibilityEnabled by viewModel.isAccessibilityEnabled + var isSnippetsSheetVisible by remember { mutableStateOf(false) } + val settingsRepo = remember { com.sameerasw.essentials.data.repository.SettingsRepository(context) } + var suggestionDisplayMode by remember { mutableStateOf(settingsRepo.getSnippetsSuggestionDisplayMode()) } + var isDisplayStyleSheetVisible by remember { mutableStateOf(false) } + var requestingPermissionsFor by remember { mutableStateOf>?>(null) } + var universalOn by remember { mutableStateOf(settingsRepo.isSnippetsUniversalEnabled()) } + var autoExpandEnabled by remember { mutableStateOf(settingsRepo.isSnippetsUniversalAutoExpandEnabled()) } + + LaunchedEffect(isAccessibilityEnabled) { + universalOn = settingsRepo.isSnippetsUniversalEnabled() + } LaunchedEffect(Unit) { while (true) { @@ -340,6 +352,103 @@ fun KeyboardSettingsUI( onCheckedChange = { viewModel.isUserDictionarySheetVisible.value = true }, ) } + + } + + // Gboard & External Keyboards Section + Text( + text = "Gboard & External Keyboards", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + RoundedCardContainer(spacing = 2.dp) { + IconToggleItem( + iconRes = R.drawable.rounded_keyboard_24, + title = stringResource(R.string.snippets_universal_title), + description = stringResource(R.string.snippets_universal_desc), + isChecked = universalOn && isAccessibilityEnabled, + onCheckedChange = { checked -> + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + if (checked) { + if (!isAccessibilityEnabled) { + requestingPermissionsFor = Pair(R.string.snippets_universal_title, listOf("ACCESSIBILITY")) + } else { + universalOn = true + settingsRepo.setSnippetsUniversalEnabled(true) + } + } else { + universalOn = false + settingsRepo.setSnippetsUniversalEnabled(false) + } + }, + modifier = Modifier.highlight(highlightSetting == "snippets_universal"), + ) + + if (universalOn && isAccessibilityEnabled) { + IconToggleItem( + iconRes = R.drawable.rounded_bolt_24, + title = stringResource(R.string.snippets_universal_auto_expand), + description = stringResource(R.string.snippets_universal_auto_expand_desc), + isChecked = autoExpandEnabled, + onCheckedChange = { checked -> + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + autoExpandEnabled = checked + settingsRepo.setSnippetsUniversalAutoExpandEnabled(checked) + }, + modifier = Modifier.highlight(highlightSetting == "snippets_universal_auto_expand"), + ) + + val modeLabel = when (suggestionDisplayMode) { + com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_NONE -> stringResource(R.string.snippets_display_none_title) + com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> stringResource(R.string.snippets_display_island_title) + com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_DUO -> stringResource(R.string.snippets_display_duo_title) + com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_BOTH -> stringResource(R.string.snippets_display_both_title) + else -> stringResource(R.string.snippets_display_pill_title) + } + val modeIcon = when (suggestionDisplayMode) { + com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_NONE -> R.drawable.rounded_visibility_off_24 + com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> R.drawable.rounded_bolt_24 + com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_DUO -> R.drawable.rounded_circle_24 + com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_BOTH -> R.drawable.rounded_auto_awesome_24 + else -> R.drawable.rounded_keyboard_24 + } + + IconToggleItem( + iconRes = modeIcon, + title = stringResource(R.string.snippets_display_style_title), + description = modeLabel, + isChecked = false, + showToggle = false, + onClick = { + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + isDisplayStyleSheetVisible = true + }, + onCheckedChange = { + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + isDisplayStyleSheetVisible = true + }, + modifier = Modifier.highlight(highlightSetting == "snippets_display_style"), + ) + } + + IconToggleItem( + iconRes = R.drawable.rounded_text_snippet_24, + title = stringResource(R.string.label_keyboard_snippets), + description = stringResource(R.string.desc_keyboard_snippets), + isChecked = false, + showToggle = false, + onClick = { + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + isSnippetsSheetVisible = true + }, + onCheckedChange = { + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + isSnippetsSheetVisible = true + }, + modifier = Modifier.highlight(highlightSetting == "keyboard_snippets"), + ) } Spacer(Modifier.height(32.dp)) @@ -350,5 +459,46 @@ fun KeyboardSettingsUI( onDismissRequest = { viewModel.isUserDictionarySheetVisible.value = false }, ) } + + if (isSnippetsSheetVisible) { + com.sameerasw.essentials.ui.features.apps.sheets.SnippetsBottomSheet( + onDismissRequest = { + isSnippetsSheetVisible = false + suggestionDisplayMode = settingsRepo.getSnippetsSuggestionDisplayMode() + }, + ) + } + + if (isDisplayStyleSheetVisible) { + com.sameerasw.essentials.ui.features.apps.sheets.SnippetDisplayStyleSheet( + currentMode = suggestionDisplayMode, + onModeSelected = { newMode -> + suggestionDisplayMode = newMode + settingsRepo.setSnippetsSuggestionDisplayMode(newMode) + }, + onDismissRequest = { isDisplayStyleSheetVisible = false }, + ) + } + + if (requestingPermissionsFor != null) { + val (titleRes, permKeys) = requestingPermissionsFor!! + val permissionItems = com.sameerasw.essentials.utils.PermissionUIHelper.getPermissionItems( + permKeys, + context, + viewModel, + ) + com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet( + onDismissRequest = { + requestingPermissionsFor = null + viewModel.check(context) + if (viewModel.isAccessibilityEnabled.value) { + universalOn = true + settingsRepo.setSnippetsUniversalEnabled(true) + } + }, + featureTitle = stringResource(titleRes), + permissions = permissionItems, + ) + } } } 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 4f578aae6d..517e1bb3f3 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 @@ -781,6 +781,7 @@ fun KeyboardInputView( ) { i -> val suggestion = mergedSuggestions[i] val isLearned = suggestion.type == SuggestionType.Learned + val isSnippet = suggestion.type == SuggestionType.Snippet val suggInteraction = remember { MutableInteractionSource() } val animatedRadius by animateDpAsState( targetValue = keyRoundness, @@ -797,14 +798,20 @@ fun KeyboardInputView( onPress = { performLightHaptic() }, interactionSource = suggInteraction, containerColor = - if (isLearned) { - MaterialTheme.colorScheme.secondaryContainer + when { + isSnippet -> MaterialTheme.colorScheme.tertiaryContainer + isLearned -> MaterialTheme.colorScheme.secondaryContainer + else -> + MaterialTheme.colorScheme.secondaryContainer.copy( + alpha = 0.7f, + ) + }, + contentColor = + if (isSnippet) { + MaterialTheme.colorScheme.onTertiaryContainer } else { - MaterialTheme.colorScheme.secondaryContainer.copy( - alpha = 0.7f, - ) + MaterialTheme.colorScheme.onSecondaryContainer }, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, shape = RoundedCornerShape(animatedRadius), modifier = Modifier @@ -812,13 +819,49 @@ fun KeyboardInputView( .fillMaxWidth() .maskClip(RoundedCornerShape(animatedRadius)), ) { - Text( - text = suggestion.text, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - fontFamily = CustomFontFamily, - maxLines = 1, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(horizontal = 8.dp), + ) { + if (isSnippet && suggestion.snippet != null) { + Icon( + painter = painterResource(id = R.drawable.rounded_text_snippet_24), + contentDescription = "Snippet", + modifier = Modifier.size(16.dp), + ) + Text( + text = suggestion.snippet.keyword, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + fontFamily = CustomFontFamily, + maxLines = 1, + ) + if (suggestion.snippet.title.isNotBlank() && suggestion.snippet.title != suggestion.snippet.keyword) { + Text( + text = "• ${suggestion.snippet.title}", + style = MaterialTheme.typography.bodySmall, + fontFamily = CustomFontFamily, + maxLines = 1, + ) + } + } else { + if (isSnippet) { + Icon( + painter = painterResource(id = R.drawable.rounded_text_snippet_24), + contentDescription = "Snippet", + modifier = Modifier.size(16.dp), + ) + } + Text( + text = suggestion.text, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + fontFamily = CustomFontFamily, + maxLines = 1, + ) + } + } } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/ime/snippets/SnippetFloatingPillView.kt b/app/src/main/java/com/sameerasw/essentials/ui/ime/snippets/SnippetFloatingPillView.kt new file mode 100644 index 0000000000..9fec19807d --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/ime/snippets/SnippetFloatingPillView.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: IME - Snippets + * File: SnippetFloatingPillView.kt + * Description: Floating suggestion pill overlay above external keyboards styled to match Essentials' floating island design. + */ + +package com.sameerasw.essentials.ui.ime.snippets + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.ime.snippets.Snippet + +@Composable +fun SnippetFloatingPillView( + snippet: Snippet, + onExpand: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val view = androidx.compose.ui.platform.LocalView.current + AnimatedVisibility( + visible = true, + enter = fadeIn() + scaleIn(initialScale = 0.88f), + exit = fadeOut() + scaleOut(targetScale = 0.88f), + ) { + Surface( + modifier = + modifier + .shadow(elevation = 10.dp, shape = CircleShape) + .clip(CircleShape) + .clickable { + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + onExpand() + }, + color = MaterialTheme.colorScheme.surfaceBright, + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 6.dp, + ) { + Row( + modifier = Modifier.padding(start = 12.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Snippet Icon Badge + Box( + modifier = + Modifier + .size(26.dp) + .background( + color = MaterialTheme.colorScheme.primaryContainer, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.rounded_text_snippet_24), + contentDescription = null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + + // Keyword Badge + Box( + modifier = + Modifier + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.tertiaryContainer) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = snippet.keyword, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + + // Title or Content Preview + Text( + text = snippet.title.ifBlank { snippet.content }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 160.dp), + ) + + // Dismiss "X" touch target + Box( + modifier = + Modifier + .size(24.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .clickable { + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + onDismiss() + }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.rounded_close_24), + contentDescription = "Dismiss", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt b/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt index 52562c277e..b4301fc364 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt @@ -531,9 +531,19 @@ class DuoOverlayView(context: Context) : View(context) { const val INTERACTIVE_MODE_BRIGHTNESS = 2 const val INTERACTIVE_MODE_TRACK = 3 const val INTERACTIVE_MODE_SOUND_MODE = 4 + const val INTERACTIVE_MODE_SNIPPET = 5 const val MEDIA_ART_SPIN_DURATION_MS = 32000L } + val isSnippetActive: Boolean + get() = interactiveMode == INTERACTIVE_MODE_SNIPPET + + private var snippetKeyword: String = "" + private var snippetTitle: String = "" + private var snippetPillFraction: Float = 0f + private var snippetPillAnimator: ValueAnimator? = null + val snippetPillRect = RectF() + private var interactiveMode: Int = INTERACTIVE_MODE_NONE private var interactiveProgress: Float = 0f private var interactiveIcon: Bitmap? = null @@ -685,6 +695,32 @@ class DuoOverlayView(context: Context) : View(context) { updateProgressAnimation(interactiveProgress) } + fun setInteractiveSnippet(keyword: String = "", title: String = "") { + interactiveMode = INTERACTIVE_MODE_SNIPPET + interactiveProgress = 100f + interactiveIcon = getThemedBitmap(R.drawable.rounded_text_snippet_24) + snippetKeyword = keyword + snippetTitle = title + + removeCallbacks(revertInteractiveRunnable) + postDelayed(revertInteractiveRunnable, 4500L) + + snippetPillAnimator?.cancel() + snippetPillAnimator = ValueAnimator.ofFloat(snippetPillFraction, 1.0f).apply { + duration = 320 + interpolator = OvershootInterpolator(1.2f) + addUpdateListener { anim -> + snippetPillFraction = anim.animatedValue as Float + invalidate() + } + start() + } + + updateActiveProgressMode() + updateProgressAnimation(100f) + triggerTapAnimation() + } + fun setInteractiveTrackRotation(rotationDegrees: Float) { trackRotationAnimator?.cancel() interactiveTrackRotation = rotationDegrees @@ -709,11 +745,35 @@ class DuoOverlayView(context: Context) : View(context) { fun resetInteractiveState(animate: Boolean = true) { removeCallbacks(revertInteractiveRunnable) - if (interactiveMode == INTERACTIVE_MODE_NONE) return + if (interactiveMode == INTERACTIVE_MODE_NONE && snippetPillFraction <= 0.01f) return interactiveMode = INTERACTIVE_MODE_NONE interactiveIcon = null releaseTrackRotation() updateActiveProgressMode() + + snippetPillAnimator?.cancel() + if (animate && snippetPillFraction > 0.01f) { + snippetPillAnimator = ValueAnimator.ofFloat(snippetPillFraction, 0f).apply { + duration = 240 + interpolator = DecelerateInterpolator() + addUpdateListener { anim -> + snippetPillFraction = anim.animatedValue as Float + if (snippetPillFraction <= 0.01f) { + snippetKeyword = "" + snippetTitle = "" + snippetPillRect.setEmpty() + } + invalidate() + } + start() + } + } else { + snippetPillFraction = 0f + snippetKeyword = "" + snippetTitle = "" + snippetPillRect.setEmpty() + invalidate() + } } private fun getChargingBoltBitmap(): Bitmap? { @@ -1450,29 +1510,31 @@ class DuoOverlayView(context: Context) : View(context) { style = Paint.Style.STROKE } - private val notificationPillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + private val snippetPillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL - color = Color.BLACK + color = Color.parseColor("#1C1B1F") } - private val notificationPillBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + private val snippetPillBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE strokeWidth = 1f * density color = Color.argb(45, 255, 255, 255) } - private val notificationTitlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + private val snippetKeywordPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) letterSpacing = -0.01f } - private val notificationBodyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.WHITE - typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + private val snippetTitlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#C4C6D0") + typeface = Typeface.DEFAULT letterSpacing = -0.01f } + private val snippetIconPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) + init { val (track, progress, dot) = getTargetColors() currentTrackColor = track @@ -1965,8 +2027,127 @@ class DuoOverlayView(context: Context) : View(context) { } } - canvas.restore() } + + if (snippetPillFraction > 0.01f) { + drawSnippetPill(canvas) + } + } + + private fun drawSnippetPill(canvas: Canvas) { + if (snippetPillFraction <= 0.01f) return + + val displayMetrics = resources.displayMetrics + val screenWidth = displayMetrics.widthPixels.toFloat() + + val pillHeight = 32f * density + val pillCornerRadius = pillHeight / 2f + val iconSize = 16f * density + val paddingHorizontal = 12f * density + val spacing = 6f * density + + val kwTextSize = 12.5f * density + val titleTextSize = 11.5f * density + snippetKeywordPaint.textSize = kwTextSize + snippetTitlePaint.textSize = titleTextSize + + val keywordText = snippetKeyword + val titleText = snippetTitle + + val kwWidth = if (keywordText.isNotEmpty()) snippetKeywordPaint.measureText(keywordText) else 0f + val hasSeparator = keywordText.isNotEmpty() && titleText.isNotEmpty() + val separator = " • " + val sepWidth = if (hasSeparator) snippetTitlePaint.measureText(separator) else 0f + var titleWidth = if (titleText.isNotEmpty()) snippetTitlePaint.measureText(titleText) else 0f + + val maxAllowedWidth = screenWidth - (32f * density) + var totalContentWidth = paddingHorizontal * 2 + iconSize + spacing + kwWidth + (if (hasSeparator) sepWidth + titleWidth else 0f) + + var displayTitle = titleText + if (totalContentWidth > maxAllowedWidth) { + val availableForTitle = (maxAllowedWidth - (paddingHorizontal * 2 + iconSize + spacing + kwWidth + sepWidth)).coerceAtLeast(0f) + if (availableForTitle > 30f * density) { + displayTitle = android.text.TextUtils.ellipsize( + titleText, + android.text.TextPaint(snippetTitlePaint), + availableForTitle, + android.text.TextUtils.TruncateAt.END + ).toString() + titleWidth = snippetTitlePaint.measureText(displayTitle) + } else { + displayTitle = "" + titleWidth = 0f + } + totalContentWidth = paddingHorizontal * 2 + iconSize + spacing + kwWidth + (if (displayTitle.isNotEmpty()) sepWidth + titleWidth else 0f) + } + + val pillWidth = totalContentWidth.coerceIn(48f * density, maxAllowedWidth) + + val minCenterX = pillWidth / 2f + 16f * density + val maxCenterX = screenWidth - pillWidth / 2f - 16f * density + val pillCenterX = cameraCenterX.coerceIn(minCenterX, maxCenterX) + + val pillLeft = pillCenterX - pillWidth / 2f + val pillRight = pillCenterX + pillWidth / 2f + + val baseRadius = (cameraRadiusPx + arcThicknessPx / 2f + 2f * density) * ringRadiusScale + val dropDistance = 10f * density + (12f * density * snippetPillFraction) + val pillTop = cameraCenterY + baseRadius + dropDistance + val pillBottom = pillTop + pillHeight + + snippetPillRect.set(pillLeft, pillTop, pillRight, pillBottom) + + val alphaInt = (255 * snippetPillFraction).toInt().coerceIn(0, 255) + if (alphaInt <= 0) return + + val saveCount = canvas.save() + val scale = 0.85f + 0.15f * snippetPillFraction + canvas.scale(scale, scale, pillCenterX, (pillTop + pillBottom) / 2f) + + snippetPillPaint.color = Color.argb((alphaInt * 0.96f).toInt(), 28, 27, 31) + canvas.drawRoundRect(snippetPillRect, pillCornerRadius, pillCornerRadius, snippetPillPaint) + + val borderAlpha = (alphaInt * 0.35f).toInt() + val borderColor = if (isDarkTheme) Color.WHITE else Color.BLACK + snippetPillBorderPaint.color = Color.argb(borderAlpha, Color.red(borderColor), Color.green(borderColor), Color.blue(borderColor)) + canvas.drawRoundRect(snippetPillRect, pillCornerRadius, pillCornerRadius, snippetPillBorderPaint) + + var currentX = pillLeft + paddingHorizontal + val iconY = pillTop + (pillHeight - iconSize) / 2f + val icon = interactiveIcon ?: getThemedBitmap(R.drawable.rounded_text_snippet_24) + if (icon != null) { + val iconRectF = RectF(currentX, iconY, currentX + iconSize, iconY + iconSize) + snippetIconPaint.alpha = alphaInt + snippetIconPaint.colorFilter = PorterDuffColorFilter(currentProgressColor, PorterDuff.Mode.SRC_IN) + canvas.drawBitmap(icon, null, iconRectF, snippetIconPaint) + currentX += iconSize + spacing + } + + val textCenterY = (pillTop + pillBottom) / 2f + val kwBounds = android.graphics.Rect() + if (keywordText.isNotEmpty()) { + snippetKeywordPaint.color = Color.argb(alphaInt, 255, 255, 255) + snippetKeywordPaint.getTextBounds(keywordText, 0, keywordText.length, kwBounds) + val kwBaseline = textCenterY - kwBounds.exactCenterY() + canvas.drawText(keywordText, currentX, kwBaseline, snippetKeywordPaint) + currentX += kwWidth + } + + if (displayTitle.isNotEmpty()) { + snippetTitlePaint.color = Color.argb((alphaInt * 0.72f).toInt(), 200, 205, 215) + if (hasSeparator) { + snippetTitlePaint.getTextBounds(separator, 0, separator.length, kwBounds) + val sepBaseline = textCenterY - kwBounds.exactCenterY() + canvas.drawText(separator, currentX, sepBaseline, snippetTitlePaint) + currentX += sepWidth + } + + snippetTitlePaint.getTextBounds(displayTitle, 0, displayTitle.length, kwBounds) + val titleBaseline = textCenterY - kwBounds.exactCenterY() + canvas.drawText(displayTitle, currentX, titleBaseline, snippetTitlePaint) + } + + canvas.restoreToCount(saveCount) } override fun onAttachedToWindow() { @@ -2000,6 +2181,8 @@ class DuoOverlayView(context: Context) : View(context) { tapAnimator?.cancel() pullDownAnimator?.cancel() trackRotationAnimator?.cancel() + snippetPillAnimator?.cancel() + snippetPillAnimator = null } } diff --git a/app/src/main/res/drawable/rounded_text_snippet_24.xml b/app/src/main/res/drawable/rounded_text_snippet_24.xml new file mode 100644 index 0000000000..0b85e4e2cc --- /dev/null +++ b/app/src/main/res/drawable/rounded_text_snippet_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a8ad4fef2b..14698ca5a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3507,7 +3507,6 @@ Open on Watch From previous updates - Weather is Now Separate Weather has been separated into my brand new app, Overcast. Install to continue having weather forecasts and effects in Island brief. @@ -3566,4 +3565,45 @@ Grant Done Widget + + + Text snippets + Manage Raycast-style expansion shortcuts + Text snippets + Search snippets… + No snippets yet + Add snippet + Edit snippet + Keyword + Title + Expanded text + Auto-expand on space + Expands immediately when typing space after keyword + Insert variable + Save + Delete + Are you sure you want to delete this snippet? + Cancel + External keyboard support + Expand snippets across Gboard, SwiftKey, and any other keyboard using accessibility + Instant auto-expand + Instantly expands keyword on Space in any app + Floating suggestion pill + Show floating pill above keyboard when trigger prefix is typed + Suggestion display style + Choose where snippet suggestions appear when typing + None (Silent) + Expand on space only without showing suggestion overlays + Floating Pill + Floating chip positioned directly above keyboard + Dynamic Island + Subtle line peek around camera cutout at top of screen + Duo Camera Ring + Ambient camera ring pulse and glow around punch-hole + Dynamic Island & Duo + Simultaneous suggestions on Dynamic Island and Duo camera ring + Recommended + Insert + Copy + Copied to clipboard diff --git a/app/src/test/java/com/sameerasw/essentials/ime/SnippetExpanderTest.kt b/app/src/test/java/com/sameerasw/essentials/ime/SnippetExpanderTest.kt new file mode 100644 index 0000000000..38348663f5 --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/ime/SnippetExpanderTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Unit Tests - IME + * File: SnippetExpanderTest.kt + * Description: Unit tests for SnippetExpander dynamic placeholder evaluation. + */ + +package com.sameerasw.essentials.ime + +import com.sameerasw.essentials.ime.snippets.SnippetExpander +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale + +class SnippetExpanderTest { + @Test + fun expand_staticText_returnsUnchanged() { + val template = "Hello, world!" + val result = SnippetExpander.expand(template, null) + assertEquals("Hello, world!", result) + } + + @Test + fun expand_datePlaceholder_replacesWithFormattedDate() { + val today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.getDefault())) + val template = "Today is {date}." + val result = SnippetExpander.expand(template, null) + assertEquals("Today is $today.", result) + } + + @Test + fun expand_timePlaceholder_replacesWithTime() { + val template = "Current time is {time}." + val result = SnippetExpander.expand(template, null) + assertTrue(result.contains("AM") || result.contains("PM")) + } + + @Test + fun expand_nullContextClipboard_replacesWithEmptyString() { + val template = "Quoted: {clipboard}" + val result = SnippetExpander.expand(template, null) + assertEquals("Quoted: ", result) + } +} diff --git a/app/src/test/java/com/sameerasw/essentials/ime/SnippetMatchingTest.kt b/app/src/test/java/com/sameerasw/essentials/ime/SnippetMatchingTest.kt new file mode 100644 index 0000000000..033b1707fe --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/ime/SnippetMatchingTest.kt @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Unit Tests - IME + * File: SnippetMatchingTest.kt + * Description: Unit tests for Raycast-style snippet prefix matching and case-insensitivity. + */ + +package com.sameerasw.essentials.ime + +import com.sameerasw.essentials.ime.snippets.Snippet +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SnippetMatchingTest { + + private val snippets = listOf( + Snippet(title = "Current Date", keyword = "!date", content = "{date}"), + Snippet(title = "Shrug", keyword = "!shrug", content = "¯\\_(ツ)_/¯"), + Snippet(title = "Email Address", keyword = "/email", content = "user@example.com"), + Snippet(title = "Phone Number", keyword = ":phone", content = "+123456789"), + Snippet(title = "Meeting Link", keyword = ";meet", content = "https://meet.google.com/xyz"), + ) + + private fun findMatching(word: String, list: List): List { + val query = word.trim() + if (query.isEmpty()) return emptyList() + + val isTriggerPrefix = query.startsWith("!") || query.startsWith("/") || query.startsWith(":") || query.startsWith(";") + return list.filter { snippet -> + snippet.keyword.equals(query, ignoreCase = true) || + (isTriggerPrefix && snippet.keyword.startsWith(query, ignoreCase = true)) + } + } + + @Test + fun findMatching_exactKeyword_matchesCaseInsensitive() { + val resultsLower = findMatching("!date", snippets) + assertEquals(1, resultsLower.size) + assertEquals("!date", resultsLower.first().keyword) + + val resultsUpper = findMatching("!DATE", snippets) + assertEquals(1, resultsUpper.size) + assertEquals("!date", resultsUpper.first().keyword) + } + + @Test + fun findMatching_triggerPrefix_matchesCandidates() { + val resultsExclamation = findMatching("!", snippets) + assertEquals(2, resultsExclamation.size) + + val resultsSlash = findMatching("/em", snippets) + assertEquals(1, resultsSlash.size) + assertEquals("/email", resultsSlash.first().keyword) + + val resultsColon = findMatching(":ph", snippets) + assertEquals(1, resultsColon.size) + assertEquals(":phone", resultsColon.first().keyword) + + val resultsSemicolon = findMatching(";me", snippets) + assertEquals(1, resultsSemicolon.size) + assertEquals(";meet", resultsSemicolon.first().keyword) + } + + @Test + fun findMatching_ordinaryText_doesNotTriggerSubstringMatching() { + // Normal typing like "the", "date", "shrug" should not trigger prefix matching unless explicit trigger prefix is typed + val resultsOrdinary = findMatching("sh", snippets) + assertTrue(resultsOrdinary.isEmpty()) + + val resultsTitleSubstring = findMatching("Date", snippets) + assertTrue(resultsTitleSubstring.isEmpty()) + } + + @Test + fun findMatching_emptyQuery_returnsEmpty() { + val results = findMatching(" ", snippets) + assertTrue(results.isEmpty()) + } + + private fun calculateReplacementIndex(fullText: String, cursor: Int, targetWord: String): Int { + return if (cursor in targetWord.length..fullText.length && + fullText.substring(cursor - targetWord.length, cursor).equals(targetWord, ignoreCase = true) + ) { + cursor - targetWord.length + } else if (fullText.endsWith(targetWord, ignoreCase = true)) { + fullText.length - targetWord.length + } else if (fullText.trimEnd().endsWith(targetWord.trimEnd(), ignoreCase = true)) { + val trimmed = fullText.trimEnd() + trimmed.length - targetWord.trimEnd().length + } else { + -1 + } + } + + @Test + fun replaceKeyword_cursorAtEnd_calculatesCorrectIndex() { + val text = "Meeting today at !time " + val index = calculateReplacementIndex(text, text.length, "!time ") + assertEquals(text.indexOf("!time "), index) + } + + @Test + fun replaceKeyword_withTrailingWhitespace_calculatesCorrectIndex() { + val text = "Search !date " + val index = calculateReplacementIndex(text, -1, "!date") + assertEquals(text.indexOf("!date"), index) + } + + @Test + fun replaceKeyword_cursorInMiddle_doesNotReplaceEarlierOccurrence() { + val text = "!date and some text and !date" + val secondIndex = text.lastIndexOf("!date") + val index = calculateReplacementIndex(text, text.length, "!date") + assertEquals(secondIndex, index) + } + + @Test + fun replaceKeyword_cursorMismatched_returnsNegativeOne() { + val text = "Here is !date and typing here" + val index = calculateReplacementIndex(text, text.length, "!date") + assertEquals(-1, index) + } + + private fun buildReplacementText(fullText: String, cursor: Int, targetWord: String, replacement: String): String? { + val targetIndex = + if (cursor in targetWord.length..fullText.length && + fullText.substring(cursor - targetWord.length, cursor).equals(targetWord, ignoreCase = true) + ) { + cursor - targetWord.length + } else if (fullText.endsWith(targetWord, ignoreCase = true)) { + fullText.length - targetWord.length + } else if (fullText.trimEnd().endsWith(targetWord.trimEnd(), ignoreCase = true)) { + val trimmed = fullText.trimEnd() + trimmed.length - targetWord.trimEnd().length + } else { + return null + } + + val actualWordEnd = + if (fullText.trimEnd().endsWith(targetWord.trimEnd(), ignoreCase = true)) { + fullText.length + } else { + (targetIndex + targetWord.length).coerceAtMost(fullText.length) + } + + return fullText.substring(0, targetIndex) + replacement + fullText.substring(actualWordEnd) + } + + @Test + fun buildReplacementText_replacesCleanlyWithoutDoubleTrailingSpaces() { + val text = "Search !date " + val replaced = buildReplacementText(text, -1, "!date", "2026-10-18 ") + assertEquals("Search 2026-10-18 ", replaced) + } + + @Test + fun buildReplacementText_middleCursor_preservesSurroundingText() { + val text = "Before !date after" + val cursor = text.indexOf("!date") + "!date".length + val replaced = buildReplacementText(text, cursor, "!date", "2026-10-18 ") + assertEquals("Before 2026-10-18 after", replaced) + } +}