Skip to content

Add manual sort for notes via drag and drop#905

Open
Crustack wants to merge 2 commits intomainfrom
feat/130
Open

Add manual sort for notes via drag and drop#905
Crustack wants to merge 2 commits intomainfrom
feat/130

Conversation

@Crustack
Copy link
Owner

@Crustack Crustack commented Mar 8, 2026

Closes #130

notallyx_issues_130_rc1.webm

Summary by CodeRabbit

  • New Features

    • Added manual note sorting with drag-and-drop reordering support.
    • Introduced sort preference menu enabling users to customize note ordering by multiple criteria.
    • Notes can now be moved up or down within custom sort order.
  • Documentation

    • Updated translation coverage table with expanded entry counts.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 8, 2026

📝 Walkthrough

Walkthrough

This pull request implements manual note sorting by introducing a database sortIdx field, drag-and-drop reordering via ItemTouchHelper, a sort mode selection dialog, and corresponding UI/ViewModel infrastructure to manage sort indices across the application stack.

Changes

Cohort / File(s) Summary
Database Layer
app/schemas/com.philkes.notallyx.data.NotallyDatabase/10.json, app/src/main/java/com/philkes/notallyx/data/NotallyDatabase.kt, app/src/main/java/com/philkes/notallyx/data/model/BaseNote.kt
New database schema v10 with sortIdx column, Migration10 from v9→v10 adding the column and index, and updated BaseNote entity including sortIdx field with equals/hashCode support and secondary index.
Data Access
app/src/main/java/com/philkes/notallyx/data/dao/BaseNoteDao.kt, app/src/main/java/com/philkes/notallyx/data/model/ModelExtensions.kt, app/src/main/java/com/philkes/notallyx/utils/backup/ImportExtensions.kt
New DAO methods (updateSortIdx, updateSortIndices, getMaxSortIdx) for sort index persistence, JSON serialization/deserialization support for sortIdx, and backup/import handling.
ViewModel & Preferences
app/src/main/java/com/philkes/notallyx/presentation/viewmodel/BaseNoteModel.kt, app/src/main/java/com/philkes/notallyx/presentation/viewmodel/NotallyModel.kt, app/src/main/java/com/philkes/notallyx/presentation/viewmodel/preference/NotesSorting.kt
New public ViewModel methods for sort index operations, updated NotesSorting enum with MANUAL option, and extension properties for manual sort detection.
Sorting Implementation
app/src/main/java/com/philkes/notallyx/presentation/view/main/sorting/BaseNoteManualSort.kt, app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteAdapter.kt
New BaseNoteManualSort comparator for manual sorting, ItemListContainer abstraction supporting both sorted and manual modes, and methods for in-place reordering (onItemMove, moveSelectedNotesUp/Down).
UI & Action Mode
app/src/main/java/com/philkes/notallyx/presentation/activity/main/ActionModeBinding.kt, app/src/main/java/com/philkes/notallyx/presentation/activity/main/MainActivity.kt, app/src/main/java/com/philkes/notallyx/presentation/utils/UiExtensions.kt
New ActionModeBinding class centralizing action mode logic, refactored MainActivity integrating ActionModeBinding with new moveNotes extension, Menu.add default order parameter change to Menu.FIRST.
Fragment & Gestures
app/src/main/java/com/philkes/notallyx/presentation/activity/main/fragment/NotallyFragment.kt, app/src/main/java/com/philkes/notallyx/presentation/activity/main/fragment/settings/PreferenceBindingExtensions.kt, app/src/main/java/com/philkes/notallyx/presentation/activity/main/fragment/settings/SettingsFragment.kt
Drag-and-drop reordering via ItemTouchHelper with updateDragAndDrop flow, public methods for moving selected notes up/down, dynamic sort direction visibility, and removed legacy sort preference observer.
Resources & Configuration
app/src/main/res/.../*, app/src/main/res/drawable/sort.xml, app/src/main/res/layout/dialog_notes_sort.xml, app/src/main/res/layout/activity_main.xml, app/src/main/res/values/strings.xml, app/src/main/res/values/themes.xml
New sort icon drawable, dialog layout for sort mode selection, added NotesSortDirectionRadioGroupLabel view ID, new string resources (manual, manual_sort_dialog_message), FAB backgroundTint styling adjustments.
Other
TRANSLATIONS.md, app/src/main/java/com/philkes/notallyx/utils/ActionMode.kt, app/src/main/java/com/philkes/notallyx/presentation/activity/note/RecordAudioActivity.kt
Translation table updated from 337→339 entries with recalculated percentages, ActionMode.clear() method added, RecordAudioActivity UI update refactored to use binding.apply.

Sequence Diagram

sequenceDiagram
    participant User
    participant Fragment as NotallyFragment
    participant Adapter as BaseNoteAdapter
    participant ViewModel as BaseNoteModel
    participant DAO as BaseNoteDao
    participant DB as Database

    User->>Fragment: Drag note to new position
    Fragment->>Adapter: onItemMove(fromPos, toPos)
    Adapter->>Adapter: Swap items in MutableListContainer
    Adapter->>Adapter: updateSortIndices()
    
    Adapter->>ViewModel: updateNotesSortIndices(idToIdx)
    ViewModel->>DAO: updateSortIndices(idToIdx)
    DAO->>DB: INSERT/UPDATE sortIdx values
    DB-->>DAO: Confirm
    DAO-->>ViewModel: Complete
    ViewModel-->>Adapter: Coroutine completes
    
    Adapter->>Fragment: notifyItemMoved()
    Fragment->>User: Display reordered note
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly Related PRs

  • #776: Updates translation coverage totals in TRANSLATIONS.md, sharing the same documentation file modifications.
  • #846: Database schema and DAO layer changes (persistence, migrations) related to BaseNote model enhancements and query modifications.

Poem

🐰 Hops of joy, I declare,
Notes now dance without a care!
Drag and drop with sortIdx delight,
Manual order feels just right! 🎵
The database sorted, the UI's a dream,
A sorting feature supreme!

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning Minor out-of-scope changes detected: Menu.add order parameter default changed from Menu.NONE to Menu.FIRST, and RadioGroup.checkedTag() null-safety assumption tightened without clear connection to manual sorting. Revert unrelated changes to Menu.add default parameter and RadioGroup.checkedTag() to keep PR focused on manual sorting feature as specified in issue #130.
Docstring Coverage ⚠️ Warning Docstring coverage is 2.31% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Add manual sort for notes via drag and drop' accurately and concisely summarizes the primary change: introducing manual note sorting via drag-and-drop functionality.
Linked Issues check ✅ Passed The PR successfully implements all coding requirements from issue #130: adds manual sorting mode (NotesSortBy.MANUAL), enables drag-and-drop reordering (ItemTouchHelper in NotallyFragment), stores sortIdx in database (schema v10), and provides UI controls.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/130

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
app/src/main/res/layout/dialog_notes_sort.xml (1)

19-28: ⚠️ Potential issue | 🟡 Minor

Label visibility not toggled alongside the RadioGroup.

The NotesSortDirectionRadioGroupLabel TextView is now exposed with an ID, but in PreferenceBindingExtensions.kt (lines 162-167), only NotesSortDirectionRadioGroup visibility is toggled when manual sort is selected. The label ("Sort Direction") would remain visible even when the direction picker is hidden, creating a confusing UI state.

Consider also toggling the label's visibility:

🔧 Suggested fix in PreferenceBindingExtensions.kt
         layout.NotesSortDirectionRadioGroup.isVisible =
             (layout.NotesSortByRadioGroup.checkedTag() as? NotesSortBy)?.isManualSort?.not() ?: true
+        layout.NotesSortDirectionRadioGroupLabel.isVisible =
+            layout.NotesSortDirectionRadioGroup.isVisible
         layout.NotesSortByRadioGroup.setOnCheckedChangeListener { group, checkedId ->
             val selectedSortBy = group.checkedTag() as? NotesSortBy
             layout.NotesSortDirectionRadioGroup.isVisible =
                 selectedSortBy?.isManualSort?.not() ?: true
+            layout.NotesSortDirectionRadioGroupLabel.isVisible =
+                layout.NotesSortDirectionRadioGroup.isVisible
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/res/layout/dialog_notes_sort.xml` around lines 19 - 28, The
TextView with id NotesSortDirectionRadioGroupLabel is not being hidden when the
NotesSortDirectionRadioGroup is toggled in the binding code; update the toggle
in the PreferenceBindingExtensions code that currently sets visibility for
NotesSortDirectionRadioGroup (the same block around where manual sort is
handled) to also set the visibility of NotesSortDirectionRadioGroupLabel to the
same value (use the same isVisible/visibility logic or helper used for the
RadioGroup so the label is hidden/shown in sync). Ensure you reference the
existing NotesSortDirectionRadioGroupLabel and NotesSortDirectionRadioGroup view
IDs and apply identical visibility changes in that handler.
app/src/main/java/com/philkes/notallyx/data/dao/BaseNoteDao.kt (1)

82-109: ⚠️ Potential issue | 🔴 Critical

Bulk insertSafe() drops the sanitized notes.

Line 90 produces a possibly truncated note, but Line 109 still inserts baseNotes. Oversized imports/batch saves will therefore be reported as truncated without actually persisting the truncated copies. Build the insert list from note and pass that list to insert(...).

Proposed fix
     suspend fun insertSafe(context: ContextWrapper, baseNotes: List<BaseNote>): List<Long> {
         val maxSortIdx = getMaxSortIdx() ?: 0
+        val notesToInsert = mutableListOf<BaseNote>()
         val truncatedNotes = mutableListOf<BaseNote>()
         var truncatedCharacterSize = 0
         baseNotes.forEachIndexed { index, baseNote ->
             if (baseNote.sortIdx == null) {
                 baseNote.sortIdx = (maxSortIdx + 1 + index)
             }
             val (truncated, note) = baseNote.truncated()
+            notesToInsert.add(note)
             if (truncated) {
                 truncatedCharacterSize += baseNote.body.length
                 truncatedNotes.add(note)
             }
         }
         if (truncatedNotes.isNotEmpty()) {
             context.log(
                 TAG,
                 "${truncatedNotes.size} Notes are too big to save, they were truncated to $truncatedCharacterSize characters",
             )
             context.showToast(
                 context.getQuantityString(
                     R.plurals.notes_too_big_truncating,
                     truncatedNotes.size,
                     truncatedCharacterSize,
                 )
             )
         }
-        return insert(baseNotes)
+        return insert(notesToInsert)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/philkes/notallyx/data/dao/BaseNoteDao.kt` around lines
82 - 109, The insertSafe function currently detects oversized notes via
BaseNote.truncated() and accumulates truncated versions in truncatedNotes but
still calls insert(baseNotes), so the sanitized/truncated note copies are never
persisted; change insertSafe to build a new list (e.g., insertList) where for
each baseNote you append the truncated note returned by baseNote.truncated()
when truncated (or the original/baseNote when not truncated), use that list for
the final insert(...) call instead of baseNotes, and keep the existing
logging/showToast behavior using truncatedNotes/truncatedCharacterSize; update
references to truncated, note, truncatedNotes, and the insert(...) call
accordingly.
app/src/main/java/com/philkes/notallyx/data/model/BaseNote.kt (1)

43-88: ⚠️ Potential issue | 🟠 Major

Keep sortIdx out of BaseNote content equality.

app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteAdapter.kt Lines 104-109 use BaseNote.equals() for areContentsTheSame(), and Lines 347-351 rewrite every note’s sortIdx during drag reorder. With Line 65 now participating in equality, each move is treated as a content update as well, which forces rebinds and fights the move animations this feature depends on. Keep sortIdx out of equals()/hashCode(), or make the adapter’s content comparison ignore it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/philkes/notallyx/data/model/BaseNote.kt` around lines
43 - 88, BaseNote currently includes sortIdx in content equality which causes
drag-reorder to be treated as a content change; update BaseNote.equals(other:
Any?) and BaseNote.hashCode() to ignore sortIdx (remove the if (sortIdx !=
other.sortIdx) check from equals and stop adding sortIdx into the hash
calculation), or alternatively make BaseNoteAdapter.areContentsTheSame(...)
explicitly ignore sortIdx; prefer removing sortIdx from BaseNote.equals/hashCode
by deleting its comparison and its contribution to the hash so reorder updates
no longer trigger content rebinds.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@app/src/main/java/com/philkes/notallyx/presentation/activity/main/ActionModeBinding.kt`:
- Around line 45-50: The OnBackPressedCallback instance actionModeCancelCallback
is never added to the back-press dispatcher so toggling its isEnabled is
ineffective; either remove the unused actionModeCancelCallback and its isEnabled
toggles, or register it with the dispatcher. To fix by registering, call
context.onBackPressedDispatcher.addCallback(lifecycleOwner,
actionModeCancelCallback) (or use the appropriate activity/fragment
onBackPressedDispatcher) when the binding is created so the callback will be
invoked, and keep using isEnabled to toggle it; if you choose removal, delete
actionModeCancelCallback and any code that sets its isEnabled (e.g., where you
enable/disable it) and ensure baseModel.actionMode.close(true) is invoked via
another registered handler.

In
`@app/src/main/java/com/philkes/notallyx/presentation/activity/main/fragment/NotallyFragment.kt`:
- Around line 263-287: The moveSelectedNotesUp/moveSelectedNotesDown handlers
(and any code that calls updateDatabaseSortIndices/updateNotesSortIndices) are
persisting order using notesAdapter.currentList which may be filtered; instead
either skip persisting when the list is filtered (only allow manual reorder on
full folder view) or build the new sort-index map against the unfiltered/full
dataset before calling updateNotesSortIndices/updateDatabaseSortIndices.
Concretely: detect a filtered state (e.g., compare notesAdapter.currentList size
vs the full notes list or use a filter flag) inside
moveSelectedNotesUp/moveSelectedNotesDown and do not call the DB update if
filtered, or construct an ID->newIndex map by iterating the full notes
collection and applying the new positions from the visible list while preserving
relative positions of hidden items, then pass that map into
updateNotesSortIndices/updateDatabaseSortIndices so hidden notes’ sortIdx remain
consistent.

In
`@app/src/main/java/com/philkes/notallyx/presentation/activity/main/fragment/settings/PreferenceBindingExtensions.kt`:
- Around line 162-168: The visibility toggle only updates
NotesSortDirectionRadioGroup but not its label; update the initial visibility
and the listener to also set layout.NotesSortDirectionRadioGroupLabel.isVisible
to the same boolean you compute (use the same expression as for
NotesSortDirectionRadioGroup), i.e. read selected sort via
layout.NotesSortByRadioGroup.checkedTag() as? NotesSortBy and set both
layout.NotesSortDirectionRadioGroup.isVisible and
layout.NotesSortDirectionRadioGroupLabel.isVisible accordingly in the initial
assignment and inside the setOnCheckedChangeListener callback so the label
hides/shows in sync with the direction radio group.

In
`@app/src/main/java/com/philkes/notallyx/presentation/activity/main/MainActivity.kt`:
- Around line 613-618: The currentFragment() helper incorrectly uses
childFragmentManager.fragments.firstOrNull(), which can return stale fragments;
update it to resolve the displayed destination via the NavHost's
primaryNavigationFragment instead: locate the call in currentFragment() (and any
code paths like prepareNewNoteIntent() or manual-sort actions that rely on it),
replace the childFragmentManager.fragments.firstOrNull() lookup with
childFragmentManager.primaryNavigationFragment, and cast that result to
NotallyFragment (as? NotallyFragment) so the app always targets the actual
current fragment shown by the Navigation component.

In
`@app/src/main/java/com/philkes/notallyx/presentation/activity/note/RecordAudioActivity.kt`:
- Around line 151-166: The READY branch currently only updates buttons and can
leave Timer running from a prior RECORDING state; update the Status.READY
handling to explicitly stop and reset the chronometer (use the same Timer
control used elsewhere, e.g., call Timer.stop() and reset its base/elapsed
state) in addition to setting Stop.isEnabled = false and
Main.setText(R.string.start), and apply the same stop+reset logic in the empty
else fallback so null or unexpected service.status.value also clears the UI
timer.

In `@app/src/main/java/com/philkes/notallyx/presentation/UiExtensions.kt`:
- Around line 401-403: RadioGroup.checkedTag() currently dereferences
findViewById(...) and RadioButton.tag unsafely which causes NPE when
checkedRadioButtonId == -1 or tag is null; change the function to safely handle
those cases by returning a nullable type (e.g., Any?) or throwing a clear
IllegalStateException with context, and update all caller sites using unsafe
"as" casts to handle null or perform safe casting; specifically modify
RadioGroup.checkedTag() to check checkedRadioButtonId != -1, null-check the
result of findViewById<RadioButton>(...), and return the nullable tag (or throw)
so callers in MainActivity and PreferenceBindingExtensions replace "as" with
safe casts or null-handling.

In
`@app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteAdapter.kt`:
- Around line 175-177: Remove the verbose debug log that prints user search
input in setSearchKeyword; locate the Log.d("SearchResult", "keyword: $keyword")
call inside the setSearchKeyword function and delete it (or replace it with a
non-PII-safe log such as a flag/length check or obfuscated/hashed value),
ensuring only non-sensitive information or no logging of the raw keyword remains
while leaving the rest of the setSearchKeyword logic intact.
- Around line 272-345: In moveSelectedNotesUp and moveSelectedNotesDown, prevent
moving a selected BaseNote across section boundaries by checking the adjacent
item and pinned-state before calling (list as MutableListContainer).move: for
each candidate index, fetch the neighboring item (index-1 for up, index+1 for
down) and skip the move if that neighbor is a Header or if its pinned state
differs from the selected item's pinned state; use the original items list to
inspect neighbors and the BaseNote.pinned (or equivalent) flag and only call
move when both adjacent-item and pinned-state guards pass, preserving all other
logic (selectedIndices iteration, moved flag, computing new positions,
updateSortIndices).

In
`@app/src/main/java/com/philkes/notallyx/presentation/viewmodel/BaseNoteModel.kt`:
- Around line 660-672: The duplicateNotes() flow currently deep-copies notes and
calls baseNoteDao.insert(copies) causing duplicates to inherit the original
sortIdx; update duplicateNotes() so each copied note resets its sortIdx (e.g.,
set sortIdx = null or a sentinel so ordering logic will assign a new index) when
calling deepCopy().copy(...), and replace the direct baseNoteDao.insert(copies)
call with insertSafe(app, copies) so inserts go through the safe insertion path
that assigns proper sort indices; reference duplicateNotes(),
deepCopy().copy(...), baseNoteDao.insert, and insertSafe(app, copies) when
making this change.

---

Outside diff comments:
In `@app/src/main/java/com/philkes/notallyx/data/dao/BaseNoteDao.kt`:
- Around line 82-109: The insertSafe function currently detects oversized notes
via BaseNote.truncated() and accumulates truncated versions in truncatedNotes
but still calls insert(baseNotes), so the sanitized/truncated note copies are
never persisted; change insertSafe to build a new list (e.g., insertList) where
for each baseNote you append the truncated note returned by baseNote.truncated()
when truncated (or the original/baseNote when not truncated), use that list for
the final insert(...) call instead of baseNotes, and keep the existing
logging/showToast behavior using truncatedNotes/truncatedCharacterSize; update
references to truncated, note, truncatedNotes, and the insert(...) call
accordingly.

In `@app/src/main/java/com/philkes/notallyx/data/model/BaseNote.kt`:
- Around line 43-88: BaseNote currently includes sortIdx in content equality
which causes drag-reorder to be treated as a content change; update
BaseNote.equals(other: Any?) and BaseNote.hashCode() to ignore sortIdx (remove
the if (sortIdx != other.sortIdx) check from equals and stop adding sortIdx into
the hash calculation), or alternatively make
BaseNoteAdapter.areContentsTheSame(...) explicitly ignore sortIdx; prefer
removing sortIdx from BaseNote.equals/hashCode by deleting its comparison and
its contribution to the hash so reorder updates no longer trigger content
rebinds.

In `@app/src/main/res/layout/dialog_notes_sort.xml`:
- Around line 19-28: The TextView with id NotesSortDirectionRadioGroupLabel is
not being hidden when the NotesSortDirectionRadioGroup is toggled in the binding
code; update the toggle in the PreferenceBindingExtensions code that currently
sets visibility for NotesSortDirectionRadioGroup (the same block around where
manual sort is handled) to also set the visibility of
NotesSortDirectionRadioGroupLabel to the same value (use the same
isVisible/visibility logic or helper used for the RadioGroup so the label is
hidden/shown in sync). Ensure you reference the existing
NotesSortDirectionRadioGroupLabel and NotesSortDirectionRadioGroup view IDs and
apply identical visibility changes in that handler.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3199f775-6ef9-45c1-adb2-0fb50178dcf9

📥 Commits

Reviewing files that changed from the base of the PR and between dde6d3d and b7488cb.

📒 Files selected for processing (26)
  • TRANSLATIONS.md
  • app/schemas/com.philkes.notallyx.data.NotallyDatabase/10.json
  • app/src/main/java/com/philkes/notallyx/data/NotallyDatabase.kt
  • app/src/main/java/com/philkes/notallyx/data/dao/BaseNoteDao.kt
  • app/src/main/java/com/philkes/notallyx/data/model/BaseNote.kt
  • app/src/main/java/com/philkes/notallyx/data/model/ModelExtensions.kt
  • app/src/main/java/com/philkes/notallyx/presentation/UiExtensions.kt
  • app/src/main/java/com/philkes/notallyx/presentation/activity/main/ActionModeBinding.kt
  • app/src/main/java/com/philkes/notallyx/presentation/activity/main/MainActivity.kt
  • app/src/main/java/com/philkes/notallyx/presentation/activity/main/fragment/NotallyFragment.kt
  • app/src/main/java/com/philkes/notallyx/presentation/activity/main/fragment/settings/PreferenceBindingExtensions.kt
  • app/src/main/java/com/philkes/notallyx/presentation/activity/main/fragment/settings/SettingsFragment.kt
  • app/src/main/java/com/philkes/notallyx/presentation/activity/note/RecordAudioActivity.kt
  • app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteAdapter.kt
  • app/src/main/java/com/philkes/notallyx/presentation/view/main/sorting/BaseNoteManualSort.kt
  • app/src/main/java/com/philkes/notallyx/presentation/viewmodel/BaseNoteModel.kt
  • app/src/main/java/com/philkes/notallyx/presentation/viewmodel/NotallyModel.kt
  • app/src/main/java/com/philkes/notallyx/presentation/viewmodel/preference/NotesSorting.kt
  • app/src/main/java/com/philkes/notallyx/utils/ActionMode.kt
  • app/src/main/java/com/philkes/notallyx/utils/backup/ImportExtensions.kt
  • app/src/main/res/drawable/sort.xml
  • app/src/main/res/layout/activity_main.xml
  • app/src/main/res/layout/dialog_notes_sort.xml
  • app/src/main/res/layout/fragment_settings.xml
  • app/src/main/res/values/strings.xml
  • app/src/main/res/values/themes.xml
💤 Files with no reviewable changes (3)
  • app/src/main/res/layout/fragment_settings.xml
  • app/src/main/java/com/philkes/notallyx/presentation/activity/main/fragment/settings/SettingsFragment.kt
  • app/src/main/res/values/themes.xml

Comment on lines +45 to +50
private val actionModeCancelCallback =
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
baseModel.actionMode.close(true)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does an AndroidX OnBackPressedCallbackhave any effect before it is registered with anOnBackPressedDispatcher?

💡 Result:

No. An OnBackPressedCallback does not intercept or receive system Back events until it is added/registered with an OnBackPressedDispatcher (e.g., via addCallback(...)). Before registration it’s just an object holding state (like isEnabled), and the dispatcher only considers enabled callbacks that are registered when deciding whether/what to dispatch on Back. [1], [2]


🏁 Script executed:

cd /tmp && find . -name "ActionModeBinding.kt" 2>/dev/null | head -1

Repository: Crustack/NotallyX

Length of output: 43


🏁 Script executed:

fd "ActionModeBinding.kt"

Repository: Crustack/NotallyX

Length of output: 147


🏁 Script executed:

find . -type f -name "ActionModeBinding.kt" 2>/dev/null

Repository: Crustack/NotallyX

Length of output: 149


🏁 Script executed:

# Search for ActionModeBinding.kt and read its full contents
fd "ActionModeBinding.kt" --exec cat -n {} \;

Repository: Crustack/NotallyX

Length of output: 14530


🏁 Script executed:

# Also search for any references to actionModeCancelCallback to see where it's used
rg "actionModeCancelCallback" -B 2 -A 2

Repository: Crustack/NotallyX

Length of output: 6866


🏁 Script executed:

# Search for addCallback or registerCallback patterns in relation to this callback
rg "addCallback|registerCallback" -B 2 -A 2

Repository: Crustack/NotallyX

Length of output: 50373


Remove the unused callback or register it with the dispatcher.

actionModeCancelCallback is enabled and disabled in this class (line 74), but it is never registered with context.onBackPressedDispatcher, so toggling its isEnabled state has no effect. An OnBackPressedCallback only intercepts back presses after it is added to a dispatcher. This orphaned callback creates dead code and makes the back-handling logic harder to follow.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/com/philkes/notallyx/presentation/activity/main/ActionModeBinding.kt`
around lines 45 - 50, The OnBackPressedCallback instance
actionModeCancelCallback is never added to the back-press dispatcher so toggling
its isEnabled is ineffective; either remove the unused actionModeCancelCallback
and its isEnabled toggles, or register it with the dispatcher. To fix by
registering, call context.onBackPressedDispatcher.addCallback(lifecycleOwner,
actionModeCancelCallback) (or use the appropriate activity/fragment
onBackPressedDispatcher) when the binding is created so the callback will be
invoked, and keep using isEnabled to toggle it; if you choose removal, delete
actionModeCancelCallback and any code that sets its isEnabled (e.g., where you
enable/disable it) and ensure baseModel.actionMode.close(true) is invoked via
another registered handler.

Comment on lines +263 to +287
fun moveSelectedNotesUp() {
val lastSelectedId =
notesAdapter?.getItem(lastSelectedNotePosition)?.let { (it as? BaseNote)?.id }
notesAdapter?.moveSelectedNotesUp(lastSelectedId)?.let { (topPos, lastSelectedPos) ->
binding?.MainListView?.scrollToPosition(topPos)
if (lastSelectedPos != -1) {
lastSelectedNotePosition = lastSelectedPos
}
updateActionModeSelectedNotes()
updateDatabaseSortIndices()
}
}

fun moveSelectedNotesDown() {
val lastSelectedId =
notesAdapter?.getItem(lastSelectedNotePosition)?.let { (it as? BaseNote)?.id }
notesAdapter?.moveSelectedNotesDown(lastSelectedId)?.let { (topPos, lastSelectedPos) ->
binding?.MainListView?.scrollToPosition(topPos)
if (lastSelectedPos != -1) {
lastSelectedNotePosition = lastSelectedPos
}
updateActionModeSelectedNotes()
updateDatabaseSortIndices()
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't persist manual order from a filtered list.

These paths save whatever notesAdapter.currentList currently contains. On search/label/unlabeled screens that is only a visible subset, so drag-and-drop or the move up/down actions will rewrite sortIdx for some notes and leave the hidden ones stale. The next full reload can then produce duplicate or ambiguous manual ordering. Restrict manual reordering to full folder lists, or build the update map from the unfiltered dataset before calling updateNotesSortIndices().

Also applies to: 298-301, 370-466

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/com/philkes/notallyx/presentation/activity/main/fragment/NotallyFragment.kt`
around lines 263 - 287, The moveSelectedNotesUp/moveSelectedNotesDown handlers
(and any code that calls updateDatabaseSortIndices/updateNotesSortIndices) are
persisting order using notesAdapter.currentList which may be filtered; instead
either skip persisting when the list is filtered (only allow manual reorder on
full folder view) or build the new sort-index map against the unfiltered/full
dataset before calling updateNotesSortIndices/updateDatabaseSortIndices.
Concretely: detect a filtered state (e.g., compare notesAdapter.currentList size
vs the full notes list or use a filter flag) inside
moveSelectedNotesUp/moveSelectedNotesDown and do not call the DB update if
filtered, or construct an ID->newIndex map by iterating the full notes
collection and applying the new positions from the visible list while preserving
relative positions of hidden items, then pass that map into
updateNotesSortIndices/updateDatabaseSortIndices so hidden notes’ sortIdx remain
consistent.

Comment on lines +162 to +168
layout.NotesSortDirectionRadioGroup.isVisible =
(layout.NotesSortByRadioGroup.checkedTag() as? NotesSortBy)?.isManualSort?.not() ?: true
layout.NotesSortByRadioGroup.setOnCheckedChangeListener { group, checkedId ->
val selectedSortBy = group.checkedTag() as? NotesSortBy
layout.NotesSortDirectionRadioGroup.isVisible =
selectedSortBy?.isManualSort?.not() ?: true
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Direction picker visibility logic is correct, but label is not hidden.

The logic to hide NotesSortDirectionRadioGroup when manual sort is selected is correct. However, NotesSortDirectionRadioGroupLabel should also be hidden for a consistent UI (see related comment on dialog_notes_sort.xml).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/com/philkes/notallyx/presentation/activity/main/fragment/settings/PreferenceBindingExtensions.kt`
around lines 162 - 168, The visibility toggle only updates
NotesSortDirectionRadioGroup but not its label; update the initial visibility
and the listener to also set layout.NotesSortDirectionRadioGroupLabel.isVisible
to the same boolean you compute (use the same expression as for
NotesSortDirectionRadioGroup), i.e. read selected sort via
layout.NotesSortByRadioGroup.checkedTag() as? NotesSortBy and set both
layout.NotesSortDirectionRadioGroup.isVisible and
layout.NotesSortDirectionRadioGroupLabel.isVisible accordingly in the initial
assignment and inside the setOnCheckedChangeListener callback so the label
hides/shows in sync with the direction radio group.

Comment on lines +613 to +618
private fun currentFragment(): NotallyFragment? =
supportFragmentManager
.findFragmentById(R.id.NavHostFragment)
?.childFragmentManager
?.fragments
?.firstOrNull() as? NotallyFragment
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Android Navigation/Fragment APIs, is childFragmentManager.fragments.firstOrNull()a reliable way to get the currently displayed destination fragment, or shouldprimaryNavigationFragment be used instead?

💡 Result:

childFragmentManager.fragments.firstOrNull() is not a reliable way to identify the currently displayed Navigation destination fragment. That list is just “all fragments currently added” (and can temporarily include multiple fragments during transitions/animations, or keep fragments around in more complex setups), so “first” can easily be the wrong one.

For Navigation + fragments, the reliable API is primaryNavigationFragment:

  • The Navigation FragmentNavigator is designed to call FragmentTransaction.setPrimaryNavigationFragment() for each navigation so the current destination fragment can be retrieved via FragmentManager.getPrimaryNavigationFragment() / primaryNavigationFragment. [1]
  • NavHostFragment/Navigation docs likewise rely on the “primary navigation fragment” concept (e.g., setPrimaryNavigationFragment(...) is what makes a host act like the default nav host). [2]
  • Navigation release notes explicitly note the behavior that the current Fragment is set as the primary navigation fragment. [3]

Typical pattern:

val navHost = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
val currentDestinationFragment = navHost.childFragmentManager.primaryNavigationFragment

Sources: [1] [2] [3]


Resolve the current destination via primaryNavigationFragment.

childFragmentManager.fragments.firstOrNull() returns all fragments currently added to the manager, not the currently displayed destination. After navigation or back-stack changes, this can return a stale NotallyFragment, causing prepareNewNoteIntent() and manual-sort actions to target the wrong screen. The Navigation framework uses primaryNavigationFragment as the reliable API for the current destination.

Suggested fix
 private fun currentFragment(): NotallyFragment? =
-    supportFragmentManager
-        .findFragmentById(R.id.NavHostFragment)
-        ?.childFragmentManager
-        ?.fragments
-        ?.firstOrNull() as? NotallyFragment
+    (supportFragmentManager.findFragmentById(R.id.NavHostFragment) as? NavHostFragment)
+        ?.childFragmentManager
+        ?.primaryNavigationFragment as? NotallyFragment
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private fun currentFragment(): NotallyFragment? =
supportFragmentManager
.findFragmentById(R.id.NavHostFragment)
?.childFragmentManager
?.fragments
?.firstOrNull() as? NotallyFragment
private fun currentFragment(): NotallyFragment? =
(supportFragmentManager.findFragmentById(R.id.NavHostFragment) as? NavHostFragment)
?.childFragmentManager
?.primaryNavigationFragment as? NotallyFragment
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/com/philkes/notallyx/presentation/activity/main/MainActivity.kt`
around lines 613 - 618, The currentFragment() helper incorrectly uses
childFragmentManager.fragments.firstOrNull(), which can return stale fragments;
update it to resolve the displayed destination via the NavHost's
primaryNavigationFragment instead: locate the call in currentFragment() (and any
code paths like prepareNewNoteIntent() or manual-sort actions that rely on it),
replace the childFragmentManager.fragments.firstOrNull() lookup with
childFragmentManager.primaryNavigationFragment, and cast that result to
NotallyFragment (as? NotallyFragment) so the app always targets the actual
current fragment shown by the Navigation component.

Comment on lines +151 to +166
when (service.status.value) {
Status.READY -> {
Stop.isEnabled = false
Main.setText(R.string.start)
}
Status.RECORDING -> {
Timer.start()
Stop.isEnabled = true
Main.setText(R.string.pause)
}
Status.PAUSED -> {
Timer.stop()
Stop.isEnabled = true
Main.setText(R.string.resume)
}
else -> {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Reset the chronometer in READY and fallback states.

Status.READY only updates the buttons, so a prior RECORDING state can leave the chronometer running. The empty else has the same stale-UI risk for null or future values.

Proposed fix
         binding.apply {
             Timer.base = service.getBase()
             when (service.status.value) {
                 Status.READY -> {
+                    Timer.stop()
                     Stop.isEnabled = false
                     Main.setText(R.string.start)
                 }
                 Status.RECORDING -> {
                     Timer.start()
                     Stop.isEnabled = true
                     Main.setText(R.string.pause)
                 }
                 Status.PAUSED -> {
                     Timer.stop()
                     Stop.isEnabled = true
                     Main.setText(R.string.resume)
                 }
-                else -> {}
+                else -> {
+                    Timer.stop()
+                    Stop.isEnabled = false
+                    Main.setText(R.string.start)
+                }
             }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
when (service.status.value) {
Status.READY -> {
Stop.isEnabled = false
Main.setText(R.string.start)
}
Status.RECORDING -> {
Timer.start()
Stop.isEnabled = true
Main.setText(R.string.pause)
}
Status.PAUSED -> {
Timer.stop()
Stop.isEnabled = true
Main.setText(R.string.resume)
}
else -> {}
when (service.status.value) {
Status.READY -> {
Timer.stop()
Stop.isEnabled = false
Main.setText(R.string.start)
}
Status.RECORDING -> {
Timer.start()
Stop.isEnabled = true
Main.setText(R.string.pause)
}
Status.PAUSED -> {
Timer.stop()
Stop.isEnabled = true
Main.setText(R.string.resume)
}
else -> {
Timer.stop()
Stop.isEnabled = false
Main.setText(R.string.start)
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/com/philkes/notallyx/presentation/activity/note/RecordAudioActivity.kt`
around lines 151 - 166, The READY branch currently only updates buttons and can
leave Timer running from a prior RECORDING state; update the Status.READY
handling to explicitly stop and reset the chronometer (use the same Timer
control used elsewhere, e.g., call Timer.stop() and reset its base/elapsed
state) in addition to setting Stop.isEnabled = false and
Main.setText(R.string.start), and apply the same stop+reset logic in the empty
else fallback so null or unexpected service.status.value also clears the UI
timer.

Comment on lines 401 to 403
fun RadioGroup.checkedTag(): Any {
return this.findViewById<RadioButton?>(this.checkedRadioButtonId).tag
return this.findViewById<RadioButton>(this.checkedRadioButtonId).tag
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all usages of checkedTag() to assess crash risk
rg -n -B3 -A3 'checkedTag\(\)' --type=kt

Repository: Crustack/NotallyX

Length of output: 87


🏁 Script executed:

#!/bin/bash
# Search for all usages of checkedTag() without file type filter
rg -n -B3 -A3 'checkedTag\(\)' --type kotlin

Repository: Crustack/NotallyX

Length of output: 9062


🏁 Script executed:

#!/bin/bash
# If kotlin type doesn't work, try searching without type filter
rg -n -B3 -A3 'checkedTag\(\)'

Repository: Crustack/NotallyX

Length of output: 9062


🏁 Script executed:

#!/bin/bash
# Also search for the PreferenceBindingExtensions.kt file and read the relevant lines
find . -name "PreferenceBindingExtensions.kt" -type f

Repository: Crustack/NotallyX

Length of output: 177


🏁 Script executed:

#!/bin/bash
# Read PreferenceBindingExtensions.kt around the unsafe cast usages to see dialog initialization
sed -n '175,195p' app/src/main/java/com/philkes/notallyx/presentation/activity/main/fragment/settings/PreferenceBindingExtensions.kt

Repository: Crustack/NotallyX

Length of output: 1086


🏁 Script executed:

#!/bin/bash
# Read MainActivity.kt around line 700 to see dialog setup
sed -n '690,715p' app/src/main/java/com/philkes/notallyx/presentation/activity/main/MainActivity.kt

Repository: Crustack/NotallyX

Length of output: 1341


🏁 Script executed:

#!/bin/bash
# Check what initializes these radio groups - search for checkedRadioButtonId or check() calls
rg -n "check\(|checkedRadioButtonId|NotesSortByRadioGroup" --context=2 | head -100

Repository: Crustack/NotallyX

Length of output: 11744


🌐 Web query:

Android RadioButton tag property nullable

💡 Result:

Yes—RadioButton’s tag is nullable.

  • RadioButton inherits android.view.View, whose getTag() returns the object stored as a tag or null if not set. [1]
  • In XML, android:tag is optional; if you don’t set it, getTag() will return null. The attribute is described as supplying a tag retrievable later via View.getTag() / findViewWithTag(). [2]

So in Kotlin you should treat it as Any? (nullable), e.g. val t = radioButton.tag as? String.

Sources
[1] Android Developers API Reference — android.view.View (getTag() “or null if not set”) (developer.android.com)
[2] Android Developers API Reference — android.view.View (android:tag attribute description) (developer.android.com)

Citations:


🏁 Script executed:

#!/bin/bash
# Check if RadioButton extends View and what tag property is
rg -n "\.tag\s*=" | head -20

Repository: Crustack/NotallyX

Length of output: 219


Fix NPE risk in RadioGroup.checkedTag() — multiple unsafe cast sites will crash.

When checkedRadioButtonId returns -1 (no selection), findViewById(-1) returns null, causing an NPE on .tag access. Additionally, RadioButton.tag is nullable, so accessing it directly is unsafe. Found 6 unsafe cast sites using as without null handling (MainActivity.kt lines 700, 705; PreferenceBindingExtensions.kt lines 188, 191, 243, 289) that will crash if the radiogroup has no selection or the tag is null.

🛡️ Proposed fix to handle missing selection
-fun RadioGroup.checkedTag(): Any {
-    return this.findViewById<RadioButton>(this.checkedRadioButtonId).tag
+fun RadioGroup.checkedTag(): Any? {
+    val checkedId = this.checkedRadioButtonId
+    if (checkedId == -1) return null
+    return this.findViewById<RadioButton>(checkedId)?.tag
 }

Alternatively, if radio groups are guaranteed to always have a selection in this app's UI, add a defensive check with a clear exception:

-fun RadioGroup.checkedTag(): Any {
-    return this.findViewById<RadioButton>(this.checkedRadioButtonId).tag
+fun RadioGroup.checkedTag(): Any {
+    val checkedId = this.checkedRadioButtonId
+    require(checkedId != -1) { "RadioGroup has no checked RadioButton" }
+    return this.findViewById<RadioButton>(checkedId).tag
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fun RadioGroup.checkedTag(): Any {
return this.findViewById<RadioButton?>(this.checkedRadioButtonId).tag
return this.findViewById<RadioButton>(this.checkedRadioButtonId).tag
}
fun RadioGroup.checkedTag(): Any? {
val checkedId = this.checkedRadioButtonId
if (checkedId == -1) return null
return this.findViewById<RadioButton>(checkedId)?.tag
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/philkes/notallyx/presentation/UiExtensions.kt` around
lines 401 - 403, RadioGroup.checkedTag() currently dereferences
findViewById(...) and RadioButton.tag unsafely which causes NPE when
checkedRadioButtonId == -1 or tag is null; change the function to safely handle
those cases by returning a nullable type (e.g., Any?) or throwing a clear
IllegalStateException with context, and update all caller sites using unsafe
"as" casts to handle null or perform safe casting; specifically modify
RadioGroup.checkedTag() to check checkedRadioButtonId != -1, null-check the
result of findViewById<RadioButton>(...), and return the nullable tag (or throw)
so callers in MainActivity and PreferenceBindingExtensions replace "as" with
safe casts or null-handling.

Comment on lines 175 to 177
fun setSearchKeyword(keyword: String) {
Log.d("SearchResult", "keyword: $keyword")
if (searchKeyword != keyword) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remove the keyword debug log.

Search text can contain note contents, passwords, or other PII, and this prints it verbatim on every keystroke.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteAdapter.kt`
around lines 175 - 177, Remove the verbose debug log that prints user search
input in setSearchKeyword; locate the Log.d("SearchResult", "keyword: $keyword")
call inside the setSearchKeyword function and delete it (or replace it with a
non-PII-safe log such as a flag/length check or obfuscated/hashed value),
ensuring only non-sensitive information or no logging of the raw keyword remains
while leaving the rest of the setSearchKeyword logic intact.

Comment on lines +272 to +345
fun moveSelectedNotesUp(lastSelectedId: Long?): Pair<Int, Int>? {
if (list is MutableListContainer) {
val items = list.toList()
val selectedIndices =
items.mapIndexedNotNull { index, item ->
if (item is BaseNote && selectedIds.contains(item.id)) index else null
}
if (selectedIndices.isEmpty()) return null

var moved = false
selectedIndices.forEachIndexed { i, index ->
if (index > i) {
(list as MutableListContainer).move(index, index - 1)
moved = true
}
}
if (!moved) return null

val newItems = list.toList()
val topMostMovedPosition =
newItems
.indexOfFirst { it is BaseNote && selectedIds.contains(it.id) }
.takeIf { it != -1 }

val newLastSelectedPos =
if (lastSelectedId != null) {
newItems.indexOfFirst { it is BaseNote && it.id == lastSelectedId }
} else -1

updateSortIndices()
return topMostMovedPosition?.let { it to newLastSelectedPos }
}
return null
}

/**
* @return a pair of (top-most moved position, new position of the last selected note). If there
* is no moved position, return null.
*/
fun moveSelectedNotesDown(lastSelectedId: Long?): Pair<Int, Int>? {
if (list is MutableListContainer) {
val items = list.toList()
val selectedIndices =
items.mapIndexedNotNull { index, item ->
if (item is BaseNote && selectedIds.contains(item.id)) index else null
}
if (selectedIndices.isEmpty()) return null

var moved = false
val n = items.size
selectedIndices.reversed().forEachIndexed { i, index ->
if (index < n - 1 - i) {
(list as MutableListContainer).move(index, index + 1)
moved = true
}
}
if (!moved) return null

val newItems = list.toList()
val topMostMovedPosition =
newItems
.indexOfFirst { it is BaseNote && selectedIds.contains(it.id) }
.takeIf { it != -1 }

val newLastSelectedPos =
if (lastSelectedId != null) {
newItems.indexOfFirst { it is BaseNote && it.id == lastSelectedId }
} else -1

updateSortIndices()
return topMostMovedPosition?.let { it to newLastSelectedPos }
}
return null
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep the move-up/down actions inside the current section.

These helpers only check absolute list bounds. A selected note at the edge of a section can move across a Header or into the other pinned state, even though drag-and-drop explicitly blocks that path. Apply the same adjacent-item and pinned-state guard here before calling move().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteAdapter.kt`
around lines 272 - 345, In moveSelectedNotesUp and moveSelectedNotesDown,
prevent moving a selected BaseNote across section boundaries by checking the
adjacent item and pinned-state before calling (list as
MutableListContainer).move: for each candidate index, fetch the neighboring item
(index-1 for up, index+1 for down) and skip the move if that neighbor is a
Header or if its pinned state differs from the selected item's pinned state; use
the original items list to inspect neighbors and the BaseNote.pinned (or
equivalent) flag and only call move when both adjacent-item and pinned-state
guards pass, preserving all other logic (selectedIndices iteration, moved flag,
computing new positions, updateSortIndices).

Comment on lines +660 to +672
fun updateNoteSortIdx(id: Long, sortIdx: Int) {
viewModelScope.launch {
withContext(Dispatchers.IO) { baseNoteDao.updateSortIdx(id, sortIdx) }
}
}

fun updateNotesSortIndices(idToIdx: Map<Long, Int>) {
viewModelScope.launch {
withContext(Dispatchers.IO) { baseNoteDao.updateSortIndices(idToIdx) }
}
}

suspend fun getMaxNoteSortIdx() = withContext(Dispatchers.IO) { baseNoteDao.getMaxSortIdx() }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Duplicates still reuse the source sortIdx.

These new sort-index helpers do not cover the duplication path in this same ViewModel: duplicateNotes() at Lines 742-758 does deepCopy().copy(...) and then calls baseNoteDao.insert(copies), so the copied note keeps the original manual position. That creates sortIdx collisions and makes manual ordering ambiguous. Reset sortIdx on duplicates and insert them through insertSafe(app, copies) instead.

Proposed fix
         val copies: List<BaseNote> =
             notes.map { original ->
                 original
                     .deepCopy()
                     .copy(
                         id = 0L,
                         title =
                             if (original.title.isNotEmpty())
                                 "${original.title} (${app.getString(R.string.copy)})"
                             else app.getString(R.string.copy),
                         timestamp = now,
                         modifiedTimestamp = now,
+                        sortIdx = null,
                     )
             }
-        return withContext(Dispatchers.IO) { baseNoteDao.insert(copies) }
+        return withContext(Dispatchers.IO) { baseNoteDao.insertSafe(app, copies) }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/com/philkes/notallyx/presentation/viewmodel/BaseNoteModel.kt`
around lines 660 - 672, The duplicateNotes() flow currently deep-copies notes
and calls baseNoteDao.insert(copies) causing duplicates to inherit the original
sortIdx; update duplicateNotes() so each copied note resets its sortIdx (e.g.,
set sortIdx = null or a sentinel so ordering logic will assign a new index) when
calling deepCopy().copy(...), and replace the direct baseNoteDao.insert(copies)
call with insertSafe(app, copies) so inserts go through the safe insertion path
that assigns proper sort indices; reference duplicateNotes(),
deepCopy().copy(...), baseNoteDao.insert, and insertSafe(app, copies) when
making this change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow notes manual sorting

1 participant