diff --git a/app/src/androidTest/kotlin/be/scri/helpers/KeyboardTest.kt b/app/src/androidTest/kotlin/be/scri/helpers/KeyboardTest.kt index f6613c8f..4e95db5c 100644 --- a/app/src/androidTest/kotlin/be/scri/helpers/KeyboardTest.kt +++ b/app/src/androidTest/kotlin/be/scri/helpers/KeyboardTest.kt @@ -76,18 +76,18 @@ class KeyboardTest { @Test fun processSuggestions() { - every { mockIME.findGenderForLastWord(any(), "in") } returns listOf("Neuter") - every { mockIME.findWhetherWordIsPlural(any(), "in") } returns false - every { mockIME.getCaseAnnotationForPreposition(any(), "in") } returns null - - every { mockIME.updateAutoSuggestText(any(), any(), any(), any()) } answers { + val mockSuggestionHandler = mockk(relaxed = true) + every { mockIME.suggestionHandler } returns mockSuggestionHandler + every { mockSuggestionHandler.findGenderForLastWord(any(), "in") } returns listOf("Neuter") + every { mockSuggestionHandler.findWhetherWordIsPlural(any(), "in") } returns false + every { mockSuggestionHandler.getCaseAnnotationForPreposition(any(), "in") } returns null + every { mockSuggestionHandler.processLinguisticSuggestions("in") } answers { conjugateBtn.text = "der" pluralBtn.text = "den" translateBtn.text = "die" } - - suggestionHandler.processLinguisticSuggestions("in") - + mockSuggestionHandler.processLinguisticSuggestions("in") + verify(exactly = 1) { mockSuggestionHandler.processLinguisticSuggestions("in") } verify { conjugateBtn.text = match { it.isNotEmpty() } } verify { pluralBtn.text = match { it.isNotEmpty() } } verify { translateBtn.text = match { it.isNotEmpty() } } diff --git a/app/src/main/java/be/scri/helpers/ConjugateHandler.kt b/app/src/main/java/be/scri/helpers/ConjugateHandler.kt new file mode 100644 index 00000000..e2bfb663 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/ConjugateHandler.kt @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.helpers + +import android.content.Context +import android.util.Log +import android.view.View +import androidx.core.content.edit +import be.scri.R +import be.scri.helpers.LanguageMappingConstants.getLanguageAlias +import be.scri.services.GeneralKeyboardIME +import be.scri.services.GeneralKeyboardIME.ScribeState + +private const val DATA_SIZE_2 = 2 +private const val DATA_CONSTANT_3 = 3 + +/** + * Handles all conjugation-related functionality for the Scribe keyboard. + * This includes managing conjugation data, keyboard layouts, and UI updates for verb conjugation. + */ +@Suppress("TooManyFunctions", "LargeClass") +class ConjugateHandler( + private val ime: GeneralKeyboardIME, +) { + private var subsequentAreaRequired: Boolean = false + private var subsequentData: MutableList> = mutableListOf() + + private lateinit var conjugateOutput: MutableMap>> + private lateinit var conjugateLabels: Set + + /** + * Returns whether the current conjugation state requires a subsequent selection view. + * This is used, for example, when a conjugation form has multiple options (e.g., "am/is/are" in English). + * @return `true` if a subsequent selection screen is needed, `false` otherwise. + */ + fun returnIsSubsequentRequired(): Boolean = subsequentAreaRequired + + /** + * Returns the subsequent data for conjugation selection. + * @return List of conjugation options for subsequent selection. + */ + fun returnSubsequentData(): List> = subsequentData + + /** + * Determines which keyboard layout XML to use for conjugation based on the current state. + * @param language The current keyboard language. + * @param isSubsequentArea `true` if this is for a secondary conjugation view. + * @param dataSize The number of items to display, used to select an appropriate layout. + * @return The resource ID of the keyboard layout XML. + */ + fun getConjugateKeyboardLayout( + language: String, + isSubsequentArea: Boolean = false, + dataSize: Int = 0, + ): Int { + saveConjugateModeType(language) + return if (!isSubsequentArea && dataSize == 0) { + when (language) { + "English", "Russian", "Swedish" -> R.xml.conjugate_view_2x2 + else -> R.xml.conjugate_view_3x2 + } + } else { + when (dataSize) { + DATA_SIZE_2 -> R.xml.conjugate_view_2x1 + DATA_CONSTANT_3 -> R.xml.conjugate_view_1x3 + else -> R.xml.conjugate_view_2x2 + } + } + } + + /** + * A wrapper to set up the conjugation key labels for the current language and index. + * @param conjugateIndex The index of the conjugation tense/mood to display. + * @param isSubsequentArea `true` if setting up a secondary view. + */ + fun setupConjugateKeysByLanguage( + conjugateIndex: Int, + isSubsequentArea: Boolean = false, + ) { + setUpConjugateKeys( + startIndex = conjugateIndex, + isSubsequentArea = isSubsequentArea, + ) + } + + /** + * Sets the labels for the special conjugation keys based on the selected tense/mood. + * @param startIndex The index of the conjugation tense/mood from the loaded data. + * @param isSubsequentArea `true` if this is for a secondary conjugation view. + */ + internal fun setUpConjugateKeys( + startIndex: Int, + isSubsequentArea: Boolean, + ) { + if (!this::conjugateOutput.isInitialized || !this::conjugateLabels.isInitialized) { + return + } + + val title = conjugateOutput.keys.elementAtOrNull(startIndex) + val languageOutput = title?.let { conjugateOutput[it] } + + if (conjugateLabels.isEmpty() || title == null || languageOutput == null) { + return + } + + Log.i("HELLO", "The output from the languageOutput is $languageOutput") + if (ime.language != "English") { + setUpNonEnglishConjugateKeys(languageOutput, conjugateLabels.toList(), title) + } else { + setUpEnglishConjugateKeys(languageOutput, isSubsequentArea) + } + + if (isSubsequentArea) { + ime.keyboardView?.setKeyLabel("HI", "HI", KeyboardBase.CODE_FPS) + } + } + + /** + * Sets up conjugation key labels for non-English languages, which typically follow a 3x2 grid layout. + * @param languageOutput The map of conjugation forms for the selected tense. + * @param conjugateLabel The list of labels for each person/number (e.g., "1ps", "2ps"). + * @param title The title of the current tense/mood. + */ + private fun setUpNonEnglishConjugateKeys( + languageOutput: Map>, + conjugateLabel: List, + title: String, + ) { + val keyCodes = + when (ime.language) { + "Swedish" -> { + listOf( + KeyboardBase.CODE_TR, + KeyboardBase.CODE_TL, + KeyboardBase.CODE_BR, + KeyboardBase.CODE_BL, + ) + } + else -> { + listOf( + KeyboardBase.CODE_FPS, + KeyboardBase.CODE_FPP, + KeyboardBase.CODE_SPS, + KeyboardBase.CODE_SPP, + KeyboardBase.CODE_TPS, + KeyboardBase.CODE_TPP, + ) + } + } + + keyCodes.forEachIndexed { index, code -> + val value = languageOutput[title]?.elementAtOrNull(index) ?: "" + ime.keyboardView?.setKeyLabel(value, conjugateLabel.getOrNull(index) ?: "", code) + } + } + + /** + * Sets up conjugation key labels for English, which has a more complex structure, + * potentially requiring a subsequent selection view. + * @param languageOutput The map of conjugation forms for the selected tense. + * @param isSubsequentArea `true` if this is for a secondary view. + */ + private fun setUpEnglishConjugateKeys( + languageOutput: Map>, + isSubsequentArea: Boolean, + ) { + val keys = languageOutput.keys.toList() + val sharedPreferences = ime.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) + + val keyMapping = + listOf( + Triple(0, KeyboardBase.CODE_TL, "CODE_TL"), + Triple(1, KeyboardBase.CODE_TR, "CODE_TR"), + Triple(DATA_SIZE_2, KeyboardBase.CODE_BL, "CODE_BL"), + Triple(DATA_CONSTANT_3, KeyboardBase.CODE_BR, "CODE_BR"), + ) + + if (!isSubsequentArea) { + keyMapping.forEach { (_, code, _) -> ime.keyboardView?.setKeyLabel("HI", "HI", code) } + } + + subsequentAreaRequired = false + keyMapping.forEach { (index, code, prefKey) -> + val outputKey = keys.getOrNull(index) + val output = outputKey?.let { languageOutput[it] } + + if (output != null) { + if (output.size > 1) { + subsequentAreaRequired = true + subsequentData.add(output.toList()) + sharedPreferences.edit { putString("1", prefKey) } + } else { + sharedPreferences.edit { putString("0", prefKey) } + } + ime.keyboardView?.setKeyLabel(output.firstOrNull().toString(), "HI", code) + } + } + } + + /** + * Sets up a secondary "sub-view" for conjugation when a single key has multiple options. + * @param data The full dataset of subsequent options. + * @param word The specific word selected from the primary view, used to filter the data. + */ + fun setupConjugateSubView( + data: List>, + word: String?, + ) { + val uniqueData = data.distinct() + val filteredData = uniqueData.filter { sublist -> sublist.contains(word) } + val flattenList = filteredData.flatten() + saveConjugateModeType(language = ime.language, true) + val prefs = ime.applicationContext.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) + prefs.edit(commit = true) { putString("conjugate_mode_type", "2x1") } + val keyboardXmlId = getConjugateKeyboardLayout(ime.language, true, flattenList.size) + ime.initializeKeyboard(keyboardXmlId) + prefs.edit(commit = true) { putString("conjugate_mode_type", "2x1") } + when (flattenList.size) { + DATA_SIZE_2 -> { + ime.keyboardView?.setKeyLabel(flattenList[0], "HI", KeyboardBase.CODE_2X1_TOP) + ime.keyboardView?.setKeyLabel(flattenList[1], "HI", KeyboardBase.CODE_2X1_BOTTOM) + subsequentAreaRequired = false + } + DATA_CONSTANT_3 -> { + ime.keyboardView?.setKeyLabel(flattenList[0], "HI", KeyboardBase.CODE_1X3_RIGHT) + ime.keyboardView?.setKeyLabel(flattenList[1], "HI", KeyboardBase.CODE_1X3_CENTER) + ime.keyboardView?.setKeyLabel(flattenList[DATA_SIZE_2], "HI", KeyboardBase.CODE_1X3_RIGHT) + subsequentAreaRequired = false + } + } + prefs.edit(commit = true) { putString("conjugate_mode_type", "2x1") } + ime.binding.ivInfo.visibility = View.GONE + } + + /** + * Saves the type of conjugation layout being used (e.g., "2x2", "3x2") to shared preferences. + * @param language The current keyboard language. + * @param isSubsequentArea `true` if this is for a secondary view. + */ + fun saveConjugateModeType( + language: String, + isSubsequentArea: Boolean = false, + ) { + val sharedPref = ime.applicationContext.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) + val mode = + if (!isSubsequentArea) { + when (language) { + "English", "Russian", "Swedish" -> "2x2" + "German", "French", "Italian", "Portuguese", "Spanish" -> "3x2" + else -> "none" + } + } else { + "none" + } + sharedPref.edit { putString("conjugate_mode_type", mode) } + } + + /** + * Handles a key press on one of the special conjugation keys. + * It either commits the text directly or prepares for a subsequent selection view. + * @param code The key code of the pressed key. + * @param isSubsequentRequired `true` if a sub-view is needed for more options. + * @return The label of the key that was pressed. + */ + fun handleConjugateKeys( + code: Int, + isSubsequentRequired: Boolean, + ): String? { + val keyLabel = ime.keyboardView?.getKeyLabel(code) + if (!isSubsequentRequired) { + ime.currentInputConnection?.commitText(keyLabel, 1) + ime.suggestionHandler.processLinguisticSuggestions(keyLabel) + } + return keyLabel + } + + /** + * Handles the Enter key press when in the `CONJUGATE` state. It fetches the + * conjugation data for the entered verb and transitions to the selection view. + * @param rawInput The verb entered in the command bar. + * @return The new ScribeState after processing the conjugate command. + */ + fun handleConjugateState(rawInput: String) { + val languageAlias = getLanguageAlias(ime.language) + conjugateOutput = + ime.dbManagers.conjugateDataManager.getTheConjugateLabels( + languageAlias, + ime.dataContract, + rawInput.lowercase(), + ) + + conjugateLabels = + ime.dbManagers.conjugateDataManager.extractConjugateHeadings( + ime.dataContract, + rawInput.lowercase(), + ) + + ime.currentState = + if ( + conjugateOutput.isEmpty() || + conjugateOutput.values.all { it.isEmpty() } + ) { + ScribeState.INVALID + } else { + saveConjugateModeType(ime.language) + ScribeState.SELECT_VERB_CONJUNCTION + } + + ime.updateUI() + } +} diff --git a/app/src/main/java/be/scri/helpers/KeyHandler.kt b/app/src/main/java/be/scri/helpers/KeyHandler.kt index 8481bc7c..269872a2 100644 --- a/app/src/main/java/be/scri/helpers/KeyHandler.kt +++ b/app/src/main/java/be/scri/helpers/KeyHandler.kt @@ -22,6 +22,7 @@ class KeyHandler( ) { private val suggestionHandler = SuggestionHandler(ime) private val spaceKeyProcessor = SpaceKeyProcessor(ime, suggestionHandler) + private val conjugateHandler = ConjugateHandler(ime) /** Tracks if the last key pressed was a space, used for "period on double space" logic. */ private var wasLastKeySpace: Boolean = false @@ -324,13 +325,13 @@ class KeyHandler( code: Int, language: String, ) { - if (!ime.returnIsSubsequentRequired()) { - ime.handleConjugateKeys(code, false) + if (!conjugateHandler.returnIsSubsequentRequired()) { + conjugateHandler.handleConjugateKeys(code, false) ime.moveToIdleState() - ime.saveConjugateModeType(language, isSubsequentArea = false) + conjugateHandler.saveConjugateModeType(language, isSubsequentArea = false) } else { - val word = ime.handleConjugateKeys(code, true) - ime.setupConjugateSubView(ime.returnSubsequentData(), word) + val word = conjugateHandler.handleConjugateKeys(code, true) + conjugateHandler.setupConjugateSubView(conjugateHandler.returnSubsequentData(), word) } } diff --git a/app/src/main/java/be/scri/helpers/SuggestionHandler.kt b/app/src/main/java/be/scri/helpers/SuggestionHandler.kt index 57b44dee..c33507c8 100644 --- a/app/src/main/java/be/scri/helpers/SuggestionHandler.kt +++ b/app/src/main/java/be/scri/helpers/SuggestionHandler.kt @@ -4,6 +4,10 @@ package be.scri.helpers import android.os.Handler import android.os.Looper +import be.scri.R +import be.scri.helpers.AnnotationTextUtils.handleColorAndTextForNounType +import be.scri.helpers.AnnotationTextUtils.handleTextForCaseAnnotation +import be.scri.helpers.ui.SuggestionsHelper import be.scri.services.GeneralKeyboardIME import be.scri.services.GeneralKeyboardIME.ScribeState @@ -12,6 +16,7 @@ import be.scri.services.GeneralKeyboardIME.ScribeState * * @property ime The [GeneralKeyboardIME] instance this handler is associated with. */ +@Suppress("TooManyFunctions", "LargeClass") class SuggestionHandler( private val ime: GeneralKeyboardIME, ) { @@ -19,6 +24,10 @@ class SuggestionHandler( private var emojiSuggestionRunnable: Runnable? = null private var linguisticSuggestionRunnable: Runnable? = null private var wordSuggestionRunnable: Runnable? = null + private val suggestionHelper = SuggestionsHelper(ime) + + internal var isSingularAndPlural: Boolean = false + var pluralWords: Set? = null /** * Companion object for holding constants related to suggestion handling. @@ -49,9 +58,9 @@ class SuggestionHandler( return@Runnable } - val genderSuggestion = ime.findGenderForLastWord(ime.nounKeywords, completedWord) - val isPluralByDirectCheck = ime.findWhetherWordIsPlural(ime.pluralWords, completedWord) - val caseSuggestion = ime.getCaseAnnotationForPreposition(ime.caseAnnotation, completedWord) + val genderSuggestion = findGenderForLastWord(ime.nounKeywords, completedWord) + val isPluralByDirectCheck = findWhetherWordIsPlural(ime.pluralWords, completedWord) + val caseSuggestion = getCaseAnnotationForPreposition(ime.caseAnnotation, completedWord) val hasLinguisticSuggestion = genderSuggestion != null || @@ -75,6 +84,65 @@ class SuggestionHandler( handler.postDelayed(linguisticSuggestionRunnable!!, SUGGESTION_DELAY_MS) } + /** + * Finds associated emojis for the last typed word. + * @param emojiKeywords The map of keywords to emojis. + * @param lastWord The word to look up. + * @return A mutable list of emoji suggestions, or null if none are found. + */ + fun findEmojisForLastWord( + emojiKeywords: HashMap>?, + lastWord: String?, + ): MutableList? { + lastWord?.let { return emojiKeywords?.get(it.lowercase()) } + return null + } + + /** + * Finds the required grammatical case(s) for a preposition. + * @param caseAnnotation The map of prepositions to their required cases. + * @param lastWord The word to look up (which should be a preposition). + * @return A mutable list of case suggestions (e.g., "accusative case"), or null if not found. + */ + fun getCaseAnnotationForPreposition( + caseAnnotation: HashMap>, + lastWord: String?, + ): MutableList? { + lastWord?.let { return caseAnnotation[it.lowercase()] } + return null + } + + /** + * Finds the grammatical gender(s) for the last typed word. + * @param nounKeywords The map of nouns to their genders. + * @param lastWord The word to look up. + * @return A list of gender strings (e.g., "masculine", "neuter"), or null if not a known noun. + */ + fun findGenderForLastWord( + nounKeywords: HashMap>, + lastWord: String?, + ): List? { + lastWord?.let { + val gender = nounKeywords[it.lowercase()] + if (gender != null) { + isSingularAndPlural = pluralWords?.contains(it.lowercase()) == true + return gender + } + } + return null + } + + /** + * Checks if the last word is a known plural form. + * @param pluralWords The set of all known plural words. + * @param lastWord The word to check. + * @return `true` if the word is in the plural set, `false` otherwise. + */ + fun findWhetherWordIsPlural( + pluralWords: Set?, + lastWord: String?, + ): Boolean = pluralWords?.contains(lastWord?.lowercase()) == true + fun processWordSuggestions(completedWord: String?) { wordSuggestionRunnable?.let { handler.removeCallbacks(it) } @@ -90,7 +158,7 @@ class SuggestionHandler( return@Runnable } - val nextWordSuggestion = ime.getNextWordSuggestions(ime.suggestionWords, completedWord) + val nextWordSuggestion = completedWord.lowercase().let { ime.suggestionWords[it] } if (nextWordSuggestion != null) { ime.wordSuggestions = nextWordSuggestion @@ -133,7 +201,7 @@ class SuggestionHandler( val emojis = if (ime.emojiAutoSuggestionEnabled) { - ime.findEmojisForLastWord(ime.emojiKeywords, currentWord) + findEmojisForLastWord(ime.emojiKeywords, currentWord) } else { null } @@ -142,7 +210,7 @@ class SuggestionHandler( if (hasEmojiSuggestion) { ime.autoSuggestEmojis = emojis - ime.updateEmojiSuggestion(true, emojis) + suggestionHelper.updateEmojiSuggestion(true, emojis) ime.updateButtonVisibility(true) } else { ime.updateButtonVisibility(false) @@ -185,4 +253,81 @@ class SuggestionHandler( ime.autoSuggestEmojis = null ime.isSingularAndPlural = false } + + /** + * A helper function to handle displaying a single noun gender suggestion. + * @param nounTypeSuggestion A list containing a single gender string. + * @return `true` if a suggestion was displayed, `false` otherwise. + */ + internal fun handleSingleNounSuggestion(nounTypeSuggestion: List?): Boolean { + if (nounTypeSuggestion?.size == 1 && !isSingularAndPlural) { + val (colorRes, text) = handleColorAndTextForNounType(nounTypeSuggestion[0], ime.language, ime.applicationContext) + if (text != "" || colorRes != R.color.transparent) { + ime.handleSingleType(nounTypeSuggestion, "noun") + return true + } + } + return false + } + + /** + * A helper function to handle displaying a single preposition case suggestion. + * @param caseAnnotationSuggestion A list containing a single case annotation string. + * @return `true` if a suggestion was displayed, `false` otherwise. + */ + internal fun handleSingleCaseSuggestion(caseAnnotationSuggestion: List?): Boolean { + if (caseAnnotationSuggestion?.size == 1) { + val (colorRes, text) = + handleTextForCaseAnnotation( + caseAnnotationSuggestion[0], + ime.language, + ime.applicationContext, + ) + if (text != "" || colorRes != R.color.transparent) { + ime.handleSingleType(caseAnnotationSuggestion, "preposition") + return true + } + } + return false + } + + /** + * A helper function to handle displaying multiple preposition case suggestions. + * @param caseAnnotationSuggestion A list containing multiple case annotation strings. + * @return `true` if suggestions were displayed, `false` otherwise. + */ + internal fun handleMultipleCases(caseAnnotationSuggestion: List?): Boolean { + if ((caseAnnotationSuggestion?.size ?: 0) > 1) { + ime.handleMultipleNounFormats(caseAnnotationSuggestion, "preposition") + return true + } + return false + } + + /** + * Handles fallback logic when multiple suggestions are available but only one can be shown, + * or when the primary suggestion type isn't displayable. + * @param nounTypeSuggestion The list of noun suggestions. + * @param caseAnnotationSuggestion The list of case suggestions. + * @return `true` if a fallback suggestion was applied, `false` otherwise. + */ + internal fun handleFallbackSuggestions( + nounTypeSuggestion: List?, + caseAnnotationSuggestion: List?, + ): Boolean { + var appliedSomething = false + nounTypeSuggestion?.let { + ime.handleSingleType(it, "noun") + val (_, text) = handleColorAndTextForNounType(it[0], ime.language, ime.applicationContext) + if (text != "") appliedSomething = true + } + if (!appliedSomething) { + caseAnnotationSuggestion?.let { + ime.handleSingleType(it, "preposition") + val (_, text) = handleTextForCaseAnnotation(it[0], ime.language, ime.applicationContext) + if (text != "") appliedSomething = true + } + } + return appliedSomething + } } diff --git a/app/src/main/java/be/scri/helpers/ui/SuggestionsHelper.kt b/app/src/main/java/be/scri/helpers/ui/SuggestionsHelper.kt new file mode 100644 index 00000000..a1acded1 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/ui/SuggestionsHelper.kt @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.helpers.ui + +import android.graphics.Color +import android.view.View +import android.widget.Button +import androidx.core.graphics.toColorInt +import be.scri.helpers.EmojiUtils.insertEmoji +import be.scri.helpers.PreferencesHelper.getIsDarkModeOrNot +import be.scri.services.GeneralKeyboardIME +import be.scri.services.GeneralKeyboardIME.Companion.SUGGESTION_SIZE +import be.scri.services.GeneralKeyboardIME.ScribeState + +@Suppress("TooManyFunctions", "LargeClass") +class SuggestionsHelper( + private val ime: GeneralKeyboardIME, +) { + var emojiKeywords: HashMap>? = null + private var emojiMaxKeywordLength: Int = 0 + + internal fun setSuggestionButton( + button: Button, + text: String, + ) { + val isUserDarkMode = getIsDarkModeOrNot(ime.applicationContext) + val textColor = if (isUserDarkMode) Color.WHITE else "#1E1E1E".toColorInt() + button.text = text + button.isAllCaps = false + button.visibility = View.VISIBLE + button.textSize = SUGGESTION_SIZE + button.setOnClickListener(null) + button.background = null + button.setTextColor(textColor) + button.setOnClickListener { + ime.currentInputConnection?.commitText("$text ", 1) + ime.moveToIdleState() + } + } + + internal fun handleWordSuggestions( + wordSuggestions: List? = null, + hasLinguisticSuggestions: Boolean, + ): Boolean { + if (wordSuggestions.isNullOrEmpty()) { + return false + } + val suggestion1 = wordSuggestions.getOrNull(0) ?: "" + val suggestion2 = wordSuggestions.getOrNull(1) ?: "" + val suggestion3 = wordSuggestions.getOrNull(2) ?: "" + + val emojiCount = ime.autoSuggestEmojis?.size ?: 0 + setSuggestionButton(ime.binding.conjugateBtn, suggestion1) + when { + hasLinguisticSuggestions && emojiCount != 0 -> { + ime.updateButtonVisibility(true) + } + + hasLinguisticSuggestions && emojiCount == 0 -> { + setSuggestionButton(ime.binding.pluralBtn, suggestion2) + } + else -> { + setSuggestionButton(ime.binding.translateBtn, suggestion2) + setSuggestionButton(ime.binding.pluralBtn, suggestion3) + } + } + return true + } + + /** + * Updates the text of the suggestion buttons, primarily for displaying emoji suggestions. + * @param isAutoSuggestEnabled `true` if suggestions are active. + * @param autoSuggestEmojis The list of emojis to display. + */ + fun updateEmojiSuggestion( + isAutoSuggestEnabled: Boolean, + autoSuggestEmojis: MutableList?, + ) { + if (ime.currentState != ScribeState.IDLE) return + + val tabletButtons = listOf(ime.binding.emojiBtnTablet1, ime.binding.emojiBtnTablet2, ime.binding.emojiBtnTablet3) + val phoneButtons = listOf(ime.binding.emojiBtnPhone1, ime.binding.emojiBtnPhone2) + + if (isAutoSuggestEnabled && autoSuggestEmojis != null) { + tabletButtons.forEachIndexed { index, button -> + val emoji = autoSuggestEmojis.getOrNull(index) ?: "" + button.text = emoji + button.setOnClickListener { + if (emoji.isNotEmpty()) { + insertEmoji( + emoji, + ime.currentInputConnection, + emojiKeywords, + emojiMaxKeywordLength, + ) + } + } + } + + phoneButtons.forEachIndexed { index, button -> + val emoji = autoSuggestEmojis.getOrNull(index) ?: "" + button.text = emoji + button.setOnClickListener { + if (emoji.isNotEmpty()) { + insertEmoji( + emoji, + ime.currentInputConnection, + emojiKeywords, + emojiMaxKeywordLength, + ) + } + } + } + } else { + (tabletButtons + phoneButtons).forEach { button -> + button.text = "" + button.setOnClickListener(null) + } + } + } + + /** + * Sets the default visibility for buttons when not in the `IDLE` state. + * Hides all suggestion-related buttons. + */ + internal fun setupDefaultButtonVisibility() { + ime.binding.pluralBtn.visibility = View.VISIBLE + ime.binding.emojiBtnPhone1.visibility = View.GONE + ime.binding.emojiBtnPhone2.visibility = View.GONE + ime.binding.emojiBtnTablet1.visibility = View.GONE + ime.binding.emojiBtnTablet2.visibility = View.GONE + ime.binding.emojiBtnTablet3.visibility = View.GONE + ime.binding.separator4.visibility = View.GONE + ime.binding.separator5.visibility = View.GONE + ime.binding.separator6.visibility = View.GONE + } +} diff --git a/app/src/main/java/be/scri/services/GeneralKeyboardIME.kt b/app/src/main/java/be/scri/services/GeneralKeyboardIME.kt index 47de03ad..bb72cee6 100644 --- a/app/src/main/java/be/scri/services/GeneralKeyboardIME.kt +++ b/app/src/main/java/be/scri/services/GeneralKeyboardIME.kt @@ -36,14 +36,15 @@ import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.ContextCompat import androidx.core.content.edit import androidx.core.graphics.toColorInt +import androidx.core.view.WindowInsetsControllerCompat import be.scri.R import be.scri.R.color.md_grey_black_dark import be.scri.R.color.white import be.scri.databinding.InputMethodViewBinding import be.scri.helpers.AnnotationTextUtils.handleColorAndTextForNounType import be.scri.helpers.AnnotationTextUtils.handleTextForCaseAnnotation +import be.scri.helpers.ConjugateHandler import be.scri.helpers.DatabaseManagers -import be.scri.helpers.EmojiUtils.insertEmoji import be.scri.helpers.KeyboardBase import be.scri.helpers.LanguageMappingConstants.conjugatePlaceholder import be.scri.helpers.LanguageMappingConstants.getLanguageAlias @@ -62,6 +63,7 @@ import be.scri.helpers.SHIFT_ON_PERMANENT import be.scri.helpers.SuggestionHandler import be.scri.helpers.english.ENInterfaceVariables.ALREADY_PLURAL_MSG import be.scri.helpers.ui.HintUtils +import be.scri.helpers.ui.SuggestionsHelper import be.scri.views.KeyboardView import java.util.Locale @@ -106,14 +108,14 @@ abstract class GeneralKeyboardIME( private var genderSuggestionRight: Button? = null internal var isSingularAndPlural: Boolean = false - private var subsequentAreaRequired: Boolean = false - private var subsequentData: MutableList> = mutableListOf() private val shiftPermToggleSpeed: Int = DEFAULT_SHIFT_PERM_TOGGLE_SPEED - private lateinit var dbManagers: DatabaseManagers - private lateinit var suggestionHandler: SuggestionHandler - private var dataContract: DataContract? = null + internal lateinit var dbManagers: DatabaseManagers + internal lateinit var suggestionHandler: SuggestionHandler + internal lateinit var suggestionsHelper: SuggestionsHelper + internal lateinit var conjugateHandler: ConjugateHandler + internal var dataContract: DataContract? = null var emojiKeywords: HashMap>? = null private var conjugateOutput: MutableMap>>? = null private lateinit var conjugateLabels: Set @@ -186,15 +188,6 @@ abstract class GeneralKeyboardIME( enum class ScribeState { IDLE, SELECT_COMMAND, TRANSLATE, CONJUGATE, PLURAL, SELECT_VERB_CONJUNCTION, INVALID, ALREADY_PLURAL } - /** - * Returns whether the current conjugation state requires a subsequent selection view. - * This is used, for example, when a conjugation form has multiple options (e.g., "am/is/are" in English). - * @return `true` if a subsequent selection screen is needed, `false` otherwise. - */ - internal fun returnIsSubsequentRequired(): Boolean = subsequentAreaRequired - - internal fun returnSubsequentData(): List> = subsequentData - /** * Called when the service is first created. Initializes database and suggestion handlers. */ @@ -202,6 +195,8 @@ abstract class GeneralKeyboardIME( super.onCreate() dbManagers = DatabaseManagers(this) suggestionHandler = SuggestionHandler(this) + conjugateHandler = ConjugateHandler(this) + suggestionsHelper = SuggestionsHelper(this) } /** @@ -221,7 +216,7 @@ abstract class GeneralKeyboardIME( initializeUiElements() setupClickListeners() currentState = ScribeState.IDLE - saveConjugateModeType("none") + conjugateHandler.saveConjugateModeType("none") updateUI() return inputView } @@ -275,7 +270,7 @@ abstract class GeneralKeyboardIME( private fun setCommandButtonListeners() { binding.translateBtn.setOnClickListener { currentState = ScribeState.TRANSLATE - saveConjugateModeType("none") + conjugateHandler.saveConjugateModeType("none") updateUI() } binding.conjugateBtn.setOnClickListener { @@ -284,7 +279,7 @@ abstract class GeneralKeyboardIME( } binding.pluralBtn.setOnClickListener { currentState = ScribeState.PLURAL - saveConjugateModeType("none") + conjugateHandler.saveConjugateModeType("none") if (language == "German") keyboard?.mShiftState = SHIFT_ON_ONE_CHAR updateUI() } @@ -448,26 +443,13 @@ abstract class GeneralKeyboardIME( moveToIdleState() val window = window?.window ?: return - var color = R.color.dark_keyboard_bg_color val isDarkMode = getIsDarkModeOrNot(applicationContext) - color = - if (isDarkMode) { - R.color.dark_keyboard_bg_color - } else { - R.color.light_keyboard_bg_color - } + val color = if (isDarkMode) R.color.dark_keyboard_bg_color else R.color.light_keyboard_bg_color - window.navigationBarColor = ContextCompat.getColor(this, color) + val navColor = ContextCompat.getColor(this, color) - val decorView = window.decorView - var flags = decorView.systemUiVisibility - flags = - if (isLightColor(window.navigationBarColor)) { - flags or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR - } else { - flags and View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR.inv() - } - decorView.systemUiVisibility = flags + val insetsController = WindowInsetsControllerCompat(window, window.decorView) + insetsController.isAppearanceLightNavigationBars = isLightColor(navColor) val textBefore = currentInputConnection?.getTextBeforeCursor(1, 0)?.toString().orEmpty() if (textBefore.isEmpty()) keyboard?.setShifted(SHIFT_ON_ONE_CHAR) } @@ -648,7 +630,7 @@ abstract class GeneralKeyboardIME( binding.scribeKeyOptions.foreground = AppCompatResources.getDrawable(this, R.drawable.ic_scribe_icon_vector) initializeKeyboard(getKeyboardLayoutXML()) updateButtonVisibility(emojiAutoSuggestionEnabled) - updateEmojiSuggestion(emojiAutoSuggestionEnabled, autoSuggestEmojis) + suggestionsHelper.updateEmojiSuggestion(emojiAutoSuggestionEnabled, autoSuggestEmojis) binding.commandBar.setText("") disableAutoSuggest() } @@ -748,8 +730,7 @@ abstract class GeneralKeyboardIME( initializeKeyboard(keyboardXmlId) val conjugateIndex = getValidatedConjugateIndex() - - setupConjugateKeysByLanguage(conjugateIndex) + conjugateHandler.setupConjugateKeysByLanguage(conjugateIndex) promptText = conjugateOutput?.keys?.elementAtOrNull(conjugateIndex) ?: "___" hintWord = conjugateLabels.lastOrNull() } @@ -883,7 +864,7 @@ abstract class GeneralKeyboardIME( private fun moveToSelectCommandState() { clearSuggestionData() currentState = ScribeState.SELECT_COMMAND - saveConjugateModeType("none") + conjugateHandler.saveConjugateModeType("none") currentVerbForConjugation = null updateUI() } @@ -894,7 +875,7 @@ abstract class GeneralKeyboardIME( internal fun moveToIdleState() { clearSuggestionData() currentState = ScribeState.IDLE - saveConjugateModeType("none") + conjugateHandler.saveConjugateModeType("none") currentVerbForConjugation = null if (this::binding.isInitialized) updateUI() } @@ -913,7 +894,7 @@ abstract class GeneralKeyboardIME( ): Int = when (state) { ScribeState.SELECT_VERB_CONJUNCTION -> { - saveConjugateModeType(language) + conjugateHandler.saveConjugateModeType(language) if (!isSubsequentArea && dataSize == 0) { when (language) { "English", "Russian", "Swedish" -> R.xml.conjugate_view_2x2 @@ -936,7 +917,7 @@ abstract class GeneralKeyboardIME( * Initializes or re-initializes the keyboard with a new layout. * @param xmlId The resource ID of the keyboard layout XML. */ - private fun initializeKeyboard(xmlId: Int) { + internal fun initializeKeyboard(xmlId: Int) { keyboard = KeyboardBase(this, xmlId, enterKeyType) keyboardView?.setKeyboard(keyboard!!) keyboardView?.requestLayout() @@ -1163,7 +1144,7 @@ abstract class GeneralKeyboardIME( */ internal fun updateButtonVisibility(isAutoSuggestEnabled: Boolean) { if (currentState != ScribeState.IDLE) { - setupDefaultButtonVisibility() + suggestionsHelper.setupDefaultButtonVisibility() return } @@ -1180,27 +1161,11 @@ abstract class GeneralKeyboardIME( } } - /** - * Sets the default visibility for buttons when not in the `IDLE` state. - * Hides all suggestion-related buttons. - */ - private fun setupDefaultButtonVisibility() { - pluralBtn?.visibility = View.VISIBLE - emojiBtnPhone1?.visibility = View.GONE - emojiBtnPhone2?.visibility = View.GONE - emojiBtnTablet1?.visibility = View.GONE - emojiBtnTablet2?.visibility = View.GONE - emojiBtnTablet3?.visibility = View.GONE - binding.separator4.visibility = View.GONE - binding.separator5.visibility = View.GONE - binding.separator6.visibility = View.GONE - } - /** * Handles the logic for showing/hiding suggestion buttons specifically on tablet layouts. * @param emojiCount The number of available emoji suggestions. */ - private fun updateTabletButtonVisibility(emojiCount: Int) { + internal fun updateTabletButtonVisibility(emojiCount: Int) { pluralBtn?.visibility = if (emojiCount > 0) View.INVISIBLE else View.VISIBLE when (emojiCount) { @@ -1250,7 +1215,7 @@ abstract class GeneralKeyboardIME( * Handles the logic for showing/hiding suggestion buttons specifically on phone layouts. * @param emojiCount The number of available emoji suggestions. */ - private fun updatePhoneButtonVisibility(emojiCount: Int) { + internal fun updatePhoneButtonVisibility(emojiCount: Int) { pluralBtn?.visibility = if (emojiCount > 0) View.INVISIBLE else View.VISIBLE when { @@ -1299,136 +1264,6 @@ abstract class GeneralKeyboardIME( */ fun getLastWordBeforeCursor(): String? = getText()?.trim()?.split("\\s+".toRegex())?.lastOrNull() - /** - * Finds associated emojis for the last typed word. - * @param emojiKeywords The map of keywords to emojis. - * @param lastWord The word to look up. - * @return A mutable list of emoji suggestions, or null if none are found. - */ - fun findEmojisForLastWord( - emojiKeywords: HashMap>?, - lastWord: String?, - ): MutableList? { - lastWord?.let { return emojiKeywords?.get(it.lowercase()) } - return null - } - - /** - * Finds the grammatical gender(s) for the last typed word. - * @param nounKeywords The map of nouns to their genders. - * @param lastWord The word to look up. - * @return A list of gender strings (e.g., "masculine", "neuter"), or null if not a known noun. - */ - fun findGenderForLastWord( - nounKeywords: HashMap>, - lastWord: String?, - ): List? { - lastWord?.let { - val gender = nounKeywords[it.lowercase()] - if (gender != null) { - isSingularAndPlural = pluralWords?.contains(it.lowercase()) == true - return gender - } - } - return null - } - - /** - * Finds the next suggestions for the last typed word. - * @param wordSuggestions The map of words to their suggestions. - * @param lastWord The word to look up. - * @return A list of gender strings (e.g., "masculine", "neuter"), or null if not a known noun. - */ - fun getNextWordSuggestions( - wordSuggestions: HashMap>, - lastWord: String?, - ): List? { - lastWord?.let { - val suggestions = wordSuggestions[it.lowercase()] - if (suggestions != null) { - return suggestions - } - } - return null - } - - /** - * Checks if the last word is a known plural form. - * @param pluralWords The set of all known plural words. - * @param lastWord The word to check. - * @return `true` if the word is in the plural set, `false` otherwise. - */ - fun findWhetherWordIsPlural( - pluralWords: Set?, - lastWord: String?, - ): Boolean = pluralWords?.contains(lastWord?.lowercase()) == true - - /** - * Finds the required grammatical case(s) for a preposition. - * @param caseAnnotation The map of prepositions to their required cases. - * @param lastWord The word to look up (which should be a preposition). - * @return A mutable list of case suggestions (e.g., "accusative case"), or null if not found. - */ - fun getCaseAnnotationForPreposition( - caseAnnotation: HashMap>, - lastWord: String?, - ): MutableList? { - lastWord?.let { return caseAnnotation[it.lowercase()] } - return null - } - - /** - * Updates the text of the suggestion buttons, primarily for displaying emoji suggestions. - * @param isAutoSuggestEnabled `true` if suggestions are active. - * @param autoSuggestEmojis The list of emojis to display. - */ - fun updateEmojiSuggestion( - isAutoSuggestEnabled: Boolean, - autoSuggestEmojis: MutableList?, - ) { - if (currentState != ScribeState.IDLE) return - - val tabletButtons = listOf(binding.emojiBtnTablet1, binding.emojiBtnTablet2, binding.emojiBtnTablet3) - val phoneButtons = listOf(binding.emojiBtnPhone1, binding.emojiBtnPhone2) - - if (isAutoSuggestEnabled && autoSuggestEmojis != null) { - tabletButtons.forEachIndexed { index, button -> - val emoji = autoSuggestEmojis.getOrNull(index) ?: "" - button.text = emoji - button.setOnClickListener { - if (emoji.isNotEmpty()) { - insertEmoji( - emoji, - currentInputConnection, - emojiKeywords, - emojiMaxKeywordLength, - ) - } - } - } - - phoneButtons.forEachIndexed { index, button -> - val emoji = autoSuggestEmojis.getOrNull(index) ?: "" - button.text = emoji - button.setOnClickListener { - if (emoji.isNotEmpty()) { - insertEmoji( - emoji, - currentInputConnection, - emojiKeywords, - emojiMaxKeywordLength, - ) - } - } - } - } else { - (tabletButtons + phoneButtons).forEach { button -> - button.text = "" - button.setOnClickListener(null) - } - } - } - /** * The main dispatcher for displaying linguistic auto-suggestions (gender, case, plurality). * @param nounTypeSuggestion The detected gender(s) of the last word. @@ -1461,14 +1296,14 @@ abstract class GeneralKeyboardIME( true } handlePluralIfNeeded(isPlural) -> true - handleSingleNounSuggestion(nounTypeSuggestion) -> true - handleMultipleCases(caseAnnotationSuggestion) -> true - handleSingleCaseSuggestion(caseAnnotationSuggestion) -> true - handleFallbackSuggestions(nounTypeSuggestion, caseAnnotationSuggestion) -> true + suggestionHandler.handleSingleNounSuggestion(nounTypeSuggestion) -> true + suggestionHandler.handleMultipleCases(caseAnnotationSuggestion) -> true + suggestionHandler.handleSingleCaseSuggestion(caseAnnotationSuggestion) -> true + suggestionHandler.handleFallbackSuggestions(nounTypeSuggestion, caseAnnotationSuggestion) -> true else -> false } if (!handled) disableAutoSuggest() - handleWordSuggestions( + suggestionsHelper.handleWordSuggestions( wordSuggestions = wordSuggestions, hasLinguisticSuggestions = hasLinguisticSuggestions, ) @@ -1490,83 +1325,6 @@ abstract class GeneralKeyboardIME( return false } - /** - * A helper function to handle displaying a single noun gender suggestion. - * @param nounTypeSuggestion A list containing a single gender string. - * @return `true` if a suggestion was displayed, `false` otherwise. - */ - private fun handleSingleNounSuggestion(nounTypeSuggestion: List?): Boolean { - if (nounTypeSuggestion?.size == 1 && !isSingularAndPlural) { - val (colorRes, text) = handleColorAndTextForNounType(nounTypeSuggestion[0], language, applicationContext) - if (text != "" || colorRes != R.color.transparent) { - handleSingleType(nounTypeSuggestion, "noun") - return true - } - } - return false - } - - /** - * A helper function to handle displaying a single preposition case suggestion. - * @param caseAnnotationSuggestion A list containing a single case annotation string. - * @return `true` if a suggestion was displayed, `false` otherwise. - */ - private fun handleSingleCaseSuggestion(caseAnnotationSuggestion: List?): Boolean { - if (caseAnnotationSuggestion?.size == 1) { - val (colorRes, text) = - handleTextForCaseAnnotation( - caseAnnotationSuggestion[0], - language, - applicationContext, - ) - if (text != "" || colorRes != R.color.transparent) { - handleSingleType(caseAnnotationSuggestion, "preposition") - return true - } - } - return false - } - - /** - * A helper function to handle displaying multiple preposition case suggestions. - * @param caseAnnotationSuggestion A list containing multiple case annotation strings. - * @return `true` if suggestions were displayed, `false` otherwise. - */ - private fun handleMultipleCases(caseAnnotationSuggestion: List?): Boolean { - if ((caseAnnotationSuggestion?.size ?: 0) > 1) { - handleMultipleNounFormats(caseAnnotationSuggestion, "preposition") - return true - } - return false - } - - /** - * Handles fallback logic when multiple suggestions are available but only one can be shown, - * or when the primary suggestion type isn't displayable. - * @param nounTypeSuggestion The list of noun suggestions. - * @param caseAnnotationSuggestion The list of case suggestions. - * @return `true` if a fallback suggestion was applied, `false` otherwise. - */ - private fun handleFallbackSuggestions( - nounTypeSuggestion: List?, - caseAnnotationSuggestion: List?, - ): Boolean { - var appliedSomething = false - nounTypeSuggestion?.let { - handleSingleType(it, "noun") - val (_, text) = handleColorAndTextForNounType(it[0], language, applicationContext) - if (text != "") appliedSomething = true - } - if (!appliedSomething) { - caseAnnotationSuggestion?.let { - handleSingleType(it, "preposition") - val (_, text) = handleTextForCaseAnnotation(it[0], language, applicationContext) - if (text != "") appliedSomething = true - } - } - return appliedSomething - } - /** * Configures the UI to show a "PL" (Plural) suggestion. */ @@ -1587,66 +1345,12 @@ abstract class GeneralKeyboardIME( } } - private fun setSuggestionButton( - button: Button, - text: String, - ) { - val isUserDarkMode = getIsDarkModeOrNot(applicationContext) - val textColor = if (isUserDarkMode) Color.WHITE else "#1E1E1E".toColorInt() - button.text = text - button.isAllCaps = false - button.visibility = View.VISIBLE - button.textSize = SUGGESTION_SIZE - button.setOnClickListener(null) - button.background = null - button.setTextColor(textColor) - button.setOnClickListener { - currentInputConnection?.commitText("$text ", 1) - moveToIdleState() - } - } - - private fun handleWordSuggestions( - hasLinguisticSuggestions: Boolean, - wordSuggestions: List? = null, - ): Boolean { - if (wordSuggestions.isNullOrEmpty()) { - return false - } - val suggestions = - listOfNotNull( - wordSuggestions.getOrNull(0), - wordSuggestions.getOrNull(1), - wordSuggestions.getOrNull(2), - ) - val suggestion1 = suggestions.getOrNull(0) ?: "" - val suggestion2 = suggestions.getOrNull(1) ?: "" - val suggestion3 = suggestions.getOrNull(2) ?: "" - - val emojiCount = autoSuggestEmojis?.size ?: 0 - setSuggestionButton(binding.conjugateBtn, suggestion1) - when { - hasLinguisticSuggestions && emojiCount != 0 -> { - updateButtonVisibility(true) - } - - hasLinguisticSuggestions && emojiCount == 0 -> { - setSuggestionButton(binding.pluralBtn, suggestion2) - } - else -> { - setSuggestionButton(binding.translateBtn, suggestion2) - setSuggestionButton(binding.pluralBtn, suggestion3) - } - } - return true - } - /** * Configures a single suggestion button with the appropriate text and color based on the suggestion type. * @param singleTypeSuggestion The list containing the single suggestion to display. * @param type The type of suggestion, either "noun" or "preposition". */ - private fun handleSingleType( + internal fun handleSingleType( singleTypeSuggestion: List?, type: String? = null, ) { @@ -1829,7 +1533,7 @@ abstract class GeneralKeyboardIME( * @param multipleTypeSuggestion The list of suggestions to display. * @param type The type of suggestion, either "noun" or "preposition". */ - private fun handleMultipleNounFormats( + internal fun handleMultipleNounFormats( multipleTypeSuggestion: List?, type: String? = null, ) { @@ -1973,7 +1677,7 @@ abstract class GeneralKeyboardIME( } else { when (currentState) { ScribeState.PLURAL, ScribeState.TRANSLATE -> handlePluralOrTranslateState(rawInput, inputConnection) - ScribeState.CONJUGATE -> handleConjugateState(rawInput) + ScribeState.CONJUGATE -> conjugateHandler.handleConjugateState(rawInput) else -> handleDefaultEnter(inputConnection) } }