From 4e56734b4bb5324f569b72bee1310c55a710ae36 Mon Sep 17 00:00:00 2001 From: salmon-21 <52450144+salmon-21@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:50:59 +0900 Subject: [PATCH] fix: don't mark filter edit dirty on render Rendering state back into the edit form called EditText.setText(), which fired the field's text-changed watcher and sent an Update command, flipping isDirty to true even though the user changed nothing. Returning to the filter list then wrongly prompted "Discard changes?". doAfterTextChanged now returns a setter that suppresses the watcher while applying a programmatic update, and render() uses it instead of setText(). Co-Authored-By: Claude Opus 4.8 --- .../logfox/core/ui/base/ext/TextViewExt.kt | 22 +++++++++- .../edit/ui/EditFilterFragment.kt | 43 +++++++++++++------ 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/core/ui/base/src/main/kotlin/com/f0x1d/logfox/core/ui/base/ext/TextViewExt.kt b/core/ui/base/src/main/kotlin/com/f0x1d/logfox/core/ui/base/ext/TextViewExt.kt index 609b0ffc..47018545 100644 --- a/core/ui/base/src/main/kotlin/com/f0x1d/logfox/core/ui/base/ext/TextViewExt.kt +++ b/core/ui/base/src/main/kotlin/com/f0x1d/logfox/core/ui/base/ext/TextViewExt.kt @@ -7,25 +7,45 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner +/** + * Observes user edits, attaching the watcher only while the fragment is resumed. + * + * Returns a setter that updates the text *without* notifying [callback]: programmatic updates (e.g. + * rendering state back into the field) would otherwise look like user edits. Use it instead of + * [TextView.setText] when pushing state into the field, and keep [TextView.setText] for genuine user + * input only. + */ fun TextView.doAfterTextChanged( fragment: Fragment, callback: (Editable?) -> Unit, -) { +): (CharSequence?) -> Unit { val textWatcher = object : TextWatcher { override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) = Unit override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) = Unit override fun afterTextChanged(s: Editable) = callback(s) } + var attached = false + fragment.viewLifecycleOwner.lifecycle.addObserver( object : DefaultLifecycleObserver { override fun onResume(owner: LifecycleOwner) { addTextChangedListener(textWatcher) + attached = true } override fun onPause(owner: LifecycleOwner) { removeTextChangedListener(textWatcher) + attached = false } }, ) + + return { text -> + // Detach around the update so the watcher doesn't fire for a programmatic change, then + // restore whatever attachment state the lifecycle had set. + removeTextChangedListener(textWatcher) + setText(text) + if (attached) addTextChangedListener(textWatcher) + } } diff --git a/feature/filters/presentation/src/main/kotlin/com/f0x1d/logfox/feature/filters/presentation/edit/ui/EditFilterFragment.kt b/feature/filters/presentation/src/main/kotlin/com/f0x1d/logfox/feature/filters/presentation/edit/ui/EditFilterFragment.kt index ac5878a4..e86e4a1b 100644 --- a/feature/filters/presentation/src/main/kotlin/com/f0x1d/logfox/feature/filters/presentation/edit/ui/EditFilterFragment.kt +++ b/feature/filters/presentation/src/main/kotlin/com/f0x1d/logfox/feature/filters/presentation/edit/ui/EditFilterFragment.kt @@ -60,6 +60,15 @@ internal class EditFilterFragment : } } + // Setters that update each field without firing its text-changed callback, so rendering state + // back into the form is not mistaken for a user edit (which would mark the form dirty). + private lateinit var setUidText: (CharSequence?) -> Unit + private lateinit var setPidText: (CharSequence?) -> Unit + private lateinit var setTidText: (CharSequence?) -> Unit + private lateinit var setPackageNameText: (CharSequence?) -> Unit + private lateinit var setTagText: (CharSequence?) -> Unit + private lateinit var setContentText: (CharSequence?) -> Unit + override fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?) = FragmentEditFilterBinding.inflate(inflater, container, false) override fun FragmentEditFilterBinding.onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -116,14 +125,14 @@ internal class EditFilterFragment : send(EditFilterCommand.Save) } - uidText.doAfterTextChanged(this@EditFilterFragment) { send(EditFilterCommand.UpdateUid(it?.toString().orEmpty())) } - pidText.doAfterTextChanged(this@EditFilterFragment) { send(EditFilterCommand.UpdatePid(it?.toString().orEmpty())) } - tidText.doAfterTextChanged(this@EditFilterFragment) { send(EditFilterCommand.UpdateTid(it?.toString().orEmpty())) } - packageNameText.doAfterTextChanged(this@EditFilterFragment) { + setUidText = uidText.doAfterTextChanged(this@EditFilterFragment) { send(EditFilterCommand.UpdateUid(it?.toString().orEmpty())) } + setPidText = pidText.doAfterTextChanged(this@EditFilterFragment) { send(EditFilterCommand.UpdatePid(it?.toString().orEmpty())) } + setTidText = tidText.doAfterTextChanged(this@EditFilterFragment) { send(EditFilterCommand.UpdateTid(it?.toString().orEmpty())) } + setPackageNameText = packageNameText.doAfterTextChanged(this@EditFilterFragment) { send(EditFilterCommand.UpdatePackageName(it?.toString().orEmpty())) } - tagText.doAfterTextChanged(this@EditFilterFragment) { send(EditFilterCommand.UpdateTag(it?.toString().orEmpty())) } - contentText.doAfterTextChanged(this@EditFilterFragment) { + setTagText = tagText.doAfterTextChanged(this@EditFilterFragment) { send(EditFilterCommand.UpdateTag(it?.toString().orEmpty())) } + setContentText = contentText.doAfterTextChanged(this@EditFilterFragment) { send(EditFilterCommand.UpdateContent(it?.toString().orEmpty())) } } @@ -136,12 +145,12 @@ internal class EditFilterFragment : updateEnabledButton(state.enabled) updateTitle(state.name) - setTextIfDifferent(uidText, state.uid.orEmpty()) - setTextIfDifferent(pidText, state.pid.orEmpty()) - setTextIfDifferent(tidText, state.tid.orEmpty()) - setTextIfDifferent(packageNameText, state.packageName.orEmpty()) - setTextIfDifferent(tagText, state.tag.orEmpty()) - setTextIfDifferent(contentText, state.content.orEmpty()) + setTextIfDifferent(uidText, state.uid.orEmpty(), setUidText) + setTextIfDifferent(pidText, state.pid.orEmpty(), setPidText) + setTextIfDifferent(tidText, state.tid.orEmpty(), setTidText) + setTextIfDifferent(packageNameText, state.packageName.orEmpty(), setPackageNameText) + setTextIfDifferent(tagText, state.tag.orEmpty(), setTagText) + setTextIfDifferent(contentText, state.content.orEmpty(), setContentText) toolbar.menu.findItem(R.id.export_item).isVisible = state.filter != null } @@ -250,9 +259,15 @@ internal class EditFilterFragment : .show() } - private fun setTextIfDifferent(textView: android.widget.EditText, text: String) { + // Updates the field only when the value actually changed, via the watcher-suppressing setter so a + // render doesn't register as a user edit. The guard also avoids needlessly moving the cursor. + private fun setTextIfDifferent( + textView: android.widget.EditText, + text: String, + setText: (CharSequence?) -> Unit, + ) { if (textView.text.toString() != text) { - textView.setText(text) + setText(text) } } }