From e845e38e42499eb5e637ab7a550511d7b97e381e Mon Sep 17 00:00:00 2001 From: Helium314 Date: Sun, 9 Feb 2025 13:27:55 +0100 Subject: [PATCH] Settings upgrade (#1325) Re-implement most of the settings in compose, searchable Languages & Layouts, Colors, and Personal Dictionary still missing, will be done later --- README.md | 1 - app/build.gradle.kts | 12 + app/src/main/AndroidManifest.xml | 4 +- .../keyboard/keyboard/KeyboardSwitcher.java | 14 +- .../helium314/keyboard/latin/LatinIME.java | 14 +- .../java/helium314/keyboard/latin/Settings.kt | 1 + .../latin/settings/DebugSettingsFragment.java | 2 +- ...Activity.java => OldSettingsActivity.java} | 2 +- .../latin/settings/SettingsValues.java | 9 +- .../latin/setup/SetupWizardActivity.java | 11 +- .../keyboard/latin/utils/CustomLayoutUtils.kt | 2 +- .../latin/utils/DeviceProtectedUtils.java | 4 + .../helium314/keyboard/latin/utils/Ktx.kt | 33 +- .../keyboard/latin/utils/ToolbarUtils.kt | 4 +- .../keyboard/settings/SearchScreen.kt | 212 +++++++++++ .../keyboard/settings/SettingsActivity.kt | 83 +++++ .../keyboard/settings/SettingsContainer.kt | 86 +++++ .../keyboard/settings/SettingsNavHost.kt | 137 +++++++ .../java/helium314/keyboard/settings/Theme.kt | 57 +++ .../settings/dialogs/ColorPickerDialog.kt | 107 ++++++ .../settings/dialogs/ConfirmationDialog.kt | 49 +++ .../settings/dialogs/CustomizeIconsDialog.kt | 185 ++++++++++ .../keyboard/settings/dialogs/InfoDialog.kt | 18 + .../settings/dialogs/LayoutEditDialog.kt | 91 +++++ .../settings/dialogs/ListPickerDialog.kt | 112 ++++++ .../settings/dialogs/ReorderDialog.kt | 103 ++++++ .../keyboard/settings/dialogs/SliderDialog.kt | 81 +++++ .../settings/dialogs/TextInputDialog.kt | 94 +++++ .../dialogs/ThreeButtonAlertDialog.kt | 105 ++++++ .../settings/dialogs/ToolbarKeysCustomizer.kt | 167 +++++++++ .../preferences/BackgroundImagePreference.kt | 128 +++++++ .../preferences/BackupRestorePreference.kt | 302 ++++++++++++++++ .../preferences/CustomFontPreference.kt | 76 ++++ .../preferences/LayoutEditPreference.kt | 59 +++ .../preferences/LoadGestureLibPreference.kt | 113 ++++++ .../settings/preferences/Preference.kt | 166 +++++++++ .../preferences/ReorderSwitchPreference.kt | 69 ++++ .../settings/preferences/SliderPreference.kt | 129 +++++++ .../settings/preferences/SwitchPreference.kt | 68 ++++ .../keyboard/settings/screens/AboutScreen.kt | 180 ++++++++++ .../settings/screens/AdvancedScreen.kt | 274 ++++++++++++++ .../settings/screens/AppearanceScreen.kt | 340 ++++++++++++++++++ .../keyboard/settings/screens/ColorsScreen.kt | 34 ++ .../keyboard/settings/screens/DebugScreen.kt | 119 ++++++ .../settings/screens/GestureTypingScreen.kt | 119 ++++++ .../settings/screens/MainSettingsScreen.kt | 175 +++++++++ .../settings/screens/PreferencesScreen.kt | 198 ++++++++++ .../settings/screens/TextCorrectionScreen.kt | 222 ++++++++++++ .../settings/screens/ToolbarScreen.kt | 131 +++++++ .../res/drawable/baseline_arrow_back_24.xml | 9 + app/src/main/res/layout/settings_activity.xml | 19 + app/src/main/res/values/platform-theme.xml | 2 +- 52 files changed, 4708 insertions(+), 24 deletions(-) rename app/src/main/java/helium314/keyboard/latin/settings/{SettingsActivity.java => OldSettingsActivity.java} (97%) create mode 100644 app/src/main/java/helium314/keyboard/settings/SearchScreen.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/SettingsActivity.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/Theme.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/dialogs/ColorPickerDialog.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/dialogs/ConfirmationDialog.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/dialogs/CustomizeIconsDialog.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/dialogs/InfoDialog.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/dialogs/LayoutEditDialog.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/dialogs/ListPickerDialog.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/dialogs/ReorderDialog.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/dialogs/SliderDialog.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/dialogs/TextInputDialog.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/dialogs/ThreeButtonAlertDialog.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/dialogs/ToolbarKeysCustomizer.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/preferences/BackgroundImagePreference.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/preferences/CustomFontPreference.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/preferences/LayoutEditPreference.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/preferences/LoadGestureLibPreference.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/preferences/Preference.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/preferences/ReorderSwitchPreference.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/preferences/SliderPreference.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/preferences/SwitchPreference.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/screens/AboutScreen.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/screens/AppearanceScreen.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/screens/ColorsScreen.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/screens/DebugScreen.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/screens/MainSettingsScreen.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/screens/PreferencesScreen.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt create mode 100644 app/src/main/res/drawable/baseline_arrow_back_24.xml create mode 100644 app/src/main/res/layout/settings_activity.xml diff --git a/README.md b/README.md index 69935b75..ae62abc0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7d6bc1be..13824ffa 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f0cee567..85d8b904 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -74,10 +74,10 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only - diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java index a07c27f0..4e189d88 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java @@ -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()); } /** diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index 592af48d..40e10769 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -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; + } + } } diff --git a/app/src/main/java/helium314/keyboard/latin/Settings.kt b/app/src/main/java/helium314/keyboard/latin/Settings.kt index 4d5ea297..278f4d8b 100644 --- a/app/src/main/java/helium314/keyboard/latin/Settings.kt +++ b/app/src/main/java/helium314/keyboard/latin/Settings.kt @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: GPL-3.0-only package helium314.keyboard.latin import android.content.Context diff --git a/app/src/main/java/helium314/keyboard/latin/settings/DebugSettingsFragment.java b/app/src/main/java/helium314/keyboard/latin/settings/DebugSettingsFragment.java index 778e0510..578c0fae 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/DebugSettingsFragment.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/DebugSettingsFragment.java @@ -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; diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsActivity.java b/app/src/main/java/helium314/keyboard/latin/settings/OldSettingsActivity.java similarity index 97% rename from app/src/main/java/helium314/keyboard/latin/settings/SettingsActivity.java rename to app/src/main/java/helium314/keyboard/latin/settings/OldSettingsActivity.java index dfbafca6..18b6a920 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsActivity.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/OldSettingsActivity.java @@ -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(); diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java index 52b81d26..51acc42c 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java @@ -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() { diff --git a/app/src/main/java/helium314/keyboard/latin/setup/SetupWizardActivity.java b/app/src/main/java/helium314/keyboard/latin/setup/SetupWizardActivity.java index 6bab8993..32a77e80 100644 --- a/app/src/main/java/helium314/keyboard/latin/setup/SetupWizardActivity.java +++ b/app/src/main/java/helium314/keyboard/latin/setup/SetupWizardActivity.java @@ -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); } diff --git a/app/src/main/java/helium314/keyboard/latin/utils/CustomLayoutUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/CustomLayoutUtils.kt index 1b7329af..35b48c18 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/CustomLayoutUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/CustomLayoutUtils.kt @@ -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) diff --git a/app/src/main/java/helium314/keyboard/latin/utils/DeviceProtectedUtils.java b/app/src/main/java/helium314/keyboard/latin/utils/DeviceProtectedUtils.java index 81b302fd..8a3da20c 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/DeviceProtectedUtils.java +++ b/app/src/main/java/helium314/keyboard/latin/utils/DeviceProtectedUtils.java @@ -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"); diff --git a/app/src/main/java/helium314/keyboard/latin/utils/Ktx.kt b/app/src/main/java/helium314/keyboard/latin/utils/Ktx.kt index 98d46c67..e3af4f32 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/Ktx.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/Ktx.kt @@ -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 Iterable.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 MutableList.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(R.id.settingsFragmentContainer).visibility = View.VISIBLE + replace(R.id.settingsFragmentContainer, fragment) + addToBackStack(null) + } +} + +fun Context.prefs(): SharedPreferences = DeviceProtectedUtils.getSharedPreferences(this) diff --git a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt index cb088f25..db2f376a 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt @@ -322,12 +322,12 @@ fun readCustomLongpressCodes(prefs: SharedPreferences) = prefs.getString(Setting it.substringBefore(",") to code } -private fun writeCustomKeyCodes(prefs: SharedPreferences, codes: Map) { +fun writeCustomKeyCodes(prefs: SharedPreferences, codes: Map) { 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) { +fun writeCustomLongpressCodes(prefs: SharedPreferences, codes: Map) { val string = codes.mapNotNull { entry -> entry.value?.let { "${entry.key},$it" } }.joinToString(";") prefs.edit().putString(Settings.PREF_TOOLBAR_CUSTOM_LONGPRESS_CODES, string).apply() } diff --git a/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt b/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt new file mode 100644 index 00000000..6cc4f173 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt @@ -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, + 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 SearchScreen( + onClickBack: () -> Unit, + title: String, + filteredItems: (String) -> List, + 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 + ) + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsActivity.kt b/app/src/main/java/helium314/keyboard/settings/SettingsActivity.kt new file mode 100644 index 00000000..20c589a8 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/SettingsActivity.kt @@ -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(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(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 diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt new file mode 100644 index 00000000..cb55691f --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt @@ -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 = HashMap(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 { + val term = searchTerm.lowercase() + val results = mutableSetOf() + 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" +} diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt b/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt new file mode 100644 index 00000000..527a2477 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt @@ -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 + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/Theme.kt b/app/src/main/java/helium314/keyboard/settings/Theme.kt new file mode 100644 index 00000000..e8924a30 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/Theme.kt @@ -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 + ) +} diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/ColorPickerDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/ColorPickerDialog.kt new file mode 100644 index 00000000..0fe913f1 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/ColorPickerDialog.kt @@ -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", {}) +} diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/ConfirmationDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/ConfirmationDialog.kt new file mode 100644 index 00000000..5a409fb8 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/ConfirmationDialog.kt @@ -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)) } + ) +} diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/CustomizeIconsDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/CustomizeIconsDialog.kt new file mode 100644 index 00000000..c0241022 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/CustomizeIconsDialog.kt @@ -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? 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() + 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 = { }, + ) +} diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/InfoDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/InfoDialog.kt new file mode 100644 index 00000000..f01b9565 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/InfoDialog.kt @@ -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 + ) +} diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/LayoutEditDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/LayoutEditDialog.kt new file mode 100644 index 00000000..ca46082b --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/LayoutEditDialog.kt @@ -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) + ) +} diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/ListPickerDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/ListPickerDialog.kt new file mode 100644 index 00000000..b2e6157c --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/ListPickerDialog.kt @@ -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 ListPickerDialog( + onDismissRequest: () -> Unit, + items: List, + 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" }, + ) +} diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/ReorderDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/ReorderDialog.kt new file mode 100644 index 00000000..9d29903b --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/ReorderDialog.kt @@ -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 ReorderDialog( + onDismissRequest: () -> Unit, + onConfirmed: (List) -> Unit, + items: List, + 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() } + ) +} diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/SliderDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/SliderDialog.kt new file mode 100644 index 00000000..713624e0 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/SliderDialog.kt @@ -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, + 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 + ) +} diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/TextInputDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/TextInputDialog.kt new file mode 100644 index 00000000..b70aad42 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/TextInputDialog.kt @@ -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") } + ) +} diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/ThreeButtonAlertDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/ThreeButtonAlertDialog.kt new file mode 100644 index 00000000..5514ac1d --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/ThreeButtonAlertDialog.kt @@ -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" + ) + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/ToolbarKeysCustomizer.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/ToolbarKeysCustomizer.kt new file mode 100644 index 00000000..22d99142 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/ToolbarKeysCustomizer.kt @@ -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() diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/BackgroundImagePreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/BackgroundImagePreference.kt new file mode 100644 index 00000000..50cf48d6 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/preferences/BackgroundImagePreference.kt @@ -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 +} diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt new file mode 100644 index 00000000..00e3433b --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt @@ -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() + 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() + 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, out: OutputStream) { + val booleans = settings.filter { it.key is String && it.value is Boolean } as Map + val ints = settings.filter { it.key is String && it.value is Int } as Map + val longs = settings.filter { it.key is String && it.value is Long } as Map + val floats = settings.filter { it.key is String && it.value is Float } as Map + val strings = settings.filter { it.key is String && it.value is String } as Map + val stringSets = settings.filter { it.key is String && it.value is Set<*> } as Map> + // 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, prefs: SharedPreferences): Boolean { + val i = list.iterator() + val e = prefs.edit() + try { + while (i.hasNext()) { + when (i.next()) { + "boolean settings" -> Json.decodeFromString>(i.next()).forEach { e.putBoolean(it.key, it.value) } + "int settings" -> Json.decodeFromString>(i.next()).forEach { e.putInt(it.key, it.value) } + "long settings" -> Json.decodeFromString>(i.next()).forEach { e.putLong(it.key, it.value) } + "float settings" -> Json.decodeFromString>(i.next()).forEach { e.putFloat(it.key, it.value) } + "string settings" -> Json.decodeFromString>(i.next()).forEach { e.putString(it.key, it.value) } + "string set settings" -> Json.decodeFromString>>(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" diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/CustomFontPreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/CustomFontPreference.kt new file mode 100644 index 00000000..3cd12f3c --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/preferences/CustomFontPreference.kt @@ -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 } +} diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/LayoutEditPreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/LayoutEditPreference.kt new file mode 100644 index 00000000..f4bcc92e --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/preferences/LayoutEditPreference.kt @@ -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, + 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 } + ) + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/LoadGestureLibPreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/LoadGestureLibPreference.kt new file mode 100644 index 00000000..85102a13 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/preferences/LoadGestureLibPreference.kt @@ -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()) ?: "") + } + ) +} diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/Preference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/Preference.kt new file mode 100644 index 00000000..7eaab76e --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/preferences/Preference.kt @@ -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") + } + } + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/ReorderSwitchPreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/ReorderSwitchPreference.kt new file mode 100644 index 00000000..3cb74137 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/preferences/ReorderSwitchPreference.kt @@ -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) diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/SliderPreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/SliderPreference.kt new file mode 100644 index 00000000..d1e5cc47 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/preferences/SliderPreference.kt @@ -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 SliderPreference( + name: String, + modifier: Modifier = Modifier, + key: String, + description: @Composable (T) -> String, + default: T, + range: ClosedFloatingPointRange, + 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 ListPreference( + setting: Setting, + items: List>, + 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 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 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}") + } + } diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/SwitchPreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/SwitchPreference.kt new file mode 100644 index 00000000..96bcc014 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/preferences/SwitchPreference.kt @@ -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) }, + ) + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AboutScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AboutScreen.kt new file mode 100644 index 00000000..c78671fa --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/screens/AboutScreen.kt @@ -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 = ("" + + ctx.getString(R.string.hidden_features_text) + "") + 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(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 { } + } + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt new file mode 100644 index 00000000..a07481de --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt @@ -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 { } + } + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AppearanceScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AppearanceScreen.kt new file mode 100644 index 00000000..6262c67d --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/screens/AppearanceScreen.kt @@ -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 { } + } + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/screens/ColorsScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/ColorsScreen.kt new file mode 100644 index 00000000..2310b90e --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/screens/ColorsScreen.kt @@ -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()) } // 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 +) \ No newline at end of file diff --git a/app/src/main/java/helium314/keyboard/settings/screens/DebugScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/DebugScreen.kt new file mode 100644 index 00000000..8053c4c4 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/screens/DebugScreen.kt @@ -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 { } + } + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt new file mode 100644 index 00000000..141036f5 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt @@ -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 { } + } + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/screens/MainSettingsScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/MainSettingsScreen.kt new file mode 100644 index 00000000..35e34dba --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/screens/MainSettingsScreen.kt @@ -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({}, {}, {}, {}, {}, {}, {}, {}) + } + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/screens/PreferencesScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/PreferencesScreen.kt new file mode 100644 index 00000000..3fdf6f3b --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/screens/PreferencesScreen.kt @@ -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 { } + } + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt new file mode 100644 index 00000000..6b75051e --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt @@ -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 { } + } + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt new file mode 100644 index 00000000..a253c4b5 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt @@ -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)) + } + } +} diff --git a/app/src/main/res/drawable/baseline_arrow_back_24.xml b/app/src/main/res/drawable/baseline_arrow_back_24.xml new file mode 100644 index 00000000..4689d8d6 --- /dev/null +++ b/app/src/main/res/drawable/baseline_arrow_back_24.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/layout/settings_activity.xml b/app/src/main/res/layout/settings_activity.xml new file mode 100644 index 00000000..b689a448 --- /dev/null +++ b/app/src/main/res/layout/settings_activity.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/values/platform-theme.xml b/app/src/main/res/values/platform-theme.xml index ed9fc5af..432d2071 100644 --- a/app/src/main/res/values/platform-theme.xml +++ b/app/src/main/res/values/platform-theme.xml @@ -6,7 +6,7 @@ --> -