Skip to content

feat(manage-tags): introduce ViewModel [DEV-only]#20578

Merged
mikehardy merged 3 commits intoankidroid:mainfrom
david-allison:tags-dialog
Apr 19, 2026
Merged

feat(manage-tags): introduce ViewModel [DEV-only]#20578
mikehardy merged 3 commits intoankidroid:mainfrom
david-allison:tags-dialog

Conversation

@david-allison
Copy link
Copy Markdown
Member

@david-allison david-allison commented Mar 25, 2026

Note

Assisted-by: Claude Opus 4.6
Almost entirely written by Claude, with hours of back-and-forth over design and refactorings
I drove the ideas, Claude wrote the code. Read it heavily

Note

Please request I break this up into commits if appropriate. 400LOC of well-commented non-test code is borderline to me.

Purpose / Description

Even though the UI of the tags dialog is subject to design, the ViewModel and basic operations which we will support are unlikely to change

  • Based on tags.tree()
  • Delete/clear unused/rename are supported
  • Collapsed/expanded state is synced with the collection
  • A TagName abstraction acts as a guardrail against logging tag names
  • Introduces a Compose-style 'Channel' for UI state notifications

Fixes

Approach

  • Build a ViewModel
  • Add basic logic: delete, rename, clear unused & collapsed. Claude added multiselect & deletion
  • Refactored ManageTagsState to use a union of Loading/Loaded/Error
  • Refactored to use tag.tree()
  • Lots of refactoring of both the code and the tests
  • Lots of high-level docs
  • Added a TagName abstraction to handle bad logging decisions
  • Added in indeterminate states to fix bugs in multi-selection & delete
  • Removed mutlti-select after further UX issues combining it with filtering

How Has This Been Tested?

Heavily unit tested

Learning

  • I've been using Compose more, and I like the Channel<Event> pattern to consolidate all events in 1 flow
  • Indeterminate selection states in a treeview is nasty
    • Happy I skipped it
  • We need to look into OpChanges classes at a later date and add a common marker interface, the lambda return bug has hit too many times
  • Performance could likely be optimized, but it's a lot of complexity for an op which takes ~50ms on my emulator.
  • filtering + selection just feels dangerous

Checklist

  • You have a descriptive commit message with a short title (first line, max 50 chars).
  • You have commented your code, particularly in hard-to-understand areas
  • You have performed a self-review of your own code
  • UI changes: include screenshots of all affected screens (in particular showing any new or changed strings)
  • UI Changes: You have tested your change using the Google Accessibility Scanner

@david-allison david-allison added the Next version Changes to be merged in the next version, to keep the current release stable. label Mar 25, 2026
@david-allison david-allison force-pushed the tags-dialog branch 4 times, most recently from a341e75 to 7ab260b Compare March 25, 2026 13:47
Copy link
Copy Markdown
Contributor

@criticalAY criticalAY left a comment

Choose a reason for hiding this comment

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

Looks cool

Comment thread AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt
Comment thread AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt
Timber.w("findVisibleNode called while not loaded")
return null
}
return loaded.visibleNodes.firstOrNull { it.fullTag == tag }.also {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

findVisibleNode right now is O(n) , what if there are 20k tags? (hypothetical), can we think of doing O(1)?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Trade off is memory though

Copy link
Copy Markdown
Member Author

@david-allison david-allison Apr 5, 2026

Choose a reason for hiding this comment

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

Even with 20k tags, this should be under a millisecond.

After this change, we rebuild the tree from the collection, so there's little point in this - we've got to pay somewhere to build the map

Comment on lines +251 to +264
private fun hasDescendantMatching(
node: BackendTagTreeNode,
parentFullTag: String,
searchQuery: String,
): Boolean {
for (child in node.childrenList) {
val fullTag = "$parentFullTag::${child.name}"
if (fullTag.contains(searchQuery, ignoreCase = true)) return true
if (hasDescendantMatching(child, fullTag, searchQuery)) return true
}
return false
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

KDoc imo? And could we do a single pass instead? Compute all full paths once, mark matches,
and propagate "has matching descendant" back up to parents.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I switched to a simple linear approach:

  • build the flattened list once, with lowercased tag for performance (removes ignoreCase = true)
  • Loop through the list and check. If a match occurs, flag all ancestors to be expanded.

@criticalAY criticalAY added Needs Author Reply Waiting for a reply from the original author and removed Needs Review labels Apr 4, 2026
@david-allison david-allison force-pushed the tags-dialog branch 2 times, most recently from 5a2112f to 41223b2 Compare April 5, 2026 04:16
* Based on tags.tree()
* Delete/clear unused/rename are supported
* Collapsed/expanded state is synced with the collection
* A 'TagName' abstraction acts as a guardrail against logging tag names
* Introduces a Compose-style 'Channel' for UI state notifications

Part of issue 10397

Assisted-by: Claude Opus 4.6
  Almost entirely written by Claude, with hours of
  back-and-forth over design
  I drove the ideas, Claude wrote the code
@david-allison david-allison added Needs Review and removed Needs Author Reply Waiting for a reply from the original author labels Apr 5, 2026
@david-allison david-allison requested a review from criticalAY April 5, 2026 04:22
@david-allison david-allison removed the Next version Changes to be merged in the next version, to keep the current release stable. label Apr 7, 2026
@david-allison david-allison changed the title feat(manage-tags): introduce ViewModel feat(manage-tags): introduce ViewModel [DEV-only] Apr 7, 2026
Copy link
Copy Markdown
Member

@mikehardy mikehardy left a comment

Choose a reason for hiding this comment

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

dev-only, and foundation for more work (wherein I assume it'll be heavily exercised and if there are faults, they'll be flushed out). Can't think why not to merge it

@mikehardy mikehardy added this pull request to the merge queue Apr 19, 2026
@mikehardy mikehardy added Pending Merge Things with approval that are waiting future merge (e.g. targets a future release, CI wait, etc) and removed Needs Review labels Apr 19, 2026
Merged via the queue into ankidroid:main with commit 8ba345e Apr 19, 2026
15 checks passed
@github-actions github-actions Bot removed the Pending Merge Things with approval that are waiting future merge (e.g. targets a future release, CI wait, etc) label Apr 19, 2026
@github-actions github-actions Bot added this to the 2.24 release milestone Apr 19, 2026
Copy link
Copy Markdown
Member

@lukstbit lukstbit left a comment

Choose a reason for hiding this comment

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

Had some comments here but this was merged before coming back.
Mostly nitpicks but some real questions as well.

I was considering 'multi-delete', and I've decided against it.

The backend provides the functionality, but the potential UI states when combining it with a search/filter mechanism are too complex to explain well to the user:

I recommend to reconsider this. I can totally see some users using prefixes in tag names to group them and wanting to delete those in one go.

Comment thread AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt
Comment thread AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt
Comment thread AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt
Comment thread AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt
Comment thread AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt
Comment thread AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt
Comment thread AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt
data object Loading : ManageTagsState()

data class Loaded(
val visibleNodes: List<TagListItem>,
Copy link
Copy Markdown
Member

@lukstbit lukstbit Apr 20, 2026

Choose a reason for hiding this comment

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

Why just the visible nodes?

Feels like we should just output all tags we have and make TagListItem have a boolean flag isFilteredOut to indicate its visibility. Should help with submitList as well if we used it in the UI.

We already have the full list of tags in the ViewModel and this should also simplify the tag filtering code.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Is there a reason we'd ever want to display filtered nodes?

We'd want to filter them before they reached the RecyclerView for performance so we're only comparing deltas in visible rows, so I don't see how submitList would benefit.

I suppose we could get rid of the cache, but I don't find that compelling in itself, was there other simplification I'm missing?

@david-allison david-allison deleted the tags-dialog branch April 20, 2026 13:19
This was referenced Apr 20, 2026
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.

4 participants