mirror of
https://github.com/Helium314/HeliBoard.git
synced 2025-04-24 08:36:26 +00:00
329 lines
15 KiB
Kotlin
329 lines
15 KiB
Kotlin
// 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.DropDownField
|
|
import helium314.keyboard.settings.SearchScreen
|
|
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<String>?
|
|
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<Locale?> {
|
|
val list: MutableList<Locale?> = 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<Word> {
|
|
val cursor = createCursor(locale, context) ?: return emptyList()
|
|
|
|
if (!cursor.moveToFirst()) return emptyList()
|
|
val result = mutableListOf<Word>()
|
|
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<String>?
|
|
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
|