From a3e685574544de2bb2fb86cab80e51376a46f14f Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Thu, 23 Apr 2026 00:17:45 +0000 Subject: [PATCH] Add first settings UI for nav button configuration --- app/src/main/AndroidManifest.xml | 15 +- .../navbuttons/NavButtonSettingsStore.kt | 112 +++++++ .../ajpanton/navbuttons/SettingsActivity.kt | 305 ++++++++++++++++++ .../main/res/drawable/bg_button_section.xml | 9 + .../main/res/drawable/bg_settings_window.xml | 8 + .../main/res/drawable/bg_spinner_field.xml | 9 + .../main/res/drawable/bg_spinner_popup.xml | 9 + .../res/drawable/bg_test_hold_overlay.xml | 5 + app/src/main/res/layout/activity_settings.xml | 60 ++++ .../res/layout/item_nav_button_section.xml | 122 +++++++ .../main/res/layout/item_spinner_dropdown.xml | 14 + .../main/res/layout/item_spinner_selected.xml | 11 + app/src/main/res/values-night/colors.xml | 17 + app/src/main/res/values/colors.xml | 17 + app/src/main/res/values/dimens.xml | 5 + app/src/main/res/values/strings.xml | 16 +- app/src/main/res/values/themes.xml | 6 + 17 files changed, 738 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/se/ajpanton/navbuttons/NavButtonSettingsStore.kt create mode 100644 app/src/main/java/se/ajpanton/navbuttons/SettingsActivity.kt create mode 100644 app/src/main/res/drawable/bg_button_section.xml create mode 100644 app/src/main/res/drawable/bg_settings_window.xml create mode 100644 app/src/main/res/drawable/bg_spinner_field.xml create mode 100644 app/src/main/res/drawable/bg_spinner_popup.xml create mode 100644 app/src/main/res/drawable/bg_test_hold_overlay.xml create mode 100644 app/src/main/res/layout/activity_settings.xml create mode 100644 app/src/main/res/layout/item_nav_button_section.xml create mode 100644 app/src/main/res/layout/item_spinner_dropdown.xml create mode 100644 app/src/main/res/layout/item_spinner_selected.xml create mode 100644 app/src/main/res/values-night/colors.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/dimens.xml create mode 100644 app/src/main/res/values/themes.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e6e334b..b69ad59 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,19 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" - android:supportsRtl="true" /> + android:supportsRtl="true" + android:theme="@style/Theme.NavButtons"> + + + + + + + + + diff --git a/app/src/main/java/se/ajpanton/navbuttons/NavButtonSettingsStore.kt b/app/src/main/java/se/ajpanton/navbuttons/NavButtonSettingsStore.kt new file mode 100644 index 0000000..cde8cab --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/NavButtonSettingsStore.kt @@ -0,0 +1,112 @@ +package se.ajpanton.navbuttons + +import android.content.Context +import android.view.ViewConfiguration +import androidx.annotation.StringRes + +enum class NavButtonId( + val preferencePrefix: String, + @get:StringRes val titleRes: Int, +) { + BACK("back", R.string.button_back), + HOME("home", R.string.button_home), + RECENTS("recents", R.string.button_recents), +} + +enum class NavButtonAction( + val storageValue: String, + @get:StringRes val labelRes: Int, +) { + STOCK("stock", R.string.action_stock), + KILL_FOREGROUND_APP("kill_foreground_app", R.string.action_kill_foreground_app), + NONE("none", R.string.action_none), + ; + + companion object { + fun fromStorageValue(value: String?): NavButtonAction { + return entries.firstOrNull { it.storageValue == value } ?: STOCK + } + } +} + +data class NavButtonConfig( + val pressAction: NavButtonAction, + val longPressAction: NavButtonAction, + val longPressDurationMs: Int, +) + +class NavButtonSettingsStore(context: Context) { + private val preferences = context.applicationContext.getSharedPreferences( + PREFERENCES_NAME, + Context.MODE_PRIVATE, + ) + + fun getConfig(buttonId: NavButtonId): NavButtonConfig { + return NavButtonConfig( + pressAction = NavButtonAction.fromStorageValue( + preferences.getString(pressActionKey(buttonId), null), + ), + longPressAction = NavButtonAction.fromStorageValue( + preferences.getString(longPressActionKey(buttonId), null), + ), + longPressDurationMs = sanitizeDuration( + preferences.getInt( + longPressDurationKey(buttonId), + defaultLongPressDurationMs(), + ), + ), + ) + } + + fun setPressAction(buttonId: NavButtonId, action: NavButtonAction) { + preferences.edit() + .putString(pressActionKey(buttonId), action.storageValue) + .apply() + } + + fun setLongPressAction(buttonId: NavButtonId, action: NavButtonAction) { + preferences.edit() + .putString(longPressActionKey(buttonId), action.storageValue) + .apply() + } + + fun setLongPressDurationMs(buttonId: NavButtonId, durationMs: Int) { + preferences.edit() + .putInt(longPressDurationKey(buttonId), sanitizeDuration(durationMs)) + .apply() + } + + fun defaultLongPressDurationMs(): Int { + return sanitizeDuration(ViewConfiguration.getLongPressTimeout()) + } + + fun sanitizeDuration(durationMs: Int): Int { + return durationMs.coerceIn(MIN_LONG_PRESS_DURATION_MS, MAX_LONG_PRESS_DURATION_MS) + } + + companion object { + const val PREFERENCES_NAME = "nav_button_settings" + const val KEY_PRESS_ACTION = "press_action" + const val KEY_LONG_PRESS_ACTION = "long_press_action" + const val KEY_LONG_PRESS_DURATION_MS = "long_press_duration_ms" + + const val MIN_LONG_PRESS_DURATION_MS = 100 + const val MAX_LONG_PRESS_DURATION_MS = 10_000 + const val MIN_DURATION_MS = 100 + const val MAX_DURATION_MS = 10_000 + const val DURATION_STEP_MS = 100 + const val HOLD_REPEAT_START_DELAY_MS = 700L + const val HOLD_REPEAT_INTERVAL_MS = 100L + + fun pressActionKey(buttonId: NavButtonId): String = key(buttonId, KEY_PRESS_ACTION) + + fun longPressActionKey(buttonId: NavButtonId): String = key(buttonId, KEY_LONG_PRESS_ACTION) + + fun longPressDurationKey(buttonId: NavButtonId): String = + key(buttonId, KEY_LONG_PRESS_DURATION_MS) + + private fun key(buttonId: NavButtonId, suffix: String): String { + return "${buttonId.preferencePrefix}_$suffix" + } + } +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/SettingsActivity.kt b/app/src/main/java/se/ajpanton/navbuttons/SettingsActivity.kt new file mode 100644 index 0000000..deae4c1 --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/SettingsActivity.kt @@ -0,0 +1,305 @@ +package se.ajpanton.navbuttons + +import android.app.Activity +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.Spinner +import android.widget.TextView + +class SettingsActivity : Activity() { + private val handler = Handler(Looper.getMainLooper()) + + private lateinit var settingsStore: NavButtonSettingsStore + private lateinit var sectionsContainer: LinearLayout + private lateinit var previewOverlay: View + private lateinit var previewRunnable: Runnable + + private var previewScheduled = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_settings) + + title = getString(R.string.settings_title) + settingsStore = NavButtonSettingsStore(applicationContext) + sectionsContainer = findViewById(R.id.button_sections_container) + previewOverlay = findViewById(R.id.test_hold_overlay) + previewRunnable = Runnable { + previewScheduled = false + previewOverlay.visibility = View.VISIBLE + previewOverlay.animate() + .alpha(1f) + .setDuration(120) + .start() + } + + addButtonSections() + } + + override fun onPause() { + super.onPause() + stopTestPreview() + } + + private fun addButtonSections() { + val inflater = LayoutInflater.from(this) + NavButtonId.entries.forEachIndexed { index, buttonId -> + val sectionView = inflater.inflate( + R.layout.item_nav_button_section, + sectionsContainer, + false, + ) + if (index > 0) { + val marginTop = resources.getDimensionPixelSize(R.dimen.section_spacing) + val layoutParams = sectionView.layoutParams as? ViewGroup.MarginLayoutParams + if (layoutParams != null) { + layoutParams.topMargin = marginTop + } else { + sectionView.layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { + topMargin = marginTop + } + } + } + + NavButtonSectionController(sectionView, buttonId).bind() + sectionsContainer.addView(sectionView) + } + } + + private fun startTestPreview(durationMs: Int) { + stopTestPreview() + previewScheduled = true + handler.postDelayed(previewRunnable, durationMs.toLong()) + } + + private fun stopTestPreview() { + if (previewScheduled) { + handler.removeCallbacks(previewRunnable) + previewScheduled = false + } + previewOverlay.animate().cancel() + previewOverlay.alpha = 0f + previewOverlay.visibility = View.GONE + } + + private inner class NavButtonSectionController( + sectionView: View, + private val buttonId: NavButtonId, + ) { + private val titleView: TextView = sectionView.findViewById(R.id.section_title) + private val pressActionSpinner: Spinner = sectionView.findViewById(R.id.press_action_spinner) + private val longPressActionSpinner: Spinner = + sectionView.findViewById(R.id.long_press_action_spinner) + private val durationInput: EditText = sectionView.findViewById(R.id.long_press_duration_input) + private val minusButton: Button = sectionView.findViewById(R.id.minus_button) + private val plusButton: Button = sectionView.findViewById(R.id.plus_button) + private val testButton: Button = sectionView.findViewById(R.id.test_button) + + private var lastValidDurationMs = settingsStore.defaultLongPressDurationMs() + private var updatingDurationText = false + + fun bind() { + val config = settingsStore.getConfig(buttonId) + titleView.setText(buttonId.titleRes) + + bindActionSpinner( + pressActionSpinner, + config.pressAction, + settingsStore::setPressAction, + ) + bindActionSpinner( + longPressActionSpinner, + config.longPressAction, + settingsStore::setLongPressAction, + ) + + applyDuration(config.longPressDurationMs) + bindDurationInput() + bindStepButtons() + bindTestButton() + } + + private fun bindActionSpinner( + spinner: Spinner, + selectedAction: NavButtonAction, + storeAction: (NavButtonId, NavButtonAction) -> Unit, + ) { + val labels = NavButtonAction.entries.map { getString(it.labelRes) } + val adapter = ArrayAdapter( + this@SettingsActivity, + R.layout.item_spinner_selected, + labels, + ) + adapter.setDropDownViewResource(R.layout.item_spinner_dropdown) + spinner.adapter = adapter + spinner.setSelection(NavButtonAction.entries.indexOf(selectedAction), false) + spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected( + parent: AdapterView<*>?, + view: View?, + position: Int, + id: Long, + ) { + storeAction(buttonId, NavButtonAction.entries[position]) + } + + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + } + } + + private fun bindDurationInput() { + durationInput.setOnFocusChangeListener { _, hasFocus -> + if (!hasFocus) { + normalizeTypedDuration() + } + } + + durationInput.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged( + s: CharSequence?, + start: Int, + count: Int, + after: Int, + ) = Unit + + override fun onTextChanged( + s: CharSequence?, + start: Int, + before: Int, + count: Int, + ) = Unit + + override fun afterTextChanged(editable: Editable?) { + if (updatingDurationText) { + return + } + + val parsed = editable?.toString()?.toIntOrNull() ?: return + if (parsed in NavButtonSettingsStore.MIN_DURATION_MS..NavButtonSettingsStore.MAX_DURATION_MS) { + lastValidDurationMs = parsed + settingsStore.setLongPressDurationMs(buttonId, parsed) + } + } + }) + } + + private fun bindStepButtons() { + attachRepeatButton(minusButton) { + adjustDuration(-NavButtonSettingsStore.DURATION_STEP_MS) + } + attachRepeatButton(plusButton) { + adjustDuration(NavButtonSettingsStore.DURATION_STEP_MS) + } + } + + private fun bindTestButton() { + testButton.setOnTouchListener { view, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + view.isPressed = true + startTestPreview(resolveDurationForUse()) + true + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL, + -> { + view.isPressed = false + stopTestPreview() + true + } + + else -> false + } + } + } + + private fun attachRepeatButton(button: Button, action: () -> Unit) { + var repeated = false + val repeatRunnable = object : Runnable { + override fun run() { + repeated = true + action() + handler.postDelayed(this, NavButtonSettingsStore.HOLD_REPEAT_INTERVAL_MS) + } + } + + button.setOnClickListener { + if (!repeated) { + action() + } + } + + button.setOnTouchListener { view, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + repeated = false + handler.postDelayed( + repeatRunnable, + NavButtonSettingsStore.HOLD_REPEAT_START_DELAY_MS, + ) + false + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL, + -> { + handler.removeCallbacks(repeatRunnable) + false + } + + else -> false + } + } + } + + private fun adjustDuration(deltaMs: Int) { + val current = resolveDurationForUse() + val updated = (current + deltaMs).coerceIn( + NavButtonSettingsStore.MIN_DURATION_MS, + NavButtonSettingsStore.MAX_DURATION_MS, + ) + applyDuration(updated) + } + + private fun normalizeTypedDuration(): Int { + val parsed = durationInput.text.toString().toIntOrNull() ?: lastValidDurationMs + val normalized = settingsStore.sanitizeDuration(parsed) + applyDuration(normalized) + return normalized + } + + private fun resolveDurationForUse(): Int { + return normalizeTypedDuration() + } + + private fun applyDuration(durationMs: Int) { + val sanitized = settingsStore.sanitizeDuration(durationMs) + val text = sanitized.toString() + updatingDurationText = true + if (durationInput.text.toString() != text) { + durationInput.setText(text) + durationInput.setSelection(durationInput.text.length) + } + updatingDurationText = false + + lastValidDurationMs = sanitized + settingsStore.setLongPressDurationMs(buttonId, sanitized) + } + } +} diff --git a/app/src/main/res/drawable/bg_button_section.xml b/app/src/main/res/drawable/bg_button_section.xml new file mode 100644 index 0000000..afaa7f3 --- /dev/null +++ b/app/src/main/res/drawable/bg_button_section.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_settings_window.xml b/app/src/main/res/drawable/bg_settings_window.xml new file mode 100644 index 0000000..b55018a --- /dev/null +++ b/app/src/main/res/drawable/bg_settings_window.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/bg_spinner_field.xml b/app/src/main/res/drawable/bg_spinner_field.xml new file mode 100644 index 0000000..570f6f1 --- /dev/null +++ b/app/src/main/res/drawable/bg_spinner_field.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_spinner_popup.xml b/app/src/main/res/drawable/bg_spinner_popup.xml new file mode 100644 index 0000000..f249b7c --- /dev/null +++ b/app/src/main/res/drawable/bg_spinner_popup.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_test_hold_overlay.xml b/app/src/main/res/drawable/bg_test_hold_overlay.xml new file mode 100644 index 0000000..538836d --- /dev/null +++ b/app/src/main/res/drawable/bg_test_hold_overlay.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000..3d2bfbc --- /dev/null +++ b/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_nav_button_section.xml b/app/src/main/res/layout/item_nav_button_section.xml new file mode 100644 index 0000000..68f183b --- /dev/null +++ b/app/src/main/res/layout/item_nav_button_section.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + +