compose PersonalDictionariesScreen

This commit is contained in:
Helium314 2025-02-23 11:38:06 +01:00
parent 52c887e941
commit 86f15e11d8
6 changed files with 450 additions and 8 deletions

View file

@ -186,6 +186,10 @@ object LocaleUtils {
return getLocaleDisplayNameInLocale(locale, context.resources, context.resources.configuration.locale())
}
// todo: use this instead of getLocaleDisplayNameInSystemLocale in .kt files
fun Locale.localizedDisplayName(context: Context) =
getLocaleDisplayNameInLocale(this, context.resources, context.resources.configuration.locale())
@JvmStatic
fun getLocaleDisplayNameInLocale(locale: Locale, resources: Resources, displayLocale: Locale): String {
val languageTag = locale.toLanguageTag()

View file

@ -103,7 +103,7 @@ fun SearchSettingsScreen(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun <T: Any> SearchScreen(
fun <T: Any?> SearchScreen(
onClickBack: () -> Unit,
title: @Composable () -> Unit,
filteredItems: (String) -> List<T>,

View file

@ -10,6 +10,8 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import helium314.keyboard.latin.common.LocaleUtils.constructLocale
import helium314.keyboard.latin.utils.Log
import helium314.keyboard.settings.screens.AboutScreen
import helium314.keyboard.settings.screens.AdvancedSettingsScreen
import helium314.keyboard.settings.screens.AppearanceScreen
@ -19,6 +21,8 @@ import helium314.keyboard.settings.screens.DictionaryScreen
import helium314.keyboard.settings.screens.GestureTypingScreen
import helium314.keyboard.settings.screens.LanguageScreen
import helium314.keyboard.settings.screens.MainSettingsScreen
import helium314.keyboard.settings.screens.PersonalDictionariesScreen
import helium314.keyboard.settings.screens.PersonalDictionaryScreen
import helium314.keyboard.settings.screens.PreferencesScreen
import helium314.keyboard.settings.screens.SecondaryLayoutScreen
import helium314.keyboard.settings.screens.TextCorrectionScreen
@ -28,6 +32,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import java.util.Locale
@Composable
fun SettingsNavHost(
@ -89,10 +94,15 @@ fun SettingsNavHost(
composable(SettingsDestination.Appearance) {
AppearanceScreen(onClickBack = ::goBack)
}
composable(SettingsDestination.PersonalDictionary) {
// PersonalDictionarySettingsScreen(
// onClickBack = ::goBack
// )
composable(SettingsDestination.PersonalDictionary + "{locale}") {
val locale = it.arguments?.getString("locale")?.takeIf { it.isNotBlank() }?.constructLocale()
PersonalDictionaryScreen(
onClickBack = ::goBack,
locale = locale
)
}
composable(SettingsDestination.PersonalDictionaries) {
PersonalDictionariesScreen(onClickBack = ::goBack)
}
composable(SettingsDestination.Languages) {
LanguageScreen(onClickBack = ::goBack)
@ -126,7 +136,8 @@ object SettingsDestination {
const val Appearance = "appearance"
const val Colors = "colors"
const val ColorsNight = "colors_night"
const val PersonalDictionary = "personal_dictionary"
const val PersonalDictionaries = "personal_dictionaries"
const val PersonalDictionary = "personal_dictionary/"
const val Languages = "languages"
const val Layouts = "layouts"
const val Dictionaries = "dictionaries"

View file

@ -0,0 +1,98 @@
package helium314.keyboard.settings.screens
import android.content.Context
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import helium314.keyboard.latin.R
import helium314.keyboard.latin.common.splitOnWhitespace
import helium314.keyboard.latin.settings.Defaults
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.utils.SubtypeLocaleUtils
import helium314.keyboard.latin.utils.SubtypeSettings.getEnabledSubtypes
import helium314.keyboard.latin.utils.SubtypeSettings.getSystemLocales
import helium314.keyboard.latin.utils.getSecondaryLocales
import helium314.keyboard.latin.utils.locale
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.SearchScreen
import helium314.keyboard.settings.SettingsDestination
import java.util.Locale
import java.util.TreeSet
@Composable
fun PersonalDictionariesScreen(
onClickBack: () -> Unit,
) {
// todo: consider adding "add word" button like old settings (requires additional navigation parameter, should not be hard)
val ctx = LocalContext.current
val locales: MutableList<Locale?> = getSortedDictionaryLocales(LocalContext.current).toMutableList()
locales.add(0, null)
SearchScreen(
onClickBack = onClickBack,
title = { Text(stringResource(R.string.edit_personal_dictionary)) },
filteredItems = { term ->
locales.filter {
it.getLocaleDisplayNameForUserDictSettings(ctx).replace("(", "")
.splitOnWhitespace().any { it.startsWith(term, true) }
}
},
itemContent = {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
SettingsDestination.navigateTo(SettingsDestination.PersonalDictionary + (it?.toLanguageTag() ?: ""))
}
.heightIn(min = 44.dp)
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(it.getLocaleDisplayNameForUserDictSettings(ctx), style = MaterialTheme.typography.bodyLarge)
Icon(
painter = painterResource(R.drawable.ic_arrow_left),
modifier = Modifier.scale(-1f, 1f),
contentDescription = null
)
}
}
)
}
fun getSortedDictionaryLocales(context: Context): TreeSet<Locale> {
val prefs = context.prefs()
val localeSystemOnly = prefs.getBoolean(Settings.PREF_USE_SYSTEM_LOCALES, Defaults.PREF_USE_SYSTEM_LOCALES)
val sortedLocales = sortedSetOf<Locale>(compareBy { it.toLanguageTag().lowercase() })
// Add the main language selected in the "Language and Layouts" setting except "No language"
for (mainSubtype in getEnabledSubtypes(prefs, true)) {
val mainLocale = mainSubtype.locale()
if (mainLocale.toLanguageTag() != SubtypeLocaleUtils.NO_LANGUAGE) {
sortedLocales.add(mainLocale)
}
// Secondary language is added only if main language is selected and if system language is not enabled
if (!localeSystemOnly) {
val enabled = getEnabledSubtypes(prefs, false)
for (subtype in enabled) {
if (subtype.locale() == mainLocale) sortedLocales.addAll(getSecondaryLocales(subtype.extraValue))
}
}
}
sortedLocales.addAll(getSystemLocales())
return sortedLocales
}

View file

@ -0,0 +1,328 @@
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
)
}
},
text = {
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(ctx, 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(context: Context, firstLocale: Locale?): List<Locale?> {
val list: MutableList<Locale?> = getSortedDictionaryLocales(context).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

View file

@ -37,6 +37,8 @@ import helium314.keyboard.settings.Setting
import helium314.keyboard.settings.preferences.Preference
import helium314.keyboard.settings.SearchSettingsScreen
import helium314.keyboard.settings.SettingsActivity
import helium314.keyboard.settings.SettingsDestination
import helium314.keyboard.settings.SettingsNavHost
import helium314.keyboard.settings.preferences.SwitchPreference
import helium314.keyboard.settings.Theme
import helium314.keyboard.settings.dialogs.ConfirmationDialog
@ -83,10 +85,9 @@ fun TextCorrectionScreen(
fun createCorrectionSettings(context: Context) = listOf(
Setting(context, SettingsWithoutKey.EDIT_PERSONAL_DICTIONARY, R.string.edit_personal_dictionary) {
val ctx = LocalContext.current
Preference(
name = stringResource(R.string.edit_personal_dictionary),
onClick = { ctx.getActivity()?.switchTo(UserDictionaryListFragment()) },
onClick = { SettingsDestination.navigateTo(SettingsDestination.PersonalDictionaries) },
) {
Icon(
painter = painterResource(R.drawable.ic_arrow_left),