I'm writing an application using the Android Architecture Components, originally based on the famous article, however that is now outdated and not accurate, so based on other documentation, articles and videos, I build something using the latest components, which turned out in a very simple architecture with very little code.
The idea is the app starts with its tables empty, and goes to read from a Firestore db to get its data, stores the data in a local SqlLite DB (using Room) and displays the updated data. Whenever the data is updated on Firestore, it should be updated in SqlLite and update the UI.
However, my UI (just a text box for now) is only updated when the application starts, and never ever after the DB is modified.
PorteroDao
package com.sarcobjects.portero.db
import androidx.room.*
import com.sarcobjects.portero.entities.Portero
import com.sarcobjects.portero.entities.PorteroWithLevelsAndUnits
#Dao
abstract class PorteroDao {
#Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(portero: Portero): Long
#Transaction
#Query("SELECT * FROM Portero WHERE porteroId == :porteroId")
abstract suspend fun getPortero(porteroId: Long): PorteroWithLevelsAndUnits
}
PorteroRepository
package com.sarcobjects.portero.repository
import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.EventListener
import com.google.firebase.firestore.FirebaseFirestore
import com.sarcobjects.portero.db.PorteroDao
import com.sarcobjects.portero.entities.Portero
import com.sarcobjects.portero.entities.PorteroWithLevelsAndUnits
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import timber.log.Timber.d
import timber.log.Timber.w
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
#Singleton
class PorteroRepository #Inject constructor(
private val porteroDao: PorteroDao,
private val firestore: FirebaseFirestore
) {
#ExperimentalCoroutinesApi
suspend fun getPortero(porteroId: Long): PorteroWithLevelsAndUnits {
GlobalScope.launch {refreshPortero(porteroId)}
val portero = porteroDao.getPortero(porteroId)
d("Retrieved portero: $portero")
return portero
}
#ExperimentalCoroutinesApi
private suspend fun refreshPortero(porteroId: Long) {
d("Refreshing")
//retrieve from firestore
retrieveFromFirestore(porteroId)
.collect { portero ->
d("Retrieved and collected: $portero")
porteroDao.insert(portero)
}
}
#ExperimentalCoroutinesApi
private fun retrieveFromFirestore(porteroId: Long): Flow<Portero> = callbackFlow {
val callback = EventListener<DocumentSnapshot> { document, e ->
if (e != null) {
w(e, "Listen from Firestore failed.")
close(e)
}
d("Read successfully from Firestore")
if (document != null && document.exists()) {
//Convert to objects
val portero = document.toObject(Portero::class.java)
d("New Portero: ${portero.toString()}")
offer(portero!!)
} else {
d("Portero not found for porteroId: $porteroId")
}
}
val addSnapshotListener = firestore.collection("portero").document(porteroId.toString())
.addSnapshotListener(callback)
awaitClose { addSnapshotListener.remove()}
}
}
ButtonsViewModel
package com.sarcobjects.portero.ui.buttons
import androidx.hilt.Assisted
import androidx.hilt.lifecycle.ViewModelInject
import androidx.lifecycle.LiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData
import com.sarcobjects.portero.entities.PorteroWithLevelsAndUnits
import com.sarcobjects.portero.repository.PorteroRepository
import timber.log.Timber.d
class ButtonsViewModel #ViewModelInject
constructor(#Assisted savedStateHandle: SavedStateHandle, porteroRepository: PorteroRepository) : ViewModel() {
private val porteroId: Long = savedStateHandle["porteroId"] ?: 0
val portero: LiveData<PorteroWithLevelsAndUnits> = liveData {
val data = porteroRepository.getPortero(porteroId)
d("Creating LiveData with: $data")
emit(data)
}
}
ButtonsFragment
package com.sarcobjects.portero.ui.buttons
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import com.sarcobjects.portero.R
import com.sarcobjects.portero.entities.PorteroWithLevelsAndUnits
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.android.synthetic.main.buttons_fragment.*
import timber.log.Timber.d
#AndroidEntryPoint
class ButtonsFragment : Fragment() {
companion object {
fun newInstance() = ButtonsFragment()
}
private val viewModel: ButtonsViewModel by viewModels (
)
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.buttons_fragment, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.portero.observe(viewLifecycleOwner, Observer<PorteroWithLevelsAndUnits> {porteroWLAU ->
d("Observing portero: $porteroWLAU")
message.text = porteroWLAU?.portero?.name ?: "Portero not found."
})
}
}
All the dependency injection seems to be OK (no NPEs), I even checked that the ViewModel instance is the same on the Fragment and in the ViewModel itself, and the persistence via Room is correct; the new data is actually being saved into SqlLite when I update Firestore. Also, no exceptions or errors in logcat.
But the UI is not updated.
So, I managed to find a way to make this work, although in a different way. My idea was to make Room trigger a liveData reload whenever I wrote to SqlLite, but I never managed to make it work, and still I don't know why.
What I did in the end was:
Return a Flow from the repository, triggered by the updates in Firestore:
#ExperimentalCoroutinesApi
fun getPorteroFlow(porteroId: Long): Flow<Portero> = retrieveFromFirestore(porteroId)
#ExperimentalCoroutinesApi
private fun retrieveFromFirestore(porteroId: Long): Flow<Portero> = callbackFlow {
val callback = EventListener<DocumentSnapshot> { document, e ->
if (e != null) {
w(e, "Listen from Firestore failed.")
return#EventListener
}
d("Read successfully from Firestore")
if (document != null && document.exists()) {
//Convert to objects
val portero = document.toObject(Portero::class.java)
d("New Portero: ${portero.toString()}")
GlobalScope.launch {
d("Saved new portero: $portero")
porteroDao.insert(portero!!)
}
offer(portero!!)
} else {
d("Portero not found for porteroId: $porteroId")
}
}
val addSnapshotListener = firestore.collection("portero").document(porteroId.toString()) //.get()
.addSnapshotListener(callback)
awaitClose { addSnapshotListener.remove()}
}
Convert the Flow to liveData in the ViewModel:
private val porteroId: Long = savedStateHandle["porteroId"] ?: 0
#ExperimentalCoroutinesApi
val portero = porteroRepository.getPorteroFlow(porteroId)
.onStart { porteroRepository.getPortero(porteroId) }
.asLiveData()
}
(onStart is used to read data from SqlLite when the app starts, in case there's no internet and Firestore is unreachable).
This works flawlessly and is very fast, as soon as I update data in Firestore console, I can see the UI update in the device.
Related
I am creating an app that right now is just supposed to be able to use Firebase authentication with mvvm as a standard to get some kind of seperation of concerns. So i have a database class that uses Firebase and gets an injection from Dagger hilt to be able to use Authentication from firebase. Now i am trying to check if the user is logged in. So i type the auth.getCurrentUser(). This does only give me the possibility to check if a user exist and with firebase when you check this and you have deleted the user while you are testing it does not update the value so it's still logged in for another hour when you have deleted the user. If you check around the internet about this you get the answer to use the authstatelistener. My question is though how to use this together with mvvm? is there a way to do this when i have my clases seperated by a viewmodel a repository and a database.
My classes look like this right now.
Database: //This has a comment written in it that has some use for the problem
import com.google.android.gms.tasks.Task
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.lang.Exception
import javax.inject.Inject
import javax.security.auth.callback.Callback
class FirDatabase #Inject constructor(var auth : FirebaseAuth) {
suspend fun register(user: User) : Task<AuthResult>{
return auth.createUserWithEmailAndPassword(user.email, user.password)
}
suspend fun checkIfUserExist() : Boolean? {
//i would like to be able to check it right here somehow
println("Currentuser " + auth.currentUser?.uid)
return auth.currentUser != null
}
}
Repository:
import androidx.lifecycle.MutableLiveData
import com.google.android.gms.tasks.Task
import com.google.android.gms.tasks.Tasks.await
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class AuthRepository #Inject constructor (private val database: FirDatabase) : IAuthRepository {
override suspend fun register(user: User) : Resource<AuthResult> {
return try{
val response = database.register(user)
val result = response.result
if(response.isSuccessful && result != null){
Resource.Success(result)
}else{
Resource.Error(response.exception?.message.toString())
}
}catch (e: Exception){
Resource.Error(e.message ?: "An Error occurred")
}
}
override suspend fun CheckIfloggedIn() : Resource<Boolean>{
return try{
val user = database.checkIfUserExist()
if(user != false){
Resource.IsLoggedIn("User is already logged in" )
}else{
Resource.IsNotLoggedIn("User is not logged in")
}
}catch(e: Exception){
Resource.Error(e.message ?: "An Error occurred")
}
}
}
ViewModel:
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
#HiltViewModel
class AuthViewModel #Inject constructor(private val repository: AuthRepository, private val dispatchers: DispatcherProvider) : ViewModel(){
sealed class AuthEvent{
class Success(val result: String): AuthEvent()
class Failure(val errorText: String): AuthEvent()
object Loading : AuthEvent()
object LoggedIn : AuthEvent()
object NotRegistered : AuthEvent()
object NotLoggedIn : AuthEvent()
object Empty : AuthEvent()
}
private val _registering = MutableStateFlow<AuthEvent>(AuthEvent.Empty)
val registering : StateFlow<AuthEvent> = _registering
private val _checkUserIsLoggedIn = MutableStateFlow<AuthEvent>(AuthEvent.Empty)
val checkUserIsLoggedIn : StateFlow<AuthEvent> = _checkUserIsLoggedIn
fun register(user: User){
viewModelScope.launch(dispatchers.io) {
_registering.value = AuthEvent.Loading
when(val authResponse = repository.register(user)){
is Resource.Error -> _registering.value = AuthEvent.Failure(authResponse.message!!)
is Resource.Success -> {
val response = authResponse.data!!.user
_registering.value = AuthEvent.Success("Success")
}
}
}
}
fun CheckIfUserIsLoggedIn()
{
viewModelScope.launch(dispatchers.io) {
when(val isUserLoggedIn = repository.CheckIfloggedIn()){
is Resource.IsLoggedIn -> _checkUserIsLoggedIn.value = AuthEvent.LoggedIn
is Resource.IsNotLoggedIn -> _checkUserIsLoggedIn.value = AuthEvent.NotLoggedIn
is Resource.Error -> _checkUserIsLoggedIn.value = AuthEvent.Failure(isUserLoggedIn.message!!)
}
}
}
}
I have followed tutorials from this dude https://www.youtube.com/watch?v=ct5etYgB5pQ
and i have already seen alot of the documentation on this page like this for example Firebase: how to check if user is logged in?
and here How does the firebase AuthStateListener work?. So with further investigations into the answer you gave me i have come up with this solution... but it is not really working? why is this?
Repository function:
override fun CheckIfloggedIn() = callbackFlow<Resource<Boolean>>{
val isUserLoggedIn = flow<Resource<Boolean>>{
database.checkIfUserExist().collect { isLoggedIn ->
if(isLoggedIn){
Resource.Success(isLoggedIn)
}else{
Resource.Error("User is not logged in")
}
}
}
}
Database function:
fun checkIfUserExist() = callbackFlow {
val authStatelistener = FirebaseAuth.AuthStateListener {auth ->
trySend(auth.currentUser == null)
}
auth.addAuthStateListener(authStatelistener)
awaitClose {
auth.removeAuthStateListener(authStatelistener)
}
}
if I any change in firebase like delete, update. The data in recyclerView is duplicated if any of those CRUD occur, so I added temporary swipeRefresh to refresh the activity but this solution doesn't make sense.
This image below explain when I update data in firebase and what happend in RecyclerView
MainDashBoard.kt
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.database.DataSnapshot
import com.google.firebase.database.DatabaseError
import com.google.firebase.database.FirebaseDatabase
import com.google.firebase.database.ValueEventListener
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
class MainDashBoard : AppCompatActivity(), OnItemPatientClickListener{
data class PatientDataItem(val patientName: String, val patientMessage: String)
private lateinit var auth: FirebaseAuth
lateinit var swipeRefreshLayout: SwipeRefreshLayout
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main_dash_board)
var database = FirebaseDatabase.getInstance().reference
var patientDataItems = ArrayList<PatientDataItem>()
val patientRecycler = findViewById<RecyclerView>(R.id.patient_recycler)
val patienDashboardprogressBar = findViewById<ProgressBar>(R.id.patientDashboardprogressBar)
val noDataMain = findViewById<TextView>(R.id.no_data_main_dashboard)
swipeRefreshLayout = findViewById(R.id.swipe)
patientRecycler.layoutManager = LinearLayoutManager(this)
patientRecycler.adapter = MainDashboardAdapter(patientDataItems, this)
auth = FirebaseAuth.getInstance()
val user = auth.currentUser
val patientsListener = object : ValueEventListener {
override fun onDataChange(p0: DataSnapshot) {
val patients = p0.child("users").child(user!!.uid)
if (p0.value == null ){
noDataMain.visibility = View.VISIBLE
}else{
noDataMain.visibility = View.GONE
for (i in p0.children){
var patientName = i.key.toString()
var patientMessage = i.value.toString()
patientDataItems.add(PatientDataItem(patientName, patientMessage))
}
}
patientRecycler.scrollToPosition(patientDataItems.size-1)
patienDashboardprogressBar.visibility = View.GONE
}
override fun onCancelled(error: DatabaseError) {
println("error")
}
}
database.child("location").child("users").child(user!!.uid).addValueEventListener(patientsListener)
// database.child("location").addValueEventListener(postListener)
swipeRefreshLayout.setOnRefreshListener {
startActivity(intent);
Handler(Looper.getMainLooper()).postDelayed(Runnable {
swipeRefreshLayout.isRefreshing = false
}, 4000)
}
}
override fun onItemClick(patientDataItems: PatientDataItem) {
val patientMacAddressName = patientDataItems.patientName
val dashboardIntent = Intent(this, DashboardActivity::class.java)
dashboardIntent.putExtra("macAddressNamePatient", patientMacAddressName)
startActivity(dashboardIntent)
}
}
MainDashBoardAdapter.kt
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.RecyclerView
import com.example.ard_here.R
class MainDashboardAdapter(private val patientDataSet: ArrayList<MainDashBoard.PatientDataItem>,
private val onPatientClickListener: OnItemPatientClickListener): RecyclerView.Adapter<MainDashboardAdapter.PatientCustomHolder>(){
override fun getItemCount(): Int {
return patientDataSet.size
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PatientCustomHolder {
var layoutInflater = LayoutInflater.from(parent?.context)
var cellForRow = layoutInflater.inflate(R.layout.main_patient_layout, parent, false)
return PatientCustomHolder(cellForRow)
}
override fun onBindViewHolder(holder: PatientCustomHolder, position: Int) {
holder.bindItems(patientDataSet[position])
holder.patientLayout.setOnClickListener {
onPatientClickListener.onItemClick(patientDataSet[position])
}
}
class PatientCustomHolder(v: View): RecyclerView.ViewHolder(v){
val patientLayout: ConstraintLayout = v.findViewById(R.id.patient_layout)
val patientName: TextView = v.findViewById(R.id.patient_name)
val patientMessage : TextView = v.findViewById(R.id.patient_message)
fun bindItems(data_item: MainDashBoard.PatientDataItem){
patientName.text = data_item.patientName
patientMessage.text = data_item.patientMessage
}
}
}
OnItemPatientClickListener.kt
interface OnItemPatientClickListener {
fun onItemClick(patientDataItems: MainDashBoard.PatientDataItem)
}
clear your data container then bind it again in recyclerview.
or you have mvvm pattern, you can use live data to observe data source and if there is any changes, your activity will easily notified and make some ui changes
Since you're reading the data with addValueEventListener, which means that:
The data from the path is read from the database right away, and passed to your onDataChange.
The client the continues monitor the path, and if anything changes it calls your onDataChange again with all data at the path.
In your onDataChange you're only ever adding data to patientDataItems. That works well the first time the data is loaded, so #1 above. But if you add or change a single child node (#2 above), you get called with all data at the path again. So that's when you end up duplicating the items in the view.
The simplest solution is to clear patientDataItems whenever onDataChange get called:
override fun onDataChange(p0: DataSnapshot) {
patientDataItems.clear()
...
in my Android project i need to use one of my viewmodel's response in another fragment. But whenever i try to get that value it is always null. I tried to get it from its own fragment with livedata and it works!. But it is not same for another fragment. Here my viewmodel code that has the response ;
package com.tolgahantutar.bexworkfloww.ui.auth
import android.content.Intent
import android.view.View
import android.widget.Toast
import androidx.hilt.lifecycle.ViewModelInject
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tolgahantutar.bexworkfloww.data.network.repositories.AuthorizeSessionRepository
import com.tolgahantutar.bexworkfloww.data.network.repositories.GetDomainRepository
import com.tolgahantutar.bexworkfloww.data.network.repositories.GetUserRepository
import com.tolgahantutar.bexworkfloww.data.network.responses.GetUserResponse
import com.tolgahantutar.bexworkfloww.ui.home.HomeActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class AuthViewModel #ViewModelInject constructor (
private val authorizeSessionRepository: AuthorizeSessionRepository,
private val getDomainRepository: GetDomainRepository,
private val getUserRepository: GetUserRepository
):ViewModel() {
var userName :String?=null
var password: String ? = null
val isLoading = MutableLiveData<Boolean>()
private val location = "bexfatest.saasteknoloji.com"
val isSuccessfull = MutableLiveData<Boolean>()
var getUserResponseMutable = MutableLiveData<GetUserResponse>()
fun onClickUserLogin(view: View){
val sessionID = 0
val authorityID = 0
val loginType = "System"
viewModelScope.launch {
if(!(userName==null||password==null)){
isLoading.value = true
val authResponse = userLogin(sessionID,authorityID,userName!!,password!!,loginType)
if(authResponse.Result){
isLoading.value=false
val domainResponse=getDomain(location)
**`val getUserResponse`** = getUser(authResponse.authorizeSessionModel!!.ID,"Bearer "+domainResponse.getDomainModel.ApiKey)
if (getUserResponse.result){
isSuccessfull.value=true
getUserResponseMutable.value=getUserResponse
}
//Toast.makeText(view.context, "Login Successfull", Toast.LENGTH_LONG).show()
val intent = Intent(view.context,HomeActivity::class.java)
view.context.startActivity(intent)
}else{
isLoading.value=false
Toast.makeText(view.context, "Login Failed!!", Toast.LENGTH_LONG).show()
}
}
else{
Toast.makeText(view.context, "Kullanıcı adı ve şifre boş bırakılamaz!!", Toast.LENGTH_SHORT).show()
}
}
}
suspend fun userLogin(
SessionID : Int,
AuthorityID: Int,
UserName: String,
Password : String,
LoginType: String
)= withContext(Dispatchers.IO){authorizeSessionRepository.userLogin(SessionID, AuthorityID, UserName, Password, LoginType)}
suspend fun getUser(
id: Int,
authorization : String
)= withContext(Dispatchers.Main){getUserRepository.getUser(id,authorization)}
suspend fun getDomain(
Location: String
)= withContext(Dispatchers.IO){getDomainRepository.getDomain(Location)}
}
i need to get getUserResponse variable in my AddressBook Fragment like this ;
package com.tolgahantutar.bexworkfloww.ui.addressbook
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import com.tolgahantutar.bexworkfloww.R
import com.tolgahantutar.bexworkfloww.ui.auth.AuthViewModel
import dagger.hilt.android.AndroidEntryPoint
#AndroidEntryPoint
class AdressBookFragment : Fragment() {
private val addressBookViewModel : AdressBookViewModel by viewModels()
private val authViewModel : AuthViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.adress_book_fragment, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
//addressBookViewModel.getContact(2,authViewModel.userResponseDelegate!!.getUserValue.apiKey)
authViewModel.getUserResponseMutable.observe(viewLifecycleOwner, Observer {
if (it.result){
Toast.makeText(requireContext(), "asdadasd", Toast.LENGTH_SHORT).show()
}
})
addressBookViewModel.isSuccessfull.observe(viewLifecycleOwner, Observer {
if (it){
Toast.makeText(requireContext(), "ContactList Get Successfully", Toast.LENGTH_SHORT).show()
}
})
}
}
but observe is always null how can i get that getUserResponse inside my AddressBookFragment ??
The AuthViewModel is most likely destroyed when you leave your AuthFragment (which I assume you have), so your AdressBookFragment is getting a new instance of the ViewModel, which doesn't retain any data from the previous screen.
I'd recommend that you store the result from AuthViewModel into a Repository or some other global state object and retrieve it from there.
ViewModels hold temporary data needed for a screen, but whether the user is authenticated matters to the whole application, not just a single screen. As such it should be stored someplace that lives for as long as the whole app, and is reachable from anywhere, like a Repository.
private val authViewModel : AuthViewModel by viewModels()
is equivalent to
private val viewModel by lazy {
ViewModelProvider(this).get(AuthViewModel::class.java)
}
So as you can see this is passed as viewModelStoreOwner.
Since i guess that you do net use AdressBookFragment as viewModelStoreOwner in your other fragment you are creating a new viewmodel in this fragment.
You probably need a shared viewmodel which you get when you use
private val viewModel by lazy {
ViewModelProvider(requireActivity()).get(AuthViewModel::class.java)
}
in both fragments
First of all I have to say that I am rather new to Kotlin, and after spending 5 days (35+ hours) trying to google this issue and trying countless different options (similar questions on stack overflow, documentation and tutorials found on Google, other Kotlin projects on GitHub, even using my own server and database wondering if the issue has something to do with ROOM) I have to give up and ask for help, as this app is for an assignment I am supposed to finish in a couple of weeks.
Description of the app (Expense tracker):
When you open the app, you see the the HomeFragment, which shows recently added expenses.
There is a Add Expense fragment/tab where you can add expenses: Write down the expense name and amount, choose category from the spinner and the date from the date picker (default: today).
There is a Totals fragment/tab where you can see statistics/data of your expenses. I have a Category spinner and Time options spinner (Today, This month, This year, All time), and when the user clicks the button, I am building a query from the selected options and want to display data according to the user's preferences on my RecyclerView below.
Above the RecyclerView you can see your expenses, income and total (total = income - expenses, using SELECT SUM query to get income and expenses once I have figured this part out), and the RV should just be a list of the result of the query, and the user could delete single expenses by swiping to the left (very basic stuff, already works on the HomeFragment where the RV shows just fine with the static query).
I feel like I have tried literally everything - Especially Transformations.switchMap as many results seem to point that way, but I haven't made any progress whatsoever. I have browsed through dozens of apps on GitHub to see how theirs work, trying to implement the logic in mine, but even if after all the time I manage to adjust the code so that I get no errors, nothing is still shown on my RecyclerView.
Here are the snippets from the classes that I believe are relevant to this issue (in the order from most relevant to somewhat relevant, some parts of the code omitted to not flood this post completely):
TotalsFragment:
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.expensetracker.R
import com.example.expensetracker.model.Category
import com.example.expensetracker.model.Expense
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.fragment_totals.*
import java.util.*
import kotlin.collections.ArrayList
class TotalsFragment : Fragment() {
private val totals: MutableList<Expense> = ArrayList()
private val totalAdapter = ExpenseAdapterTotals(totals)
private lateinit var viewModel: TotalsViewModel
//
// Bunch of variables omitted
//
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Initialize the ViewModel
viewModel = ViewModelProviders.of(activity as AppCompatActivity).get(TotalsViewModel::class.java)
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_totals, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
updateUI()
initViewModel()
initViews()
initCategorySpinner()
initTimeSpinner()
// For getting data and updating the UI after the button is clicked
btn_show.setOnClickListener {
updateRvData()
updateTotals()
updateUI()
}
}
private fun initViewModel(){
viewModel = ViewModelProviders.of(this).get(TotalsViewModel::class.java)
viewModel.totals.observe(this, Observer {
if (totals.isNotEmpty()) {
totals.clear()
}
totals.addAll(it!!)
totalAdapter.notifyDataSetChanged()
})
}
private fun initViews(){
createItemTouchHelper().attachToRecyclerView(rv_expenses_totals)
rv_expenses_totals.apply {
layoutManager = LinearLayoutManager(activity)
rv_expenses_totals.adapter = totalAdapter
rv_expenses_totals.addItemDecoration(DividerItemDecoration(this.context, DividerItemDecoration.VERTICAL))
}
}
// Code omitted
The part sending the query forward:
viewModel.getTotals(queryString)
TotalsViewModel:
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import com.example.expensetracker.database.ExpenseRepository
import com.example.expensetracker.model.Expense
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class TotalsViewModel(application: Application) : AndroidViewModel(application) {
private val ioScope = CoroutineScope(Dispatchers.IO)
private val expenseRepository = ExpenseRepository(application.applicationContext)
var query = MutableLiveData<String>()
val totals: LiveData<List<Expense>> = Transformations.switchMap(query, ::temp)
private fun temp(query: String) = expenseRepository.getTotals(query)
fun getTotals(queryString: String) = apply { query.value = queryString }
fun insertExpense(expense: Expense) {
ioScope.launch {
expenseRepository.insertExpense(expense)
}
}
fun deleteExpense(expense: Expense) {
ioScope.launch {
expenseRepository.deleteExpense(expense)
}
}
}
ExpenseDao:
#Dao
interface ExpenseDao {
// sort by order they were added, newest on top
#Query("SELECT * FROM expense_table ORDER BY id DESC LIMIT 15")
fun getExpensesMain(): LiveData<List<Expense>>
// get data for totals
#Query("SELECT * FROM expense_table WHERE :queryString")
fun getTotals(queryString: String): LiveData<List<Expense>>
// Rest of the queries omitted
ExpenseRepository:
class ExpenseRepository(context: Context) {
private var expenseDao: ExpenseDao
init {
val expenseRoomDatabase = ExpenseRoomDatabase.getDatabase(context)
expenseDao = expenseRoomDatabase!!.expenseDao()
}
fun getExpensesMain(): LiveData<List<Expense>> {
return expenseDao.getExpensesMain()
}
fun getTotals(queryString: String): LiveData<List<Expense>> {
return expenseDao.getTotals(queryString)
}
// Code omitted
ExpenseRoomDatabase:
#Database(entities = [Expense::class], version = 1, exportSchema = false)
abstract class ExpenseRoomDatabase : RoomDatabase() {
abstract fun expenseDao(): ExpenseDao
companion object {
private const val DATABASE_NAME = "EXPENSE_DATABASE"
#Volatile
private var expenseRoomDatabaseInstance: ExpenseRoomDatabase? = null
fun getDatabase(context: Context): ExpenseRoomDatabase? {
if (expenseRoomDatabaseInstance == null) {
synchronized(ExpenseRoomDatabase::class.java) {
if (expenseRoomDatabaseInstance == null) {
expenseRoomDatabaseInstance = Room.databaseBuilder(
context.applicationContext,
ExpenseRoomDatabase::class.java, DATABASE_NAME
).build()
}
}
}
return expenseRoomDatabaseInstance
}
}
}
ExpenseAdapterTotals:
class ExpenseAdapterTotals(private val totals: MutableList<Expense>) : RecyclerView.Adapter<ExpenseAdapterTotals.ViewHolder>() {
lateinit var context: Context
override fun getItemCount(): Int {
return totals.size
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
context = parent.context
return ViewHolder(LayoutInflater.from(context).inflate(R.layout.item_expense_totals, parent, false))
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(totals[position])
}
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(totals: Expense) {
itemView.tv_expense_totals.text = totals.expense
itemView.tv_category_totals.text = totals.category
itemView.tv_date_totals.text = totals.date
itemView.tv_total_totals.text = totals.total.toString()
}
}
}
I have the following dependencies in my app build.gradle:
//Navigation
implementation "androidx.navigation:navigation-fragment-ktx:2.0.0"
implementation "androidx.navigation:navigation-ui-ktx:2.0.0"
// ViewModel and LiveData
def lifecycle_version = "2.1.0"
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
// Room.
def room_version = "2.1.0-rc01"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
....
So, this code is my most recent attempt but it has changed several times. I am not getting any error messages, but nothing is being shown either.
My goal in a nutshell: When I click the button (btn_show), it should create the query string (which it does) and the RecyclerView in that fragment should update to show the desired results (which it doesn't). I assume the problem is somewhere between the ViewModel and the Fragment, but like I said, I am still a beginner, and this is the first time I am actually working on my purely own app.
Thank you so much in advance for any help and tips, and feel free to ask if I left out anything you'd like to know.
Just a few things I noticed:
In you totals fragment why are you initializing viewmodel two times in onCreate and the in onViewCreated?
Also you're not submitting your totals values into your adapter.
totals.addAll(it!!) this just adds them to the list that you have declared in your totalFragment (you don't need it at all,because you're getting all your totals from viewmodel first of all.)
Replace your ExpenseAdapterTotals: RecyclerView.Adapter< with ExpenseAdapterTotals: ListAdapter<.
Then, remove anything that shows a MutableList, or at least rename it to List.
Now you see that you don't need clear() and addAll(). You can just call submitList() on the ListAdapter and it works.
var query = MutableLiveData<String>()
Make this into a val so that you cannot mess it up by accident.
viewModel.totals.observe(this, Observer {
Should be viewLifecycleOwner if you set up this observer in onViewCreated.
But as the RecyclerView is not showing, I actually think it's probably a question of incorrect layout parameters, for example wrap_content height for the RecyclerView, and now it doesn't update its height because of setHasFixedSize(true).
For some reason, onClick isn't being registered with my adapter. I'm using the MVVM pattern and I've made sure that all the pieces are tied together but for the life of me I can't figure out why this won't work.
StoreFragment
package com.example.brandroidtest.main
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.navigation.fragment.findNavController
import com.example.brandroidtest.databinding.FragmentStoreBinding
class StoreFragment : Fragment() {
//Will Create a ViewModelProivders object of class DetailViewModel the first time viewModel is used
//Allows us to move this code from on create to the declaration
private val viewModel: StoreViewModel by lazy {
val factory = StoreViewModelFactory(requireNotNull(activity).application)
ViewModelProviders.of(this, factory).get(StoreViewModel::class.java)
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
Log.i("onCreateView", "StoreFragment created")
val binding = FragmentStoreBinding.inflate(inflater)
binding.setLifecycleOwner(this)
binding.viewModel = viewModel
binding.storeList.adapter = StoreAdapter(StoreAdapter.OnClickListener {
viewModel.displayStoreDetails(it)
Log.i("inside OnClickListener", "after displayDetails")
})
Log.i("between adapter.onclick", "and viewModel observe")
viewModel.selectedStore.observe(this, Observer {
Log.i("observe", "inside the selectedStore observe method")
if (null != it) {
this.findNavController().navigate(
StoreFragmentDirections.actionMainListFragmentToDetailFragment(
it
)
)
viewModel.displayStoreDetailsComplete()
}
})
return binding.root
}
}
StoreViewModel
package com.example.brandroidtest.main
import android.app.Application
import android.content.Context
import android.net.ConnectivityManager
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.example.brandroidtest.model.Store
import com.example.brandroidtest.network.StoreAPI
import kotlinx.coroutines.*
enum class StoreAPIStatus {LOADING, DONE, NO_CONNECTION}
class StoreViewModel(application: Application) : AndroidViewModel(application) {
// Response from server: Either Store Data or Failure Message
private val _status = MutableLiveData<StoreAPIStatus>()
// for status of get request
//displayed when there is no internet connection or if the connection is unstable and the data is being loaded
val status: LiveData<StoreAPIStatus>
get() = _status
//internal variable accessed within this file
private val listOfStores = MutableLiveData<List<Store>>()
//external variable for anywhere else
val stores: LiveData<List<Store>>
get() = listOfStores
private val _selectedStore = MutableLiveData<Store>()
val selectedStore: LiveData<Store>
get() = _selectedStore
private var viewModelJob = Job()
private val coroutineScope = CoroutineScope(viewModelJob + Dispatchers.Main)
/**
* Call getStoreData() in init so we can display the result immediately.
*/
init {
Log.i("viewModel init", "inside StoreViewModel init block")
if (isNetworkConnected(application.applicationContext))
getStoreData()
else
// Log.i("Bypassed network call", "")
listOfStores.value = emptyList()
_status.value = StoreAPIStatus.NO_CONNECTION
}
/**
* Sets the value of the status LiveData to the Store API data.
*/
private fun getStoreData() {
Log.i("getStoreData()", " inside getStoreData")
coroutineScope.launch {
try {
Log.i("getStoreData()", "Inside the coroutine before getData")
_status.value = StoreAPIStatus.LOADING
var storeData = async { StoreAPI.retrofitService.getData().stores }.await()
Log.i("getStoreData()", "Inside the coroutine after getData")
_status.value = StoreAPIStatus.DONE
listOfStores.value = storeData
} catch (e: Exception) {
_status.value = StoreAPIStatus.NO_CONNECTION
listOfStores.value = ArrayList()
e.printStackTrace()
}
}
}
override fun onCleared() {
super.onCleared()
viewModelJob.cancel()
}
private fun isNetworkConnected(context: Context): Boolean {
val cm =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager?
return cm!!.activeNetworkInfo != null && cm.activeNetworkInfo.isConnected
}
//will be called to set the store as the one that was clicked
fun displayStoreDetails(store : Store){
Log.i("displayStoreDetails", "inside this method")
_selectedStore.value = store
}
//sets the selected store's value to null so that live data can be updated when we select a new store and not show us the detail apge of the same store
fun displayStoreDetailsComplete() {
Log.i("displayStoreDetailsComplete", "inside this method")
_selectedStore.value = null
}
}
StoreAdapter
package com.example.brandroidtest.main
import android.util.Log
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.brandroidtest.model.Store
import com.example.brandroidtest.databinding.ListItemBinding
class StoreAdapter(val onClickListener: OnClickListener) :
ListAdapter<Store, StoreAdapter.StoreViewHolder>(DiffCallback) {
class StoreViewHolder(private var binding: ListItemBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(store: Store) {
binding.store = store
Log.i("Adapter bind", store.storeLogoURL)
binding.executePendingBindings()
}
}
companion object DiffCallback : DiffUtil.ItemCallback<Store>() {
override fun areItemsTheSame(oldItem: Store, newItem: Store): Boolean {
return oldItem === newItem
}
override fun areContentsTheSame(oldItem: Store, newItem: Store): Boolean {
return oldItem.storeID == newItem.storeID
}
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): StoreViewHolder {
return StoreViewHolder(ListItemBinding.inflate(LayoutInflater.from(parent.context)))
}
override fun onBindViewHolder(holder: StoreViewHolder, position: Int) {
val store = getItem(position)
Log.i("inside onBindViewHolder", "")
holder.itemView.setOnClickListener {
Log.i("inside onBindViewHolder", "setOnClickListener")
onClickListener.onClick(store)
}
holder.bind(store)
}
class OnClickListener(val clickListener: (store: Store) -> Unit) {
fun onClick(store: Store) {
Log.i("inside onClick", "click is being registered ${store.city}")
return clickListener(store)
}
}
}
StoreDetailFragment
package com.example.brandroidtest.detailed
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProviders
import com.example.brandroidtest.R
import com.example.brandroidtest.databinding.FragmentStoreDetailBinding
/**
* A simple [Fragment] subclass.
*/
class StoreDetailFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val application = requireNotNull(activity).application
val binding = FragmentStoreDetailBinding.inflate(inflater)
binding.setLifecycleOwner(this)
val store = StoreDetailFragmentArgs.fromBundle(arguments!!).selectedStore
val viewModelFactory = StoreDetailViewModelFactory(store, application)
binding.viewModel = ViewModelProviders.of(this, viewModelFactory).get(StoreDetailViewModel::class.java)
return binding.root
}
}
StoreDetailViewModel
package com.example.brandroidtest.detailed
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.example.brandroidtest.model.Store
class StoreDetailViewModel(store: Store, application: Application) : AndroidViewModel(application) {
private val _selectedStore = MutableLiveData<Store>()
val selectedStore : LiveData<Store>
get() = _selectedStore
init {
_selectedStore.value = store
}
}
I have no idea why onClick won't work and the Detail Fragment won't show because of it
Here is the project link: https://drive.google.com/open?id=1m8R8HvCt4m0KIp_IwdeO1YdB5yY8A8LK
The issue come from your adapter item layout.
The height of every item show be wrap_content. But you are using a ScrollView as the root view of your item view.
Remove the useless ScrollView and also the next LinearLayout. You layout should become like this:
<LinearLayout
...
android:padding="16dp"/>
<ImageView
android:id="#+id/store_logo"
.../>
<LinearLayout
android:id="#+id/store_detail"
...>
</LinearLayout>