Repository: Automattic/pocket-casts-android
File: modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/onboarding/recommendations/OnboardingInterestsViewModel.kt (Kotlin)

Code before:
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import au.com.shiftyjelly.pocketcasts.localization.R as LR

@HiltViewModel
class OnboardingInterestsViewModel @Inject constructor(
    private val categoriesManager: CategoriesManager,
) : ViewModel() {

    private val _state = MutableStateFlow(
        State(
            allCategories = emptyList(),
            displayedCategories = emptyList(),
        ),
    )
    val state = _state.asStateFlow()

    init {
        fetchCategories()
    }

    fun skipSelection(doWhenFinished: () -> Unit) {
        viewModelScope.launch {
            categoriesManager.setInterestCategories(emptySet())
            doWhenFinished()
        }
    }

    fun updateSelectedCategory(category: DiscoverCategory, isSelected: Boolean) {
        _state.update {
            it.copy(
                selectedCategories = if (isSelected) {
                    it.selectedCategories + category
                } else {
                    it.selectedCategories - category
                },
            )
        }
    }

    fun showMore() {
        _state.update {
            it.copy(
                displayedCategories = it.allCategories,
            )
        }
    }

    fun saveInterests(doWhenFinished: () -> Unit) {
        viewModelScope.launch {
            categoriesManager.setInterestCategories(_state.value.selectedCategories)

Review comment (bug):
⚠️ This is an erroneous pattern when communicating between UI and ViewModel. It can lead to memory leaks, missing events, or even crashes. See this code:

```kotlin
viewModelScope.launch {
  longOperation()
}
uiCallback()
```

When this code is triggered and the UI is destroyed while `longOperation` is running, the coroutine won’t be cancelled. It also might not be cancelled when a `ViewModel` is destroyed if `longOperation` is not cooperative. This causes a memory leak when UI references are captured in the `doWhenFinished` callback. After that, the callback executes for a UI component that is no longer part of the view hierarchy. Depending on what `doWhenFinished` does, this will most often lead to problems.  

Fortunately, this can be easily changed in this instance without introducing any of the communication patterns for one-off events. Since `setInterestCategories` is not a suspending function and not an expensive call, there’s no need to launch it in a coroutine or use a callback. Everything can be executed synchronously from the UI.  

```kotlin
viewModel.skipSelection()
onShowRecommendations()
```

Code after:
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import au.com.shiftyjelly.pocketcasts.localization.R as LR

@HiltViewModel
class OnboardingInterestsViewModel @Inject constructor(
    private val categoriesManager: CategoriesManager,
) : ViewModel() {

    private val _state = MutableStateFlow(
        State(
            allCategories = emptyList(),
            displayedCategories = emptyList(),
        ),
    )
    val state = _state.asStateFlow()

    init {
        fetchCategories()
    }

    fun skipSelection() {
        categoriesManager.setInterestCategories(emptySet())
    }

    fun updateSelectedCategory(category: DiscoverCategory, isSelected: Boolean) {
        _state.update {
            it.copy(
                selectedCategories = if (isSelected) {
                    it.selectedCategories + category
                } else {
                    it.selectedCategories - category
                },
            )
        }
    }

    fun showMore() {
        _state.update {
            it.copy(
                displayedCategories = it.allCategories,
            )
        }
    }

    fun saveInterests() {
        categoriesManager.setInterestCategories(_state.value.selectedCategories)
    }

    private fun fetchCategories() {
        viewModelScope.launch {