// SPDX-License-Identifier: GPL-3.0-only package helium314.keyboard.settings.screens import android.content.ContentResolver import android.content.Context import android.database.Cursor import android.provider.UserDictionary import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import helium314.keyboard.latin.R import helium314.keyboard.latin.common.LocaleUtils.localizedDisplayName import helium314.keyboard.settings.SearchScreen import helium314.keyboard.settings.dialogs.DropDownField import helium314.keyboard.settings.dialogs.ThreeButtonAlertDialog import java.util.Locale @Composable fun PersonalDictionaryScreen( onClickBack: () -> Unit, locale: Locale? ) { val ctx = LocalContext.current val words = getAll(locale, ctx) var selectedWord: Word? by remember { mutableStateOf(null) } SearchScreen( onClickBack = onClickBack, title = { Column { Text(stringResource(R.string.edit_personal_dictionary)) Text( locale.getLocaleDisplayNameForUserDictSettings(ctx), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } }, filteredItems = { term -> // we could maybe to this using a query and getting items by position // requires adjusting the SearchScreen, likely not worth the effort words.filter { it.word.startsWith(term, true) || it.shortcut?.startsWith(term, true) == true } }, itemContent = { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxWidth() .clickable { selectedWord = it } .padding(vertical = 6.dp, horizontal = 16.dp) ) { Column { Text(it.word, style = MaterialTheme.typography.bodyLarge) val details = if (it.shortcut == null) it.weight.toString() else "${it.weight} | ${it.shortcut}" Text(details, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } Icon(painterResource(R.drawable.ic_edit), stringResource(R.string.user_dict_settings_edit_dialog_title)) } } ) if (selectedWord != null) { val selWord = selectedWord!! var newWord by remember { mutableStateOf(selWord) } var newLocale by remember { mutableStateOf(locale) } val wordValid = (newWord.word == selWord.word && locale == newLocale) || !doesWordExist(newWord.word, newLocale, ctx) ThreeButtonAlertDialog( onDismissRequest = { selectedWord = null }, onConfirmed = { if (newWord != selWord || locale != newLocale) { deleteWord(selWord, locale, ctx.contentResolver) val saveWeight = newWord.weight ?: WEIGHT_FOR_USER_DICTIONARY_ADDS UserDictionary.Words.addWord(ctx, newWord.word, saveWeight, newWord.shortcut, newLocale) } }, checkOk = { newWord.word.isNotBlank() && wordValid }, confirmButtonText = stringResource(R.string.save), neutralButtonText = stringResource(R.string.delete), onNeutral = { deleteWord(selWord, locale, ctx.contentResolver) // delete the originally selected word selectedWord = null }, title = { Column { Text(stringResource(R.string.user_dict_settings_edit_dialog_title)) Text( locale.getLocaleDisplayNameForUserDictSettings(ctx), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } }, content = { Column( verticalArrangement = Arrangement.spacedBy(8.dp) ) { TextField( value = newWord.word, onValueChange = { newWord = newWord.copy(word = it) }, modifier = Modifier.fillMaxWidth(), singleLine = true ) Row( verticalAlignment = Alignment.CenterVertically ) { Text(stringResource(R.string.user_dict_settings_add_shortcut_option_name), Modifier.fillMaxWidth(0.3f)) TextField( value = newWord.shortcut ?: "", onValueChange = { newWord = newWord.copy(shortcut = it.ifBlank { null }) }, label = { Text(stringResource(R.string.user_dict_settings_add_shortcut_hint))}, modifier = Modifier.weight(1f), singleLine = true ) } Row( verticalAlignment = Alignment.CenterVertically ) { Text(stringResource(R.string.user_dict_settings_add_weight_value), Modifier.fillMaxWidth(0.3f)) TextField( newWord.weight?.toString() ?: "", { if (it.isBlank()) newWord = newWord.copy(weight = null) else if ((it.toIntOrNull() ?: -1) in 0..255) newWord = newWord.copy(weight = it.toInt()) }, label = { Text(WEIGHT_FOR_USER_DICTIONARY_ADDS.toString()) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f), singleLine = true ) } Row( verticalAlignment = Alignment.CenterVertically ) { Text(stringResource(R.string.user_dict_settings_add_locale_option_name), Modifier.fillMaxWidth(0.3f)) DropDownField( items = getSpecificallySortedLocales(locale), selectedItem = newLocale, onSelected = { newLocale = it }, ) { Text(it.getLocaleDisplayNameForUserDictSettings(ctx)) } } if (!wordValid) Text( stringResource(R.string.user_dict_word_already_present, newLocale.getLocaleDisplayNameForUserDictSettings(ctx)), color = MaterialTheme.colorScheme.error ) } } ) } ExtendedFloatingActionButton( onClick = { selectedWord = Word("", null, null) }, text = { Text(stringResource(R.string.user_dict_add_word_button)) }, icon = { Icon(painter = painterResource(R.drawable.ic_edit), stringResource(R.string.user_dict_add_word_button)) }, modifier = Modifier.wrapContentSize(Alignment.BottomEnd).padding(all = 12.dp) ) } private fun deleteWord(wordDetails: Word, locale: Locale?, resolver: ContentResolver) { val (word, shortcut, weightInt) = wordDetails val weight = weightInt.toString() if (shortcut.isNullOrBlank()) { if (locale == null) { resolver.delete( UserDictionary.Words.CONTENT_URI, DELETE_SELECTION_WITHOUT_SHORTCUT_AND_WITH_ALL_LOCALES, arrayOf(word, weight) ) } else { resolver.delete( // requires use of locale string for interaction with Android system UserDictionary.Words.CONTENT_URI, DELETE_SELECTION_WITHOUT_SHORTCUT_AND_WITH_LOCALE, arrayOf(word, weight, locale.toString()) ) } } else { if (locale == null) { resolver.delete( UserDictionary.Words.CONTENT_URI, DELETE_SELECTION_WITH_SHORTCUT_AND_WITH_ALL_LOCALES, arrayOf(word, shortcut, weight) ) } else { resolver.delete( // requires use of locale string for interaction with Android system UserDictionary.Words.CONTENT_URI, DELETE_SELECTION_WITH_SHORTCUT_AND_WITH_LOCALE, arrayOf(word, shortcut, weight, locale.toString()) ) } } } private fun doesWordExist(word: String, locale: Locale?, context: Context): Boolean { val hasWordProjection = arrayOf(UserDictionary.Words.WORD, UserDictionary.Words.LOCALE) val select: String val selectArgs: Array? if (locale == null) { select = "${UserDictionary.Words.WORD}=? AND ${UserDictionary.Words.LOCALE} is null" selectArgs = arrayOf(word) } else { select = "${UserDictionary.Words.WORD}=? AND ${UserDictionary.Words.LOCALE}=?" // requires use of locale string (as opposed to more useful language tag) for interaction with Android system selectArgs = arrayOf(word, locale.toString()) } val cursor = context.contentResolver.query(UserDictionary.Words.CONTENT_URI, hasWordProjection, select, selectArgs, null) cursor.use { if (null == it) return false return it.count > 0 } } private fun getSpecificallySortedLocales(firstLocale: Locale?): List { val list: MutableList = getSortedDictionaryLocales().toMutableList() list.remove(firstLocale) list.remove(null) list.add(0, firstLocale) if (firstLocale != null) list.add(null) return list } fun Locale?.getLocaleDisplayNameForUserDictSettings(context: Context) = this?.localizedDisplayName(context) ?: context.resources.getString(R.string.user_dict_settings_all_languages) // weight is frequency but different name towards user private data class Word(val word: String, val shortcut: String?, val weight: Int?) // getting all words instead of reading directly cursor, because filteredItems expects a list private fun getAll(locale: Locale?, context: Context): List { val cursor = createCursor(locale, context) ?: return emptyList() if (!cursor.moveToFirst()) return emptyList() val result = mutableListOf() val wordIndex = cursor.getColumnIndexOrThrow(UserDictionary.Words.WORD) val shortcutIndex = cursor.getColumnIndexOrThrow(UserDictionary.Words.SHORTCUT) val frequencyIndex = cursor.getColumnIndexOrThrow(UserDictionary.Words.FREQUENCY) while (!cursor.isAfterLast) { result.add(Word(cursor.getString(wordIndex), cursor.getString(shortcutIndex), cursor.getInt(frequencyIndex))) cursor.moveToNext() } cursor.close() return result } private fun createCursor(locale: Locale?, context: Context): Cursor? { // locale can be any of: // - An actual locale, for use of Locale#toString() // - The emptyLocale. This means we want a cursor returning words valid for all locales. // Note that this contrasts with the data inside the database, where NULL means "all // locales" and there should never be an empty string. // The confusion is called by the historical use of null for "all locales". val select: String val selectArgs: Array? if (locale == null) { select = QUERY_SELECTION_ALL_LOCALES selectArgs = null } else { select = QUERY_SELECTION // requires use of locale string (as opposed to more useful language tag) for interaction with Android system selectArgs = arrayOf(locale.toString()) } return context.contentResolver.query( UserDictionary.Words.CONTENT_URI, QUERY_PROJECTION, select, selectArgs, SORT_ORDER ) } private val QUERY_PROJECTION = arrayOf(UserDictionary.Words._ID, UserDictionary.Words.WORD, UserDictionary.Words.SHORTCUT, UserDictionary.Words.FREQUENCY) // Case-insensitive sort private const val SORT_ORDER = "UPPER(" + UserDictionary.Words.WORD + ")" // Either the locale is empty (means the word is applicable to all locales) // or the word equals our current locale private const val QUERY_SELECTION = UserDictionary.Words.LOCALE + "=?" private const val QUERY_SELECTION_ALL_LOCALES = UserDictionary.Words.LOCALE + " is null" private const val DELETE_SELECTION_WITH_SHORTCUT_AND_WITH_LOCALE = (UserDictionary.Words.WORD + "=? AND " + UserDictionary.Words.SHORTCUT + "=? AND " + UserDictionary.Words.FREQUENCY + "=? AND " + UserDictionary.Words.LOCALE + "=?") private const val DELETE_SELECTION_WITH_SHORTCUT_AND_WITH_ALL_LOCALES = (UserDictionary.Words.WORD + "=? AND " + UserDictionary.Words.SHORTCUT + "=? AND " + UserDictionary.Words.FREQUENCY + "=? AND " + UserDictionary.Words.LOCALE + " is null") private const val DELETE_SELECTION_WITHOUT_SHORTCUT_AND_WITH_LOCALE = (UserDictionary.Words.WORD + "=? AND " + UserDictionary.Words.SHORTCUT + " is null AND " + UserDictionary.Words.FREQUENCY + "=? AND " + UserDictionary.Words.LOCALE + "=? OR " + UserDictionary.Words.SHORTCUT + "='' AND " + UserDictionary.Words.FREQUENCY + "=? AND " + UserDictionary.Words.LOCALE + "=?") private const val DELETE_SELECTION_WITHOUT_SHORTCUT_AND_WITH_ALL_LOCALES = (UserDictionary.Words.WORD + "=? AND " + UserDictionary.Words.SHORTCUT + " is null AND " + UserDictionary.Words.FREQUENCY + "=? AND " + UserDictionary.Words.LOCALE + " is null OR " + UserDictionary.Words.SHORTCUT + "='' AND " + UserDictionary.Words.FREQUENCY + "=? AND " + UserDictionary.Words.LOCALE + " is null") private const val WEIGHT_FOR_USER_DICTIONARY_ADDS = 250