diff --git a/CHANGELOG.md b/CHANGELOG.md index c202b8417..6896ac7e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- Fixed the checklist dialog disappearing on screen rotation - Fixed inconsistent checklist sorting when the "Move checked items to the bottom" option is enabled ([#59]) ## [1.6.0] - 2025-10-29 diff --git a/app/src/main/kotlin/org/fossify/notes/dialogs/ChecklistItemDialogFragment.kt b/app/src/main/kotlin/org/fossify/notes/dialogs/ChecklistItemDialogFragment.kt new file mode 100644 index 000000000..b3edc00b3 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/notes/dialogs/ChecklistItemDialogFragment.kt @@ -0,0 +1,147 @@ +package org.fossify.notes.dialogs + +import android.app.Dialog +import android.os.Bundle +import android.view.View +import android.view.WindowManager +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.AppCompatEditText +import androidx.fragment.app.DialogFragment +import org.fossify.notes.R +import org.fossify.notes.databinding.DialogNewChecklistItemBinding +import org.fossify.notes.databinding.ItemAddChecklistBinding +import org.fossify.commons.extensions.getAlertDialogBuilder +import org.fossify.commons.extensions.getContrastColor +import org.fossify.commons.extensions.getProperPrimaryColor +import org.fossify.commons.extensions.showKeyboard +import org.fossify.commons.R as CommonsR + +class ChecklistItemDialogFragment : DialogFragment() { + + private val activeInputFields = mutableListOf() + private var binding: DialogNewChecklistItemBinding? = null + + companion object { + const val DIALOG_TAG = "ChecklistItemDialogFragment" + const val REQUEST_KEY = "ChecklistItemRequest" + const val RESULT_TEXT_KEY = "ResultText" + const val RESULT_TASK_ID_KEY = "ResultTaskId" + + private const val ARG_TEXT = "ArgText" + private const val ARG_TASK_ID = "ArgTaskId" + private const val SAVED_STATE_TEXTS = "SavedStateTexts" + + fun newInstance(taskId: Int = -1, text: String = ""): ChecklistItemDialogFragment { + val fragment = ChecklistItemDialogFragment() + val args = Bundle() + args.putInt(ARG_TASK_ID, taskId) + args.putString(ARG_TEXT, text) + fragment.arguments = args + return fragment + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val activity = requireActivity() + val taskId = arguments?.getInt(ARG_TASK_ID) ?: -1 + + binding = DialogNewChecklistItemBinding.inflate(activity.layoutInflater) + + activeInputFields.clear() + + // Restore rows + if (savedInstanceState != null) { + val savedTexts = savedInstanceState.getStringArrayList(SAVED_STATE_TEXTS) + if (!savedTexts.isNullOrEmpty()) { + savedTexts.forEach { text -> addNewRow(text) } + } else { + addNewRow("") + } + } else { + val initialText = arguments?.getString(ARG_TEXT) ?: "" + addNewRow(initialText) + } + + val isNewTaskMode = (taskId == -1) + if (isNewTaskMode) { + val contrastColor = activity.getProperPrimaryColor().getContrastColor() + binding!!.addItem.setColorFilter(contrastColor) + + binding!!.addItem.setOnClickListener { + addNewRow("") + } + } else { + binding!!.addItem.visibility = View.GONE + binding!!.settingsAddChecklistTop.visibility = View.GONE + } + + val titleRes = if (isNewTaskMode) R.string.add_new_checklist_items else R.string.rename_note + + val builder = activity.getAlertDialogBuilder() + .setTitle(titleRes) + .setView(binding!!.root) + .setPositiveButton(CommonsR.string.ok, null) + .setNegativeButton(CommonsR.string.cancel, null) + + val dialog = builder.create() + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE) + + dialog.setOnShowListener { + if (activeInputFields.isNotEmpty()) { + dialog.showKeyboard(activeInputFields.last()) + } + + val positiveButton = (dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE) + positiveButton.setOnClickListener { + val combinedText = activeInputFields + .map { it.text.toString().trim() } + .filter { it.isNotEmpty() } + .joinToString("\n") + + if (combinedText.isNotEmpty()) { + val resultBundle = Bundle().apply { + putString(RESULT_TEXT_KEY, combinedText) + putInt(RESULT_TASK_ID_KEY, taskId) + } + parentFragmentManager.setFragmentResult(REQUEST_KEY, resultBundle) + dialog.dismiss() + } else { + dialog.dismiss() + } + } + } + + return dialog + } + + private fun addNewRow(text: String) { + val rowBinding = ItemAddChecklistBinding.inflate(layoutInflater) + + // We disable automatic state saving for this view. + // This prevents Android from confusing the multiple EditTexts (which all share the same ID) + // and overwriting our manually restored text with the last view's text. + rowBinding.titleEditText.isSaveEnabled = false + + rowBinding.titleEditText.setText(text) + + if (text.isNotEmpty()) { + rowBinding.titleEditText.setSelection(text.length) + } + + val inputField = rowBinding.titleEditText as AppCompatEditText + activeInputFields.add(inputField) + + binding?.checklistHolder?.addView(rowBinding.root) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + val currentTexts = ArrayList(activeInputFields.map { it.text.toString() }) + outState.putStringArrayList(SAVED_STATE_TEXTS, currentTexts) + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } +} diff --git a/app/src/main/kotlin/org/fossify/notes/dialogs/EditTaskDialogFragment.kt b/app/src/main/kotlin/org/fossify/notes/dialogs/EditTaskDialogFragment.kt new file mode 100644 index 000000000..329fc22c1 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/notes/dialogs/EditTaskDialogFragment.kt @@ -0,0 +1,78 @@ +package org.fossify.notes.dialogs + +import android.content.DialogInterface +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.fossify.commons.extensions.getAlertDialogBuilder +import org.fossify.commons.extensions.setupDialogStuff +import org.fossify.commons.extensions.showKeyboard +import org.fossify.commons.extensions.toast +import org.fossify.notes.databinding.DialogRenameChecklistItemBinding +import org.fossify.notes.extensions.maybeRequestIncognito +import org.fossify.notes.models.Task + +class EditTaskDialogFragment : DialogFragment() { + + companion object { + const val TAG = "EditTaskDialog" + const val ARG_TASK_ID = "arg_task_id" + const val ARG_OLD_TITLE = "arg_old_title" + const val REQUEST_KEY = "edit_task_request" + const val RESULT_TITLE = "result_title" + const val RESULT_TASK_ID = "result_task_id" + private const val STATE_TEXT = "state_text" + + fun show( + host: androidx.fragment.app.FragmentManager, + task: Task + ) = EditTaskDialogFragment().apply { + arguments = bundleOf(ARG_OLD_TITLE to task.title, ARG_TASK_ID to task.id) + }.show(host, TAG) + } + + private lateinit var binding: DialogRenameChecklistItemBinding + + override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { + val activity = requireActivity() + binding = DialogRenameChecklistItemBinding.inflate(activity.layoutInflater).also { + val restored = savedInstanceState?.getString(STATE_TEXT) + it.checklistItemTitle.setText( + restored ?: requireArguments().getString(ARG_OLD_TITLE).orEmpty() + ) + it.checklistItemTitle.maybeRequestIncognito() + } + + val builder = activity.getAlertDialogBuilder() + .setPositiveButton(org.fossify.commons.R.string.ok, null) + .setNegativeButton(org.fossify.commons.R.string.cancel, null) + + var dialog: AlertDialog? = null + activity.setupDialogStuff(binding.root, builder) { alert -> + alert.showKeyboard(binding.checklistItemTitle) + alert.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener { + val newTitle = binding.checklistItemTitle.text?.toString().orEmpty() + if (newTitle.isEmpty()) { + activity.toast(org.fossify.commons.R.string.empty_name) + } else { + val taskId = requireArguments().getInt(ARG_TASK_ID) + parentFragmentManager + .setFragmentResult( + REQUEST_KEY, bundleOf(RESULT_TASK_ID to taskId, RESULT_TITLE to newTitle) + ) + alert.dismiss() + } + } + dialog = alert + } + + return dialog!! + } + + override fun onSaveInstanceState(outState: Bundle) { + val text = binding.checklistItemTitle.text?.toString().orEmpty() + outState.putString(STATE_TEXT, text) + super.onSaveInstanceState(outState) + } +} diff --git a/app/src/main/kotlin/org/fossify/notes/dialogs/NewChecklistItemDialogFragment.kt b/app/src/main/kotlin/org/fossify/notes/dialogs/NewChecklistItemDialogFragment.kt new file mode 100644 index 000000000..781d261f7 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/notes/dialogs/NewChecklistItemDialogFragment.kt @@ -0,0 +1,211 @@ +package org.fossify.notes.dialogs + +import android.content.DialogInterface +import android.os.Bundle +import android.view.KeyEvent +import android.view.View +import android.view.inputmethod.EditorInfo +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.AppCompatEditText +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.fossify.commons.extensions.beVisibleIf +import org.fossify.commons.extensions.getAlertDialogBuilder +import org.fossify.commons.extensions.getContrastColor +import org.fossify.commons.extensions.getProperPrimaryColor +import org.fossify.commons.extensions.setupDialogStuff +import org.fossify.commons.extensions.showKeyboard +import org.fossify.commons.extensions.toast +import org.fossify.commons.helpers.SORT_BY_CUSTOM +import org.fossify.notes.R +import org.fossify.notes.databinding.DialogNewChecklistItemBinding +import org.fossify.notes.databinding.ItemAddChecklistBinding +import org.fossify.notes.extensions.config +import org.fossify.notes.extensions.maybeRequestIncognito + +class NewChecklistItemDialogFragment : DialogFragment() { + + private val activeInputFields = mutableListOf() + private var binding: DialogNewChecklistItemBinding? = null + + // Track the index of the currently focused row + private var lastFocusedIndex = -1 + + companion object { + const val TAG = "NewChecklistItemDialogFragment" + const val REQUEST_KEY = "new_checklist_item_request" + const val RESULT_TEXT = "result_text" + const val RESULT_ADD_TOP = "result_add_top" + + private const val ARG_NOTE_ID = "arg_note_id" + private const val STATE_TEXTS = "state_texts" + + private const val STATE_FOCUSED_INDEX = "state_focused_index" + + + fun show( + host: androidx.fragment.app.FragmentManager, + noteId: Long + ) = NewChecklistItemDialogFragment().apply { + arguments = bundleOf(ARG_NOTE_ID to noteId) + }.show(host, TAG) + } + + + override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { + val activity = requireActivity() + binding = DialogNewChecklistItemBinding.inflate(activity.layoutInflater) + activeInputFields.clear() + + // Restore state or add initial row + if (savedInstanceState != null) { + val savedTexts = savedInstanceState.getStringArrayList(STATE_TEXTS) + if (!savedTexts.isNullOrEmpty()) { + savedTexts.forEach { text -> addNewRow(text) } + } else { + addNewRow("") + } + // Restore the focus index + lastFocusedIndex = savedInstanceState.getInt(STATE_FOCUSED_INDEX, -1) + } else { + addNewRow("") + } + + // Setup UI + val noteId = requireArguments().getLong(ARG_NOTE_ID) + val contrastColor = activity.getProperPrimaryColor().getContrastColor() + binding!!.addItem.setColorFilter(contrastColor) + + // Insert after the currently focused row + binding!!.addItem.setOnClickListener { + val insertIndex = if (lastFocusedIndex != -1 && lastFocusedIndex < activeInputFields.size) { + lastFocusedIndex + 1 + } else { + null // Append to end if nothing is focused + } + addNewRow("", focus = true, position = insertIndex) + } + + val config = activity.config + binding!!.settingsAddChecklistTop.beVisibleIf(config.getSorting(noteId) == SORT_BY_CUSTOM) + binding!!.settingsAddChecklistTop.isChecked = config.addNewChecklistItemsTop + + val builder = activity.getAlertDialogBuilder() + .setTitle(R.string.add_new_checklist_items) + .setPositiveButton(org.fossify.commons.R.string.ok, null) + .setNegativeButton(org.fossify.commons.R.string.cancel, null) + + var dialog: AlertDialog? = null + activity.setupDialogStuff(binding!!.root, builder) { alert -> + + // Apply Focus : if we have a valid restored index, use it + if (lastFocusedIndex != -1 && lastFocusedIndex < activeInputFields.size) { + alert.showKeyboard(activeInputFields[lastFocusedIndex]) + } else if (activeInputFields.isNotEmpty()) { + // Default to the last + alert.showKeyboard(activeInputFields.last()) + } + + alert.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener { + // Collect all texts + val combinedText = activeInputFields + .map { it.text.toString().trim() } + .filter { it.isNotEmpty() } + .joinToString("\n") + + if (combinedText.isEmpty()) { + activity.toast(org.fossify.commons.R.string.empty_name) + } else { + config.addNewChecklistItemsTop = binding!!.settingsAddChecklistTop.isChecked + + // Return result + parentFragmentManager.setFragmentResult( + REQUEST_KEY, + bundleOf( + RESULT_TEXT to combinedText, + RESULT_ADD_TOP to binding!!.settingsAddChecklistTop.isChecked + ) + ) + alert.dismiss() + } + } + dialog = alert + } + + return dialog!! + } + + private fun addNewRow(text: String, focus: Boolean = false, position: Int? = null) { + val rowBinding = ItemAddChecklistBinding.inflate(layoutInflater) + + // Disable state saving for individual views to avoid rotation conflict + rowBinding.titleEditText.isSaveEnabled = false + rowBinding.titleEditText.setText(text) + rowBinding.titleEditText.maybeRequestIncognito() + + if (text.isNotEmpty()) { + rowBinding.titleEditText.setSelection(text.length) + } + + // Track focus changes in real time + rowBinding.titleEditText.setOnFocusChangeListener { view, hasFocus -> + if (hasFocus) { + // When this view gets focus, remember its index + lastFocusedIndex = activeInputFields.indexOf(view) + } + } + + // Add "Enter" key listener to create new rows automatically + rowBinding.titleEditText.setOnEditorActionListener { v, actionId, event -> + if (actionId == EditorInfo.IME_ACTION_NEXT || + actionId == EditorInfo.IME_ACTION_DONE || + event?.keyCode == KeyEvent.KEYCODE_ENTER) { + + val currentIndex = activeInputFields.indexOf(v) + addNewRow("", focus = true, position = currentIndex + 1) + true + } else { + false + } + } + + val inputField = rowBinding.titleEditText as AppCompatEditText + + // Insert into list and view hierarchy at correct position + if (position != null && position < activeInputFields.size) { + activeInputFields.add(position, inputField) + binding?.checklistHolder?.addView(rowBinding.root, position) + } else { + activeInputFields.add(inputField) + binding?.checklistHolder?.addView(rowBinding.root) + } + + + if (focus) { + binding?.dialogHolder?.post { + // Only scroll to bottom if appending to the end + if (position == null) { + binding?.dialogHolder?.fullScroll(View.FOCUS_DOWN) + } + + inputField.requestFocus() + requireActivity().showKeyboard(inputField) + } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + val currentTexts = ArrayList(activeInputFields.map { it.text.toString() }) + outState.putStringArrayList(STATE_TEXTS, currentTexts) + + // Save the index tracked via the listener + outState.putInt(STATE_FOCUSED_INDEX, lastFocusedIndex) + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + activeInputFields.clear() + } +} diff --git a/app/src/main/kotlin/org/fossify/notes/fragments/TasksFragment.kt b/app/src/main/kotlin/org/fossify/notes/fragments/TasksFragment.kt index f444fd2ba..58d5bb9a8 100644 --- a/app/src/main/kotlin/org/fossify/notes/fragments/TasksFragment.kt +++ b/app/src/main/kotlin/org/fossify/notes/fragments/TasksFragment.kt @@ -14,8 +14,9 @@ import org.fossify.commons.helpers.ensureBackgroundThread import org.fossify.notes.activities.SimpleActivity import org.fossify.notes.adapters.TasksAdapter import org.fossify.notes.databinding.FragmentChecklistBinding -import org.fossify.notes.dialogs.EditTaskDialog -import org.fossify.notes.dialogs.NewChecklistItemDialog +import org.fossify.notes.dialogs.ChecklistItemDialogFragment +import org.fossify.notes.dialogs.EditTaskDialogFragment +import org.fossify.notes.dialogs.NewChecklistItemDialogFragment import org.fossify.notes.extensions.config import org.fossify.notes.extensions.updateWidgets import org.fossify.notes.helpers.NOTE_ID @@ -36,6 +37,9 @@ class TasksFragment : NoteFragment(), TasksActionListener { var tasks = mutableListOf() + // Variable to track the callback function + private var editTaskCallback: (() -> Unit)? = null + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FragmentChecklistBinding.inflate(inflater, container, false) noteId = requireArguments().getLong(NOTE_ID, 0L) @@ -43,6 +47,34 @@ class TasksFragment : NoteFragment(), TasksActionListener { return binding.root } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Listen for results from the NewChecklistItemDialogFragment + childFragmentManager.setFragmentResultListener(NewChecklistItemDialogFragment.REQUEST_KEY, + viewLifecycleOwner) + { _, bundle -> + val text = bundle.getString(NewChecklistItemDialogFragment.RESULT_TEXT) ?: return@setFragmentResultListener + addNewChecklistItems(text) + } + + // Listen for EditTaskDialogFragment + childFragmentManager.setFragmentResultListener(EditTaskDialogFragment.REQUEST_KEY, + viewLifecycleOwner) + { _, bundle -> + val taskId = bundle.getInt(EditTaskDialogFragment.RESULT_TASK_ID) + val newTitle = bundle.getString(EditTaskDialogFragment.RESULT_TITLE) + + if (newTitle != null) { + updateExistingTask(taskId, newTitle) + + // Invoke the callback + editTaskCallback?.invoke() + editTaskCallback = null + } + } + } + override fun onResume() { super.onResume() loadNoteById(noteId) @@ -137,30 +169,28 @@ class TasksFragment : NoteFragment(), TasksActionListener { setupLockedViews(this.toCommonBinding(), note!!) } } - private fun showNewItemDialog() { - NewChecklistItemDialog(activity as SimpleActivity, noteId) { titles -> - var currentMaxId = tasks.maxByOrNull { item -> item.id }?.id ?: 0 - val newItems = ArrayList() - - titles.forEach { title -> - title.split("\n").map { it.trim() }.filter { it.isNotBlank() }.forEach { row -> - newItems.add(Task(currentMaxId + 1, System.currentTimeMillis(), row, false)) - currentMaxId++ - } - } + NewChecklistItemDialogFragment.show(childFragmentManager, noteId) + } - if (config?.addNewChecklistItemsTop == true) { - tasks.addAll(0, newItems) - } else { - tasks.addAll(newItems) - } + private fun addNewChecklistItems(text: String) { + var currentMaxId = tasks.maxByOrNull { item -> item.id }?.id ?: 0 + val newItems = ArrayList() - saveNote() - setupAdapter() + text.split("\n").map { it.trim() }.filter { it.isNotBlank() }.forEach { row -> + newItems.add(Task(currentMaxId + 1, System.currentTimeMillis(), row, false)) + currentMaxId++ + } + + if (config?.addNewChecklistItemsTop == true) { + tasks.addAll(0, newItems) + } else { + tasks.addAll(newItems) } - } + saveNote() + setupAdapter() + } private fun prepareTaskItems(): List { return if (config?.moveDoneChecklistItems == true) { mutableListOf().apply { @@ -272,12 +302,18 @@ class TasksFragment : NoteFragment(), TasksActionListener { fun getTasks() = Gson().toJson(tasks) override fun editTask(task: Task, callback: () -> Unit) { - EditTaskDialog(activity as SimpleActivity, task.title) { title -> - val editedTask = task.copy(title = title) - val index = tasks.indexOf(task) - tasks[index] = editedTask + // Save the callback to be used when the result arrives + this.editTaskCallback = callback + EditTaskDialogFragment.show(childFragmentManager, task) + } + + private fun updateExistingTask(taskId: Int, newTitle: String) { + val taskIndex = tasks.indexOfFirst { it.id == taskId } + if (taskIndex != -1) { + val task = tasks[taskIndex] + val editedTask = task.copy(title = newTitle) + tasks[taskIndex] = editedTask saveAndReload() - callback() } }