Settings upgrade (#1325)

Re-implement most of the settings in compose, searchable
Languages & Layouts, Colors, and Personal Dictionary still missing, will be done later
This commit is contained in:
Helium314 2025-02-09 13:27:55 +01:00 committed by GitHub
parent 679754bb2d
commit e845e38e42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 4708 additions and 24 deletions

View file

@ -105,7 +105,6 @@ __Planned features and improvements:__
* [Bug fixes](https://github.com/Helium314/HeliBoard/issues?q=is%3Aissue+is%3Aopen+label%3Abug)
__What will _not_ be added:__
* Material 3 (not worth adding 1.5 MB to app size)
* Dictionaries for more languages (you can still download them)
* Anything that requires additional permissions, unless there is a _very_ good reason

View file

@ -2,6 +2,7 @@ plugins {
id("com.android.application")
kotlin("android")
kotlin("plugin.serialization") version "2.0.21"
kotlin("plugin.compose") version "2.0.0"
}
android {
@ -49,6 +50,7 @@ android {
buildFeatures {
viewBinding = true
buildConfig = true
compose = true
}
externalNativeBuild {
@ -105,6 +107,16 @@ dependencies {
// kotlin
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
// compose
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
implementation(platform("androidx.compose:compose-bom:2024.10.01"))
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("sh.calvin.reorderable:reorderable:2.4.2") // for easier re-ordering
implementation("com.github.skydoves:colorpicker-compose:1.1.2") // for user-defined colors
// color picker for user-defined colors
implementation("com.github.martin-stone:hsv-alpha-color-picker-android:3.1.0")

View file

@ -74,10 +74,10 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only
</intent-filter>
</activity>
<activity android:name=".settings.SettingsActivity"
<activity android:name="helium314.keyboard.settings.SettingsActivity"
android:theme="@style/platformActivityTheme"
android:windowSoftInputMode="adjustResize"
android:label="@string/ime_settings"
android:excludeFromRecents="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View file

@ -113,6 +113,8 @@ public final class KeyboardSwitcher implements KeyboardState.SwitchActions {
}
public void forceUpdateKeyboardTheme(@NonNull Context displayContext) {
Settings settings = Settings.getInstance();
settings.loadSettings(displayContext, settings.getCurrent().mLocale, settings.getCurrent().mInputAttributes);
mLatinIME.setInputView(onCreateInputView(displayContext, mIsHardwareAcceleratedDrawingEnabled));
}
@ -489,8 +491,7 @@ public final class KeyboardSwitcher implements KeyboardState.SwitchActions {
// Reload the entire keyboard set with the same parameters, and switch to the previous layout
boolean wasEmoji = isShowingEmojiPalettes();
boolean wasClipboard = isShowingClipboardHistory();
loadKeyboard(mLatinIME.getCurrentInputEditorInfo(), settings.getCurrent(),
mLatinIME.getCurrentAutoCapsState(), mLatinIME.getCurrentRecapitalizeState());
reloadKeyboard();
if (wasEmoji)
setEmojiKeyboard();
else if (wasClipboard) {
@ -511,8 +512,13 @@ public final class KeyboardSwitcher implements KeyboardState.SwitchActions {
!settings.getCurrent().mIsSplitKeyboardEnabled,
mCurrentOrientation == Configuration.ORIENTATION_LANDSCAPE
);
loadKeyboard(mLatinIME.getCurrentInputEditorInfo(), settings.getCurrent(),
mLatinIME.getCurrentAutoCapsState(), mLatinIME.getCurrentRecapitalizeState());
reloadKeyboard();
}
public void reloadKeyboard() {
if (mCurrentInputView != null)
loadKeyboard(mLatinIME.getCurrentInputEditorInfo(), Settings.getInstance().getCurrent(),
mLatinIME.getCurrentAutoCapsState(), mLatinIME.getCurrentRecapitalizeState());
}
/**

View file

@ -73,7 +73,6 @@ import helium314.keyboard.latin.inputlogic.InputLogic;
import helium314.keyboard.latin.permissions.PermissionsManager;
import helium314.keyboard.latin.personalization.PersonalizationHelper;
import helium314.keyboard.latin.settings.Settings;
import helium314.keyboard.latin.settings.SettingsActivity;
import helium314.keyboard.latin.settings.SettingsValues;
import helium314.keyboard.latin.suggestions.SuggestionStripView;
import helium314.keyboard.latin.suggestions.SuggestionStripViewAccessor;
@ -89,6 +88,8 @@ import helium314.keyboard.latin.utils.StatsUtilsManager;
import helium314.keyboard.latin.utils.SubtypeLocaleUtils;
import helium314.keyboard.latin.utils.SubtypeSettingsKt;
import helium314.keyboard.latin.utils.ViewLayoutUtils;
import helium314.keyboard.settings.SettingsActivity;
import helium314.keyboard.settings.SettingsActivityKt;
import kotlin.collections.CollectionsKt;
import java.io.FileDescriptor;
@ -900,6 +901,8 @@ public class LatinIME extends InputMethodService implements
void onStartInputViewInternal(final EditorInfo editorInfo, final boolean restarting) {
super.onStartInputView(editorInfo, restarting);
reloadIfNecessary();
mDictionaryFacilitator.onStartInput();
// Switch to the null consumer to handle cases leading to early exit below, for which we
// also wouldn't be consuming gesture data.
@ -1976,4 +1979,13 @@ public class LatinIME extends InputMethodService implements
// deallocateMemory always called on hiding, and should not be called when showing
}
}
private void reloadIfNecessary() {
// better do the reload when showing the keyboard next time, and not on settings change
if (SettingsActivityKt.keyboardNeedsReload) {
KeyboardLayoutSet.onKeyboardThemeChanged();
mKeyboardSwitcher.forceUpdateKeyboardTheme(mDisplayContext);
SettingsActivityKt.keyboardNeedsReload = false;
}
}
}

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.latin
import android.content.Context

View file

@ -30,7 +30,7 @@ import helium314.keyboard.latin.R;
public final class DebugSettingsFragment extends SubScreenFragment
implements Preference.OnPreferenceClickListener {
private static final String PREF_KEY_DUMP_DICTS = "dump_dictionaries";
private static final String PREF_KEY_DUMP_DICT_PREFIX = "dump_dictionaries";
public static final String PREF_KEY_DUMP_DICT_PREFIX = "dump_dictionaries";
private boolean mServiceNeedsRestart = false;
private TwoStatePreference mDebugMode;

View file

@ -21,7 +21,7 @@ import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
public final class SettingsActivity extends AppCompatActivity
public final class OldSettingsActivity extends AppCompatActivity
implements ActivityCompat.OnRequestPermissionsResultCallback {
private static final String DEFAULT_FRAGMENT = SettingsFragment.class.getName();

View file

@ -400,8 +400,13 @@ public class SettingsValues {
}
private static boolean readUseContactsEnabled(final SharedPreferences prefs, final Context context) {
return prefs.getBoolean(Settings.PREF_USE_CONTACTS, false)
&& PermissionsUtil.checkAllPermissionsGranted(context, Manifest.permission.READ_CONTACTS);
final boolean setting = prefs.getBoolean(Settings.PREF_USE_CONTACTS, false);
if (!setting) return false;
if (PermissionsUtil.checkAllPermissionsGranted(context, Manifest.permission.READ_CONTACTS))
return true;
// disable if permission not granted
prefs.edit().putBoolean(Settings.PREF_USE_CONTACTS, false).apply();
return false;
}
public String dump() {

View file

@ -26,12 +26,12 @@ import androidx.core.content.res.ResourcesCompat;
import androidx.core.graphics.drawable.DrawableCompat;
import helium314.keyboard.latin.R;
import helium314.keyboard.latin.settings.SettingsActivity;
import helium314.keyboard.latin.utils.ActivityThemeUtils;
import helium314.keyboard.latin.utils.JniUtils;
import helium314.keyboard.latin.utils.LeakGuardHandlerWrapper;
import helium314.keyboard.latin.utils.ResourceUtils;
import helium314.keyboard.latin.utils.UncachedInputMethodManagerUtils;
import helium314.keyboard.settings.SettingsActivity;
import java.util.ArrayList;
@ -104,10 +104,9 @@ public final class SetupWizardActivity extends AppCompatActivity implements View
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
final ActionBar actionBar = getSupportActionBar();
if (actionBar == null) {
return;
if (actionBar != null) {
actionBar.hide();
}
actionBar.hide();
getWindow().setStatusBarColor(getResources().getColor(R.color.setup_background));
ActivityThemeUtils.setActivityTheme(this);
@ -227,8 +226,8 @@ public final class SetupWizardActivity extends AppCompatActivity implements View
intent.setClass(this, SettingsActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
| Intent.FLAG_ACTIVITY_CLEAR_TOP);
intent.putExtra(SettingsActivity.EXTRA_ENTRY_KEY,
SettingsActivity.EXTRA_ENTRY_VALUE_APP_ICON);
// intent.putExtra(OldSettingsActivity.EXTRA_ENTRY_KEY,
// OldSettingsActivity.EXTRA_ENTRY_VALUE_APP_ICON);
startActivity(intent);
}

View file

@ -79,7 +79,7 @@ fun loadCustomLayout(layoutContent: String, layoutName: String, languageTag: Str
.show()
}
private fun checkLayout(layoutContent: String, context: Context): Boolean {
fun checkLayout(layoutContent: String, context: Context): Boolean {
val params = KeyboardParams()
params.mId = KeyboardLayoutSet.getFakeKeyboardId(KeyboardId.ELEMENT_ALPHABET)
params.mPopupKeyTypes.add(POPUP_KEYS_LAYOUT)

View file

@ -27,6 +27,10 @@ public final class DeviceProtectedUtils {
return prefs;
}
Context deviceProtectedContext = getDeviceProtectedContext(context);
if (deviceProtectedContext == null) { // not relevant in practice, but happens when compose previews access shared preferences
prefs = PreferenceManager.getDefaultSharedPreferences(context);
return prefs;
}
prefs = PreferenceManager.getDefaultSharedPreferences(deviceProtectedContext);
if (prefs.getAll().isEmpty()) {
Log.i(TAG, "Device encrypted storage is empty, copying values from credential encrypted storage");

View file

@ -1,6 +1,15 @@
package helium314.keyboard.latin.utils
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.SharedPreferences
import android.view.View
import android.widget.RelativeLayout
import androidx.activity.ComponentActivity
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.commit
import helium314.keyboard.latin.R
// generic extension functions
@ -13,9 +22,9 @@ inline fun <T> Iterable<T>.sumOf(selector: (T) -> Float): Float {
return sum
}
fun CharSequence.getStringResourceOrName(prefix: String, context: Context): CharSequence {
fun CharSequence.getStringResourceOrName(prefix: String, context: Context): String {
val resId = context.resources.getIdentifier(prefix + this, "string", context.packageName)
return if (resId == 0) this else context.getString(resId)
return if (resId == 0) this.toString() else context.getString(resId)
}
/**
@ -54,3 +63,23 @@ fun <T> MutableList<T>.replaceFirst(predicate: (T) -> Boolean, with: (T) -> T) {
val i = indexOfFirst(predicate)
if (i >= 0) this[i] = with(this[i])
}
fun Context.getActivity(): ComponentActivity? {
val componentActivity = when (this) {
is ComponentActivity -> this
is ContextWrapper -> baseContext.getActivity()
else -> null
}
return componentActivity
}
// todo: should not be necessary after full pref switch to compose
fun Activity.switchTo(fragment: androidx.fragment.app.Fragment) {
(this as AppCompatActivity).supportFragmentManager.commit {
findViewById<RelativeLayout>(R.id.settingsFragmentContainer).visibility = View.VISIBLE
replace(R.id.settingsFragmentContainer, fragment)
addToBackStack(null)
}
}
fun Context.prefs(): SharedPreferences = DeviceProtectedUtils.getSharedPreferences(this)

View file

@ -322,12 +322,12 @@ fun readCustomLongpressCodes(prefs: SharedPreferences) = prefs.getString(Setting
it.substringBefore(",") to code
}
private fun writeCustomKeyCodes(prefs: SharedPreferences, codes: Map<String, Int?>) {
fun writeCustomKeyCodes(prefs: SharedPreferences, codes: Map<String, Int?>) {
val string = codes.mapNotNull { entry -> entry.value?.let { "${entry.key},$it" } }.joinToString(";")
prefs.edit().putString(Settings.PREF_TOOLBAR_CUSTOM_KEY_CODES, string).apply()
}
private fun writeCustomLongpressCodes(prefs: SharedPreferences, codes: Map<String, Int?>) {
fun writeCustomLongpressCodes(prefs: SharedPreferences, codes: Map<String, Int?>) {
val string = codes.mapNotNull { entry -> entry.value?.let { "${entry.key},$it" } }.joinToString(";")
prefs.edit().putString(Settings.PREF_TOOLBAR_CUSTOM_LONGPRESS_CODES, string).apply()
}

View file

@ -0,0 +1,212 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import helium314.keyboard.latin.R
import helium314.keyboard.settings.preferences.PreferenceCategory
@Composable
fun SearchSettingsScreen(
onClickBack: () -> Unit,
title: String,
settings: List<Any?>,
content: @Composable (ColumnScope.() -> Unit)? = null // overrides settings if not null
) {
SearchScreen(
onClickBack = onClickBack,
title = title,
content = {
if (content != null) content()
else {
Column(Modifier.verticalScroll(rememberScrollState())) {
settings.forEach {
if (it is Int) {
PreferenceCategory(stringResource(it))
} else {
// this only animates appearing prefs
// a solution would be using a list(visible to key)
AnimatedVisibility(visible = it != null) {
if (it != null)
SettingsActivity.settingsContainer[it]?.Preference()
}
}
}
}
// lazyColumn has janky scroll for a while (not sure why compose gets smoother after a while)
// maybe related to unnecessary recompositions? but even for just displaying text it's there
// didn't manage to improve things with @Immutable list wrapper and other lazy list hints
// so for now: just use "normal" Column
// even though it takes up to ~50% longer to load it's much better UX
// and the missing appear animations could be added
// LazyColumn {
// items(prefs.filterNotNull(), key = { it }) {
// Box(Modifier.animateItem()) {
// if (it is Int)
// PreferenceCategory(stringResource(it))
// else
// SettingsActivity.settingsContainer[it]!!.Preference()
// }
// }
// }
}
},
filteredItems = { SettingsActivity.settingsContainer.filter(it) },
itemContent = { it.Preference() }
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun <T: Any> SearchScreen(
onClickBack: () -> Unit,
title: String,
filteredItems: (String) -> List<T>,
itemContent: @Composable (T) -> Unit,
content: @Composable (ColumnScope.() -> Unit)? = null,
) {
var searchText by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
Column(Modifier.fillMaxSize()) {
var showSearch by rememberSaveable { mutableStateOf(false) }
fun setShowSearch(value: Boolean) {
showSearch = value
if (!value) searchText = TextFieldValue()
}
BackHandler {
if (showSearch) setShowSearch(false)
else onClickBack()
}
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Column {
TopAppBar(
title = { Text(title) },
windowInsets = TopAppBarDefaults.windowInsets,
navigationIcon = {
IconButton(onClick = {
if (showSearch) setShowSearch(false)
else onClickBack()
}) {
Icon(
painterResource(R.drawable.baseline_arrow_back_24),
stringResource(R.string.spoken_description_action_previous)
)
}
},
actions = {
IconButton(onClick = { setShowSearch(!showSearch) })
{ Icon(painterResource(R.drawable.sym_keyboard_search_lxx), stringResource(R.string.label_search_key)) }
},
)
ExpandableSearchField(
expanded = showSearch,
onDismiss = { setShowSearch(false) },
search = searchText,
onSearchChange = { searchText = it },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface
)
)
}
}
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) {
if (searchText.text.isBlank() && content != null) {
Column(
Modifier
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom
)
)
) {
content()
}
} else {
val items = filteredItems(searchText.text)
LazyColumn {
items(items) {
itemContent(it)
}
}
}
}
}
}
// from StreetComplete
/** Expandable text field that can be dismissed and requests focus when it is expanded */
@Composable
fun ExpandableSearchField(
expanded: Boolean,
onDismiss: () -> Unit,
search: TextFieldValue,
onSearchChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
colors: TextFieldColors = TextFieldDefaults.colors(),
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(expanded) {
if (expanded) focusRequester.requestFocus()
}
AnimatedVisibility(visible = expanded, modifier = Modifier.fillMaxWidth()) {
TextField(
value = search,
onValueChange = onSearchChange,
modifier = modifier.focusRequester(focusRequester),
leadingIcon = { Icon(painterResource(R.drawable.sym_keyboard_search_lxx), stringResource(R.string.label_search_key)) },
trailingIcon = { IconButton(onClick = {
if (search.text.isBlank()) onDismiss()
else onSearchChange(TextFieldValue())
}) { Icon(painterResource(R.drawable.ic_close), stringResource(android.R.string.cancel)) } },
singleLine = true,
colors = colors
)
}
}

View file

@ -0,0 +1,83 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings
import android.content.SharedPreferences
import android.os.Bundle
import android.widget.RelativeLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material3.Surface
import androidx.compose.ui.platform.ComposeView
import androidx.core.view.isGone
import helium314.keyboard.latin.R
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.utils.prefs
import kotlinx.coroutines.flow.MutableStateFlow
// todo: with compose, app startup is slower and UI needs some "warmup" time to be snappy
// maybe baseline profiles help?
// https://developer.android.com/codelabs/android-baseline-profiles-improve
// https://developer.android.com/codelabs/jetpack-compose-performance#2
// https://developer.android.com/topic/performance/baselineprofiles/overview
class SettingsActivity : AppCompatActivity(), SharedPreferences.OnSharedPreferenceChangeListener {
private val prefs by lazy { this.prefs() }
val prefChanged = MutableStateFlow(0) // simple counter, as the only relevant information is that something changed
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Settings.getInstance().current == null)
Settings.init(this)
settingsContainer = SettingsContainer(this)
// todo: when removing old settings completely, remove settings_activity.xml and supportFragmentManager stuff
// val cv = ComposeView(context = this)
// setContentView(cv)
setContentView(R.layout.settings_activity)
supportFragmentManager.addOnBackStackChangedListener {
updateContainerVisibility()
}
// cv.setContent { // todo: when removing old settings
findViewById<ComposeView>(R.id.navHost).setContent {
Theme {
Surface {
SettingsNavHost(
onClickBack = {
// this.finish() // todo: when removing old settings
if (supportFragmentManager.findFragmentById(R.id.settingsFragmentContainer) == null)
this.finish()
else supportFragmentManager.popBackStack()
}
)
}
}
}
}
private fun updateContainerVisibility() { // todo: remove when removing old settings
findViewById<RelativeLayout>(R.id.settingsFragmentContainer).isGone = supportFragmentManager.findFragmentById(R.id.settingsFragmentContainer) == null
}
override fun onStart() {
super.onStart()
prefs.registerOnSharedPreferenceChangeListener(this)
}
override fun onStop() {
prefs.unregisterOnSharedPreferenceChangeListener(this)
super.onStop()
}
companion object {
// public write so compose previews can show the screens
// having it in a companion object is not ideal as it will stay in memory even after settings are closed
// but it's small enough to not care
lateinit var settingsContainer: SettingsContainer
}
override fun onSharedPreferenceChanged(prefereces: SharedPreferences?, key: String?) {
prefChanged.value++
}
}
@JvmField
var keyboardNeedsReload = false

View file

@ -0,0 +1,86 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import helium314.keyboard.settings.screens.createAboutSettings
import helium314.keyboard.settings.screens.createAdvancedSettings
import helium314.keyboard.settings.screens.createAppearanceSettings
import helium314.keyboard.settings.screens.createCorrectionSettings
import helium314.keyboard.settings.screens.createGestureTypingSettings
import helium314.keyboard.settings.screens.createPreferencesSettings
import helium314.keyboard.settings.screens.createToolbarSettings
class SettingsContainer(context: Context) {
private val list = createSettings(context)
private val map: Map<String, Setting> = HashMap<String, Setting>(list.size).apply {
list.forEach {
if (put(it.key, it) != null)
throw IllegalArgumentException("key $it added twice")
}
}
operator fun get(key: Any): Setting? = map[key]
// filtering could be more elaborate, but should be good enough for a start
// always have all settings in search, because:
// don't show disabled settings -> users confused
// show as disabled (i.e. no interaction possible) -> users confused
// show, but change will not do anything because another setting needs to be enabled first -> probably best
fun filter(searchTerm: String): List<Setting> {
val term = searchTerm.lowercase()
val results = mutableSetOf<Setting>()
list.forEach { setting -> if (setting.title.lowercase().startsWith(term)) results.add(setting) }
list.forEach { setting -> if (setting.title.lowercase().split(' ').any { it.startsWith(term) }) results.add(setting) }
list.forEach { setting ->
if (setting.description?.lowercase()?.split(' ')?.any { it.startsWith(term) } == true)
results.add(setting)
}
return results.toList()
}
}
@Immutable
class Setting(
context: Context,
val key: String,
@StringRes titleId: Int,
@StringRes descriptionId: Int? = null,
private val content: @Composable (Setting) -> Unit
) {
val title = context.getString(titleId)
val description = descriptionId?.let { context.getString(it) }
@Composable
fun Preference() {
content(this)
}
}
// intentionally not putting individual debug settings in here so user knows the context
private fun createSettings(context: Context) = createAboutSettings(context) +
createCorrectionSettings(context) + createPreferencesSettings(context) + createToolbarSettings(context) +
createGestureTypingSettings(context) + createAdvancedSettings(context) + createAppearanceSettings(context)
object SettingsWithoutKey {
const val EDIT_PERSONAL_DICTIONARY = "edit_personal_dictionary"
const val APP = "app"
const val VERSION = "version"
const val LICENSE = "license"
const val HIDDEN_FEATURES = "hidden_features"
const val GITHUB = "github"
const val SAVE_LOG = "save_log"
const val CUSTOM_KEY_CODES = "customize_key_codes"
const val CUSTOM_SYMBOLS_NUMBER_LAYOUTS = "custom_symbols_number_layouts"
const val CUSTOM_FUNCTIONAL_LAYOUTS = "custom_functional_key_layouts"
const val BACKUP_RESTORE = "backup_restore"
const val DEBUG_SETTINGS = "screen_debug"
const val LOAD_GESTURE_LIB = "load_gesture_library"
const val ADJUST_COLORS = "adjust_colors"
const val ADJUST_COLORS_NIGHT = "adjust_colors_night"
const val BACKGROUND_IMAGE = "background_image"
const val BACKGROUND_IMAGE_LANDSCAPE = "background_image_landscape"
const val CUSTOM_FONT = "custom_font"
}

View file

@ -0,0 +1,137 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import helium314.keyboard.settings.screens.AboutScreen
import helium314.keyboard.settings.screens.AdvancedSettingsScreen
import helium314.keyboard.settings.screens.AppearanceScreen
import helium314.keyboard.settings.screens.DebugScreen
import helium314.keyboard.settings.screens.GestureTypingScreen
import helium314.keyboard.settings.screens.MainSettingsScreen
import helium314.keyboard.settings.screens.PreferencesScreen
import helium314.keyboard.settings.screens.TextCorrectionScreen
import helium314.keyboard.settings.screens.ToolbarScreen
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
@Composable
fun SettingsNavHost(
onClickBack: () -> Unit,
startDestination: String? = null,
) {
val navController = rememberNavController()
val dir = if (LocalLayoutDirection.current == LayoutDirection.Ltr) 1 else -1
val target = SettingsDestination.navTarget.collectAsState()
fun goBack() {
if (!navController.popBackStack()) onClickBack()
}
if (target.value != SettingsDestination.Settings)
navController.navigate(route = target.value)
NavHost(
navController = navController,
startDestination = startDestination ?: SettingsDestination.Settings,
enterTransition = { slideInHorizontally(initialOffsetX = { +it * dir }) },
exitTransition = { slideOutHorizontally(targetOffsetX = { -it * dir }) },
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it * dir }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { +it * dir }) }
) {
composable(SettingsDestination.Settings) {
MainSettingsScreen(
onClickAbout = { navController.navigate(SettingsDestination.About) },
onClickTextCorrection = { navController.navigate(SettingsDestination.TextCorrection) },
onClickPreferences = { navController.navigate(SettingsDestination.Preferences) },
onClickToolbar = { navController.navigate(SettingsDestination.Toolbar) },
onClickGestureTyping = { navController.navigate(SettingsDestination.GestureTyping) },
onClickAdvanced = { navController.navigate(SettingsDestination.Advanced) },
onClickAppearance = { navController.navigate(SettingsDestination.Appearance) },
onClickBack = ::goBack,
)
}
composable(SettingsDestination.About) {
AboutScreen(onClickBack = ::goBack)
}
composable(SettingsDestination.TextCorrection) {
TextCorrectionScreen(onClickBack = ::goBack)
}
composable(SettingsDestination.Preferences) {
PreferencesScreen(onClickBack = ::goBack)
}
composable(SettingsDestination.Toolbar) {
ToolbarScreen(onClickBack = ::goBack)
}
composable(SettingsDestination.GestureTyping) {
GestureTypingScreen(onClickBack = ::goBack)
}
composable(SettingsDestination.Advanced) {
AdvancedSettingsScreen(onClickBack = ::goBack)
}
composable(SettingsDestination.Debug) {
DebugScreen(onClickBack = ::goBack)
}
composable(SettingsDestination.Appearance) {
AppearanceScreen(onClickBack = ::goBack)
}
composable(SettingsDestination.PersonalDictionary) {
// PersonalDictionarySettingsScreen(
// onClickBack = ::goBack
// )
}
composable(SettingsDestination.Languages) {
// LanguagesSettingsScreen(
// onClickBack = ::goBack
// )
}
composable(SettingsDestination.Colors) {
// ColorsScreen(
// night = false,
// onClickBack = ::goBack
// )
}
composable(SettingsDestination.ColorsNight) {
// ColorsScreen(
// night = true,
// onClickBack = ::goBack
// )
}
}
}
object SettingsDestination {
const val Settings = "settings"
const val About = "about"
const val TextCorrection = "text_correction"
const val Preferences = "preferences"
const val Toolbar = "toolbar"
const val GestureTyping = "gesture_typing"
const val Advanced = "advanced"
const val Debug = "debug"
const val Appearance = "appearance"
const val Colors = "colors"
const val ColorsNight = "colors_night"
const val PersonalDictionary = "personal_dictionary"
const val Languages = "languages"
val navTarget = MutableStateFlow(Settings)
private val navScope = CoroutineScope(Dispatchers.Default)
fun navigateTo(target: String) {
if (navTarget.value == target) {
// triggers recompose twice, but that's ok as it's a rare event
navTarget.value = Settings
navScope.launch { delay(10); navTarget.value = target }
} else
navTarget.value = target
}
}

View file

@ -0,0 +1,57 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.text.font.DeviceFontFamilyName
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import helium314.keyboard.latin.R
@Composable
fun Theme(dark: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val material3 = Typography()
val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (dark) dynamicDarkColorScheme(LocalContext.current)
else dynamicLightColorScheme(LocalContext.current)
} else {
// todo (later): more colors
if (dark) darkColorScheme(
primary = colorResource(R.color.accent),
)
else lightColorScheme(
primary = colorResource(R.color.accent)
)
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography(
headlineMedium = material3.headlineMedium.copy(fontWeight = FontWeight.Bold),
headlineSmall = material3.headlineSmall.copy(fontWeight = FontWeight.Bold),
titleLarge = material3.titleLarge.copy(
fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(DeviceFontFamilyName("sans-serif-condensed"), FontWeight.Bold))
),
titleMedium = material3.titleMedium.copy(
fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(DeviceFontFamilyName("sans-serif-condensed"), FontWeight.Bold))
),
titleSmall = material3.titleSmall.copy(
fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(DeviceFontFamilyName("sans-serif-condensed"), FontWeight.Bold))
)
),
//shapes = Shapes(),
content = content
)
}

View file

@ -0,0 +1,107 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.dialogs
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Surface
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.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.github.skydoves.colorpicker.compose.AlphaSlider
import com.github.skydoves.colorpicker.compose.BrightnessSlider
import com.github.skydoves.colorpicker.compose.ColorEnvelope
import com.github.skydoves.colorpicker.compose.HsvColorPicker
import com.github.skydoves.colorpicker.compose.rememberColorPickerController
// todo:
// setting from text doesn't work
// weird effect on start, did this start with the top row showing colors?
// text field doesn't look nice
// for initial color picks performance is not good
@Composable
fun ColorPickerDialog(
onDismissRequest: () -> Unit,
initialColor: Int,
title: String,
onConfirmed: (Int) -> Unit,
) {
val controller = rememberColorPickerController()
val barHeight = 35.dp
var value by remember { mutableStateOf(TextFieldValue(initialColor.toString(16))) }
ThreeButtonAlertDialog(
onDismissRequest = onDismissRequest,
onConfirmed = { onConfirmed(controller.selectedColor.value.toArgb()) },
title = { Text(title) },
text = {
Column {
Row {
Surface(
color = Color(initialColor),
modifier = Modifier.fillMaxWidth(0.5f)
.padding(start = 10.dp)
.height(barHeight))
{ }
Surface(
color = controller.selectedColor.value,
modifier = Modifier.fillMaxWidth()
.padding(end = 10.dp)
.height(barHeight))
{ }
}
HsvColorPicker(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.6f)
.padding(10.dp),
controller = controller,
onColorChanged = { colorEnvelope: ColorEnvelope ->
value = TextFieldValue(colorEnvelope.hexCode)
},
initialColor = Color(initialColor)
)
AlphaSlider(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
.height(barHeight),
controller = controller,
)
BrightnessSlider(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
.height(barHeight),
controller = controller,
)
TextField(
value = value,
onValueChange = {
val androidColor = kotlin.runCatching { android.graphics.Color.parseColor("#$it") }.getOrNull()
if (androidColor != null)
controller.selectByColor(Color(androidColor), true)
}
)
}
}
)
}
@Preview
@Composable
private fun Preview() {
ColorPickerDialog({}, android.graphics.Color.MAGENTA, "color name", {})
}

View file

@ -0,0 +1,49 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.dialogs
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import helium314.keyboard.latin.R
// taken from StreetComplete
/** Slight specialization of an alert dialog: AlertDialog with OK and Cancel button. Both buttons
* call [onDismissRequest] and the OK button additionally calls [onConfirmed]. */
@Composable
fun ConfirmationDialog(
onDismissRequest: () -> Unit,
onConfirmed: () -> Unit,
modifier: Modifier = Modifier,
title: @Composable (() -> Unit)? = null,
text: @Composable (() -> Unit)? = null,
confirmButtonText: String = stringResource(android.R.string.ok),
cancelButtonText: String = stringResource(android.R.string.cancel),
neutralButtonText: String? = null,
onNeutral: () -> Unit = { },
) {
ThreeButtonAlertDialog(
onDismissRequest = onDismissRequest,
onConfirmed = onConfirmed,
confirmButtonText = confirmButtonText,
cancelButtonText = cancelButtonText,
neutralButtonText = neutralButtonText,
onNeutral = onNeutral,
modifier = modifier,
title = title,
text = text,
)
}
@Preview
@Composable
private fun PreviewConfirmDialog() {
ConfirmationDialog(
onDismissRequest = { },
onConfirmed = {},
neutralButtonText = "hi",
confirmButtonText = "I don't care",
text = { Text(stringResource(R.string.disable_personalized_dicts_message)) }
)
}

View file

@ -0,0 +1,185 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.dialogs
import android.graphics.drawable.VectorDrawable
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.util.TypedValueCompat
import helium314.keyboard.keyboard.internal.KeyboardIconsSet
import helium314.keyboard.latin.R
import helium314.keyboard.latin.customIconNames
import helium314.keyboard.latin.utils.getStringResourceOrName
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.screens.GetIcon
import kotlinx.serialization.json.Json
@Composable
fun CustomizeIconsDialog(
prefKey: String,
onDismissRequest: () -> Unit,
) {
val state = rememberLazyListState()
val ctx = LocalContext.current
var iconsAndNames by remember { mutableStateOf(
KeyboardIconsSet.getAllIcons(ctx).keys.map { iconName ->
val name = iconName.getStringResourceOrName("", ctx)
if (name == iconName) iconName to iconName.getStringResourceOrName("label_", ctx)
else iconName to name
}.sortedBy { it.second }
) }
fun reloadItem(iconName: String) {
iconsAndNames = iconsAndNames.map { item ->
if (item.first == iconName) {
item.first to if (item.second.endsWith(" ")) item.second.trimEnd() else item.second + " "
}
else item
}
}
var showIconDialog: Pair<String, String>? by rememberSaveable { mutableStateOf(null) }
var showDeletePrefConfirmDialog by rememberSaveable { mutableStateOf(false) }
val prefs = ctx.prefs()
ThreeButtonAlertDialog(
onDismissRequest = onDismissRequest,
onConfirmed = { },
confirmButtonText = null,
cancelButtonText = stringResource(R.string.dialog_close),
neutralButtonText = if (prefs.contains(prefKey)) stringResource(R.string.button_default) else null,
onNeutral = { showDeletePrefConfirmDialog = true },
title = { Text(stringResource(R.string.customize_icons)) },
text = {
LazyColumn(state = state) {
items(iconsAndNames, key = { it.second }) { (iconName, displayName) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { showIconDialog = iconName to displayName }
) {
KeyboardIconsSet.instance.GetIcon(iconName)
Text(displayName, Modifier.weight(1f))
}
}
}
},
)
if (showIconDialog != null) {
val iconName = showIconDialog!!.first
val allIcons = KeyboardIconsSet.getAllIcons(ctx)
val iconsForName = allIcons[iconName].orEmpty()
val iconsSet = mutableSetOf<Int>()
iconsSet.addAll(iconsForName)
KeyboardIconsSet.getAllIcons(ctx).forEach { iconsSet.addAll(it.value) }
val icons = iconsSet.toList()
val initialIcon = KeyboardIconsSet.instance.iconIds[iconName]
var selectedIcon by rememberSaveable { mutableStateOf(initialIcon) }
val gridState = rememberLazyGridState()
LaunchedEffect(initialIcon) {
val index = icons.indexOf(initialIcon)
if (index != -1) gridState.animateScrollToItem(index, -state.layoutInfo.viewportSize.height / 3)
}
ThreeButtonAlertDialog(
onDismissRequest = { showIconDialog = null },
onConfirmed = {
runCatching {
val newIcons = customIconNames(prefs).toMutableMap()
newIcons[iconName] = selectedIcon?.let { ctx.resources.getResourceEntryName(it) } ?: return@runCatching
prefs.edit().putString(prefKey, Json.encodeToString(newIcons)).apply()
KeyboardIconsSet.instance.loadIcons(ctx)
}
reloadItem(iconName)
},
neutralButtonText = if (customIconNames(prefs).contains(iconName)) stringResource(R.string.button_default) else null,
onNeutral = {
runCatching {
val icons2 = customIconNames(prefs).toMutableMap()
icons2.remove(iconName)
if (icons2.isEmpty()) prefs.edit().remove(prefKey).apply()
else prefs.edit().putString(prefKey, Json.encodeToString(icons2)).apply()
KeyboardIconsSet.instance.loadIcons(ctx)
}
reloadItem(iconName)
},
title = { Text(showIconDialog!!.second) },
text = {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 64.dp),
state = gridState
) {
items(icons, key = { it }) { resId ->
val drawable = ContextCompat.getDrawable(ctx, resId)?.mutate() ?: return@items
val color = if (resId == selectedIcon) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface
CompositionLocalProvider(
LocalContentColor provides color
) {
Box(
Modifier.size(40.dp).clickable { selectedIcon = resId },
contentAlignment = Alignment.Center
) {
if (drawable is VectorDrawable)
Icon(painterResource(resId), null, Modifier.fillMaxSize(0.8f))
else {
val px = TypedValueCompat.dpToPx(40f, ctx.resources.displayMetrics).toInt()
Icon(drawable.toBitmap(px, px).asImageBitmap(), null, Modifier.fillMaxSize(0.8f))
}
}
}
}
}
},
)
}
if (showDeletePrefConfirmDialog) {
ConfirmationDialog(
onDismissRequest = { showDeletePrefConfirmDialog = false },
onConfirmed = {
showDeletePrefConfirmDialog = false
onDismissRequest()
prefs.edit().remove(prefKey).apply()
KeyboardIconsSet.instance.loadIcons(ctx)
},
text = { Text(stringResource(R.string.customize_icons_reset_message)) }
)
}
}
@Preview
@Composable
private fun Preview() {
KeyboardIconsSet.instance.loadIcons(LocalContext.current)
CustomizeIconsDialog(
prefKey = "",
onDismissRequest = { },
)
}

View file

@ -0,0 +1,18 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.dialogs
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun InfoDialog(
message: String,
onDismissRequest: () -> Unit
) {
ThreeButtonAlertDialog(
onDismissRequest = onDismissRequest,
text = { Text(message) },
onConfirmed = { },
confirmButtonText = null
)
}

View file

@ -0,0 +1,91 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.dialogs
import android.widget.Toast
import androidx.compose.foundation.layout.imePadding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.DialogProperties
import helium314.keyboard.latin.R
import helium314.keyboard.latin.utils.Log
import helium314.keyboard.latin.utils.checkLayout
import helium314.keyboard.latin.utils.getCustomLayoutFile
import helium314.keyboard.latin.utils.getLayoutDisplayName
import helium314.keyboard.latin.utils.onCustomLayoutFileListChanged
import helium314.keyboard.settings.keyboardNeedsReload
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun LayoutEditDialog(
onDismissRequest: () -> Unit,
layoutName: String,
startContent: String? = null,
displayName: String? = null
) {
val ctx = LocalContext.current
val file = getCustomLayoutFile(layoutName, ctx)
val scope = rememberCoroutineScope()
var job: Job? = null
var showDeleteConfirmation by rememberSaveable { mutableStateOf(false) }
TextInputDialog(
onDismissRequest = onDismissRequest,
onConfirmed = {
file.parentFile?.mkdir()
file.writeText(it)
onCustomLayoutFileListChanged()
keyboardNeedsReload = true
},
confirmButtonText = stringResource(R.string.save),
neutralButtonText = if (displayName != null && file.exists()) stringResource(R.string.delete) else null,
onNeutral = {
if (!file.exists()) return@TextInputDialog
file.delete()
onCustomLayoutFileListChanged()
keyboardNeedsReload = true
},
initialText = startContent ?: file.readText(),
singleLine = false,
title = { Text(displayName ?: getLayoutDisplayName(layoutName)) },
checkTextValid = {
val valid = checkLayout(it, ctx)
job?.cancel()
if (!valid) {
job = scope.launch {
delay(3000)
val message = Log.getLog(10)
.lastOrNull { it.tag == "CustomLayoutUtils" }?.message
?.split("\n")?.take(2)?.joinToString("\n")
Toast.makeText(ctx, ctx.getString(R.string.layout_error, message), Toast.LENGTH_LONG).show()
}
}
valid
},
modifier = Modifier.imePadding(),
// decorFitsSystemWindows = false is necessary so the dialog is not covered by keyboard
// but this also stops the background from being darkened... great idea to combine both
properties = DialogProperties(decorFitsSystemWindows = false)
)
if (showDeleteConfirmation)
ConfirmationDialog(
onDismissRequest = { showDeleteConfirmation = false },
onConfirmed = {
onDismissRequest()
file.delete()
onCustomLayoutFileListChanged()
keyboardNeedsReload = true
},
text = { Text(stringResource(R.string.delete_layout, displayName ?: "")) },
confirmButtonText = stringResource(R.string.delete)
)
}

View file

@ -0,0 +1,112 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.dialogs
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
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.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
// taken from StreetComplete
@Composable
fun <T: Any> ListPickerDialog(
onDismissRequest: () -> Unit,
items: List<T>,
onItemSelected: (T) -> Unit,
modifier: Modifier = Modifier,
title: (@Composable () -> Unit)? = null,
selectedItem: T? = null,
getItemName: (@Composable (T) -> String) = { it.toString() },
confirmImmediately: Boolean = true,
showRadioButtons: Boolean = true,
) {
var selected by remember { mutableStateOf(selectedItem) }
val state = rememberLazyListState()
LaunchedEffect(selectedItem) {
val index = items.indexOf(selectedItem)
if (index != -1) state.scrollToItem(index, -state.layoutInfo.viewportSize.height / 3)
}
ThreeButtonAlertDialog(
onDismissRequest = onDismissRequest,
onConfirmed = { selected?.let { onItemSelected(it) } },
confirmButtonText = if (confirmImmediately) null else stringResource(android.R.string.ok),
checkOk = { selected != null },
modifier = modifier,
title = title,
text = {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodyLarge
) {
LazyColumn(state = state) {
items(items) { item ->
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable {
if (confirmImmediately) {
onDismissRequest()
onItemSelected(item)
}
selected = item
}
.padding(horizontal = 24.dp)
.heightIn(min = 40.dp)
) {
Text(
text = getItemName(item),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
if (showRadioButtons)
RadioButton(
selected = selected == item,
onClick = {
if (confirmImmediately) {
onDismissRequest()
onItemSelected(item)
}
selected = item
}
)
}
}
}
}
},
)
}
@Preview
@Composable
private fun PreviewListPickerDialog() {
val items = remember { (0..<5).toList() }
ListPickerDialog(
onDismissRequest = {},
items = items,
onItemSelected = {},
title = { Text("Select something") },
selectedItem = 2,
getItemName = { "Item $it" },
)
}

View file

@ -0,0 +1,103 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.dialogs
import androidx.compose.animation.core.animateDpAsState
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.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import helium314.keyboard.latin.R
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
@Composable
fun <T: Any> ReorderDialog(
onDismissRequest: () -> Unit,
onConfirmed: (List<T>) -> Unit,
items: List<T>,
getKey: (T) -> Any, // actually it's not "Any", but "anything that can be stored in a bundle"
displayItem: @Composable (T) -> Unit,
modifier: Modifier = Modifier,
title: @Composable (() -> Unit)? = null,
onNeutral: () -> Unit = { },
neutralButtonText: String? = null,
) {
var reorderableItems by remember(items) { mutableStateOf(items) }
val listState = rememberLazyListState()
val dragDropState = rememberReorderableLazyListState(listState) { from, to ->
reorderableItems = reorderableItems.toMutableList().apply {
add(to.index, removeAt(from.index))
}
}
ThreeButtonAlertDialog(
onDismissRequest = onDismissRequest,
onConfirmed = { onConfirmed(reorderableItems) },
onNeutral = onNeutral,
neutralButtonText = neutralButtonText,
modifier = modifier,
title = title,
text = {
LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(reorderableItems, key = getKey) { item ->
ReorderableItem(
state = dragDropState,
key = getKey(item)
) { dragging ->
val elevation by animateDpAsState(if (dragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Row(
modifier = Modifier
.longPressDraggableHandle()
.heightIn(min = 36.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painterResource(R.drawable.ic_drag_indicator),
"Reorder",
Modifier.padding(end = 6.dp),
MaterialTheme.colorScheme.onSurfaceVariant
)
displayItem(item)
}
}
}
}
}
},
)
}
@Preview
@Composable
private fun Preview() {
ReorderDialog(
onConfirmed = {},
onDismissRequest = {},
items = listOf(1, 2, 3),
displayItem = { Text(it.toString(), Modifier.fillMaxWidth(), textAlign = TextAlign.Center) },
getKey = { it.toString() }
)
}

View file

@ -0,0 +1,81 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.dialogs
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import helium314.keyboard.latin.R
@Composable
fun SliderDialog(
onDismissRequest: () -> Unit,
onDone: (Float) -> Unit,
initialValue: Float,
range: ClosedFloatingPointRange<Float>,
modifier: Modifier = Modifier,
showDefault: Boolean = false,
onDefault: () -> Unit = { },
onValueChanged: (Float) -> Unit = { },
title: (@Composable () -> Unit)? = null,
intermediateSteps: Int? = null,
positionString: (@Composable (Float) -> String) = { it.toString() },
) {
var sliderPosition by remember { mutableFloatStateOf(initialValue) }
ThreeButtonAlertDialog(
onDismissRequest = onDismissRequest,
neutralButtonText = if (showDefault) stringResource(R.string.button_default) else null,
onNeutral = onDefault,
onConfirmed = { onDone(sliderPosition) },
modifier = modifier,
title = title,
text = {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodyLarge
) {
Column {
if (intermediateSteps == null)
Slider(
value = sliderPosition,
onValueChange = { sliderPosition = it },
onValueChangeFinished = { onValueChanged(sliderPosition) },
valueRange = range,
)
else
Slider(
value = sliderPosition,
onValueChange = { sliderPosition = it },
onValueChangeFinished = { onValueChanged(sliderPosition) },
valueRange = range,
steps = intermediateSteps
)
Text(positionString(sliderPosition))
}
}
},
)
}
@Preview
@Composable
private fun PreviewSliderDialog() {
SliderDialog(
onDismissRequest = { },
onDone = { },
initialValue = 100f,
range = 0f..500f,
title = { Text("move it") },
showDefault = true
)
}

View file

@ -0,0 +1,94 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.dialogs
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.window.DialogProperties
// mostly taken from StreetComplete / SCEE
/** Dialog with which to input text. OK button is only clickable if [checkTextValid] returns true. */
@Composable
fun TextInputDialog(
onDismissRequest: () -> Unit,
onConfirmed: (text: String) -> Unit,
modifier: Modifier = Modifier,
title: @Composable (() -> Unit)? = null,
onNeutral: () -> Unit = { },
neutralButtonText: String? = null,
confirmButtonText: String = stringResource(android.R.string.ok),
initialText: String = "",
textInputLabel: @Composable (() -> Unit)? = null,
singleLine: Boolean = true,
keyboardType: KeyboardType = KeyboardType.Unspecified,
properties: DialogProperties = DialogProperties(),
checkTextValid: (text: String) -> Boolean = { it.isNotBlank() }
) {
val focusRequester = remember { FocusRequester() }
// crappy workaround because otherwise we get a disappearing dialog and a crash
// but doesn't work perfectly, dialog doesn't nicely show up again...
// todo: understand why it works in ExpandableSearchField, but not here
var done by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(initialText) {
if (done) return@LaunchedEffect
focusRequester.requestFocus()
done = true
}
var value by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(initialText, selection = TextRange(if (singleLine) initialText.length else 0)))
}
ThreeButtonAlertDialog(
onDismissRequest = onDismissRequest,
onConfirmed = { onConfirmed(value.text) },
confirmButtonText = confirmButtonText,
checkOk = { checkTextValid(value.text) },
neutralButtonText = neutralButtonText,
onNeutral = onNeutral,
modifier = modifier,
title = title,
text = {
OutlinedTextField(
value = value,
onValueChange = { value = it },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
label = textInputLabel,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
singleLine = singleLine
)
},
properties = properties
)
}
@Preview
@Composable
private fun Preview() {
TextInputDialog(
onDismissRequest = {},
onConfirmed = {},
title = { Text("Title") },
initialText = "some text\nand another line",
singleLine = false,
textInputLabel = { Text("fill it") }
)
}

View file

@ -0,0 +1,105 @@
/*
* Copyright (C) 2021 The Android Open Source Project
* parts taken from Material3 AlertDialog.kt
* SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only
*/
package helium314.keyboard.settings.dialogs
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import helium314.keyboard.settings.Theme
// text should be smaller, and background should be darkened
@Composable
fun ThreeButtonAlertDialog(
onDismissRequest: () -> Unit,
onConfirmed: () -> Unit,
modifier: Modifier = Modifier,
title: @Composable (() -> Unit)? = null,
text: @Composable (() -> Unit)? = null,
onNeutral: () -> Unit = { },
checkOk: () -> Boolean = { true },
confirmButtonText: String? = stringResource(android.R.string.ok),
cancelButtonText: String = stringResource(android.R.string.cancel),
neutralButtonText: String? = null,
properties: DialogProperties = DialogProperties()
) {
Dialog(
onDismissRequest = onDismissRequest,
properties = properties
) {
Box(
modifier = modifier.sizeIn(minWidth = 280.dp, maxWidth = 560.dp),
propagateMinConstraints = true
) {
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
contentColor = contentColorFor(MaterialTheme.colorScheme.surface),
) {
Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 6.dp)) {
title?.let {
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.titleLarge) {
Box(Modifier.padding(PaddingValues(bottom = 16.dp))) {
title()
}
}
}
text?.let {
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyMedium) {
Box(Modifier.weight(weight = 1f, fill = false).padding(bottom = 8.dp)) {
text()
}
}
}
Row {
if (neutralButtonText != null)
TextButton(
onClick = { onDismissRequest(); onNeutral() }
) { Text(neutralButtonText) }
Spacer(modifier.weight(1f))
TextButton(onClick = onDismissRequest) { Text(cancelButtonText) }
if (confirmButtonText != null)
TextButton(
enabled = checkOk(),
onClick = { onDismissRequest(); onConfirmed() },
) { Text(confirmButtonText) }
}
}
}
}
}
}
@Preview
@Composable
private fun Preview() {
Theme {
ThreeButtonAlertDialog(
onDismissRequest = {},
onConfirmed = { },
text = { Text("hello") },
title = { Text("title") },
neutralButtonText = "Default"
)
}
}

View file

@ -0,0 +1,167 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.dialogs
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
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.saveable.rememberSaveable
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.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import helium314.keyboard.keyboard.internal.KeyboardIconsSet
import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode.checkAndConvertCode
import helium314.keyboard.latin.R
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.utils.ToolbarKey
import helium314.keyboard.latin.utils.getCodeForToolbarKey
import helium314.keyboard.latin.utils.getCodeForToolbarKeyLongClick
import helium314.keyboard.latin.utils.getStringResourceOrName
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.latin.utils.readCustomKeyCodes
import helium314.keyboard.latin.utils.readCustomLongpressCodes
import helium314.keyboard.latin.utils.writeCustomKeyCodes
import helium314.keyboard.latin.utils.writeCustomLongpressCodes
import helium314.keyboard.settings.screens.GetIcon
import kotlinx.serialization.json.Json
// todo (later): reading and writing prefs should be done in the preference, or at least with the provided (single!) pref key
@Composable
fun ToolbarKeysCustomizer(
onDismissRequest: () -> Unit
) {
val ctx = LocalContext.current
val prefs = ctx.prefs()
var showKeyCustomizer: ToolbarKey? by rememberSaveable { mutableStateOf(null) }
var showDeletePrefConfirmDialog by rememberSaveable { mutableStateOf(false) }
ThreeButtonAlertDialog(
onDismissRequest = onDismissRequest,
cancelButtonText = stringResource(R.string.dialog_close),
confirmButtonText = null,
onConfirmed = { },
neutralButtonText = if (readCustomKeyCodes(prefs).isNotEmpty() || readCustomLongpressCodes(prefs).isNotEmpty()) stringResource(R.string.button_default) else null,
onNeutral = { showDeletePrefConfirmDialog = true },
title = { Text(stringResource(R.string.customize_toolbar_key_codes)) },
text = {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(ToolbarKey.entries) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { showKeyCustomizer = it }.fillParentMaxWidth()
) {
KeyboardIconsSet.instance.GetIcon(it.name)
Text(it.name.lowercase().getStringResourceOrName("", ctx))
}
}
}
},
)
if (showKeyCustomizer != null) {
val shownKey = showKeyCustomizer
if (shownKey != null)
ToolbarKeyCustomizer(shownKey) { showKeyCustomizer = null }
}
if (showDeletePrefConfirmDialog)
ConfirmationDialog(
onDismissRequest = { showDeletePrefConfirmDialog = false },
onConfirmed = {
showDeletePrefConfirmDialog = false
onDismissRequest()
prefs.edit {
remove(Settings.PREF_TOOLBAR_CUSTOM_KEY_CODES)
remove(Settings.PREF_TOOLBAR_CUSTOM_LONGPRESS_CODES)
}
},
text = { Text(stringResource(R.string.customize_toolbar_key_code_reset_message)) }
)
}
@Composable
private fun ToolbarKeyCustomizer(
key: ToolbarKey,
onDismissRequest: () -> Unit
) {
val ctx = LocalContext.current
val prefs = ctx.prefs()
var code by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue(getCodeForToolbarKey(key).toString())) }
var longPressCode by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue(getCodeForToolbarKeyLongClick(key).toString())) }
ThreeButtonAlertDialog(
onDismissRequest = onDismissRequest,
onConfirmed = {
writeCustomKeyCodes(prefs, readCustomKeyCodes(prefs) + (key.name to checkCode(code)))
writeCustomLongpressCodes(prefs, readCustomLongpressCodes(prefs) + (key.name to checkCode(longPressCode)))
},
checkOk = { checkCode(code) != null && checkCode(longPressCode) != null },
neutralButtonText = if (readCustomKeyCodes(prefs).containsKey(key.name) || readCustomLongpressCodes(prefs).containsKey(key.name))
stringResource(R.string.button_default)
else null,
onNeutral = {
val keys = readCustomKeyCodes(prefs).toMutableMap()
keys.remove(key.name)
prefs.edit().putString(Settings.PREF_TOOLBAR_CUSTOM_KEY_CODES, Json.encodeToString(keys)).apply()
val longpressKeys = readCustomLongpressCodes(prefs).toMutableMap()
longpressKeys.remove(key.name)
prefs.edit().putString(Settings.PREF_TOOLBAR_CUSTOM_LONGPRESS_CODES, Json.encodeToString(longpressKeys)).apply()
},
title = { Text(key.name.lowercase().getStringResourceOrName("", ctx)) },
text = {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.key_code), Modifier.weight(0.5f))
TextField(
value = code,
onValueChange = { code = it },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(0.5f)
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.long_press_code), Modifier.weight(0.5f))
TextField(
value = longPressCode,
onValueChange = { longPressCode = it },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(0.5f)
)
}
}
},
)
}
@Preview
@Composable
fun PreviewToolbarKeyCustomizer() {
Settings.init(LocalContext.current)
ToolbarKeyCustomizer(ToolbarKey.CUT) { }
}
@Preview
@Composable
fun PreviewToolbarKeysCustomizer() {
Settings.init(LocalContext.current)
KeyboardIconsSet.instance.loadIcons(LocalContext.current)
ToolbarKeysCustomizer { }
}
private fun checkCode(code: TextFieldValue) = runCatching {
code.text.toIntOrNull()?.takeIf { it.checkAndConvertCode() <= Char.MAX_VALUE.code }
}.getOrNull()

View file

@ -0,0 +1,128 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.preferences
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import helium314.keyboard.latin.R
import helium314.keyboard.latin.common.FileUtils
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.utils.Log
import helium314.keyboard.latin.utils.getActivity
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.Setting
import helium314.keyboard.settings.SettingsActivity
import helium314.keyboard.settings.dialogs.ConfirmationDialog
import helium314.keyboard.settings.dialogs.InfoDialog
import helium314.keyboard.settings.keyboardNeedsReload
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun BackgroundImagePref(setting: Setting, isLandscape: Boolean) {
var showDayNightDialog by rememberSaveable { mutableStateOf(false) }
var showSelectionDialog by rememberSaveable { mutableStateOf(false) }
var showErrorDialog by rememberSaveable { mutableStateOf(false) }
var isNight by rememberSaveable { mutableStateOf(false) }
val ctx = LocalContext.current
fun getFile() = Settings.getCustomBackgroundFile(ctx, isNight, isLandscape)
val b = (ctx.getActivity() as? SettingsActivity)?.prefChanged?.collectAsState()
if ((b?.value ?: 0) < 0) // necessary to reload dayNightPref
Log.v("irrelevant", "stupid way to trigger recomposition on preference change")
val dayNightPref = Settings.readDayNightPref(ctx.prefs(), ctx.resources)
if (!dayNightPref)
isNight = false
val scope = rememberCoroutineScope()
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val uri = result.data?.data ?: return@rememberLauncherForActivityResult
showSelectionDialog = false
showDayNightDialog = false
scope.launch(Dispatchers.IO) {
if (!setBackgroundImage(ctx, uri, isNight, isLandscape))
showErrorDialog = true
}
}
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("image/*")
Preference(
name = setting.title,
onClick = {
if (dayNightPref) {
showDayNightDialog = true
} else if (!getFile().exists()) {
launcher.launch(intent)
} else {
showSelectionDialog = true
}
}
)
if (showDayNightDialog) {
ConfirmationDialog(
onDismissRequest = { showDayNightDialog = false },
onConfirmed = {
isNight = false
if (getFile().exists())
showSelectionDialog = true
else launcher.launch(intent)
},
confirmButtonText = stringResource(R.string.day_or_night_day),
cancelButtonText = "",
onNeutral = {
isNight = true
if (getFile().exists())
showSelectionDialog = true
else launcher.launch(intent)
},
neutralButtonText = stringResource(R.string.day_or_night_night),
title = { Text(stringResource(R.string.day_or_night_image)) },
)
}
if (showSelectionDialog) {
ConfirmationDialog(
onDismissRequest = { showSelectionDialog = false },
title = { Text(stringResource(R.string.customize_background_image)) },
confirmButtonText = stringResource(R.string.button_load_custom),
onConfirmed = { launcher.launch(intent) },
neutralButtonText = stringResource(R.string.delete),
onNeutral = {
getFile().delete()
Settings.clearCachedBackgroundImages()
keyboardNeedsReload = true
}
)
}
if (showErrorDialog) {
InfoDialog(stringResource(R.string.file_read_error)) { showErrorDialog = false }
}
}
private fun setBackgroundImage(ctx: Context, uri: Uri, isNight: Boolean, isLandscape: Boolean): Boolean {
val imageFile = Settings.getCustomBackgroundFile(ctx, isNight, isLandscape)
FileUtils.copyContentUriToNewFile(uri, ctx, imageFile)
keyboardNeedsReload = true
try {
BitmapFactory.decodeFile(imageFile.absolutePath)
} catch (_: Exception) {
imageFile.delete()
return false
}
Settings.clearCachedBackgroundImages()
return true
}

View file

@ -0,0 +1,302 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.preferences
import android.app.Activity
import android.content.Intent
import android.content.SharedPreferences
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.preference.PreferenceManager
import helium314.keyboard.dictionarypack.DictionaryPackConstants
import helium314.keyboard.keyboard.internal.keyboard_parser.LAYOUT_NUMBER
import helium314.keyboard.keyboard.internal.keyboard_parser.LAYOUT_NUMPAD
import helium314.keyboard.keyboard.internal.keyboard_parser.LAYOUT_NUMPAD_LANDSCAPE
import helium314.keyboard.keyboard.internal.keyboard_parser.LAYOUT_PHONE
import helium314.keyboard.keyboard.internal.keyboard_parser.LAYOUT_PHONE_SYMBOLS
import helium314.keyboard.keyboard.internal.keyboard_parser.LAYOUT_SYMBOLS
import helium314.keyboard.keyboard.internal.keyboard_parser.LAYOUT_SYMBOLS_ARABIC
import helium314.keyboard.keyboard.internal.keyboard_parser.LAYOUT_SYMBOLS_SHIFTED
import helium314.keyboard.latin.R
import helium314.keyboard.latin.checkVersionUpgrade
import helium314.keyboard.latin.common.FileUtils
import helium314.keyboard.latin.common.LocaleUtils.constructLocale
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.settings.USER_DICTIONARY_SUFFIX
import helium314.keyboard.latin.utils.AdditionalSubtypeUtils
import helium314.keyboard.latin.utils.CUSTOM_LAYOUT_PREFIX
import helium314.keyboard.latin.utils.DeviceProtectedUtils
import helium314.keyboard.latin.utils.ExecutorUtils
import helium314.keyboard.latin.utils.Log
import helium314.keyboard.latin.utils.getActivity
import helium314.keyboard.latin.utils.onCustomLayoutFileListChanged
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.latin.utils.reloadEnabledSubtypes
import helium314.keyboard.latin.utils.updateAdditionalSubtypes
import helium314.keyboard.settings.Setting
import helium314.keyboard.settings.SettingsActivity
import helium314.keyboard.settings.dialogs.ConfirmationDialog
import helium314.keyboard.settings.dialogs.InfoDialog
import helium314.keyboard.settings.keyboardNeedsReload
import kotlinx.serialization.json.Json
import java.io.File
import java.io.FileInputStream
import java.io.OutputStream
import java.util.concurrent.CountDownLatch
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
@Composable
fun BackupRestorePreference(setting: Setting) {
var showDialog by rememberSaveable { mutableStateOf(false) }
val ctx = LocalContext.current
val prefs = ctx.prefs()
var error: String? by rememberSaveable { mutableStateOf(null) }
val backupFilePatterns by lazy { listOf(
"blacklists/.*\\.txt".toRegex(),
"layouts/$CUSTOM_LAYOUT_PREFIX+\\..{0,4}".toRegex(), // can't expect a period at the end, as this would break restoring older backups
"dicts/.*/.*user\\.dict".toRegex(),
"UserHistoryDictionary.*/UserHistoryDictionary.*\\.(body|header)".toRegex(),
"custom_background_image.*".toRegex(),
"custom_font".toRegex(),
) }
val backupLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val uri = result.data?.data ?: return@rememberLauncherForActivityResult
// zip all files matching the backup patterns
// essentially this is the typed words information, and user-added dictionaries
val filesDir = ctx.filesDir ?: return@rememberLauncherForActivityResult
val filesPath = filesDir.path + File.separator
val files = mutableListOf<File>()
filesDir.walk().forEach { file ->
val path = file.path.replace(filesPath, "")
if (backupFilePatterns.any { path.matches(it) })
files.add(file)
}
val protectedFilesDir = DeviceProtectedUtils.getFilesDir(ctx)
val protectedFilesPath = protectedFilesDir.path + File.separator
val protectedFiles = mutableListOf<File>()
protectedFilesDir.walk().forEach { file ->
val path = file.path.replace(protectedFilesPath, "")
if (backupFilePatterns.any { path.matches(it) })
protectedFiles.add(file)
}
val wait = CountDownLatch(1)
ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute {
try {
ctx.getActivity()?.contentResolver?.openOutputStream(uri)?.use { os ->
// write files to zip
val zipStream = ZipOutputStream(os)
files.forEach {
val fileStream = FileInputStream(it).buffered()
zipStream.putNextEntry(ZipEntry(it.path.replace(filesPath, "")))
fileStream.copyTo(zipStream, 1024)
fileStream.close()
zipStream.closeEntry()
}
protectedFiles.forEach {
val fileStream = FileInputStream(it).buffered()
zipStream.putNextEntry(ZipEntry(it.path.replace(protectedFilesDir.path, "unprotected")))
fileStream.copyTo(zipStream, 1024)
fileStream.close()
zipStream.closeEntry()
}
zipStream.putNextEntry(ZipEntry(PREFS_FILE_NAME))
settingsToJsonStream(prefs.all, zipStream)
zipStream.closeEntry()
zipStream.putNextEntry(ZipEntry(PROTECTED_PREFS_FILE_NAME))
settingsToJsonStream(PreferenceManager.getDefaultSharedPreferences(ctx).all, zipStream)
zipStream.closeEntry()
zipStream.close()
}
} catch (t: Throwable) {
error = "b" + t.message
Log.w("AdvancedScreen", "error during backup", t)
} finally {
wait.countDown()
}
}
wait.await()
}
val restoreLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val uri = result.data?.data ?: return@rememberLauncherForActivityResult
val wait = CountDownLatch(1)
ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute {
try {
ctx.getActivity()?.contentResolver?.openInputStream(uri)?.use { inputStream ->
ZipInputStream(inputStream).use { zip ->
var entry: ZipEntry? = zip.nextEntry
val filesDir = ctx.filesDir?.path ?: return@execute
val deviceProtectedFilesDir = DeviceProtectedUtils.getFilesDir(ctx).path
Settings.getInstance().stopListener()
while (entry != null) {
if (entry.name.startsWith("unprotected${File.separator}")) {
val adjustedName = entry.name.substringAfter("unprotected${File.separator}")
if (backupFilePatterns.any { adjustedName.matches(it) }) {
val targetFileName = upgradeFileNames(adjustedName)
val file = File(deviceProtectedFilesDir, targetFileName)
FileUtils.copyStreamToNewFile(zip, file)
}
} else if (backupFilePatterns.any { entry!!.name.matches(it) }) {
val targetFileName = upgradeFileNames(entry.name)
val file = File(filesDir, targetFileName)
FileUtils.copyStreamToNewFile(zip, file)
} else if (entry.name == PREFS_FILE_NAME) {
val prefLines = String(zip.readBytes()).split("\n")
prefs.edit().clear().apply()
readJsonLinesToSettings(prefLines, prefs)
} else if (entry.name == PROTECTED_PREFS_FILE_NAME) {
val prefLines = String(zip.readBytes()).split("\n")
val protectedPrefs = PreferenceManager.getDefaultSharedPreferences(ctx)
protectedPrefs.edit().clear().apply()
readJsonLinesToSettings(prefLines, protectedPrefs)
}
zip.closeEntry()
entry = zip.nextEntry
}
}
}
} catch (t: Throwable) {
error = "r" + t.message
Log.w("AdvancedScreen", "error during restore", t)
} finally {
wait.countDown()
}
}
wait.await()
checkVersionUpgrade(ctx)
Settings.getInstance().startListener()
val additionalSubtypes = Settings.readPrefAdditionalSubtypes(prefs, ctx.resources)
updateAdditionalSubtypes(AdditionalSubtypeUtils.createAdditionalSubtypesArray(additionalSubtypes))
reloadEnabledSubtypes(ctx)
val newDictBroadcast = Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION)
ctx.getActivity()?.sendBroadcast(newDictBroadcast)
onCustomLayoutFileListChanged()
(ctx.getActivity() as? SettingsActivity)?.prefChanged?.value = 210 // for settings reload
keyboardNeedsReload = true
}
Preference(name = setting.title, onClick = { showDialog = true })
if (showDialog) {
ConfirmationDialog(
onDismissRequest = { showDialog = false },
title = { Text(stringResource(R.string.backup_restore_title)) },
text = { Text(stringResource(R.string.backup_restore_message)) },
confirmButtonText = stringResource(R.string.button_backup),
neutralButtonText = stringResource(R.string.button_restore),
onNeutral = {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("application/zip")
restoreLauncher.launch(intent)
},
onConfirmed = {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.putExtra(
Intent.EXTRA_TITLE,
ctx.getString(R.string.english_ime_name)
.replace(" ", "_") + "_backup.zip"
)
.setType("application/zip")
backupLauncher.launch(intent)
}
)
}
if (error != null) {
InfoDialog(
if (error!!.startsWith("b"))
stringResource(R.string.backup_error, error!!.drop(1))
else stringResource(R.string.restore_error, error!!.drop(1))
) { error = null }
}
}
@Suppress("UNCHECKED_CAST") // it is checked... but whatever (except string set, because can't check for that))
private fun settingsToJsonStream(settings: Map<String?, Any?>, out: OutputStream) {
val booleans = settings.filter { it.key is String && it.value is Boolean } as Map<String, Boolean>
val ints = settings.filter { it.key is String && it.value is Int } as Map<String, Int>
val longs = settings.filter { it.key is String && it.value is Long } as Map<String, Long>
val floats = settings.filter { it.key is String && it.value is Float } as Map<String, Float>
val strings = settings.filter { it.key is String && it.value is String } as Map<String, String>
val stringSets = settings.filter { it.key is String && it.value is Set<*> } as Map<String, Set<String>>
// now write
out.write("boolean settings\n".toByteArray())
out.write(Json.encodeToString(booleans).toByteArray())
out.write("\nint settings\n".toByteArray())
out.write(Json.encodeToString(ints).toByteArray())
out.write("\nlong settings\n".toByteArray())
out.write(Json.encodeToString(longs).toByteArray())
out.write("\nfloat settings\n".toByteArray())
out.write(Json.encodeToString(floats).toByteArray())
out.write("\nstring settings\n".toByteArray())
out.write(Json.encodeToString(strings).toByteArray())
out.write("\nstring set settings\n".toByteArray())
out.write(Json.encodeToString(stringSets).toByteArray())
}
private fun readJsonLinesToSettings(list: List<String>, prefs: SharedPreferences): Boolean {
val i = list.iterator()
val e = prefs.edit()
try {
while (i.hasNext()) {
when (i.next()) {
"boolean settings" -> Json.decodeFromString<Map<String, Boolean>>(i.next()).forEach { e.putBoolean(it.key, it.value) }
"int settings" -> Json.decodeFromString<Map<String, Int>>(i.next()).forEach { e.putInt(it.key, it.value) }
"long settings" -> Json.decodeFromString<Map<String, Long>>(i.next()).forEach { e.putLong(it.key, it.value) }
"float settings" -> Json.decodeFromString<Map<String, Float>>(i.next()).forEach { e.putFloat(it.key, it.value) }
"string settings" -> Json.decodeFromString<Map<String, String>>(i.next()).forEach { e.putString(it.key, it.value) }
"string set settings" -> Json.decodeFromString<Map<String, Set<String>>>(i.next()).forEach { e.putStringSet(it.key, it.value) }
}
}
e.apply()
return true
} catch (e: Exception) {
return false
}
}
// todo (later): remove this when new package name has been in use for long enough, this is only for migrating from old openboard name
private fun upgradeFileNames(originalName: String): String {
return when {
originalName.endsWith(USER_DICTIONARY_SUFFIX) -> {
// replace directory after switch to language tag
val dirName = originalName.substringAfter(File.separator).substringBefore(File.separator)
originalName.replace(dirName, dirName.constructLocale().toLanguageTag())
}
originalName.startsWith("blacklists") -> {
// replace file name after switch to language tag
val fileName = originalName.substringAfter("blacklists${File.separator}").substringBefore(".txt")
originalName.replace(fileName, fileName.constructLocale().toLanguageTag())
}
originalName.startsWith("layouts") -> {
// replace file name after switch to language tag, but only if it's not a layout
val localeString = originalName.substringAfter(".").substringBefore(".")
if (localeString in listOf(LAYOUT_SYMBOLS, LAYOUT_SYMBOLS_SHIFTED, LAYOUT_SYMBOLS_ARABIC, LAYOUT_NUMBER, LAYOUT_NUMPAD, LAYOUT_NUMPAD_LANDSCAPE, LAYOUT_PHONE, LAYOUT_PHONE_SYMBOLS))
return originalName // it's a layout!
val locale = localeString.constructLocale()
if (locale.toLanguageTag() != "und")
originalName.replace(localeString, locale.toLanguageTag())
else
originalName // no valid locale -> must be symbols layout, don't change
}
originalName.startsWith("UserHistoryDictionary") -> {
val localeString = originalName.substringAfter(".").substringBefore(".")
val locale = localeString.constructLocale()
originalName.replace(localeString, locale.toLanguageTag())
}
else -> originalName
}
}
private const val PREFS_FILE_NAME = "preferences.json"
private const val PROTECTED_PREFS_FILE_NAME = "protected_preferences.json"

View file

@ -0,0 +1,76 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.preferences
import android.app.Activity
import android.content.Intent
import android.graphics.Typeface
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import helium314.keyboard.latin.R
import helium314.keyboard.latin.common.FileUtils
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.utils.DeviceProtectedUtils
import helium314.keyboard.settings.Setting
import helium314.keyboard.settings.dialogs.ConfirmationDialog
import helium314.keyboard.settings.dialogs.InfoDialog
import helium314.keyboard.settings.keyboardNeedsReload
import java.io.File
@Composable
fun CustomFontPreference(setting: Setting) {
val ctx = LocalContext.current
var showDialog by rememberSaveable { mutableStateOf(false) }
var showErrorDialog by rememberSaveable { mutableStateOf(false) }
val fontFile = Settings.getCustomFontFile(ctx)
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val uri = it.data?.data ?: return@rememberLauncherForActivityResult
val tempFile = File(DeviceProtectedUtils.getFilesDir(ctx), "temp_file")
FileUtils.copyContentUriToNewFile(uri, ctx, tempFile)
try {
Typeface.createFromFile(tempFile)
fontFile.delete()
tempFile.renameTo(fontFile)
Settings.clearCachedTypeface()
keyboardNeedsReload = true
} catch (_: Exception) {
showErrorDialog = true
tempFile.delete()
}
}
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("*/*")
Preference(
name = setting.title,
onClick = {
if (fontFile.exists())
showDialog = true
else launcher.launch(intent)
},
)
if (showDialog)
ConfirmationDialog(
onDismissRequest = { showDialog = false },
onConfirmed = { launcher.launch(intent) },
onNeutral = {
fontFile.delete()
Settings.clearCachedTypeface()
keyboardNeedsReload = true
},
neutralButtonText = stringResource(R.string.delete),
confirmButtonText = stringResource(R.string.load),
title = { Text(stringResource(R.string.custom_font)) }
)
if (showErrorDialog)
InfoDialog(stringResource(R.string.file_read_error)) { showErrorDialog = false }
}

View file

@ -0,0 +1,59 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.preferences
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import helium314.keyboard.latin.utils.CUSTOM_LAYOUT_PREFIX
import helium314.keyboard.latin.utils.getCustomLayoutFiles
import helium314.keyboard.settings.Setting
import helium314.keyboard.settings.dialogs.LayoutEditDialog
import helium314.keyboard.settings.dialogs.ListPickerDialog
import java.io.File
@Composable
fun LayoutEditPreference(
setting: Setting,
items: List<String>,
getItemName: @Composable (String) -> String,
getDefaultLayout: @Composable (String?) -> String?,
) {
var showDialog by rememberSaveable { mutableStateOf(false) }
val ctx = LocalContext.current
var layout: String? by rememberSaveable { mutableStateOf(null) }
Preference(
name = setting.title,
onClick = { showDialog = true }
)
if (showDialog) {
ListPickerDialog(
onDismissRequest = { showDialog = false },
showRadioButtons = false,
confirmImmediately = true,
items = items,
getItemName = getItemName,
onItemSelected = { layout = it },
title = { Text(setting.title) }
)
}
if (layout != null) {
val customLayoutName = getCustomLayoutFiles(ctx).firstOrNull {
if (layout!!.startsWith(CUSTOM_LAYOUT_PREFIX))
it.name.startsWith("$layout.")
else it.name.startsWith("$CUSTOM_LAYOUT_PREFIX$layout.")
}?.name
val originalLayout = if (customLayoutName != null) null
else getDefaultLayout(layout)?.let { ctx.assets.open("layouts" + File.separator + it).reader().readText() }
LayoutEditDialog(
layoutName = customLayoutName ?: "$CUSTOM_LAYOUT_PREFIX$layout.",
startContent = originalLayout,
displayName = getItemName(layout!!),
onDismissRequest = { layout = null }
)
}
}

View file

@ -0,0 +1,113 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.preferences
import android.app.Activity
import android.content.Intent
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import helium314.keyboard.latin.R
import helium314.keyboard.latin.common.FileUtils
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.utils.ChecksumCalculator
import helium314.keyboard.latin.utils.JniUtils
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.Setting
import helium314.keyboard.settings.dialogs.ConfirmationDialog
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
@Composable
fun LoadGestureLibPreference(setting: Setting) {
var showDialog by rememberSaveable { mutableStateOf(false) }
val ctx = LocalContext.current
val prefs = ctx.prefs()
val abi = Build.SUPPORTED_ABIS[0]
val libFile = File(ctx.filesDir.absolutePath + File.separator + JniUtils.JNI_LIB_IMPORT_FILE_NAME)
fun renameToLibFileAndRestart(file: File, checksum: String) {
libFile.delete()
// store checksum in default preferences (soo JniUtils)
prefs.edit().putString(Settings.PREF_LIBRARY_CHECKSUM, checksum).commit()
file.renameTo(libFile)
Runtime.getRuntime().exit(0) // exit will restart the app, so library will be loaded
}
var tempFilePath: String? by rememberSaveable { mutableStateOf(null) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val uri = result.data?.data ?: return@rememberLauncherForActivityResult
val tmpfile = File(ctx.filesDir.absolutePath + File.separator + "tmplib")
try {
val otherTemporaryFile = File(ctx.filesDir.absolutePath + File.separator + "tmpfile")
FileUtils.copyContentUriToNewFile(uri, ctx, otherTemporaryFile)
val inputStream = FileInputStream(otherTemporaryFile)
val outputStream = FileOutputStream(tmpfile)
outputStream.use {
tmpfile.setReadOnly() // as per recommendations in https://developer.android.com/about/versions/14/behavior-changes-14#safer-dynamic-code-loading
FileUtils.copyStreamToOtherStream(inputStream, it)
}
otherTemporaryFile.delete()
val checksum = ChecksumCalculator.checksum(tmpfile.inputStream()) ?: ""
if (checksum == JniUtils.expectedDefaultChecksum()) {
renameToLibFileAndRestart(tmpfile, checksum)
} else {
tempFilePath = tmpfile.absolutePath
AlertDialog.Builder(ctx)
.setMessage(ctx.getString(R.string.checksum_mismatch_message, abi))
.setPositiveButton(android.R.string.ok) { _, _ -> renameToLibFileAndRestart(tmpfile, checksum) }
.setNegativeButton(android.R.string.cancel) { _, _ -> tmpfile.delete() }
.show()
}
} catch (e: IOException) {
tmpfile.delete()
// should inform user, but probably the issues will only come when reading the library
}
}
Preference(
name = setting.title,
onClick = { showDialog = true }
)
if (showDialog) {
ConfirmationDialog(
onDismissRequest = { showDialog = false },
onConfirmed = {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("application/octet-stream")
launcher.launch(intent)
},
title = { Text(stringResource(R.string.load_gesture_library)) },
text = { Text(stringResource(R.string.load_gesture_library_message, abi)) },
neutralButtonText = if (libFile.exists()) stringResource(R.string.load_gesture_library_button_delete) else null,
onNeutral = {
libFile.delete()
prefs.edit().remove(Settings.PREF_LIBRARY_CHECKSUM).commit()
Runtime.getRuntime().exit(0)
}
)
}
if (tempFilePath != null)
ConfirmationDialog(
onDismissRequest = {
File(tempFilePath!!).delete()
tempFilePath = null
},
text = { Text(stringResource(R.string.checksum_mismatch_message, abi)) },
onConfirmed = {
val tempFile = File(tempFilePath!!)
renameToLibFileAndRestart(tempFile, ChecksumCalculator.checksum(tempFile.inputStream()) ?: "")
}
)
}

View file

@ -0,0 +1,166 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.preferences
import androidx.annotation.DrawableRes
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.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import helium314.keyboard.latin.R
// partially taken from StreetComplete / SCEE
@Composable
fun PreferenceCategory(
title: String,
modifier: Modifier = Modifier,
) {
Column {
HorizontalDivider()
Text(
text = title,
modifier = modifier.padding(top = 12.dp, start = 16.dp, end = 8.dp, bottom = 8.dp),
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.titleSmall
)
}
}
@Composable
fun Preference(
name: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
description: String? = null,
@DrawableRes icon: Int? = null,
value: @Composable (RowScope.() -> Unit)? = null,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable { onClick() }
.heightIn(min = 44.dp)
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null)
Icon(painterResource(icon), name, modifier = Modifier.size(36.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = name, style = MaterialTheme.typography.bodyLarge)
if (description != null) {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodyMedium,
LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant
) {
Text(
text = description,
modifier = Modifier.padding(top = 2.dp)
)
}
}
}
if (value != null) {
CompositionLocalProvider(
LocalTextStyle provides LocalTextStyle.current.copy(
textAlign = TextAlign.End,
hyphens = Hyphens.Auto
),
LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant
) {
Row(
horizontalArrangement = Arrangement.spacedBy(
space = 8.dp,
alignment = Alignment.End
),
verticalAlignment = Alignment.CenterVertically,
) { value() }
}
}
}
}
@Preview
@Composable
private fun PreferencePreview() {
Surface {
Column {
PreferenceCategory("Preference Category")
Preference(
name = "Preference",
onClick = {},
)
Preference(
name = "Preference with icon",
onClick = {},
icon = R.drawable.ic_settings_about_foreground
)
SliderPreference(
name = "SliderPreference",
key = "",
default = 1,
description = { it.toString() },
range = -5f..5f
)
Preference(
name = "Preference with icon and description",
description = "some text",
onClick = {},
icon = R.drawable.ic_settings_about_foreground
)
Preference(
name = "Preference with switch",
onClick = {}
) {
Switch(checked = true, onCheckedChange = {})
}
SwitchPreference(
name = "SwitchPreference",
key = "none",
default = true
)
Preference(
name = "Preference",
onClick = {},
description = "A long description which may actually be several lines long, so it should wrap."
) {
Icon(painterResource(R.drawable.ic_arrow_left), null)
}
Preference(
name = "Long preference name that wraps",
onClick = {},
) {
Text("Long preference value")
}
Preference(
name = "Long preference name 2",
onClick = {},
description = "hello I am description"
) {
Text("Long preference value")
}
}
}
}

View file

@ -0,0 +1,69 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.preferences
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.stringResource
import helium314.keyboard.keyboard.internal.KeyboardIconsSet
import helium314.keyboard.latin.R
import helium314.keyboard.latin.utils.getStringResourceOrName
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.Setting
import helium314.keyboard.settings.dialogs.ReorderDialog
import helium314.keyboard.settings.keyboardNeedsReload
import helium314.keyboard.settings.screens.GetIcon
@Composable
fun ReorderSwitchPreference(setting: Setting, default: String) {
var showDialog by rememberSaveable { mutableStateOf(false) }
Preference(
name = setting.title,
description = setting.description,
onClick = { showDialog = true },
)
if (showDialog) {
val ctx = LocalContext.current
val prefs = ctx.prefs()
val items = prefs.getString(setting.key, default)!!.split(";").mapTo(ArrayList()) {
val both = it.split(",")
KeyAndState(both.first(), both.last().toBoolean())
}
ReorderDialog(
onConfirmed = { reorderedItems ->
val value = reorderedItems.joinToString(";") { it.name + "," + it.state }
prefs.edit().putString(setting.key, value).apply()
keyboardNeedsReload = true
},
onDismissRequest = { showDialog = false },
onNeutral = { prefs.edit().remove(setting.key).apply() },
neutralButtonText = if (prefs.contains(setting.key)) stringResource(R.string.button_default) else null,
items = items,
title = { Text(setting.title) },
displayItem = { item ->
var checked by rememberSaveable { mutableStateOf(item.state) }
Row(verticalAlignment = Alignment.CenterVertically) {
KeyboardIconsSet.instance.GetIcon(item.name)
val text = item.name.lowercase().getStringResourceOrName("", ctx)
Text(text, Modifier.weight(1f))
Switch(
checked = checked,
onCheckedChange = { item.state = it; checked = it }
)
}
},
getKey = { it.name }
)
}
}
private class KeyAndState(var name: String, var state: Boolean)

View file

@ -0,0 +1,129 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.preferences
import android.content.SharedPreferences
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.edit
import helium314.keyboard.latin.utils.Log
import helium314.keyboard.latin.utils.getActivity
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.Setting
import helium314.keyboard.settings.SettingsActivity
import helium314.keyboard.settings.dialogs.ListPickerDialog
import helium314.keyboard.settings.dialogs.SliderDialog
import kotlin.math.roundToInt
@Composable
/** Slider preference for Int or Float (weird casting stuff, but should be fine) */
fun <T: Number> SliderPreference(
name: String,
modifier: Modifier = Modifier,
key: String,
description: @Composable (T) -> String,
default: T,
range: ClosedFloatingPointRange<Float>,
stepSize: Int? = null,
onValueChanged: (Float) -> Unit = { },
) {
val ctx = LocalContext.current
val prefs = ctx.prefs()
val b = (ctx.getActivity() as? SettingsActivity)?.prefChanged?.collectAsState()
if ((b?.value ?: 0) < 0)
Log.v("irrelevant", "stupid way to trigger recomposition on preference change")
val initialValue = if (default is Int || default is Float)
getPrefOfType(prefs, key, default)
else throw IllegalArgumentException("only float and int are supported")
var showDialog by rememberSaveable { mutableStateOf(false) }
Preference(
name = name,
onClick = { showDialog = true },
modifier = modifier,
description = description(initialValue)
)
if (showDialog)
SliderDialog(
onDismissRequest = { showDialog = false },
onDone = {
if (default is Int) prefs.edit().putInt(key, it.toInt()).apply()
else prefs.edit().putFloat(key, it).apply()
},
initialValue = initialValue.toFloat(),
range = range,
positionString = {
@Suppress("UNCHECKED_CAST")
description((if (default is Int) it.roundToInt() else it) as T)
},
onValueChanged = onValueChanged,
showDefault = true,
onDefault = { prefs.edit().remove(key).apply() },
intermediateSteps = stepSize?.let {
// this is not nice, but slider wants it like this...
((range.endInclusive - range.start) / it - 1).toInt()
}
)
}
@Composable
// just in here so we can keep getPrefOfType private... rename file?
fun <T: Any> ListPreference(
setting: Setting,
items: List<Pair<String, T>>,
default: T,
onChanged: (T) -> Unit = { }
) {
var showDialog by rememberSaveable { mutableStateOf(false) }
val prefs = LocalContext.current.prefs()
val selected = items.firstOrNull { it.second == getPrefOfType(prefs, setting.key, default) }
Preference(
name = setting.title,
description = selected?.first,
onClick = { showDialog = true }
)
if (showDialog) {
ListPickerDialog(
onDismissRequest = { showDialog = false },
items = items,
onItemSelected = {
if (it == selected) return@ListPickerDialog
putPrefOfType(prefs, setting.key, it.second)
onChanged(it.second)
},
selectedItem = selected,
title = { Text(setting.title) },
getItemName = { it.first }
)
}
}
@Suppress("UNCHECKED_CAST")
private fun <T: Any> getPrefOfType(prefs: SharedPreferences, key: String, default: T): T =
when (default) {
is String -> prefs.getString(key, default)
is Int -> prefs.getInt(key, default)
is Long -> prefs.getLong(key, default)
is Float -> prefs.getFloat(key, default)
is Boolean -> prefs.getBoolean(key, default)
else -> throw IllegalArgumentException("unknown type ${default.javaClass}")
} as T
private fun <T: Any> putPrefOfType(prefs: SharedPreferences, key: String, value: T) =
prefs.edit {
when (value) {
is String -> putString(key, value)
is Int -> putInt(key, value)
is Long -> putLong(key, value)
is Float -> putFloat(key, value)
is Boolean -> putBoolean(key, value)
else -> throw IllegalArgumentException("unknown type ${value.javaClass}")
}
}

View file

@ -0,0 +1,68 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.preferences
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import helium314.keyboard.latin.utils.Log
import helium314.keyboard.latin.utils.getActivity
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.Setting
import helium314.keyboard.settings.SettingsActivity
@Composable
fun SwitchPreference(
setting: Setting,
default: Boolean,
allowCheckedChange: (Boolean) -> Boolean = { true },
onCheckedChange: (Boolean) -> Unit = { }
) {
SwitchPreference(
name = setting.title,
description = setting.description,
key = setting.key,
default = default,
allowCheckedChange = allowCheckedChange,
onCheckedChange = onCheckedChange
)
}
@Composable
fun SwitchPreference(
name: String,
modifier: Modifier = Modifier,
key: String,
default: Boolean,
description: String? = null,
allowCheckedChange: (Boolean) -> Boolean = { true }, // true means ok, usually for showing some dialog
onCheckedChange: (Boolean) -> Unit = { },
) {
val ctx = LocalContext.current
val prefs = ctx.prefs()
val b = (ctx.getActivity() as? SettingsActivity)?.prefChanged?.collectAsState()
if ((b?.value ?: 0) < 0)
Log.v("irrelevant", "stupid way to trigger recomposition on preference change")
var value = prefs.getBoolean(key, default)
fun switched(newValue: Boolean) {
if (!allowCheckedChange(newValue)) {
value = !newValue
return
}
value = newValue
prefs.edit().putBoolean(key, newValue).apply()
onCheckedChange(newValue)
}
Preference(
name = name,
onClick = { switched(!value) },
modifier = modifier,
description = description
) {
Switch(
checked = value,
onCheckedChange = { switched(it) },
)
}
}

View file

@ -0,0 +1,180 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.screens
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.text.method.LinkMovementMethod
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.net.toUri
import helium314.keyboard.latin.BuildConfig
import helium314.keyboard.latin.R
import helium314.keyboard.latin.settings.DebugSettings
import helium314.keyboard.latin.utils.Log
import helium314.keyboard.latin.utils.SpannableStringUtils
import helium314.keyboard.latin.utils.getActivity
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.SettingsContainer
import helium314.keyboard.settings.SettingsWithoutKey
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.Theme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun AboutScreen(
onClickBack: () -> Unit,
) {
val items = listOf(
SettingsWithoutKey.APP,
SettingsWithoutKey.VERSION,
SettingsWithoutKey.LICENSE,
SettingsWithoutKey.HIDDEN_FEATURES,
SettingsWithoutKey.GITHUB,
SettingsWithoutKey.SAVE_LOG
)
SearchSettingsScreen(
onClickBack = onClickBack,
title = stringResource(R.string.settings_screen_about),
settings = items
)
}
fun createAboutSettings(context: Context) = listOf(
Setting(context, SettingsWithoutKey.APP, R.string.english_ime_name, R.string.app_slogan) {
Preference(
name = it.title,
description = it.description,
onClick = { },
icon = R.drawable.ic_launcher_foreground // use the bitmap trick here if we really want the colored icon
)
},
Setting(context, SettingsWithoutKey.VERSION, R.string.version) {
var count by rememberSaveable { mutableIntStateOf(0) }
val ctx = LocalContext.current
val prefs = ctx.prefs()
Preference(
name = it.title,
description = stringResource(R.string.version_text, BuildConfig.VERSION_NAME),
onClick = {
if (prefs.getBoolean(DebugSettings.PREF_SHOW_DEBUG_SETTINGS, false) || BuildConfig.DEBUG)
return@Preference
count++
if (count < 5) return@Preference
prefs.edit().putBoolean(DebugSettings.PREF_SHOW_DEBUG_SETTINGS, true).apply()
Toast.makeText(ctx, R.string.prefs_debug_settings_enabled, Toast.LENGTH_LONG).show()
},
icon = R.drawable.ic_settings_about_foreground
)
},
Setting(context, SettingsWithoutKey.LICENSE, R.string.license, R.string.gnu_gpl) {
val ctx = LocalContext.current
Preference(
name = it.title,
description = it.description,
onClick = {
val intent = Intent()
intent.data = "https://github.com/Helium314/HeliBoard/blob/main/LICENSE-GPL-3".toUri()
intent.action = Intent.ACTION_VIEW
ctx.startActivity(intent)
},
icon = R.drawable.ic_settings_about_license_foreground
)
},
Setting(context, SettingsWithoutKey.HIDDEN_FEATURES, R.string.hidden_features_title, R.string.hidden_features_summary) {
val ctx = LocalContext.current
Preference(
name = it.title,
description = it.description,
onClick = {
// Compose dialogs are in a rather sad state. They don't understand HTML, and don't scroll without customization.
// this should be re-done in compose, but... bah
val link = ("<a href=\"https://developer.android.com/reference/android/content/Context#createDeviceProtectedStorageContext()\">"
+ ctx.getString(R.string.hidden_features_text) + "</a>")
val message = ctx.getString(R.string.hidden_features_message, link)
val dialogMessage = SpannableStringUtils.fromHtml(message)
val builder = AlertDialog.Builder(ctx)
.setIcon(R.drawable.ic_settings_about_hidden_features)
.setTitle(R.string.hidden_features_title)
.setMessage(dialogMessage)
.setPositiveButton(R.string.dialog_close, null)
.create()
builder.show()
(builder.findViewById<View>(android.R.id.message) as TextView).movementMethod = LinkMovementMethod.getInstance()
},
icon = R.drawable.ic_settings_about_hidden_features_foreground
)
},
Setting(context, SettingsWithoutKey.GITHUB, R.string.about_github_link) {
val ctx = LocalContext.current
Preference(
name = it.title,
description = it.description,
onClick = {
val intent = Intent()
intent.data = "https://github.com/Helium314/HeliBoard".toUri()
intent.action = Intent.ACTION_VIEW
ctx.startActivity(intent)
},
icon = R.drawable.ic_settings_about_github_foreground
)
},
Setting(context, SettingsWithoutKey.SAVE_LOG, R.string.save_log) { setting ->
val ctx = LocalContext.current
val scope = rememberCoroutineScope()
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val uri = result.data?.data ?: return@rememberLauncherForActivityResult
scope.launch(Dispatchers.IO) {
ctx.getActivity()?.contentResolver?.openOutputStream(uri)?.use { os ->
os.bufferedWriter().use { it.write(Log.getLog().joinToString("\n")) }
}
}
}
Preference(
name = setting.title,
description = setting.description,
onClick = {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.putExtra(
Intent.EXTRA_TITLE,
ctx.getString(R.string.english_ime_name)
.replace(" ", "_") + "_log_${System.currentTimeMillis()}.txt"
)
.setType("text/plain")
launcher.launch(intent)
},
icon = R.drawable.ic_settings_about_log_foreground
)
},
)
@Preview
@Composable
private fun Preview() {
SettingsActivity.settingsContainer = SettingsContainer(LocalContext.current)
Theme(true) {
Surface {
AboutScreen { }
}
}
}

View file

@ -0,0 +1,274 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.screens
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.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.tooling.preview.Preview
import helium314.keyboard.keyboard.KeyboardActionListener
import helium314.keyboard.keyboard.KeyboardLayoutSet
import helium314.keyboard.keyboard.internal.keyboard_parser.RawKeyboardParser
import helium314.keyboard.latin.BuildConfig
import helium314.keyboard.latin.R
import helium314.keyboard.latin.SystemBroadcastReceiver
import helium314.keyboard.latin.common.splitOnWhitespace
import helium314.keyboard.latin.settings.DebugSettings
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.utils.CUSTOM_FUNCTIONAL_LAYOUT_NORMAL
import helium314.keyboard.latin.utils.CUSTOM_FUNCTIONAL_LAYOUT_SYMBOLS
import helium314.keyboard.latin.utils.CUSTOM_FUNCTIONAL_LAYOUT_SYMBOLS_SHIFTED
import helium314.keyboard.latin.utils.CUSTOM_LAYOUT_PREFIX
import helium314.keyboard.latin.utils.getStringResourceOrName
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.SettingsContainer
import helium314.keyboard.settings.preferences.ListPreference
import helium314.keyboard.settings.SettingsWithoutKey
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.preferences.SliderPreference
import helium314.keyboard.settings.preferences.SwitchPreference
import helium314.keyboard.settings.Theme
import helium314.keyboard.settings.dialogs.TextInputDialog
import helium314.keyboard.settings.keyboardNeedsReload
import helium314.keyboard.settings.preferences.BackupRestorePreference
import helium314.keyboard.settings.preferences.LayoutEditPreference
import helium314.keyboard.settings.preferences.LoadGestureLibPreference
@Composable
fun AdvancedSettingsScreen(
onClickBack: () -> Unit,
) {
val prefs = LocalContext.current.prefs()
val items = listOf(
Settings.PREF_ALWAYS_INCOGNITO_MODE,
Settings.PREF_KEY_LONGPRESS_TIMEOUT,
Settings.PREF_SPACE_HORIZONTAL_SWIPE,
Settings.PREF_SPACE_VERTICAL_SWIPE,
if (Settings.readHorizontalSpaceSwipe(prefs) == KeyboardActionListener.SWIPE_SWITCH_LANGUAGE
|| Settings.readVerticalSpaceSwipe(prefs) == KeyboardActionListener.SWIPE_SWITCH_LANGUAGE)
Settings.PREF_LANGUAGE_SWIPE_DISTANCE else null,
Settings.PREF_DELETE_SWIPE,
Settings.PREF_SPACE_TO_CHANGE_LANG,
Settings.PREFS_LONG_PRESS_SYMBOLS_FOR_NUMPAD,
Settings.PREF_ENABLE_EMOJI_ALT_PHYSICAL_KEY,
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) Settings.PREF_SHOW_SETUP_WIZARD_ICON else null,
Settings.PREF_ABC_AFTER_SYMBOL_SPACE,
Settings.PREF_ABC_AFTER_EMOJI,
Settings.PREF_ABC_AFTER_CLIP,
Settings.PREF_CUSTOM_CURRENCY_KEY,
Settings.PREF_MORE_POPUP_KEYS,
SettingsWithoutKey.CUSTOM_SYMBOLS_NUMBER_LAYOUTS,
SettingsWithoutKey.CUSTOM_FUNCTIONAL_LAYOUTS,
SettingsWithoutKey.BACKUP_RESTORE,
if (BuildConfig.DEBUG || prefs.getBoolean(DebugSettings.PREF_SHOW_DEBUG_SETTINGS, false)) SettingsWithoutKey.DEBUG_SETTINGS else null,
R.string.settings_category_experimental,
Settings.PREF_EMOJI_MAX_SDK,
Settings.PREF_URL_DETECTION,
if (BuildConfig.BUILD_TYPE != "nouserlib") SettingsWithoutKey.LOAD_GESTURE_LIB else null
)
SearchSettingsScreen(
onClickBack = onClickBack,
title = stringResource(R.string.settings_screen_advanced),
settings = items
)
}
@SuppressLint("ApplySharedPref")
fun createAdvancedSettings(context: Context) = listOf(
Setting(context, Settings.PREF_ALWAYS_INCOGNITO_MODE,
R.string.incognito, R.string.prefs_force_incognito_mode_summary)
{
SwitchPreference(it, false)
},
Setting(context, Settings.PREF_KEY_LONGPRESS_TIMEOUT, R.string.prefs_key_longpress_timeout_settings) { setting ->
SliderPreference(
name = setting.title,
key = setting.key,
default = 300,
range = 100f..700f,
description = { stringResource(R.string.abbreviation_unit_milliseconds, it.toString()) }
)
},
Setting(context, Settings.PREF_SPACE_HORIZONTAL_SWIPE, R.string.show_horizontal_space_swipe) {
val items = listOf(
stringResource(R.string.space_swipe_move_cursor_entry) to "move_cursor",
stringResource(R.string.switch_language) to "switch_language",
stringResource(R.string.space_swipe_toggle_numpad_entry) to "toggle_numpad",
stringResource(R.string.action_none) to "none",
)
ListPreference(it, items, "move_cursor")
},
Setting(context, Settings.PREF_SPACE_VERTICAL_SWIPE, R.string.show_vertical_space_swipe) {
val items = listOf(
stringResource(R.string.space_swipe_move_cursor_entry) to "move_cursor",
stringResource(R.string.switch_language) to "switch_language",
stringResource(R.string.space_swipe_toggle_numpad_entry) to "toggle_numpad",
stringResource(R.string.action_none) to "none",
)
ListPreference(it, items, "none")
},
Setting(context, Settings.PREF_LANGUAGE_SWIPE_DISTANCE, R.string.prefs_language_swipe_distance) { setting ->
SliderPreference(
name = setting.title,
key = setting.key,
default = 5,
range = 2f..18f,
description = { it.toString() }
)
},
Setting(context, Settings.PREF_DELETE_SWIPE, R.string.delete_swipe, R.string.delete_swipe_summary) {
SwitchPreference(it, true)
},
Setting(context, Settings.PREF_SPACE_TO_CHANGE_LANG,
R.string.prefs_long_press_keyboard_to_change_lang,
R.string.prefs_long_press_keyboard_to_change_lang_summary)
{
SwitchPreference(it, true)
},
Setting(context, Settings.PREFS_LONG_PRESS_SYMBOLS_FOR_NUMPAD, R.string.prefs_long_press_symbol_for_numpad) {
SwitchPreference(it, false)
},
Setting(context, Settings.PREF_ENABLE_EMOJI_ALT_PHYSICAL_KEY, R.string.prefs_enable_emoji_alt_physical_key,
R.string.prefs_enable_emoji_alt_physical_key_summary)
{
SwitchPreference(it, true)
},
Setting(context, Settings.PREF_SHOW_SETUP_WIZARD_ICON, R.string.prefs_enable_emoji_alt_physical_key_summary) {
val ctx = LocalContext.current
SwitchPreference(it, true) { SystemBroadcastReceiver.toggleAppIcon(ctx) }
},
Setting(context, Settings.PREF_ABC_AFTER_SYMBOL_SPACE,
R.string.switch_keyboard_after, R.string.after_symbol_and_space)
{
SwitchPreference(it, true)
},
Setting(context, Settings.PREF_ABC_AFTER_EMOJI, R.string.switch_keyboard_after, R.string.after_emoji) {
SwitchPreference(it, false)
},
Setting(context, Settings.PREF_ABC_AFTER_CLIP, R.string.switch_keyboard_after, R.string.after_clip) {
SwitchPreference(it, false)
},
Setting(context, Settings.PREF_CUSTOM_CURRENCY_KEY, R.string.customize_currencies) { setting ->
var showDialog by remember { mutableStateOf(false) } // todo: textInputDialog...
Preference(
name = setting.title,
onClick = { showDialog = true }
)
if (showDialog) {
val prefs = LocalContext.current.prefs()
TextInputDialog(
onDismissRequest = { showDialog = false },
textInputLabel = { Text(stringResource(R.string.customize_currencies_detail)) },
initialText = prefs.getString(setting.key, "")!!,
onConfirmed = { prefs.edit().putString(setting.key, it).apply(); KeyboardLayoutSet.onSystemLocaleChanged() },
title = { Text(stringResource(R.string.customize_currencies)) },
neutralButtonText = if (prefs.contains(setting.key)) stringResource(R.string.button_default) else null,
onNeutral = { prefs.edit().remove(setting.key).apply(); KeyboardLayoutSet.onSystemLocaleChanged() },
checkTextValid = { text -> text.splitOnWhitespace().none { it.length > 8 } }
)
}
},
Setting(context, Settings.PREF_MORE_POPUP_KEYS, R.string.show_popup_keys_title) {
val items = listOf(
stringResource(R.string.show_popup_keys_normal) to "normal",
stringResource(R.string.show_popup_keys_main) to "main",
stringResource(R.string.show_popup_keys_more) to "more",
stringResource(R.string.show_popup_keys_all) to "all",
)
ListPreference(it, items, "main") { KeyboardLayoutSet.onSystemLocaleChanged() }
},
Setting(context, SettingsWithoutKey.CUSTOM_SYMBOLS_NUMBER_LAYOUTS, R.string.customize_symbols_number_layouts) { setting ->
LayoutEditPreference(
setting = setting,
items = RawKeyboardParser.symbolAndNumberLayouts,
getItemName = { it.getStringResourceOrName("layout_", LocalContext.current) },
getDefaultLayout = { LocalContext.current.assets.list("layouts")?.firstOrNull { it.startsWith("$it.") } }
)
},
Setting(context, SettingsWithoutKey.CUSTOM_FUNCTIONAL_LAYOUTS, R.string.customize_functional_key_layouts) { setting ->
LayoutEditPreference(
setting = setting,
items = listOf(CUSTOM_FUNCTIONAL_LAYOUT_NORMAL, CUSTOM_FUNCTIONAL_LAYOUT_SYMBOLS, CUSTOM_FUNCTIONAL_LAYOUT_SYMBOLS_SHIFTED)
.map { it.substringBeforeLast(".") },
getItemName = { it.substringAfter(CUSTOM_LAYOUT_PREFIX).getStringResourceOrName("layout_", LocalContext.current) },
getDefaultLayout = { if (Settings.getInstance().isTablet) "functional_keys_tablet.json" else "functional_keys.json" }
)
},
Setting(context, SettingsWithoutKey.BACKUP_RESTORE, R.string.backup_restore_title) {
BackupRestorePreference(it)
},
Setting(context, SettingsWithoutKey.DEBUG_SETTINGS, R.string.debug_settings_title) {
Preference(
name = it.title,
onClick = { SettingsDestination.navigateTo(SettingsDestination.Debug) }
) {
Icon(
painter = painterResource(R.drawable.ic_arrow_left),
modifier = Modifier.scale(-1f, 1f),
contentDescription = null
)
}
},
Setting(context, Settings.PREF_EMOJI_MAX_SDK, R.string.prefs_key_emoji_max_sdk) { setting ->
SliderPreference(
name = setting.title,
key = setting.key,
default = Build.VERSION.SDK_INT,
range = 21f..35f,
description = {
"Android " + when(it) {
21 -> "5.0"
22 -> "5.1"
23 -> "6"
24 -> "7.0"
25 -> "7.1"
26 -> "8.0"
27 -> "8.1"
28 -> "9"
29 -> "10"
30 -> "11"
31 -> "12"
32 -> "12L"
33 -> "13"
34 -> "14"
35 -> "15"
else -> "version unknown"
}
},
onValueChanged = { keyboardNeedsReload = true }
)
},
Setting(context, Settings.PREF_URL_DETECTION, R.string.url_detection_title, R.string.url_detection_summary) {
SwitchPreference(it, false)
},
Setting(context, SettingsWithoutKey.LOAD_GESTURE_LIB, R.string.load_gesture_library, R.string.load_gesture_library_summary) {
LoadGestureLibPreference(it)
},
)
@Preview
@Composable
private fun Preview() {
SettingsActivity.settingsContainer = SettingsContainer(LocalContext.current)
Theme(true) {
Surface {
AdvancedSettingsScreen { }
}
}
}

View file

@ -0,0 +1,340 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.screens
import android.content.Context
import android.os.Build
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import helium314.keyboard.keyboard.KeyboardLayoutSet
import helium314.keyboard.keyboard.KeyboardSwitcher
import helium314.keyboard.keyboard.KeyboardTheme
import helium314.keyboard.latin.R
import helium314.keyboard.latin.settings.ColorsNightSettingsFragment
import helium314.keyboard.latin.settings.ColorsSettingsFragment
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.settings.SettingsValues
import helium314.keyboard.latin.utils.Log
import helium314.keyboard.latin.utils.getActivity
import helium314.keyboard.latin.utils.getStringResourceOrName
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.latin.utils.switchTo
import helium314.keyboard.settings.SettingsContainer
import helium314.keyboard.settings.preferences.ListPreference
import helium314.keyboard.settings.SettingsWithoutKey
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.preferences.SliderPreference
import helium314.keyboard.settings.preferences.SwitchPreference
import helium314.keyboard.settings.Theme
import helium314.keyboard.settings.dialogs.CustomizeIconsDialog
import helium314.keyboard.settings.dialogs.TextInputDialog
import helium314.keyboard.settings.keyboardNeedsReload
import helium314.keyboard.settings.preferences.BackgroundImagePref
import helium314.keyboard.settings.preferences.CustomFontPreference
@Composable
fun AppearanceScreen(
onClickBack: () -> Unit,
) {
val ctx = LocalContext.current
val prefs = ctx.prefs()
val b = (LocalContext.current.getActivity() as? SettingsActivity)?.prefChanged?.collectAsState()
if ((b?.value ?: 0) < 0)
Log.v("irrelevant", "stupid way to trigger recomposition on preference change")
val dayNightMode = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && Settings.readDayNightPref(prefs, ctx.resources)
val items = listOf(
R.string.settings_screen_theme,
Settings.PREF_THEME_STYLE,
Settings.PREF_ICON_STYLE,
Settings.PREF_CUSTOM_ICON_NAMES,
Settings.PREF_THEME_COLORS,
if (prefs.getString(Settings.PREF_THEME_COLORS, KeyboardTheme.THEME_LIGHT) == KeyboardTheme.THEME_USER)
SettingsWithoutKey.ADJUST_COLORS else null,
Settings.PREF_THEME_KEY_BORDERS,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
Settings.PREF_THEME_DAY_NIGHT else null,
if (dayNightMode) Settings.PREF_THEME_COLORS_NIGHT else null,
if (dayNightMode && prefs.getString(Settings.PREF_THEME_COLORS_NIGHT, KeyboardTheme.THEME_DARK) == KeyboardTheme.THEME_USER_NIGHT)
SettingsWithoutKey.ADJUST_COLORS_NIGHT else null,
Settings.PREF_NAVBAR_COLOR,
SettingsWithoutKey.BACKGROUND_IMAGE,
SettingsWithoutKey.BACKGROUND_IMAGE_LANDSCAPE,
R.string.settings_category_miscellaneous,
Settings.PREF_ENABLE_SPLIT_KEYBOARD,
if (prefs.getBoolean(Settings.PREF_ENABLE_SPLIT_KEYBOARD, false))
Settings.PREF_SPLIT_SPACER_SCALE else null,
Settings.PREF_ENABLE_SPLIT_KEYBOARD_LANDSCAPE,
if (prefs.getBoolean(Settings.PREF_ENABLE_SPLIT_KEYBOARD_LANDSCAPE, false))
Settings.PREF_SPLIT_SPACER_SCALE_LANDSCAPE else null,
Settings.PREF_NARROW_KEY_GAPS,
Settings.PREF_KEYBOARD_HEIGHT_SCALE,
Settings.PREF_BOTTOM_PADDING_SCALE,
Settings.PREF_BOTTOM_PADDING_SCALE_LANDSCAPE,
Settings.PREF_SIDE_PADDING_SCALE,
Settings.PREF_SIDE_PADDING_SCALE_LANDSCAPE,
Settings.PREF_SPACE_BAR_TEXT,
SettingsWithoutKey.CUSTOM_FONT,
Settings.PREF_FONT_SCALE,
Settings.PREF_EMOJI_FONT_SCALE,
)
SearchSettingsScreen(
onClickBack = onClickBack,
title = stringResource(R.string.settings_screen_appearance),
settings = items
)
}
fun createAppearanceSettings(context: Context) = listOf(
Setting(context, Settings.PREF_THEME_STYLE, R.string.theme_style) { setting ->
val ctx = LocalContext.current
val prefs = ctx.prefs()
val items = KeyboardTheme.STYLES.map {
it.getStringResourceOrName("style_name_", ctx) to it
}
ListPreference(
setting,
items,
KeyboardTheme.STYLE_MATERIAL
) {
if (it != KeyboardTheme.STYLE_HOLO) {
// todo (later): use defaults once they exist
if (prefs.getString(Settings.PREF_THEME_COLORS, "") == KeyboardTheme.THEME_HOLO_WHITE)
prefs.edit().putString(Settings.PREF_THEME_COLORS, KeyboardTheme.THEME_LIGHT).apply()
if (prefs.getString(Settings.PREF_THEME_COLORS_NIGHT, "") == KeyboardTheme.THEME_HOLO_WHITE)
prefs.edit().putString(Settings.PREF_THEME_COLORS_NIGHT, KeyboardTheme.THEME_DARK).apply()
}
}
},
Setting(context, Settings.PREF_ICON_STYLE, R.string.icon_style) { setting ->
val ctx = LocalContext.current
val items = KeyboardTheme.STYLES.map { it.getStringResourceOrName("style_name_", ctx) to it }
ListPreference(
setting,
items,
KeyboardTheme.STYLE_MATERIAL
) { keyboardNeedsReload = true }
},
Setting(context, Settings.PREF_CUSTOM_ICON_NAMES, R.string.customize_icons) { setting ->
var showDialog by rememberSaveable { mutableStateOf(false) }
Preference(
name = setting.title,
onClick = { showDialog = true }
)
if (showDialog) {
if (keyboardNeedsReload) {
KeyboardSwitcher.getInstance().forceUpdateKeyboardTheme(LocalContext.current)
keyboardNeedsReload = false
}
CustomizeIconsDialog(setting.key) { showDialog = false }
}
},
Setting(context, Settings.PREF_THEME_COLORS, R.string.theme_colors) { setting ->
val ctx = LocalContext.current
val b = (ctx.getActivity() as? SettingsActivity)?.prefChanged?.collectAsState()
if ((b?.value ?: 0) < 0)
Log.v("irrelevant", "stupid way to trigger recomposition on preference change")
val currentStyle = ctx.prefs().getString(Settings.PREF_THEME_STYLE, KeyboardTheme.STYLE_MATERIAL)
val items = KeyboardTheme.COLORS.mapNotNull {
if (it == KeyboardTheme.THEME_HOLO_WHITE && currentStyle != KeyboardTheme.STYLE_HOLO)
return@mapNotNull null
it.getStringResourceOrName("theme_name_", ctx) to it
}
ListPreference(
setting,
items,
KeyboardTheme.THEME_LIGHT
) { keyboardNeedsReload = true }
},
Setting(context, Settings.PREF_THEME_COLORS_NIGHT, R.string.theme_colors_night) { setting ->
val ctx = LocalContext.current
val b = (ctx.getActivity() as? SettingsActivity)?.prefChanged?.collectAsState()
if ((b?.value ?: 0) < 0)
Log.v("irrelevant", "stupid way to trigger recomposition on preference change")
val currentStyle = ctx.prefs().getString(Settings.PREF_THEME_STYLE, KeyboardTheme.STYLE_MATERIAL)
val items = KeyboardTheme.COLORS_DARK.mapNotNull {
if (it == KeyboardTheme.THEME_HOLO_WHITE && currentStyle == KeyboardTheme.STYLE_HOLO)
return@mapNotNull null
it.getStringResourceOrName("theme_name_", ctx) to it
}
ListPreference(
setting,
items,
KeyboardTheme.THEME_DARK
) { keyboardNeedsReload = true }
},
Setting(context, SettingsWithoutKey.ADJUST_COLORS, R.string.select_user_colors, R.string.select_user_colors_summary) {
val ctx = LocalContext.current
Preference(
name = it.title,
description = it.description,
onClick = {
ctx.getActivity()?.switchTo(ColorsSettingsFragment())
//SettingsDestination.navigateTo(SettingsDestination.Colors) todo: later
}
)
},
Setting(context, SettingsWithoutKey.ADJUST_COLORS_NIGHT, R.string.select_user_colors_night, R.string.select_user_colors_summary) {
val ctx = LocalContext.current
Preference(
name = it.title,
description = it.description,
onClick = {
ctx.getActivity()?.switchTo(ColorsNightSettingsFragment())
//SettingsDestination.navigateTo(SettingsDestination.ColorsNight) todo: later
}
)
},
Setting(context, Settings.PREF_THEME_KEY_BORDERS, R.string.key_borders) {
SwitchPreference(it, false)
},
Setting(context, Settings.PREF_THEME_DAY_NIGHT, R.string.day_night_mode, R.string.day_night_mode_summary) {
SwitchPreference(it, Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { keyboardNeedsReload = true }
},
Setting(context, Settings.PREF_NAVBAR_COLOR, R.string.theme_navbar, R.string.day_night_mode_summary) {
SwitchPreference(it, Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
},
Setting(context, SettingsWithoutKey.BACKGROUND_IMAGE, R.string.customize_background_image) {
BackgroundImagePref(it, false)
},
Setting(context, SettingsWithoutKey.BACKGROUND_IMAGE_LANDSCAPE,
R.string.customize_background_image_landscape, R.string.summary_customize_background_image_landscape)
{
BackgroundImagePref(it, true)
},
Setting(context, Settings.PREF_ENABLE_SPLIT_KEYBOARD, R.string.enable_split_keyboard) {
SwitchPreference(it, false) { KeyboardSwitcher.getInstance().reloadKeyboard() }
},
Setting(context, Settings.PREF_SPLIT_SPACER_SCALE, R.string.split_spacer_scale) { setting ->
SliderPreference(
name = setting.title,
key = setting.key,
default = SettingsValues.DEFAULT_SIZE_SCALE,
range = 0.5f..2f,
description = { "${(100 * it).toInt()}%" }
) { keyboardNeedsReload = true }
},
Setting(context, Settings.PREF_ENABLE_SPLIT_KEYBOARD_LANDSCAPE, R.string.enable_split_keyboard_landscape) {
SwitchPreference(it, false) { KeyboardSwitcher.getInstance().reloadKeyboard() }
},
Setting(context, Settings.PREF_SPLIT_SPACER_SCALE_LANDSCAPE, R.string.split_spacer_scale_landscape) { setting ->
SliderPreference(
name = setting.title,
key = setting.key,
default = SettingsValues.DEFAULT_SIZE_SCALE,
range = 0.5f..2f,
description = { "${(100 * it).toInt()}%" }
) { keyboardNeedsReload = true }
},
Setting(context, Settings.PREF_NARROW_KEY_GAPS, R.string.prefs_narrow_key_gaps) {
SwitchPreference(it, false) { keyboardNeedsReload = true }
},
Setting(context, Settings.PREF_KEYBOARD_HEIGHT_SCALE, R.string.prefs_keyboard_height_scale) { setting ->
SliderPreference(
name = setting.title,
key = setting.key,
default = SettingsValues.DEFAULT_SIZE_SCALE,
range = 0.5f..1.5f,
description = { "${(100 * it).toInt()}%" }
) { keyboardNeedsReload = true }
},
Setting(context, Settings.PREF_BOTTOM_PADDING_SCALE, R.string.prefs_bottom_padding_scale) { setting ->
SliderPreference(
name = setting.title,
key = setting.key,
default = SettingsValues.DEFAULT_SIZE_SCALE,
range = 0f..5f,
description = { "${(100 * it).toInt()}%" }
) { keyboardNeedsReload = true }
},
Setting(context, Settings.PREF_BOTTOM_PADDING_SCALE_LANDSCAPE, R.string.prefs_bottom_padding_scale_landscape) { setting ->
SliderPreference(
name = setting.title,
key = setting.key,
default = 0f,
range = 0f..5f,
description = { "${(100 * it).toInt()}%" }
) { keyboardNeedsReload = true }
},
Setting(context, Settings.PREF_SIDE_PADDING_SCALE, R.string.prefs_side_padding_scale) { setting ->
SliderPreference(
name = setting.title,
key = setting.key,
default = 0f,
range = 0f..3f,
description = { "${(100 * it).toInt()}%" }
) { keyboardNeedsReload = true }
},
Setting(context, Settings.PREF_SIDE_PADDING_SCALE_LANDSCAPE, R.string.prefs_side_padding_scale_landscape) { setting ->
SliderPreference(
name = setting.title,
key = setting.key,
default = 0f,
range = 0f..3f,
description = { "${(100 * it).toInt()}%" }
) { keyboardNeedsReload = true }
},
Setting(context, Settings.PREF_SPACE_BAR_TEXT, R.string.prefs_space_bar_text) { setting ->
var showDialog by rememberSaveable { mutableStateOf(false) } // todo: textInputDialog...
val prefs = LocalContext.current.prefs()
Preference(
name = setting.title,
onClick = { showDialog = true },
description = prefs.getString(setting.key, "")
)
if (showDialog) {
TextInputDialog(
onDismissRequest = { showDialog = false },
onConfirmed = {
prefs.edit().putString(setting.key, it).apply()
keyboardNeedsReload = true
},
initialText = prefs.getString(setting.key, "") ?: "",
title = { Text(setting.title) },
checkTextValid = { true }
)
}
},
Setting(context, SettingsWithoutKey.CUSTOM_FONT, R.string.custom_font) {
CustomFontPreference(it)
},
Setting(context, Settings.PREF_FONT_SCALE, R.string.prefs_font_scale) { def ->
SliderPreference(
name = def.title,
key = def.key,
default = 1f,
range = 0.5f..1.5f,
description = { "${(100 * it).toInt()}%" }
) { keyboardNeedsReload = true }
},
Setting(context, Settings.PREF_EMOJI_FONT_SCALE, R.string.prefs_emoji_font_scale) { setting ->
SliderPreference(
name = setting.title,
key = setting.key,
default = 1f,
range = 0.5f..1.5f,
description = { "${(100 * it).toInt()}%" }
) { keyboardNeedsReload = true }
},
)
@Preview
@Composable
private fun Preview() {
SettingsActivity.settingsContainer = SettingsContainer(LocalContext.current)
Theme(true) {
Surface {
AppearanceScreen { }
}
}
}

View file

@ -0,0 +1,34 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.screens
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.res.stringResource
import helium314.keyboard.latin.R
import helium314.keyboard.settings.SearchScreen
@Composable
fun ColorsScreen(
night: Boolean,
onClickBack: () -> Unit
) {
var availableColors by remember { mutableStateOf(emptyList<ColorSetting>()) } // todo (later): type?
// todo (later): save / load / type selection here? or in ... menu as previously?
SearchScreen(
title = stringResource(if (night) R.string.select_user_colors_night else R.string.select_user_colors),
onClickBack = onClickBack,
filteredItems = { search -> availableColors.filter { it.displayName.contains(search, true) } },
itemContent = { }
)
}
private class ColorSetting(
val key: String, // old, this should go away
val displayName: String,
var auto: Boolean, // not for all
var color: Int
)

View file

@ -0,0 +1,119 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.screens
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import helium314.keyboard.latin.BuildConfig
import helium314.keyboard.latin.DictionaryDumpBroadcastReceiver
import helium314.keyboard.latin.DictionaryFacilitator
import helium314.keyboard.latin.R
import helium314.keyboard.latin.settings.DebugSettings
import helium314.keyboard.latin.settings.DebugSettingsFragment
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.SettingsContainer
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.preferences.SwitchPreference
import helium314.keyboard.settings.Theme
import helium314.keyboard.settings.keyboardNeedsReload
import helium314.keyboard.settings.preferences.PreferenceCategory
@Composable
fun DebugScreen(
onClickBack: () -> Unit,
) {
val ctx = LocalContext.current
val settings = createDebugSettings(ctx)
val items = listOfNotNull(
if (!BuildConfig.DEBUG) DebugSettings.PREF_SHOW_DEBUG_SETTINGS else null,
DebugSettings.PREF_DEBUG_MODE,
DebugSettings.PREF_SHOW_SUGGESTION_INFOS,
DebugSettings.PREF_FORCE_NON_DISTINCT_MULTITOUCH,
DebugSettings.PREF_SLIDING_KEY_INPUT_PREVIEW,
R.string.prefs_dump_dynamic_dicts
) + DictionaryFacilitator.DYNAMIC_DICTIONARY_TYPES.map { DebugSettingsFragment.PREF_KEY_DUMP_DICT_PREFIX + it }
SearchSettingsScreen(
onClickBack = {
if (needsRestart) {
val intent = Intent.makeRestartActivityTask(ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.component)
intent.setPackage(ctx.packageName)
ctx.startActivity(intent)
Runtime.getRuntime().exit(0)
}
onClickBack()
},
title = stringResource(R.string.debug_settings_title),
settings = emptyList()
) {
// the preferences are not in SettingsContainer, so set content instead
LazyColumn {
items(items, key = { it }) { item ->
if (item is Int) PreferenceCategory(stringResource(item))
else settings.first { it.key == item }.Preference()
}
}
}
}
private var needsRestart = false
private fun createDebugSettings(context: Context) = listOf(
Setting(context, DebugSettings.PREF_SHOW_DEBUG_SETTINGS, R.string.prefs_show_debug_settings) { setting ->
val prefs = LocalContext.current.prefs()
SwitchPreference(setting, false)
{ if (!it) prefs.edit().putBoolean(DebugSettings.PREF_DEBUG_MODE, false).apply() }
},
Setting(context, DebugSettings.PREF_DEBUG_MODE, R.string.prefs_debug_mode) { setting ->
val prefs = LocalContext.current.prefs()
SwitchPreference(
name = setting.title,
key = setting.key,
description = stringResource(R.string.version_text, BuildConfig.VERSION_NAME),
default = false,
) {
if (!it) prefs.edit().putBoolean(DebugSettings.PREF_SHOW_SUGGESTION_INFOS, false).apply()
needsRestart = true
}
},
Setting(context, DebugSettings.PREF_SHOW_SUGGESTION_INFOS, R.string.prefs_show_suggestion_infos) {
SwitchPreference(it, false) { keyboardNeedsReload = true }
},
Setting(context, DebugSettings.PREF_FORCE_NON_DISTINCT_MULTITOUCH, R.string.prefs_force_non_distinct_multitouch) {
SwitchPreference(it, false) { needsRestart = true }
},
Setting(context, DebugSettings.PREF_SLIDING_KEY_INPUT_PREVIEW, R.string.sliding_key_input_preview, R.string.sliding_key_input_preview_summary) { def ->
SwitchPreference(def, false)
},
) + DictionaryFacilitator.DYNAMIC_DICTIONARY_TYPES.map { type ->
Setting(context, DebugSettingsFragment.PREF_KEY_DUMP_DICT_PREFIX + type, R.string.button_default) {
val ctx = LocalContext.current
Preference(
name = "Dump $type dictionary",
onClick = {
val intent = Intent(DictionaryDumpBroadcastReceiver.DICTIONARY_DUMP_INTENT_ACTION)
intent.putExtra(DictionaryDumpBroadcastReceiver.DICTIONARY_NAME_KEY, type)
ctx.sendBroadcast(intent)
}
)
}
}
@Preview
@Composable
private fun Preview() {
SettingsActivity.settingsContainer = SettingsContainer(LocalContext.current)
Theme(true) {
Surface {
DebugScreen { }
}
}
}

View file

@ -0,0 +1,119 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.screens
import android.content.Context
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import helium314.keyboard.latin.R
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.utils.Log
import helium314.keyboard.latin.utils.getActivity
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.SettingsContainer
import helium314.keyboard.settings.Setting
import helium314.keyboard.settings.SearchSettingsScreen
import helium314.keyboard.settings.SettingsActivity
import helium314.keyboard.settings.preferences.SliderPreference
import helium314.keyboard.settings.preferences.SwitchPreference
import helium314.keyboard.settings.Theme
import helium314.keyboard.settings.keyboardNeedsReload
@Composable
fun GestureTypingScreen(
onClickBack: () -> Unit,
) {
val prefs = LocalContext.current.prefs()
val b = (LocalContext.current.getActivity() as? SettingsActivity)?.prefChanged?.collectAsState()
if ((b?.value ?: 0) < 0)
Log.v("irrelevant", "stupid way to trigger recomposition on preference change")
val gestureFloatingPreviewEnabled = prefs.getBoolean(Settings.PREF_GESTURE_FLOATING_PREVIEW_TEXT, true)
val gestureEnabled = prefs.getBoolean(Settings.PREF_GESTURE_INPUT, true)
val items = listOf(
Settings.PREF_GESTURE_INPUT,
if (gestureEnabled)
Settings.PREF_GESTURE_PREVIEW_TRAIL else null,
if (gestureEnabled)
Settings.PREF_GESTURE_FLOATING_PREVIEW_TEXT else null,
if (gestureEnabled && gestureFloatingPreviewEnabled)
Settings.PREF_GESTURE_FLOATING_PREVIEW_DYNAMIC else null,
if (gestureEnabled)
Settings.PREF_GESTURE_SPACE_AWARE else null,
if (gestureEnabled)
Settings.PREF_GESTURE_FAST_TYPING_COOLDOWN else null,
if (gestureEnabled && (prefs.getBoolean(Settings.PREF_GESTURE_PREVIEW_TRAIL, true) || gestureFloatingPreviewEnabled))
Settings.PREF_GESTURE_TRAIL_FADEOUT_DURATION else null
)
SearchSettingsScreen(
onClickBack = onClickBack,
title = stringResource(R.string.settings_screen_gesture),
settings = items
)
}
fun createGestureTypingSettings(context: Context) = listOf(
Setting(context, Settings.PREF_GESTURE_INPUT, R.string.gesture_input, R.string.gesture_input_summary) {
SwitchPreference(it, true)
},
Setting(context, Settings.PREF_GESTURE_PREVIEW_TRAIL, R.string.gesture_preview_trail) {
SwitchPreference(it, true)
},
Setting(context, Settings.PREF_GESTURE_FLOATING_PREVIEW_TEXT,
R.string.gesture_floating_preview_static, R.string.gesture_floating_preview_static_summary)
{
SwitchPreference(it, true)
},
Setting(context, Settings.PREF_GESTURE_FLOATING_PREVIEW_DYNAMIC,
R.string.gesture_floating_preview_text, R.string.gesture_floating_preview_dynamic_summary)
{ def ->
val ctx = LocalContext.current
SwitchPreference(def, true) {
// is this complexity and 2 pref keys for one setting really needed?
// default value is based on system reduced motion
val default = Settings.readGestureDynamicPreviewDefault(ctx)
val followingSystem = it == default
// allow the default to be overridden
ctx.prefs().edit().putBoolean(Settings.PREF_GESTURE_DYNAMIC_PREVIEW_FOLLOW_SYSTEM, followingSystem).apply()
keyboardNeedsReload = true
}
},
Setting(context, Settings.PREF_GESTURE_SPACE_AWARE, R.string.gesture_space_aware, R.string.gesture_space_aware_summary) {
SwitchPreference(it, false)
},
Setting(context, Settings.PREF_GESTURE_FAST_TYPING_COOLDOWN, R.string.gesture_fast_typing_cooldown) { def ->
SliderPreference(
name = def.title,
key = def.key,
default = 500,
range = 0f..500f,
description = {
if (it <= 0) stringResource(R.string.gesture_fast_typing_cooldown_instant)
else stringResource(R.string.abbreviation_unit_milliseconds, it.toString())
}
)
},
Setting(context, Settings.PREF_GESTURE_TRAIL_FADEOUT_DURATION, R.string.gesture_trail_fadeout_duration) { def ->
SliderPreference(
name = def.title,
key = def.key,
default = 800,
range = 100f..1900f,
description = { stringResource(R.string.abbreviation_unit_milliseconds, (it + 100).toString()) },
stepSize = 10,
) { keyboardNeedsReload = true }
},
)
@Preview
@Composable
private fun Preview() {
SettingsActivity.settingsContainer = SettingsContainer(LocalContext.current)
Theme(true) {
Surface {
GestureTypingScreen { }
}
}
}

View file

@ -0,0 +1,175 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
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.tooling.preview.Preview
import helium314.keyboard.latin.R
import helium314.keyboard.latin.settings.AboutFragment
import helium314.keyboard.latin.settings.AdvancedSettingsFragment
import helium314.keyboard.latin.settings.AppearanceSettingsFragment
import helium314.keyboard.latin.settings.CorrectionSettingsFragment
import helium314.keyboard.latin.settings.GestureSettingsFragment
import helium314.keyboard.latin.settings.LanguageSettingsFragment
import helium314.keyboard.latin.settings.PreferencesSettingsFragment
import helium314.keyboard.latin.settings.ToolbarSettingsFragment
import helium314.keyboard.latin.utils.JniUtils
import helium314.keyboard.latin.utils.getActivity
import helium314.keyboard.latin.utils.switchTo
import helium314.keyboard.settings.preferences.Preference
import helium314.keyboard.settings.preferences.PreferenceCategory
import helium314.keyboard.settings.SearchSettingsScreen
import helium314.keyboard.settings.Theme
@Composable
fun MainSettingsScreen(
onClickAbout: () -> Unit,
onClickTextCorrection: () -> Unit,
onClickPreferences: () -> Unit,
onClickToolbar: () -> Unit,
onClickGestureTyping: () -> Unit,
onClickAdvanced: () -> Unit,
onClickAppearance: () -> Unit,
onClickBack: () -> Unit,
) {
val ctx = LocalContext.current
SearchSettingsScreen(
onClickBack = onClickBack,
title = stringResource(R.string.ime_settings),
settings = emptyList(),
) {
Column(Modifier.verticalScroll(rememberScrollState())) {
Preference(
name = stringResource(R.string.settings_screen_preferences),
onClick = onClickPreferences,
icon = R.drawable.ic_settings_preferences_foreground
) {
Icon(
painter = painterResource(R.drawable.ic_arrow_left),
modifier = Modifier.scale(-1f, 1f),
contentDescription = null
)
}
Preference(
name = stringResource(R.string.settings_screen_appearance),
onClick = onClickAppearance,
icon = R.drawable.ic_settings_appearance_foreground
) {
Icon(
painter = painterResource(R.drawable.ic_arrow_left),
modifier = Modifier.scale(-1f, 1f),
contentDescription = null
)
}
Preference(
name = stringResource(R.string.settings_screen_toolbar),
onClick = onClickToolbar,
icon = R.drawable.ic_settings_toolbar_foreground
) {
Icon(
painter = painterResource(R.drawable.ic_arrow_left),
modifier = Modifier.scale(-1f, 1f),
contentDescription = null
)
}
if (JniUtils.sHaveGestureLib)
Preference(
name = stringResource(R.string.settings_screen_gesture),
onClick = onClickGestureTyping,
icon = R.drawable.ic_settings_gesture_foreground
) {
Icon(
painter = painterResource(R.drawable.ic_arrow_left),
modifier = Modifier.scale(-1f, 1f),
contentDescription = null
)
}
Preference(
name = stringResource(R.string.settings_screen_correction),
onClick = onClickTextCorrection,
icon = R.drawable.ic_settings_correction_foreground
) {
Icon(
painter = painterResource(R.drawable.ic_arrow_left),
modifier = Modifier.scale(-1f, 1f),
contentDescription = null
)
}
Preference(
name = stringResource(R.string.settings_screen_advanced),
onClick = onClickAdvanced,
icon = R.drawable.ic_settings_advanced_foreground
) {
Icon(
painter = painterResource(R.drawable.ic_arrow_left),
modifier = Modifier.scale(-1f, 1f),
contentDescription = null
)
}
Preference(
name = stringResource(R.string.settings_screen_about),
onClick = onClickAbout,
icon = R.drawable.ic_settings_about_foreground
) {
Icon(
painter = painterResource(R.drawable.ic_arrow_left),
modifier = Modifier.scale(-1f, 1f),
contentDescription = null
)
}
PreferenceCategory(title = "old screens")
Preference(
name = stringResource(R.string.language_and_layouts_title),
onClick = { ctx.getActivity()?.switchTo(LanguageSettingsFragment()) }
)
Preference(
name = stringResource(R.string.settings_screen_preferences),
onClick = { ctx.getActivity()?.switchTo(PreferencesSettingsFragment()) }
)
Preference(
name = stringResource(R.string.settings_screen_appearance),
onClick = { ctx.getActivity()?.switchTo(AppearanceSettingsFragment()) }
)
Preference(
name = stringResource(R.string.settings_screen_toolbar),
onClick = { ctx.getActivity()?.switchTo(ToolbarSettingsFragment()) }
)
if (JniUtils.sHaveGestureLib)
Preference(
name = stringResource(R.string.settings_screen_gesture),
onClick = { ctx.getActivity()?.switchTo(GestureSettingsFragment()) }
)
Preference(
name = stringResource(R.string.settings_screen_correction),
onClick = { ctx.getActivity()?.switchTo(CorrectionSettingsFragment()) }
)
Preference(
name = stringResource(R.string.settings_screen_advanced),
onClick = { ctx.getActivity()?.switchTo(AdvancedSettingsFragment()) }
)
Preference(
name = stringResource(R.string.settings_screen_about),
onClick = { ctx.getActivity()?.switchTo(AboutFragment()) }
)
}
}
}
@Preview
@Composable
private fun PreviewScreen() {
Theme(true) {
Surface {
MainSettingsScreen({}, {}, {}, {}, {}, {}, {}, {})
}
}
}

View file

@ -0,0 +1,198 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.screens
import android.content.Context
import android.media.AudioManager
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import helium314.keyboard.keyboard.KeyboardLayoutSet
import helium314.keyboard.latin.AudioAndHapticFeedbackManager
import helium314.keyboard.latin.R
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.utils.Log
import helium314.keyboard.latin.utils.POPUP_KEYS_LABEL_DEFAULT
import helium314.keyboard.latin.utils.POPUP_KEYS_ORDER_DEFAULT
import helium314.keyboard.latin.utils.getActivity
import helium314.keyboard.latin.utils.getEnabledSubtypes
import helium314.keyboard.latin.utils.locale
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.SettingsContainer
import helium314.keyboard.settings.preferences.ListPreference
import helium314.keyboard.settings.Setting
import helium314.keyboard.settings.preferences.ReorderSwitchPreference
import helium314.keyboard.settings.SearchSettingsScreen
import helium314.keyboard.settings.SettingsActivity
import helium314.keyboard.settings.preferences.SliderPreference
import helium314.keyboard.settings.preferences.SwitchPreference
import helium314.keyboard.settings.Theme
import helium314.keyboard.settings.keyboardNeedsReload
@Composable
fun PreferencesScreen(
onClickBack: () -> Unit,
) {
val prefs = LocalContext.current.prefs()
val b = (LocalContext.current.getActivity() as? SettingsActivity)?.prefChanged?.collectAsState()
if ((b?.value ?: 0) < 0)
Log.v("irrelevant", "stupid way to trigger recomposition on preference change")
val items = listOf(
R.string.settings_category_input,
Settings.PREF_SHOW_HINTS,
if (prefs.getBoolean(Settings.PREF_SHOW_HINTS, true))
Settings.PREF_POPUP_KEYS_LABELS_ORDER else null,
Settings.PREF_POPUP_KEYS_ORDER,
Settings.PREF_SHOW_POPUP_HINTS,
Settings.PREF_POPUP_ON,
Settings.PREF_VIBRATE_ON,
if (prefs.getBoolean(Settings.PREF_VIBRATE_ON, true))
Settings.PREF_VIBRATION_DURATION_SETTINGS else null,
if (prefs.getBoolean(Settings.PREF_VIBRATE_ON, true))
Settings.PREF_VIBRATE_IN_DND_MODE else null,
Settings.PREF_SOUND_ON,
if (prefs.getBoolean(Settings.PREF_SOUND_ON, true))
Settings.PREF_KEYPRESS_SOUND_VOLUME else null,
R.string.settings_category_additional_keys,
Settings.PREF_SHOW_NUMBER_ROW,
if (getEnabledSubtypes(prefs, true).any { it.locale().language in localesWithLocalizedNumberRow })
Settings.PREF_LOCALIZED_NUMBER_ROW else null,
if (prefs.getBoolean(Settings.PREF_SHOW_HINTS, true) && prefs.getBoolean(Settings.PREF_SHOW_NUMBER_ROW, false))
Settings.PREF_SHOW_NUMBER_ROW_HINTS else null,
Settings.PREF_SHOW_LANGUAGE_SWITCH_KEY,
Settings.PREF_LANGUAGE_SWITCH_KEY,
Settings.PREF_SHOW_EMOJI_KEY,
Settings.PREF_REMOVE_REDUNDANT_POPUPS,
R.string.settings_category_clipboard_history,
Settings.PREF_ENABLE_CLIPBOARD_HISTORY,
if (prefs.getBoolean(Settings.PREF_ENABLE_CLIPBOARD_HISTORY, true))
Settings.PREF_CLIPBOARD_HISTORY_RETENTION_TIME else null
)
SearchSettingsScreen(
onClickBack = onClickBack,
title = stringResource(R.string.settings_screen_preferences),
settings = items
)
}
fun createPreferencesSettings(context: Context) = listOf(
Setting(context, Settings.PREF_SHOW_HINTS, R.string.show_hints, R.string.show_hints_summary) {
SwitchPreference(it, true)
},
Setting(context, Settings.PREF_POPUP_KEYS_LABELS_ORDER, R.string.hint_source) {
ReorderSwitchPreference(it, POPUP_KEYS_LABEL_DEFAULT)
},
Setting(context, Settings.PREF_POPUP_KEYS_ORDER, R.string.popup_order) {
ReorderSwitchPreference(it, POPUP_KEYS_ORDER_DEFAULT)
},
Setting(context, Settings.PREF_SHOW_POPUP_HINTS, R.string.show_popup_hints, R.string.show_popup_hints_summary) {
SwitchPreference(it, false) { keyboardNeedsReload = true }
},
Setting(context, Settings.PREF_POPUP_ON, R.string.popup_on_keypress) {
val dm = LocalContext.current.resources.displayMetrics
val px600 = with(LocalDensity.current) { 600.dp.toPx() }
SwitchPreference(it, dm.widthPixels >= px600 || dm.heightPixels >= px600)
},
Setting(context, Settings.PREF_VIBRATE_ON, R.string.vibrate_on_keypress) {
SwitchPreference(it, false)
},
Setting(context, Settings.PREF_VIBRATE_IN_DND_MODE, R.string.vibrate_in_dnd_mode) {
SwitchPreference(it, false)
},
Setting(context, Settings.PREF_SOUND_ON, R.string.sound_on_keypress) {
SwitchPreference(it, false)
},
Setting(context, Settings.PREF_ENABLE_CLIPBOARD_HISTORY,
R.string.enable_clipboard_history, R.string.enable_clipboard_history_summary)
{
SwitchPreference(it, true)
},
Setting(context, Settings.PREF_SHOW_NUMBER_ROW, R.string.number_row, R.string.number_row_summary) {
SwitchPreference(it, false) { keyboardNeedsReload = true }
},
Setting(context, Settings.PREF_LOCALIZED_NUMBER_ROW, R.string.localized_number_row, R.string.localized_number_row_summary) {
SwitchPreference(it, true) { KeyboardLayoutSet.onSystemLocaleChanged() }
},
Setting(context, Settings.PREF_SHOW_NUMBER_ROW_HINTS, R.string.number_row_hints) {
SwitchPreference(it, false) { keyboardNeedsReload = true }
},
Setting(context, Settings.PREF_SHOW_LANGUAGE_SWITCH_KEY, R.string.show_language_switch_key) {
SwitchPreference(it, false) { keyboardNeedsReload = true }
},
Setting(context, Settings.PREF_LANGUAGE_SWITCH_KEY, R.string.language_switch_key_behavior) {
ListPreference(
it,
listOf(
"internal" to stringResource(R.string.switch_language),
"input_method" to stringResource(R.string.language_switch_key_switch_input_method),
"both" to stringResource(R.string.language_switch_key_switch_both)
),
"internal"
) { keyboardNeedsReload = true }
},
Setting(context, Settings.PREF_SHOW_EMOJI_KEY, R.string.show_emoji_key) {
SwitchPreference(it, false)
},
Setting(context, Settings.PREF_REMOVE_REDUNDANT_POPUPS,
R.string.remove_redundant_popups, R.string.remove_redundant_popups_summary)
{
SwitchPreference(it, false) { keyboardNeedsReload = true }
},
Setting(context, Settings.PREF_CLIPBOARD_HISTORY_RETENTION_TIME, R.string.clipboard_history_retention_time) { setting ->
SliderPreference(
name = setting.title,
key = setting.key,
default = 10,
description = {
if (it < 0) stringResource(R.string.settings_no_limit)
else stringResource(R.string.abbreviation_unit_minutes, it.toString())
},
range = -1f..120f,
)
},
Setting(context, Settings.PREF_VIBRATION_DURATION_SETTINGS, R.string.prefs_keypress_vibration_duration_settings) { setting ->
SliderPreference(
name = setting.title,
key = setting.key,
default = -1,
description = {
if (it < 0) stringResource(R.string.settings_system_default)
else stringResource(R.string.abbreviation_unit_milliseconds, it.toString())
},
range = -1f..100f,
onValueChanged = { AudioAndHapticFeedbackManager.getInstance().vibrate(it.toLong()) }
)
},
Setting(context, Settings.PREF_KEYPRESS_SOUND_VOLUME, R.string.prefs_keypress_sound_volume_settings) { setting ->
val audioManager = LocalContext.current.getSystemService(Context.AUDIO_SERVICE) as AudioManager
SliderPreference(
name = setting.title,
key = setting.key,
default = -0.01f,
description = {
if (it < 0) stringResource(R.string.settings_system_default)
else (it * 100).toInt().toString()
},
range = -0.01f..1f,
onValueChanged = { audioManager.playSoundEffect(AudioManager.FX_KEYPRESS_STANDARD, it) }
)
},
)
// todo (later): not good to have it hardcoded, but reading a bunch of files may be noticeably slow
private val localesWithLocalizedNumberRow = listOf("ar", "bn", "fa", "gu", "hi", "kn", "mr", "ne", "ur")
@Preview
@Composable
private fun Preview() {
SettingsActivity.settingsContainer = SettingsContainer(LocalContext.current)
Theme(true) {
Surface {
PreferencesScreen { }
}
}
}

View file

@ -0,0 +1,222 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.screens
import android.Manifest
import android.content.Context
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
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.tooling.preview.Preview
import helium314.keyboard.latin.R
import helium314.keyboard.latin.permissions.PermissionsUtil
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.settings.UserDictionaryListFragment
import helium314.keyboard.latin.utils.Log
import helium314.keyboard.latin.utils.getActivity
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.latin.utils.switchTo
import helium314.keyboard.settings.SettingsContainer
import helium314.keyboard.settings.preferences.ListPreference
import helium314.keyboard.settings.SettingsWithoutKey
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.preferences.SwitchPreference
import helium314.keyboard.settings.Theme
import helium314.keyboard.settings.dialogs.ConfirmationDialog
import helium314.keyboard.settings.keyboardNeedsReload
@Composable
fun TextCorrectionScreen(
onClickBack: () -> Unit,
) {
val prefs = LocalContext.current.prefs()
val b = (LocalContext.current.getActivity() as? SettingsActivity)?.prefChanged?.collectAsState()
if ((b?.value ?: 0) < 0)
Log.v("irrelevant", "stupid way to trigger recomposition on preference change")
val autocorrectEnabled = prefs.getBoolean(Settings.PREF_AUTO_CORRECTION, true)
val suggestionsEnabled = prefs.getBoolean(Settings.PREF_SHOW_SUGGESTIONS, true)
val items = listOf(
SettingsWithoutKey.EDIT_PERSONAL_DICTIONARY,
R.string.settings_category_correction,
Settings.PREF_BLOCK_POTENTIALLY_OFFENSIVE,
Settings.PREF_AUTO_CORRECTION,
if (autocorrectEnabled) Settings.PREF_MORE_AUTO_CORRECTION else null,
if (autocorrectEnabled) Settings.PREF_AUTOCORRECT_SHORTCUTS else null,
if (autocorrectEnabled) Settings.PREF_AUTO_CORRECTION_CONFIDENCE else null,
Settings.PREF_AUTO_CAP,
Settings.PREF_KEY_USE_DOUBLE_SPACE_PERIOD,
Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION,
R.string.settings_category_suggestions,
Settings.PREF_SHOW_SUGGESTIONS,
if (suggestionsEnabled) Settings.PREF_ALWAYS_SHOW_SUGGESTIONS else null,
if (suggestionsEnabled) Settings.PREF_CENTER_SUGGESTION_TEXT_TO_ENTER else null,
Settings.PREF_KEY_USE_PERSONALIZED_DICTS,
Settings.PREF_BIGRAM_PREDICTIONS,
Settings.PREF_SUGGEST_CLIPBOARD_CONTENT,
Settings.PREF_USE_CONTACTS,
if (prefs.getBoolean(Settings.PREF_KEY_USE_PERSONALIZED_DICTS, true))
Settings.PREF_ADD_TO_PERSONAL_DICTIONARY else null
)
SearchSettingsScreen(
onClickBack = onClickBack,
title = stringResource(R.string.settings_screen_correction),
settings = items
)
}
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()) },
) {
Icon(
painter = painterResource(R.drawable.ic_arrow_left),
modifier = Modifier.scale(-1f, 1f),
contentDescription = null
)
}
},
Setting(context, Settings.PREF_BLOCK_POTENTIALLY_OFFENSIVE,
R.string.prefs_block_potentially_offensive_title, R.string.prefs_block_potentially_offensive_summary
) {
SwitchPreference(it, true)
},
Setting(context, Settings.PREF_AUTO_CORRECTION,
R.string.autocorrect, R.string.auto_correction_summary
) {
SwitchPreference(it, true)
},
Setting(context, Settings.PREF_MORE_AUTO_CORRECTION,
R.string.more_autocorrect, R.string.more_autocorrect_summary
) {
SwitchPreference(it, true) // todo (later): shouldn't it better be false?
},
Setting(context, Settings.PREF_AUTOCORRECT_SHORTCUTS,
R.string.auto_correct_shortcuts, R.string.auto_correct_shortcuts_summary
) {
SwitchPreference(it, true)
},
Setting(context, Settings.PREF_AUTO_CORRECTION_CONFIDENCE, R.string.auto_correction_confidence) {
val items = listOf(
stringResource(R.string.auto_correction_threshold_mode_modest) to "0",
stringResource(R.string.auto_correction_threshold_mode_aggressive) to "1",
stringResource(R.string.auto_correction_threshold_mode_very_aggressive) to "2",
)
ListPreference(it, items, "0")
},
Setting(context, Settings.PREF_AUTO_CAP,
R.string.auto_cap, R.string.auto_cap_summary
) {
SwitchPreference(it, true)
},
Setting(context, Settings.PREF_KEY_USE_DOUBLE_SPACE_PERIOD,
R.string.use_double_space_period, R.string.use_double_space_period_summary
) {
SwitchPreference(it, true)
},
Setting(context, Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION,
R.string.autospace_after_punctuation, R.string.autospace_after_punctuation_summary
) {
SwitchPreference(it, false)
},
Setting(context, Settings.PREF_SHOW_SUGGESTIONS,
R.string.prefs_show_suggestions, R.string.prefs_show_suggestions_summary
) {
SwitchPreference(it, true)
},
Setting(context, Settings.PREF_ALWAYS_SHOW_SUGGESTIONS,
R.string.prefs_always_show_suggestions, R.string.prefs_always_show_suggestions_summary
) {
SwitchPreference(it, false)
},
Setting(context, Settings.PREF_KEY_USE_PERSONALIZED_DICTS,
R.string.use_personalized_dicts, R.string.use_personalized_dicts_summary
) { setting ->
var showConfirmDialog by rememberSaveable { mutableStateOf(false) }
SwitchPreference(setting, true,
allowCheckedChange = {
showConfirmDialog = !it
it
}
)
if (showConfirmDialog) {
val prefs = LocalContext.current.prefs()
ConfirmationDialog(
onDismissRequest = { showConfirmDialog = false },
onConfirmed = {
prefs.edit().putBoolean(setting.key, false).apply()
},
text = { Text(stringResource(R.string.disable_personalized_dicts_message)) }
)
}
},
Setting(context, Settings.PREF_BIGRAM_PREDICTIONS,
R.string.bigram_prediction, R.string.bigram_prediction_summary
) {
SwitchPreference(it, true) { keyboardNeedsReload = true }
},
Setting(context, Settings.PREF_CENTER_SUGGESTION_TEXT_TO_ENTER,
R.string.center_suggestion_text_to_enter, R.string.center_suggestion_text_to_enter_summary
) {
SwitchPreference(it, false)
},
Setting(context, Settings.PREF_SUGGEST_CLIPBOARD_CONTENT,
R.string.suggest_clipboard_content, R.string.suggest_clipboard_content_summary
) {
SwitchPreference(it, true)
},
Setting(context, Settings.PREF_USE_CONTACTS,
R.string.use_contacts_dict, R.string.use_contacts_dict_summary
) { setting ->
val activity = LocalContext.current.getActivity() ?: return@Setting
var granted by remember { mutableStateOf(PermissionsUtil.checkAllPermissionsGranted(activity, Manifest.permission.READ_CONTACTS)) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
granted = it
if (granted)
activity.prefs().edit().putBoolean(setting.key, true).apply()
}
SwitchPreference(setting, false,
allowCheckedChange = {
if (it && !granted) {
launcher.launch(Manifest.permission.READ_CONTACTS)
false
} else true
}
)
},
Setting(context, Settings.PREF_ADD_TO_PERSONAL_DICTIONARY,
R.string.add_to_personal_dictionary, R.string.add_to_personal_dictionary_summary
) {
SwitchPreference(it, false)
},
)
@Preview
@Composable
private fun PreferencePreview() {
SettingsActivity.settingsContainer = SettingsContainer(LocalContext.current)
Theme(true) {
Surface {
TextCorrectionScreen { }
}
}
}

View file

@ -0,0 +1,131 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.screens
import android.content.Context
import android.graphics.drawable.VectorDrawable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import androidx.core.util.TypedValueCompat
import helium314.keyboard.keyboard.internal.KeyboardIconsSet
import helium314.keyboard.latin.R
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.utils.defaultClipboardToolbarPref
import helium314.keyboard.latin.utils.defaultPinnedToolbarPref
import helium314.keyboard.latin.utils.defaultToolbarPref
import helium314.keyboard.settings.SettingsContainer
import helium314.keyboard.settings.SettingsWithoutKey
import helium314.keyboard.settings.Setting
import helium314.keyboard.settings.preferences.Preference
import helium314.keyboard.settings.preferences.ReorderSwitchPreference
import helium314.keyboard.settings.SearchSettingsScreen
import helium314.keyboard.settings.SettingsActivity
import helium314.keyboard.settings.preferences.SwitchPreference
import helium314.keyboard.settings.Theme
import helium314.keyboard.settings.dialogs.ToolbarKeysCustomizer
import helium314.keyboard.settings.keyboardNeedsReload
@Composable
fun ToolbarScreen(
onClickBack: () -> Unit,
) {
val items = listOf(
Settings.PREF_TOOLBAR_KEYS,
Settings.PREF_PINNED_TOOLBAR_KEYS,
Settings.PREF_CLIPBOARD_TOOLBAR_KEYS,
SettingsWithoutKey.CUSTOM_KEY_CODES,
Settings.PREF_QUICK_PIN_TOOLBAR_KEYS,
Settings.PREF_AUTO_SHOW_TOOLBAR,
Settings.PREF_AUTO_HIDE_TOOLBAR,
Settings.PREF_VARIABLE_TOOLBAR_DIRECTION
)
SearchSettingsScreen(
onClickBack = onClickBack,
title = stringResource(R.string.settings_screen_toolbar),
settings = items
)
}
fun createToolbarSettings(context: Context) = listOf(
Setting(context, Settings.PREF_TOOLBAR_KEYS, R.string.toolbar_keys) {
ReorderSwitchPreference(it, defaultToolbarPref)
},
Setting(context, Settings.PREF_PINNED_TOOLBAR_KEYS, R.string.pinned_toolbar_keys) {
ReorderSwitchPreference(it, defaultPinnedToolbarPref)
},
Setting(context, Settings.PREF_CLIPBOARD_TOOLBAR_KEYS, R.string.clipboard_toolbar_keys) {
ReorderSwitchPreference(it, defaultClipboardToolbarPref)
},
Setting(context, SettingsWithoutKey.CUSTOM_KEY_CODES, R.string.customize_toolbar_key_codes) {
var showDialog by rememberSaveable { mutableStateOf(false) }
Preference(
name = it.title,
onClick = { showDialog = true },
)
if (showDialog)
// todo (later): CUSTOM_KEY_CODES vs the 2 actual prefs that are changed...
ToolbarKeysCustomizer(
onDismissRequest = { showDialog = false }
)
},
Setting(context, Settings.PREF_QUICK_PIN_TOOLBAR_KEYS,
R.string.quick_pin_toolbar_keys, R.string.quick_pin_toolbar_keys_summary)
{
SwitchPreference(it, false,) { keyboardNeedsReload = true }
},
Setting(context, Settings.PREF_AUTO_SHOW_TOOLBAR, R.string.auto_show_toolbar, R.string.auto_show_toolbar_summary)
{
SwitchPreference(it, false,)
},
Setting(context, Settings.PREF_AUTO_HIDE_TOOLBAR, R.string.auto_hide_toolbar, R.string.auto_hide_toolbar_summary)
{
SwitchPreference(it, false,)
},
Setting(context, Settings.PREF_VARIABLE_TOOLBAR_DIRECTION,
R.string.var_toolbar_direction, R.string.var_toolbar_direction_summary)
{
SwitchPreference(it, true,)
}
)
@Preview
@Composable
private fun Preview() {
SettingsActivity.settingsContainer = SettingsContainer(LocalContext.current)
KeyboardIconsSet.instance.loadIcons(LocalContext.current)
Theme(true) {
Surface {
ToolbarScreen { }
}
}
}
@Composable
fun KeyboardIconsSet.GetIcon(name: String?) {
val ctx = LocalContext.current
val drawable = getNewDrawable(name, ctx)
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
if (drawable is VectorDrawable)
Icon(painterResource(iconIds[name?.lowercase()]!!), null, Modifier.fillMaxSize(0.8f))
else if (drawable != null) {
val px = TypedValueCompat.dpToPx(40f, ctx.resources.displayMetrics).toInt()
Icon(drawable.toBitmap(px, px).asImageBitmap(), null, Modifier.fillMaxSize(0.8f))
}
}
}

View file

@ -0,0 +1,9 @@
<!--
icon available in Android Studio
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
</vector>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.compose.ui.platform.ComposeView
android:id="@+id/navHost"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<RelativeLayout
android:id="@+id/settingsFragmentContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/setup_background"
android:visibility="gone" >
</RelativeLayout>
</RelativeLayout>

View file

@ -6,7 +6,7 @@
-->
<resources xmlns:android="http://schemas.android.com/apk/res/android">
<style name="platformActivityTheme" parent="Theme.AppCompat.DayNight">
<style name="platformActivityTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:colorAccent">@color/accent</item>
<item name="colorAccent">@color/accent</item>