finish ActionMode on Dialog resolution after selected items processed - android

I'm using my data model to accurately keep track of the selected list items in an adapter. I reset the selected item flags in onDestroyActionMode which I understand is called from mode.finish(). Unfortunately the DialogFragment is run asynchronously so by the time I get to my business logic in the songViewModel everything is reset already.
How do I propperly finish my actionMode with the selected items remaining selected until I'm done with them?
It seems I have 2 options that I don't really like.
I can implement some handler and try to do some run blocking.
I can move some of the business logic from the viewModel to MainActivity, making a filtered list of the items that I pass to the DialogFragment which then passes them to the viewModel.
I'm leaning toward option 2 but it really confuses me because I'm under the impression that
I should be handling most of my business logic in my viewModel rather than passing it to MainActivity then to a DialogFragment just to come back to the viewModel again.
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_make_set_list -> {
//make a list
showSetListDialog(currentArtist)
Toast.makeText(this#MainActivity, "Make Set List Selected", Toast.LENGTH_SHORT)
.show()
mode.finish()
true
}
R.id.action_delete_songs -> {
songViewModel.deleteSelectedSongs()
Toast.makeText(this#MainActivity, "Delete Songs selected", Toast.LENGTH_SHORT)
.show()
mode.finish()
true
}
else -> false
}
}
override fun onDestroyActionMode(mode: ActionMode?) {
mActionMode = null
isInActionMode = false
selectionCounter = 0
actionAdapter?.notifyDataSetChanged()
//reset the isSelected field for the current list, is there a more efficient way???
actionAdapter?.currentList?.forEach { it.isSelected = false }
}
I placed mode.finish() AFTER the showSetListDialog expecting it would execute after the Dialog was finished because I didn't know the Dialog was asynchronous. What happened is all the items were deselected by the time I got to my function in the viewModel.

Related

When flow collect stop itself?

There is ParentFragment that shows DialogFragment. I collect a dialog result through SharedFlow. When result received, dialog dismissed. Should I stop collect by additional code? What happens when dialog closed, but fragment still resumed?
// ParentFragment
private fun save() {
val dialog = ContinueDialogFragment(R.string.dialog_is_save_task)
dialog.show(parentFragmentManager, "is_save_dialog")
lifecycleScope.launch {
dialog.resultSharedFlow.collect {
when (it) {
ContinueDialogFragment.RESULT_YES -> {
viewModel.saveTask()
closeFragment()
}
ContinueDialogFragment.RESULT_NO -> {
closeFragment()
}
ContinueDialogFragment.RESULT_CONTINUE -> {
// dont close fragment
}
}
}
}
}
class ContinueDialogFragment(
#StringRes private val titleStringId: Int,
#StringRes private val messageStringId: Int? = null
) : DialogFragment() {
private val _resultSharedFlow = MutableSharedFlow<Int>(1)
val resultSharedFlow = _resultSharedFlow.asSharedFlow()
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return activity?.let { context ->
AlertDialog.Builder(context)
.setTitle(getString(titleStringId))
.setMessage(messageStringId?.let { getString(it) })
.setPositiveButton(getString(R.string.dialog_yes)) { _, _ ->
_resultSharedFlow.tryEmit(RESULT_YES)
}
.setNegativeButton(getString(R.string.dialog_no)) { _, _ ->
_resultSharedFlow.tryEmit(RESULT_NO)
}
.setNeutralButton(getString(R.string.dialog_continue)) { _, _ ->
_resultSharedFlow.tryEmit(RESULT_CONTINUE)
}
.create()
} ?: throw IllegalStateException("Activity cannot be null")
}
companion object {
const val RESULT_YES = 1
const val RESULT_NO = 0
const val RESULT_CONTINUE = 2
}
}
When a Flow completes depends on its original source. A Flow built with flowOf or asFlow() ends once it reaches the last item in its list. A Flow built with the flow builder could be finite or infinite, depending on whether it has an infinite loop in it.
A flow created with MutableSharedFlow is always infinite. It stays open until the coroutine collecting it is cancelled. Therefore, you are leaking the dialog fragment with your current code because you are hanging onto its MutableSharedFlow reference, which is capturing the dialog fragment reference. You need to manually cancel your coroutine or collection.
Or more simply, you could use first() instead of collect { }.
Side note, this is a highly unusual uses of a Flow, which is why you're running into this fragile condition in the first place. A Flow is for a series of emitted objects, not for a single object.
It is also very fragile that you're collecting this flow is a function called save(), but you don't appear to be doing anything in save() to store the instance state such that if the activity/fragment is recreated you'll start collecting from the flow again. So, if the screen rotates, the dialog will reappear, the user could click the positive button, and nothing will be saved. It will silently fail.
DialogFragments are pretty clumsy to work with in my opinion. Anyway, I would take the easiest route and directly put your behaviors in the DialogFragment code instead of trying to react to the result back in your parent fragment. But if you don't want to do that, you need to go through the pain of calling back through to the parent fragment. Alternatively, you could use a shared ViewModel between these two fragments that will handle the dialog results.
I believe you will have a memory leak of DialogFragment: ParentFragment will be referencing the field dialog.resultSharedFlow until the corresponding coroutine finishes execution. The latter may never happen while ParentFragment is open because dialog.resultSharedFlow is an infinite Flow. You can call cancel() to finish the coroutine execution and make dialog eligible for garbage collection:
lifecycleScope.launch {
dialog.resultSharedFlow.collect {
when (it) {
ContinueDialogFragment.RESULT_YES -> {
viewModel.saveTask()
closeFragment()
cancel()
}
ContinueDialogFragment.RESULT_NO -> {
closeFragment()
cancel()
}
ContinueDialogFragment.RESULT_CONTINUE -> {
// dont close fragment
}
}
}
}

conditional navigation in compose, without click

I am working on a compose screen, where on application open, i redirect user to profile page. And if profile is complete, then redirect to user list page.
my code is like below
#Composable
fun UserProfile(navigateToProviderList: () -> Unit) {
val viewModel: MainActivityViewModel = viewModel()
if(viewModel.userProfileComplete == true) {
navigateToProviderList()
return
}
else {
//compose elements here
}
}
but the app is blinking and when logged, i can see its calling the above redirect condition again and again. when going through doc, its mentioned that we should navigate only through callbacks. How do i handle this condition here? i don't have onCLick condition here.
Content of composable function can be called many times.
If you need to do some action inside composable, you need to use side effects
In this case LaunchedEffect should work:
LaunchedEffect(viewModel.userProfileComplete == true) {
if(viewModel.userProfileComplete == true) {
navigateToProviderList()
}
}
In the key(first argument of LaunchedEffect) you need to specify some key. Each time this key changes since the last recomposition, the inner code will be called. You may put Unit there, in this case it'll only be called once, when the view appears at the first place
The LaunchedEffect did not work for me since I wanted to use it in UI thread but it wasn't for some reason :/
However, I made this for my self:
#Composable
fun <T> SelfDestructEvent(liveData: LiveData<T>, onEvent: (argument: T) -> Unit) {
val previousState = remember { mutableStateOf(false) }
val state by liveData.observeAsState(null)
if (state != null && !previousState.value) {
previousState.value = true
onEvent.invoke(state!!)
}
}
and you use it like this in any other composables:
SingleEvent(viewModel.someLiveData) {
//your action with that data, whenever it was triggered, but only once
}

ViewModel refetching data again with distinctUntilChanged()

I have a Fragment that I want to do a fetch once on its data, I have used distinctUntilChanged() to fetch just once because my location is not changing during this fragment.
Fragment
private val viewModel by viewModels<LandingViewModel> {
VMLandingFactory(
LandingRepoImpl(
LandingDataSource()
)
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val sharedPref = requireContext().getSharedPreferences("LOCATION", Context.MODE_PRIVATE)
val nombre = sharedPref.getString("name", null)
location = name!!
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupRecyclerView()
fetchShops(location)
}
private fun fetchShops(localidad: String) {
viewModel.setLocation(location.toLowerCase(Locale.ROOT).trim())
viewModel.fetchShopList
.observe(viewLifecycleOwner, Observer {
when (it) {
is Resource.Loading -> {
showProgress()
}
is Resource.Success -> {
hideProgress()
myAdapter.setItems(it.data)
}
is Resource.Failure -> {
hideProgress()
Toast.makeText(
requireContext(),
"There was an error loading the shops.",
Toast.LENGTH_SHORT
).show()
}
}
})
}
Viewmodel
private val locationQuery = MutableLiveData<String>()
fun setLocation(location: String) {
locationQuery.value = location
}
val fetchShopList = locationQuery.distinctUntilChanged().switchMap { location ->
liveData(viewModelScope.coroutineContext + Dispatchers.IO) {
emit(Resource.Loading())
try{
emit(repo.getShopList(location))
}catch (e:Exception){
emit(Resource.Failure(e))
}
}
}
Now, if I go to the next fragment and press back, this fires again, I know that maybe this is because the fragment is recreating and then passing a new instance of viewmodel and thats why the location is not retained, but if I put activityViewModels as the instance of the viewmodel, it also happends the same, the data is loaded again on backpress, this is not acceptable since going back will get the data each time and this is not server efficient for me, I need to just fetch this data when the user is in this fragment and if they press back to not fetch it again.
Any clues ?
I'm using navigation components, so I cant use .add or do fragment transactions, I want to just fetch once on this fragment when creating it first time and not refetching on backpress of the next fragment
TL;DR
You need to use a LiveData that emits its event only once, even if the ui re-subscribe to it. for more info and explanation and ways to fix, continue reading.
When you go from Fragment 1 -> Fragment 2, Fragment 1 is not actually destroyed right away, it just un-subscribe from your ViewModel LiveData.
Now when you go back from F2 to F1, the fragment will re-subscribe back to ViewModel LiveData, and since the LiveData is - by nature - state holder, then it will re-emit its latest value right away, causing the ui to rebind.
What you need is some sort of LiveData that won't emit an event that has been emitted before.
This is common use case with LiveData, there's a pretty nice article talking about this need for a similar LiveData for different types of use cases, you can read it here.
Although the article proposed a couple of solutions but those can be a bit of an overkill sometimes, so a simpler solution would be using the following ActionLiveView
// First extend the MutableLiveData class
class ActionLiveData<T> : MutableLiveData<T>() {
#MainThread
override fun observe(owner: LifecycleOwner, observer: Observer<T?>) {
// Being strict about the observer numbers is up to you
// I thought it made sense to only allow one to handle the event
if (hasObservers()) {
throw Throwable("Only one observer at a time may subscribe to a ActionLiveData")
}
super.observe(owner, Observer { data ->
// We ignore any null values and early return
if (data == null) return
observer.onChanged(data)
// We set the value to null straight after emitting the change to the observer
value = null
// This means that the state of the data will always be null / non existent
// It will only be available to the observer in its callback and since we do not emit null values
// the observer never receives a null value and any observers resuming do not receive the last event.
// Therefore it only emits to the observer the single action so you are free to show messages over and over again
// Or launch an activity/dialog or anything that should only happen once per action / click :).
})
}
// Just a nicely named method that wraps setting the value
#MainThread
fun sendAction(data: T) {
value = data
}
}
You can find more explainiation for ActionLiveData in this link if you want.
I would advise using the ActionLiveData class, I've been using it for small to medium project size and it's working alright so far, but again, you know your use cases better than me. :)

Android Chrome Cast Introduction Overlay has incorrect behaviour when fragment has options menu

I am showing the Cast button as an Options menu item that is inflated from an activity, but I noticed that when the activity has a child fragment and the child fragment does not have an options menu item by itself, the chrome cast introduction overlay works correctly. However when the fragment has its own options menu, the Cast introduction overlay does not work correctly, it either shows in the top left corner or shows up in the correct position but does not highlight the cast icon.
Here is the code to initialize the Overlay
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
loadCastButton(menu)
return super.onCreateOptionsMenu(menu)
}
private fun loadCastButton(menu: Menu?) {
menuInflater.inflate(R.menu.menu_cast, menu)
CastButtonFactory.setUpMediaRouteButton(applicationContext, menu, R.id.cast_menu_item)
val mediaRoutebutton = menu?.findItem(R.id.cast_menu_item)?.actionView as? MediaRouteButton
mediaRoutebutton?.dialogFactory = CastDialogFactory()
handleCastTutorial(menu)
}
private fun handleCastTutorial(menu: Menu?) {
val castButton = menu?.findItem(R.id.cast_menu_item)
if (castButton == null) {
return
}
castViewModel.isCastingAvailable.observe(this) {
if (it == true && castButton.isVisible) {
//Show cast tutorial
castViewModel.setCastTutorialShown(true)
IntroductoryOverlay.Builder(this, castButton)
.setTitleText(R.string.cast_tutorial_title)
.setSingleTime()
.build()
.show()
}
}
}
When you are showing Cast buttons in fragments and activities, menus are inflated everywhere, with Cast buttons initialized in one of the fragments or activities and then immediately hidden again. My recommended solution is delaying the cast tutorial with a minor amount of delay, and then checking for visibility and window attach status again:
if (!castViewModel.getCastTutorialShown()) {
binding.root.postDelayed(200L) {
// Check if it is still visible.
if (castButton.isVisible && castButton.actionView.isAttachedToWindow && !castViewModel.getCastTutorialShown()) {
castViewModel.setCastTutorialShown(true)
IntroductoryOverlay.Builder(this, castButton)
.setTitleText(R.string.cast_tutorial_title)
.setSingleTime()
.build()
.show()
}
}
}

LiveData not notifying all items after adding one to database

I have a database in which I am storring some items from shopping list. I have two main activities - in one activity, I have a list of items with the state of an item - (saved in shopping list or not). In another one - description of every item and button, which saves or removes items (based on condition). If I press a button, my list condition must change too, and it does not always works fine (I actually can not define where it works, and where does not). How do I fix this? My list class listens in observeForever getAllShoppingListItemsIds() to detect if an item was added or not.
Here is the code.
open class BaseViewModel(private val listDao: ShoppingListDao) : BaseViewModel() {
protected fun addItemToShoppingList(sku: Sku) {
doAsync {
listDao.addItemToShoppingList(SavedShoppingListModel(sku.code, sku.title, sku.subTitle, sku.description, sku.image, sku.validityStartDate, sku.validityEndDate, sku.offerDescription, sku.regularPrice.toString(), sku.discountPrice.toString(), Const.SHOPPING_LIST_CATALOG, 1, Date(), false))
}
}
protected fun addItemToShoppingList(savedItem: SavedShoppingListModel) {
doAsync {
listDao.addItemToShoppingList(savedItem)
}
}
protected fun removeItemFromShoppingList(id: String) {
doAsync {
listDao.deleteById(id)
}
}
protected fun getAllShoppingListItemsIds() = listDao.getAllShoppingListItemsIds()
protected fun getShoppingListItemBydId(id: String) = listDao.getShoppingListItemBydId(id)
protected fun getShoppingListItemUidBydId(id: String) = listDao.getShoppingListItemUidBydId(id)
}
Problem was in observeForever in BaseViewModel. If you will move observation from your viewModel in Activity or Fragment with observe (not observeForever), all notifications will work

Categories

Resources