diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 70d8d1a9..e4bef3f2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,12 +27,11 @@ jacoco { toolVersion = "0.8.12" } -val kotlinVersion by extra("2.0.0") val junit5Version by extra("5.11.2") val mockkVersion by extra("1.13.13") android { - compileSdk = 34 + compileSdk = 35 defaultConfig { applicationId = "be.scri" @@ -123,9 +122,10 @@ android { toolVersion = "1.23.8" buildUponDefaultConfig = true allRules = false - config = rootProject.files("detekt.yml") + config.setFrom(rootProject.files("detekt.yml")) } + kotlinter { failBuildWhenCannotAutoFormat = false ignoreFailures = false @@ -142,7 +142,7 @@ android { listOf( // Data binding. "**/R.class", - "**/R\$*.class", + "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*", "**/*Test*.*", @@ -156,9 +156,7 @@ android { xml.required.set(true) html.required.set(true) } - // Set source directories to the main source directory. sourceDirectories.setFrom(layout.projectDirectory.dir("src/main")) - // Set class directories to compiled Java and Kotlin classes, excluding specified exclusions. classDirectories.setFrom( files( fileTree(layout.buildDirectory.dir("intermediates/javac/")) { @@ -169,7 +167,6 @@ android { }, ), ) - // Collect execution data from .exec and .ec files generated during test execution. executionData.setFrom( files( fileTree(layout.buildDirectory) { include(listOf("**/*.exec", "**/*.ec")) }, @@ -179,91 +176,116 @@ android { } } + + dependencies { detektPlugins("io.nlopez.compose.rules:detekt:0.4.17") lintChecks("com.slack.lint.compose:compose-lint-checks:1.4.2") - // AndroidX dependencies - implementation("androidx.appcompat:appcompat:1.7.0") - implementation("androidx.activity:activity-ktx:1.9.2") - implementation("androidx.navigation:navigation-fragment-ktx:2.8.4") - implementation("androidx.navigation:navigation-ui-ktx:2.8.4") - debugImplementation("androidx.fragment:fragment-testing:1.8.5") + // ========================== + // AndroidX Dependencies + // ========================== + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("androidx.activity:activity-ktx:1.10.1") + implementation("androidx.navigation:navigation-fragment-ktx:2.9.0") + implementation("androidx.navigation:navigation-ui-ktx:2.9.0") + debugImplementation("androidx.fragment:fragment-testing:1.8.8") implementation("androidx.test.ext:junit-ktx:1.2.1") - // Room database - ksp("androidx.room:room-compiler:2.6.1") - implementation("androidx.room:room-runtime:2.6.1") - - // Kotlin dependencies - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.0.0") - - // Layout and UI components - implementation("androidx.constraintlayout:constraintlayout:2.2.0") - implementation("androidx.documentfile:documentfile:1.0.1") + // ========================== + // Room Database + // ========================== + ksp("androidx.room:room-compiler:2.7.2") + implementation("androidx.room:room-runtime:2.7.2") + implementation("androidx.room:room-ktx:2.7.2") + + // ========================== + // Kotlin + // ========================== + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.0") + + // ========================== + // Layout and UI + // ========================== + implementation("androidx.constraintlayout:constraintlayout:2.2.1") + implementation("androidx.documentfile:documentfile:1.1.0") implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") - implementation("androidx.exifinterface:exifinterface:1.3.7") + implementation("androidx.exifinterface:exifinterface:1.4.1") implementation("androidx.biometric:biometric-ktx:1.2.0-alpha05") implementation("com.google.android.material:material:1.12.0") - implementation("androidx.recyclerview:recyclerview:1.3.2") + implementation("androidx.recyclerview:recyclerview:1.4.0") implementation("androidx.cardview:cardview:1.0.0") implementation("androidx.viewpager2:viewpager2:1.1.0") - implementation("com.google.android.play:core:1.10.3") - implementation("androidx.navigation:navigation-compose:2.6.0") + implementation("com.google.android.play:core-ktx:1.8.1") + implementation("androidx.navigation:navigation-compose:2.9.0") - // Jetpack Compose BOM + // ========================== + // Jetpack Compose + // ========================== val composeBom = platform("androidx.compose:compose-bom:2024.10.00") implementation(composeBom) androidTestImplementation(composeBom) implementation("androidx.compose.material3:material3") - implementation("androidx.compose.material:material:1.7.6") - implementation("com.google.android.material:material:1.12.0") + implementation("androidx.compose.material:material:1.8.3") implementation("androidx.compose.ui:ui-tooling-preview") debugImplementation("androidx.compose.ui:ui-tooling") androidTestImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-test-manifest") + // ========================== // Activity Compose + // ========================== implementation("androidx.activity:activity-compose") + // ========================== // Navigation Compose - implementation("androidx.navigation:navigation-compose:$2.8.4") + // ========================== + implementation("androidx.navigation:navigation-compose:2.9.0") + + // ========================== + // Unit Testing + // ========================== + - // Testing libraries testImplementation("org.junit.jupiter:junit-jupiter-api:$junit5Version") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junit5Version") testImplementation("io.mockk:mockk:$mockkVersion") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") - // For Instrumentation Tests - androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.7.5") - debugImplementation("androidx.compose.ui:ui-test-manifest:1.7.5") + // ========================== + // Instrumentation Tests + // ========================== + androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.8.3") + debugImplementation("androidx.compose.ui:ui-test-manifest:1.8.3") - // Espresso for UI tests + // ========================== + // UI Tests + // ========================== androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") - // Android testing framework + // ========================== + // Android Testing + // ========================== androidTestImplementation("androidx.test:core-ktx:1.6.1") androidTestImplementation("androidx.test.ext:junit-ktx:1.2.1") androidTestImplementation("androidx.test.espresso:espresso-intents:3.6.1") - - // JUnit 5 dependencies - testImplementation("org.junit.jupiter:junit-jupiter-api:$junit5Version") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junit5Version") - - // AndroidJUnit4 is included androidTestImplementation("androidx.test.ext:junit:1.2.1") - androidTestImplementation("androidx.test:runner:1.6.1") - androidTestImplementation("io.mockk:mockk-android:1.13.5") + androidTestImplementation("androidx.test:runner:1.6.2") + androidTestImplementation("io.mockk:mockk-android:1.13.13") + + // ========================== + // Other + // ========================== - // Other libraries - api("joda-time:joda-time:2.10.13") + api("joda-time:joda-time:2.12.7") api("com.github.tibbi:RecyclerView-FastScroller:e7d3e150c4") api("com.github.tibbi:reprint:2cb206415d") - api("androidx.core:core-ktx:1.13.1") - api("com.google.code.gson:gson:2.10.1") - api("com.github.bumptech.glide:glide:4.14.2") + api("androidx.core:core-ktx:1.16.0") + api("com.google.code.gson:gson:2.11.0") + api("com.github.bumptech.glide:glide:4.16.0") ksp("com.github.bumptech.glide:ksp:4.14.2") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") } tasks.register("moveFromi18n") { @@ -317,7 +339,7 @@ tasks.register("jacocoTestReport") { reports { html.required.set(true) - xml.outputLocation.set(file("${buildDir}/reports/jacoco/jacoco.xml")) + xml.outputLocation.set(layout.buildDirectory.file("reports/jacoco/jacoco.xml")) xml.required.set(true) csv.required.set(true) } diff --git a/app/src/main/assets/data-contracts/en.json b/app/src/main/assets/data-contracts/en.json index 3dda956f..d44eca2c 100644 --- a/app/src/main/assets/data-contracts/en.json +++ b/app/src/main/assets/data-contracts/en.json @@ -1,11 +1,11 @@ { "numbers": { "singular": "plural" }, "genders": { - "canonical": ["NOT_INCLUDED"], - "feminines": ["NOT_INCLUDED"], - "masculines": ["NOT_INCLUDED"], - "commons": ["NOT_INCLUDED"], - "neuters": ["NOT_INCLUDED"] + "canonical": [], + "feminines": [], + "masculines": [], + "commons": [], + "neuters": [] }, "conjugations": { "1": { diff --git a/app/src/main/assets/data-contracts/ru.json b/app/src/main/assets/data-contracts/ru.json index fd62ed34..1742fb91 100644 --- a/app/src/main/assets/data-contracts/ru.json +++ b/app/src/main/assets/data-contracts/ru.json @@ -1,39 +1,44 @@ { - "numbers": { "nominativeSingular": "nominativePlural" }, + "numbers": { + "nominativeSingular": "nominativePlural" + }, "genders": { - "canonical": ["gender"], - "feminines": [], - "masculines": [], - "commons": [], - "neuters": [] + "canonical": ["gender"], + "feminines": [], + "masculines": [], + "commons": [], + "neuters": [] }, "conjugations": { - "1": { - "title": "Настоящее", - "1": { - "title": "Настоящее", - "Simple": { - "я": "indicativePresentFirstPersonSingular", - "ты": "indicativePresentFirstPersonPlural", - "он/она/оно": "indicativePresentSecondPersonSingular", - "мы": "indicativePresentSecondPersonPlural", - "вы": "indicativePresentThirdPersonSingular", - "они": "indicativePresentThirdPersonPlural" - } - } - }, - "2": { - "title": "Прошедшее", "1": { - "title": "Прошедшее", - "Simple": { - "я/ты/она": "feminineIndicativePast", - "я/ты/он": "masculineIndicativePast", - "оно": "neuterIndicativePast", - "мы/вы/они": "indicativePastPlural" - } + "title": "Настоящее (Present)", + "conjugationTypes": { + "1": { + "title": "Indicative Present", + "conjugationForms": { + "я (I)": "indicativePresentFirstPersonSingular", + "ты (you, sing.)": "indicativePresentSecondPersonSingular", + "он/она/оно (he/she/it)": "indicativePresentThirdPersonSingular", + "мы (we)": "indicativePresentFirstPersonPlural", + "вы (you, pl.)": "indicativePresentSecondPersonPlural", + "они (they)": "indicativePresentThirdPersonPlural" + } + } + } + }, + "2": { + "title": "Прошедшее (Past)", + "conjugationTypes": { + "1": { + "title": "Indicative Past", + "conjugationForms": { + "я/ты/она (I/you/she, fem.)": "feminineIndicativePast", + "я/ты/он (I/you/he, masc.)": "masculineIndicativePast", + "оно (it, neut.)": "neuterIndicativePast", + "мы/вы/они (we/you/they)": "indicativePastPlural" + } + } + } } - } } - } - \ No newline at end of file +} diff --git a/app/src/main/assets/data-contracts/sv.json b/app/src/main/assets/data-contracts/sv.json index 05457e2a..6179e6f1 100644 --- a/app/src/main/assets/data-contracts/sv.json +++ b/app/src/main/assets/data-contracts/sv.json @@ -10,20 +10,35 @@ "commons": [], "neuters": [] }, - "conjugations": { + "conjugations": { "1": { - "title": "Aktiv", - "1": { "imperativ": "imperative" }, - "2": { "presens": "activePresent" }, - "3": { "dåtid": "activePreterite" }, - "4": { "liggande": "activeSupine" } + "title": "Aktiv (Active Voice)", + "conjugationTypes": { + "1": { + "title": "Active Forms", + "conjugationForms": { + "infinitiv": "activeInfinitive", + "imperativ": "imperative", + "presens": "activePresent", + "preteritum (dåtid)": "activePreterite", + "supinum (liggande)": "activeSupine" + } + } + } }, "2": { - "title": "Passiv", - "1": { "infinitiv": "passiveInfinitive" }, - "2": { "presens": "passivePresent" }, - "3": { "liggande": "passivePreterite" }, - "4": { "liggande": "passiveSupine" } + "title": "Passiv (Passive Voice)", + "conjugationTypes": { + "1": { + "title": "Passive Forms", + "conjugationForms": { + "infinitiv": "passiveInfinitive", + "presens": "passivePresent", + "preteritum (dåtid)": "passivePreterite", + "supinum (liggande)": "passiveSupine" + } + } + } } } } diff --git a/app/src/main/java/be/scri/helpers/AnnotationTextUtils.kt b/app/src/main/java/be/scri/helpers/AnnotationTextUtils.kt new file mode 100644 index 00000000..e158286d --- /dev/null +++ b/app/src/main/java/be/scri/helpers/AnnotationTextUtils.kt @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers + +import android.content.Context +import be.scri.R +import be.scri.helpers.LanguageMappingConstants.nounAnnotationConversionDict +import be.scri.helpers.LanguageMappingConstants.prepAnnotationConversionDict + +/** + * Utility object for handling the display text and color of annotations + * related to noun cases, genders, and types. + * + * This object provides functions to map raw annotation strings (e.g., "genitive case", "masculine") + * to user-friendly display text and corresponding color resources. It also handles + * language-specific conversions for abbreviations of cases and genders. + */ +object AnnotationTextUtils { + /** + * Maps a case annotation string (e.g., "genitive case") to a displayable text and color. + * @param nounType The case annotation string. + * @return A pair containing the color resource ID and the display text. + */ + fun handleTextForCaseAnnotation( + nounType: String, + language: String, + context: Context, + ): Pair { + val color = R.color.annotateOrange + val suggestionMap = + mapOf( + "genitive case" to Pair(color, processValuesForPreposition(language, "Gen")), + "accusative case" to Pair(color, processValuesForPreposition(language, "Acc")), + "dative case" to Pair(color, processValuesForPreposition(language, "Dat")), + "locative case" to Pair(color, processValuesForPreposition(language, "Loc")), + "Prepositional case" to Pair(color, processValuesForPreposition(language, "Pre")), + "Instrumental case" to Pair(color, processValuesForPreposition(language, "Ins")), + ) + return suggestionMap[nounType] ?: Pair(R.color.transparent, context.getString(R.string.suggestion)) + } + + /** + * Maps a noun type string (e.g., "masculine") to a displayable text and color. + * @param nounType The noun type or gender string. + * @return A pair containing the color resource ID and the display text. + */ + fun handleColorAndTextForNounType( + nounType: String, + language: String, + context: Context, + ): Pair { + val suggestionMap = + mapOf( + "PL" to Pair(R.color.annotateOrange, "PL"), + "neuter" to Pair(R.color.annotateGreen, processValueForNouns(language, "N")), + "common of two genders" to Pair(R.color.annotatePurple, processValueForNouns(language, "C")), + "common" to Pair(R.color.annotatePurple, processValueForNouns(language, "C")), + "masculine" to Pair(R.color.annotateBlue, processValueForNouns(language, "M")), + "feminine" to Pair(R.color.annotateRed, processValueForNouns(language, "F")), + ) + return suggestionMap[nounType] ?: Pair(R.color.transparent, context.getString(R.string.suggestion)) + } + + /** + * Processes a noun gender abbreviation for display, converting it based on language-specific conventions. + * @param language The current keyboard language. + * @param text The gender abbreviation (e.g., "M", "F", "N"). + * @return The language-specific display text (e.g., "М" for Russian masculine). + */ + fun processValueForNouns( + language: String, + text: String, + ): String = nounAnnotationConversionDict[language]?.get(text) ?: text + + /** + * Processes a preposition case abbreviation for display, converting it based on language-specific conventions. + * @param language The current keyboard language. + * @param text The case abbreviation (e.g., "Acc", "Dat"). + * @return The language-specific display text (e.g., "Akk" for German accusative). + */ + fun processValuesForPreposition( + language: String, + text: String, + ): String = prepAnnotationConversionDict[language]?.get(text) ?: text +} diff --git a/app/src/main/java/be/scri/helpers/BaseConfig.kt b/app/src/main/java/be/scri/helpers/BaseConfig.kt index c5281124..9281cece 100644 --- a/app/src/main/java/be/scri/helpers/BaseConfig.kt +++ b/app/src/main/java/be/scri/helpers/BaseConfig.kt @@ -53,17 +53,6 @@ open class BaseConfig( get() = prefs.getInt(ACCENT_COLOR, context.resources.getColor(R.color.color_primary)) set(accentColor) = prefs.edit().putInt(ACCENT_COLOR, accentColor).apply() - var useEnglish: Boolean - get() = prefs.getBoolean(USE_ENGLISH, false) - set(useEnglish) { - wasUseEnglishToggled = true - prefs.edit().putBoolean(USE_ENGLISH, useEnglish).commit() - } - - var wasUseEnglishToggled: Boolean - get() = prefs.getBoolean(WAS_USE_ENGLISH_TOGGLED, false) - set(wasUseEnglishToggled) = prefs.edit().putBoolean(WAS_USE_ENGLISH_TOGGLED, wasUseEnglishToggled).apply() - var isUsingSystemTheme: Boolean get() = prefs.getBoolean(IS_USING_SYSTEM_THEME, false) set(isUsingSystemTheme) = prefs.edit().putBoolean(IS_USING_SYSTEM_THEME, isUsingSystemTheme).apply() diff --git a/app/src/main/java/be/scri/helpers/CommonsConstants.kt b/app/src/main/java/be/scri/helpers/CommonsConstants.kt index a8bd63d7..c3fc7924 100644 --- a/app/src/main/java/be/scri/helpers/CommonsConstants.kt +++ b/app/src/main/java/be/scri/helpers/CommonsConstants.kt @@ -14,6 +14,4 @@ const val KEY_COLOR = "key_color" const val BACKGROUND_COLOR = "background_color" const val PRIMARY_COLOR = "primary_color_2" const val ACCENT_COLOR = "accent_color" -const val USE_ENGLISH = "use_english" -const val WAS_USE_ENGLISH_TOGGLED = "was_use_english_toggled" const val IS_USING_SYSTEM_THEME = "is_using_system_theme" diff --git a/app/src/main/java/be/scri/helpers/Config.kt b/app/src/main/java/be/scri/helpers/Config.kt index 8422878f..90ecd725 100644 --- a/app/src/main/java/be/scri/helpers/Config.kt +++ b/app/src/main/java/be/scri/helpers/Config.kt @@ -36,10 +36,6 @@ class Config( get() = prefs.getBoolean(SHOW_POPUP_ON_KEYPRESS, true) set(showPopupOnKeypress) = prefs.edit().putBoolean(SHOW_POPUP_ON_KEYPRESS, showPopupOnKeypress).apply() - var darkTheme: Boolean - get() = prefs.getBoolean(DARK_THEME, true) - set(darkTheme) = prefs.edit().putBoolean(DARK_THEME, darkTheme).apply() - var periodOnDoubleTap: Boolean get() = prefs.getBoolean(PERIOD_ON_DOUBLE_TAP, true) set(periodOnDoubleTap) = prefs.edit().putBoolean(PERIOD_ON_DOUBLE_TAP, periodOnDoubleTap).apply() diff --git a/app/src/main/java/be/scri/helpers/Constants.kt b/app/src/main/java/be/scri/helpers/Constants.kt index ed703368..1ae23f6f 100644 --- a/app/src/main/java/be/scri/helpers/Constants.kt +++ b/app/src/main/java/be/scri/helpers/Constants.kt @@ -15,7 +15,6 @@ const val MAX_KEYS_PER_MINI_ROW = 9 // Shared preferences. const val VIBRATE_ON_KEYPRESS = "vibrate_on_keypress" const val SHOW_POPUP_ON_KEYPRESS = "show_popup_on_keypress" -const val DARK_THEME = "dark_theme" const val PERIOD_ON_DOUBLE_TAP = "period_on_double_tap" const val PERIOD_AND_COMMA = "period_and_comma" const val DISABLE_ACCENT_CHARACTER = "disable_accent_character" diff --git a/app/src/main/java/be/scri/helpers/CustomAdapter.kt b/app/src/main/java/be/scri/helpers/CustomAdapter.kt deleted file mode 100644 index 432836de..00000000 --- a/app/src/main/java/be/scri/helpers/CustomAdapter.kt +++ /dev/null @@ -1,255 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -package be.scri.helpers - -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import android.widget.Toast -import androidx.appcompat.widget.SwitchCompat -import androidx.core.content.ContextCompat.getString -import androidx.recyclerview.widget.RecyclerView -import be.scri.R -import be.scri.models.ItemsViewModel -import be.scri.models.SwitchItem -import be.scri.models.TextItem - -/** - * A RecyclerView adapter that supports multiple view types for different data models. - *

- * This adapter can handle displaying images, switches, and text items in different layouts. - *

- * - * @param mList The list of items to be displayed in the RecyclerView. - * @param context The context used to inflate layouts and handle item actions. - */ -class CustomAdapter( - private val mList: List, - private val context: Context, -) : RecyclerView.Adapter() { - /** - * View types for menu options. - */ - companion object { - private const val VIEW_TYPE_IMAGE = 0 - private const val VIEW_TYPE_SWITCH = 1 - private const val VIEW_TYPE_TEXT = 2 - } - - /** - * Called when RecyclerView needs a new [RecyclerView.ViewHolder] of the given type to represent - * an item. - * - * @param parent The parent view that will hold the new view. - * @param viewType The view type of the new view. - * @return A new [RecyclerView.ViewHolder]. - */ - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): RecyclerView.ViewHolder = - when (viewType) { - VIEW_TYPE_IMAGE -> { - val view = - LayoutInflater - .from(parent.context) - .inflate(R.layout.card_view_with_image, parent, false) - ImageViewHolder(view) - } - VIEW_TYPE_SWITCH -> { - val view = - LayoutInflater - .from(parent.context) - .inflate(R.layout.card_view_with_switch, parent, false) - SwitchViewHolder(view) - } - VIEW_TYPE_TEXT -> { - val view = - LayoutInflater - .from(parent.context) - .inflate(R.layout.card_view_text, parent, false) - TextViewHolder(view) - } - else -> throw IllegalArgumentException("Invalid view type") - } - - /** - * Binds data to the appropriate ViewHolder based on its type. - * - * @param holder The [RecyclerView.ViewHolder] to bind data to. - * @param position The position of the item in the data set. - */ - override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - ) { - when (holder) { - is ImageViewHolder -> bindImageViewHolder(holder, position) - is SwitchViewHolder -> bindSwitchViewHolder(holder, position) - is TextViewHolder -> bindTextViewHolder(holder, position) - } - - val backgroundResource = - when { - mList.size == 1 -> R.drawable.rounded_all_corners - position == 0 -> R.drawable.rounded_top - position == mList.size - 1 -> R.drawable.rounded_bottom - else -> R.drawable.rounded_middle - } - - holder.itemView.setBackgroundResource(backgroundResource) - } - - /** - * Binds data to the [ImageViewHolder]. - * - * @param holder The [ImageViewHolder] to bind data to. - * @param position The position of the item in the data set. - */ - private fun bindImageViewHolder( - holder: ImageViewHolder, - position: Int, - ) { - val item = mList[position] as ItemsViewModel - - holder.imageView.setImageResource(item.image) - holder.textView.text = - with(item.text) { - context.resources.getString(resId, formatArgs.toList()) - } - - holder.imageView2.setImageResource(item.image2) - - holder.itemView.setOnClickListener { - when { - item.url != null -> { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(item.url)) - context.startActivity(intent) - } - item.activity != null -> { - val intent = Intent(context, item.activity) - context.startActivity(intent) - } - item.action != null -> { - item.action.invoke() - } - else -> { - Toast.makeText(context, "No action defined", Toast.LENGTH_SHORT).show() - } - } - } - } - - /** - * Binds data to the [TextViewHolder]. - * - * @param holder The [TextViewHolder] to bind data to. - * @param position The position of the item in the data set. - */ - private fun bindTextViewHolder( - holder: TextViewHolder, - position: Int, - ) { - val item = mList[position] as TextItem - holder.textView.text = getString(context, item.text) - holder.imageView.setImageResource(item.image) - holder.itemView.setOnClickListener { - when (item.action) { - null -> Toast.makeText(context, "No action defined", Toast.LENGTH_SHORT).show() - else -> item.action.invoke() - } - } - if (item.description.isNullOrEmpty()) { - holder.descriptionTextView.visibility = View.GONE - } else { - holder.descriptionTextView.visibility = View.VISIBLE - holder.descriptionTextView.text = item.description - } - } - - /** - * Binds data to the [SwitchViewHolder]. - * - * @param holder The [SwitchViewHolder] to bind data to. - * @param position The position of the item in the data set. - */ - private fun bindSwitchViewHolder( - holder: SwitchViewHolder, - position: Int, - ) { - val item = mList[position] as? SwitchItem - - holder.switchView.isChecked = item!!.isChecked - holder.switchView.setOnCheckedChangeListener(null) - holder.textView.text = item.title - if (item.description.isNullOrEmpty()) { - holder.descriptionTextView.visibility = View.GONE - } else { - holder.descriptionTextView.visibility = View.VISIBLE - holder.descriptionTextView.text = item.description - } - holder.switchView.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - item.isChecked = isChecked - item.action?.invoke() - } else { - item.isChecked = isChecked - item.isChecked = false - item.action2?.invoke() - } - } - } - - /** - * Returns the total number of items in the data set. - * - * @return The number of items. - */ - override fun getItemCount(): Int = mList.size - - /** - * Returns the view type for the item at the given position. - * - * @param position The position of the item. - * @return The view type corresponding to the item. - */ - override fun getItemViewType(position: Int): Int = - when (mList[position]) { - is ItemsViewModel -> VIEW_TYPE_IMAGE - is SwitchItem -> VIEW_TYPE_SWITCH - is TextItem -> VIEW_TYPE_TEXT - else -> throw IllegalArgumentException("Invalid item type") - } - - /** ViewHolder for items with an image. */ - class ImageViewHolder( - itemView: View, - ) : RecyclerView.ViewHolder(itemView) { - val imageView: ImageView = itemView.findViewById(R.id.imgView1) - val textView: TextView = itemView.findViewById(R.id.tvText) - val imageView2: ImageView = itemView.findViewById(R.id.imgView2) - } - - /** ViewHolder for items with a switch. */ - class SwitchViewHolder( - itemView: View, - ) : RecyclerView.ViewHolder(itemView) { - val switchView: SwitchCompat = itemView.findViewById(R.id.checkbox) - val textView: TextView = itemView.findViewById(R.id.tvText) - val descriptionTextView: TextView = itemView.findViewById(R.id.tvSubTitle) - } - - /** ViewHolder for items with text. */ - class TextViewHolder( - itemView: View, - ) : RecyclerView.ViewHolder(itemView) { - val textView: TextView = itemView.findViewById(R.id.tvText) - val imageView: ImageView = itemView.findViewById(R.id.imgView2) - val descriptionTextView: TextView = itemView.findViewById(R.id.tvSubTitle) - } -} diff --git a/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt b/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt index e711978f..bff3743d 100644 --- a/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt +++ b/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt @@ -1,43 +1,109 @@ // SPDX-License-Identifier: GPL-3.0-or-later - package be.scri.helpers import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteException import android.util.Log +import java.io.File import java.io.FileOutputStream +import java.io.IOException /** - * A helper class to facilitate loading and managing database files for different languages in the Scribe keyboard. - * This class is responsible for loading language-specific SQLite database files from assets into the app's - * internal storage. - * - * @param context The context used to access the app's resources and file system. + * Manages access to all SQLite database files. + * Ensures DB files are copied from assets and provides read-only connections. + * @param context The application context. */ class DatabaseFileManager( private val context: Context, ) { /** - * Loads a database file for a specific language. - *

- * The method checks if the database file for the given language already exists in the app's internal - * storage. If it doesn't exist, it copies the corresponding database file from the assets folder to the - * internal storage. - *

+ * Companion object used for logging and shared constants. + * + * @see DatabaseFileManager + */ + companion object { + private const val TAG = "DatabaseFileManager" + } + + /** + * Retrieves a read-only [SQLiteDatabase] instance for a specific language's data. + * It handles copying the database from assets if it doesn't exist locally. + * + * @param language The language code (e.g., "DE", "FR") used to determine the database filename. + * @return An open, read-only [SQLiteDatabase] instance, or `null` on failure. + */ + fun getLanguageDatabase(language: String): SQLiteDatabase? { + val dbName = "${language}LanguageData.sqlite" + return getDatabase(dbName, "data/$dbName") + } + + /** + * Retrieves a read-only [SQLiteDatabase] instance for the translation data. + * It handles copying the database from assets if it doesn't exist locally. * - * @param language The language for which the database file is being loaded. + * @return An open, read-only [SQLiteDatabase] instance, or `null` on failure. */ - fun loadDatabaseFile(language: String) { - val databaseName = "${language}LanguageData.sqlite" - val dbFile = context.getDatabasePath(databaseName) - Log.i("ALPHA", "Loaded Database") + fun getTranslationDatabase(): SQLiteDatabase? { + val dbName = "TranslationData.sqlite" + return getDatabase(dbName, "data/$dbName") + } + + /** + * A generic function to get a database. It ensures the database file exists in the app's + * private storage (copying it from assets if necessary) and then opens a read-only connection. + * + * @param dbName The filename of the database (e.g., "ENLanguageData.sqlite"). + * @param assetPath The path to the database file within the app's assets folder + * (e.g., "data/ENLanguageData.sqlite"). + * @return An open, read-only [SQLiteDatabase], or `null` if copying or opening fails. + */ + private fun getDatabase( + dbName: String, + assetPath: String, + ): SQLiteDatabase? { + val dbFile = context.getDatabasePath(dbName) if (!dbFile.exists()) { - context.assets.open("data/$databaseName").use { inputStream -> + if (!copyDatabaseFromAssets(dbFile, assetPath)) { + return null + } + } + + // At this point, the file should exist. Attempt to open it. + return try { + SQLiteDatabase.openDatabase(dbFile.path, null, SQLiteDatabase.OPEN_READONLY) + } catch (e: SQLiteException) { + Log.e(TAG, "Failed to open database $dbName", e) + null + } + } + + /** + * Copies a database file from the app's assets folder to its internal database directory. + * This is called when a database is accessed for the first time. + * + * @param dbFile The destination [File] in the app's database directory. + * @param assetPath The path to the source file within the assets folder. + * @return `true` if the copy was successful, `false` otherwise. + */ + private fun copyDatabaseFromAssets( + dbFile: File, + assetPath: String, + ): Boolean = + try { + dbFile.parentFile?.mkdirs() + + context.assets.open(assetPath).use { inputStream -> FileOutputStream(dbFile).use { outputStream -> inputStream.copyTo(outputStream) - outputStream.flush() } } + Log.i(TAG, "Database copied from assets to ${dbFile.path}") + true + } catch (e: IOException) { + Log.e(TAG, "Error copying database from assets to ${dbFile.path}", e) + dbFile.delete() + false } - } } diff --git a/app/src/main/java/be/scri/helpers/DatabaseHelper.kt b/app/src/main/java/be/scri/helpers/DatabaseHelper.kt deleted file mode 100644 index 3f30c605..00000000 --- a/app/src/main/java/be/scri/helpers/DatabaseHelper.kt +++ /dev/null @@ -1,215 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -package be.scri.helpers - -import DataContract -import android.content.Context -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteOpenHelper - -/** - * A helper class to facilitate database calls for Scribe keyboard commands. - * This class handles interactions with the database, including loading the database - * for a specific language and querying the required data related to words, emoji keywords, - * gender, plural forms, and case annotations. - * - * @param context The context used to access the app's resources and database. - */ -@Suppress("TooManyFunctions") -class DatabaseHelper( - context: Context, -) : SQLiteOpenHelper( - context, - null, - null, - DATABASE_VERSION, - ) { - private val dbManagers = DatabaseManagers(context) - - /** - * The database version of the application. - */ - companion object { - private const val DATABASE_VERSION = 1 - } - - /** - * Creates the database tables. Currently, this method does nothing as no database schema is defined. - * - * @param db The database to be created. - */ - override fun onCreate(db: SQLiteDatabase) { - // No operation for now - } - - /** - * Handles database upgrades. Currently, this method does nothing as there is no upgrade strategy defined. - * - * @param db The database to be upgraded. - * @param oldVersion The old version of the database. - * @param newVersion The new version of the database. - */ - override fun onUpgrade( - db: SQLiteDatabase, - oldVersion: Int, - newVersion: Int, - ) { - // No operation for now - } - - /** - * Loads the database for a specific language. - * This method checks if the required database file is available and loads it if necessary. - * - * @param language The language for which the database is being loaded. - */ - fun loadDatabase(language: String) { - dbManagers.fileManager.loadDatabaseFile(language) - } - - /** - * Retrieves the required data for a specific language. - * - * @param language The language for which the data is being fetched. - * @return The [DataContract] containing the data for the specified language, or null if not found. - */ - private fun getRequiredData(language: String): DataContract? = - dbManagers.contractLoader.loadContract( - language, - ) - - /** - * Retrieves the emoji keywords for a specific language. - * - * @param language The language for which the emoji keywords are being fetched. - * @return A [HashMap] mapping emoji keywords to their corresponding list of keywords. - */ - fun getEmojiKeywords(language: String): HashMap> = - dbManagers.emojiManager.getEmojiKeywords( - language, - ) - - /** - * Finds the gender of words for a given language. - * - * @param language The language for which the gender of words is being determined. - * @return A [HashMap] mapping words to their corresponding gender. - */ - fun findGenderOfWord(language: String): HashMap> = - dbManagers.genderManager.findGenderOfWord( - language, - getRequiredData(language), - ) - - /** - * Checks if a word is plural in a given language. - * - * @param language The language for which the plural form of the word is being checked. - * @return A list of words that are considered plural, or null if not found. - */ - fun checkIfWordIsPlural(language: String): List? = - dbManagers.pluralManager.checkIfWordIsPlural( - language, - getRequiredData(language), - ) - - /** - * Retrieves the maximum length of emoji keywords. - * - * @return The maximum length of an emoji keyword. - */ - fun getEmojiMaxKeywordLength(): Int = dbManagers.emojiManager.maxKeywordLength - - /** - * Finds the case annotations for prepositions in a given language. - * This method is only applicable for German (DE) and Russian (RU). - * - * @param language The language for which case annotations for prepositions are being fetched. - * @return A [HashMap] mapping prepositions to their corresponding case annotations. - */ - fun findCaseAnnnotationForPreposition(language: String): HashMap> = - if (language != "DE" && language != "RU") { - hashMapOf() - } else { - dbManagers.prepositionManager.getCaseAnnotations( - language, - ) - } - - /** - * Retrieves the plural representation of a noun for a specific language. - * - * @param language The language for which the plural representation is being fetched. - * @param noun The noun for which the plural form is being queried. - * @return A [Map] containing the singular and plural forms of the noun. - */ - fun getPluralRepresentation( - language: String, - noun: String, - ): Map = - dbManagers.pluralManager.queryPluralRepresentation( - language, - getRequiredData(language), - noun, - ) - - /** - * Retrieves the translation of a given word between the source and destination languages. - * - * This function determines the source and destination language ISO codes based on the provided - * language name, and then fetches the translation of the specified word using those language codes. - * - * @param language The language name (e.g., "english") to determine the source and destination languages. - * @param word The word whose translation is to be fetched. - * @return The translation of the given word in the destination language, - * or an empty string if no translation is found. - */ - fun getTranslationSourceAndDestination( - language: String, - word: String, - ): String { - val sourceAndDestination = dbManagers.translationDataManager.getSourceAndDestinationLanguage(language) - return dbManagers.translationDataManager.getTranslationDataForAWord(sourceAndDestination, word) - } - - /** - * Retrieves conjugation data for a word in a given language. - * - * Delegates to [ConjugateDataManager] after fetching language-specific data. - * - * @param language The language code (e.g., "en", "es"). - * @param word The word to conjugate. - * @return A map of conjugation labels (e.g., "present") to lists of conjugated forms. - * Returns an empty map if no data is found. - * @throws Exception If data retrieval fails. - */ - fun getConjugateData( - language: String, - word: String, - ): MutableMap>> = - dbManagers.conjugateDataManager.getTheConjugateLabels( - language, - getRequiredData(language), - word, - ) - - /** - * Retrieves conjugate labels for a given language. - * - * Fetches data for the language and extracts conjugate headings. - * - * @param language The language code (e.g., "en", "es"). - * @return A set of conjugate labels. Returns an empty set if none are found. - * @throws Exception if there's an error accessing or processing data. - * @see getRequiredData - * @see dbManagers.conjugateDataManager.extractConjugateHeadings - */ - fun getConjugateLabels( - language: String, - word: String, - ): Set = - dbManagers.conjugateDataManager.extractConjugateHeadings( - getRequiredData(language), - word, - ) -} diff --git a/app/src/main/java/be/scri/helpers/DatabaseManagers.kt b/app/src/main/java/be/scri/helpers/DatabaseManagers.kt index 8b1530f5..0392178f 100644 --- a/app/src/main/java/be/scri/helpers/DatabaseManagers.kt +++ b/app/src/main/java/be/scri/helpers/DatabaseManagers.kt @@ -1,34 +1,44 @@ // SPDX-License-Identifier: GPL-3.0-or-later - package be.scri.helpers -import ContractDataLoader -import EmojiDataManager -import GenderDataManager -import PluralFormsManager +import DataContract import android.content.Context -import be.scri.helpers.keyboardDBHelper.ConjugateDataManager -import be.scri.helpers.keyboardDBHelper.PrepositionDataManager -import be.scri.helpers.keyboardDBHelper.TranslationDataManager +import be.scri.helpers.data.ConjugateDataManager +import be.scri.helpers.data.ContractDataLoader +import be.scri.helpers.data.EmojiDataManager +import be.scri.helpers.data.GenderDataManager +import be.scri.helpers.data.PluralFormsManager +import be.scri.helpers.data.PrepositionDataManager +import be.scri.helpers.data.TranslationDataManager /** - * A helper class that manages various database-related operations - * and data managers for the Scribe keyboard. - * This class provides access to all the necessary managers that interact - * with the database for different features such as contracts, emojis, gender - * data, plural forms, and prepositions. - * - * @param context The context used to access the app's resources and database. + * The primary entry point for all data-related operations. + * This class acts as a facade, providing simple access to specialized managers + * for features like emojis, gender, plurals, and conjugations. + * @param context The application context. */ class DatabaseManagers( context: Context, ) { - val fileManager = DatabaseFileManager(context) - val contractLoader = ContractDataLoader(context) - val emojiManager = EmojiDataManager(context) - val genderManager = GenderDataManager(context) - val pluralManager = PluralFormsManager(context) - val prepositionManager = PrepositionDataManager(context) - val translationDataManager = TranslationDataManager(context) - val conjugateDataManager = ConjugateDataManager(context) + // Centralized providers for file and contract loading. + private val fileManager = DatabaseFileManager(context) + private val contractLoader = ContractDataLoader(context) + + // Specialized data managers, ready for use. + val emojiManager = EmojiDataManager(fileManager) + val genderManager = GenderDataManager(fileManager) + val pluralManager = PluralFormsManager(fileManager) + val prepositionManager = PrepositionDataManager(fileManager) + val translationDataManager = TranslationDataManager(context, fileManager) + val conjugateDataManager = ConjugateDataManager(fileManager) + + /** + * A facade method to load the data contract for a given language. + * It delegates the loading and parsing logic to the [ContractDataLoader]. + * + * @param language The language code (e.g., "DE", "FR") for which to load the contract. + * @return A [DataContract] object containing the language's structural metadata, or `null` + * if not found or on error. + */ + fun getLanguageContract(language: String): DataContract? = contractLoader.loadContract(language) } diff --git a/app/src/main/java/be/scri/helpers/EmojiUtils.kt b/app/src/main/java/be/scri/helpers/EmojiUtils.kt new file mode 100644 index 00000000..0001a3b9 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/EmojiUtils.kt @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers + +import android.view.inputmethod.InputConnection + +/** + * Utility object for handling emoji-related operations. + */ +object EmojiUtils { + private const val DATA_SIZE_2 = 2 + + /** + * Checks if the end of a string is likely an emoji. + * This is a heuristic check based on common emoji Unicode ranges. + * @param word The string to check. + * @return `true` if the end of the string contains an emoji character, `false` otherwise. + */ + fun isEmoji(word: String?): Boolean { + if (word.isNullOrEmpty() || word.length < DATA_SIZE_2) { + return false + } + + val lastTwoChars = + word.substring( + word.length - DATA_SIZE_2, + ) + + val emojiRegex = + Regex( + "[\\uD83C\\uDF00-\\uD83E\\uDDFF]" + + "|[\\u2600-\\u26FF]" + + "|[\\u2700-\\u27BF]", + ) + + return emojiRegex.containsMatchIn(lastTwoChars) + } + + /** + * Inserts an emoji into the text field, replacing the keyword that triggered it if found. + * @param emoji The emoji character to insert. + */ + fun insertEmoji( + emoji: String, + ic: InputConnection, + emojiKeywords: HashMap>?, + emojiMaxKeywordLength: Int, + ) { + val maxLookBack = emojiMaxKeywordLength.coerceAtLeast(1) + ic.beginBatchEdit() + try { + val prevText = ic.getTextBeforeCursor(maxLookBack, 0)?.toString() ?: "" + val lastSpace = prevText.lastIndexOf(' ') + when { + prevText.isEmpty() || + (lastSpace != -1 && lastSpace == prevText.length - 1) -> { + ic.commitText(emoji, 1) + } + + lastSpace != -1 -> { + val lastWord = prevText.substring(lastSpace + 1) + + if (emojiKeywords?.containsKey(lastWord.lowercase()) == true) { + ic.deleteSurroundingText(lastWord.length, 0) + } + + ic.commitText(emoji, 1) + } + + else -> { + if (emojiKeywords?.containsKey(prevText.lowercase()) == true) { + ic.deleteSurroundingText(prevText.length, 0) + } + + ic.commitText(emoji, 1) + } + } + } finally { + ic.endBatchEdit() + } + } +} diff --git a/app/src/main/java/be/scri/helpers/KeyHandler.kt b/app/src/main/java/be/scri/helpers/KeyHandler.kt index 71998352..a9564add 100644 --- a/app/src/main/java/be/scri/helpers/KeyHandler.kt +++ b/app/src/main/java/be/scri/helpers/KeyHandler.kt @@ -9,257 +9,197 @@ import be.scri.services.GeneralKeyboardIME import be.scri.services.GeneralKeyboardIME.ScribeState /** - * Handles key events for the EnglishKeyboardIME. + * Handles key events for the [GeneralKeyboardIME]. + * This class processes raw key codes, determines the appropriate action based on the + * current keyboard state and the key pressed, and delegates to specific handlers + * or directly interacts with the [GeneralKeyboardIME] instance. + * + * @property ime The [GeneralKeyboardIME] instance this handler is associated with. */ @Suppress("TooManyFunctions") class KeyHandler( private val ime: GeneralKeyboardIME, ) { + private val suggestionHandler = SuggestionHandler(ime) + private val spaceKeyProcessor = SpaceKeyProcessor(ime, suggestionHandler) + + /** Tracks if the last key pressed was a space, used for "period on double space" logic. */ + private var wasLastKeySpace: Boolean = false + + private companion object { + private const val TAG = "KeyHandler" + } + /** - * Processes the given key code and performs the corresponding action. + * Handles a key press event. This is the main entry point for processing key codes from the keyboard. + * It routes the key code to the appropriate handler method based on the code and the current IME state. + * + * @param code The integer code of the key that was pressed (e.g., from [KeyboardBase]). + * @param language The current keyboard language. */ fun handleKey( code: Int, language: String, ) { val inputConnection = ime.currentInputConnection - if (!isValidState(inputConnection)) return + if (!isValidState(inputConnection)) { + wasLastKeySpace = false + return + } resetShiftIfNeeded(code) + val previousWasLastKeySpace = wasLastKeySpace + var resetWLSAtEnd = true + + if (code != KeyboardBase.KEYCODE_SPACE) { + suggestionHandler.clearLinguisticSuggestions() + } + when (code) { KeyboardBase.KEYCODE_TAB -> commitTab(inputConnection) KeyboardBase.KEYCODE_CAPS_LOCK -> handleCapsLock() KeyboardBase.KEYCODE_DELETE -> handleDeleteKey() - KeyboardBase.KEYCODE_SHIFT -> handleShiftKey() + KeyboardBase.KEYCODE_SHIFT -> { + handleShiftKey() + wasLastKeySpace = previousWasLastKeySpace + resetWLSAtEnd = false + } KeyboardBase.KEYCODE_ENTER -> handleEnterKey() KeyboardBase.KEYCODE_MODE_CHANGE -> handleModeChangeKey() - KeyboardBase.KEYCODE_SPACE -> handleKeycodeSpace() - KeyboardBase.KEYCODE_LEFT_ARROW, - KeyboardBase.KEYCODE_RIGHT_ARROW, - -> handleArrowKey(code == KeyboardBase.KEYCODE_RIGHT_ARROW) - KeyboardBase.DISPLAY_LEFT, - KeyboardBase.DISPLAY_RIGHT, - -> handleConjugateKeys(code, context = ime.applicationContext) - KeyboardBase.CODE_FPS, - KeyboardBase.CODE_FPP, - KeyboardBase.CODE_SPS, - KeyboardBase.CODE_SPP, - KeyboardBase.CODE_TPS, - KeyboardBase.CODE_TPP, - KeyboardBase.CODE_TR, - KeyboardBase.CODE_TL, - KeyboardBase.CODE_BR, - KeyboardBase.CODE_BL, - KeyboardBase.CODE_1X1, - KeyboardBase.CODE_1X3_LEFT, - KeyboardBase.CODE_1X3_CENTER, - KeyboardBase.CODE_1X3_RIGHT, - KeyboardBase.CODE_2X1_TOP, - KeyboardBase.CODE_2X1_BOTTOM, - -> - returnTheConjugateLabels( - code, - language = language, - ) + KeyboardBase.KEYCODE_SPACE -> { + wasLastKeySpace = spaceKeyProcessor.processKeycodeSpace(previousWasLastKeySpace) + resetWLSAtEnd = false + } + in KeyboardBase.NAVIGATION_KEYS -> handleNavigationKey(code) + in KeyboardBase.SCRIBE_VIEW_KEYS -> handleScribeViewKey(code, language) else -> handleDefaultKey(code) } - updateKeyboardState(code) + if (resetWLSAtEnd) { + wasLastKeySpace = false + } } + /** + * Checks if the IME is in a valid state to process key events. + * A valid state requires a non-null keyboard instance and an active input connection. + * + * @param inputConnection The current input connection. + * @return `true` if the state is valid, `false` otherwise. + */ private fun isValidState(inputConnection: InputConnection?): Boolean = ime.keyboard != null && inputConnection != null - private fun resetShiftIfNeeded(code: Int) { - if (code != KeyboardBase.KEYCODE_SHIFT) { - ime.lastShiftPressTS = 0 - } - } - - private fun commitTab(inputConnection: InputConnection) { - inputConnection.commitText("\t", GeneralKeyboardIME.COMMIT_TEXT_CURSOR_POSITION) - } - /** - * Updates the keyboard state after each key press, including checking for emojis and plural words. - * Also updates the shift key state if necessary. - * @param code the key code that was pressed. + * Resets the shift key's double-tap timestamp if the pressed key is not the shift key itself. + * This is used to manage the "shift lock on double-tap" feature. + * + * @param code The integer code of the key that was pressed. */ - private fun updateKeyboardState(code: Int) { - ime.lastWord = ime.getLastWordBeforeCursor() - Log.d("Debug", "${ime.lastWord}") - ime.autoSuggestEmojis = ime.emojiKeywords?.let { ime.findEmojisForLastWord(it, ime.lastWord) } - ime.checkIfPluralWord = ime.pluralWords?.let { ime.findWhetherWordIsPlural(it, ime.lastWord) } == true - - Log.i(TAG, "${ime.checkIfPluralWord}") - Log.d("Debug", "${ime.autoSuggestEmojis}") - Log.d(TAG, "${ime.nounTypeSuggestion}") - ime.updateButtonText(ime.emojiAutoSuggestionEnabled, ime.autoSuggestEmojis) - - if (ime.currentState == ScribeState.IDLE || ime.currentState == ScribeState.SELECT_COMMAND) { - ime.updateAutoSuggestText(isPlural = ime.checkIfPluralWord) - } - + private fun resetShiftIfNeeded(code: Int) { if (code != KeyboardBase.KEYCODE_SHIFT) { - ime.updateShiftKeyState() + ime.lastShiftPressTS = 0 } } /** - * Handles left/right conjugate key presses. - * - * Updates the "conjugate_index" preference to cycle through conjugate options. + * Commits a tab character to the current input connection. * - * @param code The pressed key code (`KeyboardBase.DISPLAY_LEFT`, `KeyboardBase.DISPLAY_RIGHT`, or other). - * @param context Application context for accessing shared preferences. - * - * - `KeyboardBase.DISPLAY_LEFT`: Increments the conjugate index. - * - `KeyboardBase.DISPLAY_RIGHT`: Decrements the conjugate index. - * - Other: Index remains unchanged. - * - * The function also saves the new index and triggers the IME to update the toolbar. + * @param inputConnection The active input connection. */ - private fun handleConjugateKeys( - code: Int, - context: Context, - ) { - Log.i("ALPHA", "Conjugate key was clicked") - val sharedPreferences = context.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) - val editor = sharedPreferences.edit() - val currentValue = sharedPreferences.getInt("conjugate_index", 0) - val newValue = - when (code) { - KeyboardBase.DISPLAY_LEFT -> currentValue + 1 - KeyboardBase.DISPLAY_RIGHT -> currentValue - 1 - else -> currentValue - } - editor.putInt("conjugate_index", newValue) - editor.apply() - ime.switchToToolBar() - Log.i("ALPHA", "$newValue") + private fun commitTab(inputConnection: InputConnection) { + inputConnection.commitText("\t", GeneralKeyboardIME.COMMIT_TEXT_CURSOR_POSITION) } /** - * Handles the Caps Lock key event. - * Toggles the shift state between OFF and LOCKED, and invalidates the keyboard view. + * Toggles the state of the caps lock on the keyboard. + * If the shift state is off, it changes to locked; otherwise, it turns it off. */ private fun handleCapsLock() { - ime.keyboard?.let { + ime.keyboard?.let { kb -> val newState = - when (it.mShiftState) { - KeyboardBase.SHIFT_OFF -> KeyboardBase.SHIFT_LOCKED - else -> KeyboardBase.SHIFT_OFF + if (kb.mShiftState == KeyboardBase.SHIFT_OFF) { + KeyboardBase.SHIFT_LOCKED + } else { + KeyboardBase.SHIFT_OFF } - if (it.setShifted(newState)) { + if (kb.setShifted(newState)) { ime.keyboardView?.invalidateAllKeys() } } } /** - * Handles a default key event (for keys not explicitly defined in other methods). - * Executes the necessary actions based on the current state and keyboard mode. - * @param code the key code of the pressed key. + * Handles a non-special character key press. It delegates the character insertion to the IME + * and then triggers a re-evaluation of word suggestions. + * + * @param code The character code of the key pressed. */ private fun handleDefaultKey(code: Int) { - when (ime.currentState) { - ScribeState.IDLE, - ScribeState.SELECT_COMMAND, - -> ime.handleElseCondition(code, ime.keyboardMode, binding = null) - ScribeState.INVALID -> { - ime.moveToIdleState() - ime.handleElseCondition(code, ime.keyboardMode, ime.keyboardBinding) - } - else -> ime.handleElseCondition(code, ime.keyboardMode, ime.keyboardBinding, commandBarState = true) - } + val isCommandBarActive = + ime.currentState == ScribeState.TRANSLATE || + ime.currentState == ScribeState.CONJUGATE || + ime.currentState == ScribeState.PLURAL + ime.handleElseCondition(code, ime.keyboardMode, isCommandBarActive) - ime.disableAutoSuggest() + if (ime.currentState == ScribeState.IDLE) { + suggestionHandler.processEmojiSuggestions(ime.getLastWordBeforeCursor()) + } else if (isCommandBarActive) { + suggestionHandler.clearAllSuggestionsAndHideButtonUI() + } } /** - * Handles the Delete key event. - * Deletes the previous character if in an appropriate state and updates the keyboard view. + * Handles the delete/backspace key press. It delegates the deletion logic to the IME + * and then triggers a re-evaluation of word suggestions based on the new text. */ private fun handleDeleteKey() { - val shouldDelete = - when (ime.currentState) { - ScribeState.IDLE, ScribeState.SELECT_COMMAND -> false - else -> true - } - ime.handleDelete(shouldDelete, ime.keyboardBinding) - ime.keyboardView!!.invalidateAllKeys() - ime.disableAutoSuggest() + val isCommandBarActive = + ime.currentState == ScribeState.TRANSLATE || + ime.currentState == ScribeState.CONJUGATE || + ime.currentState == ScribeState.PLURAL + ime.handleDelete(isCommandBarActive) + + // Only process suggestions if the state is exactly IDLE. + if (ime.currentState == ScribeState.IDLE) { + suggestionHandler.processEmojiSuggestions(ime.getLastWordBeforeCursor()) + } } /** - * Handles the Shift key event. - * Updates the keyboard to reflect the new letter case and invalidates the keyboard view. + * Handles the shift key press. It delegates the logic for changing the shift state to the IME + * and then invalidates the keyboard view to reflect the change. */ private fun handleShiftKey() { ime.handleKeyboardLetters(ime.keyboardMode, ime.keyboardView) - ime.keyboardView!!.invalidateAllKeys() - ime.disableAutoSuggest() + ime.keyboardView?.invalidateAllKeys() } /** - * Handles the Enter key event. - * Executes different actions depending on the current state of the keyboard. + * Handles the enter key press by delegating the complex logic (e.g., command execution, + * editor action) to the main IME class. */ private fun handleEnterKey() { - Log.d(TAG, "handleEnterKey ${ime.currentState}") - if (ime.currentState == ScribeState.IDLE || - ime.currentState == ScribeState.SELECT_COMMAND || - ime.currentState == ScribeState.INVALID - ) { - ime.handleKeycodeEnter(ime.keyboardBinding, false) - } else if (ime.currentState == ScribeState.CONJUGATE) { - ime.handleKeycodeEnter(ime.keyboardBinding, false) - ime.currentState = ScribeState.SELECT_VERB_CONJUNCTION - ime.updateUI() - } else { - ime.handleKeycodeEnter(ime.keyboardBinding, true) - } - ime.disableAutoSuggest() + ime.handleKeycodeEnter() } /** - * Handles the Mode Change key event. - * Switches the keyboard mode and updates the state accordingly. + * Handles the mode change key press (e.g., switching to the symbol keyboard). + * It delegates the logic to the IME and clears any active suggestions. */ private fun handleModeChangeKey() { ime.handleModeChange(ime.keyboardMode, ime.keyboardView, ime) - ime.disableAutoSuggest() - } - - private fun returnTheConjugateLabels( - code: Int, - language: String, - ) { - if (!ime.returnIsSubsequentRequired()) { - ime.handleConjugateKeys(code, false) - ime.currentState = ScribeState.IDLE - ime.switchToCommandToolBar() - ime.updateUI() - ime.saveConjugateModeType( - language, - isSubsequentArea = false, - ) - } else { - ime.setupConjugateKeysByLanguage(conjugateIndex = 0, true) - ime.switchToToolBar(isSubsequentArea = false) - val word = ime.handleConjugateKeys(code, false) - ime.setupConjugateSubView(ime.returnSubsequentData(), word) - } + suggestionHandler.clearAllSuggestionsAndHideButtonUI() } /** - * Handles the Arrow key event (either left or right). - * Moves the cursor in the appropriate direction based on the pressed key. - * @param isRight true if the right arrow key is pressed, false if the left arrow key is pressed. + * Handles navigation keys (left/right arrows). + * @param code The key code, used to determine direction. */ - private fun handleArrowKey(isRight: Boolean) { + private fun handleNavigationKey(code: Int) { + val isRight = code == KeyboardBase.KEYCODE_RIGHT_ARROW ime.currentInputConnection?.let { ic -> val currentPos = ic.getTextBeforeCursor(GeneralKeyboardIME.MAX_TEXT_LENGTH, 0)?.length ?: 0 val newPos = @@ -270,25 +210,75 @@ class KeyHandler( (currentPos - 1).coerceAtLeast(0) } ic.setSelection(newPos, newPos) + suggestionHandler.processEmojiSuggestions(ime.getLastWordBeforeCursor()) } } /** - * Handles the Space key event. - * Inserts a space character and performs any necessary updates based on the current state. + * Handles all special keys related to the Scribe command views (conjugation, etc.). + * @param code The key code of the pressed key. + * @param language The current keyboard language. */ - private fun handleKeycodeSpace() { - val code = KeyboardBase.KEYCODE_SPACE - if (ime.currentState == ScribeState.IDLE || ime.currentState == ScribeState.SELECT_COMMAND) { - ime.handleElseCondition(code, ime.keyboardMode, binding = null) - ime.updateAutoSuggestText(isPlural = ime.checkIfPluralWord) - } else { - ime.handleElseCondition(code, ime.keyboardMode, ime.keyboardBinding, commandBarState = true) - ime.disableAutoSuggest() + private fun handleScribeViewKey( + code: Int, + language: String, + ) { + when (code) { + KeyboardBase.DISPLAY_LEFT, KeyboardBase.DISPLAY_RIGHT -> + handleConjugateCycleKeys(code, ime.applicationContext) + else -> + handleConjugateSelectionKey(code, language) } } - private companion object { - const val TAG = "KeyHandler" + /** + * Handles the cycle keys (< >) in the conjugation view, which move between different + * tenses or moods (e.g., Present, Past, Future). It updates a shared preference to + * track the current index and refreshes the UI. + * + * @param code The key code, used to determine direction (left or right). + * @param context The application context to access SharedPreferences. + */ + private fun handleConjugateCycleKeys( + code: Int, + context: Context, + ) { + val sharedPreferences = context.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) + val editor = sharedPreferences.edit() + var currentValue = sharedPreferences.getInt("conjugate_index", 0) + + if (code == KeyboardBase.DISPLAY_LEFT) { + currentValue-- + } else if (code == KeyboardBase.DISPLAY_RIGHT) { + currentValue++ + } + + editor.putInt("conjugate_index", currentValue) + editor.apply() + + ime.updateUI() + Log.i(TAG, "New conjugate_index: $currentValue") + } + + /** + * Handles a key press on a specific conjugation key (e.g., "1st Person Singular"). + * If the conjugation has multiple forms, it triggers a sub-view; otherwise, it commits + * the selected form to the input field and returns to the idle state. + * + * @param code The key code of the selected conjugation. + * @param language The current keyboard language. + */ + private fun handleConjugateSelectionKey( + code: Int, + language: String, + ) { + if (!ime.returnIsSubsequentRequired()) { + ime.handleConjugateKeys(code, false) + ime.moveToIdleState() + ime.saveConjugateModeType(language, isSubsequentArea = false) + } else { + val word = ime.handleConjugateKeys(code, true) + ime.setupConjugateSubView(ime.returnSubsequentData(), word) + } } } diff --git a/app/src/main/java/be/scri/helpers/KeyboardBase.kt b/app/src/main/java/be/scri/helpers/KeyboardBase.kt index c2a2e0bd..f42c20e0 100644 --- a/app/src/main/java/be/scri/helpers/KeyboardBase.kt +++ b/app/src/main/java/be/scri/helpers/KeyboardBase.kt @@ -18,6 +18,7 @@ import androidx.annotation.XmlRes import be.scri.R import org.xmlpull.v1.XmlPullParserException import java.io.IOException +import kotlin.math.roundToInt /** * Loads an XML description of a keyboard and stores the attributes of the keys. A keyboard consists of rows of keys. @@ -77,6 +78,7 @@ class KeyboardBase { const val KEYCODE_RIGHT_ARROW = -56 const val SHIFT_OFF = 0 const val SHIFT_ON = 1 + const val SHIFT_ON_PERMANENT = 2 const val SHIFT_LOCKED = 2 const val DISPLAY_LEFT = 2002 const val DISPLAY_RIGHT = 2001 @@ -96,6 +98,36 @@ class KeyboardBase { const val CODE_1X3_RIGHT = 1023 const val CODE_2X1_TOP = 1031 const val CODE_2X1_BOTTOM = 1032 + private const val MAX_KEYS_PER_MINI_ROW = 10 + + // Sets for grouping key codes to reduce complexity in KeyHandler. + val NAVIGATION_KEYS = + setOf( + KEYCODE_LEFT_ARROW, + KEYCODE_RIGHT_ARROW, + ) + + val SCRIBE_VIEW_KEYS = + setOf( + DISPLAY_LEFT, + DISPLAY_RIGHT, + CODE_FPS, + CODE_FPP, + CODE_SPS, + CODE_SPP, + CODE_TPS, + CODE_TPP, + CODE_TR, + CODE_TL, + CODE_BR, + CODE_BL, + CODE_1X1, + CODE_1X3_LEFT, + CODE_1X3_CENTER, + CODE_1X3_RIGHT, + CODE_2X1_TOP, + CODE_2X1_BOTTOM, + ) /** * Retrieves the dimension or fraction value from the attributes, adjusting the base value if necessary. @@ -115,7 +147,7 @@ class KeyboardBase { val value = a.peekValue(index) ?: return defValue return when (value.type) { TypedValue.TYPE_DIMENSION -> a.getDimensionPixelOffset(index, defValue) - TypedValue.TYPE_FRACTION -> Math.round(a.getFraction(index, base, base, defValue.toFloat())) + TypedValue.TYPE_FRACTION -> a.getFraction(index, base, base, defValue.toFloat()).roundToInt() else -> defValue } } @@ -322,7 +354,6 @@ class KeyboardBase { a.recycle() } - // Create an empty key with no attributes. init { height = parent.defaultHeight width = parent.defaultWidth diff --git a/app/src/main/java/be/scri/helpers/LanguageMappingConstants.kt b/app/src/main/java/be/scri/helpers/LanguageMappingConstants.kt new file mode 100644 index 00000000..b6dfb4f9 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/LanguageMappingConstants.kt @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers + +import be.scri.helpers.english.ENInterfaceVariables +import be.scri.helpers.french.FRInterfaceVariables +import be.scri.helpers.german.DEInterfaceVariables +import be.scri.helpers.italian.ITInterfaceVariables +import be.scri.helpers.portuguese.PTInterfaceVariables +import be.scri.helpers.russian.RUInterfaceVariables +import be.scri.helpers.spanish.ESInterfaceVariables +import be.scri.helpers.swedish.SVInterfaceVariables + +/** + * Object containing constant mappings related to language-specific data and UI elements. + * This includes conversions for grammatical annotations and placeholders for UI text. + */ +object LanguageMappingConstants { + val prepAnnotationConversionDict = + mapOf( + "German" to mapOf("Acc" to "Akk"), + "Russian" to + mapOf( + "Acc" to "Вин", + "Dat" to "Дат", + "Gen" to "Род", + "Loc" to "Мес", + "Pre" to "Пре", + "Ins" to "Инс", + ), + ) + + val nounAnnotationConversionDict = + mapOf( + "Swedish" to mapOf("C" to "U"), + "Russian" to mapOf("F" to "Ж", "M" to "М", "N" to "Н"), + ) + + val translatePlaceholder = + mapOf( + "EN" to ENInterfaceVariables.TRANSLATE_KEY_LBL, + "ES" to ESInterfaceVariables.TRANSLATE_KEY_LBL, + "DE" to DEInterfaceVariables.TRANSLATE_KEY_LBL, + "IT" to ITInterfaceVariables.TRANSLATE_KEY_LBL, + "FR" to FRInterfaceVariables.TRANSLATE_KEY_LBL, + "PT" to PTInterfaceVariables.TRANSLATE_KEY_LBL, + "RU" to RUInterfaceVariables.TRANSLATE_KEY_LBL, + "SV" to SVInterfaceVariables.TRANSLATE_KEY_LBL, + ) + + val conjugatePlaceholder = + mapOf( + "EN" to ENInterfaceVariables.CONJUGATE_KEY_LBL, + "ES" to ESInterfaceVariables.CONJUGATE_KEY_LBL, + "DE" to DEInterfaceVariables.CONJUGATE_KEY_LBL, + "IT" to ITInterfaceVariables.CONJUGATE_KEY_LBL, + "FR" to FRInterfaceVariables.CONJUGATE_KEY_LBL, + "PT" to PTInterfaceVariables.CONJUGATE_KEY_LBL, + "RU" to RUInterfaceVariables.CONJUGATE_KEY_LBL, + "SV" to SVInterfaceVariables.CONJUGATE_KEY_LBL, + ) + + val pluralPlaceholder = + mapOf( + "EN" to ENInterfaceVariables.PLURAL_KEY_LBL, + "ES" to ESInterfaceVariables.PLURAL_KEY_LBL, + "DE" to DEInterfaceVariables.PLURAL_KEY_LBL, + "IT" to ITInterfaceVariables.PLURAL_KEY_LBL, + "FR" to FRInterfaceVariables.PLURAL_KEY_LBL, + "PT" to PTInterfaceVariables.PLURAL_KEY_LBL, + "RU" to RUInterfaceVariables.PLURAL_KEY_LBL, + "SV" to SVInterfaceVariables.PLURAL_KEY_LBL, + ) + + /** + * Converts a full language name (e.g., "English") to its two-letter ISO alias (e.g., "EN"). + * @param language The full name of the language. + * @return The two-letter alias. + */ + fun getLanguageAlias(language: String): String = + when (language) { + "English" -> "EN" + "French" -> "FR" + "German" -> "DE" + "Italian" -> "IT" + "Portuguese" -> "PT" + "Russian" -> "RU" + "Spanish" -> "ES" + "Swedish" -> "SV" + else -> "" + } +} diff --git a/app/src/main/java/be/scri/helpers/PreferencesHelper.kt b/app/src/main/java/be/scri/helpers/PreferencesHelper.kt index 96993f1c..64a1e2e6 100644 --- a/app/src/main/java/be/scri/helpers/PreferencesHelper.kt +++ b/app/src/main/java/be/scri/helpers/PreferencesHelper.kt @@ -208,9 +208,9 @@ object PreferencesHelper { shouldShowPopupOnKeypress: Boolean, ) { val sharedPref = context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE) - val editor = sharedPref.edit() - editor.putBoolean(getLanguageSpecificPreferenceKey(SHOW_POPUP_ON_KEYPRESS, language), shouldShowPopupOnKeypress) - editor.apply() + sharedPref.edit { + putBoolean(getLanguageSpecificPreferenceKey(SHOW_POPUP_ON_KEYPRESS, language), shouldShowPopupOnKeypress) + } Toast .makeText( context, @@ -232,9 +232,9 @@ object PreferencesHelper { darkMode: Boolean, ) { val sharedPref = context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE) - val editor = sharedPref.edit() - editor.putBoolean("dark_mode", darkMode) - editor.apply() + sharedPref.edit { + putBoolean("dark_mode", darkMode) + } } /** diff --git a/app/src/main/java/be/scri/helpers/RatingHelper.kt b/app/src/main/java/be/scri/helpers/RatingHelper.kt deleted file mode 100644 index fa4fe3b0..00000000 --- a/app/src/main/java/be/scri/helpers/RatingHelper.kt +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -package be.scri.helpers - -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.util.Log -import android.widget.Toast -import be.scri.activities.MainActivity -import com.google.android.play.core.review.ReviewManagerFactory - -/** - * A helper to facilitate rating the application on Google Play or F-Droid. - */ -object RatingHelper { - /** - * Retrieves the source from which the application was installed. - * - * @param context The application context. - * @return The install source, or null if unable to determine the source. - */ - private fun getInstallSource(context: Context): String? = - try { - val packageManager = context.packageManager - packageManager.getInstallerPackageName(context.packageName) - } catch (e: PackageManager.NameNotFoundException) { - Log.e("RatingHelper", "Failed to get install source", e) - null - } - - /** - * Launches the appropriate flow for rating the application based on its installation source. - * If the application is installed from Google Play, the Google Play review flow is launched. - * If the application is installed from F-Droid, the F-Droid page for the app is opened in the browser. - * - * @param context The application context. - * @param activity The main activity of the application that is used to launch the review flow. - */ - fun rateScribe( - context: Context, - activity: MainActivity, - ) { - val installSource = getInstallSource(context) - - if (installSource == "com.android.vending") { - val reviewManager = ReviewManagerFactory.create(context) - val request = reviewManager.requestReviewFlow() - - request.addOnCompleteListener { task -> - if (task.isSuccessful) { - val reviewInfo = task.result - reviewManager - .launchReviewFlow(activity, reviewInfo) - .addOnCompleteListener { _ -> - } - } else { - Toast.makeText(context, "Failed to launch review flow", Toast.LENGTH_SHORT).show() - } - } - } else if (installSource == "org.fdroid.fdroid") { - val url = "https://f-droid.org/packages/${context.packageName}" - val intent = - Intent(Intent.ACTION_VIEW) - .apply { - data = Uri.parse(url) - } - context.startActivity(intent) - } else { - Toast.makeText(context, "Unknown installation source", Toast.LENGTH_SHORT).show() - } - } -} diff --git a/app/src/main/java/be/scri/helpers/SpaceKeyProcessor.kt b/app/src/main/java/be/scri/helpers/SpaceKeyProcessor.kt new file mode 100644 index 00000000..9b188341 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/SpaceKeyProcessor.kt @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.helpers + +import android.inputmethodservice.InputMethodService +import be.scri.services.GeneralKeyboardIME +import be.scri.services.GeneralKeyboardIME.ScribeState + +/** + * Processes key events specifically related to the space key. + * This includes handling "period on double tap" logic, committing spaces + * in normal input mode or command bar mode, and interacting with suggestions. + * + * @property ime The [GeneralKeyboardIME] instance this processor is associated with. + * @property suggestionHandler The [SuggestionHandler] to manage suggestions. + */ +class SpaceKeyProcessor( + private val ime: GeneralKeyboardIME, + private val suggestionHandler: SuggestionHandler, +) { + /** + * Handles the "Space" key press. + * If not in command bar mode, it implements "period on double tap" logic or commits a normal space. + * If in command bar mode, it treats space as a regular character input. + * + * @param currentWasLastKeySpace The state of whether the previous key was a space. + * @return The new state for `wasLastKeySpace` after processing the space key. + */ + fun processKeycodeSpace(currentWasLastKeySpace: Boolean): Boolean { + val isCommandBar = + ime.currentState != ScribeState.IDLE && + ime.currentState != ScribeState.SELECT_COMMAND + + return if (isCommandBar) { + handleSpaceInCommandBar() + false + } else { + handleNormalSpaceInput(currentWasLastKeySpace) + true + } + } + + /** + * Handles space key press when in command bar mode. + * It commits the space to the command bar editor and clears suggestions. + */ + private fun handleSpaceInCommandBar() { + ime.handleElseCondition( + code = KeyboardBase.KEYCODE_SPACE, + keyboardMode = ime.keyboardMode, + commandBarState = true, + ) + suggestionHandler.clearAllSuggestionsAndHideButtonUI() + } + + /** + * Handles space key press when not in command bar mode. + * This includes the "period on double tap" logic if enabled and applicable, + * otherwise commits a normal space. Updates word suggestions. + * @param wasLastKeySpace True if the previous key pressed was a space. + */ + private fun handleNormalSpaceInput(wasLastKeySpace: Boolean) { + val sharedPrefs = ime.getSharedPreferences("app_preferences", InputMethodService.MODE_PRIVATE) + val prefKey = PreferencesHelper.getLanguageSpecificPreferenceKey(PERIOD_ON_DOUBLE_TAP, ime.language) + val periodOnDoubleTapEnabled = sharedPrefs.getBoolean(prefKey, true) + + val ic = ime.currentInputConnection ?: return + + val wordBeforeSpace = ime.getLastWordBeforeCursor() + + // Clear emoji suggestions since the word is now complete. + // suggestionHandler.processEmojiSuggestions(null) + + if (periodOnDoubleTapEnabled && wasLastKeySpace && ime.hasTextBeforeCursor()) { + val textBeforeTwoChars = ic.getTextBeforeCursor(2, 0)?.toString() + + if (meetsTwoCharDoubleSpacePeriodCondition(textBeforeTwoChars)) { + val oneCharBefore = ic.getTextBeforeCursor(1, 0)?.toString() + if (oneCharBefore == " ") { + ime.commitPeriodAfterSpace() + } else { + commitNormalSpace() + } + } else { + val textBeforeOneChar = ic.getTextBeforeCursor(1, 0)?.toString() + if (textBeforeOneChar != null && textBeforeOneChar.length == 1 && textBeforeOneChar == " ") { + ime.commitPeriodAfterSpace() + } else { + commitNormalSpace() + } + } + } else { + commitNormalSpace() + } + suggestionHandler.processLinguisticSuggestions(wordBeforeSpace) + } + + /** + * Commits a single space character to the input connection. + * This is used when "period on double tap" conditions are not met, the feature is disabled, + * or a simple space is intended. + */ + private fun commitNormalSpace() { + ime.handleElseCondition( + code = KeyboardBase.KEYCODE_SPACE, + keyboardMode = ime.keyboardMode, + commandBarState = false, + ) + } + + /** + * Checks if the text before the cursor meets the specific criteria for inserting a period + * on a double space when the text before is two characters long. + * Criteria: not null, length is 2, starts with a space, and does not end with " .". + * This typically matches patterns like " X" (where X is not '.') or " ". + * @param textBefore The two characters of text immediately before the cursor. + * @return True if the conditions are met, false otherwise. + */ + private fun meetsTwoCharDoubleSpacePeriodCondition(textBefore: String?): Boolean = + textBefore != null && + textBefore.length == 2 && + textBefore.startsWith(" ") && + !textBefore.endsWith(" .") +} diff --git a/app/src/main/java/be/scri/helpers/SuggestionHandler.kt b/app/src/main/java/be/scri/helpers/SuggestionHandler.kt new file mode 100644 index 00000000..83601102 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/SuggestionHandler.kt @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.helpers + +import android.os.Handler +import android.os.Looper +import be.scri.services.GeneralKeyboardIME +import be.scri.services.GeneralKeyboardIME.ScribeState + +/** + * Handles auto-suggestions such as noun gender, plurality, case, and emojis. + * + * @property ime The [GeneralKeyboardIME] instance this handler is associated with. + */ +class SuggestionHandler( + private val ime: GeneralKeyboardIME, +) { + private val handler = Handler(Looper.getMainLooper()) + private var emojiSuggestionRunnable: Runnable? = null + private var linguisticSuggestionRunnable: Runnable? = null + + /** + * Companion object for holding constants related to suggestion handling. + */ + companion object { + private const val SUGGESTION_DELAY_MS = 50L + } + + /** + * Processes the given word to find and display relevant LINGUISTIC suggestions. + * This includes noun gender, plurality, and case annotations. + * This is intended to be called AFTER a word is completed (e.g., after space). + * + * @param completedWord The word that was just completed. + */ + fun processLinguisticSuggestions(completedWord: String?) { + linguisticSuggestionRunnable?.let { handler.removeCallbacks(it) } + + linguisticSuggestionRunnable = + Runnable { + if (ime.currentState != ScribeState.IDLE) { + clearAllSuggestionsAndHideButtonUI() + return@Runnable + } + + if (completedWord.isNullOrEmpty()) { + clearLinguisticSuggestions() + return@Runnable + } + + val genderSuggestion = ime.findGenderForLastWord(ime.nounKeywords, completedWord) + val isPluralByDirectCheck = ime.findWhetherWordIsPlural(ime.pluralWords, completedWord) + val caseSuggestion = ime.getCaseAnnotationForPreposition(ime.caseAnnotation, completedWord) + + val hasLinguisticSuggestion = + genderSuggestion != null || + isPluralByDirectCheck || + caseSuggestion != null || + ime.isSingularAndPlural + + if (hasLinguisticSuggestion) { + ime.nounTypeSuggestion = genderSuggestion + ime.checkIfPluralWord = isPluralByDirectCheck + ime.caseAnnotationSuggestion = caseSuggestion + ime.updateAutoSuggestText( + genderSuggestion, + isPluralByDirectCheck || ime.isSingularAndPlural, + caseSuggestion, + ) + } else { + ime.disableAutoSuggest() + } + } + handler.postDelayed(linguisticSuggestionRunnable!!, SUGGESTION_DELAY_MS) + } + + /** + * Processes the given word to find and display relevant EMOJI suggestions. + * This is intended to be called AS the user types. + * + * @param currentWord The word currently being typed. + */ + fun processEmojiSuggestions(currentWord: String?) { + emojiSuggestionRunnable?.let { handler.removeCallbacks(it) } + + emojiSuggestionRunnable = + Runnable { + if (ime.currentState != ScribeState.IDLE) { + clearAllSuggestionsAndHideButtonUI() + return@Runnable + } + + ime.lastWord = currentWord + + if (currentWord.isNullOrEmpty()) { + ime.updateButtonVisibility(false) + return@Runnable + } + + val emojis = + if (ime.emojiAutoSuggestionEnabled) { + ime.findEmojisForLastWord(ime.emojiKeywords, currentWord) + } else { + null + } + + val hasEmojiSuggestion = !emojis.isNullOrEmpty() + + if (hasEmojiSuggestion) { + ime.autoSuggestEmojis = emojis + ime.updateEmojiSuggestion(true, emojis) + ime.updateButtonVisibility(true) + } else { + ime.updateButtonVisibility(false) + } + } + + handler.postDelayed(emojiSuggestionRunnable!!, SUGGESTION_DELAY_MS) + } + + /** + * Clears only the linguistic suggestions (gender, case, plural) from the UI. + * Leaves emoji suggestions untouched. + */ + fun clearLinguisticSuggestions() { + linguisticSuggestionRunnable?.let { handler.removeCallbacks(it) } + ime.disableAutoSuggest() + ime.nounTypeSuggestion = null + ime.checkIfPluralWord = false + ime.caseAnnotationSuggestion = null + ime.isSingularAndPlural = false + } + + /** + * Clears all suggestion states (noun type, plurality, case, emojis) from the IME + * and hides suggestion-related UI elements. + */ + fun clearAllSuggestionsAndHideButtonUI() { + emojiSuggestionRunnable?.let { handler.removeCallbacks(it) } + linguisticSuggestionRunnable?.let { handler.removeCallbacks(it) } + + ime.disableAutoSuggest() + ime.updateButtonVisibility(false) + ime.nounTypeSuggestion = null + ime.checkIfPluralWord = false + ime.caseAnnotationSuggestion = null + ime.autoSuggestEmojis = null + ime.isSingularAndPlural = false + } +} diff --git a/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt b/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt new file mode 100644 index 00000000..ceeb9de4 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers.data + +import DataContract +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.util.Log +import be.scri.helpers.DatabaseFileManager + +/** + * Manages verb conjugation data by interfacing with SQLite databases. + * @param fileManager The central manager for database file access. + */ +class ConjugateDataManager( + private val fileManager: DatabaseFileManager, +) { + /** + * Retrieves a comprehensive map of conjugation data for a specific verb in a given language. + * The returned map is structured by tense/mood, then by conjugation type (e.g., "Indicative Present"). + * + * @param language The language code (e.g., "EN", "SV") to determine the correct database. + * @param jsonData The data contract for the language, which defines the structure of conjugations. + * @param word The specific verb to look up conjugations for. + * @return A nested map where the outer key is the tense group title + * (e.g., "Indicative"), the inner key is the + * conjugation category title (e.g., "Present"), and the value is a collection of the conjugated forms. + */ + fun getTheConjugateLabels( + language: String, + jsonData: DataContract?, + word: String, + ): MutableMap>> { + val finalOutput: MutableMap>> = mutableMapOf() + jsonData?.conjugations?.values?.forEach { tenseGroup -> + val conjugateForms: MutableMap> = mutableMapOf() + tenseGroup.conjugationTypes.values.forEach { conjugationCategory -> + val forms = + conjugationCategory.conjugationForms.values.map { form -> + getTheValueForTheConjugateWord(word, form, language) + } + conjugateForms[conjugationCategory.title] = forms + } + finalOutput[tenseGroup.title] = conjugateForms + } + return finalOutput + } + + /** + * Extracts a unique set of all conjugation form keys (e.g., "1ps", "2ps", "participle") + * from the data contract. + * + * @param jsonData The data contract containing the conjugation structure. + * @param word The base word, which is also added to the set. + * @return A `Set` of unique strings representing all possible conjugation form identifiers. + */ + fun extractConjugateHeadings( + jsonData: DataContract?, + word: String, + ): Set { + val allFormKeys = mutableSetOf() + jsonData?.conjugations?.values?.forEach { tenseGroup -> + tenseGroup.conjugationTypes.values.forEach { conjugationCategory -> + allFormKeys.addAll(conjugationCategory.conjugationForms.keys) + } + } + allFormKeys.add(word) + return allFormKeys + } + + /** + * Retrieves the specific conjugated form of a word from the database. + * + * @param word The base word (verb) to look up. + * @param form The specific conjugation form identifier (e.g., "1ps", "past_participle"). + * @param language The language code to select the correct database. + * @return The conjugated word as a [String], or an empty string if not found. + */ + private fun getTheValueForTheConjugateWord( + word: String, + form: String?, + language: String, + ): String { + if (form.isNullOrEmpty()) return "" + return fileManager.getLanguageDatabase(language)?.use { db -> + getVerbCursor(db, word, language)?.use { cursor -> + getConjugatedValueFromCursor(cursor, form) + } + } ?: "" + } + + /** + * Extracts a conjugated value from a database cursor for a given form. + * It handles both simple column lookups and complex forms that require parsing. + * + * @param cursor The database cursor positioned at the correct row for the verb. + * @param form The form identifier, which can be a simple column name or a complex string. + * @return The conjugated value, or an empty string on failure. + */ + private fun getConjugatedValueFromCursor( + cursor: Cursor, + form: String, + ): String = + if (form.contains("[")) { + parseComplexForm(cursor, form) + } else { + try { + cursor.getString(cursor.getColumnIndexOrThrow(form)) + } catch (e: IllegalArgumentException) { + Log.e("ConjugateDataManager", "Simple form column not found: '$form'", e) + "" + } + } + + /** + * Parses a complex conjugation form that includes an auxiliary part in brackets, + * such as "[have] past_participle". It combines the auxiliary word with the value + * from the specified database column. + * + * @param cursor The database cursor positioned at the correct row. + * @param form The complex form string to parse. + * @return The combined string (e.g., "have walked"), or an empty string on failure. + */ + private fun parseComplexForm( + cursor: Cursor, + form: String, + ): String { + val bracketRegex = Regex("""\[(.*?)]""") + val match = bracketRegex.find(form) ?: return "" + + val auxiliaryWords = match.groupValues[1] + val dbColumnName = form.replace(bracketRegex, "").trim() + return try { + val verbPart = cursor.getString(cursor.getColumnIndexOrThrow(dbColumnName)) + "$auxiliaryWords $verbPart".trim() + } catch (e: IllegalArgumentException) { + Log.e("ConjugateDataManager", "Complex form column '$dbColumnName' not found", e) + "" + } + } + + /** + * Creates and returns a database cursor pointing to the requested verb's data row. + * Note: Handles a special case for Swedish ("SV") where the key column is 'verb' instead of 'infinitive'. + * + * @param db The SQLite database instance to query. + * @param word The verb to search for. + * @param language The language code, used for special query conditions. + * @return A [Cursor] positioned at the verb's row, or null if the verb is not found. + * The caller is responsible for closing the cursor. + */ + private fun getVerbCursor( + db: SQLiteDatabase, + word: String, + language: String, + ): Cursor? { + val query = + if (language == "SV") { + "SELECT * FROM verbs WHERE verb = ?" + } else { + "SELECT * FROM verbs WHERE infinitive = ?" + } + val cursor = db.rawQuery(query, arrayOf(word)) + return if (cursor.moveToFirst()) { + cursor + } else { + cursor.close() + null + } + } +} diff --git a/app/src/main/java/be/scri/helpers/data/ContractDataLoader.kt b/app/src/main/java/be/scri/helpers/data/ContractDataLoader.kt new file mode 100644 index 00000000..48d1349c --- /dev/null +++ b/app/src/main/java/be/scri/helpers/data/ContractDataLoader.kt @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers.data + +import DataContract +import android.content.Context +import android.util.Log +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import java.io.IOException + +/** + * Loads and deserializes contract data from assets. + * @param context The application context. + */ +class ContractDataLoader( + private val context: Context, +) { + /** + * Loads and deserializes a data contract from a JSON file in the assets folder. + * It gracefully handles file-not-found and JSON parsing errors by returning null. + * + * @param language The language code (e.g., "DE", "EN") used to determine the filename (e.g., "de.json"). + * @return The decoded [DataContract] object if successful, or `null` + * if the file does not exist or cannot be parsed. + */ + fun loadContract(language: String): DataContract? { + val contractName = "${language.lowercase()}.json" + Log.i("ContractDataLoader", "Attempting to load contract: $contractName") + + return try { + val jsonParser = Json { ignoreUnknownKeys = true } + context.assets.open("data-contracts/$contractName").use { contractFile -> + val content = contractFile.bufferedReader().readText() + jsonParser.decodeFromString(content) + } + } catch (e: IOException) { + Log.e("ContractDataLoader", "Error loading contract file: $contractName. It may not exist.", e) + null + } catch (e: SerializationException) { + Log.e("ContractDataLoader", "Error parsing JSON for contract: $contractName", e) + null + } + } +} diff --git a/app/src/main/java/be/scri/helpers/data/EmojiDataManager.kt b/app/src/main/java/be/scri/helpers/data/EmojiDataManager.kt new file mode 100644 index 00000000..a7b239f9 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/data/EmojiDataManager.kt @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers.data + +import android.database.Cursor +import be.scri.helpers.DatabaseFileManager + +/** + * Manages emoji keywords by querying an SQLite database. + * @param fileManager The central manager for database file access. + */ +class EmojiDataManager( + private val fileManager: DatabaseFileManager, +) { + /** The maximum length of any emoji keyword found. */ + var maxKeywordLength = 0 + private set + + /** + * Retrieves a map of all emoji keywords for a specified language from the database. + * As a side effect, it also calculates and stores the maximum length of any keyword found. + * + * @param language The language code (e.g., "DE", "FR") to select the correct database. + * @return A [HashMap] where keys are lowercase words and values are a list of associated emoji strings. + */ + fun getEmojiKeywords(language: String): HashMap> { + val emojiMap = HashMap>() + val db = fileManager.getLanguageDatabase(language) ?: return emojiMap + + db.use { + it.rawQuery("SELECT MAX(LENGTH(word)) FROM emoji_keywords", null).use { cursor -> + if (cursor.moveToFirst()) { + maxKeywordLength = cursor.getInt(0) + } + } + it.rawQuery("SELECT * FROM emoji_keywords", null).use { cursor -> + processEmojiCursor(cursor, emojiMap) + } + } + return emojiMap + } + + /** + * Iterates through a database cursor from the `emoji_keywords` table and populates a map. + * + * @param cursor The cursor containing the emoji keyword data. + * @param emojiMap The [HashMap] to populate with the results. + */ + private fun processEmojiCursor( + cursor: Cursor, + emojiMap: HashMap>, + ) { + val wordIndex = cursor.getColumnIndex("word") + if (wordIndex == -1 || !cursor.moveToFirst()) return + + val emojiIndices = + listOf("emoji_keyword_0", "emoji_keyword_1", "emoji_keyword_2") + .mapNotNull { name -> cursor.getColumnIndex(name).takeIf { it != -1 } } + + do { + val word = cursor.getString(wordIndex) + val emojis = + emojiIndices + .mapNotNull { index -> cursor.getString(index)?.takeIf { it.isNotBlank() } } + .toMutableList() + + if (emojis.isNotEmpty()) { + emojiMap[word] = emojis + } + } while (cursor.moveToNext()) + } +} diff --git a/app/src/main/java/be/scri/helpers/data/GenderDataManager.kt b/app/src/main/java/be/scri/helpers/data/GenderDataManager.kt new file mode 100644 index 00000000..d36c5378 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/data/GenderDataManager.kt @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers.data + +import DataContract +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.util.Log +import be.scri.helpers.DatabaseFileManager + +/** + * Manages and processes gender data from the database. + * @param fileManager The central manager for database file access. + */ +class GenderDataManager( + private val fileManager: DatabaseFileManager, +) { + /** + * Retrieves a map of words and their associated grammatical genders for a given language. + * + * @param language The language code (e.g., "DE", "FR") to select the correct database. + * @param contract The data contract for the language, which defines the gender-related database columns. + * @return A [HashMap] where keys are lowercase nouns and values are a list of their gender(s) (e.g., "masculine"). + */ + fun findGenderOfWord( + language: String, + contract: DataContract?, + ): HashMap> = + contract?.let { + fileManager.getLanguageDatabase(language)?.use { db -> + processGenderData(db, it) + } + } ?: hashMapOf() + + /** + * The main processing function that dispatches to the correct gender-handling logic + * based on the structure defined in the [DataContract]. + * + * @param db The SQLite database instance. + * @param contract The data contract defining how gender is stored for this language. + * @return A [HashMap] of nouns to their genders. + */ + private fun processGenderData( + db: SQLiteDatabase, + contract: DataContract, + ): HashMap> { + val genderMap = HashMap>() + + when { + hasCanonicalGender(contract) -> + processGenders( + db = db, + nounColumn = contract.numbers.keys.firstOrNull(), + genderColumn = contract.genders.canonical.firstOrNull(), + genderMap = genderMap, + ) + hasMasculineFeminine(contract) -> { + processGenders( + db = db, + nounColumn = contract.genders.masculines.firstOrNull(), + genderMap = genderMap, + defaultGender = "masculine", + ) + processGenders( + db = db, + nounColumn = contract.genders.feminines.firstOrNull(), + genderMap = genderMap, + defaultGender = "feminine", + ) + } + else -> Log.w("GenderDataManager", "No valid gender columns found in contract for language.") + } + return genderMap + } + + /** + * Checks if the data contract defines a single, canonical gender column. + * @param contract The data contract to check. + * @return `true` if a canonical gender column is specified, `false` otherwise. + */ + private fun hasCanonicalGender(contract: DataContract): Boolean = + contract.genders.canonical + .firstOrNull() + ?.isNotEmpty() == true + + /** + * Checks if the data contract defines separate columns for masculine and feminine genders. + * @param contract The data contract to check. + * @return `true` if both masculine and feminine columns are specified, `false` otherwise. + */ + private fun hasMasculineFeminine(contract: DataContract): Boolean { + val masculineList = contract.genders.masculines + val feminineList = contract.genders.feminines + + val hasMasculine = masculineList.isNotEmpty() + val hasFeminine = feminineList.isNotEmpty() + + return hasMasculine && hasFeminine + } + + /** + * Queries the `nouns` table for gender information and populates the gender map. + * It is defensive and will not proceed if the columns specified in the contract do not exist in the table. + * + * @param db The SQLite database to query. + * @param nounColumn The name of the column containing the noun. + * @param genderMap The map to populate with results. + * @param genderColumn The name of the column containing the gender information (optional). + * @param defaultGender A default gender to assign if `genderColumn` is not provided (optional). + */ + private fun processGenders( + db: SQLiteDatabase, + nounColumn: String?, + genderMap: HashMap>, + genderColumn: String? = null, + defaultGender: String? = null, + ) { + if (nounColumn.isNullOrEmpty()) { + Log.e("GenderDataManager", "No valid noun column provided in contract.") + return + } + + val columnsToSelect = listOfNotNull(nounColumn, genderColumn).distinct() + + db.rawQuery("SELECT * FROM nouns LIMIT 1", null).use { tempCursor -> + for (column in columnsToSelect) { + if (tempCursor.getColumnIndex(column) == -1) { + Log.e( + "GenderDataManager", + "Column '$column' specified in the data contract was NOT FOUND in the 'nouns' table" + + " Skipping this gender processing step to prevent a crash.", + ) + return + } + } + } + + val selection = columnsToSelect.joinToString(", ") { "`$it`" } + + db.rawQuery("SELECT $selection FROM nouns", null).use { cursor -> + val nounIndex = cursor.getColumnIndex(nounColumn) + // genderIndex will be valid because we checked it above. + val genderIndex = genderColumn?.let { cursor.getColumnIndex(it) } ?: -1 + + while (cursor.moveToNext()) { + processGenderRow(cursor, nounIndex, genderIndex, defaultGender, genderMap) + } + } + } + + /** + * Processes a single row from the gender query cursor, extracting the noun and its + * gender, and adds the entry to the provided map. + * + * @param cursor The database cursor, positioned at the row to process. + * @param nounIndex The column index for the noun. + * @param genderIndex The column index for the gender, or -1 if not applicable. + * @param defaultGender A fallback gender to use if `genderIndex` is -1. + * @param genderMap The map to which the noun/gender pair will be added. + */ + private fun processGenderRow( + cursor: Cursor, + nounIndex: Int, + genderIndex: Int, + defaultGender: String?, + genderMap: HashMap>, + ) { + val noun = cursor.getString(nounIndex)?.lowercase()?.takeIf { it.isNotEmpty() } ?: return + + val gender = + if (genderIndex != -1) { + cursor.getString(genderIndex) + } else { + defaultGender + } + + if (!gender.isNullOrEmpty()) { + @Suppress("UNCHECKED_CAST") + val list = genderMap.getOrPut(noun) { mutableListOf() } as MutableList + if (!list.contains(gender)) { + list.add(gender) + } + } + } +} diff --git a/app/src/main/java/be/scri/helpers/data/PluralFormsManager.kt b/app/src/main/java/be/scri/helpers/data/PluralFormsManager.kt new file mode 100644 index 00000000..4500e637 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/data/PluralFormsManager.kt @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers.data + +import DataContract +import android.database.sqlite.SQLiteDatabase +import be.scri.helpers.DatabaseFileManager + +/** + * Manages and queries plural forms of words from the database. + * @param fileManager The central manager for database file access. + */ +class PluralFormsManager( + private val fileManager: DatabaseFileManager, +) { + /** + * Retrieves a list of all known plural forms for a given language from the database. + * + * @param language The language code (e.g., "EN", "DE") to select the correct database. + * @param jsonData The data contract, which specifies the names of the columns containing plural forms. + * @return A [List] of all plural word forms, or `null` + * if the operation fails or no plural columns are defined. + */ + fun getAllPluralForms( + language: String, + jsonData: DataContract?, + ): List? = + jsonData?.numbers?.values?.toList()?.takeIf { it.isNotEmpty() }?.let { pluralForms -> + fileManager.getLanguageDatabase(language)?.use { db -> + queryAllPluralForms(db, pluralForms) + } + } + + /** + * Retrieves the specific plural representation for a single noun. + * + * @param language The language code to select the correct database. + * @param jsonData The data contract, which specifies the singular and plural column names. + * @param noun The singular noun to find the plural for. + * @return A [Map] containing the singular noun as the key and + * its plural form as the value, or an empty map if not found. + */ + fun getPluralRepresentation( + language: String, + jsonData: DataContract?, + noun: String, + ): Map = + jsonData?.numbers?.let { numbers -> + val singularCol = numbers.keys.firstOrNull() + val pluralCol = numbers.values.firstOrNull() + + if (singularCol != null && pluralCol != null) { + fileManager.getLanguageDatabase(language)?.use { db -> + querySpecificPlural(db, singularCol, pluralCol, noun) + } + } else { + null + } + } ?: emptyMap() + + /** + * Executes a database query to find the plural form for a specific noun. + * + * @param db The SQLite database to query. + * @param singularCol The name of the column containing singular nouns. + * @param pluralCol The name of the column containing the corresponding plural nouns. + * @param noun The specific singular noun to search for. + * @return A map of the singular noun to its plural, or an empty map if not found. + */ + private fun querySpecificPlural( + db: SQLiteDatabase, + singularCol: String, + pluralCol: String, + noun: String, + ): Map { + val query = + "SELECT " + + "`$singularCol`, " + + "`$pluralCol` " + + "FROM nouns " + + "WHERE `$singularCol` = ? " + + "COLLATE NOCASE" + + return db.rawQuery(query, arrayOf(noun)).use { cursor -> + if (cursor.moveToFirst()) { + mapOf(cursor.getString(0) to cursor.getString(1)) + } else { + emptyMap() + } + } + } + + /** + * Executes a database query to retrieve all values from all specified plural columns in the `nouns` table. + * + * @param db The SQLite database to query. + * @param pluralColumns A list of column names that contain plural forms. + * @return A [List] of all plural words found in the specified columns. + */ + private fun queryAllPluralForms( + db: SQLiteDatabase, + pluralColumns: List, + ): List { + val result = mutableListOf() + val columns = pluralColumns.joinToString(", ") { "`$it`" } + val query = "SELECT $columns FROM nouns" + + db.rawQuery(query, null).use { cursor -> + if (!cursor.moveToFirst()) return@use + + do { + addPluralsFromRow(cursor, pluralColumns.indices, result) + } while (cursor.moveToNext()) + } + return result + } + + /** + * Extracts all non-blank string values from the current cursor row for a given set of columns + * and adds them to a result list. + * + * @param cursor The database cursor, positioned at the desired row. + * @param columnIndices The range of column indices to read from. + * @param result The list to which the plural forms will be added. + */ + private fun addPluralsFromRow( + cursor: android.database.Cursor, + columnIndices: IntRange, + result: MutableList, + ) { + for (index in columnIndices) { + val value = cursor.getString(index) + if (value?.isNotBlank() == true) { + result.add(value) + } + } + } +} diff --git a/app/src/main/java/be/scri/helpers/data/PrepositionDataManager.kt b/app/src/main/java/be/scri/helpers/data/PrepositionDataManager.kt new file mode 100644 index 00000000..60716476 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/data/PrepositionDataManager.kt @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers.data + +import android.database.Cursor +import android.util.Log +import be.scri.helpers.DatabaseFileManager + +/** + * Manages preposition data and case annotations from the database. + * @param fileManager The central manager for database file access. + */ +class PrepositionDataManager( + private val fileManager: DatabaseFileManager, +) { + /** + * Retrieves a map of prepositions to their required grammatical cases for a specific language. + * This functionality is currently only supported for German ("DE") and Russian ("RU"). + * + * @param language The language code. + * @return A [HashMap] where keys are prepositions and values are a list of required cases + * (e.g., "accusative case"). + * Returns an empty map for unsupported languages or on failure. + */ + fun getCaseAnnotations(language: String): HashMap> { + if (language.uppercase() !in listOf("DE", "RU")) { + return hashMapOf() + } + return fileManager.getLanguageDatabase(language)?.use { db -> + db.rawQuery("SELECT preposition, grammaticalCase FROM prepositions", null).use { cursor -> + processCursor(cursor) + } // handle case where cursor is null + } ?: hashMapOf() // handle case where database is null + } + + /** + * Iterates through a database cursor from the `prepositions` table and populates a map with the results. + * + * @param cursor The cursor containing the preposition and grammatical case data. + * @return A [HashMap] mapping prepositions to a list of their cases. + */ + private fun processCursor(cursor: Cursor): HashMap> { + val result = HashMap>() + val prepIndex = cursor.getColumnIndex("preposition") + val caseIndex = cursor.getColumnIndex("grammaticalCase") + + if (prepIndex == -1 || caseIndex == -1 || !cursor.moveToFirst()) { + Log.e("PrepositionDataManager", "Required columns not found in prepositions table.") + return result + } + + do { + val prep = cursor.getString(prepIndex) + val case = cursor.getString(caseIndex) + + if (prep != null && case != null) { + result.getOrPut(prep) { mutableListOf() }.add(case) + } + } while (cursor.moveToNext()) + + return result + } +} diff --git a/app/src/main/java/be/scri/helpers/data/TranslationDataManager.kt b/app/src/main/java/be/scri/helpers/data/TranslationDataManager.kt new file mode 100644 index 00000000..a1bb4993 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/data/TranslationDataManager.kt @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers.data + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import be.scri.helpers.DatabaseFileManager +import be.scri.helpers.PreferencesHelper + +/** + * Manages translations from a local SQLite database. + * @param context The application context. + * @param fileManager The central manager for database file access. + */ +class TranslationDataManager( + private val context: Context, + private val fileManager: DatabaseFileManager, +) { + /** + * Determines the source and destination language ISO codes for a translation operation. + * The source is derived from user preferences, and the destination is the current keyboard language. + * + * @param language The current keyboard language name (e.g., "English"). + * @return A [Pair] containing the source and destination ISO codes (e.g., "en" to "fr"). + */ + fun getSourceAndDestinationLanguage(language: String): Pair { + val sourceLanguage = PreferencesHelper.getPreferredTranslationLanguage(context, language) + return Pair(generateISOCodeForLanguage(sourceLanguage.toString()), generateISOCodeForLanguage(language)) + } + + /** + * Retrieves the translation for a given word from the local translation database. + * If the source and destination languages are the same, it returns the original word. + * + * @param sourceAndDestination A [Pair] of source and destination ISO language codes. + * @param word The word to be translated. + * @return The translated word as a [String], or an empty string if no translation is found. + */ + fun getTranslationDataForAWord( + sourceAndDestination: Pair, + word: String, + ): String { + val (sourceCode, destCode) = sourceAndDestination + + if (sourceCode == destCode || sourceCode == null || destCode == null) { + return word + } + + val sourceTable = generateLanguageNameForISOCode(sourceCode) + + return fileManager.getTranslationDatabase()?.use { db -> + queryForTranslation(db, sourceTable, destCode, word) + } ?: "" + } + + /** + * Executes the raw SQL query to find a translation for a given word in the database. + * + * @param db The SQLite database instance. + * @param sourceTable The name of the table to query (derived from the source language, e.g., "english"). + * @param destColumn The name of the column containing the translation + * (derived from the destination language ISO code, e.g., "fr"). + * @param word The word to search for in the 'word' column of the source table. + * @return The translated word, or an empty string if not found. + */ + private fun queryForTranslation( + db: SQLiteDatabase, + sourceTable: String, + destColumn: String, + word: String, + ): String { + val query = + """ + SELECT `$destColumn` + FROM `$sourceTable` + WHERE word = ? + """.trimIndent() + + return db.rawQuery(query, arrayOf(word)).use { cursor -> + if (cursor.moveToFirst()) { + cursor.getString(cursor.getColumnIndexOrThrow(destColumn))?.trimEnd() ?: "" + } else { + "" + } + } + } + + /** + * Converts a full language name (e.g., "english") to its corresponding two-letter ISO 639-1 code. + * + * @param languageName The full name of the language. + * @return The two-letter ISO code as a [String]. Defaults to "en". + */ + private fun generateISOCodeForLanguage(languageName: String): String = + when (languageName.lowercase()) { + "english" -> "en" + "french" -> "fr" + "german" -> "de" + "spanish" -> "es" + "italian" -> "it" + "portuguese" -> "pt" + "russian" -> "ru" + "swedish" -> "sv" + else -> "en" + } + + /** + * Converts a two-letter ISO 639-1 code to its corresponding full language name used for table lookups. + * + * @param isoCode The two-letter ISO code. + * @return The full language name as a [String]. Defaults to "english". + */ + private fun generateLanguageNameForISOCode(isoCode: String): String = + when (isoCode.lowercase()) { + "en" -> "english" + "fr" -> "french" + "de" -> "german" + "es" -> "spanish" + "it" -> "italian" + "pt" -> "portuguese" + "ru" -> "russian" + "sv" -> "swedish" + else -> "english" + } +} diff --git a/app/src/main/java/be/scri/helpers/keyboardDBHelper/ConjugateDataManager.kt b/app/src/main/java/be/scri/helpers/keyboardDBHelper/ConjugateDataManager.kt deleted file mode 100644 index be878e64..00000000 --- a/app/src/main/java/be/scri/helpers/keyboardDBHelper/ConjugateDataManager.kt +++ /dev/null @@ -1,214 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -package be.scri.helpers.keyboardDBHelper - -import DataContract -import android.content.Context -import android.database.Cursor -import android.database.sqlite.SQLiteDatabase -import android.util.Log -import java.io.FileOutputStream - -/** - * Manages conjugation data for different languages by interfacing with SQLite databases. - * This class handles retrieving and processing verb conjugation information. - * - * @property context The Android application context used to access assets and databases - */ -class ConjugateDataManager( - private val context: Context, -) { - /** - * Retrieves conjugation labels and their corresponding values for a given word. - * - * @param language The language code (e.g., "EN", "SV") - * @param jsonData The data contract containing conjugation information - * @param word The verb to get conjugations for - * @return A map of conjugation titles to their corresponding person forms - */ - fun getTheConjugateLabels( - language: String, - jsonData: DataContract?, - word: String, - ): MutableMap>> { - Log.i("ISSUE-123", "The conjugate data is ${jsonData?.conjugations}") - val finalOutput: MutableMap>> = mutableMapOf() - for (i in jsonData?.conjugations?.keys!!) { - val title = jsonData?.conjugations?.get(i)?.title - Log.i("MY-TAG", "The keys for the task are ${jsonData?.conjugations?.keys}") - val label1 = jsonData.conjugations.get(i) - val label2 = label1?.conjugationTypes - val keys = label2?.keys - val conjugateForms: MutableMap> = mutableMapOf() - for (key in keys!!) { - val conjugationType = label2[key] - val formTitle = conjugationType?.title ?: continue - val forms = - conjugationType.conjugationForms.values.map { form -> - getTheValueForTheConjugateWord( - word = word, - form = form, - language = language, - ) - } - conjugateForms[formTitle] = forms - } - if (title != null) { - finalOutput[title] = conjugateForms - } - Log.i("CONJUGATE-ISSUE", "The conjugate forms are $conjugateForms") - } - Log.i("CONJUGATE-ISSUE", "The final output is $finalOutput") - return finalOutput - } - - /** - * Extracts all unique conjugation headings from the provided data contract. - * - * @param jsonData The data contract containing conjugation information - * @return A set of unique conjugation heading strings - */ - fun extractConjugateHeadings( - jsonData: DataContract?, - word: String, - ): Set { - val allFormKeys = mutableSetOf() - - jsonData?.conjugations?.values?.forEach { tenseGroup -> - tenseGroup.conjugationTypes.values.forEach { conjugationCategory -> - allFormKeys.addAll(conjugationCategory.conjugationForms.keys) - } - } - allFormKeys.add(word) - Log.i("BETA-TAG", "The conjugate form keys are $allFormKeys") - return allFormKeys - } - - /** - * Retrieves the conjugated form of a word based on the provided form pattern. - * - * @param word The base verb form - * @param form The pattern describing how to conjugate the verb - * @param language The language code (e.g., "EN", "SV") - * @return The conjugated form of the word - */ - private fun getTheValueForTheConjugateWord( - word: String, - form: String?, - language: String, - ): String { - var result = "" - val db = openDatabase(language) - - db?.use { database -> - val verbCursor = getVerbCursor(database, word, language) - - if (verbCursor != null && form != null) { - result = - try { - verbCursor.getString(verbCursor.getColumnIndexOrThrow(form)) - } catch (e: IllegalArgumentException) { - Log.w("ConjugateDataManager", "Form column not found: $form", e) - resolveFallbackForm(database, verbCursor, word, form, language) - } - } - } - - return result - } - - private fun openDatabase(language: String): SQLiteDatabase? { - val dbPath = context.getDatabasePath("${language}LanguageData.sqlite") - if (!dbPath.exists()) { - dbPath.parentFile?.mkdirs() - context.assets.open("data/${language}LanguageData.sqlite").use { inputStream -> - FileOutputStream(dbPath).use { it.write(inputStream.readBytes()) } - } - } - return SQLiteDatabase.openDatabase(dbPath.path, null, SQLiteDatabase.OPEN_READONLY) - } - - private fun getVerbCursor( - db: SQLiteDatabase, - word: String, - language: String, - ): Cursor? { - val query = - if (language == "SV") { - "SELECT * FROM verbs WHERE verb = ?" - } else { - "SELECT * FROM verbs WHERE infinitive = ?" - } - - val cursor = db.rawQuery(query, arrayOf(word)) - return if (cursor.moveToFirst()) cursor else null - } - - private fun resolveFallbackForm( - db: SQLiteDatabase, - verbCursor: Cursor, - word: String, - form: String, - language: String, - ): String { - var fallbackFields: MutableList = mutableListOf() - var resultList: MutableList = mutableListOf() - if (language != "EN") { - val bracketRegex = Regex("""\[(.*?)\]""") - val wordsRegex = Regex("""\b(\w+)\s+(\w+)\b""") - val bracketPart = bracketRegex.find(form)?.groupValues?.get(1) ?: "" - val (first, second) = wordsRegex.find(bracketPart)?.destructured ?: return "" - val rest = form.replace(bracketRegex, "").trim() - fallbackFields = mutableListOf(first, second, rest) - } else { - val bracketRegex = Regex("""\[(.*?)]""") - val bracketContent = bracketRegex.find(form)?.groupValues?.get(1) ?: "" - val (first, second) = - bracketContent.split(" ").let { - when (it.size) { - 0 -> "" to "" - 1 -> it[0] to "" - else -> it[0] to it[1] - } - } - val rest = form.replace(bracketRegex, "").trim() - resultList = mutableListOf(first, second, rest) - } - return if (language != "EN") { - resolveFallbackNonEnglish(db, verbCursor, word, fallbackFields) - } else { - resolveFallbackEnglish(verbCursor, resultList) - } - } - - private fun resolveFallbackNonEnglish( - db: SQLiteDatabase, - verbCursor: Cursor, - word: String, - fields: MutableList, - ): String { - db.rawQuery("SELECT ${fields[1]} FROM verbs WHERE infinitive = ?", arrayOf(word)).use { - if (it.moveToFirst()) { - fields[1] = it.getString(it.getColumnIndexOrThrow(fields[1])) - } - } - - fields[2] = verbCursor.getString(verbCursor.getColumnIndexOrThrow(fields[2])) - - db.rawQuery("SELECT ${fields[0]} FROM verbs WHERE wdLexemeID = ?", arrayOf(fields[1])).use { - if (it.moveToFirst()) { - fields[1] = it.getString(it.getColumnIndexOrThrow(fields[0])) - } - } - - return "${fields[1]} ${fields[2]}" - } - - private fun resolveFallbackEnglish( - verbCursor: Cursor, - fields: MutableList, - ): String { - fields[2] = verbCursor.getString(verbCursor.getColumnIndexOrThrow(fields[2])) - Log.i("CONJUGATE-ISSUE", "The fields are $fields") - return "${fields[0]} ${fields[1]} ${fields[2]}" - } -} diff --git a/app/src/main/java/be/scri/helpers/keyboardDBHelper/ContractDataLoader.kt b/app/src/main/java/be/scri/helpers/keyboardDBHelper/ContractDataLoader.kt deleted file mode 100644 index 4c2b1943..00000000 --- a/app/src/main/java/be/scri/helpers/keyboardDBHelper/ContractDataLoader.kt +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -import android.content.Context -import android.util.Log -import kotlinx.serialization.json.Json -import java.io.IOException - -/** - * A helper class to load and deserialize contract data from assets based on the specified language. - */ -class ContractDataLoader( - private val context: Context, -) { - /** - * Loads a data contract JSON file from the assets based on the given language. - * The file is expected to be located in the `assets/data-contracts` directory and named as `.json`. - * - * @param language the language for which to load the data contract (e.g., "en", "fr") - * @return the decoded [DataContract] object if successful; null otherwise - */ - fun loadContract(language: String): DataContract? { - val contractName = "${language.lowercase()}.json" - Log.i("ALPHA", "This is the $language") - - return try { - val json = Json { ignoreUnknownKeys = true } - context.assets.open("data-contracts/$contractName").use { contractFile -> - val content = contractFile.bufferedReader().readText() - Log.i("ALPHA", content) - json.decodeFromString(content).also { - Log.i("MY-TAG", it.toString()) - } - } - } catch (e: IOException) { - Log.e("MY-TAG", "Error loading contract: $contractName", e) - null - } - } -} diff --git a/app/src/main/java/be/scri/helpers/keyboardDBHelper/EmojiDataManager.kt b/app/src/main/java/be/scri/helpers/keyboardDBHelper/EmojiDataManager.kt deleted file mode 100644 index b09c1bb3..00000000 --- a/app/src/main/java/be/scri/helpers/keyboardDBHelper/EmojiDataManager.kt +++ /dev/null @@ -1,88 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -import android.content.Context -import android.database.Cursor -import android.database.sqlite.SQLiteDatabase - -/** - * A helper class to manage emoji keywords by querying an SQLite database based on the specified language. - */ -class EmojiDataManager( - private val context: Context, -) { - // Track max keyword length. - var maxKeywordLength = 0 - - /** - * Retrieves emoji keywords for the specified language from a corresponding SQLite database file. - * - * @param language the language code (e.g., "en", "es") used to locate the correct database file. - * @return a [HashMap] mapping emoji characters to a list of associated keywords in the given language. - */ - fun getEmojiKeywords(language: String): HashMap> { - val dbFile = context.getDatabasePath("${language}LanguageData.sqlite") - return processEmojiKeywords(dbFile.path) - } - - /** - * Processes an SQLite database to extract emoji keywords and stores them in a map. - * - * @param dbPath the path to the SQLite database file containing emoji keyword mappings. - * @return a [HashMap] mapping emoji characters to a list of associated keywords. - * - * The function also determines the maximum keyword length from the database and stores it in [maxKeywordLength]. - */ - private fun processEmojiKeywords(dbPath: String): HashMap> { - val hashMap = HashMap>() - - SQLiteDatabase.openDatabase(dbPath, null, SQLiteDatabase.OPEN_READONLY).use { db -> - // Get max keyword length. - db.rawQuery("SELECT MAX(LENGTH(word)) FROM emoji_keywords", null).use { cursor -> - if (cursor.moveToFirst()) { - maxKeywordLength = cursor.getInt(0) - } - } - - // Keyword processing. - db.rawQuery("SELECT * FROM emoji_keywords", null).use { cursor -> - processEmojiCursor(cursor, hashMap) - } - } - return hashMap - } - - /** - * Processes a database cursor containing emoji keyword data and populates a map with the results. - * - * @param cursor the [Cursor] object returned from querying the `emoji_keywords` table. - * @param map a [HashMap] that maps emoji characters (as keys) to their associated keywords (as a list of strings). - * - * Each row in the cursor is expected to contain a column for the emoji and a column for the keyword. - * This function adds each keyword to the list corresponding to its emoji in the map. - */ - private fun processEmojiCursor( - cursor: Cursor, - hashMap: HashMap>, - ) { - if (!cursor.moveToFirst()) return - - do { - val key = cursor.getString(0) - hashMap[key] = getEmojiKeyMaps(cursor) - } while (cursor.moveToNext()) - } - - /** - * Extracts emoji keyword mappings from a database cursor row. - * - * @param cursor the [Cursor] positioned at a row in the `emoji_keywords` table. - * @return a [MutableList] of [String] containing all column values except the first (typically the emoji itself). - * - * This function assumes the first column contains the emoji character, - * and the remaining columns contain associated keywords. - */ - private fun getEmojiKeyMaps(cursor: Cursor): MutableList = - MutableList(cursor.columnCount - 1) { index -> - cursor.getString(index + 1) - } -} diff --git a/app/src/main/java/be/scri/helpers/keyboardDBHelper/GenderDataManager.kt b/app/src/main/java/be/scri/helpers/keyboardDBHelper/GenderDataManager.kt deleted file mode 100644 index 1c9f649e..00000000 --- a/app/src/main/java/be/scri/helpers/keyboardDBHelper/GenderDataManager.kt +++ /dev/null @@ -1,157 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -import android.content.Context -import android.database.sqlite.SQLiteDatabase -import android.util.Log - -/** - * A helper class to manage and process gender data from an SQLite database for a given language and JSON contract. - */ -class GenderDataManager( - private val context: Context, -) { - /** - * Retrieves gender-related word mappings for a given language from the local SQLite database. - * - * @param language the language code (e.g., "en", "fr") used to locate the corresponding database. - * @param jsonData the [DataContract] defining which tables and fields to extract gender mappings from. - * @return a [HashMap] where each key is a base word and its value is a list of gendered variations. - * - * If the [jsonData] is `null` or the database does not exist, an empty map is returned. - */ - fun findGenderOfWord( - language: String, - jsonData: DataContract?, - ): HashMap> = - when { - jsonData == null -> HashMap() - !context.getDatabasePath("${language}LanguageData.sqlite").exists() -> { - Log.e("MY-TAG", "Database file for $language does not exist.") - HashMap() - } - - else -> processGenderData("${language}LanguageData.sqlite", jsonData) - } - - /** - * Processes the gender data from the SQLite database using the schema defined in the given [DataContract]. - * - * @param dbFileName the name of the SQLite database file containing the gender data. - * @param contract the [DataContract] that specifies which table and fields to use for extracting gender variations. - * @return a [HashMap] mapping base words to their gendered variants. - * - * The function reads the specified table and columns from the database and organizes the data - * into a map for easy access to gender-related word forms. - */ - private fun processGenderData( - dbPath: String, - jsonData: DataContract, - ): HashMap> = - HashMap>().also { genderMap -> - context.getDatabasePath(dbPath).path.let { path -> - SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - when { - hasCanonicalGender(jsonData) -> - processGenders( - db = db, - nounColumn = jsonData.numbers.keys.firstOrNull(), - genderColumn = jsonData.genders.canonical.firstOrNull(), - genderMap = genderMap, - ) - - hasMasculineFeminine(jsonData) -> { - processGenders( - db = db, - nounColumn = jsonData.genders.masculines.firstOrNull(), - genderMap = genderMap, - defaultGender = "masculine", - ) - processGenders( - db = db, - nounColumn = jsonData.genders.feminines.firstOrNull(), - genderMap = genderMap, - defaultGender = "feminine", - ) - } - - else -> Log.e("MY-TAG", "No valid gender columns found.") - } - } - } - Log.i("MY-TAG", "Found ${genderMap.size} gender entries") - } - - /** - * Checks whether the gender data contains a non-empty canonical gender entry. - * - * @param jsonData the [DataContract] containing gender metadata. - * @return `true` if the canonical gender list has at least one non-empty entry; `false` otherwise. - */ - private fun hasCanonicalGender(jsonData: DataContract): Boolean = - jsonData.genders.canonical - .firstOrNull() - ?.isNotEmpty() == true - - /** - * Determines whether both masculine and feminine gender lists are present and non-empty - * in the given [DataContract]. - * - * @param jsonData the [DataContract] containing gender metadata. - * @return `true` if both masculine and feminine lists are non-empty; `false` otherwise. - */ - private fun hasMasculineFeminine(jsonData: DataContract): Boolean = - jsonData.genders.masculines.isNotEmpty() && - jsonData.genders.feminines.isNotEmpty() - - /** - * Processes the gender data for nouns from the database and stores it in the provided gender map. - * - * This function reads from the "nouns" table in the database, extracting noun names and their associated genders. - * It adds the genders to the provided [genderMap] where the noun is the key, and the list of genders is the value. - * The function handles optional gender columns and allows setting a default gender if no specific gender is found. - * - * @param db the SQLite database instance to query. - * @param nounColumn the name of the column containing the noun names. - * @param genderMap a [HashMap] that will be populated with noun-gender pairs. - * @param genderColumn the name of the column containing gender data (optional). If `null`, the default gender will - * be used. - * @param defaultGender the default gender to be used if no specific gender is found for a noun. - */ - @Suppress("NestedBlockDepth") - private fun processGenders( - db: SQLiteDatabase, - nounColumn: String?, - genderMap: HashMap>, - genderColumn: String? = null, - defaultGender: String? = null, - ) { - db.rawQuery("SELECT * FROM nouns", null)?.use { cursor -> - val nounIndex = cursor.getColumnIndex(nounColumn) - val genderIndex = genderColumn?.let { cursor.getColumnIndex(it) } ?: -1 - - if (nounIndex == -1 || (genderColumn != null && genderIndex == -1)) { - Log.e("MY-TAG", "Required columns not found.") - return - } - - while (cursor.moveToNext()) { - cursor.getString(nounIndex)?.lowercase()?.takeUnless { it.isEmpty() }?.let { noun -> - val gender = - when { - genderColumn != null -> cursor.getString(genderIndex) - else -> defaultGender - } - - if (!gender.isNullOrEmpty()) { - val existingGenders = genderMap[noun]?.toMutableList() ?: mutableListOf() - if (!existingGenders.contains(gender)) { - existingGenders.add(gender) - genderMap[noun] = existingGenders - } - } - } - } - } - Log.i("MY-TAG", genderMap["schild"].toString()) - } -} diff --git a/app/src/main/java/be/scri/helpers/keyboardDBHelper/PluralFormsManager.kt b/app/src/main/java/be/scri/helpers/keyboardDBHelper/PluralFormsManager.kt deleted file mode 100644 index 85098ae6..00000000 --- a/app/src/main/java/be/scri/helpers/keyboardDBHelper/PluralFormsManager.kt +++ /dev/null @@ -1,188 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -import android.content.Context -import android.database.Cursor -import android.database.sqlite.SQLiteDatabase -import android.util.Log - -/** - * A helper class to manage and query plural forms of - * words from an SQLite database based on the provided language and JSON contract. - */ -class PluralFormsManager( - private val context: Context, -) { - /** - * Checks if a word is plural by querying the database for plural forms. - * - * This function checks if the `numbers` data in the provided [jsonData] is valid and non-empty. - * It retrieves the plural forms from the JSON data and queries the database to check for matching plural forms. - * - * @param language the language code (e.g., "en" for English) used to locate the corresponding database file. - * @param jsonData the [DataContract] containing language-specific data, including plural forms. - * @return a list of plural forms for the word, or `null` if the `numbers` data is null or empty. - */ - fun checkIfWordIsPlural( - language: String, - jsonData: DataContract?, - ): List? { - if (jsonData?.numbers?.values.isNullOrEmpty()) { - Log.e("MY-TAG", "JSON data for 'numbers' is null or empty.") - return null - } - - val dbFile = context.getDatabasePath("${language}LanguageData.sqlite") - val pluralForms = jsonData!!.numbers.values.toList() - Log.d("MY-TAG", "Plural Forms: $pluralForms") - - return queryPluralForms(dbFile.path, pluralForms) - } - - /** - * Queries the plural representation of a given noun based on the provided language and JSON data. - * - * This function checks if the `numbers` data in the provided [jsonData] is valid and non-empty. It retrieves - * the plural and singular forms from the JSON data and queries the database to find the corresponding plural form - * for the given noun. - * - * @param language the language code (e.g., "en" for English) used to locate the corresponding database file. - * @param jsonData the [DataContract] containing language-specific data, including singular and plural forms. - * @param noun the noun for which the plural representation is being queried. - * @return a map containing the plural forms of the noun, or an empty map if the `numbers` data is null or empty. - */ - fun queryPluralRepresentation( - language: String, - jsonData: DataContract?, - noun: String, - ): Map { - if (jsonData?.numbers?.values.isNullOrEmpty()) { - Log.e("MY-TAG", "JSON data for 'numbers' is null or empty.") - return mapOf() - } - - val dbFile = context.getDatabasePath("${language}LanguageData.sqlite") - val pluralForms = jsonData!!.numbers.values.toList() - val singularForms = jsonData.numbers.keys.toList() - - return queryPluralForms(dbFile.path, pluralForms, singularForms, noun) - } - - /** - * Queries the database for plural forms of nouns based on the provided plural form values. - * - * This function opens the database located at [dbPath] and searches for entries in the `nouns` table. - * It checks each noun against the provided [pluralForms] list, adding any matching plural forms - * to the result list. The database is opened in read-only mode, and the query is processed using a cursor. - * - * @param dbPath the file path to the SQLite database containing noun data. - * @param pluralForms the list of plural forms to match against nouns in the database. - * @return a list of plural forms found in the database that match the provided [pluralForms]. - */ - private fun queryPluralForms( - dbPath: String, - pluralForms: List, - ): List { - val result = mutableListOf() - val db = SQLiteDatabase.openDatabase(dbPath, null, SQLiteDatabase.OPEN_READONLY) - - db.use { database -> - database.rawQuery("SELECT * FROM nouns", null)?.use { cursor -> - processPluralFormsCursor(cursor, pluralForms, result) - } - } - db.close() - return result - } - - /** - * Queries the database for plural forms of a noun based on the provided singular forms and plural forms. - * - * This function builds a dynamic SQL query to search for nouns in the database that match the provided - * [singularForms]. - * For each singular noun found, the function attempts to find the corresponding plural form from the [pluralForms] - * list. - * The results are mapped as a pair of plural forms and their corresponding plural representations. - * - * @param dbPath the file path to the SQLite database that contains noun data. - * @param pluralForms the list of plural forms that are being matched against the database entries. - * @param singularForms the list of singular noun forms used to query the database. - * @param noun the specific noun for which plural forms are being queried. - * @return a map where the keys are plural forms from [pluralForms], and the values are the corresponding plural - * forms found in the database. If no match is found, the value will be `null` for that plural form. - */ - private fun queryPluralForms( - dbPath: String, - pluralForms: List, - singularForms: List, - noun: String, - ): Map { - val result = mutableListOf() - val db = SQLiteDatabase.openDatabase(dbPath, null, SQLiteDatabase.OPEN_READONLY) - val queryBuilder = StringBuilder("SELECT * FROM nouns WHERE") - val placeholders = singularForms.joinToString("OR ") { "$it = ?" } - queryBuilder.append(" $placeholders;") - val selectionArgs = Array(singularForms.size) { noun } - - db.use { database -> - database.rawQuery(queryBuilder.toString(), selectionArgs)?.use { cursor -> - processPluralFormsCursor(cursor, pluralForms, result) - } - } - db.close() - return pluralForms.zip(result).toMap() - } - - /** - * Processes the cursor containing noun data and extracts plural forms. - * - * This function iterates over the cursor, which contains data from the `nouns` table in the database. - * For each row in the cursor, it extracts the relevant plural forms and adds them to the provided [result] list. - * The plural forms to match against are provided in the [pluralForms] list. - * - * If the cursor is empty, a warning message is logged indicating that no data was found. - * - * @param cursor the cursor containing the results from a query on the `nouns` table. - * @param pluralForms the list of plural forms to be matched against the noun data. - * @param result the mutable list to store the plural forms that are found. - */ - private fun processPluralFormsCursor( - cursor: Cursor, - pluralForms: List, - result: MutableList, - ) { - if (!cursor.moveToFirst()) { - Log.w("MY-TAG", "Cursor is empty, no data found in 'nouns' table.") - return - } - - do { - addPluralForms(cursor, pluralForms, result) - } while (cursor.moveToNext()) - } - - /** - * Adds plural forms from the cursor to the result list based on the provided plural form names. - * - * This function checks for the presence of columns corresponding to the plural forms in the cursor. - * For each plural form name in the [pluralForms] list, it looks for the corresponding column in the cursor. - * If the column exists, its value is added to the [result] list. If the column is not found, an error is logged. - * - * @param cursor the cursor containing noun data, which should have columns corresponding to plural forms. - * @param pluralForms the list of plural form names that correspond to columns in the cursor. - * @param result the mutable list to store the plural form values retrieved from the cursor. - */ - private fun addPluralForms( - cursor: Cursor, - pluralForms: List, - result: MutableList, - ) { - pluralForms.forEach { pluralForm -> - val columnIndex = cursor.getColumnIndex(pluralForm) - if (columnIndex != -1) { - cursor.getString(columnIndex)?.let { result.add(it) } - } else { - Log.e("MY-TAG", "Column '$pluralForm' not found in the database.") - } - } - } -} diff --git a/app/src/main/java/be/scri/helpers/keyboardDBHelper/PrepositionDataManager.kt b/app/src/main/java/be/scri/helpers/keyboardDBHelper/PrepositionDataManager.kt deleted file mode 100644 index 2250cafe..00000000 --- a/app/src/main/java/be/scri/helpers/keyboardDBHelper/PrepositionDataManager.kt +++ /dev/null @@ -1,111 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -package be.scri.helpers.keyboardDBHelper - -import android.content.Context -import android.database.Cursor -import android.database.sqlite.SQLiteDatabase -import android.util.Log - -/** - * A helper class to manage preposition data and extract case annotations from an SQLite database for a given language. - */ -class PrepositionDataManager( - private val context: Context, -) { - /** - * Opens a read-only SQLite database for the specified language. - * - * This function opens a database file named `LanguageData.sqlite` from the app's internal storage. - * It returns an [SQLiteDatabase] object that allows for querying the database in read-only mode. - * - * @param language the language code (e.g., "en", "de") used to locate the corresponding database file. - * @return an [SQLiteDatabase] object representing the open database. - * @throws SQLiteException if the database cannot be opened, for example if the file does not exist or is corrupted. - */ - fun openDatabase(language: String): SQLiteDatabase { - val dbFile = context.getDatabasePath("${language}LanguageData.sqlite") - return SQLiteDatabase.openDatabase(dbFile.path, null, SQLiteDatabase.OPEN_READONLY) - } - - /** - * Extracts the preposition and its associated case annotation from the provided cursor. - * - * This function retrieves the preposition (at column index 1) and its case annotation (at column index 2) - * from the given [Cursor]. The preposition and case annotation are returned as a pair. - * - * @param cursor the [Cursor] containing the data, assumed to have at least two columns: - * - Column 1: the preposition - * - Column 2: the case annotation. - * @return a [Pair] where the first element is the preposition (a [String]), - * and the second element is the case annotation (a [String]). - */ - fun extractPrepositionCase(cursor: Cursor): Pair { - val preposition = cursor.getString(1) - val caseAnnotation = cursor.getString(2) - return preposition to caseAnnotation - } - - /** - * Processes a [Cursor] to extract prepositions and their associated case annotations. - * The data is stored in a [HashMap], where the key is the preposition and the value is a list - * of associated case annotations. If a preposition appears more than once, its case annotations - * are accumulated in the list. - * - * This function expects that the cursor contains at least two columns: - * - Column 1: Preposition (a [String]). - * - Column 2: Case annotation (a [String]). - * - * The [Cursor] is iterated over, and the [extractPrepositionCase] function is used to extract - * the preposition and case annotation for each row. The result is stored in the map, with the - * preposition as the key and a list of case annotations as the value. - * - * @param cursor The [Cursor] containing preposition and case annotation data. - * It is assumed to have two columns: - * - The first column contains the preposition (a [String]). - * - The second column contains the case annotation (a [String]). - * @return A [HashMap] where each key is a preposition and the value is a list of associated - * case annotations. - */ - fun processCursor(cursor: Cursor): HashMap> { - val result = HashMap>() - if (cursor.moveToFirst()) { - do { - val (preposition, caseAnnotation) = extractPrepositionCase(cursor) - if (result.containsKey(preposition)) { - result[preposition]?.add(caseAnnotation) - } else { - result[preposition] = mutableListOf(caseAnnotation) - } - } while (cursor.moveToNext()) - } - return result - } - - /** - * Retrieves case annotations associated with prepositions from the database for a specified language. - * The case annotations are stored in a [HashMap], where the key is the preposition, and the value - * is a list of associated case annotations. - * - * This function opens the database for the specified language, queries the `PREPOSITIONS` table - * to fetch all prepositions and their corresponding case annotations, and processes the data using - * the [processCursor] function. The resulting [HashMap] is returned, containing the prepositions as - * keys and the case annotations as values. - * - * @param language The language code for the database from which the case annotations are to be retrieved. - * @return A [HashMap] where each key is a preposition, and the value is a list of case annotations - * associated with that preposition. - */ - fun getCaseAnnotations(language: String): HashMap> { - val db = openDatabase(language) - val result = HashMap>() - - db.use { database -> - database.rawQuery("SELECT * FROM PREPOSITIONS", null)?.use { cursor -> - result.putAll(processCursor(cursor)) - } - } - Log.i("MY-TAG", " These are the case annotations ${result["in"]}") - return result - } -} diff --git a/app/src/main/java/be/scri/helpers/keyboardDBHelper/TranslationDataManager.kt b/app/src/main/java/be/scri/helpers/keyboardDBHelper/TranslationDataManager.kt deleted file mode 100644 index c703a613..00000000 --- a/app/src/main/java/be/scri/helpers/keyboardDBHelper/TranslationDataManager.kt +++ /dev/null @@ -1,121 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -package be.scri.helpers.keyboardDBHelper - -import android.content.Context -import android.database.sqlite.SQLiteDatabase -import be.scri.helpers.PreferencesHelper -import java.io.FileOutputStream - -/** - * A helper class to manage translations from a local SQLite database. - * - * This class provides methods to fetch translations between different languages - * by querying the `TranslationData.sqlite` database stored locally. It handles - * the source and destination language code generation based on user preferences. - * - * @property context The application context used to access resources and the database. - */ -class TranslationDataManager( - private val context: Context, -) { - /** - * Retrieves the source and destination language ISO codes based on the provided language. - * - * This function fetches the preferred translation language from the preferences - * and generates the corresponding ISO codes for the source and destination languages. - * - * @param language The language name (e.g., "english") for which the source and destination - * ISO codes need to be determined. - * @return A pair of ISO codes: the source language and the destination language. - */ - fun getSourceAndDestinationLanguage(language: String): Pair { - val sourceLanguage = PreferencesHelper.getPreferredTranslationLanguage(context, language) - return Pair(generateISOCodeForLanguage(sourceLanguage.toString()), generateISOCodeForLanguage(language)) - } - - /** - * Generates the ISO code for a given language name. - * - * This function maps the full name of a language (e.g., "english") to its corresponding - * ISO 639-1 code (e.g., "en"). - * - * @param languageName The name of the language (e.g., "english"). - * @return The ISO 639-1 code for the given language, or "en" if the language is unrecognized. - */ - private fun generateISOCodeForLanguage(languageName: String): String? = - when (languageName.lowercase()) { - "english" -> "en" - "french" -> "fr" - "german" -> "de" - "spanish" -> "es" - "italian" -> "it" - "portuguese" -> "pt" - "russian" -> "ru" - else -> "en" - } - - /** - * Generates the full language name for a given ISO code. - * - * This function maps an ISO 639-1 language code (e.g., "en") to the corresponding full - * language name (e.g., "english"). - * - * @param isoCode The ISO 639-1 code for the language (e.g., "en"). - * @return The full name of the language, or "english" if the ISO code is unrecognized. - */ - private fun generateLanguageNameForISOCode(isoCode: String): String? = - when (isoCode.lowercase()) { - "en" -> "english" - "fr" -> "french" - "de" -> "german" - "es" -> "spanish" - "it" -> "italian" - "pt" -> "portuguese" - "ru" -> "russian" - else -> "english" - } - - /** - * Retrieves the translation data for a given word from the database. - * - * This function queries the database for the translation of the given word from the source - * language to the destination language, using the source and destination language ISO codes. - * - * @param sourceAndDestination A pair of ISO codes representing the source and destination languages. - * @param word The word for which the translation is to be fetched. - * @return The translation of the word in the destination language, or an empty string if not found. - */ - fun getTranslationDataForAWord( - sourceAndDestination: Pair, - word: String, - ): String { - val sourceLanguage = generateLanguageNameForISOCode(sourceAndDestination.first!!) - val destinationColumn = sourceAndDestination.second!! - val dbPath = context.getDatabasePath("TranslationData.sqlite") - - if (!dbPath.exists()) { - dbPath.parentFile?.mkdirs() - context.assets.open("data/TranslationData.sqlite").use { inputStream -> - FileOutputStream(dbPath).use { outputStream -> - inputStream.copyTo(outputStream) - } - } - } - val db = SQLiteDatabase.openDatabase(dbPath.path, null, SQLiteDatabase.OPEN_READONLY) - var result = "" - - db.use { database -> - val query = "SELECT $destinationColumn FROM `$sourceLanguage` WHERE word = ?" - val cursor = database.rawQuery(query, arrayOf(word)) - - cursor.use { - if (it.moveToFirst()) { - result = it.getString(it.getColumnIndexOrThrow(destinationColumn)) - } - } - } - - return result - } -} diff --git a/app/src/main/java/be/scri/helpers/HintUtils.kt b/app/src/main/java/be/scri/helpers/ui/HintUtils.kt similarity index 98% rename from app/src/main/java/be/scri/helpers/HintUtils.kt rename to app/src/main/java/be/scri/helpers/ui/HintUtils.kt index 73792cca..fe69c3a9 100644 --- a/app/src/main/java/be/scri/helpers/HintUtils.kt +++ b/app/src/main/java/be/scri/helpers/ui/HintUtils.kt @@ -1,8 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later -package be.scri.helpers +package be.scri.helpers.ui import android.content.Context +import be.scri.helpers.PreferencesHelper import be.scri.helpers.english.ENInterfaceVariables import be.scri.helpers.french.FRInterfaceVariables import be.scri.helpers.german.DEInterfaceVariables @@ -12,6 +13,7 @@ import be.scri.helpers.russian.RUInterfaceVariables import be.scri.helpers.spanish.ESInterfaceVariables import be.scri.helpers.swedish.SVInterfaceVariables import be.scri.services.GeneralKeyboardIME +import kotlin.collections.get /** * Utility object for handling hint-related logic throughout the application. diff --git a/app/src/main/java/be/scri/helpers/ui/RatingHelper.kt b/app/src/main/java/be/scri/helpers/ui/RatingHelper.kt new file mode 100644 index 00000000..8234af72 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/ui/RatingHelper.kt @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers.ui + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import com.google.android.play.core.review.ReviewManagerFactory + +/** + * Helper object for managing app rating functionality. + * + * This object provides methods to determine the installation source of the app + * and initiate the appropriate rating process, such as launching an in-app review + * for Play Store installs or opening the F-Droid page for F-Droid installs. + */ +object RatingHelper { + private const val INSTALLER_PLAY_STORE = "com.android.vending" + private const val INSTALLER_FDROID = "org.fdroid.fdroid" + + /** + * Gets the package name of the app that installed this app. + * + * For example, "com.android.vending" for Google Play Store. + * + * @param context App context. + * @return Installer package name, or null if unknown or on error. Logs errors. + */ + private fun getInstallSource(context: Context): String? = + try { + context.packageManager.getInstallerPackageName(context.packageName) + } catch (e: PackageManager.NameNotFoundException) { + Log.e("RatingHelper", "Failed to get install source", e) + null + } + + /** + * Initiates the app rating process based on the installation source. + * + * If the app was installed from the Google Play Store, it attempts to launch the in-app review flow. + * If the app was installed from F-Droid, it opens the app's F-Droid page in a browser. + * For any other installation source, it displays a toast message indicating an unknown source. + * + * @param context The application context. + * @param activity The current activity, required for launching the in-app review flow. + */ + fun rateScribe( + context: Context, + activity: ComponentActivity, + ) { + when (getInstallSource(context)) { + INSTALLER_PLAY_STORE -> { + val reviewManager = ReviewManagerFactory.create(context) + val request = reviewManager.requestReviewFlow() + + request.addOnCompleteListener { task -> + if (task.isSuccessful) { + val reviewInfo = task.result + reviewManager + .launchReviewFlow(activity, reviewInfo) + .addOnCompleteListener { } + } else { + Toast.makeText(context, "Failed to launch review flow", Toast.LENGTH_SHORT).show() + } + } + } + + INSTALLER_FDROID -> { + val url = "https://f-droid.org/packages/${context.packageName}" + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + try { + context.startActivity(intent) + } catch (e: PackageManager.NameNotFoundException) { + Toast.makeText(context, "No browser found to open F-Droid page", Toast.LENGTH_SHORT).show() + Log.e("RatingHelper", "Unable to open F-Droid link", e) + } + } + + else -> { + Toast.makeText(context, "Unknown installation source", Toast.LENGTH_SHORT).show() + } + } + } +} diff --git a/app/src/main/java/be/scri/helpers/ShareHelper.kt b/app/src/main/java/be/scri/helpers/ui/ShareHelper.kt similarity index 58% rename from app/src/main/java/be/scri/helpers/ShareHelper.kt rename to app/src/main/java/be/scri/helpers/ui/ShareHelper.kt index 52a8a1ed..d1d60471 100644 --- a/app/src/main/java/be/scri/helpers/ShareHelper.kt +++ b/app/src/main/java/be/scri/helpers/ui/ShareHelper.kt @@ -1,22 +1,23 @@ // SPDX-License-Identifier: GPL-3.0-or-later - -package be.scri.helpers +package be.scri.helpers.ui import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.util.Log -import androidx.core.content.ContextCompat.startActivity +import androidx.core.content.ContextCompat /** * A helper to facilitate sharing of the application and contacting the team. */ object ShareHelper { /** - * Shares the link to the Scribe Android project repository. - * This method opens an intent chooser to allow the user to select an application to share the link. + * Shares the Scribe GitHub repository link using an external app. + * + * Creates an ACTION_SEND intent with the repository URL and starts an activity chooser. + * Handles [ActivityNotFoundException] and [IllegalArgumentException]. * - * @param context The application context. + * @param context The Context to launch the sharing intent from. */ fun shareScribe(context: Context) { try { @@ -25,7 +26,11 @@ object ShareHelper { type = "text/plain" putExtra(Intent.EXTRA_TEXT, "https://github.com/scribe-org/Scribe-Android") } - startActivity(context, Intent.createChooser(sharingIntent, "Share via"), null) + ContextCompat.startActivity( + context, + Intent.createChooser(sharingIntent, "Share via"), + null, + ) } catch (e: ActivityNotFoundException) { Log.e("AboutFragment", "No application found to share content", e) } catch (e: IllegalArgumentException) { @@ -35,19 +40,27 @@ object ShareHelper { /** * Sends an email to the Scribe team. - * This method opens an intent chooser to allow the user to select an email client to send the email. * - * @param context The application context. + * Launches an email client with a pre-filled email to "team@scri.be" + * and subject "Hey Scribe!". + * + * @param context The context to launch the email intent from. + * @throws ActivityNotFoundException If no email client is installed. + * @throws IllegalArgumentException If there's an issue with the email intent arguments. */ fun sendEmail(context: Context) { try { val intent = Intent(Intent.ACTION_SEND).apply { + type = "message/rfc822" putExtra(Intent.EXTRA_EMAIL, arrayOf("team@scri.be")) putExtra(Intent.EXTRA_SUBJECT, "Hey Scribe!") - type = "message/rfc822" } - startActivity(context, Intent.createChooser(intent, "Choose an Email client:"), null) + ContextCompat.startActivity( + context, + Intent.createChooser(intent, "Choose an Email client:"), + null, + ) } catch (e: ActivityNotFoundException) { Log.e("AboutFragment", "No email client found", e) } catch (e: IllegalArgumentException) { diff --git a/app/src/main/java/be/scri/services/EnglishKeyboardIME.kt b/app/src/main/java/be/scri/services/EnglishKeyboardIME.kt index 4850c0b4..d860b663 100644 --- a/app/src/main/java/be/scri/services/EnglishKeyboardIME.kt +++ b/app/src/main/java/be/scri/services/EnglishKeyboardIME.kt @@ -3,40 +3,24 @@ package be.scri.services import android.text.InputType -import android.view.View import android.view.inputmethod.EditorInfo.IME_ACTION_NONE import be.scri.R -import be.scri.databinding.KeyboardViewCommandOptionsBinding import be.scri.helpers.KeyHandler import be.scri.helpers.KeyboardBase import be.scri.helpers.PreferencesHelper.getEnablePeriodAndCommaABC import be.scri.helpers.PreferencesHelper.getIsPreviewEnabled import be.scri.helpers.PreferencesHelper.getIsVibrateEnabled -import be.scri.views.KeyboardView /** * The EnglishKeyboardIME class provides the input method for the English language keyboard. */ class EnglishKeyboardIME : GeneralKeyboardIME("English") { companion object { - /** - * Threshold value (in dp) for determining if the device is a tablet. - */ const val SMALLEST_SCREEN_WIDTH_TABLET = 600 } - /** - * Checks whether the current device is a tablet based on smallest screen width. - * - * @return Boolean true if the device is a tablet, false otherwise. - */ private fun isTablet(): Boolean = resources.configuration.smallestScreenWidthDp >= SMALLEST_SCREEN_WIDTH_TABLET - /** - * Returns the appropriate keyboard layout XML resource based on device type and user preferences. - * - * @return Int XML layout resource ID. - */ override fun getKeyboardLayoutXML(): Int = when { isTablet() -> R.xml.keys_letters_english_tablet @@ -44,118 +28,34 @@ class EnglishKeyboardIME : GeneralKeyboardIME("English") { else -> R.xml.keys_letters_english_without_period_and_comma } - /** - * Constant representing the letter keyboard mode. - */ - override val keyboardLetters = 0 - - /** - * Constant representing the symbol keyboard mode. - */ - override val keyboardSymbols = 1 - - /** - * Constant representing the shifted symbol keyboard mode. - */ - override val keyboardSymbolShift = 2 - - /** - * The active keyboard layout instance. - */ + override val keyboardLetters: Int = 0 + override val keyboardSymbols: Int = 1 + override val keyboardSymbolShift: Int = 2 override var keyboard: KeyboardBase? = null + override var lastShiftPressTS: Long = 0L + override var keyboardMode: Int = keyboardLetters // default to letters + override var inputTypeClass: Int = InputType.TYPE_CLASS_TEXT + override var enterKeyType: Int = IME_ACTION_NONE + override var switchToLetters: Boolean = false + override var hasTextBeforeCursor: Boolean = false - /** - * The UI view component used to display the keyboard. - */ - override var keyboardView: KeyboardView? = null - - /** - * Timestamp of the last time the shift key was pressed. - */ - override var lastShiftPressTS = 0L - - /** - * The current mode of the keyboard (e.g., letters, symbols). - */ - - override var keyboardMode = keyboardLetters + private val keyHandler by lazy { KeyHandler(this) } /** - * Defines the input type class (e.g., text, number). + * Initializes the keyboard. This is where the magic happens. */ - override var inputTypeClass = InputType.TYPE_CLASS_TEXT - - /** - * Defines the type of Enter key action (e.g., none, done, send). - */ - override var enterKeyType = IME_ACTION_NONE - - /** - * Indicates whether to switch back to letter mode after a symbol is typed. - */ - - override var switchToLetters = false - - /** - * Indicates whether there is text before the current cursor position. - */ - override var hasTextBeforeCursor = false - - /** - * Binding for the command options layout used with the keyboard view. - */ - override lateinit var binding: KeyboardViewCommandOptionsBinding - - // Key handling logic extracted to a separate class - - /** - * Instance of KeyHandler responsible for processing key input events. - */ - private val keyHandler = KeyHandler(this) - - /** - * Inflates and returns the keyboard view layout, sets up properties and listeners. - * - * @return View The root view of the keyboard UI. - */ - - override fun onCreateInputView(): View { - binding = KeyboardViewCommandOptionsBinding.inflate(layoutInflater) - setupCommandBarTheme(binding) - val keyboardHolder = binding.root - keyboardView = binding.keyboardView - keyboardView!!.setKeyboard(keyboard!!) - keyboardView!!.setPreview = getIsPreviewEnabled(applicationContext, language) - keyboardView!!.setVibrate = getIsVibrateEnabled(applicationContext, language) - when (currentState) { - ScribeState.IDLE -> keyboardView!!.setEnterKeyColor(null) - else -> keyboardView!!.setEnterKeyColor(R.color.dark_scribe_blue) - } + override fun onCreate() { + super.onCreate() - keyboardView!!.setKeyboardHolder() - keyboardView?.mOnKeyboardActionListener = this - initializeEmojiButtons() - updateUI() - return keyboardHolder + // Add customizations specific to the English keyboard. + keyboardView?.setPreview = getIsPreviewEnabled(applicationContext, language) + keyboardView?.setVibrate = getIsVibrateEnabled(applicationContext, language) } /** * Handles key input from the keyboard and delegates it to [KeyHandler]. - * - * @param code The integer code of the key that was pressed. */ override fun onKey(code: Int) { keyHandler.handleKey(code, language) } - - /** - * Initializes the keyboard and sets up the input view upon service creation. - */ - - override fun onCreate() { - super.onCreate() - keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) - onCreateInputView() - setupCommandBarTheme(binding) - } } diff --git a/app/src/main/java/be/scri/services/FrenchKeyboardIME.kt b/app/src/main/java/be/scri/services/FrenchKeyboardIME.kt index b8ba3471..29f84a67 100644 --- a/app/src/main/java/be/scri/services/FrenchKeyboardIME.kt +++ b/app/src/main/java/be/scri/services/FrenchKeyboardIME.kt @@ -3,36 +3,24 @@ package be.scri.services import android.text.InputType -import android.view.View import android.view.inputmethod.EditorInfo.IME_ACTION_NONE import be.scri.R -import be.scri.databinding.KeyboardViewCommandOptionsBinding import be.scri.helpers.KeyHandler import be.scri.helpers.KeyboardBase import be.scri.helpers.PreferencesHelper.getEnablePeriodAndCommaABC import be.scri.helpers.PreferencesHelper.getIsPreviewEnabled import be.scri.helpers.PreferencesHelper.getIsVibrateEnabled -import be.scri.views.KeyboardView /** * The FrenchKeyboardIME class provides the input method for the French language keyboard. */ class FrenchKeyboardIME : GeneralKeyboardIME("French") { companion object { - // Define the smallest screen width threshold for tablets const val SMALLEST_SCREEN_WIDTH_TABLET = 600 } - /** - * Determines if the device is a tablet based on screen width. - * @return True if the device is a tablet, false otherwise. - */ private fun isTablet(): Boolean = resources.configuration.smallestScreenWidthDp >= SMALLEST_SCREEN_WIDTH_TABLET - /** - * Returns the XML layout resource for the keyboard based on user preferences. - * @return The resource ID of the keyboard layout XML. - */ override fun getKeyboardLayoutXML(): Int = when { isTablet() -> R.xml.keys_letters_french_tablet @@ -40,65 +28,37 @@ class FrenchKeyboardIME : GeneralKeyboardIME("French") { else -> R.xml.keys_letter_french_without_period_and_comma } - // Keyboard modes - override val keyboardLetters = 0 - override val keyboardSymbols = 1 - override val keyboardSymbolShift = 2 - - // Keyboard components + override val keyboardLetters: Int = 0 + override val keyboardSymbols: Int = 1 + override val keyboardSymbolShift: Int = 2 override var keyboard: KeyboardBase? = null - override var keyboardView: KeyboardView? = null - override var lastShiftPressTS = 0L - override var keyboardMode = keyboardLetters - override var inputTypeClass = InputType.TYPE_CLASS_TEXT - override var enterKeyType = IME_ACTION_NONE - override var switchToLetters = false - override var hasTextBeforeCursor = false - override lateinit var binding: KeyboardViewCommandOptionsBinding + override var lastShiftPressTS: Long = 0L + override var keyboardMode: Int = keyboardLetters + override var inputTypeClass: Int = InputType.TYPE_CLASS_TEXT + override var enterKeyType: Int = IME_ACTION_NONE + override var switchToLetters: Boolean = false + override var hasTextBeforeCursor: Boolean = false - private val keyHandler = KeyHandler(this) + // REFACTOR_FIX: The 'binding' and 'keyboardView' properties are no longer abstract in the parent class, + // so we must remove the overrides here. They are now inherited directly. + // override lateinit var binding: KeyboardViewCommandOptionsBinding // REMOVED + // override var keyboardView: KeyboardView? = null // REMOVED + + private val keyHandler by lazy { KeyHandler(this) } /** - * Creates and returns the input view for the keyboard. - * @return The root view of the keyboard layout. + * Initializes the keyboard. Let the parent class handle the setup. */ - override fun onCreateInputView(): View { - binding = KeyboardViewCommandOptionsBinding.inflate(layoutInflater) - setupCommandBarTheme(binding) - val keyboardHolder = binding.root - keyboardView = binding.keyboardView - keyboardView!!.setKeyboard(keyboard!!) - keyboardView!!.setPreview = getIsPreviewEnabled(applicationContext, language) - keyboardView!!.setVibrate = getIsVibrateEnabled(applicationContext, language) - - // Update the enter key color based on the current state - when (currentState) { - ScribeState.IDLE -> keyboardView!!.setEnterKeyColor(null) - else -> keyboardView!!.setEnterKeyColor(R.color.dark_scribe_blue) - } - - keyboardView!!.setKeyboardHolder() - keyboardView?.mOnKeyboardActionListener = this - initializeEmojiButtons() - updateUI() - return keyboardHolder + override fun onCreate() { + super.onCreate() + keyboardView?.setPreview = getIsPreviewEnabled(applicationContext, language) + keyboardView?.setVibrate = getIsVibrateEnabled(applicationContext, language) } /** * Handles key press events on the keyboard. - * @param code The key code of the pressed key. */ override fun onKey(code: Int) { keyHandler.handleKey(code, language) } - - /** - * Initializes the keyboard and sets up the input view. - */ - override fun onCreate() { - super.onCreate() - keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) - onCreateInputView() - setupCommandBarTheme(binding) - } } diff --git a/app/src/main/java/be/scri/services/GeneralKeyboardIME.kt b/app/src/main/java/be/scri/services/GeneralKeyboardIME.kt index c44ee0bd..75eca21a 100644 --- a/app/src/main/java/be/scri/services/GeneralKeyboardIME.kt +++ b/app/src/main/java/be/scri/services/GeneralKeyboardIME.kt @@ -2,10 +2,13 @@ package be.scri.services +import DataContract import android.content.Context import android.content.res.Configuration import android.graphics.Color -import android.graphics.PorterDuff +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.LayerDrawable +import android.graphics.drawable.RippleDrawable import android.inputmethodservice.InputMethodService import android.text.InputType import android.text.InputType.TYPE_CLASS_DATETIME @@ -13,7 +16,6 @@ import android.text.InputType.TYPE_CLASS_NUMBER import android.text.InputType.TYPE_CLASS_PHONE import android.text.InputType.TYPE_MASK_CLASS import android.text.TextUtils -import android.util.Log import android.view.KeyEvent import android.view.View import android.view.inputmethod.EditorInfo @@ -30,49 +32,36 @@ import androidx.core.graphics.toColorInt import be.scri.R import be.scri.R.color.md_grey_black_dark import be.scri.R.color.white -import be.scri.databinding.KeyboardViewCommandOptionsBinding -import be.scri.databinding.KeyboardViewKeyboardBinding -import be.scri.helpers.DatabaseHelper -import be.scri.helpers.HintUtils +import be.scri.databinding.InputMethodViewBinding +import be.scri.helpers.AnnotationTextUtils.handleColorAndTextForNounType +import be.scri.helpers.AnnotationTextUtils.handleTextForCaseAnnotation +import be.scri.helpers.DatabaseManagers +import be.scri.helpers.EmojiUtils.insertEmoji +import be.scri.helpers.EmojiUtils.isEmoji import be.scri.helpers.KeyboardBase +import be.scri.helpers.LanguageMappingConstants.conjugatePlaceholder +import be.scri.helpers.LanguageMappingConstants.getLanguageAlias +import be.scri.helpers.LanguageMappingConstants.pluralPlaceholder +import be.scri.helpers.LanguageMappingConstants.translatePlaceholder import be.scri.helpers.PERIOD_ON_DOUBLE_TAP import be.scri.helpers.PreferencesHelper import be.scri.helpers.PreferencesHelper.getIsDarkModeOrNot import be.scri.helpers.PreferencesHelper.getIsEmojiSuggestionsEnabled -import be.scri.helpers.PreferencesHelper.getPreferredTranslationLanguage import be.scri.helpers.SHIFT_OFF import be.scri.helpers.SHIFT_ON_ONE_CHAR import be.scri.helpers.SHIFT_ON_PERMANENT -import be.scri.helpers.english.ENInterfaceVariables -import be.scri.helpers.french.FRInterfaceVariables -import be.scri.helpers.german.DEInterfaceVariables -import be.scri.helpers.italian.ITInterfaceVariables -import be.scri.helpers.portuguese.PTInterfaceVariables -import be.scri.helpers.russian.RUInterfaceVariables -import be.scri.helpers.spanish.ESInterfaceVariables -import be.scri.helpers.swedish.SVInterfaceVariables +import be.scri.helpers.SuggestionHandler +import be.scri.helpers.ui.HintUtils import be.scri.views.KeyboardView -// based on https://www.androidauthority.com/lets-build-custom-keyboard-android-832362/ - private const val DATA_SIZE_2 = 2 - private const val DATA_CONSTANT_3 = 3 -/** - * The base keyboard input method (IME) imported into all language keyboards. - */ @Suppress("TooManyFunctions", "LargeClass") abstract class GeneralKeyboardIME( var language: String, ) : InputMethodService(), KeyboardView.OnKeyboardActionListener { - /** - * Returns the XML layout resource ID for the current keyboard layout. - * Subclasses must implement this to provide the appropriate keyboard XML layout. - * - * @return The resource ID of the keyboard layout XML file. - */ abstract fun getKeyboardLayoutXML(): Int abstract val keyboardLetters: Int @@ -80,14 +69,15 @@ abstract class GeneralKeyboardIME( abstract val keyboardSymbolShift: Int abstract var keyboard: KeyboardBase? - abstract var keyboardView: KeyboardView? + var keyboardView: KeyboardView? = null abstract var lastShiftPressTS: Long abstract var keyboardMode: Int abstract var inputTypeClass: Int abstract var enterKeyType: Int abstract var switchToLetters: Boolean abstract var hasTextBeforeCursor: Boolean - abstract var binding: KeyboardViewCommandOptionsBinding + + internal lateinit var binding: InputMethodViewBinding private var pluralBtn: Button? = null private var emojiBtnPhone1: Button? = null @@ -98,30 +88,25 @@ abstract class GeneralKeyboardIME( private var emojiBtnTablet2: Button? = null private var emojiSpaceTablet2: View? = null private var emojiBtnTablet3: Button? = null - private var genderSuggestionLeft: Button? = null private var genderSuggestionRight: Button? = null - private var isSingularAndPlural: Boolean = false + internal var isSingularAndPlural: Boolean = false private var subsequentAreaRequired: Boolean = false - - private var subsequentAreaKey: Int = 0 - - private var subsequentAreaItems: Int = 0 - private var subsequentData: MutableList> = mutableListOf() - // How quickly do we have to double-tap shift to enable permanent caps lock. private val shiftPermToggleSpeed: Int = DEFAULT_SHIFT_PERM_TOGGLE_SPEED - private lateinit var dbHelper: DatabaseHelper + private lateinit var dbManagers: DatabaseManagers + private lateinit var suggestionHandler: SuggestionHandler + private var dataContract: DataContract? = null var emojiKeywords: HashMap>? = null private lateinit var conjugateOutput: MutableMap>> private lateinit var conjugateLabels: Set private var emojiMaxKeywordLength: Int = 0 - private lateinit var nounKeywords: HashMap> - var pluralWords: List? = null - private lateinit var caseAnnotation: HashMap> + internal lateinit var nounKeywords: HashMap> + var pluralWords: Set? = null + internal lateinit var caseAnnotation: HashMap> var emojiAutoSuggestionEnabled: Boolean = false var lastWord: String? = null var autoSuggestEmojis: MutableList? = null @@ -129,250 +114,153 @@ abstract class GeneralKeyboardIME( var nounTypeSuggestion: List? = null var checkIfPluralWord: Boolean = false private var currentEnterKeyType: Int? = null - private val commandCursor = "│" - private val prepAnnotationConversionDict = - mapOf( - "German" to mapOf("Acc" to "Akk"), - "Russian" to - mapOf( - "Acc" to "Вин", - "Dat" to "Дат", - "Gen" to "Род", - "Loc" to "Мес", - "Pre" to "Пре", - "Ins" to "Инс", - ), - ) - - private val nounAnnotationConversionDict = - mapOf( - "Swedish" to mapOf("C" to "U"), - "Russian" to - mapOf( - "F" to "Ж", - "M" to "М", - "N" to "Н", - "PL" to "МН", - ), - ) - - private val translatePlaceholder = - mapOf( - "EN" to ENInterfaceVariables.TRANSLATE_KEY_LBL, - "ES" to ESInterfaceVariables.TRANSLATE_KEY_LBL, - "DE" to DEInterfaceVariables.TRANSLATE_KEY_LBL, - "IT" to ITInterfaceVariables.TRANSLATE_KEY_LBL, - "FR" to FRInterfaceVariables.TRANSLATE_KEY_LBL, - "PT" to PTInterfaceVariables.TRANSLATE_KEY_LBL, - "RU" to RUInterfaceVariables.TRANSLATE_KEY_LBL, - "SV" to SVInterfaceVariables.TRANSLATE_KEY_LBL, - ) - - private val conjugatePlaceholder = - mapOf( - "EN" to ENInterfaceVariables.CONJUGATE_KEY_LBL, - "ES" to ESInterfaceVariables.CONJUGATE_KEY_LBL, - "DE" to DEInterfaceVariables.CONJUGATE_KEY_LBL, - "IT" to ITInterfaceVariables.CONJUGATE_KEY_LBL, - "FR" to FRInterfaceVariables.CONJUGATE_KEY_LBL, - "PT" to PTInterfaceVariables.CONJUGATE_KEY_LBL, - "RU" to RUInterfaceVariables.CONJUGATE_KEY_LBL, - "SV" to SVInterfaceVariables.CONJUGATE_KEY_LBL, - ) - - private val pluralPlaceholder = - mapOf( - "EN" to ENInterfaceVariables.PLURAL_KEY_LBL, - "ES" to ESInterfaceVariables.PLURAL_KEY_LBL, - "DE" to DEInterfaceVariables.PLURAL_KEY_LBL, - "IT" to ITInterfaceVariables.PLURAL_KEY_LBL, - "FR" to FRInterfaceVariables.PLURAL_KEY_LBL, - "PT" to PTInterfaceVariables.PLURAL_KEY_LBL, - "RU" to RUInterfaceVariables.PLURAL_KEY_LBL, - "SV" to SVInterfaceVariables.PLURAL_KEY_LBL, - ) internal var currentState: ScribeState = ScribeState.IDLE - internal lateinit var keyboardBinding: KeyboardViewKeyboardBinding private var earlierValue: Int? = keyboardView?.setEnterKeyIcon(ScribeState.IDLE) - enum class ScribeState { - IDLE, - SELECT_COMMAND, - TRANSLATE, - CONJUGATE, - PLURAL, - SELECT_VERB_CONJUNCTION, - SELECT_CASE_DECLENSION, - ALREADY_PLURAL, - INVALID, - DISPLAY_INFORMATION, - } + enum class ScribeState { IDLE, SELECT_COMMAND, TRANSLATE, CONJUGATE, PLURAL, SELECT_VERB_CONJUNCTION, INVALID } + /** + * 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 returnSubsequentAreaKey(): Int = subsequentAreaKey - - internal fun returnSubsequentAreaItems(): Int = subsequentAreaItems - internal fun returnSubsequentData(): List> = subsequentData /** - * Called by the system when the service is first created. This is where you should - * initialize your service. The service will only be created once, and this method - * will only be called once. + * Called when the service is first created. Initializes database and suggestion handlers. */ override fun onCreate() { super.onCreate() - Log.i("NEW-TAG", "I am being called from onCreate()") - keyboardBinding = KeyboardViewKeyboardBinding.inflate(layoutInflater) - keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) - onCreateInputView() - updateCommandBarHintAndPrompt() - saveConjugateModeType("none") - updateUI() - val sharedPref = applicationContext.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) - sharedPref.edit { - putString("conjugate_mode_type", "none") - } - setupCommandBarTheme(binding) + dbManagers = DatabaseManagers(this) + suggestionHandler = SuggestionHandler(this) } /** - * Called when the input view is being finished. - * - * @param finishingInput Boolean indicating whether the input is finishing. + * Creates the main view for the input method, inflating it from XML and setting up the keyboard. + * @return The root View of the input method. */ - override fun onFinishInputView(finishingInput: Boolean) { - Log.i("NEW-TAG", "I am being called from onFinishInput()") - super.onFinishInputView(finishingInput) + override fun onCreateInputView(): View { + binding = InputMethodViewBinding.inflate(layoutInflater) + val inputView = binding.root + keyboardView = binding.keyboardView + keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) + keyboardView!!.setKeyboard(keyboard!!) + keyboardView!!.mOnKeyboardActionListener = this + initializeUiElements() + setupClickListeners() currentState = ScribeState.IDLE - updateCommandBarHintAndPrompt() saveConjugateModeType("none") updateUI() - switchToCommandToolBar() - updateUI() - moveToIdleState() + return inputView } /** - * Called by the input method framework when the input method is first created. - * This method is used to perform any one-time initialization tasks. - * Override this method to set up any resources or configurations needed by the input method. + * Finds and initializes UI elements from the inflated view binding. */ - override fun onInitializeInterface() { - Log.i("NEW-TAG", "I am being called from onInitializeInterface()") - super.onInitializeInterface() - updateCommandBarHintAndPrompt() - - saveConjugateModeType("none") - updateUI() - keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) + private fun initializeUiElements() { + pluralBtn = binding.pluralBtn + emojiBtnPhone1 = binding.emojiBtnPhone1 + emojiSpacePhone = binding.emojiSpacePhone + emojiBtnPhone2 = binding.emojiBtnPhone2 + emojiBtnTablet1 = binding.emojiBtnTablet1 + emojiSpaceTablet1 = binding.emojiSpaceTablet1 + emojiBtnTablet2 = binding.emojiBtnTablet2 + emojiSpaceTablet2 = binding.emojiSpaceTablet2 + emojiBtnTablet3 = binding.emojiBtnTablet3 + genderSuggestionLeft = binding.translateBtnLeft + genderSuggestionRight = binding.translateBtnRight } /** - * Checks if there is any text before the cursor in the input field. - * - * @return `true` if there is text before the cursor, `false` otherwise. + * Sets up the OnClickListeners for the main interactive elements of the Scribe key and toolbar. */ - override fun hasTextBeforeCursor(): Boolean { - val inputConnection = currentInputConnection ?: return false - val textBeforeCursor = inputConnection.getTextBeforeCursor(Int.MAX_VALUE, 0)?.trim() ?: "" - return textBeforeCursor.isNotEmpty() && textBeforeCursor.lastOrNull() != '.' + private fun setupClickListeners() { + binding.scribeKeyOptions.setOnClickListener { + if (currentState == ScribeState.IDLE || currentState == ScribeState.SELECT_COMMAND) { + if (currentState == ScribeState.IDLE) { + moveToSelectCommandState() + } else { + moveToIdleState() + } + } + } + + setCommandButtonListeners() + binding.scribeKeyToolbar.setOnClickListener { moveToIdleState() } } /** - * Called by the framework when the input view is being created. - * This is where you can create and return the view hierarchy that will be used - * as the input view for the IME. - * - * @return The view to be used as the input view for the IME. + * Attaches OnClickListeners to the command buttons (Translate, Conjugate, Plural). */ - override fun onCreateInputView(): View { - Log.i("NEW-TAG", "I am being called from onCreateInputView()") - binding = KeyboardViewCommandOptionsBinding.inflate(layoutInflater) - val keyboardHolder = binding.root - keyboardView = binding.keyboardView - keyboardView!!.setKeyboard(keyboard!!) - currentState = ScribeState.IDLE - updateCommandBarHintAndPrompt() - saveConjugateModeType("none") - updateUI() - keyboardView!!.invalidateAllKeys() - keyboardView!!.setKeyboardHolder() - keyboardView!!.mOnKeyboardActionListener = this - - return keyboardHolder + private fun setCommandButtonListeners() { + binding.translateBtn.setOnClickListener { + currentState = ScribeState.TRANSLATE + saveConjugateModeType("none") + updateUI() + } + binding.conjugateBtn.setOnClickListener { + currentState = ScribeState.CONJUGATE + updateUI() + } + binding.pluralBtn.setOnClickListener { + currentState = ScribeState.PLURAL + saveConjugateModeType("none") + if (language == "German") keyboard?.mShiftState = SHIFT_ON_ONE_CHAR + updateUI() + } } /** - * Called when a key is pressed. - * - * @param primaryCode The unicode of the key being pressed. - *If the touch is not on a valid key, the value will be zero. + * Called when the input view is finished. Resets the keyboard state to IDLE. + * @param finishingInput `true` if we are finishing for good, + * `false` if just switching to another app. */ - override fun onPress(primaryCode: Int) { - if (primaryCode != 0) { - keyboardView?.vibrateIfNeeded() - } + override fun onFinishInputView(finishingInput: Boolean) { + super.onFinishInputView(finishingInput) + moveToIdleState() } /** - * Called when the input method is starting input in a new editor. - * This is where you can set up any state you need. - * - * @param attribute Information about the type of text being edited. - * @param restarting If true, this is restarting input on the same text field. + * Called by the system when the service is first initialized, before the input view is created. + * Initializes the base keyboard and updates the UI if the view is already bound. */ - override fun onStartInput( - attribute: EditorInfo?, - restarting: Boolean, - ) { - super.onStartInput(attribute, restarting) - Log.i("NEW-TAG", "I am being called from onStartInput()") - inputTypeClass = attribute!!.inputType and TYPE_MASK_CLASS - enterKeyType = attribute.imeOptions and (IME_MASK_ACTION or IME_FLAG_NO_ENTER_ACTION) - currentEnterKeyType = enterKeyType - val inputConnection = currentInputConnection - hasTextBeforeCursor = inputConnection?.getTextBeforeCursor(1, 0)?.isNotEmpty() == true - - val keyboardXml = - when (inputTypeClass) { - TYPE_CLASS_NUMBER, TYPE_CLASS_DATETIME, TYPE_CLASS_PHONE -> { - keyboardMode = keyboardSymbols - R.xml.keys_symbols - } + override fun onInitializeInterface() { + super.onInitializeInterface() + keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) + if (this::binding.isInitialized) updateUI() + } - else -> { - keyboardMode = keyboardLetters - getKeyboardLayoutXML() - } - } + /** + * Overrides the default implementation to check if there is any + * non-whitespace text before the cursor. + * @return `true` if there is meaningful text before the cursor, `false` otherwise. + */ + override fun hasTextBeforeCursor(): Boolean { + val ic = currentInputConnection ?: return false + val text = ic.getTextBeforeCursor(Int.MAX_VALUE, 0)?.trim() ?: "" + return text.isNotEmpty() && text.lastOrNull() != '.' + } - val languageAlias = getLanguageAlias(language) - dbHelper = DatabaseHelper(this) - dbHelper.loadDatabase(languageAlias) - emojiKeywords = dbHelper.getEmojiKeywords(languageAlias) - emojiMaxKeywordLength = dbHelper.getEmojiMaxKeywordLength() - pluralWords = dbHelper.checkIfWordIsPlural(languageAlias)!! - nounKeywords = dbHelper.findGenderOfWord(languageAlias) - caseAnnotation = dbHelper.findCaseAnnnotationForPreposition(languageAlias) - conjugateOutput = dbHelper.getConjugateData(languageAlias, "describe") - conjugateLabels = dbHelper.getConjugateLabels(languageAlias, "describe") - keyboard = KeyboardBase(this, keyboardXml, enterKeyType) - keyboardView?.setKeyboard(keyboard!!) + /** + * Called when a key is pressed down. Triggers haptic feedback if enabled. + * @param primaryCode The integer code of the key that was pressed. + */ + override fun onPress(primaryCode: Int) { + if (primaryCode != 0) keyboardView?.vibrateIfNeeded() } /** - * This method is called when a key is released. - * It handles the actions to be performed on key release. + * Called when a key is released. Handles the logic + * to switch back to the letter keyboard + * after typing a character from the symbol keyboard. */ override fun onActionUp() { - Log.i("NEW-TAG", "I am being called from onActionUp()") if (switchToLetters) { keyboardMode = keyboardLetters keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) - val editorInfo = currentInputEditorInfo if ( editorInfo != null && @@ -383,331 +271,410 @@ abstract class GeneralKeyboardIME( keyboard?.setShifted(SHIFT_ON_ONE_CHAR) } } - keyboardView!!.setKeyboard(keyboard!!) - switchToLetters = false } } - /** - * Moves the cursor one position to the left. - * This method is typically used to handle user input for cursor navigation. - */ override fun moveCursorLeft() { moveCursor(false) } - /** - * Moves the cursor one position to the right. - * This method is typically used to handle user input for cursor navigation. - */ override fun moveCursorRight() { moveCursor(true) } - /** - * Handles the input text when the user types on the keyboard. - * - * @param text The text input by the user. - */ override fun onText(text: String) { currentInputConnection?.commitText(text, 0) } /** - * Called when the input view is starting. This is where you can set up the input view - * to be shown to the user, such as configuring the keyboard layout or initializing - * any necessary resources. - * - * @param attribute The attributes of the input method editor (IME) that is starting. - * @param restarting If true, this is a restart of the input view, not the initial start. + * Called when the IME is starting to interact with a new input field. + * It initializes the keyboard based on the input type and loads all language-specific data. + * @param attribute The editor information for the new input field. + * @param restarting `true` if we are restarting the input with the same editor. + */ + override fun onStartInput( + attribute: EditorInfo?, + restarting: Boolean, + ) { + super.onStartInput(attribute, restarting) + inputTypeClass = attribute!!.inputType and TYPE_MASK_CLASS + enterKeyType = attribute.imeOptions and (IME_MASK_ACTION or IME_FLAG_NO_ENTER_ACTION) + currentEnterKeyType = enterKeyType + hasTextBeforeCursor = currentInputConnection?.getTextBeforeCursor(1, 0)?.isNotEmpty() == true + val keyboardXml = + when (inputTypeClass) { + TYPE_CLASS_NUMBER, TYPE_CLASS_DATETIME, TYPE_CLASS_PHONE -> { + keyboardMode = keyboardSymbols + R.xml.keys_symbols + } + else -> { + keyboardMode = keyboardLetters + getKeyboardLayoutXML() + } + } + val languageAlias = getLanguageAlias(language) + dataContract = dbManagers.getLanguageContract(languageAlias) + emojiKeywords = dbManagers.emojiManager.getEmojiKeywords(languageAlias) + emojiMaxKeywordLength = dbManagers.emojiManager.maxKeywordLength + pluralWords = dbManagers.pluralManager.getAllPluralForms(languageAlias, dataContract)?.toSet() + nounKeywords = dbManagers.genderManager.findGenderOfWord(languageAlias, dataContract) + caseAnnotation = dbManagers.prepositionManager.getCaseAnnotations(languageAlias) + conjugateOutput = dbManagers.conjugateDataManager.getTheConjugateLabels(languageAlias, dataContract, "describe") + conjugateLabels = dbManagers.conjugateDataManager.extractConjugateHeadings(dataContract, "describe") + keyboard = KeyboardBase(this, keyboardXml, enterKeyType) + keyboardView?.setKeyboard(keyboard!!) + } + + /** + * Called when the input view is starting. It sets up the UI theme, emoji settings, + * and initial keyboard state. + * @param editorInfo The editor information for the input field. + * @param restarting `true` if we are restarting the input with the same editor. */ override fun onStartInputView( editorInfo: EditorInfo?, restarting: Boolean, ) { + super.onStartInputView(editorInfo, restarting) val isUserDarkMode = getIsDarkModeOrNot(applicationContext) updateEnterKeyColor(isUserDarkMode) - initializeEmojiButtons() emojiAutoSuggestionEnabled = getIsEmojiSuggestionsEnabled(applicationContext, language) - updateButtonVisibility(emojiAutoSuggestionEnabled) - setupIdleView() - super.onStartInputView(editorInfo, restarting) - val textBefore = - currentInputConnection - ?.getTextBeforeCursor(1, 0) - ?.toString() - .orEmpty() - if (textBefore.isEmpty()) { - keyboard?.setShifted(SHIFT_ON_ONE_CHAR) - } - setupCommandBarTheme(binding) - } - /** - * Sets up the theme for the toolbar in the keyboard view. - * - * @param binding The binding object for the keyboard view layout. - */ - private fun setupToolBarTheme(binding: KeyboardViewKeyboardBinding) { - val isUserDarkMode = getIsDarkModeOrNot(applicationContext) - when (isUserDarkMode) { - true -> { - binding.commandField.setBackgroundColor("#1E1E1E".toColorInt()) - } - else -> { - binding.commandField.setBackgroundColor("#d2d4da".toColorInt()) - } - } + autoSuggestEmojis = null + suggestionHandler.clearAllSuggestionsAndHideButtonUI() + + moveToIdleState() + val textBefore = currentInputConnection?.getTextBeforeCursor(1, 0)?.toString().orEmpty() + if (textBefore.isEmpty()) keyboard?.setShifted(SHIFT_ON_ONE_CHAR) } /** - * Commits a period after a space character. - * This method is typically used to automatically insert a period - * when the user types a space, enhancing typing efficiency. + * Handles the "period on double tap" feature. If enabled, it replaces the two spaces with a period and a space. */ override fun commitPeriodAfterSpace() { if (currentState == ScribeState.IDLE || currentState == ScribeState.SELECT_COMMAND) { - if (getSharedPreferences("app_preferences", MODE_PRIVATE) - .getBoolean( - PreferencesHelper.getLanguageSpecificPreferenceKey(PERIOD_ON_DOUBLE_TAP, language), - true, - ) + val prefs = getSharedPreferences("app_preferences", MODE_PRIVATE) + if ( + prefs.getBoolean( + PreferencesHelper.getLanguageSpecificPreferenceKey( + PERIOD_ON_DOUBLE_TAP, + language, + ), + true, + ) ) { - val inputConnection = currentInputConnection ?: return - inputConnection.deleteSurroundingText(1, 0) - inputConnection.commitText(". ", 1) + currentInputConnection?.apply { + deleteSurroundingText(1, 0) + commitText(". ", 1) + } } else { - val inputConnection = currentInputConnection ?: return - inputConnection.deleteSurroundingText(1, 0) - inputConnection.commitText(" ", 1) + currentInputConnection?.apply { + deleteSurroundingText(1, 0) + commitText(" ", 1) + } } } } /** - * Updates the color of the enter key based on the current theme mode. - * - * @param isDarkMode Optional parameter to specify if dark mode is enabled. - * If null, the current system theme will be used to determine the color. + * Updates the color of the Enter key based on the current Scribe state and theme (dark/light mode). + * @param isDarkMode The current dark mode status. If null, it will be determined from context. */ - private fun updateEnterKeyColor(isDarkMode: Boolean? = null) { + private fun updateEnterKeyColor(isDarkMode: Boolean?) { + val resolvedIsDarkMode = isDarkMode ?: getIsDarkModeOrNot(applicationContext) when (currentState) { - ScribeState.IDLE -> { + ScribeState.IDLE, ScribeState.SELECT_COMMAND -> { keyboardView?.setEnterKeyIcon(ScribeState.IDLE, earlierValue) - keyboardView?.setEnterKeyColor(null, isDarkMode = isDarkMode) - } - ScribeState.SELECT_COMMAND -> { - keyboardView?.setEnterKeyColor(null, isDarkMode = isDarkMode) - earlierValue = keyboardView?.setEnterKeyIcon(ScribeState.SELECT_COMMAND) + keyboardView?.setEnterKeyColor(null, isDarkMode = resolvedIsDarkMode) } else -> { keyboardView?.setEnterKeyColor(getColor(R.color.color_primary)) keyboardView?.setEnterKeyIcon(ScribeState.PLURAL, earlierValue) } } - if (isDarkMode == true) { - val color = ContextCompat.getColorStateList(this, R.color.light_key_color) - binding.scribeKey.foregroundTintList = color - } else { - val colorLight = ContextCompat.getColorStateList(this, R.color.light_key_text_color) - binding.scribeKey.foregroundTintList = colorLight - } + val scribeKeyTint = if (resolvedIsDarkMode) R.color.light_key_color else R.color.light_key_text_color + binding.scribeKeyOptions.foregroundTintList = ContextCompat.getColorStateList(this, scribeKeyTint) + binding.scribeKeyToolbar.foregroundTintList = ContextCompat.getColorStateList(this, scribeKeyTint) } /** - * Updates the command bar hint and prompt based on the current context or state. - * This function is responsible for modifying the UI elements of the command bar - * to provide appropriate hints and prompts to the user. + * Updates the hint and prompt text displayed in the command bar area based on the current state. + * @param isUserDarkMode The current dark mode status. If null, it will be determined from context. + * @param text A specific text to be displayed in the prompt, often used for conjugation titles. + * @param word A word to be included in the hint text. */ private fun updateCommandBarHintAndPrompt( isUserDarkMode: Boolean? = null, text: String? = null, word: String? = null, ) { - val commandBarEditText = keyboardBinding.commandBar + val resolvedIsDarkMode = isUserDarkMode ?: getIsDarkModeOrNot(applicationContext) + val commandBarEditText = binding.commandBar val hintMessage = HintUtils.getCommandBarHint(currentState, language, word) val promptText = HintUtils.getPromptText(currentState, language, context = this, text) - val promptTextView = keyboardBinding.promptText + val promptTextView = binding.promptText promptTextView.text = promptText commandBarEditText.hint = hintMessage - commandBarEditText.requestFocus() - - if (isUserDarkMode == true) { + if (resolvedIsDarkMode) { commandBarEditText.setHintTextColor(getColor(R.color.hint_white)) commandBarEditText.setTextColor(getColor(white)) - keyboardBinding.commandBarLayout.backgroundTintList = - ContextCompat.getColorStateList(this, R.color.command_bar_color_dark) + binding.commandBarLayout.backgroundTintList = + ContextCompat.getColorStateList( + this, + R.color.command_bar_color_dark, + ) promptTextView.setTextColor(getColor(white)) - promptTextView.setBackgroundColor(getColor(R.color.command_bar_color_dark)) - keyboardBinding.promptTextBorder.setBackgroundColor(getColor(R.color.command_bar_color_dark)) + binding.promptTextBorder.setBackgroundColor(getColor(R.color.command_bar_color_dark)) } else { commandBarEditText.setHintTextColor(getColor(R.color.hint_black)) commandBarEditText.setTextColor(Color.BLACK) - keyboardBinding.commandBarLayout.backgroundTintList = ContextCompat.getColorStateList(this, white) + binding.commandBarLayout.backgroundTintList = ContextCompat.getColorStateList(this, white) promptTextView.setTextColor(Color.BLACK) promptTextView.setBackgroundColor(getColor(white)) - keyboardBinding.promptTextBorder.setBackgroundColor(getColor(white)) + binding.promptTextBorder.setBackgroundColor(getColor(white)) } - Log.d( - "KeyboardUpdate", - "CommandBar Hint Updated: [State: $currentState, Language: $language, Hint: $hintMessage]", - ) } /** - * Switches the current input method to the command toolbar. - * This function is protected and can be accessed within the same class or subclasses. + * The main dispatcher for updating the entire keyboard UI. It calls the appropriate setup function + * based on the current [ScribeState]. */ - internal fun switchToCommandToolBar() { - val binding = KeyboardViewCommandOptionsBinding.inflate(layoutInflater) - this.binding = binding - val keyboardHolder = binding.root - setupCommandBarTheme(binding) - keyboardView = binding.keyboardView - keyboardView!!.setKeyboard(keyboard!!) - keyboardView!!.mOnKeyboardActionListener = this - keyboardBinding.scribeKey.setOnClickListener { - currentState = ScribeState.IDLE - setupSelectCommandView() - updateUI() + internal fun updateUI() { + if (!this::binding.isInitialized) return + val isUserDarkMode = getIsDarkModeOrNot(applicationContext) + when (currentState) { + ScribeState.IDLE -> setupIdleView() + ScribeState.SELECT_COMMAND -> setupSelectCommandView() + ScribeState.INVALID -> setupInvalidView() + else -> setupToolbarView() } - setInputView(keyboardHolder) + updateEnterKeyColor(isUserDarkMode) } /** - * Updates the user interface of the keyboard. - * This function is responsible for refreshing or modifying the UI elements - * of the keyboard based on the current state or input. + * Configures the UI for the `IDLE` state, showing default suggestions or emoji suggestions. */ - internal fun updateUI() { + private fun setupIdleView() { + binding.commandOptionsBar.visibility = View.VISIBLE + binding.toolbarBar.visibility = View.GONE + val isUserDarkMode = getIsDarkModeOrNot(applicationContext) - when (currentState) { - ScribeState.IDLE -> { - setupIdleView() - handleTextSizeForSuggestion(binding) - initializeEmojiButtons() - saveConjugateModeType("none") - keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) - keyboardView!!.setKeyboard(keyboard!!) - updateButtonVisibility(emojiAutoSuggestionEnabled) - updateButtonText(emojiAutoSuggestionEnabled, autoSuggestEmojis) - } - ScribeState.SELECT_COMMAND -> { - binding.translateBtn.textSize = SUGGESTION_SIZE - setupSelectCommandView() - } - ScribeState.SELECT_VERB_CONJUNCTION -> { - switchToToolBar() - } + binding.commandOptionsBar.setBackgroundColor( + ContextCompat.getColor( + this, + if (isUserDarkMode) { + R.color.dark_keyboard_bg_color + } else { + R.color.light_keyboard_bg_color + }, + ), + ) - ScribeState.INVALID -> { - setupInvalidView(isUserDarkMode) - } - else -> switchToToolBar() + val textColor = if (isUserDarkMode) Color.WHITE else "#1E1E1E".toColorInt() + + listOf(binding.translateBtn, binding.conjugateBtn, binding.pluralBtn).forEach { button -> + button.visibility = View.VISIBLE + button.background = null + button.setTextColor(textColor) + button.text = getString(R.string.suggestion) + button.textSize = SUGGESTION_SIZE + button.setOnClickListener(null) } - updateEnterKeyColor(isUserDarkMode) - } - private fun setupInvalidView(isUserDarkMode: Boolean) { - keyboardBinding.scribeKey.foreground = AppCompatResources.getDrawable(this, R.drawable.ic_scribe_icon_vector) - keyboardBinding.scribeKey.setOnClickListener { - switchToCommandToolBar() - moveToSelectCommandState(isUserDarkMode) + listOf(binding.separator2, binding.separator3).forEach { separator -> + separator.setBackgroundColor(ContextCompat.getColor(this, R.color.special_key_light)) + val params = separator.layoutParams + // Convert 0.5dp to pixels. coerceAtLeast(1) ensures it's never zero. + params.width = (SEPARATOR_WIDTH * resources.displayMetrics.density).toInt().coerceAtLeast(1) + separator.layoutParams = params + + separator.visibility = View.VISIBLE } - keyboardBinding.ivInfo?.visibility = View.VISIBLE - setDefaultKeyboardLanguage() - val promptText = HintUtils.getInvalidHint(language = language) - val commandBarButton = keyboardBinding.commandBar - val promptTextView = keyboardBinding.promptText - promptTextView.text = promptText - commandBarButton.hint = "" - Log.i(TAG, "INVALID STATE ${commandBarButton.text}") - } - private fun setDefaultKeyboardLanguage() { - val keyboardXmlId = getKeyboardLayoutXML() - keyboard = KeyboardBase(this, keyboardXmlId, enterKeyType) - keyboardView = - keyboardBinding.keyboardView.apply { - setKeyboard(keyboard!!) - mOnKeyboardActionListener = this@GeneralKeyboardIME - } - } + binding.separator1.visibility = View.GONE + + binding.scribeKeyOptions.foreground = AppCompatResources.getDrawable(this, R.drawable.ic_scribe_icon_vector) + initializeKeyboard(getKeyboardLayoutXML()) + updateButtonVisibility(emojiAutoSuggestionEnabled) + updateEmojiSuggestion(emojiAutoSuggestionEnabled, autoSuggestEmojis) - private fun moveToSelectCommandState(isUserDarkMode: Boolean) { - currentState = ScribeState.SELECT_COMMAND disableAutoSuggest() - updateButtonVisibility(false) - Log.i(TAG, "SELECT COMMAND STATE") - binding.scribeKey.foreground = AppCompatResources.getDrawable(this, R.drawable.close) - updateUI() - val sharedPref = applicationContext.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) - sharedPref.edit { - putString("conjugate_mode_type", "none") + } + + /** + * Configures the UI for the `SELECT_COMMAND` state, showing the main command buttons + * (Translate, Conjugate, Plural). + */ + private fun setupSelectCommandView() { + binding.commandOptionsBar.visibility = View.VISIBLE + binding.toolbarBar.visibility = View.GONE + + val isUserDarkMode = getIsDarkModeOrNot(applicationContext) + + binding.commandOptionsBar.setBackgroundColor( + ContextCompat.getColor( + this, + if (isUserDarkMode) { + R.color.dark_keyboard_bg_color + } else { + R.color.light_keyboard_bg_color + }, + ), + ) + + val langAlias = getLanguageAlias(language) + + updateButtonVisibility(isAutoSuggestEnabled = false) + setCommandButtonListeners() + + val buttonTextColor = if (isUserDarkMode) Color.WHITE else Color.BLACK + + listOf(binding.translateBtn, binding.conjugateBtn, binding.pluralBtn).forEach { button -> + button.visibility = View.VISIBLE + button.background = ContextCompat.getDrawable(this, R.drawable.button_background_rounded) + button.backgroundTintList = ContextCompat.getColorStateList(this, R.color.theme_scribe_blue) + button.setTextColor(buttonTextColor) + button.textSize = SUGGESTION_SIZE + } + + binding.translateBtn.text = translatePlaceholder[langAlias] ?: "Translate" + binding.conjugateBtn.text = conjugatePlaceholder[langAlias] ?: "Conjugate" + binding.pluralBtn.text = pluralPlaceholder[langAlias] ?: "Plural" + + val separatorColor = (if (isUserDarkMode) DARK_THEME else LIGHT_THEME).toColorInt() + binding.separator2.setBackgroundColor(separatorColor) + binding.separator3.setBackgroundColor(separatorColor) + + val spaceInDp = COMMAND_BUTTON_SPACING_DP + val spaceInPx = (spaceInDp * resources.displayMetrics.density).toInt() + listOf(binding.separator2, binding.separator3).forEach { separator -> + separator.setBackgroundColor(Color.TRANSPARENT) + val params = separator.layoutParams + params.width = spaceInPx + separator.layoutParams = params } - binding.translateBtn.setTextColor(if (isUserDarkMode) Color.WHITE else Color.BLACK) + + binding.separator1.visibility = View.GONE + binding.separator2.visibility = View.VISIBLE + binding.separator3.visibility = View.VISIBLE + binding.separator4.visibility = View.GONE + binding.separator5.visibility = View.GONE + binding.separator6.visibility = View.GONE + + binding.scribeKeyOptions.foreground = AppCompatResources.getDrawable(this, R.drawable.close) } /** - * Switches the input method to the toolbar. - * This function is responsible for changing the current input method - * to have toolbar interface, allowing the user to interact with the toolbar. + * Configures the UI for command modes (`TRANSLATE`, `CONJUGATE`, etc.), showing the command bar and toolbar. */ - internal fun switchToToolBar( - isSubsequentArea: Boolean = false, - dataSize: Int = 0, - ) { - keyboardBinding = initializeKeyboardBinding() - val keyboardHolder = keyboardBinding.root + private fun setupToolbarView() { + binding.commandOptionsBar.visibility = View.GONE + binding.toolbarBar.visibility = View.VISIBLE + val isDarkMode = getIsDarkModeOrNot(applicationContext) + binding.toolbarBar.setBackgroundColor( + ContextCompat.getColor( + this, + if (isDarkMode) { + R.color.dark_keyboard_bg_color + } else { + R.color.light_keyboard_bg_color + }, + ), + ) - applyToolBarVisualSettings() - handleModeChange(keyboardSymbols, keyboardView, this) + binding.scribeKeyToolbar.foreground = + AppCompatResources.getDrawable( + this, + R.drawable.close, + ) - val keyboardXmlId = getKeyboardLayoutForState(currentState, isSubsequentArea, dataSize) - initializeKeyboard(keyboardXmlId) + var hintWord: String? = null + var promptText: String? = null - setupScribeKeyListener() - val conjugateIndex = getValidatedConjugateIndex() + if (currentState == ScribeState.SELECT_VERB_CONJUNCTION) { + val keyboardXmlId = getKeyboardLayoutForState(currentState) + initializeKeyboard(keyboardXmlId) - setupConjugateKeysByLanguage(conjugateIndex) + val conjugateIndex = getValidatedConjugateIndex() + setupConjugateKeysByLanguage(conjugateIndex) + promptText = conjugateOutput.keys.elementAtOrNull(conjugateIndex) + hintWord = conjugateLabels.lastOrNull() + } - setInputView(keyboardHolder) + updateCommandBarHintAndPrompt(text = promptText, isUserDarkMode = isDarkMode, word = hintWord) } - private fun applyToolBarVisualSettings() { - setupToolBarTheme(keyboardBinding) - handleTextSizeForSuggestion(binding) - binding.translateBtn.textSize = SUGGESTION_SIZE + /** + * Configures the UI for the `INVALID` state, which is shown when a command (e.g., translation) fails. + */ + private fun setupInvalidView() { + binding.commandOptionsBar.visibility = View.GONE + binding.toolbarBar.visibility = View.VISIBLE val isDarkMode = getIsDarkModeOrNot(applicationContext) - updateToolBarTheme(isDarkMode) + binding.toolbarBar.setBackgroundColor(if (isDarkMode) "#1E1E1E".toColorInt() else "#d2d4da".toColorInt()) + binding.ivInfo.visibility = View.VISIBLE + binding.promptText.text = HintUtils.getInvalidHint(language = language) + binding.commandBar.hint = "" + binding.scribeKeyToolbar.foreground = AppCompatResources.getDrawable(this, R.drawable.ic_scribe_icon_vector) + binding.scribeKeyToolbar.setOnClickListener { moveToSelectCommandState() } } + /** + * Clears all cached suggestion data. + */ + private fun clearSuggestionData() { + autoSuggestEmojis = null + nounTypeSuggestion = null + caseAnnotationSuggestion = null + isSingularAndPlural = false + } + + /** + * Transitions the keyboard to the `SELECT_COMMAND` state and updates the UI. + */ + private fun moveToSelectCommandState() { + clearSuggestionData() + currentState = ScribeState.SELECT_COMMAND + saveConjugateModeType("none") + updateUI() + } + + /** + * Transitions the keyboard to the `IDLE` state and updates the UI. + */ + internal fun moveToIdleState() { + clearSuggestionData() + currentState = ScribeState.IDLE + saveConjugateModeType("none") + if (this::binding.isInitialized) updateUI() + } + + /** + * Determines which keyboard layout XML to use based on the current [ScribeState]. + * @param state The current state of the Scribe keyboard. + * @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. + */ private fun getKeyboardLayoutForState( state: ScribeState, isSubsequentArea: Boolean = false, dataSize: Int = 0, ): Int = when (state) { - ScribeState.TRANSLATE -> { - val language = getPreferredTranslationLanguage(this, language) - baseKeyboardOfAnyLanguage(language) - } ScribeState.SELECT_VERB_CONJUNCTION -> { saveConjugateModeType(language) if (!isSubsequentArea && dataSize == 0) { when (language) { - "English" -> R.xml.conjugate_view_2x2 - "Swedish" -> R.xml.conjugate_view_2x2 - "Russian" -> R.xml.conjugate_view_2x2 + "English", "Swedish", "Russian" -> R.xml.conjugate_view_2x2 else -> R.xml.conjugate_view_3x2 } } else { - Log.i("CONJUGATE-ISSUE", "The data size is $dataSize") when (dataSize) { DATA_SIZE_2 -> R.xml.conjugate_view_2x1 DATA_CONSTANT_3 -> R.xml.conjugate_view_1x3 @@ -715,137 +682,156 @@ abstract class GeneralKeyboardIME( } } } - else -> getKeyboardLayoutXML() + else -> { + getKeyboardLayoutXML() + } } + /** + * 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) { keyboard = KeyboardBase(this, xmlId, enterKeyType) - keyboardView = - keyboardBinding.keyboardView.apply { - setKeyboard(keyboard!!) - mOnKeyboardActionListener = this@GeneralKeyboardIME - } - } - - private fun setupScribeKeyListener() { - keyboardBinding.scribeKey.setOnClickListener { - currentState = ScribeState.IDLE - switchToCommandToolBar() - handleTextSizeForSuggestion(binding) - updateUI() - } + keyboardView?.setKeyboard(keyboard!!) + keyboardView?.requestLayout() } + /** + * Retrieves and validates the stored index for the current conjugation view. + * Ensures the index is within the bounds of available conjugation types. + * @return A valid, zero-based index for the conjugation type. + */ private fun getValidatedConjugateIndex(): Int { val prefs = getSharedPreferences("keyboard_preferences", MODE_PRIVATE) var index = prefs.getInt("conjugate_index", 0) - val maxIndex = conjugateOutput.keys.count() - DATA_SIZE_2 - index = - if (maxIndex >= 0) { - index.coerceIn(0, maxIndex + 1) - } else { - 0 - } + val maxIndex = if (this::conjugateOutput.isInitialized) conjugateOutput.keys.count() - 1 else -1 + index = if (maxIndex >= 0) index.coerceIn(0, maxIndex) else 0 prefs.edit { putInt("conjugate_index", index) } return index } + /** + * 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. + */ internal fun setupConjugateKeysByLanguage( conjugateIndex: Int, isSubsequentArea: Boolean = false, ) { - val isDarkMode = getIsDarkModeOrNot(applicationContext) - setUpConjugateKeys( startIndex = conjugateIndex, - conjugateOutput = conjugateOutput, - isDarkMode = isDarkMode, - isSubsequentArea, + 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. + */ private fun setUpConjugateKeys( startIndex: Int, - conjugateOutput: MutableMap>>, - isDarkMode: Boolean, isSubsequentArea: Boolean, ) { - val keyCodeMap = - mapOf( - "3x2" to - listOf( - KeyboardBase.CODE_FPS, - KeyboardBase.CODE_FPP, - KeyboardBase.CODE_SPS, - KeyboardBase.CODE_SPP, - KeyboardBase.CODE_TPS, - KeyboardBase.CODE_TPP, - ), - "1x1" to listOf(KeyboardBase.CODE_1X1), - "1x3" to - listOf( - KeyboardBase.CODE_1X3_LEFT, - KeyboardBase.CODE_1X3_CENTER, - KeyboardBase.CODE_1X3_RIGHT, - ), - "2x1" to listOf(KeyboardBase.CODE_2X1_TOP, KeyboardBase.CODE_2X1_BOTTOM), - "2x2" to - listOf( - KeyboardBase.CODE_TL, - KeyboardBase.CODE_TR, - KeyboardBase.CODE_BL, - KeyboardBase.CODE_BR, - ), - ) - val title = conjugateOutput.keys.elementAtOrNull(startIndex) ?: return - val languageOutput = conjugateOutput[title] ?: return - val conjugateLabel = conjugateLabels.toList() + 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 + } + if (language != "English") { - keyCodeMap["3x2"]?.forEachIndexed { index, code -> - val value = languageOutput[title]?.elementAtOrNull(index) ?: return@forEachIndexed - keyboardView?.setKeyLabel(value, conjugateLabel[index], code) - } + setUpNonEnglishConjugateKeys(languageOutput, conjugateLabels.toList(), title) } else { - val keys = languageOutput.keys.toList() - val sharedPreferences = this.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) + setUpEnglishConjugateKeys(languageOutput, isSubsequentArea) + } - fun handleOutput( - index: Int, - code: Int, - prefKey: String, - ) { - val output = languageOutput[keys.getOrNull(index)] ?: return + if (isSubsequentArea) { + 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 = + 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) ?: return@forEachIndexed + 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 = this.getSharedPreferences("keyboard_preferences", 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, _) -> 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 { - subsequentAreaRequired = false sharedPreferences.edit { putString("0", prefKey) } } keyboardView?.setKeyLabel(output.firstOrNull().toString(), "HI", code) } - if (!isSubsequentArea) { - keyCodeMap["3x2"]?.forEach { code -> - keyboardView?.setKeyLabel("HI", "HI", code) - } - } - - handleOutput(0, KeyboardBase.CODE_TL, "CODE_TL") - handleOutput(1, KeyboardBase.CODE_TR, "CODE_TR") - handleOutput(DATA_SIZE_2, KeyboardBase.CODE_BL, "CODE_BL") - handleOutput(DATA_CONSTANT_3, KeyboardBase.CODE_BR, "CODE_BR") } - if (isSubsequentArea) { - keyboardView?.setKeyLabel("HI", "HI", KeyboardBase.CODE_FPS) - } - updateCommandBarHintAndPrompt( - text = title, - isUserDarkMode = isDarkMode, - word = conjugateLabels.last(), - ) } + /** + * 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. + */ internal fun setupConjugateSubView( data: List>, word: String?, @@ -853,40 +839,16 @@ abstract class GeneralKeyboardIME( val uniqueData = data.distinct() val filteredData = uniqueData.filter { sublist -> sublist.contains(word) } val flattenList = filteredData.flatten() - - Log.i("CONJUGATE-ISSUE", "The length of the data would be ${uniqueData.size}") - Log.i("CONJUGATE-ISSUE", "the data is $uniqueData") - Log.i("CONJUGATE-ISSUE", "the filtered data is $filteredData") - Log.i("CONJUGATE-ISSUE", "tHE FLATTEN LIST IS $flattenList") - Log.i("CONJUGATE-ISSUE", "the length of the flatten list is ${flattenList.size}") - Log.i("CONJUGATE-ISSUE", "The length of the data would be ${data.size}") - Log.i("CONJUGATE-ISSUE", "The length of the unique data would be ${uniqueData.size}") - Log.i("CONJUGATE-ISSUE", "The length of the filtered data would be ${filteredData.size}") saveConjugateModeType(language = language, true) - val prefs = applicationContext.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) + val prefs = applicationContext.getSharedPreferences("keyboard_preferences", MODE_PRIVATE) prefs.edit(commit = true) { putString("conjugate_mode_type", "2x1") } -// when (flattenList.size) { -// 2 -> { -// val sharedPref = applicationContext.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) -// sharedPref.edit { -// putString("conjugate_mode_type", "2x1") -// } -// } -// 3 -> { -// val sharedPref = applicationContext.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) -// sharedPref.edit { -// putString("conjugate_mode_type", "3x1") -// } -// } -// } - switchToToolBar(true, flattenList.size) - Log.i("CONJUGATE-ISSUE", "SharedPref value = ${prefs.getString("conjugate_mode_type", "3x1")}") + val keyboardXmlId = getKeyboardLayoutForState(currentState, true, flattenList.size) + initializeKeyboard(keyboardXmlId) prefs.edit(commit = true) { putString("conjugate_mode_type", "2x1") } when (flattenList.size) { DATA_SIZE_2 -> { keyboardView?.setKeyLabel(flattenList[0], "HI", KeyboardBase.CODE_2X1_TOP) keyboardView?.setKeyLabel(flattenList[1], "HI", KeyboardBase.CODE_2X1_BOTTOM) - subsequentAreaRequired = false } DATA_CONSTANT_3 -> { @@ -897,334 +859,307 @@ abstract class GeneralKeyboardIME( } } prefs.edit(commit = true) { putString("conjugate_mode_type", "2x1") } - Log.i("CONJUGATE-ISSUE", "SharedPref value = ${prefs.getString("conjugate_mode_type", "3x1")}") - keyboardBinding.ivInfo?.visibility = View.GONE + binding.ivInfo.visibility = View.GONE } /** - * Updates the toolbar theme based on the current system theme (dark or light). - * - * This function adjusts the toolbar's visual elements such as the top divider color - * and the tint of the custom scribe key, depending on whether dark mode is enabled. - * - * @param isDarkMode A boolean indicating if the system is in dark mode. + * 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. */ - private fun updateToolBarTheme(isDarkMode: Boolean) { - val dividerColor = if (isDarkMode) R.color.special_key_dark else R.color.special_key_light - keyboardBinding.topKeyboardDivider.setBackgroundColor(getColor(dividerColor)) - - val tintColor = - if (isDarkMode) { - ContextCompat.getColorStateList(this, R.color.light_key_color) + internal fun saveConjugateModeType( + language: String, + isSubsequentArea: Boolean = false, + ) { + val sharedPref = applicationContext.getSharedPreferences("keyboard_preferences", MODE_PRIVATE) + val mode = + if (!isSubsequentArea) { + when (language) { + "Swedish", "English", "Russian" -> "2x2" + "German", "French", "Italian", "Spanish", "Portuguese" -> "3x2" + else -> "none" + } } else { - ContextCompat.getColorStateList(this, R.color.light_key_text_color) + "none" } - - keyboardBinding.scribeKey.foregroundTintList = tintColor + sharedPref.edit { putString("conjugate_mode_type", mode) } } /** - * Sets up the idle view for the keyboard input method editor (IME). - * This function initializes and configures the view that is displayed - * when the keyboard is in an idle state. + * Updates the visibility of the suggestion buttons based on device type (phone/tablet) + * and whether auto-suggestions are currently active. + * @param isAutoSuggestEnabled `true` if emoji or linguistic suggestions are available. */ - private fun setupIdleView() { - binding.translateBtn.textSize = SUGGESTION_SIZE - val isUserDarkMode = getIsDarkModeOrNot(applicationContext) - - // Set common properties for buttons. - val textColor = if (isUserDarkMode) Color.WHITE else Color.parseColor("#1E1E1E") - val separatorColor = Color.parseColor(if (isUserDarkMode) DARK_THEME else LIGHT_THEME) - - // Apply to all buttons. - listOf(binding.translateBtn, binding.conjugateBtn, binding.pluralBtn).forEach { button -> - button.setBackgroundColor(getColor(R.color.transparent)) - button.setTextColor(textColor) - button.text = getString(R.string.suggestion) - } - - // Apply to all separators. - listOf( - binding.separator2, - binding.separator3, - binding.separator4, - binding.separator5, - binding.separator6, - ).forEach { separator -> - separator.setBackgroundColor(separatorColor) + internal fun updateButtonVisibility(isAutoSuggestEnabled: Boolean) { + if (currentState != ScribeState.IDLE) { + setupDefaultButtonVisibility() + return } - // Set visibility. - binding.separator2.visibility = View.VISIBLE - binding.separator3.visibility = View.VISIBLE - val isTablet = (resources.configuration.screenLayout and Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE - binding.separator4.visibility = if (isTablet) View.GONE else View.VISIBLE - binding.separator5.visibility = if (isTablet) View.VISIBLE else View.GONE - binding.separator6.visibility = if (isTablet) View.VISIBLE else View.GONE - - setupCommandBarTheme(binding) + val emojiCount = if (isAutoSuggestEnabled) autoSuggestEmojis?.size ?: 0 else 0 - binding.scribeKey.setOnClickListener { - moveToSelectCommandState(isUserDarkMode) + if (isTablet) { + updateTabletButtonVisibility(emojiCount) + } else { + updatePhoneButtonVisibility(emojiCount) } } /** - * Sets up the command view for the keyboard input method editor (IME). - * This function initializes and configures the view that is displayed - * when the keyboard is in command state. The command state is the state in - * which the keyboard shows the different command available for the keyboard. + * Sets the default visibility for buttons when not in the `IDLE` state. + * Hides all suggestion-related buttons. */ - private fun setupSelectCommandView() { - binding.translateBtn.background = AppCompatResources.getDrawable(this, R.drawable.button_background_rounded) - binding.conjugateBtn.background = AppCompatResources.getDrawable(this, R.drawable.button_background_rounded) - binding.pluralBtn.background = AppCompatResources.getDrawable(this, R.drawable.button_background_rounded) - getLanguageAlias(language) - binding.translateBtn.text = translatePlaceholder[getLanguageAlias(language)] ?: "Translate" - binding.conjugateBtn.text = conjugatePlaceholder[getLanguageAlias(language)] ?: "Conjugate" - binding.pluralBtn.text = pluralPlaceholder[getLanguageAlias(language)] ?: "Plural" - binding.separator2.visibility = View.GONE - binding.separator3.visibility = View.GONE + 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 - setupCommandBarTheme(binding) - binding.scribeKey.setOnClickListener { - currentState = ScribeState.IDLE - Log.i(TAG, "IDLE STATE") - binding.translateBtn.setTextColor(Color.WHITE) - disableAutoSuggest() - saveConjugateModeType("none") - binding.scribeKey.foreground = AppCompatResources.getDrawable(this, R.drawable.ic_scribe_icon_vector) - updateUI() - } - binding.translateBtn.setOnClickListener { - Log.i(TAG, "TRANSLATE STATE") - keyboardView?.invalidateAllKeys() - updateCommandBarHintAndPrompt() - saveConjugateModeType("none") - currentState = ScribeState.TRANSLATE - - updateUI() - } - binding.conjugateBtn.setOnClickListener { - Log.i(TAG, "CONJUGATE STATE") - updateCommandBarHintAndPrompt() - currentState = ScribeState.CONJUGATE - updateUI() - } - binding.pluralBtn.setOnClickListener { - Log.i(TAG, "PLURAL STATE") - updateCommandBarHintAndPrompt() - currentState = ScribeState.PLURAL - updateUI() - saveConjugateModeType("none") - if (language == "German") { - keyboard!!.mShiftState = SHIFT_ON_ONE_CHAR - } - } } /** - * Saves the conjugate mode type to the shared preferences. - * - * This function stores the given conjugate mode type in the shared preferences - * under the key "conjugate_mode_type". It uses asynchronous saving via `apply()`. - * This ensures the mode type is stored persistently and can be retrieved later - * across app sessions. - * - * @param mode The conjugate mode type to be saved, represented as a string. - * This can be a mode like "none", "3x2", or any other mode type. + * Handles the logic for showing/hiding suggestion buttons specifically on tablet layouts. + * @param emojiCount The number of available emoji suggestions. */ - internal fun saveConjugateModeType( - language: String, - isSubsequentArea: Boolean = false, - ) { - val sharedPref = applicationContext.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) - val mode = - if (!isSubsequentArea) { - when (language) { - "Swedish", "English" -> "2x2" - "German", "French", "Russian", "Italian", "Spanish", "Portuguese" -> "3x2" - else -> "none" - } + private fun updateTabletButtonVisibility(emojiCount: Int) { + pluralBtn?.visibility = if (emojiCount > 0) View.INVISIBLE else View.VISIBLE + + emojiBtnTablet1?.visibility = + if (emojiCount >= EMOJI_SUGGESTION_THRESHOLD_ONE) { + View.VISIBLE } else { - "none" + View.INVISIBLE } - sharedPref.edit { - putString("conjugate_mode_type", mode) - } + emojiBtnTablet2?.visibility = + if (emojiCount >= EMOJI_SUGGESTION_THRESHOLD_TWO) { + View.VISIBLE + } else { + View.INVISIBLE + } + emojiBtnTablet3?.visibility = + if (emojiCount >= EMOJI_SUGGESTION_THRESHOLD_THREE) { + View.VISIBLE + } else { + View.INVISIBLE + } + + binding.separator5.visibility = if (emojiCount >= 1) View.VISIBLE else View.GONE + binding.separator6.visibility = if (emojiCount >= 1) View.VISIBLE else View.GONE + + emojiBtnPhone1?.visibility = View.GONE + emojiBtnPhone2?.visibility = View.GONE + binding.separator4.visibility = View.GONE } /** - * Sets up the theme for the command bar in the keyboard view. - * - * @param binding The binding object for the keyboard view command options. + * Handles the logic for showing/hiding suggestion buttons specifically on phone layouts. + * @param emojiCount The number of available emoji suggestions. */ - fun setupCommandBarTheme(binding: KeyboardViewCommandOptionsBinding) { - val isUserDarkMode = getIsDarkModeOrNot(context = applicationContext) - when (isUserDarkMode) { - true -> { - binding.commandField.setBackgroundColor("#1E1E1E".toColorInt()) - binding.translateBtn.setTextColor(getColor(white)) - } - else -> { - binding.commandField.setBackgroundColor("#d2d4da".toColorInt()) - binding.translateBtn.setTextColor(getColor(md_grey_black_dark)) - } - } + private fun updatePhoneButtonVisibility(emojiCount: Int) { + pluralBtn?.visibility = if (emojiCount > 0) View.INVISIBLE else View.VISIBLE + + emojiBtnPhone1?.visibility = if (emojiCount >= 1) View.VISIBLE else View.INVISIBLE + emojiBtnPhone2?.visibility = if (emojiCount >= 2) View.VISIBLE else View.INVISIBLE + + binding.separator4.visibility = if (emojiCount >= 1) View.VISIBLE else View.GONE + + emojiBtnTablet1?.visibility = View.GONE + emojiBtnTablet2?.visibility = View.GONE + emojiBtnTablet3?.visibility = View.GONE + binding.separator5.visibility = View.GONE + binding.separator6.visibility = View.GONE } /** - * Initializes and returns the binding for the keyboard view. - * - * @return The binding for the keyboard view. + * Retrieves the text immediately preceding the cursor. + * @return The text before the cursor, up to a defined maximum length. */ - private fun initializeKeyboardBinding(): KeyboardViewKeyboardBinding { - val keyboardBinding = KeyboardViewKeyboardBinding.inflate(layoutInflater) - return keyboardBinding - } + fun getText(): String? = currentInputConnection?.getTextBeforeCursor(TEXT_LENGTH, 0)?.toString() /** - * Initializes the emoji buttons on the keyboard. - * This method sets up the necessary configurations and listeners - * for the emoji buttons to function correctly. + * Extracts the last word from the text immediately preceding the cursor. + * @return The last word as a [String], or null if no word is found. */ - fun initializeEmojiButtons() { - pluralBtn = binding.pluralBtn - emojiBtnPhone1 = binding.emojiBtnPhone1 - emojiSpacePhone = binding.emojiSpacePhone - emojiBtnPhone2 = binding.emojiBtnPhone2 - emojiBtnTablet1 = binding.emojiBtnTablet1 - emojiSpaceTablet1 = binding.emojiSpaceTablet1 - emojiBtnTablet2 = binding.emojiBtnTablet2 - emojiSpaceTablet2 = binding.emojiSpaceTablet2 - emojiBtnTablet3 = binding.emojiBtnTablet3 - genderSuggestionLeft = binding.translateBtnLeft - genderSuggestionRight = binding.translateBtnRight + 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 } /** - * Updates the visibility of the button based on whether auto-suggest is enabled. - * - * @param isAutoSuggestEnabled A boolean indicating if auto-suggest is enabled. + * 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. */ - private fun updateButtonVisibility(isAutoSuggestEnabled: Boolean) { - val isTablet = - ( - resources.configuration.screenLayout and - Configuration.SCREENLAYOUT_SIZE_MASK - ) >= Configuration.SCREENLAYOUT_SIZE_LARGE - if (isTablet) { - pluralBtn?.visibility = if (isAutoSuggestEnabled) View.INVISIBLE else View.VISIBLE - emojiBtnTablet1?.visibility = if (isAutoSuggestEnabled) View.VISIBLE else View.INVISIBLE - emojiSpaceTablet1?.visibility = if (isAutoSuggestEnabled) View.VISIBLE else View.INVISIBLE - emojiBtnTablet2?.visibility = if (isAutoSuggestEnabled) View.VISIBLE else View.INVISIBLE - emojiSpaceTablet2?.visibility = if (isAutoSuggestEnabled) View.VISIBLE else View.INVISIBLE - emojiBtnTablet3?.visibility = if (isAutoSuggestEnabled) View.VISIBLE else View.INVISIBLE - } else { - pluralBtn?.visibility = if (isAutoSuggestEnabled) View.INVISIBLE else View.VISIBLE - emojiBtnPhone1?.visibility = if (isAutoSuggestEnabled) View.VISIBLE else View.INVISIBLE - emojiSpacePhone?.visibility = if (isAutoSuggestEnabled) View.VISIBLE else View.INVISIBLE - emojiBtnPhone2?.visibility = if (isAutoSuggestEnabled) View.VISIBLE else View.INVISIBLE + 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 } /** - * Retrieves the text from the input method editor (IME). - * - * @return A string containing the text from the IME, or null if no text is available. + * 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 getText(): String? { - val inputConnection = currentInputConnection ?: return null - return inputConnection.getTextBeforeCursor(TEXT_LENGTH, 0)?.toString() + fun findWhetherWordIsPlural( + pluralWords: Set?, + lastWord: String?, + ): Boolean = pluralWords?.contains(lastWord) == 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 } /** - * Retrieves the last word before the cursor in the current input field. - * - * @return The last word before the cursor, or null if there is no word. + * 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 getLastWordBeforeCursor(): String? { - val textBeforeCursor = getText() ?: return null - val trimmedText = textBeforeCursor.trim() - val lastWord = trimmedText.split("\\s+".toRegex()).lastOrNull() - return lastWord + 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) + } + } } /** - * Finds and returns a list of emojis that are relevant to the last word typed. - * - * @return A list of emojis that match the last word. + * The main dispatcher for displaying linguistic auto-suggestions (gender, case, plurality). + * @param nounTypeSuggestion The detected gender(s) of the last word. + * @param isPlural `true` if the last word is plural. + * @param caseAnnotationSuggestion The detected case(s) required by the last word. */ - fun findEmojisForLastWord( - emojiKeywords: HashMap>, - lastWord: String?, - ): MutableList? { - lastWord?.let { word -> - val lowerCaseWord = word.lowercase() - val emojis = emojiKeywords[lowerCaseWord] - if (emojis != null) { - Log.d("Debug", "Emojis for '$word': $emojis") - return emojis - } else { - Log.d("Debug", "No emojis found for '$word'") + fun updateAutoSuggestText( + nounTypeSuggestion: List? = null, + isPlural: Boolean = false, + caseAnnotationSuggestion: MutableList? = null, + ) { + if (currentState != ScribeState.IDLE) { + disableAutoSuggest() + return + } + + val handled = + when { + (isPlural && nounTypeSuggestion != null) -> { + handleMultipleNounFormats(nounTypeSuggestion, "noun") + true + } + ((nounTypeSuggestion?.size ?: 0) > 1) -> { + handleMultipleNounFormats(nounTypeSuggestion, "noun") + true + } + handlePluralIfNeeded(isPlural) -> true + handleSingleNounSuggestion(nounTypeSuggestion) -> true + handleMultipleCases(caseAnnotationSuggestion) -> true + handleSingleCaseSuggestion(caseAnnotationSuggestion) -> true + handleFallbackSuggestions(nounTypeSuggestion, caseAnnotationSuggestion) -> true + else -> false } - } - return null + if (!handled) disableAutoSuggest() } /** - * Finds the gender for the last word typed. - * - * @return The gender associated with the last word, if any. + * A helper function to specifically trigger the plural suggestion UI if needed. + * @param isPlural `true` if the word is plural. + * @return `true` if the plural suggestion was handled, `false` otherwise. */ - fun findGenderForLastWord( - nounKeywords: HashMap>, - lastWord: String?, - ): List? { - lastWord?.let { word -> - val lowerCaseWord = word.lowercase() - Log.i(TAG, word) - Log.i(TAG, nounKeywords.keys.toString()) - Log.i(TAG, nounKeywords[word].toString()) - val gender = nounKeywords[lowerCaseWord] - if (gender != null) { - Log.d("Debug", "Gender for '$word': $gender") - Log.i(TAG, pluralWords?.contains(lastWord).toString()) - if (pluralWords?.any { it.equals(lastWord, ignoreCase = true) } == true) { - Log.i(TAG, "Plural Words : $pluralWords") - isSingularAndPlural = true - Log.i(TAG, "isSingularPlural Updated to true") - } else { - isSingularAndPlural = false - Log.i(TAG, "Plural Words : $pluralWords") - Log.i(TAG, "isSingularPlural Updated to false") - } - return gender - } else { - Log.d("Debug", "No gender found for '$word'") - } + private fun handlePluralIfNeeded(isPlural: Boolean): Boolean { + if (isPlural) { + handlePluralAutoSuggest() + return true } - return null + return false } /** - * Determines whether a given word is plural. - * - * @param word The word to be checked. - * @return `true` if the word is plural, `false` otherwise. + * 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. */ - fun findWhetherWordIsPlural( - pluralWords: List, - lastWord: String?, - ): Boolean { - for (item in pluralWords) { - if (item == lastWord) { + private fun handleSingleNounSuggestion(nounTypeSuggestion: List?): Boolean { + if (nounTypeSuggestion?.size == 1 && !isSingularAndPlural) { + val (colorRes, text) = handleColorAndTextForNounType(nounTypeSuggestion[0], language, applicationContext) + if (text != getString(R.string.suggestion) || colorRes != R.color.transparent) { + handleSingleType(nounTypeSuggestion, "noun") return true } } @@ -1232,558 +1167,351 @@ abstract class GeneralKeyboardIME( } /** - * Retrieves the case annotation for a given preposition. - * - * @param preposition The preposition for which the case annotation is to be retrieved. - * @return The case annotation associated with the specified preposition. + * 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. */ - fun getCaseAnnotationForPreposition( - caseAnnotation: HashMap>, - lastWord: String?, - ): MutableList? { - lastWord?.let { word -> - val lowerCaseWord = word.lowercase() - val caseAnnotations = caseAnnotation[lowerCaseWord] - return caseAnnotations + private fun handleSingleCaseSuggestion(caseAnnotationSuggestion: List?): Boolean { + if (caseAnnotationSuggestion?.size == 1) { + val (colorRes, text) = + handleTextForCaseAnnotation( + caseAnnotationSuggestion[0], + language, + applicationContext, + ) + if (text != getString(R.string.suggestion) || colorRes != R.color.transparent) { + handleSingleType(caseAnnotationSuggestion, "preposition") + return true + } } - return null + return false } /** - * Updates the text displayed on a button. - * - * @param buttonId The ID of the button whose text needs to be updated. - * @param newText The new text to be displayed on the button. + * 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. */ - fun updateButtonText( - isAutoSuggestEnabled: Boolean, - autoSuggestEmojis: MutableList?, - ) { - if (isAutoSuggestEnabled) { - emojiBtnTablet1?.text = autoSuggestEmojis?.get(0) - emojiBtnTablet2?.text = autoSuggestEmojis?.get(1) - emojiBtnTablet3?.text = autoSuggestEmojis?.get(DATA_SIZE_2) - - emojiBtnPhone1?.text = autoSuggestEmojis?.get(0) - emojiBtnPhone2?.text = autoSuggestEmojis?.get(1) - - binding.emojiBtnTablet1.setOnClickListener { insertEmoji(emojiBtnTablet1?.text.toString()) } - binding.emojiBtnTablet2.setOnClickListener { insertEmoji(emojiBtnTablet2?.text.toString()) } - binding.emojiBtnTablet3.setOnClickListener { insertEmoji(emojiBtnTablet3?.text.toString()) } - - binding.emojiBtnPhone1.setOnClickListener { insertEmoji(emojiBtnPhone1?.text.toString()) } - binding.emojiBtnPhone2.setOnClickListener { insertEmoji(emojiBtnPhone2?.text.toString()) } + private fun handleMultipleCases(caseAnnotationSuggestion: List?): Boolean { + if ((caseAnnotationSuggestion?.size ?: 0) > 1) { + handleMultipleNounFormats(caseAnnotationSuggestion, "preposition") + return true } + return false } /** - * Updates the first auto suggestion button based on the current input. - * - * This function is responsible for generating and displaying - * suggestions as the user types. It takes into account the - * current context and input to provide relevant suggestions.It shows wheather - * the word is plural or the gender of the word. - * - * @param inputText The current text input by the user. - * @param cursorPosition The position of the cursor within the input text. + * 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. */ - fun updateAutoSuggestText( - nounTypeSuggestion: List? = null, - isPlural: Boolean = false, - caseAnnotationSuggestion: MutableList? = null, - ) { - if (isPlural) { - handlePluralAutoSuggest() - } else { - Log.i(TAG, "These are the case annotations $caseAnnotationSuggestion") - nounTypeSuggestion?.size?.let { - if (it > 1 || isSingularAndPlural) { - handleMultipleNounFormats(nounTypeSuggestion, "noun") - } else { - handleSingleType(nounTypeSuggestion, "noun") - } - } - caseAnnotationSuggestion?.size?.let { - if (it > 1) { - handleMultipleNounFormats(caseAnnotationSuggestion, "preposition") - } else { - handleSingleType(caseAnnotationSuggestion, "preposition") - } + 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 != getString(R.string.suggestion)) appliedSomething = true + } + if (!appliedSomething) { + caseAnnotationSuggestion?.let { + handleSingleType(it, "preposition") + val (_, text) = handleTextForCaseAnnotation(it[0], language, applicationContext) + if (text != getString(R.string.suggestion)) appliedSomething = true } } + return appliedSomething } /** - * Handles the auto-suggestion of plural forms for words. - * This function is responsible for providing suggestions for pluralizing words - * based on the current context and user input. + * Configures the UI to show a "PL" (Plural) suggestion. */ private fun handlePluralAutoSuggest() { - var(colorRes, text) = handleColorAndTextForNounType(nounType = "PL") - text = "PL" - colorRes = R.color.annotateOrange binding.translateBtnLeft.visibility = View.INVISIBLE binding.translateBtnRight.visibility = View.INVISIBLE - handleTextSize(binding) + binding.translateBtn.apply { visibility = View.VISIBLE - binding.translateBtn.text = text - background = - ContextCompat.getDrawable(context, R.drawable.rounded_drawable)?.apply { - setTintMode(PorterDuff.Mode.SRC_IN) - setTint(ContextCompat.getColor(context, colorRes)) - } + text = "PL" + textSize = NOUN_TYPE_SIZE + background = ContextCompat.getDrawable(context, R.drawable.button_background_rounded) + backgroundTintList = ContextCompat.getColorStateList(context, R.color.annotateOrange) + setTextColor(getColor(white)) + isClickable = false + setOnClickListener(null) } } /** - * Handles a single type event. - * - * This function processes a single type event, performing necessary actions based on the input. - * - * @param input The input data to be processed. - * @return The result of processing the input. + * 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( singleTypeSuggestion: List?, type: String? = null, ) { - Log.i(TAG, "Single suggestion activated $singleTypeSuggestion") - val text = singleTypeSuggestion?.get(0).toString() - var (colorRes, buttonText) = Pair(R.color.transparent, "Suggestion") - val isUserDarkMode = getIsDarkModeOrNot(applicationContext) - var textColor = md_grey_black_dark - if (isUserDarkMode) { - colorRes = white - textColor = md_grey_black_dark - } else { - colorRes = md_grey_black_dark - textColor = white - } - when (type) { - "noun" -> { - val (newColorRes, newButtonText) = handleColorAndTextForNounType(text) - colorRes = newColorRes - buttonText = newButtonText - } - "preposition" -> { - val (_, newButtonText) = handleTextForCaseAnnotation(text) - buttonText = newButtonText - } - else -> { - val (newColorRes, newButtonText) = Pair(R.color.transparent, "Suggestion") - colorRes = newColorRes - buttonText = newButtonText + val suggestionText = singleTypeSuggestion?.getOrNull(0).toString() + + val (colorRes, buttonText) = + when (type) { + "noun" -> handleColorAndTextForNounType(suggestionText, language, applicationContext) + "preposition" -> handleTextForCaseAnnotation(suggestionText, language, applicationContext) + else -> Pair(R.color.transparent, getString(R.string.suggestion)) } - } - Log.i(TAG, "These are the colorRes and text $colorRes and $buttonText") + binding.translateBtnLeft.visibility = View.INVISIBLE binding.translateBtnRight.visibility = View.INVISIBLE - binding.translateBtn.setTextColor(getColor(textColor)) - handleTextSize(binding) + binding.translateBtn.textSize = NOUN_TYPE_SIZE + binding.translateBtn.apply { visibility = View.VISIBLE - binding.translateBtn.text = buttonText - setTextColor(getColor(textColor)) - background = - ContextCompat.getDrawable(context, R.drawable.rounded_drawable)?.apply { - setTintMode(PorterDuff.Mode.SRC_IN) - setTint(ContextCompat.getColor(context, colorRes)) - } + text = buttonText + isClickable = false + setOnClickListener(null) + + if (colorRes != R.color.transparent) { + background = ContextCompat.getDrawable(context, R.drawable.button_background_rounded) + backgroundTintList = ContextCompat.getColorStateList(context, colorRes) + setTextColor(getColor(white)) + } else { + background = null + val isUserDarkMode = getIsDarkModeOrNot(applicationContext) + backgroundTintList = ContextCompat.getColorStateList(context, R.color.transparent) + setTextColor(getColor(if (isUserDarkMode) white else md_grey_black_dark)) + } } } /** - * Adjusts the text size of the keyboard view based on the provided binding. - * - * @param binding The binding object for the keyboard view command options. + * Determines the left and right suggestion types to display for dual suggestions. + * @param type The suggestion type ("noun" or "preposition"). + * @param suggestions The list of suggestion strings. + * @return A pair of strings representing the left and right suggestion. */ - private fun handleTextSize(binding: KeyboardViewCommandOptionsBinding) { - binding.translateBtn.textSize = NOUN_TYPE_SIZE - } + private fun getSuggestionTypes( + type: String?, + suggestions: List?, + ): Pair = + if (type == "noun" && isSingularAndPlural) { + "PL" to (suggestions?.getOrNull(0) ?: "") + } else { + (suggestions?.getOrNull(0) ?: "") to (suggestions?.getOrNull(1) ?: "") + } /** - * Handles different formats of nouns. - * - * This function processes multiple formats of nouns and applies the necessary transformations - * or actions based on the specific format of the noun provided. - * - * @param noun The noun to be processed. - * @return The processed noun in the desired format. - */ - private fun handleMultipleNounFormats( - multipleTypeSuggestion: List?, - type: String? = null, - ) { - binding.apply { - translateBtnLeft.visibility = View.VISIBLE - translateBtnRight.visibility = View.VISIBLE - translateBtn.visibility = View.INVISIBLE - binding.translateBtnLeft.setTextColor(getColor(white)) - binding.translateBtnRight.setTextColor(getColor(white)) - val (leftType, rightType) = - if (isSingularAndPlural) { - "PL" to multipleTypeSuggestion?.get(0).toString() - } else { - multipleTypeSuggestion?.get(0).toString() to multipleTypeSuggestion?.get(1).toString() - } - - when (type) { - "noun" -> { - handleTextForNouns(leftType, rightType, binding) - } - "preposition" -> { - handleTextForPreposition(leftType, rightType, binding) - } - } + * Creates pairs of (color, text) for dual suggestion buttons. + * @param type The suggestion type ("noun" or "preposition"). + * @param suggestions The list of suggestion strings. + * @return A pair of pairs, each containing a color resource ID and a text string, or null on failure. + */ + private fun getSuggestionPairs( + type: String?, + suggestions: List?, + ): Pair, Pair>? { + val (leftType, rightType) = getSuggestionTypes(type, suggestions) + return when (type) { + "noun" -> + handleColorAndTextForNounType(leftType, language, applicationContext) to + handleColorAndTextForNounType(rightType, language, applicationContext) + "preposition" -> + handleTextForCaseAnnotation(leftType, language, applicationContext) to + handleTextForCaseAnnotation(rightType, language, applicationContext) + else -> null } } /** - * Handles the text input specifically for nouns. - * This function processes the given text and performs necessary actions - * to handle nouns appropriately within the input method editor (IME). - * - * @param text The input text that needs to be processed for nouns. + * Applies a specific style to a suggestion button, including text, color, and a custom background. + * @param button The Button to style. + * @param colorRes The color resource ID for the background. + * @param text The text to display on the button. + * @param backgroundRes The drawable resource ID for the button's background. */ - private fun handleTextForNouns( - leftType: String, - rightType: String, - binding: KeyboardViewCommandOptionsBinding, + private fun applyInformativeSuggestionStyle( + button: Button, + colorRes: Int, + text: String, + backgroundRes: Int, ) { - handleColorAndTextForNounType(leftType).let { (colorRes, text) -> - binding.translateBtnLeft.text = text - binding.translateBtnLeft.background = - ContextCompat - .getDrawable( - applicationContext, - R.drawable.gender_suggestion_button_left_background, - )?.apply { - setTintMode(PorterDuff.Mode.SRC_IN) - setTint(ContextCompat.getColor(applicationContext, colorRes)) - } - } + button.text = text + button.setTextColor(getColor(white)) + button.isClickable = false + button.setOnClickListener(null) - handleColorAndTextForNounType(rightType).let { (colorRes, text) -> - binding.translateBtnRight.text = text - binding.translateBtnRight.background = - ContextCompat - .getDrawable( - applicationContext, - R.drawable.gender_suggestion_button_right_background, - )?.apply { - setTintMode(PorterDuff.Mode.SRC_IN) - setTint(ContextCompat.getColor(applicationContext, colorRes)) - } - } - } + val background = ContextCompat.getDrawable(applicationContext, backgroundRes)?.mutate() - /** - * Handles the text input for prepositions. - * - * This function processes the given text to identify and handle prepositions - * appropriately within the input method editor (IME). - * - * @param text The input text to be processed for prepositions. - */ - private fun handleTextForPreposition( - leftType: String, - rightType: String, - binding: KeyboardViewCommandOptionsBinding, - ) { - handleTextForCaseAnnotation(leftType).let { (colorRes, text) -> - binding.translateBtnLeft.text = text - binding.translateBtnLeft.background = - ContextCompat - .getDrawable( - applicationContext, - R.drawable.gender_suggestion_button_left_background, - )?.apply { - setTintMode(PorterDuff.Mode.SRC_IN) - setTint(ContextCompat.getColor(applicationContext, colorRes)) - } - } + if (background is RippleDrawable) { + val contentDrawable = background.getDrawable(0) + + if (contentDrawable is LayerDrawable) { + val shapeDrawable = + contentDrawable.findDrawableByLayerId( + R.id.button_background_shape, + ) as? GradientDrawable - handleTextForCaseAnnotation(rightType).let { (colorRes, text) -> - binding.translateBtnRight.text = text - binding.translateBtnRight.background = - ContextCompat - .getDrawable( + shapeDrawable?.setColor( + ContextCompat.getColor( applicationContext, - R.drawable.gender_suggestion_button_right_background, - )?.apply { - setTintMode(PorterDuff.Mode.SRC_IN) - setTint(ContextCompat.getColor(applicationContext, colorRes)) - } + colorRes, + ), + ) + } } + button.background = background } /** - * Handles text for case annotation based on the provided noun type. - * - * @param nounType The type of noun to be annotated. - * @return A pair containing an integer and a string. The integer represents the status code, - * and the string contains the annotated text. + * Sets up the UI for two side-by-side suggestion buttons. + * @param leftSuggestion A pair containing the color and text for the left button. + * @param rightSuggestion A pair containing the color and text for the right button. */ - private fun handleTextForCaseAnnotation(nounType: String): Pair { - val suggestionMap = - mapOf( - "genitive case" to Pair(md_grey_black_dark, processValuesForPreposition(language, "Gen")), - "accusative case" to Pair(md_grey_black_dark, processValuesForPreposition(language, "Acc")), - "dative case" to Pair(md_grey_black_dark, processValuesForPreposition(language, "Dat")), - "locative case" to Pair(md_grey_black_dark, processValuesForPreposition(language, "Loc")), - "Prepositional case" to Pair(md_grey_black_dark, processValuesForPreposition(language, "Pre")), - "Instrumental case" to Pair(md_grey_black_dark, processValuesForPreposition(language, "Ins")), - ) - var (colorRes, text) = - suggestionMap[nounType] - ?: Pair(R.color.transparent, "Suggestion") - return Pair(colorRes, text) - } - - /** - * Handles the color and text representation for a given noun type. - * - * @param nounType The type of noun for which the color and text need to be determined. - * @return A pair containing the color (as an Int) and the text (as a String) corresponding to the noun type. - */ - private fun handleColorAndTextForNounType(nounType: String): Pair { - val suggestionMap = - mapOf( - "PL" to Pair(R.color.annotateOrange, "PL"), - "neuter" to Pair(R.color.annotateGreen, "N"), - "common of two genders" to Pair(R.color.annotatePurple, processValueForNouns(language, "C")), - "common" to Pair(R.color.annotatePurple, processValueForNouns(language, "C")), - "masculine" to Pair(R.color.annotateBlue, processValueForNouns(language, "M")), - "feminine" to Pair(R.color.annotateRed, processValueForNouns(language, "F")), + private fun setupDualSuggestionButtons( + leftSuggestion: Pair, + rightSuggestion: Pair, + ) { + binding.apply { + translateBtnLeft.visibility = View.VISIBLE + translateBtnRight.visibility = View.VISIBLE + translateBtn.visibility = View.INVISIBLE + + applyInformativeSuggestionStyle( + translateBtnLeft, + leftSuggestion.first, + leftSuggestion.second, + R.drawable.gender_suggestion_button_left_background, ) - var (colorRes, text) = - suggestionMap[nounType] - ?: Pair(R.color.transparent, "Suggestion") - return Pair(colorRes, text) + applyInformativeSuggestionStyle( + translateBtnRight, + rightSuggestion.first, + rightSuggestion.second, + R.drawable.gender_suggestion_button_right_background, + ) + } } /** - * Processes the given value to identify and handle nouns. - * - * @param value The input value that needs to be processed for nouns. - * @return The processed result after handling nouns. + * Handles the logic when a word has multiple possible genders or + * cases but only one suggestion slot is available. + * It picks the first valid suggestion to display. + * @param multipleTypeSuggestion The list of noun suggestions. */ - private fun processValueForNouns( - language: String, - text: String, - ): String { - var textOutput: String - if (nounAnnotationConversionDict[language]?.get(text) != null) { - textOutput = nounAnnotationConversionDict[language]?.get(text).toString() + private fun handleFallbackOrSingleSuggestion(multipleTypeSuggestion: List?) { + val suggestionText = getString(R.string.suggestion) + val validNouns = + multipleTypeSuggestion?.filter { + handleColorAndTextForNounType( + it, + language, + applicationContext, + ).second != suggestionText + } + val validCases = + caseAnnotationSuggestion?.filter { + handleTextForCaseAnnotation( + it, + language, + applicationContext, + ).second != suggestionText + } + if (!validNouns.isNullOrEmpty()) { + handleSingleType(validNouns, "noun") + } else if (!validCases.isNullOrEmpty()) { + handleSingleType(validCases, "preposition") } else { - return text + disableAutoSuggest() } - return textOutput } /** - * Processes the given values to determine the appropriate preposition. - * - * @param values The list of values to be processed. - * @return The determined preposition based on the processed values. + * Handles the UI logic for displaying multiple suggestions simultaneously, + * typically for words with multiple genders. + * @param multipleTypeSuggestion The list of suggestions to display. + * @param type The type of suggestion, either "noun" or "preposition". */ - private fun processValuesForPreposition( - language: String, - text: String, - ): String { - var textOutput: String - if (prepAnnotationConversionDict[language]?.get(text) != null) { - textOutput = prepAnnotationConversionDict[language]?.get(text).toString() - } else { - return text + private fun handleMultipleNounFormats( + multipleTypeSuggestion: List?, + type: String? = null, + ) { + val suggestionPairs = getSuggestionPairs(type, multipleTypeSuggestion) ?: return + val (leftSuggestion, rightSuggestion) = suggestionPairs + val suggestionText = getString(R.string.suggestion) + if (leftSuggestion.second == suggestionText || rightSuggestion.second == suggestionText) { + handleFallbackOrSingleSuggestion(multipleTypeSuggestion) + return } - return textOutput + setupDualSuggestionButtons(leftSuggestion, rightSuggestion) } /** - * Disables the auto-suggest feature of the keyboard. - * This function is used to disable the suggestion of plural or gender - * when the keyboard switches to one of the other modes. + * Disables all auto-suggestions and resets the suggestion buttons to their default, inactive state. */ fun disableAutoSuggest() { binding.translateBtnRight.visibility = View.INVISIBLE binding.translateBtnLeft.visibility = View.INVISIBLE binding.translateBtn.visibility = View.VISIBLE binding.translateBtn.text = getString(R.string.suggestion) - binding.translateBtn.setTextColor(getColor(R.color.special_key_dark)) - binding.translateBtn.setBackgroundColor(getColor(R.color.transparent)) - handleTextSizeForSuggestion(binding) - if (currentState == ScribeState.SELECT_COMMAND) { - setupIdleView() - setupSelectCommandView() - } + binding.translateBtn.background = null + binding.translateBtn.setOnClickListener(null) + binding.conjugateBtn.setOnClickListener(null) + binding.pluralBtn.setOnClickListener(null) + handleTextSizeForSuggestion(binding.translateBtn) } /** - * Adjusts the text size for the suggestion view in the keyboard. - * - * @param binding The binding object for the keyboard view command options. + * Sets the text size and color for a default, non-active suggestion button. + * @param button The button to style. */ - private fun handleTextSizeForSuggestion(binding: KeyboardViewCommandOptionsBinding) { - binding.translateBtn.textSize = SUGGESTION_SIZE + private fun handleTextSizeForSuggestion(button: Button) { + button.textSize = SUGGESTION_SIZE val isUserDarkMode = getIsDarkModeOrNot(applicationContext) - if (isUserDarkMode) { - binding.translateBtn.setTextColor(getColor(white)) - } else { - binding.translateBtn.setTextColor(getColor(md_grey_black_dark)) - } - } - - /** - * Inserts the specified emoji into the current input field. - * Replaces the last word if there's no trailing space. - * - * @param emoji The emoji character to be inserted. - */ - private fun insertEmoji(emoji: String) { - val inputConnection = currentInputConnection ?: return - val maxLookBack = emojiMaxKeywordLength.coerceAtLeast(1) - - inputConnection.beginBatchEdit() - try { - val previousText = inputConnection.getTextBeforeCursor(maxLookBack, 0)?.toString() ?: "" - - // Find last word boundary efficiently - val lastSpaceIndex = previousText.lastIndexOf(' ') - val hasSpace = lastSpaceIndex != -1 - - when { - // Case 1: Ends with space or empty - previousText.isEmpty() || hasSpace && lastSpaceIndex == previousText.length - 1 -> { - inputConnection.commitText(emoji, 1) - } - - // Case 2: Has previous word - hasSpace -> { - val lastWord = previousText.substring(lastSpaceIndex + 1) - if (emojiKeywords?.containsKey(lastWord.lowercase()) == true) { - inputConnection.deleteSurroundingText(lastWord.length, 0) - inputConnection.commitText(emoji, 1) - } else { - inputConnection.commitText(emoji, 1) - } - } - - // Case 3: Entire text is the word - else -> { - if (emojiKeywords?.containsKey(previousText.lowercase()) == true) { - inputConnection.deleteSurroundingText(previousText.length, 0) - inputConnection.commitText(emoji, 1) - } else { - inputConnection.commitText(emoji, 1) - } - } - } - } finally { - inputConnection.endBatchEdit() - } + button.setTextColor(if (isUserDarkMode) getColor(white) else getColor(md_grey_black_dark)) } /** - * Returns the plural representation of the given word. - * - * @param word The word to be pluralized. Can be null. - * @return The plural form of the word, or null if the input word is null. + * Retrieves the plural form of a word from the database. + * @param word The singular word to find the plural for. + * @return The plural form as a string, or null if not found. */ private fun getPluralRepresentation(word: String?): String? { if (word.isNullOrEmpty()) return null - val languageAlias = getLanguageAlias(language) - val pluralRepresentationMap = dbHelper.getPluralRepresentation(languageAlias, word) - return pluralRepresentationMap.values.filterNotNull().firstOrNull() - } - - /** - * Returns the alias for the given language. - * - * @param language The language for which the alias is to be retrieved. - * @return The alias corresponding to the provided language. - */ - private fun getLanguageAlias(language: String): String = - when (language) { - "English" -> "EN" - "French" -> "FR" - "German" -> "DE" - "Italian" -> "IT" - "Portuguese" -> "PT" - "Russian" -> "RU" - "Spanish" -> "ES" - "Swedish" -> "SV" - else -> "" - } - - /** - * Updates the state of the shift key based on the current context. - * This method should be called whenever there is a change in the input state - * that might affect the shift key, such as a change in the input type or - * the current text being edited. - */ - fun updateShiftKeyState() { - // The shift state in the Scribe commands should not depend on the Input Connection. - // The current state should be transferred to the command unless required by the language. - if ((currentState == ScribeState.IDLE || currentState == ScribeState.SELECT_COMMAND) && - keyboardMode == keyboardLetters - - ) { - val editorInfo = currentInputEditorInfo - if ( - editorInfo != null && - editorInfo.inputType != InputType.TYPE_NULL && - keyboard?.mShiftState != SHIFT_ON_PERMANENT - ) { - if (currentInputConnection.getCursorCapsMode(editorInfo.inputType) != 0) { - keyboard?.setShifted(SHIFT_ON_ONE_CHAR) - keyboardView?.invalidateAllKeys() - } - } - } + val langAlias = getLanguageAlias(language) + val pluralMap = dbManagers.pluralManager.getPluralRepresentation(langAlias, dataContract, word) + return pluralMap.values.firstOrNull() } /** * Moves the cursor in the input field. - * - * @param moveRight A boolean indicating the direction to move the cursor. - * If true, the cursor moves to the right. - * If false, the cursor moves to the left. + * @param moveRight `true` to move right, `false` to move left. */ private fun moveCursor(moveRight: Boolean) { val extractedText = currentInputConnection?.getExtractedText(ExtractedTextRequest(), 0) ?: return - var newCursorPosition = extractedText.selectionStart - newCursorPosition = - if (moveRight) { - newCursorPosition + 1 - } else { - newCursorPosition - 1 - } - - currentInputConnection?.setSelection(newCursorPosition, newCursorPosition) + val newPos = extractedText.selectionStart + if (moveRight) 1 else -1 + currentInputConnection?.setSelection(newPos, newPos) } /** - * Safely retrieves the translation of a word between source and destination languages. - * - * This function calls `getTranslationSourceAndDestination` and handles the null case - * by returning an empty string if the translation is null. - * - * @param language The language name (e.g., "english") to determine the source and destination languages. - * @param commandBarInput The word whose translation is to be fetched. - * @return The translation of the word in the destination language, or an empty string if no translation is found. + * Retrieves the translation for a given word. + * @param language The current keyboard language (destination language). + * @param commandBarInput The word to be translated (source word). + * @return The translated word as a string. */ fun getTranslation( language: String, commandBarInput: String, - ): String = dbHelper.getTranslationSourceAndDestination(language, commandBarInput) ?: "" + ): String { + val sourceDest = dbManagers.translationDataManager.getSourceAndDestinationLanguage(language) + return dbManagers.translationDataManager.getTranslationDataForAWord(sourceDest, commandBarInput) + } /** - * Retrieves the action ID associated with the IME (Input Method Editor) options. - * - * @return The action ID as an integer. + * Gets the IME action ID (e.g., Go, Search, Done) from the current editor info. + * @return The IME action ID, or `IME_ACTION_NONE`. */ private fun getImeOptionsActionId(): Int = if (currentInputEditorInfo.imeOptions and IME_FLAG_NO_ENTER_ACTION != 0) { @@ -1793,87 +1521,129 @@ abstract class GeneralKeyboardIME( } /** - * Handles the action to be performed when the Enter key is pressed. - * - * This function is responsible for managing the behavior of the Enter key - * within the input method editor (IME). It determines the appropriate action - * based on the current context and state of the input field. - * - * @param keyCode The keycode of the key event. - * @param event The key event associated with the Enter key press. - * @return Boolean indicating whether the key event was handled. + * Handles the logic for the Enter key press. This can either perform an editor action, + * commit a newline, or execute a Scribe command depending on the current state. */ - fun handleKeycodeEnter( - binding: KeyboardViewKeyboardBinding? = null, - commandBarState: Boolean = false, - ) { + fun handleKeycodeEnter() { val inputConnection = currentInputConnection ?: return - val imeOptionsActionId = getImeOptionsActionId() - - val isConjugate = currentState == ScribeState.CONJUGATE - val isCommandBarMode = commandBarState == true + val rawInput = + binding.commandBar.text + ?.toString() + ?.trim() + .takeIf { !it.isNullOrEmpty() } - if (isConjugate || isCommandBarMode) { - val rawInput = - binding - ?.commandBar - ?.text - ?.toString() - ?.trim() - .orEmpty() + if (rawInput == null) { + moveToIdleState() + return + } - if (isConjugate) { - Log.i("ALPHA", "Inside CONJUGATE mode") - saveConjugateModeType(language) - currentState = ScribeState.SELECT_VERB_CONJUNCTION - } + when (currentState) { + ScribeState.PLURAL, ScribeState.TRANSLATE -> handlePluralOrTranslateState(rawInput, inputConnection) + ScribeState.CONJUGATE -> handleConjugateState(rawInput) + else -> handleDefaultEnter(inputConnection) + } + } - val processedOutput = - when (currentState) { - ScribeState.PLURAL -> getPluralRepresentation(rawInput).orEmpty() - ScribeState.TRANSLATE -> getTranslation(language, rawInput) - else -> rawInput - } - Log.i("CONJUGATE-ISSUE", "The raw input is $rawInput") - if (isCommandBarMode) { - val output = if (processedOutput.length > rawInput.length) "$processedOutput " else processedOutput - inputConnection.commitText(output, 1) + /** + * Handles the Enter key press when in the `PLURAL` or `TRANSLATE` state. + * @param rawInput The text from the command bar. + * @param inputConnection The current input connection. + */ + private fun handlePluralOrTranslateState( + rawInput: String, + inputConnection: InputConnection, + ) { + val commandModeOutput = + when (currentState) { + ScribeState.PLURAL -> getPluralRepresentation(rawInput).orEmpty() + ScribeState.TRANSLATE -> getTranslation(language, rawInput) + else -> "" } - conjugateOutput = dbHelper.getConjugateData(getLanguageAlias(language), processedOutput) - conjugateLabels = dbHelper.getConjugateLabels(getLanguageAlias(language), processedOutput) - Log.i("ALPHA", "Processed input: $rawInput") + if (commandModeOutput.isEmpty()) { + currentState = ScribeState.INVALID + updateUI() } else { - handleNonCommandEnter(imeOptionsActionId, inputConnection) + applyCommandOutput(commandModeOutput, inputConnection) } } - internal fun moveToIdleState() { - Log.i(TAG, "IDLE STATE") - currentState = ScribeState.IDLE - switchToCommandToolBar() + /** + * 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. + */ + private fun handleConjugateState(rawInput: String) { + val languageAlias = getLanguageAlias(language) + + conjugateOutput = + dbManagers.conjugateDataManager.getTheConjugateLabels( + languageAlias, + dataContract, + rawInput, + ) + + conjugateLabels = + dbManagers.conjugateDataManager.extractConjugateHeadings( + dataContract, + rawInput, + ) + + currentState = + if ( + conjugateOutput.isEmpty() || + conjugateOutput.values.all { it.isEmpty() } + ) { + ScribeState.INVALID + } else { + saveConjugateModeType(language) + ScribeState.SELECT_VERB_CONJUNCTION + } + updateUI() } - private fun handleNonCommandEnter( - imeOptionsActionId: Int, - inputConnection: InputConnection, - ) { + /** + * Handles the default behavior of the Enter key when not in a special Scribe command mode. + * It performs the editor action or sends a standard Enter key event. + * @param inputConnection The current input connection. + */ + private fun handleDefaultEnter(inputConnection: InputConnection) { + val imeOptionsActionId = getImeOptionsActionId() if (imeOptionsActionId != IME_ACTION_NONE) { inputConnection.performEditorAction(imeOptionsActionId) } else { inputConnection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)) inputConnection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER)) } + + suggestionHandler.clearAllSuggestionsAndHideButtonUI() + moveToIdleState() + } + + /** + * Commits the output of a Scribe command (like translation or pluralization) to the input field. + * @param commandModeOutput The string result of the command. + * @param inputConnection The current input connection. + */ + private fun applyCommandOutput( + commandModeOutput: String, + inputConnection: InputConnection, + ) { + if (commandModeOutput.isNotEmpty()) { + val output = if (!commandModeOutput.endsWith(" ")) "$commandModeOutput " else commandModeOutput + inputConnection.commitText(output, COMMIT_TEXT_CURSOR_POSITION) + suggestionHandler.processLinguisticSuggestions(output.trim()) + } + binding.commandBar.setText("") moveToIdleState() } /** - * Handles the change of input mode in the keyboard. - * This function is responsible for switching between different input modes - * such as alphabetic, numeric, or symbolic modes. - * - * @param newMode The new input mode to switch to. + * Handles switching between the letter and symbol keyboards. + * @param keyboardMode The current keyboard mode (letters or symbols). + * @param keyboardView The instance of the keyboard view. + * @param context The application context. */ fun handleModeChange( keyboardMode: Int, @@ -1889,17 +1659,15 @@ abstract class GeneralKeyboardIME( getKeyboardLayoutXML() } keyboard = KeyboardBase(context, keyboardXml, enterKeyType) - keyboardView?.invalidateAllKeys() keyboardView?.setKeyboard(keyboard!!) + keyboardView?.requestLayout() } /** - * Handles the input of keyboard letters. - * - * This function processes the input from the keyboard when letters are typed. - * It performs necessary actions based on the input letters. - * - * @param input The input string containing the letters typed on the keyboard. + * Handles the logic for the Shift key. It cycles through shift states (off, on-for-one-char, caps lock) + * on the letter keyboard, and toggles between symbol pages on the symbol keyboard. + * @param keyboardMode The current keyboard mode. + * @param keyboardView The instance of the keyboard view. */ fun handleKeyboardLetters( keyboardMode: Int, @@ -1910,17 +1678,19 @@ abstract class GeneralKeyboardIME( keyboard!!.mShiftState == SHIFT_ON_PERMANENT -> { keyboard!!.mShiftState = SHIFT_OFF } + System.currentTimeMillis() - lastShiftPressTS < shiftPermToggleSpeed -> { keyboard!!.mShiftState = SHIFT_ON_PERMANENT } + keyboard!!.mShiftState == SHIFT_ON_ONE_CHAR -> { keyboard!!.mShiftState = SHIFT_OFF } + keyboard!!.mShiftState == SHIFT_OFF -> { keyboard!!.mShiftState = SHIFT_ON_ONE_CHAR } } - lastShiftPressTS = System.currentTimeMillis() } else { val keyboardXml = @@ -1937,68 +1707,64 @@ abstract class GeneralKeyboardIME( } /** - * Handles the delete action from the command bar. - * - * @param binding The binding object for the keyboard view. This can be null. + * Handles the delete key press specifically for the command bar text field. */ - private fun handleCommandBarDelete(binding: KeyboardViewKeyboardBinding?) { - binding?.commandBar?.let { commandBar -> - var newText = "" - if (commandBar.text.length <= DATA_SIZE_2) { - binding.promptTextBorder.visibility = View.VISIBLE - binding.commandBar.setPadding( - binding.commandBar.paddingRight, - binding.commandBar.paddingTop, - binding.commandBar.paddingRight, - binding.commandBar.paddingBottom, - ) - if (language == "German" && this.currentState == ScribeState.PLURAL) { - keyboard?.mShiftState = SHIFT_ON_ONE_CHAR - } - } else { - newText = "${commandBar.text.trim().dropLast(DATA_SIZE_2)}$commandCursor" + private fun handleCommandBarDelete() { + val commandBar = binding.commandBar + val start = commandBar.selectionStart + if (start > 0) { + commandBar.text.delete(start - 1, start) + } + + if (commandBar.text.isEmpty()) { + binding.commandBar.setPadding( + binding.commandBar.paddingRight, + commandBar.paddingTop, + binding.commandBar.paddingRight, + commandBar.paddingBottom, + ) + + if ( + language == "German" && + this.currentState == ScribeState.PLURAL + ) { + keyboard?.mShiftState = SHIFT_ON_ONE_CHAR } - commandBar.setText(newText) - commandBar.setSelection(newText.length) } } /** - * To return the conjugation based on the code + * 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 = keyboardView?.getKeyLabel(code) if (!isSubsequentRequired) { - val inputConnection = currentInputConnection - inputConnection.commitText(keyboardView?.getKeyLabel(code), 1) + currentInputConnection?.commitText(keyLabel, 1) + suggestionHandler.processLinguisticSuggestions(keyLabel) } - return keyboardView?.getKeyLabel(code) + return keyLabel } /** - * Handles the delete key press event. - * This function is responsible for managing the behavior when the delete key is pressed - * on the keyboard. It ensures that the appropriate actions are taken to delete the - * selected text or character. + * Handles the logic for the Delete/Backspace key. It deletes characters from either + * the main input field or the command bar, depending on the context. + * @param isCommandBar `true` if the deletion should happen in the command bar. */ - fun handleDelete( - currentState: Boolean? = false, - binding: KeyboardViewKeyboardBinding? = null, - ) { - val wordBeforeCursor = getText() - val inputConnection = currentInputConnection - if (keyboard!!.mShiftState == SHIFT_ON_ONE_CHAR) { - keyboard!!.mShiftState = SHIFT_OFF - } - - if (currentState == true) { - handleCommandBarDelete(binding) + fun handleDelete(isCommandBar: Boolean = false) { + if (keyboard!!.mShiftState == SHIFT_ON_ONE_CHAR) keyboard!!.mShiftState = SHIFT_OFF + if (isCommandBar) { + handleCommandBarDelete() } else { - val selectedText = inputConnection.getSelectedText(0) - if (TextUtils.isEmpty(selectedText)) { - if (isEmoji(wordBeforeCursor)) { + val inputConnection = currentInputConnection ?: return + if (TextUtils.isEmpty(inputConnection.getSelectedText(0))) { + if (isEmoji(getText())) { inputConnection.deleteSurroundingText(DATA_SIZE_2, 0) } else { inputConnection.deleteSurroundingText(1, 0) @@ -2006,8 +1772,7 @@ abstract class GeneralKeyboardIME( } else { inputConnection.commitText("", 1) } - val before = inputConnection.getTextBeforeCursor(1, 0)?.isEmpty() ?: true - if (before) { + if (inputConnection.getTextBeforeCursor(1, 0)?.isEmpty() != false) { keyboard!!.mShiftState = SHIFT_ON_ONE_CHAR keyboardView!!.invalidateAllKeys() } @@ -2015,114 +1780,63 @@ abstract class GeneralKeyboardIME( } /** - * Checks if the given word is an emoji. - * - * @param word The word to check, which can be null. - * @return True if the word is an emoji, false otherwise. - */ - private fun isEmoji(word: String?): Boolean { - if (word.isNullOrEmpty() || word.length < DATA_SIZE_2) { - return false - } - - val lastTwoChars = word.substring(word.length - DATA_SIZE_2) - val emojiRegex = Regex("[\\uD83C\\uDF00-\\uD83E\\uDDFF]|[\\u2600-\\u26FF]|[\\u2700-\\u27BF]") - return emojiRegex.containsMatchIn(lastTwoChars) - } - - /** - * Handles the else condition for the given context. - * - * This function is called when none of the specific conditions are met. - * It performs the necessary actions to handle the default case. - * These are the set of actions performed when the keyboard space , shift or such - * characters are clicked. - * - * @param context The context in which the else condition is being handled. + * Handles the input of any non-special character key (e.g., letters, numbers, punctuation). + * It commits the character to the main input field or the command bar. + * @param code The character code of the key. + * @param keyboardMode The current keyboard mode. + * @param commandBarState `true` if input should go to the command bar. */ fun handleElseCondition( code: Int, keyboardMode: Int, - binding: KeyboardViewKeyboardBinding?, commandBarState: Boolean = false, ) { val inputConnection = currentInputConnection ?: return var codeChar = code.toChar() - if (Character.isLetter(codeChar) && keyboard!!.mShiftState > SHIFT_OFF) { codeChar = Character.toUpperCase(codeChar) } - if (commandBarState) { - binding?.commandBar?.let { commandBar -> - if (commandBar.text.isEmpty()) { - binding.promptTextBorder.visibility = View.GONE - binding.commandBar.setPadding( - 0, - binding.commandBar.paddingTop, - binding.commandBar.paddingRight, - binding.commandBar.paddingBottom, - ) - } - val newText = "${commandBar.text}$codeChar" - commandBar.setText(newText) - commandBar.setSelection(newText.length) + val commandBar = binding.commandBar + if (commandBar.text.isEmpty()) { + binding.commandBar.setPadding( + 0, + commandBar.paddingTop, + commandBar.paddingRight, + commandBar.paddingBottom, + ) } + + commandBar.text.insert(commandBar.selectionStart, codeChar.toString()) } else { - // Handling space key logic. if (keyboardMode != keyboardLetters && code == KeyboardBase.KEYCODE_SPACE) { - binding?.commandBar?.setText(" ") val originalText = inputConnection.getExtractedText(ExtractedTextRequest(), 0).text inputConnection.commitText(codeChar.toString(), 1) val newText = inputConnection.getExtractedText(ExtractedTextRequest(), 0).text switchToLetters = originalText != newText } else { - binding?.commandBar?.append(codeChar.toString()) inputConnection.commitText(codeChar.toString(), 1) } } - if (keyboard!!.mShiftState == SHIFT_ON_ONE_CHAR && keyboardMode == keyboardLetters) { keyboard!!.mShiftState = SHIFT_OFF keyboardView!!.invalidateAllKeys() } } - /** - * Returns the XML layout resource ID for the base keyboard of the specified language. - * - * This function maps a given language name to its corresponding keyboard layout XML file. - * If the provided language is `null` or doesn't match any of the predefined options, - * the function defaults to returning the English keyboard layout. - * - * @param language The name of the language for which the base keyboard layout is requested. - * Expected values are: "English", "French", "German", "Italian", - * "Portuguese", "Russian", "Spanish", and "Swedish". - * - * @return The resource ID of the XML layout file for the corresponding keyboard. - */ - private fun baseKeyboardOfAnyLanguage(language: String?): Int = - when (language) { - "English" -> R.xml.keys_letters_english - "French" -> R.xml.keys_letters_french - "German" -> R.xml.keys_letters_german - "Italian" -> R.xml.keys_letters_italian - "Portuguese" -> R.xml.keys_letters_portuguese - "Russian" -> R.xml.keys_letters_russian - "Spanish" -> R.xml.keys_letters_spanish - "Swedish" -> R.xml.keys_letters_swedish - else -> R.xml.keys_letters_english - } - internal companion object { - private const val TAG = "ScribeKeyboardLog" const val DEFAULT_SHIFT_PERM_TOGGLE_SPEED = 500 const val TEXT_LENGTH = 20 - const val NOUN_TYPE_SIZE = 22f + const val NOUN_TYPE_SIZE = 20f const val SUGGESTION_SIZE = 15f const val DARK_THEME = "#aeb3be" const val LIGHT_THEME = "#4b4b4b" const val MAX_TEXT_LENGTH = 1000 const val COMMIT_TEXT_CURSOR_POSITION = 1 + private const val COMMAND_BUTTON_SPACING_DP = 4 + private const val EMOJI_SUGGESTION_THRESHOLD_ONE = 1 + private const val EMOJI_SUGGESTION_THRESHOLD_TWO = 2 + private const val EMOJI_SUGGESTION_THRESHOLD_THREE = 3 + private const val SEPARATOR_WIDTH = 0.5f } } diff --git a/app/src/main/java/be/scri/services/GermanKeyboardIME.kt b/app/src/main/java/be/scri/services/GermanKeyboardIME.kt index f57bcec3..0baa2c6d 100644 --- a/app/src/main/java/be/scri/services/GermanKeyboardIME.kt +++ b/app/src/main/java/be/scri/services/GermanKeyboardIME.kt @@ -3,18 +3,14 @@ package be.scri.services import android.text.InputType -import android.util.Log -import android.view.View import android.view.inputmethod.EditorInfo.IME_ACTION_NONE import be.scri.R -import be.scri.databinding.KeyboardViewCommandOptionsBinding import be.scri.helpers.KeyHandler import be.scri.helpers.KeyboardBase import be.scri.helpers.PreferencesHelper.getEnablePeriodAndCommaABC import be.scri.helpers.PreferencesHelper.getIsAccentCharacterDisabled import be.scri.helpers.PreferencesHelper.getIsPreviewEnabled import be.scri.helpers.PreferencesHelper.getIsVibrateEnabled -import be.scri.views.KeyboardView /** * The GermanKeyboardIME class provides the input method for the German language keyboard. @@ -26,10 +22,6 @@ class GermanKeyboardIME : GeneralKeyboardIME("German") { private fun isTablet(): Boolean = resources.configuration.smallestScreenWidthDp >= SMALLEST_SCREEN_WIDTH_TABLET - /** - * Returns the XML layout resource for the keyboard based on user preferences. - * @return The resource ID of the keyboard layout XML. - */ override fun getKeyboardLayoutXML(): Int = if (isTablet()) { R.xml.keys_letters_german_tablet @@ -49,61 +41,32 @@ class GermanKeyboardIME : GeneralKeyboardIME("German") { R.xml.keys_letter_german_without_period_and_comma } - override lateinit var binding: KeyboardViewCommandOptionsBinding - override var keyboardView: KeyboardView? = null + // Fulfill the abstract contract from GeneralKeyboardIME. + override val keyboardLetters: Int = 0 + override val keyboardSymbols: Int = 1 + override val keyboardSymbolShift: Int = 2 override var keyboard: KeyboardBase? = null - override var enterKeyType = IME_ACTION_NONE - override val keyboardLetters = 0 - override val keyboardSymbols = 1 - override val keyboardSymbolShift = 2 - override var lastShiftPressTS = 0L - override var keyboardMode = keyboardLetters - override var inputTypeClass = InputType.TYPE_CLASS_TEXT - override var switchToLetters = false - override var hasTextBeforeCursor = false + override var lastShiftPressTS: Long = 0L + override var keyboardMode: Int = keyboardLetters + override var inputTypeClass: Int = InputType.TYPE_CLASS_TEXT + override var enterKeyType: Int = IME_ACTION_NONE + override var switchToLetters: Boolean = false + override var hasTextBeforeCursor: Boolean = false - // Key handling logic extracted to a separate class - private val keyHandler = KeyHandler(this) + // REFACTOR_FIX: The 'binding' and 'keyboardView' properties are no longer abstract in the parent class, + // so we must remove the overrides here. They are now inherited directly. + // override lateinit var binding: KeyboardViewCommandOptionsBinding // REMOVED + // override var keyboardView: KeyboardView? = null // REMOVED - /** - * Creates and returns the input view for the keyboard. - * @return The root view of the keyboard layout. - */ - override fun onCreateInputView(): View { - binding = KeyboardViewCommandOptionsBinding.inflate(layoutInflater) - setupCommandBarTheme(binding) - val keyboardHolder = binding.root - Log.i("MY-TAG", "From German Keyboard IME") - keyboardView = binding.keyboardView - keyboardView!!.setKeyboard(keyboard!!) - keyboardView!!.setPreview = getIsPreviewEnabled(applicationContext, language) - keyboardView!!.setVibrate = getIsVibrateEnabled(applicationContext, language) - when (currentState) { - ScribeState.IDLE -> keyboardView!!.setEnterKeyColor(null) - else -> keyboardView!!.setEnterKeyColor(R.color.dark_scribe_blue) - } - keyboardView!!.setKeyboardHolder() - keyboardView!!.mOnKeyboardActionListener = this - initializeEmojiButtons() - updateUI() - return keyboardHolder + private val keyHandler by lazy { KeyHandler(this) } + + override fun onCreate() { + super.onCreate() + keyboardView?.setPreview = getIsPreviewEnabled(applicationContext, language) + keyboardView?.setVibrate = getIsVibrateEnabled(applicationContext, language) } - /** - * Handles key press events on the keyboard. - * @param code The key code of the pressed key. - */ override fun onKey(code: Int) { keyHandler.handleKey(code, language) } - - /** - * Initializes the keyboard and sets up the input view. - */ - override fun onCreate() { - super.onCreate() - keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) - onCreateInputView() - setupCommandBarTheme(binding) - } } diff --git a/app/src/main/java/be/scri/services/ItalianKeyboardIME.kt b/app/src/main/java/be/scri/services/ItalianKeyboardIME.kt index 2538c9d4..9fc7551e 100644 --- a/app/src/main/java/be/scri/services/ItalianKeyboardIME.kt +++ b/app/src/main/java/be/scri/services/ItalianKeyboardIME.kt @@ -3,17 +3,13 @@ package be.scri.services import android.text.InputType -import android.util.Log -import android.view.View import android.view.inputmethod.EditorInfo.IME_ACTION_NONE import be.scri.R -import be.scri.databinding.KeyboardViewCommandOptionsBinding import be.scri.helpers.KeyHandler import be.scri.helpers.KeyboardBase import be.scri.helpers.PreferencesHelper.getEnablePeriodAndCommaABC import be.scri.helpers.PreferencesHelper.getIsPreviewEnabled import be.scri.helpers.PreferencesHelper.getIsVibrateEnabled -import be.scri.views.KeyboardView /** * The ItalianKeyboardIME class provides the input method for the Italian language keyboard. @@ -25,10 +21,6 @@ class ItalianKeyboardIME : GeneralKeyboardIME("Italian") { private fun isTablet(): Boolean = resources.configuration.smallestScreenWidthDp >= SMALLEST_SCREEN_WIDTH_TABLET - /** - * Returns the XML layout resource for the keyboard based on user preferences. - * @return The resource ID of the keyboard layout XML. - */ override fun getKeyboardLayoutXML(): Int = when { isTablet() -> R.xml.keys_letters_italian_tablet @@ -36,57 +28,26 @@ class ItalianKeyboardIME : GeneralKeyboardIME("Italian") { else -> R.xml.keys_letter_italian_without_period_and_comma } - override lateinit var binding: KeyboardViewCommandOptionsBinding - override var keyboardView: KeyboardView? = null + override val keyboardLetters: Int = 0 + override val keyboardSymbols: Int = 1 + override val keyboardSymbolShift: Int = 2 override var keyboard: KeyboardBase? = null - override var enterKeyType = IME_ACTION_NONE - override val keyboardLetters = 0 - override val keyboardSymbols = 1 - override val keyboardSymbolShift = 2 - override var lastShiftPressTS = 0L - override var keyboardMode = keyboardLetters - override var inputTypeClass = InputType.TYPE_CLASS_TEXT - override var switchToLetters = false - override var hasTextBeforeCursor = false + override var lastShiftPressTS: Long = 0L + override var keyboardMode: Int = keyboardLetters + override var inputTypeClass: Int = InputType.TYPE_CLASS_TEXT + override var enterKeyType: Int = IME_ACTION_NONE + override var switchToLetters: Boolean = false + override var hasTextBeforeCursor: Boolean = false - // Key handling logic extracted to a separate class - private val keyHandler = KeyHandler(this) + private val keyHandler by lazy { KeyHandler(this) } - /** - * Creates and returns the input view for the keyboard. - * @return The root view of the keyboard layout. - */ - override fun onCreateInputView(): View { - binding = KeyboardViewCommandOptionsBinding.inflate(layoutInflater) - val keyboardHolder = binding.root - Log.i("MY-TAG", "From Italian Keyboard IME") - keyboardView = binding.keyboardView - keyboardView!!.setKeyboard(keyboard!!) - keyboardView!!.setPreview = getIsPreviewEnabled(applicationContext, language) - keyboardView!!.setVibrate = getIsVibrateEnabled(applicationContext, language) - keyboardView!!.setKeyboardHolder() - setupCommandBarTheme(binding) - keyboardView!!.mOnKeyboardActionListener = this - initializeEmojiButtons() - updateUI() - return keyboardHolder + override fun onCreate() { + super.onCreate() + keyboardView?.setPreview = getIsPreviewEnabled(applicationContext, language) + keyboardView?.setVibrate = getIsVibrateEnabled(applicationContext, language) } - /** - * Handles key press events on the keyboard. - * @param code The key code of the pressed key. - */ override fun onKey(code: Int) { keyHandler.handleKey(code, language) } - - /** - * Initializes the keyboard and sets up the input view. - */ - override fun onCreate() { - super.onCreate() - keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) - onCreateInputView() - setupCommandBarTheme(binding) - } } diff --git a/app/src/main/java/be/scri/services/PortugueseKeyboardIME.kt b/app/src/main/java/be/scri/services/PortugueseKeyboardIME.kt index 002c2c9f..59174554 100644 --- a/app/src/main/java/be/scri/services/PortugueseKeyboardIME.kt +++ b/app/src/main/java/be/scri/services/PortugueseKeyboardIME.kt @@ -3,17 +3,13 @@ package be.scri.services import android.text.InputType -import android.util.Log -import android.view.View import android.view.inputmethod.EditorInfo.IME_ACTION_NONE import be.scri.R -import be.scri.databinding.KeyboardViewCommandOptionsBinding import be.scri.helpers.KeyHandler import be.scri.helpers.KeyboardBase import be.scri.helpers.PreferencesHelper.getEnablePeriodAndCommaABC import be.scri.helpers.PreferencesHelper.getIsPreviewEnabled import be.scri.helpers.PreferencesHelper.getIsVibrateEnabled -import be.scri.views.KeyboardView /** * The PortugueseKeyboardIME class provides the input method for the Portuguese language keyboard. @@ -25,10 +21,6 @@ class PortugueseKeyboardIME : GeneralKeyboardIME("Portuguese") { private fun isTablet(): Boolean = resources.configuration.smallestScreenWidthDp >= SMALLEST_SCREEN_WIDTH_TABLET - /** - * Returns the XML layout resource for the keyboard based on user preferences. - * @return The resource ID of the keyboard layout XML. - */ override fun getKeyboardLayoutXML(): Int = when { isTablet() -> R.xml.keys_letters_portuguese_tablet @@ -36,61 +28,26 @@ class PortugueseKeyboardIME : GeneralKeyboardIME("Portuguese") { else -> R.xml.keys_letters_portuguese_without_period_and_comma } - override lateinit var binding: KeyboardViewCommandOptionsBinding - override var keyboardView: KeyboardView? = null + override val keyboardLetters: Int = 0 + override val keyboardSymbols: Int = 1 + override val keyboardSymbolShift: Int = 2 override var keyboard: KeyboardBase? = null - override var enterKeyType = IME_ACTION_NONE - override val keyboardLetters = 0 - override val keyboardSymbols = 1 - override val keyboardSymbolShift = 2 - override var lastShiftPressTS = 0L - override var keyboardMode = keyboardLetters - override var inputTypeClass = InputType.TYPE_CLASS_TEXT - override var switchToLetters = false - override var hasTextBeforeCursor = false + override var lastShiftPressTS: Long = 0L + override var keyboardMode: Int = keyboardLetters + override var inputTypeClass: Int = InputType.TYPE_CLASS_TEXT + override var enterKeyType: Int = IME_ACTION_NONE + override var switchToLetters: Boolean = false + override var hasTextBeforeCursor: Boolean = false - // Key handling logic extracted to a separate class - private val keyHandler = KeyHandler(this) + private val keyHandler by lazy { KeyHandler(this) } - /** - * Creates and returns the input view for the keyboard. - * @return The root view of the keyboard layout. - */ - override fun onCreateInputView(): View { - binding = KeyboardViewCommandOptionsBinding.inflate(layoutInflater) - setupCommandBarTheme(binding) - val keyboardHolder = binding.root - Log.i("MY-TAG", "From Portuguese Keyboard IME") - keyboardView = binding.keyboardView - keyboardView!!.setKeyboard(keyboard!!) - keyboardView!!.setPreview = getIsPreviewEnabled(applicationContext, language) - keyboardView!!.setVibrate = getIsVibrateEnabled(applicationContext, language) - when (currentState) { - ScribeState.IDLE -> keyboardView!!.setEnterKeyColor(null) - else -> keyboardView!!.setEnterKeyColor(R.color.dark_scribe_blue) - } - keyboardView!!.setKeyboardHolder() - keyboardView?.mOnKeyboardActionListener = this - initializeEmojiButtons() - updateUI() - return keyboardHolder + override fun onCreate() { + super.onCreate() + keyboardView?.setPreview = getIsPreviewEnabled(applicationContext, language) + keyboardView?.setVibrate = getIsVibrateEnabled(applicationContext, language) } - /** - * Handles key press events on the keyboard. - * @param code The key code of the pressed key. - */ override fun onKey(code: Int) { keyHandler.handleKey(code, language) } - - /** - * Initializes the keyboard and sets up the input view. - */ - override fun onCreate() { - super.onCreate() - keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) - onCreateInputView() - setupCommandBarTheme(binding) - } } diff --git a/app/src/main/java/be/scri/services/RussianKeyboardIME.kt b/app/src/main/java/be/scri/services/RussianKeyboardIME.kt index 20b6f4d1..1395229b 100644 --- a/app/src/main/java/be/scri/services/RussianKeyboardIME.kt +++ b/app/src/main/java/be/scri/services/RussianKeyboardIME.kt @@ -3,16 +3,13 @@ package be.scri.services import android.text.InputType -import android.view.View import android.view.inputmethod.EditorInfo.IME_ACTION_NONE import be.scri.R -import be.scri.databinding.KeyboardViewCommandOptionsBinding import be.scri.helpers.KeyHandler import be.scri.helpers.KeyboardBase import be.scri.helpers.PreferencesHelper.getEnablePeriodAndCommaABC import be.scri.helpers.PreferencesHelper.getIsPreviewEnabled import be.scri.helpers.PreferencesHelper.getIsVibrateEnabled -import be.scri.views.KeyboardView /** * The RussianKeyboardIME class provides the input method for the Russian language keyboard. @@ -24,10 +21,6 @@ class RussianKeyboardIME : GeneralKeyboardIME("Russian") { private fun isTablet(): Boolean = resources.configuration.smallestScreenWidthDp >= SMALLEST_SCREEN_WIDTH_TABLET - /** - * Returns the XML layout resource for the keyboard based on user preferences. - * @return The resource ID of the keyboard layout XML. - */ override fun getKeyboardLayoutXML(): Int = when { isTablet() -> R.xml.keys_letters_russian_tablet @@ -35,60 +28,26 @@ class RussianKeyboardIME : GeneralKeyboardIME("Russian") { else -> R.xml.keys_letters_russian_without_period_and_comma } - override lateinit var binding: KeyboardViewCommandOptionsBinding - override var keyboardView: KeyboardView? = null + override val keyboardLetters: Int = 0 + override val keyboardSymbols: Int = 1 + override val keyboardSymbolShift: Int = 2 override var keyboard: KeyboardBase? = null - override var enterKeyType = IME_ACTION_NONE - override val keyboardLetters = 0 - override val keyboardSymbols = 1 - override val keyboardSymbolShift = 2 - override var lastShiftPressTS = 0L - override var keyboardMode = keyboardLetters - override var inputTypeClass = InputType.TYPE_CLASS_TEXT - override var switchToLetters = false - override var hasTextBeforeCursor = false + override var lastShiftPressTS: Long = 0L + override var keyboardMode: Int = keyboardLetters + override var inputTypeClass: Int = InputType.TYPE_CLASS_TEXT + override var enterKeyType: Int = IME_ACTION_NONE + override var switchToLetters: Boolean = false + override var hasTextBeforeCursor: Boolean = false - // Key handling logic extracted to a separate class - private val keyHandler = KeyHandler(this) + private val keyHandler by lazy { KeyHandler(this) } - /** - * Creates and returns the input view for the keyboard. - * @return The root view of the keyboard layout. - */ - override fun onCreateInputView(): View { - binding = KeyboardViewCommandOptionsBinding.inflate(layoutInflater) - setupCommandBarTheme(binding) - val keyboardHolder = binding.root - keyboardView = binding.keyboardView - keyboardView!!.setKeyboard(keyboard!!) - keyboardView!!.setPreview = getIsPreviewEnabled(applicationContext, language) - keyboardView!!.setVibrate = getIsVibrateEnabled(applicationContext, language) - when (currentState) { - ScribeState.IDLE -> keyboardView!!.setEnterKeyColor(null) - else -> keyboardView!!.setEnterKeyColor(R.color.dark_scribe_blue) - } - keyboardView!!.setKeyboardHolder() - keyboardView?.mOnKeyboardActionListener = this - initializeEmojiButtons() - updateUI() - return keyboardHolder + override fun onCreate() { + super.onCreate() + keyboardView?.setPreview = getIsPreviewEnabled(applicationContext, language) + keyboardView?.setVibrate = getIsVibrateEnabled(applicationContext, language) } - /** - * Handles key press events on the keyboard. - * @param code The key code of the pressed key. - */ override fun onKey(code: Int) { keyHandler.handleKey(code, language) } - - /** - * Initializes the keyboard and sets up the input view. - */ - override fun onCreate() { - super.onCreate() - keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) - onCreateInputView() - setupCommandBarTheme(binding) - } } diff --git a/app/src/main/java/be/scri/services/SpanishKeyboardIME.kt b/app/src/main/java/be/scri/services/SpanishKeyboardIME.kt index cd792194..9f843852 100644 --- a/app/src/main/java/be/scri/services/SpanishKeyboardIME.kt +++ b/app/src/main/java/be/scri/services/SpanishKeyboardIME.kt @@ -3,18 +3,14 @@ package be.scri.services import android.text.InputType -import android.util.Log -import android.view.View import android.view.inputmethod.EditorInfo.IME_ACTION_NONE import be.scri.R -import be.scri.databinding.KeyboardViewCommandOptionsBinding import be.scri.helpers.KeyHandler import be.scri.helpers.KeyboardBase import be.scri.helpers.PreferencesHelper.getEnablePeriodAndCommaABC import be.scri.helpers.PreferencesHelper.getIsAccentCharacterDisabled import be.scri.helpers.PreferencesHelper.getIsPreviewEnabled import be.scri.helpers.PreferencesHelper.getIsVibrateEnabled -import be.scri.views.KeyboardView /** * The SpanishKeyboardIME class provides the input method for the Spanish language keyboard. @@ -26,10 +22,6 @@ class SpanishKeyboardIME : GeneralKeyboardIME("Spanish") { private fun isTablet(): Boolean = resources.configuration.smallestScreenWidthDp >= SMALLEST_SCREEN_WIDTH_TABLET - /** - * Returns the XML layout resource for the keyboard based on user preferences. - * @return The resource ID of the keyboard layout XML. - */ override fun getKeyboardLayoutXML(): Int = when { isTablet() -> R.xml.keys_letters_spanish_tablet @@ -46,61 +38,26 @@ class SpanishKeyboardIME : GeneralKeyboardIME("Spanish") { R.xml.keys_letter_spanish_without_period_and_comma } - override lateinit var binding: KeyboardViewCommandOptionsBinding - override var keyboardView: KeyboardView? = null + override val keyboardLetters: Int = 0 + override val keyboardSymbols: Int = 1 + override val keyboardSymbolShift: Int = 2 override var keyboard: KeyboardBase? = null - override var enterKeyType = IME_ACTION_NONE - override val keyboardLetters = 0 - override val keyboardSymbols = 1 - override val keyboardSymbolShift = 2 - override var lastShiftPressTS = 0L - override var keyboardMode = keyboardLetters - override var inputTypeClass = InputType.TYPE_CLASS_TEXT - override var switchToLetters = false - override var hasTextBeforeCursor = false + override var lastShiftPressTS: Long = 0L + override var keyboardMode: Int = keyboardLetters + override var inputTypeClass: Int = InputType.TYPE_CLASS_TEXT + override var enterKeyType: Int = IME_ACTION_NONE + override var switchToLetters: Boolean = false + override var hasTextBeforeCursor: Boolean = false - // Key handling logic extracted to a separate class - private val keyHandler = KeyHandler(this) + private val keyHandler by lazy { KeyHandler(this) } - /** - * Creates and returns the input view for the keyboard. - * @return The root view of the keyboard layout. - */ - override fun onCreateInputView(): View { - binding = KeyboardViewCommandOptionsBinding.inflate(layoutInflater) - val keyboardHolder = binding.root - Log.i("MY-TAG", "From Spanish Keyboard IME") - keyboardView = binding.keyboardView - keyboardView!!.setKeyboard(keyboard!!) - setupCommandBarTheme(binding) - keyboardView!!.setPreview = getIsPreviewEnabled(applicationContext, language) - keyboardView!!.setVibrate = getIsVibrateEnabled(applicationContext, language) - when (currentState) { - ScribeState.IDLE -> keyboardView!!.setEnterKeyColor(null) - else -> keyboardView!!.setEnterKeyColor(R.color.dark_scribe_blue) - } - keyboardView!!.setKeyboardHolder() - keyboardView!!.mOnKeyboardActionListener = this - initializeEmojiButtons() - updateUI() - return keyboardHolder + override fun onCreate() { + super.onCreate() + keyboardView?.setPreview = getIsPreviewEnabled(applicationContext, language) + keyboardView?.setVibrate = getIsVibrateEnabled(applicationContext, language) } - /** - * Handles key press events on the keyboard. - * @param code The key code of the pressed key. - */ override fun onKey(code: Int) { keyHandler.handleKey(code, language) } - - /** - * Initializes the keyboard and sets up the input view. - */ - override fun onCreate() { - super.onCreate() - keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) - onCreateInputView() - setupCommandBarTheme(binding) - } } diff --git a/app/src/main/java/be/scri/services/SwedishKeyboardIME.kt b/app/src/main/java/be/scri/services/SwedishKeyboardIME.kt index 53529695..ce6ac8f2 100644 --- a/app/src/main/java/be/scri/services/SwedishKeyboardIME.kt +++ b/app/src/main/java/be/scri/services/SwedishKeyboardIME.kt @@ -3,18 +3,14 @@ package be.scri.services import android.text.InputType -import android.util.Log -import android.view.View import android.view.inputmethod.EditorInfo.IME_ACTION_NONE import be.scri.R -import be.scri.databinding.KeyboardViewCommandOptionsBinding import be.scri.helpers.KeyHandler import be.scri.helpers.KeyboardBase import be.scri.helpers.PreferencesHelper.getEnablePeriodAndCommaABC import be.scri.helpers.PreferencesHelper.getIsAccentCharacterDisabled import be.scri.helpers.PreferencesHelper.getIsPreviewEnabled import be.scri.helpers.PreferencesHelper.getIsVibrateEnabled -import be.scri.views.KeyboardView /** * The SwedishKeyboardIME class provides the input method for the Swedish language keyboard. @@ -26,10 +22,6 @@ class SwedishKeyboardIME : GeneralKeyboardIME("Swedish") { private fun isTablet(): Boolean = resources.configuration.smallestScreenWidthDp >= SMALLEST_SCREEN_WIDTH_TABLET - /** - * Returns the XML layout resource for the keyboard based on user preferences. - * @return The resource ID of the keyboard layout XML. - */ override fun getKeyboardLayoutXML(): Int = when { isTablet() -> R.xml.keys_letters_swedish_tablet @@ -46,57 +38,26 @@ class SwedishKeyboardIME : GeneralKeyboardIME("Swedish") { R.xml.keys_letter_swedish_without_period_and_comma } - override lateinit var binding: KeyboardViewCommandOptionsBinding - override var keyboardView: KeyboardView? = null + override val keyboardLetters: Int = 0 + override val keyboardSymbols: Int = 1 + override val keyboardSymbolShift: Int = 2 override var keyboard: KeyboardBase? = null - override var enterKeyType = IME_ACTION_NONE - override val keyboardLetters = 0 - override val keyboardSymbols = 1 - override val keyboardSymbolShift = 2 - override var lastShiftPressTS = 0L - override var keyboardMode = keyboardLetters - override var inputTypeClass = InputType.TYPE_CLASS_TEXT - override var switchToLetters = false - override var hasTextBeforeCursor = false + override var lastShiftPressTS: Long = 0L + override var keyboardMode: Int = keyboardLetters + override var inputTypeClass: Int = InputType.TYPE_CLASS_TEXT + override var enterKeyType: Int = IME_ACTION_NONE + override var switchToLetters: Boolean = false + override var hasTextBeforeCursor: Boolean = false - // Key handling logic extracted to a separate class - private val keyHandler = KeyHandler(this) + private val keyHandler by lazy { KeyHandler(this) } - /** - * Creates and returns the input view for the keyboard. - * @return The root view of the keyboard layout. - */ - override fun onCreateInputView(): View { - binding = KeyboardViewCommandOptionsBinding.inflate(layoutInflater) - val keyboardHolder = binding.root - Log.i("MY-TAG", "From Swedish Keyboard IME") - keyboardView = binding.keyboardView - keyboardView!!.setKeyboard(keyboard!!) - setupCommandBarTheme(binding) - keyboardView!!.setPreview = getIsPreviewEnabled(applicationContext, language) - keyboardView!!.setVibrate = getIsVibrateEnabled(applicationContext, language) - keyboardView!!.setKeyboardHolder() - keyboardView!!.mOnKeyboardActionListener = this - initializeEmojiButtons() - updateUI() - return keyboardHolder + override fun onCreate() { + super.onCreate() + keyboardView?.setPreview = getIsPreviewEnabled(applicationContext, language) + keyboardView?.setVibrate = getIsVibrateEnabled(applicationContext, language) } - /** - * Handles key press events on the keyboard. - * @param code The key code of the pressed key. - */ override fun onKey(code: Int) { keyHandler.handleKey(code, language) } - - /** - * Initializes the keyboard and sets up the input view. - */ - override fun onCreate() { - super.onCreate() - keyboard = KeyboardBase(this, getKeyboardLayoutXML(), enterKeyType) - onCreateInputView() - setupCommandBarTheme(binding) - } } diff --git a/app/src/main/java/be/scri/ui/screens/about/AboutScreen.kt b/app/src/main/java/be/scri/ui/screens/about/AboutScreen.kt index c7a1c230..61881ed4 100644 --- a/app/src/main/java/be/scri/ui/screens/about/AboutScreen.kt +++ b/app/src/main/java/be/scri/ui/screens/about/AboutScreen.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import be.scri.R -import be.scri.helpers.HintUtils +import be.scri.helpers.ui.HintUtils import be.scri.ui.common.ScribeBaseScreen import be.scri.ui.common.components.ItemCardContainerWithTitle import be.scri.ui.screens.about.AboutUtil.getCommunityList diff --git a/app/src/main/java/be/scri/ui/screens/about/AboutUtil.kt b/app/src/main/java/be/scri/ui/screens/about/AboutUtil.kt index 23fe5b7b..c6cee266 100644 --- a/app/src/main/java/be/scri/ui/screens/about/AboutUtil.kt +++ b/app/src/main/java/be/scri/ui/screens/about/AboutUtil.kt @@ -9,8 +9,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import be.scri.R import be.scri.activities.MainActivity -import be.scri.helpers.RatingHelper -import be.scri.helpers.ShareHelper +import be.scri.helpers.ui.RatingHelper +import be.scri.helpers.ui.ShareHelper import be.scri.ui.models.ScribeItem import be.scri.ui.models.ScribeItemList diff --git a/app/src/main/java/be/scri/views/CustomDividerItemDecoration.kt b/app/src/main/java/be/scri/views/CustomDividerItemDecoration.kt deleted file mode 100644 index ad932c18..00000000 --- a/app/src/main/java/be/scri/views/CustomDividerItemDecoration.kt +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -import android.graphics.Canvas -import android.graphics.Rect -import android.graphics.drawable.Drawable -import android.view.View -import androidx.recyclerview.widget.RecyclerView - -/** - * A custom divider for use in recycle views. - */ -class CustomDividerItemDecoration( - private val drawable: Drawable, - private val width: Int, - private val marginLeft: Int, - private val marginRight: Int, -) : RecyclerView.ItemDecoration() { - override fun onDraw( - canvas: Canvas, - parent: RecyclerView, - state: RecyclerView.State, - ) { - val left = parent.paddingLeft + marginLeft - val right = parent.width - parent.paddingRight - marginRight - - val childCount = parent.childCount - for (i in 0 until childCount - 1) { - val child = parent.getChildAt(i) - val params = child.layoutParams as RecyclerView.LayoutParams - val top = child.bottom + params.bottomMargin - val bottom = top + width - - drawable.setBounds(left, top, right, bottom) - drawable.draw(canvas) - } - } - - override fun getItemOffsets( - outRect: Rect, - view: View, - parent: RecyclerView, - state: RecyclerView.State, - ) { - outRect.set(0, 0, 0, width) - } -} diff --git a/app/src/main/res/layout/actionbar_title.xml b/app/src/main/res/layout/actionbar_title.xml deleted file mode 100644 index 42bff3b4..00000000 --- a/app/src/main/res/layout/actionbar_title.xml +++ /dev/null @@ -1,10 +0,0 @@ - - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 0f346dc9..00000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/card_view_text.xml b/app/src/main/res/layout/card_view_text.xml deleted file mode 100644 index 727687ad..00000000 --- a/app/src/main/res/layout/card_view_text.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/card_view_with_image.xml b/app/src/main/res/layout/card_view_with_image.xml deleted file mode 100644 index 3db11ee9..00000000 --- a/app/src/main/res/layout/card_view_with_image.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/card_view_with_switch.xml b/app/src/main/res/layout/card_view_with_switch.xml deleted file mode 100644 index 2a155346..00000000 --- a/app/src/main/res/layout/card_view_with_switch.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/custom_action_bar_layout.xml b/app/src/main/res/layout/custom_action_bar_layout.xml deleted file mode 100644 index 4322653c..00000000 --- a/app/src/main/res/layout/custom_action_bar_layout.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - -